feat(narrative): extend translator with world context + dead filter

- PROSE_PROMPT_TEMPLATE extended with world_rules, world_events,
  world_locations, and event_enforcement substitution fields
- EVENT_ENFORCEMENT_STRENGTH set to "hard" — opening line MUST
  reference the most recent world event (aligns with existing
  punchy, cinematic voice)
- Character summaries now resolve location id → name for prompts
- translate_round loads world_state and filters dead characters
  from both the roster and the action list before prose generation
- User-supplied strings (rules, events, locations) brace-escaped
  before str.format() to prevent KeyError on stray { or }

Tests: 36/36 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
anadoris007 2026-04-22 14:54:22 +05:30
parent a569976d55
commit d3bec344c7
2 changed files with 127 additions and 16 deletions

View File

@ -11,6 +11,38 @@ from typing import List, Tuple
from app.services.narrative.action_mapper import get_narrative_context
def _escape_braces(text: str) -> str:
"""Escape { and } in user-supplied strings before str.format()."""
return text.replace("{", "{{").replace("}", "}}")
def _format_world_rules(world: dict) -> str:
rules = world.get("rules", [])
if not rules:
return "(none)"
return "; ".join(_escape_braces(r) for r in rules)
def _format_world_events(world: dict) -> str:
events = world.get("event_log", [])[-3:]
if not events:
return "(none)"
return "\n ".join(
f"(Round {e.get('round', '?')}) {_escape_braces(e.get('description', ''))}"
for e in events
)
def _format_world_locations(world: dict) -> str:
locs = list(world.get("locations", {}).values())[:5]
if not locs:
return "(none)"
return "\n ".join(
f"{_escape_braces(l.get('name', ''))}{_escape_braces(l.get('description', ''))}"
for l in locs
)
def read_actions_for_round(
jsonl_path: str, start_offset: int, target_round: int
) -> Tuple[List[dict], int]:
@ -84,11 +116,18 @@ def call_llm(prompt: str) -> str:
)
def _format_character_summary(character: dict) -> str:
def _format_character_summary(character: dict, locations: dict | None = None) -> str:
emotions = character.get("emotional_state", {}).get("current", {})
top = sorted(emotions.items(), key=lambda kv: -kv[1])[:2]
emo_str = ", ".join(f"{e[0]}={e[1]:.1f}" for e in top) or "neutral"
return f"{character.get('name', 'Unknown')} (feeling: {emo_str})"
loc_id = character.get("location")
loc_str = ""
if loc_id and locations and loc_id in locations:
loc_str = f" at {_escape_braces(locations[loc_id].get('name', loc_id))}"
name = _escape_braces(character.get("name", "Unknown"))
return f"{name} (feeling: {emo_str}){loc_str}"
def _format_action_line(action: dict) -> str:
@ -125,10 +164,35 @@ def _format_action_line(action: dict) -> str:
# A temporary minimal template is used below to make tests pass — REPLACE
# WITH YOUR OWN DESIGN to dial in story quality.
# ============================================================================
# ============================================================================
# USER CONTRIBUTION POINT — how strongly to force world events into prose
# ============================================================================
# Three supported values:
# "soft" — "consider referencing the most recent world event if it fits"
# "medium" — "weave it in OR acknowledge its aftermath. Do not ignore it."
# "hard" — "the opening line MUST reference the most recent world event"
# Temporary default is "medium" — change after reviewing sample output.
# ============================================================================
EVENT_ENFORCEMENT_STRENGTH = "hard"
_ENFORCEMENT_PHRASES = {
"soft": "- Consider referencing the most recent world event if it fits naturally.",
"medium": "- Weave the most recent world event in, OR acknowledge its aftermath. Do not ignore it.",
"hard": "- The OPENING LINE of this passage MUST reference the most recent world event.",
}
PROSE_PROMPT_TEMPLATE = """You are a screenwriter-turned-novelist. Your voice is PUNCHY, CINEMATIC, and DIALOGUE-DRIVEN.
Tone: {tone}
World grounding:
Rules: {world_rules}
Recent events:
{world_events}
Known locations:
{world_locations}
Previous scene:
{previous}
@ -155,6 +219,8 @@ CINEMATIC DETAIL
- Open on a concrete visual: a hand, a face, an object, the weather.
- One sharp sensory detail per paragraph. Not five.
- Cut hard between beats no connective "meanwhile" or "then."
- If a character has a known location, root the scene there.
{event_enforcement}
EMOTION
- Show it in bodies and voice clenched jaw, dropped eyes, half-smile, a pause too long.
@ -167,20 +233,32 @@ Write the prose only. No headings, no preamble, no meta commentary."""
# ============================================================================
def generate_prose(actions: list, characters: list, tone: str, previous_beats: list) -> str:
def generate_prose(actions: list, characters: list, tone: str,
previous_beats: list, world: dict | None = None) -> str:
"""Generate a narrative passage from a round's actions via the LLM."""
if not actions:
return "A quiet pause settles over the scene. No one acts; no one speaks."
char_summaries = "\n".join(_format_character_summary(c) for c in characters)
world = world or {"rules": [], "locations": {}, "event_log": []}
locations = world.get("locations", {})
char_summaries = "\n".join(_format_character_summary(c, locations) for c in characters)
action_lines = "\n".join(_format_action_line(a) for a in actions)
prev_prose = "\n\n".join(b.get("prose", "") for b in previous_beats[-2:])
prev_prose = "\n\n".join(_escape_braces(b.get("prose", "")) for b in previous_beats[-2:])
enforcement = _ENFORCEMENT_PHRASES.get(
EVENT_ENFORCEMENT_STRENGTH, _ENFORCEMENT_PHRASES["medium"]
)
prompt = PROSE_PROMPT_TEMPLATE.format(
tone=tone,
tone=_escape_braces(tone),
world_rules=_format_world_rules(world),
world_events=_format_world_events(world),
world_locations=_format_world_locations(world),
characters=char_summaries or "(none)",
actions=action_lines,
previous=prev_prose or "(this is the first scene)",
event_enforcement=enforcement,
)
return call_llm(prompt)
@ -195,35 +273,44 @@ def translate_round(sim_dir: str, platform: str, target_round: int, tone: str =
Steps:
1. Read the round's actions from `{sim_dir}/{platform}/actions.jsonl`
(starting from the saved file offset).
2. Generate prose via the LLM using current character state.
3. Apply emotional-state deltas for every action.
4. Persist the new beat, updated character state, and new file offset.
2. Load world state (rules, locations, event log) empty if not present.
3. Filter dead characters and their actions out before prose generation.
4. Generate prose via the LLM using current character + world state.
5. Apply emotional-state deltas for every action to living characters.
6. Persist the new beat, updated character state, and new file offset.
Returns the newly created story beat dict.
"""
# Lazy imports keep module import lightweight for unit tests of helpers
from app.services.narrative.story_store import StoryStore
from app.services.narrative.character_engine import apply_action_emotional_delta
from app.services.narrative.world_state import WorldStateStore
store = StoryStore(sim_dir)
world = WorldStateStore(sim_dir).load()
actions_path = os.path.join(sim_dir, platform, "actions.jsonl")
start_offset = store.get_file_offset(platform)
actions, new_offset = read_actions_for_round(actions_path, start_offset, target_round)
characters = store.load_characters()
# Filter dead characters and their actions
all_characters = store.load_characters()
alive_names = {c["name"] for c in all_characters if c.get("status", "alive") != "dead"}
living_characters = [c for c in all_characters if c["name"] in alive_names]
actions = [a for a in actions if a.get("agent_name") in alive_names]
previous_beats = store.get_all_beats()
prose = generate_prose(actions, living_characters, tone, previous_beats, world)
prose = generate_prose(actions, characters, tone, previous_beats)
# De-duplicated list of character names that acted this round
# De-duplicated list of character names that acted this round (living only)
involved = sorted({a.get("agent_name") for a in actions if a.get("agent_name")})
# Mutate character emotional state in-place, then persist
char_by_name = {c["name"]: c for c in characters}
# Apply emotional deltas only to living characters
char_by_name = {c["name"]: c for c in all_characters}
for action in actions:
char = char_by_name.get(action.get("agent_name"))
if char:
if char and char.get("status", "alive") != "dead":
apply_action_emotional_delta(char, action.get("action_type", ""))
store.save_characters(list(char_by_name.values()))

View File

@ -118,3 +118,27 @@ def test_translate_round_produces_beat(tmp_path):
stored_beats = store.get_all_beats()
assert len(stored_beats) == 1
def test_generate_prose_includes_world_context():
actions = [{"agent_name": "Alice", "action_type": "CREATE_POST", "action_args": {}}]
characters = [
{"id": "1", "name": "Alice", "status": "alive", "location": "tower",
"emotional_state": {"current": {"anger": 0, "fear": 0, "joy": 0,
"sadness": 0, "trust": 0.5, "surprise": 0}}},
]
world = {
"rules": ["Magic is forbidden", "Winter is near"],
"locations": {"tower": {"id": "tower", "name": "The Tower", "description": "tall"}},
"event_log": [{"id": "evt_1", "round": 1, "type": "god_mode_injection",
"description": "A stranger arrived."}],
}
with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm:
mock_llm.return_value = "prose"
generate_prose(actions, characters, tone="noir", previous_beats=[], world=world)
prompt = mock_llm.call_args[0][0]
assert "Magic is forbidden" in prompt
assert "A stranger arrived" in prompt
assert "The Tower" in prompt