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