Spaces:
Running on Zero
Running on Zero
| """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) | |