298 lines
10 KiB
Python
298 lines
10 KiB
Python
"""Tests for the phase-2 prompt builder (P1.4)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from dataclasses import fields
|
|
from pathlib import Path
|
|
|
|
from pf1e_simulator.combat import Action, Observation
|
|
from pf1e_simulator.nl_policy import KindValue
|
|
from pf1e_simulator.nl_policy import get_args as _get_args
|
|
from pf1e_simulator.prompt import PromptPack
|
|
from pf1e_simulator.prompt_builder import (
|
|
PromptBuilder,
|
|
_render_examples,
|
|
_supported_kinds,
|
|
action_schema,
|
|
render_candidates,
|
|
)
|
|
|
|
_FIXTURE_PACK = Path(__file__).resolve().parent / "fixtures" / "prompt_pack"
|
|
_FIXTURE_PACK_FS = Path(__file__).resolve().parent / "fixtures" / "prompt_pack_fs"
|
|
_FIXTURE_VERSION = "0.1.0-test"
|
|
_FIXTURE_PROMPT_NAME = "nl_default"
|
|
|
|
|
|
def _obs() -> Observation:
|
|
"""Minimal valid observation for deterministic tests."""
|
|
return Observation(
|
|
round=1,
|
|
acting_combatant=0,
|
|
combatants=(
|
|
{"id": "goblin", "side": "players", "active": True, "pos": (1, 1)},
|
|
),
|
|
pairs=(),
|
|
terrain=((None,),),
|
|
elevation=((0,),),
|
|
)
|
|
|
|
|
|
def _pack() -> PromptPack:
|
|
return PromptPack.load(_FIXTURE_PACK)
|
|
|
|
|
|
def _builder() -> PromptBuilder:
|
|
return PromptBuilder(pack=_pack())
|
|
|
|
|
|
def test_supported_kinds_filters_unsupported() -> None:
|
|
kinds = _supported_kinds()
|
|
assert "swift" not in kinds
|
|
assert "free" not in kinds
|
|
assert "full_round" not in kinds
|
|
# 27 kinds remain
|
|
assert len(kinds) == 27
|
|
# Order preserved from source Literal
|
|
assert kinds == (
|
|
"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",
|
|
)
|
|
|
|
|
|
def test_action_schema_structure() -> None:
|
|
schema = action_schema()
|
|
assert schema["type"] == "object"
|
|
assert schema["required"] == ["kind"]
|
|
assert schema["additionalProperties"] is False
|
|
|
|
props = schema["properties"]
|
|
# Properties match Action fields exactly
|
|
action_field_names = {f.name for f in fields(Action)}
|
|
assert set(props.keys()) == action_field_names
|
|
|
|
# Kind enum is the filtered 27
|
|
kind_prop = props["kind"]
|
|
assert kind_prop["type"] == "string"
|
|
assert kind_prop["enum"] == list(_supported_kinds())
|
|
|
|
|
|
def test_action_schema_spot_check_fragments() -> None:
|
|
schema = action_schema()
|
|
props = schema["properties"]
|
|
|
|
# target_id accepts either a string or null
|
|
assert props["target_id"] == {"type": ["string", "null"]}
|
|
|
|
# destination is a nullable two-int pair (prefixItems + min/maxItems)
|
|
dest = props["destination"]
|
|
assert dest["type"] == ["array", "null"]
|
|
assert dest["prefixItems"] == [{"type": "integer"}, {"type": "integer"}]
|
|
assert dest["minItems"] == 2
|
|
assert dest["maxItems"] == 2
|
|
|
|
# channel_exclude: string[] (array of strings)
|
|
ch_ex = props["channel_exclude"]
|
|
assert ch_ex["type"] == "array"
|
|
assert ch_ex["items"] == {"type": "string"}
|
|
|
|
# cast_defensively is a boolean field
|
|
assert props["cast_defensively"] == {"type": "boolean"}
|
|
|
|
# tumble is a boolean field
|
|
assert props["tumble"] == {"type": "boolean"}
|
|
|
|
# channel_mode: nullable enum
|
|
ch_mode = props["channel_mode"]
|
|
assert ch_mode["type"] == ["string", "null"]
|
|
assert ch_mode["enum"] == ["heal", "harm"]
|
|
|
|
# aid_mode: nullable enum
|
|
aid = props["aid_mode"]
|
|
assert aid["type"] == ["string", "null"]
|
|
assert aid["enum"] == ["attack", "ac"]
|
|
|
|
# trick_condition: nullable enum (6 values)
|
|
tc = props["trick_condition"]
|
|
assert tc["type"] == ["string", "null"]
|
|
assert tc["enum"] == ["dazzled", "blinded", "deafened", "entangled", "shaken", "sickened"]
|
|
|
|
# ready_trigger: nullable enum (4 values)
|
|
rt = props["ready_trigger"]
|
|
assert rt["type"] == ["string", "null"]
|
|
assert rt["enum"] == ["enemy_enters_reach", "enemy_casts", "enemy_attacks", "enemy_hits_ally"]
|
|
|
|
# metamagic: string[]
|
|
meta = props["metamagic"]
|
|
assert meta["type"] == "array"
|
|
assert meta["items"] == {"type": "string"}
|
|
|
|
# class_ability is a nullable string field
|
|
assert props["class_ability"] == {"type": ["string", "null"]}
|
|
|
|
|
|
def test_render_candidates() -> None:
|
|
# Single action with payload
|
|
rendered = render_candidates((Action(kind="move", destination=(2, 3)),))
|
|
assert rendered == ('0. move {"destination":[2,3]}',)
|
|
|
|
# Action with no payload (all defaults/None)
|
|
rendered = render_candidates((Action(kind="total_defense"),))
|
|
assert rendered == ("0. total_defense",)
|
|
|
|
# Action with target_id
|
|
rendered = render_candidates((Action(kind="attack", target_id="orc"),))
|
|
assert rendered == ('0. attack {"target_id":"orc"}',)
|
|
|
|
# Empty menu
|
|
assert render_candidates(()) == ()
|
|
|
|
# Multiple actions
|
|
rendered = render_candidates((
|
|
Action(kind="attack", target_id="a"),
|
|
Action(kind="move", destination=(1, 1)),
|
|
))
|
|
assert rendered == (
|
|
'0. attack {"target_id":"a"}',
|
|
'1. move {"destination":[1,1]}',
|
|
)
|
|
|
|
|
|
def test_prompt_builder_build_deterministic() -> None:
|
|
builder = _builder()
|
|
obs = _obs()
|
|
prompt1 = builder.build(obs)
|
|
prompt2 = builder.build(obs)
|
|
assert prompt1 == prompt2
|
|
|
|
|
|
def test_prompt_builder_sections_present() -> None:
|
|
builder = _builder()
|
|
obs = _obs()
|
|
prompt = builder.build(obs)
|
|
|
|
# Template content
|
|
assert "tactical director" in prompt
|
|
assert "battle state" in prompt
|
|
|
|
# Sections
|
|
assert "## observation\n" in prompt
|
|
assert "## plausible actions\n" in prompt
|
|
assert "## action schema\n" in prompt
|
|
|
|
# Schema is valid JSON and contains kind enum
|
|
schema_part = prompt.split("## action schema\n")[1]
|
|
parsed = json.loads(schema_part)
|
|
assert parsed["properties"]["kind"]["enum"] == list(_supported_kinds())
|
|
|
|
|
|
def test_prompt_version_format() -> None:
|
|
builder = _builder()
|
|
pv = builder.prompt_version(_FIXTURE_PROMPT_NAME)
|
|
assert pv.startswith(f"{_FIXTURE_VERSION}:{_FIXTURE_PROMPT_NAME}:")
|
|
digest = pv.split(":")[-1]
|
|
assert len(digest) == 12
|
|
assert all(c in "0123456789abcdef" for c in digest)
|
|
# Cross-check against actual pack content
|
|
pack = _pack()
|
|
content = pack.get(_FIXTURE_PROMPT_NAME)
|
|
expected_digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
|
|
assert digest == expected_digest
|
|
|
|
|
|
def test_prompt_builder_custom_prompt_name() -> None:
|
|
builder = _builder()
|
|
obs = _obs()
|
|
# nl_default is the only fixture, but the API accepts any name
|
|
prompt = builder.build(obs, prompt_name=_FIXTURE_PROMPT_NAME)
|
|
assert "tactical director" in prompt
|
|
|
|
|
|
def test_prompt_version_delegates_to_pack() -> None:
|
|
builder = _builder()
|
|
pack = _pack()
|
|
assert builder.prompt_version(_FIXTURE_PROMPT_NAME) == pack.prompt_version(_FIXTURE_PROMPT_NAME)
|
|
|
|
|
|
def test_action_schema_cross_pin_with_nl_policy_kindvalue() -> None:
|
|
"""The kind enum in the schema must match nl_policy.KindValue exactly."""
|
|
schema = action_schema()
|
|
assert schema["properties"]["kind"]["enum"] == list(_get_args(KindValue))
|
|
|
|
|
|
# ---- few-shot examples (P5.2) ----------------------------------------------
|
|
|
|
_FS_PACK = PromptPack.load(_FIXTURE_PACK_FS)
|
|
|
|
|
|
def _fs_builder() -> PromptBuilder:
|
|
return PromptBuilder(pack=_FS_PACK)
|
|
|
|
|
|
def test_render_examples_block_format() -> None:
|
|
"""Few-shot pairs render as numbered observation-to-response blocks."""
|
|
rendered = _render_examples((_FS_PACK.examples(_FIXTURE_PROMPT_NAME)[0],))
|
|
assert rendered == (
|
|
"## few-shot examples\n"
|
|
"### example 1\n"
|
|
"observation:\n"
|
|
'{"combatant": "goblin", "distance_ft": 10, "enemy": "orc", "round": 1}\n'
|
|
"\n"
|
|
"response:\n"
|
|
'[{"kind": "attack", "target_id": "orc"}]'
|
|
)
|
|
|
|
|
|
def test_build_without_examples_unchanged() -> None:
|
|
"""Packs without examples keep the pre-few-shot bit-for-bit section order."""
|
|
plain = _builder().build(_obs())
|
|
assert "## few-shot examples" not in plain
|
|
# Template, then observation, then candidates, then schema — in order.
|
|
sections = [
|
|
"## observation\n",
|
|
"## plausible actions\n",
|
|
"## action schema\n",
|
|
]
|
|
positions = [plain.index(section) for section in sections]
|
|
assert positions == sorted(positions)
|
|
# The template comes first, unmodified.
|
|
assert plain.startswith("You are the tactical director")
|
|
|
|
|
|
def test_build_with_examples_inserts_between_template_and_observation() -> None:
|
|
"""The few-shot section sits between the template and the observation."""
|
|
prompt = _fs_builder().build(_obs())
|
|
assert "## few-shot examples\n" in prompt
|
|
assert prompt.index("## few-shot examples\n") < prompt.index("## observation\n")
|
|
# Template still leads the prompt.
|
|
assert prompt.startswith("You are the tactical director")
|
|
# Observation/candidates/schema keep their relative order.
|
|
assert prompt.index("## observation\n") < prompt.index("## plausible actions\n")
|
|
assert prompt.index("## plausible actions\n") < prompt.index("## action schema\n")
|
|
|
|
|
|
def test_build_with_examples_includes_example_content() -> None:
|
|
"""The rendered example content is embedded verbatim (sorted keys)."""
|
|
prompt = _fs_builder().build(_obs())
|
|
assert '{"combatant": "goblin", "distance_ft": 10, "enemy": "orc", "round": 1}' in prompt
|
|
assert '[{"kind": "attack", "target_id": "orc"}]' in prompt
|
|
assert "### example 1" in prompt
|
|
|
|
|
|
def test_empty_examples_array_no_section(tmp_path: Path) -> None:
|
|
"""An empty examples array yields no few-shot section (not an error)."""
|
|
root = tmp_path / "emptyfs"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
(root / "version.txt").write_text("1.0.0", encoding="utf-8")
|
|
(root / "nl_default.md").write_text("T", encoding="utf-8")
|
|
(root / "nl_default.examples.json").write_text("[]", encoding="utf-8")
|
|
pack = PromptPack.load(root)
|
|
assert pack.examples("nl_default") == ()
|
|
prompt = PromptBuilder(pack=pack).build(_obs())
|
|
assert "## few-shot examples" not in prompt
|