diff --git a/backend/app/services/narrative/narrative_translator.py b/backend/app/services/narrative/narrative_translator.py new file mode 100644 index 00000000..f6ca2839 --- /dev/null +++ b/backend/app/services/narrative/narrative_translator.py @@ -0,0 +1,60 @@ +"""Reads OASIS actions.jsonl and translates rounds into story prose. + +The translator is stateless about which round comes next — callers maintain +the file offset via StoryStore. Each call to `read_actions_for_round` reads +from the saved offset until a matching `round_end` event is seen (or EOF). +""" +import os +import json +from typing import List, Tuple + + +def read_actions_for_round( + jsonl_path: str, start_offset: int, target_round: int +) -> Tuple[List[dict], int]: + """Read all agent actions for `target_round` starting at `start_offset`. + + Returns: + (actions, new_offset): list of action dicts and the file position to + resume from on the next read. If the target round hasn't completed yet + (no matching `round_end` event), new_offset is advanced to EOF so the + next call picks up where we left off. + """ + if not os.path.exists(jsonl_path): + return [], start_offset + + actions: List[dict] = [] + new_offset = start_offset + + with open(jsonl_path, "r", encoding="utf-8") as f: + f.seek(start_offset) + while True: + line = f.readline() + if not line: + break + try: + entry = json.loads(line) + except json.JSONDecodeError: + # Skip malformed lines but keep advancing + new_offset = f.tell() + continue + + # Round-end event for our target marks the boundary + if entry.get("event_type") == "round_end" and entry.get("round") == target_round: + new_offset = f.tell() + break + + # Skip other event types (simulation_start, simulation_end, etc.) + if "event_type" in entry: + new_offset = f.tell() + continue + + # Skip actions from other rounds + if entry.get("round") != target_round: + new_offset = f.tell() + continue + + actions.append(entry) + new_offset = f.tell() + + return actions, new_offset diff --git a/backend/tests/test_narrative_translator.py b/backend/tests/test_narrative_translator.py new file mode 100644 index 00000000..750c7843 --- /dev/null +++ b/backend/tests/test_narrative_translator.py @@ -0,0 +1,45 @@ +import os +import json +import tempfile +import pytest +from app.services.narrative.narrative_translator import read_actions_for_round + + +@pytest.fixture +def actions_file(): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "actions.jsonl") + lines = [ + {"round": 1, "agent_id": 1, "agent_name": "Alice", "action_type": "CREATE_POST", + "action_args": {"content": "Hi"}, "success": True, "timestamp": "2026-03-26T12:00:00"}, + {"round": 1, "agent_id": 2, "agent_name": "Bob", "action_type": "LIKE_POST", + "action_args": {"post_id": 1}, "success": True, "timestamp": "2026-03-26T12:00:01"}, + {"event_type": "round_end", "round": 1, "timestamp": "2026-03-26T12:00:02"}, + {"round": 2, "agent_id": 1, "agent_name": "Alice", "action_type": "REPOST", + "action_args": {}, "success": True, "timestamp": "2026-03-26T12:00:03"}, + ] + with open(path, "w") as f: + for line in lines: + f.write(json.dumps(line) + "\n") + yield path + + +def test_read_actions_for_round_1(actions_file): + actions, next_offset = read_actions_for_round(actions_file, start_offset=0, target_round=1) + assert len(actions) == 2 + assert actions[0]["agent_name"] == "Alice" + assert actions[1]["agent_name"] == "Bob" + assert next_offset > 0 + + +def test_read_actions_resumes_from_offset(actions_file): + _, offset_after_round_1 = read_actions_for_round(actions_file, start_offset=0, target_round=1) + actions, _ = read_actions_for_round(actions_file, start_offset=offset_after_round_1, target_round=2) + assert len(actions) == 1 + assert actions[0]["action_type"] == "REPOST" + + +def test_read_actions_missing_file_returns_empty(): + actions, offset = read_actions_for_round("/nonexistent/path.jsonl", start_offset=0, target_round=1) + assert actions == [] + assert offset == 0