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:
parent
a569976d55
commit
d3bec344c7
|
|
@ -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()))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue