feat(phase2): external config + versioned prompt pack (P1a)

config.py centralizes phase-2 path/env reads (no hard-coded data paths,
section 15). prompt.py loads the EXTERNAL, versioned prompt pack (section
14): pack version + per-prompt content hash feed prompt_version for the
response-cache composite key (D6); missing/invalid packs raise a clear
PromptPackError, never a silent fallback. Minimal fixture pack under
tests/fixtures/prompt_pack/ pins CI to a local, versioned pack.
This commit is contained in:
2026-09-22 09:21:28 +02:00
parent 26c5b51b98
commit f0617a0de6
5 changed files with 343 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
"""Environment-driven configuration for the phase-2 LLM layer (P1).
Architecture doc section 15: the phase-2 code never hard-codes data or
configuration paths -- every externally supplied value is resolved here from
the environment, so the repository can keep shipping pure code once the data
sets and the prompt pack detach. The prompt-pack contract is documented in
section 14 (see :mod:`pf1e_simulator.prompt`).
"""
from __future__ import annotations
import os
from pathlib import Path
# Environment variable names (documented in the architecture doc, sections
# 14-15). ``PF1E_PROMPT_PACK_DIR`` points at the external, versioned prompt
# pack; ``PF1E_LLM_API_KEY`` feeds the remote provider client (P5.1).
ENV_PROMPT_PACK_DIR = "PF1E_PROMPT_PACK_DIR"
ENV_LLM_API_KEY = "PF1E_LLM_API_KEY"
def env_str(name: str, *, default: str | None = None) -> str | None:
"""Read a configuration string from the environment, if set."""
return os.environ.get(name, default)
def env_path(name: str) -> Path | None:
"""Read a configuration path from the environment, if set."""
raw = os.environ.get(name)
return Path(raw) if raw else None
def llm_api_key() -> str | None:
"""The provider API key, or None when the caller runs offline."""
return env_str(ENV_LLM_API_KEY)
+130
View File
@@ -0,0 +1,130 @@
"""Externally-loaded, versioned prompt pack for the phase-2 LLM layer (P1).
Prompts do NOT live in this repository (architecture doc section 14 -- the
repo is code only). They live in a separate, versioned pack directory,
resolved through configuration (env var ``PF1E_PROMPT_PACK_DIR``, never
hard-coded -- section 15). This module loads a pack once per resolved path,
exposes each prompt by name and computes the ``prompt_version`` that must
enter the response-cache composite key (decision D6), the ``ReplayRecord``
and the sweep reports, so a campaign stays reproducible after a prompt edit
(traceability rule, section 14).
Pack layout
-----------
<pack_dir>/
version.txt # single line: semver or commit hash (mandatory)
<name>.md # one file per prompt name, e.g. ``nl_default.md``
Loading is eager and strict. A missing pack, a missing ``version.txt`` or a
missing ``<name>.md`` raises a typed ``PromptPackError`` -- there is no
silent fallback (validation rule, section 14). Unit tests use the minimal
local fixture under ``tests/fixtures/prompt_pack/`` so CI never depends on
the external prompt repository (pinning rule, section 14).
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from functools import cache
from typing import TYPE_CHECKING
from pf1e_simulator.config import ENV_PROMPT_PACK_DIR, env_path
if TYPE_CHECKING:
from pathlib import Path
_PROMPT_SUFFIX = ".md"
_VERSION_FILE = "version.txt"
_HASH_PREFIX_LEN = 12
class PromptPackError(RuntimeError):
"""A prompt pack is missing, invalid, or lacks a required prompt."""
def _pack_error(kind: str, message: str) -> None:
msg = f"{kind}: {message}"
raise PromptPackError(msg)
@dataclass(frozen=True)
class PromptPack:
"""A versioned set of prompts loaded from one external directory.
Instances are immutable (frozen), so a loaded pack can be shared by
parallel sweep workers without drift. ``_prompts`` maps prompt names
(file stems) to their full text, read at load time.
"""
root: Path
version: str
_prompts: dict[str, str] = field(repr=False)
def get(self, name: str) -> str:
"""Return the prompt ``name``'s text, or raise ``PromptPackError``."""
try:
return self._prompts[name]
except KeyError as exc:
msg = (
f"prompt '{name}' missing from pack at {self.root} "
f"(expected file '{name}{_PROMPT_SUFFIX}')"
)
raise PromptPackError(msg) from exc
def prompt_version(self, name: str) -> str:
"""Pack version plus per-prompt content hash (cache-key component, D6)."""
content = self.get(name)
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:_HASH_PREFIX_LEN]
return f"{self.version}:{name}:{digest}"
@classmethod
def load(cls, root: Path) -> PromptPack:
"""Load and validate the pack rooted at ``root`` (strict, no fallback)."""
if not root.is_dir():
_pack_error(
"pack directory not found",
f"{root} does not exist or is not a directory",
)
version_file = root / _VERSION_FILE
if not version_file.is_file():
_pack_error("invalid pack", f"{root} has no {_VERSION_FILE} file")
version = version_file.read_text(encoding="utf-8").strip()
if not version:
_pack_error("invalid pack", f"{root / _VERSION_FILE} is empty")
prompts: dict[str, str] = {}
for path in sorted(root.glob(f"*{_PROMPT_SUFFIX}")):
prompts[path.stem] = path.read_text(encoding="utf-8")
return cls(root=root, version=version, _prompts=prompts)
@cache
def _load_cached(root: Path) -> PromptPack:
"""Load once per resolved path (thread-safe; parallel sweeps share it)."""
return PromptPack.load(root)
def load_prompt_pack(pack_dir: Path | None = None) -> PromptPack:
"""Load the prompt pack from ``pack_dir`` or the configured directory.
With ``pack_dir`` given, load that directory directly (tests use this
with their local fixture). Otherwise resolve
``PF1E_PROMPT_PACK_DIR``; when unset, raise a clear error instead of
falling back to a bundled (non-existent) pack.
"""
if pack_dir is not None:
return _load_cached(pack_dir)
configured = env_path(ENV_PROMPT_PACK_DIR)
if configured is None:
_pack_error(
"prompt pack not configured",
f"set {ENV_PROMPT_PACK_DIR} to the external prompt pack directory "
"(architecture doc section 14)",
)
return _load_cached(configured)
def clear_pack_cache() -> None:
"""Drop the per-path pack cache (used by tests that relocate fixtures)."""
_load_cached.cache_clear()