From c5cc59905300a016a7911c9a7887ed8798cc1825 Mon Sep 17 00:00:00 2001 From: anadoris007 Date: Mon, 20 Apr 2026 20:41:14 +0530 Subject: [PATCH] docs: add narrative layer design spec and implementation plan - docs/superpowers/specs/2026-03-26-narrative-layer-design.md - docs/superpowers/plans/2026-03-26-narrative-layer-foundation.md Spec extends MiroFish into a creative storytelling platform via a new narrative layer over the existing OASIS simulation engine. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-26-narrative-layer-foundation.md | 1468 +++++++++++++++++ .../2026-03-26-narrative-layer-design.md | 532 ++++++ 2 files changed, 2000 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-26-narrative-layer-foundation.md create mode 100644 docs/superpowers/specs/2026-03-26-narrative-layer-design.md diff --git a/docs/superpowers/plans/2026-03-26-narrative-layer-foundation.md b/docs/superpowers/plans/2026-03-26-narrative-layer-foundation.md new file mode 100644 index 00000000..7bc25459 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-narrative-layer-foundation.md @@ -0,0 +1,1468 @@ +# Narrative Layer Foundation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the foundational narrative layer — translate OASIS simulation actions into story prose, track extended character state, and display the generated story in a new Vue view. + +**Architecture:** New `backend/app/services/narrative/` module with three services (translator, character engine, action mapper). New Flask blueprint at `/api/narrative/*`. New Vue view `StoryTimelineView.vue` polls for narrative updates. All state is file-based under `uploads/simulations/{sim_id}/narrative/`. + +**Tech Stack:** Python 3.11, Flask, Vue 3 + Vite, existing LLM client (`backend/app/utils/llm_client.py`), existing retry utility (`backend/app/utils/retry.py`). + +**Scope boundary:** This plan covers ONLY translator + character engine + timeline view. God Mode, timeline branching, world state, enhanced input, and export are separate follow-on plans. + +--- + +## File Structure + +### New files + +| File | Responsibility | +|---|---| +| `backend/app/services/narrative/__init__.py` | Package init | +| `backend/app/services/narrative/action_mapper.py` | Maps OASIS action types to narrative verbs/interpretations | +| `backend/app/services/narrative/character_engine.py` | Extended character profiles, emotional state, arc detection | +| `backend/app/services/narrative/narrative_translator.py` | Reads actions.jsonl, generates prose via LLM | +| `backend/app/services/narrative/story_store.py` | File I/O for narrative state (story_beats.json, characters.json, translator_state.json) | +| `backend/app/api/narrative.py` | Flask blueprint with narrative endpoints | +| `backend/tests/test_action_mapper.py` | Tests for action mapping | +| `backend/tests/test_character_engine.py` | Tests for character state updates | +| `backend/tests/test_narrative_translator.py` | Tests for translation pipeline | +| `backend/tests/test_story_store.py` | Tests for file I/O | +| `frontend/src/api/narrative.js` | Frontend API client for narrative endpoints | +| `frontend/src/views/StoryTimelineView.vue` | Story reading UI | +| `frontend/src/components/StoryBeat.vue` | Single story paragraph component | +| `frontend/src/components/CharacterCard.vue` | Character summary with emotion indicators | + +### Modified files + +| File | Change | +|---|---| +| `backend/app/api/__init__.py` | Add `narrative_bp` blueprint registration | +| `backend/app/__init__.py` | Register narrative blueprint | +| `frontend/src/router/index.js` | Add `/story/:simId` route | + +--- + +## Task 1: Action Mapper — Static Mapping Table + +**Files:** +- Create: `backend/app/services/narrative/__init__.py` +- Create: `backend/app/services/narrative/action_mapper.py` +- Test: `backend/tests/test_action_mapper.py` + +- [ ] **Step 1: Create empty package init** + +Create `backend/app/services/narrative/__init__.py` with a single empty line. + +- [ ] **Step 2: Write the failing test** + +Create `backend/tests/test_action_mapper.py`: + +```python +from app.services.narrative.action_mapper import map_action_to_verb, get_narrative_context + +def test_create_post_maps_to_speech(): + result = map_action_to_verb("CREATE_POST") + assert result == "speaks" + +def test_like_post_maps_to_agreement(): + result = map_action_to_verb("LIKE_POST") + assert result == "agrees with" + +def test_unknown_action_returns_fallback(): + result = map_action_to_verb("UNKNOWN_ACTION") + assert result == "does something" + +def test_get_narrative_context_returns_interpretation(): + ctx = get_narrative_context("REPOST") + assert "rumor" in ctx.lower() or "amplifies" in ctx.lower() +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/test_action_mapper.py -v` +Expected: FAIL with ImportError (module not yet created). + +- [ ] **Step 4: Implement action_mapper.py** + +Create `backend/app/services/narrative/action_mapper.py`: + +```python +"""Maps OASIS action types to narrative verbs and interpretations.""" + +ACTION_TO_VERB = { + "CREATE_POST": "speaks", + "LIKE_POST": "agrees with", + "REPOST": "spreads word of", + "QUOTE_POST": "responds to", + "FOLLOW": "shows loyalty to", + "DO_NOTHING": "observes in silence", + "CREATE_COMMENT": "engages with", + "DISLIKE_POST": "disapproves of", + "LIKE_COMMENT": "validates", + "DISLIKE_COMMENT": "dismisses", + "SEARCH_POSTS": "investigates", + "SEARCH_USER": "seeks out", + "MUTE": "ignores", +} + +ACTION_TO_NARRATIVE = { + "CREATE_POST": "Character speaks, declares, or announces", + "LIKE_POST": "Character agrees, supports, or nods", + "REPOST": "Character spreads rumor, amplifies, or gossips", + "QUOTE_POST": "Character responds, debates, or challenges", + "FOLLOW": "Character allies with or shows loyalty to", + "DO_NOTHING": "Character reflects, observes, or waits", + "CREATE_COMMENT": "Character engages in dialogue", + "DISLIKE_POST": "Character opposes, confronts, or disapproves", + "LIKE_COMMENT": "Character validates a response", + "DISLIKE_COMMENT": "Character dismisses or mocks", + "SEARCH_POSTS": "Character investigates or seeks information", + "SEARCH_USER": "Character seeks out a specific person", + "MUTE": "Character avoids, ignores, or shuns", +} + + +def map_action_to_verb(action_type: str) -> str: + return ACTION_TO_VERB.get(action_type, "does something") + + +def get_narrative_context(action_type: str) -> str: + return ACTION_TO_NARRATIVE.get(action_type, "Character takes an unknown action") +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd backend && uv run pytest tests/test_action_mapper.py -v` +Expected: 4 passed. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/narrative/__init__.py backend/app/services/narrative/action_mapper.py backend/tests/test_action_mapper.py +git commit -m "feat(narrative): add action-to-verb mapping for OASIS actions" +``` + +--- + +## Task 2: Story Store — File I/O for Narrative State + +**Files:** +- Create: `backend/app/services/narrative/story_store.py` +- Test: `backend/tests/test_story_store.py` + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_story_store.py`: + +```python +import os +import tempfile +import pytest +from app.services.narrative.story_store import StoryStore + +@pytest.fixture +def temp_sim_dir(): + with tempfile.TemporaryDirectory() as d: + sim_dir = os.path.join(d, "sim_test123") + os.makedirs(sim_dir) + yield sim_dir + +def test_save_and_load_story_beats(temp_sim_dir): + store = StoryStore(temp_sim_dir) + beat = {"round": 1, "prose": "Elena spoke.", "characters": ["elena"]} + store.append_beat(beat) + + beats = store.get_all_beats() + assert len(beats) == 1 + assert beats[0]["prose"] == "Elena spoke." + +def test_translator_state_tracks_offset(temp_sim_dir): + store = StoryStore(temp_sim_dir) + assert store.get_file_offset("twitter") == 0 + + store.set_file_offset("twitter", 1024) + assert store.get_file_offset("twitter") == 1024 + +def test_get_beat_by_round(temp_sim_dir): + store = StoryStore(temp_sim_dir) + store.append_beat({"round": 1, "prose": "First"}) + store.append_beat({"round": 2, "prose": "Second"}) + + beat = store.get_beat_by_round(2) + assert beat["prose"] == "Second" + +def test_narrative_dir_created_on_first_write(temp_sim_dir): + store = StoryStore(temp_sim_dir) + store.append_beat({"round": 1, "prose": "test"}) + + narrative_dir = os.path.join(temp_sim_dir, "narrative") + assert os.path.isdir(narrative_dir) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/test_story_store.py -v` +Expected: FAIL with ImportError. + +- [ ] **Step 3: Implement story_store.py** + +Create `backend/app/services/narrative/story_store.py`: + +```python +"""File-based persistence for narrative state.""" +import os +import json +from typing import Optional + + +class StoryStore: + """Manages narrative/*.json files for a single simulation.""" + + def __init__(self, sim_dir: str): + self.sim_dir = sim_dir + self.narrative_dir = os.path.join(sim_dir, "narrative") + self.beats_path = os.path.join(self.narrative_dir, "story_beats.json") + self.translator_state_path = os.path.join(self.narrative_dir, "translator_state.json") + self.characters_path = os.path.join(self.narrative_dir, "characters.json") + + def _ensure_dir(self): + os.makedirs(self.narrative_dir, exist_ok=True) + + def append_beat(self, beat: dict) -> None: + self._ensure_dir() + beats = self.get_all_beats() + beats.append(beat) + with open(self.beats_path, "w", encoding="utf-8") as f: + json.dump(beats, f, ensure_ascii=False, indent=2) + + def get_all_beats(self) -> list: + if not os.path.exists(self.beats_path): + return [] + with open(self.beats_path, "r", encoding="utf-8") as f: + return json.load(f) + + def get_beat_by_round(self, round_num: int) -> Optional[dict]: + for beat in self.get_all_beats(): + if beat.get("round") == round_num: + return beat + return None + + def get_file_offset(self, platform: str) -> int: + if not os.path.exists(self.translator_state_path): + return 0 + with open(self.translator_state_path, "r", encoding="utf-8") as f: + state = json.load(f) + return state.get(f"{platform}_offset", 0) + + def set_file_offset(self, platform: str, offset: int) -> None: + self._ensure_dir() + state = {} + if os.path.exists(self.translator_state_path): + with open(self.translator_state_path, "r", encoding="utf-8") as f: + state = json.load(f) + state[f"{platform}_offset"] = offset + with open(self.translator_state_path, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + + def save_characters(self, characters: list) -> None: + self._ensure_dir() + with open(self.characters_path, "w", encoding="utf-8") as f: + json.dump(characters, f, ensure_ascii=False, indent=2) + + def load_characters(self) -> list: + if not os.path.exists(self.characters_path): + return [] + with open(self.characters_path, "r", encoding="utf-8") as f: + return json.load(f) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && uv run pytest tests/test_story_store.py -v` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/narrative/story_store.py backend/tests/test_story_store.py +git commit -m "feat(narrative): add StoryStore for file-based narrative persistence" +``` + +--- + +## Task 3: Character Engine — Initial Emotional State + +**Files:** +- Create: `backend/app/services/narrative/character_engine.py` +- Test: `backend/tests/test_character_engine.py` + +**Learning mode note:** Step 4 of this task asks **you (the user)** to write the emotional delta rules. Those rules encode your creative judgment about how characters react emotionally — it's the kind of decision that shapes storytelling quality and is better made by a human than an LLM. + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_character_engine.py`: + +```python +from app.services.narrative.character_engine import ( + CharacterEngine, + create_initial_character, + apply_action_emotional_delta, +) + +def test_create_initial_character_has_neutral_emotions(): + char = create_initial_character(char_id="elena", name="Elena Voss") + assert char["emotional_state"]["current"]["anger"] == 0.0 + assert char["emotional_state"]["current"]["joy"] == 0.0 + assert char["emotional_state"]["current"]["trust"] == 0.5 # neutral baseline + +def test_create_initial_character_stores_name_and_id(): + char = create_initial_character(char_id="elena", name="Elena Voss") + assert char["id"] == "elena" + assert char["name"] == "Elena Voss" + +def test_apply_delta_clamps_to_zero_one_range(): + char = create_initial_character(char_id="x", name="X") + # Apply a large positive anger delta + apply_action_emotional_delta(char, "DISLIKE_POST") + apply_action_emotional_delta(char, "DISLIKE_POST") + apply_action_emotional_delta(char, "DISLIKE_POST") + apply_action_emotional_delta(char, "DISLIKE_POST") + apply_action_emotional_delta(char, "DISLIKE_POST") + assert 0.0 <= char["emotional_state"]["current"]["anger"] <= 1.0 + +def test_create_post_increases_confidence_proxy(): + # Speaking out should bump joy slightly (a proxy for confidence) + char = create_initial_character(char_id="x", name="X") + baseline_joy = char["emotional_state"]["current"]["joy"] + apply_action_emotional_delta(char, "CREATE_POST") + assert char["emotional_state"]["current"]["joy"] >= baseline_joy + +def test_dislike_post_increases_anger(): + char = create_initial_character(char_id="x", name="X") + apply_action_emotional_delta(char, "DISLIKE_POST") + assert char["emotional_state"]["current"]["anger"] > 0.0 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/test_character_engine.py -v` +Expected: FAIL with ImportError. + +- [ ] **Step 3: Implement scaffolding with placeholder deltas** + +Create `backend/app/services/narrative/character_engine.py`: + +```python +"""Extended character profiles, emotional state, arc detection.""" +from typing import Dict + + +EMOTIONS = ["anger", "fear", "joy", "sadness", "trust", "surprise"] + +INITIAL_EMOTIONAL_STATE = { + "anger": 0.0, + "fear": 0.0, + "joy": 0.0, + "sadness": 0.0, + "trust": 0.5, + "surprise": 0.0, +} + + +def create_initial_character(char_id: str, name: str, backstory: str = "", + motivations: list = None, personality: list = None) -> dict: + """Build a new character profile with neutral emotional state.""" + return { + "id": char_id, + "name": name, + "backstory": backstory, + "motivations": motivations or [], + "personality_traits": personality or [], + "emotional_state": { + "current": dict(INITIAL_EMOTIONAL_STATE), + "history": [], + }, + "relationships": {}, + "arc": {"archetype": None, "stage": "beginning", "key_moments": []}, + } + + +# >>> USER CONTRIBUTION POINT — see Step 4 <<< +ACTION_EMOTIONAL_DELTAS: Dict[str, Dict[str, float]] = { + # TODO: Fill this in — see Task 3, Step 4 +} + + +def apply_action_emotional_delta(character: dict, action_type: str) -> None: + """Apply an emotional state change based on an action the character took.""" + deltas = ACTION_EMOTIONAL_DELTAS.get(action_type, {}) + current = character["emotional_state"]["current"] + for emotion, delta in deltas.items(): + if emotion in current: + current[emotion] = max(0.0, min(1.0, current[emotion] + delta)) + + +class CharacterEngine: + """Manages character roster for a simulation.""" + + def __init__(self, store): + self.store = store + + def initialize_from_profiles(self, oasis_profiles: list) -> list: + """Bootstrap character roster from OASIS profiles.""" + characters = [] + for profile in oasis_profiles: + char = create_initial_character( + char_id=str(profile.get("user_id", profile.get("id", ""))), + name=profile.get("name", "Unknown"), + ) + characters.append(char) + self.store.save_characters(characters) + return characters +``` + +- [ ] **Step 4: 🎯 USER CONTRIBUTION — Define Emotional Deltas** + +**This is the key creative decision.** The `ACTION_EMOTIONAL_DELTAS` dictionary maps each OASIS action to changes in the character's emotional state. Your choices here directly shape how characters feel and evolve over a story. + +**Open** `backend/app/services/narrative/character_engine.py` and fill in the `ACTION_EMOTIONAL_DELTAS` dictionary. A delta is a dict of `{emotion: float}` where the float is added to the current emotion value (clamped to 0.0–1.0). + +**Guidance — things to consider:** +- `CREATE_POST` (speaking out) — small confidence bump? small anxiety bump? +- `LIKE_POST` (agreeing) — builds trust? slight joy? +- `DISLIKE_POST` (confronting) — anger up, trust down? +- `FOLLOW` (allying) — trust up, maybe joy? +- `REPOST` (spreading rumor) — neutral? or surprise? +- `DO_NOTHING` (observing) — sadness creep? fear creep? + +Suggested range: keep deltas between -0.15 and +0.15 per action (so they accumulate gradually). Example format: + +```python +ACTION_EMOTIONAL_DELTAS: Dict[str, Dict[str, float]] = { + "CREATE_POST": {"joy": 0.05}, + "DISLIKE_POST": {"anger": 0.10, "trust": -0.05}, + # ... fill in the rest for all 13 actions in action_mapper.py +} +``` + +**Why your judgment matters:** These values are a model of human emotional reaction. An LLM would pick generic values; your intuitions as a storyteller will produce richer, more deliberate character arcs. If you want characters who spiral into darkness easily, use bigger negative deltas. If you want resilient characters, use smaller ones. + +Fill in deltas for at least these 7 core actions: `CREATE_POST`, `LIKE_POST`, `DISLIKE_POST`, `REPOST`, `QUOTE_POST`, `FOLLOW`, `DO_NOTHING`. The other 6 can use defaults (empty dicts). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd backend && uv run pytest tests/test_character_engine.py -v` +Expected: 5 passed. If `test_dislike_post_increases_anger` or `test_create_post_increases_confidence_proxy` fails, your deltas may be missing the relevant emotion — adjust. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/narrative/character_engine.py backend/tests/test_character_engine.py +git commit -m "feat(narrative): add CharacterEngine with emotional state tracking" +``` + +--- + +## Task 4: Narrative Translator — Read Actions.jsonl + +**Files:** +- Create: `backend/app/services/narrative/narrative_translator.py` +- Test: `backend/tests/test_narrative_translator.py` + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_narrative_translator.py`: + +```python +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): + # First read round 1 + _, offset_after_round_1 = read_actions_for_round(actions_file, start_offset=0, target_round=1) + # Then read round 2 using that offset + 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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/test_narrative_translator.py -v` +Expected: FAIL with ImportError. + +- [ ] **Step 3: Implement read_actions_for_round** + +Create `backend/app/services/narrative/narrative_translator.py`: + +```python +"""Reads OASIS actions.jsonl and translates them into story prose.""" +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 actions for target_round starting at start_offset. + + Returns (actions, new_offset). new_offset is the file position right after + the round_end event (or EOF if not found yet). + """ + if not os.path.exists(jsonl_path): + return [], start_offset + + actions = [] + new_offset = start_offset + + with open(jsonl_path, "r", encoding="utf-8") as f: + f.seek(start_offset) + while True: + line_start = f.tell() + line = f.readline() + if not line: + break + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + # Round-end event marks boundary + if entry.get("event_type") == "round_end" and entry.get("round") == target_round: + new_offset = f.tell() + break + + # Skip events, other rounds + if "event_type" in entry: + new_offset = f.tell() + continue + if entry.get("round") != target_round: + new_offset = f.tell() + continue + + actions.append(entry) + new_offset = f.tell() + + return actions, new_offset +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && uv run pytest tests/test_narrative_translator.py -v` +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/narrative/narrative_translator.py backend/tests/test_narrative_translator.py +git commit -m "feat(narrative): add actions.jsonl round reader" +``` + +--- + +## Task 5: Narrative Translator — LLM Prose Generation + +**Files:** +- Modify: `backend/app/services/narrative/narrative_translator.py` +- Modify: `backend/tests/test_narrative_translator.py` + +**Learning mode note:** Step 4 of this task asks you to shape the **prose generation prompt**. The prompt is the single biggest lever for story quality. This is where your voice as a storyteller gets encoded. + +- [ ] **Step 1: Add failing test for generate_prose** + +Append to `backend/tests/test_narrative_translator.py`: + +```python +from unittest.mock import patch, MagicMock +from app.services.narrative.narrative_translator import generate_prose + +def test_generate_prose_calls_llm_with_context(): + actions = [ + {"agent_name": "Elena", "action_type": "CREATE_POST", "action_args": {"content": "We must act."}}, + {"agent_name": "Marcus", "action_type": "DISLIKE_POST", "action_args": {}}, + ] + characters = [ + {"id": "1", "name": "Elena", "emotional_state": {"current": {"anger": 0.2, "joy": 0.0, "fear": 0.1, "sadness": 0.0, "trust": 0.5, "surprise": 0.0}}}, + {"id": "2", "name": "Marcus", "emotional_state": {"current": {"anger": 0.5, "joy": 0.0, "fear": 0.0, "sadness": 0.0, "trust": 0.3, "surprise": 0.0}}}, + ] + fake_response = "Elena's voice cut through the silence. Marcus scowled, unmoved." + + with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm: + mock_llm.return_value = fake_response + result = generate_prose(actions, characters, tone="dark political thriller", previous_beats=[]) + + assert result == fake_response + # Verify the prompt included character names and action info + call_args = mock_llm.call_args + prompt = str(call_args) + assert "Elena" in prompt + assert "Marcus" in prompt + +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() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && uv run pytest tests/test_narrative_translator.py::test_generate_prose_calls_llm_with_context -v` +Expected: FAIL with ImportError for `generate_prose` or `call_llm`. + +- [ ] **Step 3: Add LLM client import and prose generator scaffold** + +Append to `backend/app/services/narrative/narrative_translator.py`: + +```python +from app.utils.llm_client import call_llm +from app.services.narrative.action_mapper import get_narrative_context + + +def _format_character_summary(character: dict) -> str: + emotions = character["emotional_state"]["current"] + top_emotions = sorted(emotions.items(), key=lambda kv: -kv[1])[:2] + emo_str = ", ".join(f"{e[0]}={e[1]:.1f}" for e in top_emotions) + return f"{character['name']} (feeling: {emo_str})" + + +def _format_action_line(action: dict) -> str: + name = action.get("agent_name", "Someone") + act = action.get("action_type", "UNKNOWN") + args = action.get("action_args", {}) + content = args.get("content", "") + ctx = get_narrative_context(act) + if content: + return f"- {name}: {ctx}. Content: \"{content}\"" + return f"- {name}: {ctx}" + + +# >>> USER CONTRIBUTION POINT — see Step 4 <<< +PROSE_PROMPT_TEMPLATE = """TODO: the user will define this in Step 4""" + + +def generate_prose(actions: list, characters: list, tone: str, previous_beats: list) -> str: + """Generate a narrative passage from a round's actions.""" + if not actions: + return "A quiet pause settles over the scene. No one acts; no one speaks." + + char_summaries = "\n".join(_format_character_summary(c) for c in characters) + action_lines = "\n".join(_format_action_line(a) for a in actions) + prev_prose = "\n\n".join(b.get("prose", "") for b in previous_beats[-2:]) + + prompt = PROSE_PROMPT_TEMPLATE.format( + tone=tone, + characters=char_summaries, + actions=action_lines, + previous=prev_prose or "(this is the first scene)", + ) + return call_llm(prompt) +``` + +- [ ] **Step 4: 🎯 USER CONTRIBUTION — Design the Prose Prompt** + +**This is the storytelling soul of the system.** Replace the `PROSE_PROMPT_TEMPLATE` with your prompt. The template has 4 substitution fields: `{tone}`, `{characters}`, `{actions}`, `{previous}`. + +**Guidance — key choices:** + +1. **Tense and POV**: Third-person past is most versatile. First-person or present can work but limit you. +2. **Paragraph count**: 2-4 is a good range. Too short = feels like a log. Too long = drags. +3. **Dialogue**: Should the prompt encourage dialogue when actions have `content`? Probably yes. +4. **"Show don't tell"**: Instructing the LLM to show emotions through action/dialogue rather than stating them is a classic writing lever. +5. **Tone consistency**: How hard do you push the tone? A strong instruction like "Every line should feel grim" vs a soft one like "Maintain a grim tone." +6. **Continuity**: Instruct it to continue naturally from `{previous}`. + +**Suggested structure (copy and modify):** + +```python +PROSE_PROMPT_TEMPLATE = """You are a narrative writer working in the tone of {tone}. + +Previous story context: +{previous} + +Characters in this scene: +{characters} + +Events this round: +{actions} + +Write a story passage of 2-4 paragraphs that: +- Uses third-person past tense +- Shows character emotions through action, dialogue, and internal thought (never state emotions directly) +- Weaves the events above into prose — never list them like a log +- Continues naturally from the previous context +- Maintains the tone of {tone} throughout + +Write only the prose. No headings, no preamble.""" +``` + +Feel free to modify heavily — try your own instructions, add more rules, or tighten it. The quality of every generated story depends on this prompt. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd backend && uv run pytest tests/test_narrative_translator.py -v` +Expected: 5 passed (3 from Task 4 + 2 new). + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/narrative/narrative_translator.py backend/tests/test_narrative_translator.py +git commit -m "feat(narrative): add LLM-driven prose generation" +``` + +--- + +## Task 6: Narrative Translator — Translate Round Orchestration + +**Files:** +- Modify: `backend/app/services/narrative/narrative_translator.py` +- Modify: `backend/tests/test_narrative_translator.py` + +- [ ] **Step 1: Add failing test** + +Append to `backend/tests/test_narrative_translator.py`: + +```python +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 l in lines: + f.write(json.dumps(l) + "\n") + + store = StoryStore(sim_dir) + store.save_characters([ + {"id": "1", "name": "Alice", "emotional_state": {"current": {"anger": 0, "fear": 0, "joy": 0, "sadness": 0, "trust": 0.5, "surprise": 0}}}, + ]) + + 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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/test_narrative_translator.py::test_translate_round_produces_beat -v` +Expected: FAIL with ImportError for `translate_round`. + +- [ ] **Step 3: Implement translate_round** + +Append to `backend/app/services/narrative/narrative_translator.py`: + +```python +from app.services.narrative.story_store import StoryStore +from app.services.narrative.character_engine import apply_action_emotional_delta + + +def translate_round(sim_dir: str, platform: str, target_round: int, tone: str = "neutral") -> dict: + """Translate a single round of simulation into a story beat.""" + 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) + + involved = list({a.get("agent_name") for a in actions if a.get("agent_name")}) + + # Update emotional states + char_by_name = {c["name"]: c for c in characters} + for a in actions: + char = char_by_name.get(a.get("agent_name")) + if char: + apply_action_emotional_delta(char, a.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 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && uv run pytest tests/test_narrative_translator.py -v` +Expected: 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/narrative/narrative_translator.py backend/tests/test_narrative_translator.py +git commit -m "feat(narrative): orchestrate round translation with state updates" +``` + +--- + +## Task 7: Flask Blueprint — Narrative API + +**Files:** +- Create: `backend/app/api/narrative.py` +- Modify: `backend/app/api/__init__.py` +- Modify: `backend/app/__init__.py` + +- [ ] **Step 1: Inspect existing API pattern** + +Read `backend/app/api/__init__.py` and `backend/app/api/simulation.py` to understand the existing blueprint registration pattern and response format. + +- [ ] **Step 2: Create narrative.py blueprint** + +Create `backend/app/api/narrative.py`: + +```python +"""Narrative Layer API endpoints.""" +import os +from flask import Blueprint, jsonify, request +from app.services.narrative.story_store import StoryStore +from app.services.narrative.narrative_translator import translate_round +from app.services.narrative.character_engine import CharacterEngine +from app.config import Config + +narrative_bp = Blueprint('narrative', __name__) + + +def _sim_dir(sim_id: str) -> str: + return os.path.join(Config.OASIS_SIMULATION_DATA_DIR, sim_id) + + +@narrative_bp.route('/story/', methods=['GET']) +def get_full_story(sim_id): + store = StoryStore(_sim_dir(sim_id)) + return jsonify({"sim_id": sim_id, "beats": store.get_all_beats()}) + + +@narrative_bp.route('/story//round/', methods=['GET']) +def get_round_story(sim_id, round_num): + store = StoryStore(_sim_dir(sim_id)) + beat = store.get_beat_by_round(round_num) + if not beat: + return jsonify({"error": "Round not translated yet"}), 404 + return jsonify(beat) + + +@narrative_bp.route('/translate', methods=['POST']) +def translate(): + data = request.get_json() + sim_id = data.get('sim_id') + round_num = data.get('round') + platform = data.get('platform', 'twitter') + tone = data.get('tone', 'neutral') + try: + beat = translate_round(_sim_dir(sim_id), platform, round_num, tone) + return jsonify(beat) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@narrative_bp.route('/characters/', methods=['GET']) +def get_characters(sim_id): + store = StoryStore(_sim_dir(sim_id)) + return jsonify({"characters": store.load_characters()}) + + +@narrative_bp.route('/characters//init', methods=['POST']) +def initialize_characters(sim_id): + """Bootstrap characters from existing OASIS profiles.""" + import json + sim_dir = _sim_dir(sim_id) + profiles_path = os.path.join(sim_dir, 'profiles.json') + if not os.path.exists(profiles_path): + return jsonify({"error": "profiles.json not found"}), 404 + with open(profiles_path, 'r', encoding='utf-8') as f: + profiles = json.load(f) + store = StoryStore(sim_dir) + engine = CharacterEngine(store) + characters = engine.initialize_from_profiles(profiles) + return jsonify({"count": len(characters), "characters": characters}) +``` + +- [ ] **Step 3: Register blueprint in __init__.py** + +Modify `backend/app/api/__init__.py` by adding (at the bottom of existing imports): + +```python +from . import narrative +``` + +Then modify `backend/app/__init__.py` to register: + +```python +from app.api.narrative import narrative_bp +app.register_blueprint(narrative_bp, url_prefix='/api/narrative') +``` + +(Find where other blueprints are registered and add this line alongside them.) + +- [ ] **Step 4: Manual smoke test** + +```bash +cd backend && uv run python run.py & +sleep 3 +curl http://localhost:5001/api/narrative/story/nonexistent_sim +# Expected: {"sim_id": "nonexistent_sim", "beats": []} +kill %1 +``` + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/narrative.py backend/app/api/__init__.py backend/app/__init__.py +git commit -m "feat(narrative): add narrative API blueprint" +``` + +--- + +## Task 8: Frontend API Client + +**Files:** +- Create: `frontend/src/api/narrative.js` + +- [ ] **Step 1: Inspect existing API client pattern** + +Read `frontend/src/api/simulation.js` and `frontend/src/api/index.js` to understand the existing axios usage. + +- [ ] **Step 2: Create narrative API client** + +Create `frontend/src/api/narrative.js`: + +```javascript +import api from './index.js' + +export const narrativeAPI = { + getFullStory(simId) { + return api.get(`/narrative/story/${simId}`) + }, + + getRoundStory(simId, roundNum) { + return api.get(`/narrative/story/${simId}/round/${roundNum}`) + }, + + translate(simId, round, platform = 'twitter', tone = 'neutral') { + return api.post('/narrative/translate', { sim_id: simId, round, platform, tone }) + }, + + getCharacters(simId) { + return api.get(`/narrative/characters/${simId}`) + }, + + initCharacters(simId) { + return api.post(`/narrative/characters/${simId}/init`) + }, +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/api/narrative.js +git commit -m "feat(narrative): add frontend API client" +``` + +--- + +## Task 9: StoryBeat Component + +**Files:** +- Create: `frontend/src/components/StoryBeat.vue` + +- [ ] **Step 1: Create component** + +Create `frontend/src/components/StoryBeat.vue`: + +```vue + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/components/StoryBeat.vue +git commit -m "feat(narrative): add StoryBeat component" +``` + +--- + +## Task 10: StoryTimelineView + +**Files:** +- Create: `frontend/src/views/StoryTimelineView.vue` + +- [ ] **Step 1: Create view** + +Create `frontend/src/views/StoryTimelineView.vue`: + +```vue + + + + + +``` + +- [ ] **Step 2: Add route** + +Read `frontend/src/router/index.js`. Then add to its routes array: + +```javascript +{ + path: '/story/:simId', + name: 'story', + component: () => import('../views/StoryTimelineView.vue'), +}, +``` + +- [ ] **Step 3: Manual smoke test** + +```bash +cd frontend && npm run dev +# Open http://localhost:3000/story/any_sim_id +# Expected: "No story yet" message, Refresh/Translate buttons visible +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/views/StoryTimelineView.vue frontend/src/router/index.js +git commit -m "feat(narrative): add StoryTimelineView and route" +``` + +--- + +## Task 11: CharacterCard Component + Integration + +**Files:** +- Create: `frontend/src/components/CharacterCard.vue` +- Modify: `frontend/src/views/StoryTimelineView.vue` + +- [ ] **Step 1: Create CharacterCard** + +Create `frontend/src/components/CharacterCard.vue`: + +```vue + + + + + +``` + +- [ ] **Step 2: Integrate into StoryTimelineView** + +In `frontend/src/views/StoryTimelineView.vue`, add character loading and display. Add to imports: + +```javascript +import CharacterCard from '../components/CharacterCard.vue' +``` + +Add to script setup (after `title`): + +```javascript +const characters = ref([]) +async function loadCharacters() { + try { + const { data } = await narrativeAPI.getCharacters(simId) + characters.value = data.characters || [] + } catch (e) { /* ignore */ } +} +``` + +Change `onMounted(refresh)` to `onMounted(() => { refresh(); loadCharacters() })`. + +Change `translateNext` to reload characters after: add `await loadCharacters()` before `await refresh()`. + +Add this block in template, right after `