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) <noreply@anthropic.com>
This commit is contained in:
anadoris007 2026-04-20 21:29:15 +05:30
parent 6b9e6eeeb2
commit 052fd87acd
2 changed files with 105 additions and 0 deletions

View File

@ -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

View File

@ -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