you-are-a-bug / assemble_context.py
jnalv's picture
Upload folder using huggingface_hub
1082b22 verified
Raw
History Blame Contribute Delete
5.52 kB
"""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)