"""assemble_context — the single source of truth for prompt assembly. This pure module turns stored state (sheet JSON + daily_context + UI payload + a windowed turn log) into the one text block — the **card** — handed to the model each turn. It is imported by BOTH the data-generation pipeline and the live Gradio runtime, so train/serve parity is guaranteed by *the same code running both times*, not by discipline (DECISIONS §15, §16). It does only stripping, simple formatting, and concatenation — **no arithmetic** (the stat-flatten layer is gone, DECISIONS §9). Nothing ever machine-parses the card; the model only reads it. The thing that gets parsed is the model's output. Card layout is locked in DECISIONS §16a (normal turn) and §16b (turn-0 opening); this module reproduces those byte-for-byte and is pinned by the golden test in test_assemble_context.py. Note on windowing: the turn log passed in is assumed already windowed to the last N turns by the caller. Window size N is still open (DECISIONS §15 open items), so it is deliberately NOT baked in here yet. """ # Fixed stat order — render is independent of dict insertion order (DECISIONS §3). STAT_ORDER = ("might", "speed", "smarts", "mystique") # resolved-branch -> the word used in a roll log tag (DECISIONS §16a). RESULT_WORD = {"success": "succeeded", "fail": "failed"} # Turn-0 instruction line, verbatim from DECISIONS §16b (two physical lines). OPENING_INSTRUCTION = ( "Create a brief, evocative, thematic description of the forest on this new day\n" "and how the bug experiences it, using these details:" ) def _cap_first(text): """Capitalize only the first character, preserving the rest verbatim.""" return text[:1].upper() + text[1:] def _bug_name(bug_id): """Roster id -> display name: 'stag_beetle' -> 'Stag Beetle'.""" return bug_id.replace("_", " ").title() def render_sheet(sheet): """PLAYER block: header, stats, persona (DECISIONS §16a). Stats render exactly as stored — there is no modifier math (§9). Abilities are NOT listed here; the model only sees one when narrating its use (§16a). """ name = _bug_name(sheet["bug"]) hp = sheet["hp"] moxie = sheet["moxie"] header = ( f"{name}, Level {sheet['level']} — " f"HP {hp['cur']}/{hp['max']} · Moxie {moxie['cur']}/{moxie['max']}" ) stats = sheet["stats"] stat_line = " · ".join(f"{s.capitalize()} {stats[s]}" for s in STAT_ORDER) return f"PLAYER\n{header}\n{stat_line}\n{sheet['persona']}" def render_day(daily_context): """THE WORLD block — renders on every card, not just turn 0 (DECISIONS §16a).""" dc = daily_context return ( "THE WORLD\n" f"{_cap_first(dc['phase'])}, {dc['season']}. " f"{_cap_first(dc['weather'])}. " f"{_cap_first(dc['mood'])} — {dc['event']}." ) def render_log_entry(entry): """One log line. `kind` discriminates the four line shapes (DECISIONS §16a).""" kind = entry["kind"] if kind == "player": return f"[player] {entry['text']}" if kind == "player_ability": # Compressed marker once an ability use has scrolled into the log (§16a). return f"[player · used {entry['ability']}]" if kind == "dm": return f"[dm] {entry['text']}" if kind == "dm_roll": stat = entry["stat"].capitalize() band = entry["band"].capitalize() result = RESULT_WORD[entry["result"]] return f"[dm · {stat} check, {band}, {result}] {entry['text']}" raise ValueError(f"unknown log entry kind: {kind!r}") def render_log(log): """WHAT JUST HAPPENED block — the windowed turn log as labeled lines.""" lines = ["WHAT JUST HAPPENED"] lines.extend(render_log_entry(e) for e in log) return "\n".join(lines) def render_now(user_input, sheet): """PLAYER'S TURN block: the declared action, plus any ability-use affordance. The full second-person ability `description` is surfaced here at the trigger moment as a narration affordance (DECISIONS §16a); it is looked up from the sheet so no description text rides in the UI payload. """ lines = ["PLAYER'S TURN", f"[player] {user_input['message']}"] abilities_by_name = {a["name"]: a for a in sheet.get("abilities", [])} for name in user_input.get("activations", []): description = abilities_by_name[name]["description"] lines.append(f"[used ability: {name} — {description}]") return "\n".join(lines) def assemble_context( *, sheet, daily_context, system_prompt, log=None, user_input=None, opening=False, ): """Assemble the full card handed to the model this turn. Sections are joined by blank lines. Two shapes (DECISIONS §16a/§16b): - normal turn: PLAYER · THE WORLD · WHAT JUST HAPPENED · PLAYER'S TURN - opening (turn 0, `opening=True`): PLAYER · instruction line · THE WORLD, with no log and no player action. The frozen serve system prompt is embedded verbatim as the first block; it is the same string in every training row and every live turn (PIPELINE-PLAN §3). """ blocks = [system_prompt, render_sheet(sheet)] if opening: blocks.append(OPENING_INSTRUCTION) blocks.append(render_day(daily_context)) else: blocks.append(render_day(daily_context)) blocks.append(render_log(log or [])) blocks.append(render_now(user_input, sheet)) return "\n\n".join(blocks)