feat(phase2): P1c
This commit is contained in:
@@ -1526,7 +1526,7 @@ _REASON_CANNOT_COMBINE = "cannot_combine"
|
||||
|
||||
# Action kinds that are declared but have no engine dispatch (U1 surfaces
|
||||
# them as explicit rejections instead of silent no-ops).
|
||||
_UNSUPPORTED_ACTION_KINDS: frozenset[str] = frozenset({"swift", "free", "full_round"})
|
||||
UNSUPPORTED_ACTION_KINDS: frozenset[str] = frozenset({"swift", "free", "full_round"})
|
||||
# Action kinds whose helper is a silent no-op (or worse) without a resolved
|
||||
# target; _execute rejects them up front with missing_target instead.
|
||||
# move / 5foot_step / run are exempt when an explicit destination is provided
|
||||
@@ -5097,7 +5097,7 @@ class CombatEngine:
|
||||
reports ok.
|
||||
"""
|
||||
kind = action.kind
|
||||
if kind in _UNSUPPORTED_ACTION_KINDS:
|
||||
if kind in UNSUPPORTED_ACTION_KINDS:
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"{kind} rejected ({_REASON_UNSUPPORTED_KIND})"
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Natural-language policy: parses LLM JSON replies into Action sequences (P1.3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, 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"),)
|
||||
|
||||
|
||||
# ---- 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
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
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)
|
||||
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)
|
||||
try:
|
||||
return parse_actions(retry_reply)
|
||||
except NLPolicyError:
|
||||
return self.fallback(menu)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import MISSING, asdict, dataclass, fields
|
||||
from types import UnionType
|
||||
from typing import TYPE_CHECKING, Literal, Union, get_args, get_origin, get_type_hints
|
||||
|
||||
from pf1e_simulator.candidates import candidates
|
||||
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
|
||||
|
||||
|
||||
def _supported_kinds() -> tuple[str, ...]:
|
||||
"""Return the 27 action kinds supported by the LLM layer (declaration order)."""
|
||||
kind_literal = get_type_hints(Action)["kind"]
|
||||
all_kinds = get_args(kind_literal)
|
||||
return tuple(k for k in all_kinds if k not in UNSUPPORTED_ACTION_KINDS)
|
||||
|
||||
|
||||
_EMPTY_LITERAL_MSG = "empty Literal"
|
||||
_UNSUPPORTED_LITERAL_MSG = "unsupported Literal values: {}"
|
||||
_MULTI_UNION_MSG = "multi-type union not supported in Action: {}"
|
||||
_UNSUPPORTED_TYPE_MSG = "unsupported type in Action schema: {}"
|
||||
_TUPLE_ELLIPSIS_LEN = 2
|
||||
|
||||
|
||||
def _json_schema_base(tp: object) -> dict[str, object] | None:
|
||||
"""Return the JSON Schema fragment for a base type, else None."""
|
||||
if tp is type(None):
|
||||
return {"type": "null"}
|
||||
if tp is str:
|
||||
return {"type": "string"}
|
||||
if tp is int:
|
||||
return {"type": "integer"}
|
||||
if tp is bool:
|
||||
return {"type": "boolean"}
|
||||
return None
|
||||
|
||||
|
||||
def _json_schema_literal(args: tuple[object, ...]) -> dict[str, object]:
|
||||
"""Return the JSON Schema fragment for a Literal type."""
|
||||
if not args:
|
||||
raise ValueError(_EMPTY_LITERAL_MSG)
|
||||
first = args[0]
|
||||
if isinstance(first, str):
|
||||
base_type = "string"
|
||||
elif isinstance(first, int):
|
||||
base_type = "integer"
|
||||
else:
|
||||
msg = _UNSUPPORTED_LITERAL_MSG.format(args)
|
||||
raise TypeError(msg)
|
||||
return {"type": base_type, "enum": list(args)}
|
||||
|
||||
|
||||
def _json_schema_tuple(args: tuple[object, ...]) -> dict[str, object]:
|
||||
"""Return the JSON Schema fragment for a tuple type."""
|
||||
if not args:
|
||||
return {"type": "array"}
|
||||
if len(args) == _TUPLE_ELLIPSIS_LEN and args[1] is Ellipsis:
|
||||
# tuple[T, ...] → homogeneous array
|
||||
item_schema = _json_schema_for(args[0])
|
||||
return {"type": "array", "items": item_schema}
|
||||
# Fixed-length tuple → prefixItems + minItems/maxItems
|
||||
prefix = [_json_schema_for(arg) for arg in args]
|
||||
return {
|
||||
"type": "array",
|
||||
"prefixItems": prefix,
|
||||
"minItems": len(args),
|
||||
"maxItems": len(args),
|
||||
}
|
||||
|
||||
|
||||
def _json_schema_union(args: tuple[object, ...]) -> dict[str, object]:
|
||||
"""Return the JSON Schema fragment for a nullable single-member union."""
|
||||
non_none = [a for a in args if a is not type(None)]
|
||||
if len(non_none) == 1:
|
||||
member_schema = _json_schema_for(non_none[0])
|
||||
# Merge "null" into the type array
|
||||
member_type = member_schema.get("type")
|
||||
if isinstance(member_type, str):
|
||||
member_schema["type"] = [member_type, "null"]
|
||||
# If member already has an enum (e.g. nullable Literal), keep it.
|
||||
return member_schema
|
||||
msg = _MULTI_UNION_MSG.format(args)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _json_schema_for(tp: object) -> dict[str, object]:
|
||||
"""Convert a Python type annotation to a JSON Schema fragment."""
|
||||
base_schema = _json_schema_base(tp)
|
||||
if base_schema is not None:
|
||||
return base_schema
|
||||
|
||||
origin = get_origin(tp)
|
||||
args = get_args(tp)
|
||||
|
||||
if origin is Literal:
|
||||
return _json_schema_literal(args)
|
||||
|
||||
if origin is tuple:
|
||||
return _json_schema_tuple(args)
|
||||
|
||||
if origin is Union or origin is UnionType:
|
||||
return _json_schema_union(args)
|
||||
|
||||
msg = _UNSUPPORTED_TYPE_MSG.format(tp)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def action_schema() -> dict[str, object]:
|
||||
"""JSON Schema for a single Action object."""
|
||||
hints = get_type_hints(Action)
|
||||
properties = {name: _json_schema_for(tp) for name, tp in hints.items()}
|
||||
# Override kind with the filtered 27-kind enum (source Literal order)
|
||||
properties["kind"] = {"type": "string", "enum": list(_supported_kinds())}
|
||||
# Only 'kind' lacks a default
|
||||
required = [
|
||||
f.name
|
||||
for f in fields(Action)
|
||||
if f.default is MISSING and f.default_factory is MISSING
|
||||
]
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def render_candidates(actions: tuple[Action, ...]) -> tuple[str, ...]:
|
||||
"""Render the plausible-action menu as numbered lines with compact JSON."""
|
||||
lines: list[str] = []
|
||||
for index, action in enumerate(actions):
|
||||
payload = {
|
||||
key: value
|
||||
for key, value in asdict(action).items()
|
||||
if key != "kind" and value is not None and value != () and value is not False
|
||||
}
|
||||
suffix = f" {json.dumps(payload, sort_keys=True, separators=(',', ':'))}" if payload else ""
|
||||
lines.append(f"{index}. {action.kind}{suffix}")
|
||||
return tuple(lines)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptBuilder:
|
||||
"""Builds the complete LLM prompt from a prompt pack and an observation."""
|
||||
|
||||
pack: PromptPack
|
||||
|
||||
def prompt_version(self, name: str) -> str:
|
||||
"""Pack version plus per-prompt content hash (cache-key component, D6)."""
|
||||
return self.pack.prompt_version(name)
|
||||
|
||||
def build(self, observation: object, *, prompt_name: str = "nl_default") -> str:
|
||||
"""Assemble the full prompt for the given observation."""
|
||||
if not isinstance(observation, Observation):
|
||||
msg = "observation must be an Observation instance"
|
||||
raise TypeError(msg)
|
||||
|
||||
template = self.pack.get(prompt_name)
|
||||
obs_text = compact_observation(observation).rstrip("\n")
|
||||
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,
|
||||
"## observation\n" + obs_text,
|
||||
"## plausible actions\n" + actions_text,
|
||||
"## action schema\n" + schema_text,
|
||||
])
|
||||
Reference in New Issue
Block a user