314 lines
11 KiB
Python
314 lines
11 KiB
Python
"""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, field, fields
|
|
from typing import TYPE_CHECKING, Literal, TypeGuard, get_args
|
|
|
|
from pf1e_simulator.candidates import candidates
|
|
from pf1e_simulator.combat import Action, ReadyTrigger, TrickCondition
|
|
|
|
if TYPE_CHECKING:
|
|
from pf1e_simulator.client import LLMClient
|
|
from pf1e_simulator.combat import Observation
|
|
from pf1e_simulator.prompt_builder import PromptBuilder
|
|
|
|
|
|
# ---- Type aliases for the 27 supported kinds (filtered from Action.kind) ----
|
|
KindValue = Literal[
|
|
"attack", "move", "wait", "immediate", "full_attack", "charge", "withdraw",
|
|
"5foot_step", "cast_spell", "maneuver", "channel_energy", "hex", "deed",
|
|
"spell_combat", "inspire_courage", "retrieve_weapon", "repair_weapon",
|
|
"activate_power_attack", "delay", "ready", "total_defense",
|
|
"fight_defensively", "stand_up", "drop_prone", "aid_another", "run", "hide",
|
|
]
|
|
_KIND_VALUES: frozenset[str] = frozenset(get_args(KindValue))
|
|
_FIELD_NAMES: frozenset[str] = frozenset(f.name for f in fields(Action))
|
|
_PAIR_LEN = 2
|
|
_CHANNEL_MODES: frozenset[str] = frozenset(get_args(Literal["heal", "harm"]))
|
|
_AID_MODES: frozenset[str] = frozenset(get_args(Literal["attack", "ac"]))
|
|
_TRICK_CONDITIONS: frozenset[str] = frozenset(get_args(TrickCondition))
|
|
_READY_TRIGGERS: frozenset[str] = frozenset(get_args(ReadyTrigger))
|
|
|
|
|
|
class NLPolicyError(ValueError):
|
|
"""The LLM reply is not a valid JSON action sequence."""
|
|
|
|
|
|
# ---- Type guards -------------------------------------------------------------
|
|
|
|
def _is_kind(value: object) -> TypeGuard[KindValue]:
|
|
return value in _KIND_VALUES
|
|
|
|
|
|
def _is_channel_mode(value: object) -> TypeGuard[Literal["heal", "harm"]]:
|
|
return value in _CHANNEL_MODES
|
|
|
|
|
|
def _is_aid_mode(value: object) -> TypeGuard[Literal["attack", "ac"]]:
|
|
return value in _AID_MODES
|
|
|
|
|
|
def _is_trick_condition(value: object) -> TypeGuard[TrickCondition]:
|
|
return value in _TRICK_CONDITIONS
|
|
|
|
|
|
def _is_ready_trigger(value: object) -> TypeGuard[ReadyTrigger]:
|
|
return value in _READY_TRIGGERS
|
|
|
|
|
|
def _is_object_list(value: object) -> TypeGuard[list[object]]:
|
|
return isinstance(value, list)
|
|
|
|
|
|
def _is_object_seq(value: object) -> TypeGuard[list[object] | tuple[object, ...]]:
|
|
return isinstance(value, (list, tuple))
|
|
|
|
|
|
def _is_object_dict(value: object) -> TypeGuard[dict[str, object]]:
|
|
return isinstance(value, dict)
|
|
|
|
|
|
# ---- Coercion helpers --------------------------------------------------------
|
|
|
|
def _opt_str(value: object, name: str) -> str | None:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, str):
|
|
return value
|
|
msg = f"{name!r} must be a string"
|
|
raise NLPolicyError(msg)
|
|
|
|
|
|
def _str_tuple(value: object, name: str) -> tuple[str, ...]:
|
|
if value is None:
|
|
return ()
|
|
if not _is_object_seq(value):
|
|
msg = f"{name!r} must be a list of strings"
|
|
raise NLPolicyError(msg)
|
|
out: list[str] = []
|
|
for item in value:
|
|
if not isinstance(item, str):
|
|
msg = f"{name!r} must be a list of strings"
|
|
raise NLPolicyError(msg)
|
|
out.append(item)
|
|
return tuple(out)
|
|
|
|
|
|
def _int_pair(value: object, name: str) -> tuple[int, int] | None:
|
|
if value is None:
|
|
return None
|
|
if not _is_object_seq(value):
|
|
msg = f"{name!r} must be a [row, col] pair"
|
|
raise NLPolicyError(msg)
|
|
if len(value) != _PAIR_LEN:
|
|
msg = f"{name!r} must be a [row, col] pair"
|
|
raise NLPolicyError(msg)
|
|
first, second = value[0], value[1]
|
|
if (
|
|
isinstance(first, bool)
|
|
or isinstance(second, bool)
|
|
or not isinstance(first, int)
|
|
or not isinstance(second, int)
|
|
):
|
|
msg = f"{name!r} must be a [row, col] pair"
|
|
raise NLPolicyError(msg)
|
|
return (first, second)
|
|
|
|
|
|
def _opt_bool(value: object, name: str) -> bool:
|
|
if value is None:
|
|
return False
|
|
if isinstance(value, bool):
|
|
return value
|
|
msg = f"{name!r} must be a boolean"
|
|
raise NLPolicyError(msg)
|
|
|
|
|
|
def _opt_channel_mode(value: object) -> Literal["heal", "harm"] | None:
|
|
if value is None:
|
|
return None
|
|
if _is_channel_mode(value):
|
|
return value
|
|
msg = "'channel_mode' must be one of: heal, harm"
|
|
raise NLPolicyError(msg)
|
|
|
|
|
|
def _opt_aid_mode(value: object) -> Literal["attack", "ac"] | None:
|
|
if value is None:
|
|
return None
|
|
if _is_aid_mode(value):
|
|
return value
|
|
msg = "'aid_mode' must be one of: attack, ac"
|
|
raise NLPolicyError(msg)
|
|
|
|
|
|
def _opt_trick_condition(value: object) -> TrickCondition | None:
|
|
if value is None:
|
|
return None
|
|
if _is_trick_condition(value):
|
|
return value
|
|
msg = "'trick_condition' must be one of: " + ", ".join(get_args(TrickCondition))
|
|
raise NLPolicyError(msg)
|
|
|
|
|
|
def _opt_ready_trigger(value: object) -> ReadyTrigger | None:
|
|
if value is None:
|
|
return None
|
|
if _is_ready_trigger(value):
|
|
return value
|
|
msg = "'ready_trigger' must be one of: " + ", ".join(get_args(ReadyTrigger))
|
|
raise NLPolicyError(msg)
|
|
|
|
|
|
# ---- Action construction -----------------------------------------------------
|
|
|
|
def _action_from_item(item: object) -> Action:
|
|
if not _is_object_dict(item):
|
|
msg = "each action must be a JSON object"
|
|
raise NLPolicyError(msg)
|
|
fields_dict = item
|
|
unknown = sorted(set(fields_dict) - _FIELD_NAMES)
|
|
if unknown:
|
|
msg = f"unknown action field(s): {', '.join(unknown)}"
|
|
raise NLPolicyError(msg)
|
|
kind = fields_dict.get("kind")
|
|
if not _is_kind(kind):
|
|
msg = "missing or unknown 'kind'"
|
|
raise NLPolicyError(msg)
|
|
return Action(
|
|
kind=kind,
|
|
target_id=_opt_str(fields_dict.get("target_id"), "target_id"),
|
|
spell_name=_opt_str(fields_dict.get("spell_name"), "spell_name"),
|
|
maneuver=_opt_str(fields_dict.get("maneuver"), "maneuver"),
|
|
hex_name=_opt_str(fields_dict.get("hex_name"), "hex_name"),
|
|
deed_name=_opt_str(fields_dict.get("deed_name"), "deed_name"),
|
|
class_ability=_opt_str(fields_dict.get("class_ability"), "class_ability"),
|
|
channel_mode=_opt_channel_mode(fields_dict.get("channel_mode")),
|
|
channel_exclude=_str_tuple(fields_dict.get("channel_exclude"), "channel_exclude"),
|
|
direction=_int_pair(fields_dict.get("direction"), "direction"),
|
|
trick_condition=_opt_trick_condition(fields_dict.get("trick_condition")),
|
|
weapon_id=_opt_str(fields_dict.get("weapon_id"), "weapon_id"),
|
|
ready_trigger=_opt_ready_trigger(fields_dict.get("ready_trigger")),
|
|
destination=_int_pair(fields_dict.get("destination"), "destination"),
|
|
target_pos=_int_pair(fields_dict.get("target_pos"), "target_pos"),
|
|
aid_mode=_opt_aid_mode(fields_dict.get("aid_mode")),
|
|
metamagic=_str_tuple(fields_dict.get("metamagic"), "metamagic"),
|
|
cast_defensively=_opt_bool(fields_dict.get("cast_defensively"), "cast_defensively"),
|
|
tumble=_opt_bool(fields_dict.get("tumble"), "tumble"),
|
|
)
|
|
|
|
|
|
# ---- Public parse API --------------------------------------------------------
|
|
|
|
def parse_actions(reply: str) -> tuple[Action, ...]:
|
|
"""Parse an LLM reply into a tuple of Action objects."""
|
|
try:
|
|
parsed = json.loads(reply)
|
|
except json.JSONDecodeError as exc:
|
|
msg = f"reply is not valid JSON: {exc}"
|
|
raise NLPolicyError(msg) from exc
|
|
if not _is_object_list(parsed):
|
|
msg = "reply must be a JSON array of action objects"
|
|
raise NLPolicyError(msg)
|
|
items = parsed
|
|
return tuple(_action_from_item(item) for item in items)
|
|
|
|
|
|
# ---- Fallback policy ---------------------------------------------------------
|
|
|
|
Fallback = Callable[[tuple[Action, ...]], tuple[Action, ...]]
|
|
|
|
|
|
def fallback_default(menu: tuple[Action, ...]) -> tuple[Action, ...]:
|
|
"""Deterministic fallback: first decisive action, then a defensive hold."""
|
|
for kind in ("full_attack", "attack", "charge", "move", "5foot_step"):
|
|
for action in menu:
|
|
if action.kind == kind:
|
|
return (action,)
|
|
if menu:
|
|
return (menu[0],)
|
|
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)
|
|
class NLPolicy:
|
|
"""Turn LLM completions into Action sequences, with a scripted fallback."""
|
|
|
|
client: LLMClient
|
|
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 = (
|
|
f"client prompt_version {self.client.prompt_version!r} does not "
|
|
f"match pack {expected!r} for {self.prompt_name!r}"
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
def __call__(self, observation: Observation) -> tuple[Action, ...]:
|
|
menu = candidates(observation)
|
|
prompt = self.builder.build(observation, prompt_name=self.prompt_name)
|
|
reply = self.client.complete(prompt)
|
|
self.stats.calls += 1
|
|
errors: list[NLPolicyError] = []
|
|
while True:
|
|
try:
|
|
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
|