feat(phase2): few-shot prompts + bounded repair loop (P5.2)

This commit is contained in:
2026-09-25 23:24:27 +02:00
parent d4db4f6d33
commit dc890fc8fd
9 changed files with 572 additions and 33 deletions
+55 -13
View File
@@ -1,10 +1,16 @@
"""Natural-language policy: parses LLM JSON replies into Action sequences (P1.3)."""
"""Natural-language policy (P1.3): parses LLM JSON replies into Action sequences.
P5.2 adds a bounded repair loop: on a parse failure the policy re-asks the
model with an accumulated error trail up to ``max_repairs`` times, then
falls back to the scripted policy; ``NLPolicyStats`` exposes the call and
repair counters for the sweep and the phase-3 report.
"""
from __future__ import annotations
import json
from collections.abc import Callable
from dataclasses import dataclass, fields
from dataclasses import dataclass, field, fields
from typing import TYPE_CHECKING, Literal, TypeGuard, get_args
from pf1e_simulator.candidates import candidates
@@ -233,6 +239,27 @@ def fallback_default(menu: tuple[Action, ...]) -> tuple[Action, ...]:
return (Action(kind="wait"),)
# ---- Repair protocol ---------------------------------------------------------
_REPAIR_INSTRUCTION = "Return a valid JSON action sequence."
_REPAIR_HEADER_TEMPLATE = "-- previous reply invalid (attempt {}) --\n{}"
@dataclass
class NLPolicyStats:
"""Mutable counters for the LLM decision loop (repair observability).
The phase-3 report and the sweep pipeline read these counters to
quantify how often the LLM layer repairs invalid replies or falls back
to the scripted policy.
"""
calls: int = 0
repairs_attempted: int = 0
repairs_succeeded: int = 0
fallbacks: int = 0
# ---- NLPolicy ----------------------------------------------------------------
@dataclass(frozen=True)
@@ -243,8 +270,13 @@ class NLPolicy:
builder: PromptBuilder
prompt_name: str = "nl_default"
fallback: Fallback = fallback_default
max_repairs: int = 1
stats: NLPolicyStats = field(default_factory=NLPolicyStats)
def __post_init__(self) -> None:
if self.max_repairs < 0:
msg = f"max_repairs must be >= 0, got {self.max_repairs}"
raise ValueError(msg)
expected = self.builder.prompt_version(self.prompt_name)
if self.client.prompt_version != expected:
msg = (
@@ -257,15 +289,25 @@ class NLPolicy:
menu = candidates(observation)
prompt = self.builder.build(observation, prompt_name=self.prompt_name)
reply = self.client.complete(prompt)
try:
return parse_actions(reply)
except NLPolicyError as exc:
retry_prompt = (
f"{prompt}\n\n-- previous reply invalid --\n{exc}\n"
"Return a valid JSON action sequence."
)
retry_reply = self.client.complete(retry_prompt)
self.stats.calls += 1
errors: list[NLPolicyError] = []
while True:
try:
return parse_actions(retry_reply)
except NLPolicyError:
return self.fallback(menu)
actions = parse_actions(reply)
break
except NLPolicyError as exc:
if len(errors) >= self.max_repairs:
self.stats.fallbacks += 1
return self.fallback(menu)
errors.append(exc)
self.stats.repairs_attempted += 1
trail = "\n\n".join(
_REPAIR_HEADER_TEMPLATE.format(index, error)
for index, error in enumerate(errors, start=1)
)
retry_prompt = f"{prompt}\n\n{trail}\n{_REPAIR_INSTRUCTION}"
reply = self.client.complete(retry_prompt)
self.stats.calls += 1
if errors:
self.stats.repairs_succeeded += 1
return actions
+115 -11
View File
@@ -12,22 +12,32 @@ and the sweep reports, so a campaign stays reproducible after a prompt edit
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``
version.txt # single line: semver or commit hash (mandatory)
<name>.md # one file per prompt name, e.g. ``nl_default.md``
<name>.examples.json # optional few-shot examples for that prompt: a
# JSON array of ``{"observation": {...},
# "actions": [{...}, ...]}`` teaching pairs
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).
silent fallback (validation rule, section 14). An optional
``<name>.examples.json`` file carries few-shot examples (P5.2): it comes
from the external pack, never from this repo (section 14), and is validated
at load time -- an unreadable, malformed or orphaned examples file is a
pack error, not a silent skip. ``prompt_version`` covers the examples
content alongside the prompt text, so the response-cache key (D6) stays in
sync with any pack edit. Unit tests use the minimal local fixtures under
``tests/fixtures/prompt_pack/`` so CI never depends on the external prompt
repository (pinning rule, section 14).
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from functools import cache
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, NoReturn, TypeGuard
from pf1e_simulator.config import ENV_PROMPT_PACK_DIR, env_path
@@ -35,31 +45,95 @@ if TYPE_CHECKING:
from pathlib import Path
_PROMPT_SUFFIX = ".md"
_EXAMPLES_SUFFIX = ".examples.json"
_VERSION_FILE = "version.txt"
_HASH_PREFIX_LEN = 12
_HASH_SEPARATOR = "\0"
_EXAMPLE_KEYS = frozenset({"observation", "actions"})
class PromptPackError(RuntimeError):
"""A prompt pack is missing, invalid, or lacks a required prompt."""
def _pack_error(kind: str, message: str) -> None:
def _pack_error(kind: str, message: str) -> NoReturn:
msg = f"{kind}: {message}"
raise PromptPackError(msg)
def _is_object_dict(value: object) -> TypeGuard[dict[str, object]]:
return isinstance(value, dict)
def _is_object_list(value: object) -> TypeGuard[list[object]]:
return isinstance(value, list)
@dataclass(frozen=True)
class FewShotExample:
"""One structured few-shot pair: an example observation and a valid reply.
``observation`` is a plain JSON object (the pack author composes it, the
builder renders it verbatim); ``actions`` is a JSON array of Action-shaped
objects teaching the exact reply format the parser accepts.
"""
observation: dict[str, object]
actions: tuple[dict[str, object], ...]
def _parse_examples(path: Path, text: str) -> tuple[FewShotExample, ...]:
"""Parse and strictly validate one few-shot examples file."""
try:
raw = json.loads(text)
except json.JSONDecodeError as exc:
msg = f"{path.name} is not valid JSON: {exc}"
_pack_error("invalid pack", msg)
if not _is_object_list(raw):
_pack_error("invalid pack", f"{path.name} must be a JSON array of examples")
examples: list[FewShotExample] = []
for index, item in enumerate(raw):
if not _is_object_dict(item):
msg = f"{path.name}[{index}] must be an object with 'observation' and 'actions'"
_pack_error("invalid pack", msg)
unknown = sorted(set(item) - _EXAMPLE_KEYS)
if unknown:
msg = f"{path.name}[{index}] has unknown key(s): {', '.join(unknown)}"
_pack_error("invalid pack", msg)
observation = item.get("observation")
actions = item.get("actions")
if not _is_object_dict(observation):
msg = f"{path.name}[{index}] 'observation' must be a JSON object"
_pack_error("invalid pack", msg)
if not _is_object_list(actions):
msg = f"{path.name}[{index}] 'actions' must be a JSON array of objects"
_pack_error("invalid pack", msg)
action_dicts: list[dict[str, object]] = []
for action in actions:
if not _is_object_dict(action):
msg = f"{path.name}[{index}] 'actions' must contain only JSON objects"
_pack_error("invalid pack", msg)
action_dicts.append(action)
examples.append(FewShotExample(observation=observation, actions=tuple(action_dicts)))
return tuple(examples)
@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.
(file stems) to their full text, read at load time; ``_examples`` maps
prompt names to their parsed few-shot pairs (empty when the pack ships
none) and ``_examples_text`` keeps the raw file bytes for hashing.
"""
root: Path
version: str
_prompts: dict[str, str] = field(repr=False)
_examples: dict[str, tuple[FewShotExample, ...]] = field(repr=False)
_examples_text: dict[str, str] = field(repr=False)
def get(self, name: str) -> str:
"""Return the prompt ``name``'s text, or raise ``PromptPackError``."""
@@ -72,10 +146,24 @@ class PromptPack:
)
raise PromptPackError(msg) from exc
def examples(self, name: str) -> tuple[FewShotExample, ...]:
"""Return the few-shot pairs for ``name`` (empty when none)."""
return self._examples.get(name, ())
def prompt_version(self, name: str) -> str:
"""Pack version plus per-prompt content hash (cache-key component, D6)."""
"""Pack version plus per-prompt content hash (cache-key component, D6).
The digest covers the prompt text and, when the pack ships a
``<name>.examples.json`` file, that file's bytes too -- any pack edit
invalidates the cache key. Without an examples file the digest is
byte-identical to the pre-few-shot scheme.
"""
content = self.get(name)
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:_HASH_PREFIX_LEN]
examples_text = self._examples_text.get(name)
digest_input = content
if examples_text is not None:
digest_input = f"{content}{_HASH_SEPARATOR}{examples_text}"
digest = hashlib.sha256(digest_input.encode("utf-8")).hexdigest()[:_HASH_PREFIX_LEN]
return f"{self.version}:{name}:{digest}"
@classmethod
@@ -95,7 +183,23 @@ class PromptPack:
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)
examples: dict[str, tuple[FewShotExample, ...]] = {}
examples_text: dict[str, str] = {}
for path in sorted(root.glob(f"*{_EXAMPLES_SUFFIX}")):
stem = path.name.removesuffix(_EXAMPLES_SUFFIX)
if stem not in prompts:
msg = f"{path.name} has no matching '{stem}{_PROMPT_SUFFIX}' prompt"
_pack_error("invalid pack", msg)
text = path.read_text(encoding="utf-8")
examples[stem] = _parse_examples(path, text)
examples_text[stem] = text
return cls(
root=root,
version=version,
_prompts=prompts,
_examples=examples,
_examples_text=examples_text,
)
@cache
+29 -6
View File
@@ -1,9 +1,14 @@
"""Prompt builder for the phase-2 LLM layer (P1.4).
Builds a deterministic prompt from an ``Observation`` plus the external
prompt pack: template + compact observation + plausible actions + JSON
action schema. The schema is derived from the ``Action`` dataclass via
``typing.get_type_hints`` so it stays in sync with the engine.
prompt pack: template + optional few-shot examples + compact observation +
plausible actions + JSON action schema. The examples (P5.2) come from the
pack's ``<name>.examples.json`` file when present and are mounted between
the template and the live observation, so the query stays the last section
before the reply (few-shot recency) and packs without examples keep the
original section order bit-for-bit. The schema is derived from the
``Action`` dataclass via ``typing.get_type_hints`` so it stays in sync with
the engine.
"""
from __future__ import annotations
@@ -18,7 +23,7 @@ from pf1e_simulator.combat import UNSUPPORTED_ACTION_KINDS, Action, Observation
from pf1e_simulator.llm_view import compact_observation
if TYPE_CHECKING:
from pf1e_simulator.prompt import PromptPack
from pf1e_simulator.prompt import FewShotExample, PromptPack
def _supported_kinds() -> tuple[str, ...]:
@@ -152,6 +157,20 @@ def render_candidates(actions: tuple[Action, ...]) -> tuple[str, ...]:
return tuple(lines)
def _render_examples(examples: tuple[FewShotExample, ...]) -> str:
"""Render few-shot pairs as observation-to-response teaching blocks."""
blocks: list[str] = []
for index, example in enumerate(examples):
observation_json = json.dumps(example.observation, sort_keys=True)
actions_json = json.dumps(example.actions, sort_keys=True)
blocks.append(
f"### example {index + 1}\n"
f"observation:\n{observation_json}\n\n"
f"response:\n{actions_json}"
)
return "## few-shot examples\n" + "\n\n".join(blocks)
@dataclass(frozen=True)
class PromptBuilder:
"""Builds the complete LLM prompt from a prompt pack and an observation."""
@@ -173,9 +192,13 @@ class PromptBuilder:
menu = candidates(observation)
actions_text = "\n".join(render_candidates(menu))
schema_text = json.dumps(action_schema(), sort_keys=True, indent=2)
return "\n\n".join([
template,
sections: list[str] = [template]
examples = self.pack.examples(prompt_name)
if examples:
sections.append(_render_examples(examples))
sections.extend([
"## observation\n" + obs_text,
"## plausible actions\n" + actions_text,
"## action schema\n" + schema_text,
])
return "\n\n".join(sections)