feat(phase2): few-shot prompts + bounded repair loop (P5.2)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user