# 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 `