diff --git a/backend/app/context/builder.py b/backend/app/context/builder.py index 8fa98b4..f84af84 100644 --- a/backend/app/context/builder.py +++ b/backend/app/context/builder.py @@ -56,6 +56,24 @@ def _script_memory(adventure: models.Adventure) -> dict: return memory if isinstance(memory, dict) else {} +def _history_text(action: models.Action) -> str: + """An AI turn as the model should see it in replayed history: its narration + with the state block it emitted re-appended (reconstructed from the stored + delta). The block is stripped before storage/UI, so without this every past + AI turn would look like one that emitted nothing — biasing the model, by + imitation, to stop emitting too. Player turns and blockless turns are + returned unchanged.""" + text = action.text + snap = action.context_snapshot if isinstance(action.context_snapshot, dict) else None + if snap: + ws = snap.get("world_state") + if isinstance(ws, dict): + block = worldstate.render_delta_block(ws.get("delta") or {}) + if block: + text = f"{text}\n{block}" + return text + + def _visible_npcs(adventure: models.Adventure, stat_schema: dict) -> dict[str, str]: """Defined NPCs whose trigger words appear in the recent story — the ones "in scene", so only their stats get injected. Maps npc id -> display name.""" @@ -99,7 +117,8 @@ def build_context( # RPG world state (Phase 12): current stats/milestones + how to report changes. stat_schema = adventure.scenario.stat_schema if adventure.scenario else None - if worldstate.has_schema(stat_schema): + has_ws = worldstate.has_schema(stat_schema) + if has_ws: guide = worldstate.render_reference(stat_schema) if guide: system_sections.append(Section("world_state_guide", guide)) @@ -139,6 +158,7 @@ def build_context( sum(s.tokens for s in system_sections) + count_tokens(authors_note) + count_tokens(front_memory) + + (count_tokens(worldstate.EMIT_REMINDER) if has_ws else 0) ) available = max(256, settings.context_token_budget - reserved) @@ -171,7 +191,10 @@ def build_context( spent = 0 oldest_truncated = False for action in reversed(actions): - tokens = count_tokens(action.text) + count_tokens(SEPARATOR) + # Budget on the text as it will actually appear — with the re-attached + # state block (B) when this adventure tracks world state. + rendered = _history_text(action) if has_ws else action.text + tokens = count_tokens(rendered) + count_tokens(SEPARATOR) if spent + tokens > history_budget: if not included_actions: # Even the newest action alone is over budget: hard-truncate it. @@ -189,7 +212,9 @@ def build_context( included_actions.reverse() # ----- Assemble story text with author's note near the end ----- - texts = [a.text for a in included_actions] + # Re-attach each AI turn's state block (stripped before storage) so recent + # history shows the model its own emit pattern to imitate. + texts = [_history_text(a) if has_ws else a.text for a in included_actions] note_sections: list[Section] = [] if authors_note: pos = max(0, len(texts) - AUTHORS_NOTE_DEPTH) @@ -202,6 +227,10 @@ def build_context( note_sections.append(Section("history", SEPARATOR.join(texts))) if front_memory: note_sections.append(Section("front_memory", front_memory)) + if has_ws: + # Terminal reminder: the emit rule sits up in the system block, far from + # where the model generates; repeat it last, in the strongest recency slot. + note_sections.append(Section("world_state_reminder", worldstate.EMIT_REMINDER)) story_sections = [s for s in note_sections if s.text] system_text = SEPARATOR.join(s.text for s in system_sections if s.text) diff --git a/backend/app/worldstate/__init__.py b/backend/app/worldstate/__init__.py index 009d512..54b83fd 100644 --- a/backend/app/worldstate/__init__.py +++ b/backend/app/worldstate/__init__.py @@ -2,6 +2,7 @@ module validates and clamps them against a scenario's stat_schema.""" from .engine import ( + EMIT_REMINDER, EMIT_RULE, apply_delta, apply_override, @@ -11,11 +12,13 @@ from .engine import ( instantiate, npc_name, npc_triggers, + render_delta_block, render_reference, render_state_section, ) __all__ = [ + "EMIT_REMINDER", "EMIT_RULE", "apply_delta", "apply_override", @@ -25,6 +28,7 @@ __all__ = [ "instantiate", "npc_name", "npc_triggers", + "render_delta_block", "render_reference", "render_state_section", ] diff --git a/backend/app/worldstate/engine.py b/backend/app/worldstate/engine.py index 4b88edf..ed2faf3 100644 --- a/backend/app/worldstate/engine.py +++ b/backend/app/worldstate/engine.py @@ -45,6 +45,22 @@ EMIT_RULE = ( '"player.outfit": "torn traveling cloak"}\n```' ) +# Short terminal reminder placed at the very end of the prompt (strongest +# recency position) so the emit rule is fresh right where the model generates. +EMIT_REMINDER = ( + "[Reminder: end your reply with a ```state block of the changes this turn " + "(deltas only), or omit it if truly nothing changed.]" +) + + +def render_delta_block(delta: dict) -> str: + """Render a stored delta back into the fenced `state` block the AI emitted, + for re-injecting past turns into context so the model imitates the format. + Empty delta -> empty string (the turn legitimately changed nothing).""" + if not isinstance(delta, dict) or not delta: + return "" + return "```state\n" + json.dumps(delta, ensure_ascii=False) + "\n```" + # ```state { ... } ``` (also tolerates ```json or an unlabelled fence); DOTALL. _FENCE_RE = re.compile(r"```(?:state|json)?\s*(\{.*?\})\s*```", re.DOTALL | re.IGNORECASE) # Fallback: a bare JSON object hugging the end of the text. diff --git a/backend/tests/test_worldstate.py b/backend/tests/test_worldstate.py index dec103f..23736ae 100644 --- a/backend/tests/test_worldstate.py +++ b/backend/tests/test_worldstate.py @@ -243,3 +243,20 @@ def test_band_label(): assert w.band_label(d, 10) == "very weak" assert w.band_label(d, 55) == "minor damage" assert w.band_label(d, 100) == "full health" # inclusive top edge + + +def test_render_delta_block_roundtrips_through_extract(): + # A rendered block re-injected into history must parse back to the same delta, + # so the model sees valid examples of its own emit format. + delta = {"player.hp": -15, "milestones.escaped": True} + block = w.render_delta_block(delta) + assert block.startswith("```state") + clean, parsed = w.extract_delta(f"You flee into the night.\n{block}") + assert clean == "You flee into the night." + assert parsed == delta + + +def test_render_delta_block_empty_is_blank(): + # No change this turn -> nothing appended to history. + assert w.render_delta_block({}) == "" + assert w.render_delta_block(None) == ""