From 4d8a19353e994ffe2ea5c47e045fa99cad0b8893 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 08:11:57 -0500 Subject: [PATCH] feat(api): prompt loader (header split) + curated canon-log digest Co-Authored-By: Claude Opus 4.8 (1M context) --- api/app/prompts.py | 57 +++++++++++++++++++++++++++++++++++++++ api/tests/test_prompts.py | 35 ++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 api/app/prompts.py create mode 100644 api/tests/test_prompts.py diff --git a/api/app/prompts.py b/api/app/prompts.py new file mode 100644 index 0000000..f334ea5 --- /dev/null +++ b/api/app/prompts.py @@ -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) diff --git a/api/tests/test_prompts.py b/api/tests/test_prompts.py new file mode 100644 index 0000000..092f119 --- /dev/null +++ b/api/tests/test_prompts.py @@ -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