diff --git a/backend/app/services/narrative/narrative_translator.py b/backend/app/services/narrative/narrative_translator.py index 9f432745..773fe5d2 100644 --- a/backend/app/services/narrative/narrative_translator.py +++ b/backend/app/services/narrative/narrative_translator.py @@ -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 diff --git a/backend/tests/test_narrative_translator.py b/backend/tests/test_narrative_translator.py index f9b2397d..71c19b50 100644 --- a/backend/tests/test_narrative_translator.py +++ b/backend/tests/test_narrative_translator.py @@ -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