diff --git a/src/pf1e_simulator/config.py b/src/pf1e_simulator/config.py new file mode 100644 index 0000000..03e8d63 --- /dev/null +++ b/src/pf1e_simulator/config.py @@ -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) + diff --git a/src/pf1e_simulator/prompt.py b/src/pf1e_simulator/prompt.py new file mode 100644 index 0000000..1132e17 --- /dev/null +++ b/src/pf1e_simulator/prompt.py @@ -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 +----------- +/ + version.txt # single line: semver or commit hash (mandatory) + .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 ``.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() + diff --git a/tests/fixtures/prompt_pack/nl_default.md b/tests/fixtures/prompt_pack/nl_default.md new file mode 100644 index 0000000..7dc1287 --- /dev/null +++ b/tests/fixtures/prompt_pack/nl_default.md @@ -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. \ No newline at end of file diff --git a/tests/fixtures/prompt_pack/version.txt b/tests/fixtures/prompt_pack/version.txt new file mode 100644 index 0000000..804a184 --- /dev/null +++ b/tests/fixtures/prompt_pack/version.txt @@ -0,0 +1 @@ +0.1.0-test \ No newline at end of file diff --git a/tests/test_phase2_prompt.py b/tests/test_phase2_prompt.py new file mode 100644 index 0000000..3c0d108 --- /dev/null +++ b/tests/test_phase2_prompt.py @@ -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 + .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 .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 +