feat(narrative): orchestrate round translation with state updates

translate_round() ties together:
  - reading the round's actions from actions.jsonl
  - generating prose via the LLM
  - updating per-character emotional state
  - persisting the beat, characters, and file offset

Full test suite now at 19/19 passing across action mapper, character
engine, story store, and translator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
anadoris007 2026-04-20 22:01:19 +05:30
parent 9db7b399cc
commit caec8b5c77
2 changed files with 93 additions and 0 deletions

View File

@ -183,3 +183,57 @@ def generate_prose(actions: list, characters: list, tone: str, previous_beats: l
previous=prev_prose or "(this is the first scene)",
)
return call_llm(prompt)
# ---------------------------------------------------------------------------
# Round orchestration — the entry point most callers use
# ---------------------------------------------------------------------------
def translate_round(sim_dir: str, platform: str, target_round: int, tone: str = "neutral") -> dict:
"""Translate a single simulation round into a story beat end-to-end.
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.
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
store = StoryStore(sim_dir)
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()
previous_beats = store.get_all_beats()
prose = generate_prose(actions, characters, tone, previous_beats)
# De-duplicated list of character names that acted this round
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}
for action in actions:
char = char_by_name.get(action.get("agent_name"))
if char:
apply_action_emotional_delta(char, action.get("action_type", ""))
store.save_characters(list(char_by_name.values()))
beat = {
"round": target_round,
"prose": prose,
"characters": involved,
"action_count": len(actions),
"platform": platform,
}
store.append_beat(beat)
store.set_file_offset(platform, new_offset)
return beat

View File

@ -79,3 +79,42 @@ def test_generate_prose_calls_llm_with_context():
def test_generate_prose_empty_actions_returns_placeholder():
result = generate_prose([], [], tone="any", previous_beats=[])
assert "quiet" in result.lower() or "pause" in result.lower()
# ---- Round orchestration tests ----
from app.services.narrative.narrative_translator import translate_round
from app.services.narrative.story_store import StoryStore
def test_translate_round_produces_beat(tmp_path):
sim_dir = str(tmp_path / "sim_test")
os.makedirs(sim_dir)
platform_dir = os.path.join(sim_dir, "twitter")
os.makedirs(platform_dir)
actions_path = os.path.join(platform_dir, "actions.jsonl")
lines = [
{"round": 1, "agent_id": 1, "agent_name": "Alice", "action_type": "CREATE_POST",
"action_args": {"content": "Hi"}, "success": True, "timestamp": "t"},
{"event_type": "round_end", "round": 1, "timestamp": "t"},
]
with open(actions_path, "w") as f:
for line in lines:
f.write(json.dumps(line) + "\n")
store = StoryStore(sim_dir)
store.save_characters([{
"id": "1", "name": "Alice",
"emotional_state": {"current": {k: 0.0 for k in ["anger","fear","joy","sadness","trust","surprise"]}},
}])
with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm:
mock_llm.return_value = "Alice spoke into the void."
beat = translate_round(sim_dir, platform="twitter", target_round=1, tone="neutral")
assert beat["round"] == 1
assert beat["prose"] == "Alice spoke into the void."
assert "Alice" in beat.get("characters", [])
stored_beats = store.get_all_beats()
assert len(stored_beats) == 1