From 052fd87acdfee731e2280b6a9b1d089644a7b66a Mon Sep 17 00:00:00 2001 From: anadoris007 Date: Mon, 20 Apr 2026 21:29:15 +0530 Subject: [PATCH] feat(narrative): add actions.jsonl round reader Reads one round's worth of agent actions from the OASIS log file, tracking file offset so callers can resume across translation calls. Handles missing files, malformed JSON lines, and non-target rounds gracefully. Tests: 3/3 passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../narrative/narrative_translator.py | 60 +++++++++++++++++++ backend/tests/test_narrative_translator.py | 45 ++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 backend/app/services/narrative/narrative_translator.py create mode 100644 backend/tests/test_narrative_translator.py 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