feat(api): prompt loader (header split) + curated canon-log digest
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
57
api/app/prompts.py
Normal file
57
api/app/prompts.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Load a role's system prompt (§16 — prompts are source code) and render the
|
||||||
|
canon log into a curated digest for the user message (charter §11).
|
||||||
|
|
||||||
|
The digest is a projection, not a dump: it surfaces narrative context and
|
||||||
|
deliberately omits raw integers — companion dispositions render as names only,
|
||||||
|
luck as its descriptor, and ids/schema_version are dropped (charter §7/§2).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def system_prompt(role: str) -> str:
|
||||||
|
text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8")
|
||||||
|
# Everything below the first '---' line is the model-facing prompt; the
|
||||||
|
# header above it is human/dev front-matter.
|
||||||
|
parts = text.split("\n---\n", 1)
|
||||||
|
body = parts[1] if len(parts) == 2 else text
|
||||||
|
return body.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def render_digest(canon_log: dict) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
location = canon_log.get("location", {})
|
||||||
|
lines.append(f"Location: {location.get('name', 'an unknown place')}")
|
||||||
|
|
||||||
|
events = canon_log.get("recent_events", [])
|
||||||
|
if events:
|
||||||
|
lines.append("Recent events:")
|
||||||
|
lines += [f"- {e}" for e in events]
|
||||||
|
|
||||||
|
facts = canon_log.get("established_facts", [])
|
||||||
|
if facts:
|
||||||
|
lines.append("Established facts:")
|
||||||
|
lines += [f"- {f}" for f in facts]
|
||||||
|
|
||||||
|
party = canon_log.get("party", [])
|
||||||
|
if party:
|
||||||
|
lines.append("Companions present: " + ", ".join(m.get("name", "") for m in party))
|
||||||
|
|
||||||
|
for quest in canon_log.get("active_quests", []):
|
||||||
|
if quest.get("status") == "active":
|
||||||
|
lines.append(f"Active quest: {quest.get('name', '')} — {quest.get('objective', '')}")
|
||||||
|
|
||||||
|
player = canon_log.get("player", {})
|
||||||
|
descriptor = player.get("luck_descriptor")
|
||||||
|
if descriptor:
|
||||||
|
lines.append(f"Fortune: {descriptor}")
|
||||||
|
lines.append(f"Player: {player.get('name', '')}, a {player.get('class_id', '')}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Describe the scene as it is now.")
|
||||||
|
return "\n".join(lines)
|
||||||
35
api/tests/test_prompts.py
Normal file
35
api/tests/test_prompts.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.prompts import render_digest, system_prompt
|
||||||
|
|
||||||
|
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_prompt_strips_the_header():
|
||||||
|
body = system_prompt("narrator")
|
||||||
|
assert isinstance(body, str) and body.strip() != ""
|
||||||
|
# The human-facing markdown title above the '---' is not part of the prompt.
|
||||||
|
assert "# Narrator prompt" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_has_the_narrative_context():
|
||||||
|
d = render_digest(VALID)
|
||||||
|
assert "Location: the Greywater docks" in d
|
||||||
|
assert "Arrived at Greywater by barge before dawn" in d
|
||||||
|
assert "Established facts:" in d
|
||||||
|
assert "the harbourmaster is named Oda Fenn" in d
|
||||||
|
assert "Companions present: Brannoc Thane, Cadwyn Vell" in d
|
||||||
|
assert "Active quest: The Missing Ledger — Find who took Fenn's ledger" in d
|
||||||
|
assert "Fortune: Fortune spits on you" in d
|
||||||
|
assert "Player: Aldric, a sellsword" in d
|
||||||
|
assert d.rstrip().endswith("Describe the scene as it is now.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_omits_raw_numbers_and_ids():
|
||||||
|
d = render_digest(VALID)
|
||||||
|
# §7/§2: no disposition integers, no ids, no schema_version leak into the prompt.
|
||||||
|
assert "disposition" not in d
|
||||||
|
assert "40" not in d and "15" not in d # the deserter dispositions
|
||||||
|
assert "brannoc_thane" not in d # ids stay out; names only
|
||||||
|
assert "schema_version" not in d
|
||||||
Reference in New Issue
Block a user