Files
code_of_conquest_dnd/api/app/prompts.py
Phillip Tarrant a668cf98eb refactor(contract)!: class_id -> calling_id, and the log gains race_id
class_id is the rulebook's word; the world has callings — that was the whole §17
reconcile. The contract the Narrator reads should say what the world says. There
are no saves (M9) and no deployed clients, so this is as cheap as it will ever be
and strictly more expensive every milestone after.

race_id reaches the log because the AI must be able to describe the player:
api/app/prompts.py rendered 'a sellsword' and now renders 'a beastfolk cutpurse'.
It humanizes the id (the model must never read 'hedge_mage') and picks the right
article ('an elf', not 'a elf').

LogPlayer's hardcoded CLASSES const is deleted — the roster is Callings.IDS. A
parity test reads the JSON Schema from the client and asserts its enum equals the
code, so the two sides of the HTTP boundary cannot drift.

The deserter allowed three callings that no longer exist. He now allows all seven;
a thematic gate is a content decision for the Greywater authoring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:27:56 -05:00

129 lines
4.3 KiB
Python

"""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"
def _humanize(id_: str) -> str:
"""Ids are snake_case; the model should read prose. hedge_mage -> hedge-mage."""
return id_.replace("_", "-")
def _article(word: str) -> str:
return "an" if word[:1].lower() in "aeiou" else "a"
def _describe_player(player: dict) -> str:
"""'Aldric, a beastfolk cutpurse' — what the AI narrates the player as."""
race = _humanize(player.get("race_id", ""))
calling = _humanize(player.get("calling_id", ""))
name = player.get("name", "")
return f"{name}, {_article(race)} {race} {calling}".strip()
@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: {_describe_player(player)}")
lines.append("")
lines.append("Describe the scene as it is now.")
return "\n".join(lines)
def render_npc_digest(
canon_log: dict,
*,
persona: str,
knowledge: list[str],
disposition: int,
available_moves: list[str],
utterance: str,
) -> str:
"""User-message digest for the NPC role (§6). Unlike the narrator digest,
this deliberately carries the disposition integer (§6 permits it; §7 hides
Luck, not disposition) and the closed move vocabulary the NPC may use."""
lines: list[str] = []
lines.append(f"You are: {persona}")
lines.append(
f"Your disposition toward the player: {disposition} "
f"(-100 hostile, 0 neutral, 100 devoted)."
)
if knowledge:
lines.append("")
lines.append("What you know (this is ALL you know — do not invent beyond it):")
lines += [f"- {k}" for k in knowledge]
lines.append("")
lines.append("Moves you may perform this turn (use ONLY these, as [MOVE: name(args)]):")
lines += [f"- {m}" for m in available_moves]
location = canon_log.get("location", {})
lines.append("")
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]
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", {})
lines.append(f"The player is {_describe_player(player)}.")
lines.append("")
lines.append(f'The player says to you: "{utterance}"')
return "\n".join(lines)