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:
@@ -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)
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
You are the tactical director for a Pathfinder 1e battle.
|
||||||
|
|
||||||
|
You see the current battle state as a compact observation view, followed by
|
||||||
|
the list of plausible actions the acting combatant could take.
|
||||||
|
Respond with the JSON action sequence described in the schema.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
0.1.0-test
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Phase-2 config and external prompt pack (docs/phase-2 P1a, sections 14-15).
|
||||||
|
|
||||||
|
``config.py`` centralizes every phase-2 path/env read (no hard-coded data
|
||||||
|
paths - section 15). ``prompt.py`` loads the EXTERNAL, versioned prompt pack
|
||||||
|
(section 14): prompts never live in this repo; the pack carries its own
|
||||||
|
version and each prompt has an individual content hash, so ``prompt_version``
|
||||||
|
can enter the response-cache composite key (D6). The minimal pack under
|
||||||
|
``tests/fixtures/prompt_pack/`` is the local fixture so CI never depends on
|
||||||
|
the external prompt repository, and missing/invalid packs raise a clear
|
||||||
|
``PromptPackError`` instead of falling back silently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pf1e_simulator.config import (
|
||||||
|
ENV_LLM_API_KEY,
|
||||||
|
ENV_PROMPT_PACK_DIR,
|
||||||
|
env_path,
|
||||||
|
env_str,
|
||||||
|
llm_api_key,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.prompt import (
|
||||||
|
PromptPack,
|
||||||
|
PromptPackError,
|
||||||
|
clear_pack_cache,
|
||||||
|
load_prompt_pack,
|
||||||
|
)
|
||||||
|
|
||||||
|
FIXTURE_PACK = Path(__file__).resolve().parent / "fixtures" / "prompt_pack"
|
||||||
|
_FIXTURE_VERSION = "0.1.0-test"
|
||||||
|
_FIXTURE_PROMPT_NAME = "nl_default"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_pack(root: Path, *, version: str, prompts: dict[str, str]) -> None:
|
||||||
|
"""Write a minimal prompt pack directory (version.txt + <name>.md)."""
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / "version.txt").write_text(version, encoding="utf-8")
|
||||||
|
for name, text in prompts.items():
|
||||||
|
(root / f"{name}.md").write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ── config.py ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_str_and_path_round_trip(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""env_str/env_path read the environment and respect deletes."""
|
||||||
|
monkeypatch.setenv("PF1E_SIM_TEST_STR", "abc")
|
||||||
|
monkeypatch.setenv("PF1E_SIM_TEST_PATH", str(tmp_path / "x"))
|
||||||
|
assert env_str("PF1E_SIM_TEST_STR") == "abc"
|
||||||
|
assert env_str("PF1E_SIM_TEST_UNSET") is None
|
||||||
|
assert env_str("PF1E_SIM_TEST_UNSET", default="d") == "d"
|
||||||
|
assert env_path("PF1E_SIM_TEST_PATH") == tmp_path / "x"
|
||||||
|
assert env_path("PF1E_SIM_TEST_UNSET") is None
|
||||||
|
monkeypatch.delenv("PF1E_SIM_TEST_STR")
|
||||||
|
assert env_str("PF1E_SIM_TEST_STR") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_api_key_reads_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""llm_api_key surfaces the dedicated env var (None when unset)."""
|
||||||
|
assert llm_api_key() is None
|
||||||
|
monkeypatch.setenv(ENV_LLM_API_KEY, "sk-test")
|
||||||
|
assert llm_api_key() == "sk-test"
|
||||||
|
|
||||||
|
|
||||||
|
# ── prompt.py — loading and validation ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_pack() -> None:
|
||||||
|
"""The local fixture loads: version pinned, prompts readable by name."""
|
||||||
|
pack = load_prompt_pack(FIXTURE_PACK)
|
||||||
|
assert pack.version == _FIXTURE_VERSION
|
||||||
|
assert pack.root == FIXTURE_PACK
|
||||||
|
text = pack.get(_FIXTURE_PROMPT_NAME)
|
||||||
|
assert "tactical director" in text
|
||||||
|
# The pack has exactly the fixture prompts (only <name>.md files count).
|
||||||
|
assert pack.get("nl_default") == (FIXTURE_PACK / "nl_default.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_version_stable_and_content_sensitive(tmp_path: Path) -> None:
|
||||||
|
"""prompt_version carries pack version + per-prompt content hash (D6)."""
|
||||||
|
pack = load_prompt_pack(FIXTURE_PACK)
|
||||||
|
a = pack.prompt_version(_FIXTURE_PROMPT_NAME)
|
||||||
|
b = pack.prompt_version(_FIXTURE_PROMPT_NAME)
|
||||||
|
assert a == b # deterministic within a loaded pack
|
||||||
|
assert a.startswith(f"{_FIXTURE_VERSION}:{_FIXTURE_PROMPT_NAME}:")
|
||||||
|
|
||||||
|
other = tmp_path / "other"
|
||||||
|
_write_pack(other, version=_FIXTURE_VERSION, prompts={_FIXTURE_PROMPT_NAME: "x"})
|
||||||
|
assert load_prompt_pack(other).prompt_version(_FIXTURE_PROMPT_NAME) != a
|
||||||
|
|
||||||
|
bumped = tmp_path / "bumped"
|
||||||
|
_write_pack(bumped, version="1.0.0", prompts={_FIXTURE_PROMPT_NAME: "x"})
|
||||||
|
assert load_prompt_pack(bumped).prompt_version(_FIXTURE_PROMPT_NAME).startswith(
|
||||||
|
"1.0.0:"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_prompt_raises_clear_error(tmp_path: Path) -> None:
|
||||||
|
"""A missing prompt name fails loudly, naming the expected file."""
|
||||||
|
other = tmp_path / "other"
|
||||||
|
_write_pack(other, version="1.0.0", prompts={"other": "x"})
|
||||||
|
with pytest.raises(PromptPackError, match=r"missing.*nl_default\.md"):
|
||||||
|
load_prompt_pack(other).get("nl_default")
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_pack_dir_raises(tmp_path: Path) -> None:
|
||||||
|
"""A nonexistent pack path is a clear error, not a silent fallback."""
|
||||||
|
with pytest.raises(PromptPackError, match="not found"):
|
||||||
|
load_prompt_pack(tmp_path / "nope")
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_version_file_raises(tmp_path: Path) -> None:
|
||||||
|
"""A pack without version.txt is invalid (traceability, section 14)."""
|
||||||
|
root = tmp_path / "noversion"
|
||||||
|
_write_pack(root, version="", prompts={})
|
||||||
|
(root / "version.txt").unlink()
|
||||||
|
with pytest.raises(PromptPackError, match="version\\.txt"):
|
||||||
|
load_prompt_pack(root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_version_file_raises(tmp_path: Path) -> None:
|
||||||
|
"""An empty version.txt is invalid (no untraceable pack)."""
|
||||||
|
root = tmp_path / "emptyversion"
|
||||||
|
_write_pack(root, version="", prompts={_FIXTURE_PROMPT_NAME: "x"})
|
||||||
|
with pytest.raises(PromptPackError, match="empty"):
|
||||||
|
load_prompt_pack(root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unset_pack_dir_raises(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Without PF1E_PROMPT_PACK_DIR, loading raises instead of falling back."""
|
||||||
|
monkeypatch.delenv(ENV_PROMPT_PACK_DIR, raising=False)
|
||||||
|
with pytest.raises(PromptPackError, match=ENV_PROMPT_PACK_DIR):
|
||||||
|
load_prompt_pack()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack_dir_from_env(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""load_prompt_pack() resolves the pack through the env var (section 15)."""
|
||||||
|
monkeypatch.setenv(ENV_PROMPT_PACK_DIR, str(FIXTURE_PACK))
|
||||||
|
assert load_prompt_pack().version == _FIXTURE_VERSION
|
||||||
|
monkeypatch.delenv(ENV_PROMPT_PACK_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_and_clear(tmp_path: Path) -> None:
|
||||||
|
"""Repeated loads share the cached instance; clear_pack_cache drops it."""
|
||||||
|
root = tmp_path / "cached"
|
||||||
|
_write_pack(root, version="1.0.0", prompts={_FIXTURE_PROMPT_NAME: "x"})
|
||||||
|
first = load_prompt_pack(root)
|
||||||
|
assert load_prompt_pack(root) is first
|
||||||
|
clear_pack_cache()
|
||||||
|
assert load_prompt_pack(root) is not first
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_returns_prompt_pack_instance() -> None:
|
||||||
|
"""The loaded object is a PromptPack (typed API for the client layer)."""
|
||||||
|
pack = load_prompt_pack(FIXTURE_PACK)
|
||||||
|
assert isinstance(pack, PromptPack)
|
||||||
|
assert pack.version == _FIXTURE_VERSION
|
||||||
|
|
||||||
Reference in New Issue
Block a user