"""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: if not word: return "a" 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", "") parts = [p for p in (race, calling) if p] if not parts: return name descriptor = " ".join(parts) return f"{name}, {_article(parts[0])} {descriptor}".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)