diff --git a/backend/app/__init__.py b/backend/app/__init__.py index aba624bba9..ccfc8c606c 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -63,10 +63,11 @@ def log_response(response): return response # 注册蓝图 - from .api import graph_bp, simulation_bp, report_bp + from .api import graph_bp, simulation_bp, report_bp, narrative_bp app.register_blueprint(graph_bp, url_prefix='/api/graph') app.register_blueprint(simulation_bp, url_prefix='/api/simulation') app.register_blueprint(report_bp, url_prefix='/api/report') + app.register_blueprint(narrative_bp, url_prefix='/api/narrative') # 健康检查 @app.route('/health') diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index ffda743a31..bcd6a2787b 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -7,8 +7,10 @@ graph_bp = Blueprint('graph', __name__) simulation_bp = Blueprint('simulation', __name__) report_bp = Blueprint('report', __name__) +narrative_bp = Blueprint('narrative', __name__) from . import graph # noqa: E402, F401 from . import simulation # noqa: E402, F401 from . import report # noqa: E402, F401 +from . import narrative # noqa: E402, F401 diff --git a/backend/app/api/narrative.py b/backend/app/api/narrative.py new file mode 100644 index 0000000000..f7baa66b68 --- /dev/null +++ b/backend/app/api/narrative.py @@ -0,0 +1,180 @@ +"""Narrative Layer API endpoints. + +Routes are attached to the narrative_bp blueprint defined in api/__init__.py. +All endpoints are prefixed with /api/narrative (see app/__init__.py). +""" +import os +import json +from flask import jsonify, request + +from . import narrative_bp +from ..config import Config +from ..services.narrative.story_store import StoryStore +from ..services.narrative.narrative_translator import translate_round +from ..services.narrative.character_engine import CharacterEngine +from ..services.narrative.world_state import WorldStateStore +from ..services.narrative.god_mode import ( + inject_event as gm_inject_event, + modify_emotion as gm_modify_emotion, + kill_character as gm_kill_character, +) + + +def _sim_dir(sim_id: str) -> str: + """Resolve a simulation ID to its on-disk directory.""" + return os.path.join(Config.OASIS_SIMULATION_DATA_DIR, sim_id) + + +@narrative_bp.route('/story/', methods=['GET']) +def get_full_story(sim_id): + """Return every story beat generated so far for this simulation.""" + 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): + """Return a single round's story beat, or 404 if not yet translated.""" + 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(): + """Translate a specific round on demand. + + Body: {"sim_id": str, "round": int, "platform": str = "twitter", "tone": str = "neutral"} + """ + data = request.get_json() or {} + sim_id = data.get('sim_id') + round_num = data.get('round') + platform = data.get('platform', 'twitter') + tone = data.get('tone', 'neutral') + + if not sim_id or round_num is None: + return jsonify({"error": "sim_id and round are required"}), 400 + + try: + beat = translate_round(_sim_dir(sim_id), platform, int(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): + """Return the extended character roster (with emotional state).""" + 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 narrative character profiles from the simulation's OASIS profiles.""" + 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 for this simulation"}), 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}) + + +# --------------------------------------------------------------------------- +# World State endpoints +# --------------------------------------------------------------------------- + +@narrative_bp.route('/world/', methods=['GET']) +def get_world(sim_id): + """Return the full world_state.json for this simulation.""" + store = WorldStateStore(_sim_dir(sim_id)) + return jsonify(store.load()) + + +@narrative_bp.route('/world//rules', methods=['POST']) +def set_rules(sim_id): + """Replace the full rules list. Body: {"rules": [str, ...]}.""" + data = request.get_json() or {} + rules = data.get('rules') + if not isinstance(rules, list): + return jsonify({"error": "rules must be a list of strings"}), 400 + store = WorldStateStore(_sim_dir(sim_id)) + store.set_rules([str(r) for r in rules]) + return jsonify(store.load()) + + +@narrative_bp.route('/world//locations', methods=['POST']) +def upsert_location(sim_id): + """Insert or update a location. Body: {"id", "name", "description"}.""" + data = request.get_json() or {} + if not data.get('id') or not data.get('name'): + return jsonify({"error": "id and name are required"}), 400 + store = WorldStateStore(_sim_dir(sim_id)) + loc = store.upsert_location({ + "id": str(data['id']), + "name": str(data['name']), + "description": str(data.get('description', '')), + }) + return jsonify(loc) + + +# --------------------------------------------------------------------------- +# God Mode endpoints +# --------------------------------------------------------------------------- + +@narrative_bp.route('/godmode//inject-event', methods=['POST']) +def godmode_inject_event(sim_id): + """Inject a world event. Body: {"description", "round"? (optional, >=0)}.""" + data = request.get_json() or {} + description = data.get('description') + if not description: + return jsonify({"error": "description is required"}), 400 + + round_num = data.get('round') + if round_num is not None: + try: + round_num = int(round_num) + if round_num < 0: + raise ValueError() + except (TypeError, ValueError): + return jsonify({"error": "round must be a non-negative integer"}), 400 + + evt = gm_inject_event(_sim_dir(sim_id), description=str(description), round_num=round_num) + return jsonify(evt) + + +@narrative_bp.route('/godmode//modify-emotion', methods=['POST']) +def godmode_modify_emotion(sim_id): + """Overwrite emotion values. Body: {"character_id", "emotions": {name: float}}.""" + data = request.get_json() or {} + char_id = data.get('character_id') + emotions = data.get('emotions') + if not char_id or not isinstance(emotions, dict): + return jsonify({"error": "character_id and emotions are required"}), 400 + try: + char = gm_modify_emotion(_sim_dir(sim_id), str(char_id), emotions) + except ValueError as e: + return jsonify({"error": str(e)}), 404 + return jsonify(char) + + +@narrative_bp.route('/godmode//kill', methods=['POST']) +def godmode_kill(sim_id): + """Mark a character as dead. Body: {"character_id"}.""" + data = request.get_json() or {} + char_id = data.get('character_id') + if not char_id: + return jsonify({"error": "character_id is required"}), 400 + try: + char = gm_kill_character(_sim_dir(sim_id), str(char_id)) + except ValueError as e: + return jsonify({"error": str(e)}), 404 + return jsonify(char) diff --git a/backend/app/services/narrative/__init__.py b/backend/app/services/narrative/__init__.py new file mode 100644 index 0000000000..3b069736f6 --- /dev/null +++ b/backend/app/services/narrative/__init__.py @@ -0,0 +1 @@ +"""Narrative Layer — translates OASIS simulation output into story prose.""" diff --git a/backend/app/services/narrative/action_mapper.py b/backend/app/services/narrative/action_mapper.py new file mode 100644 index 0000000000..b757550557 --- /dev/null +++ b/backend/app/services/narrative/action_mapper.py @@ -0,0 +1,47 @@ +"""Maps OASIS action types to narrative verbs and interpretations. + +Used by the Narrative Translator to convert raw simulation actions +(`CREATE_POST`, `LIKE_POST`, etc.) into human-readable story language. +""" + +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 a narrative verb phrase for an OASIS action type.""" + return ACTION_TO_VERB.get(action_type, "does something") + + +def get_narrative_context(action_type: str) -> str: + """Return a longer narrative interpretation for an OASIS action type.""" + return ACTION_TO_NARRATIVE.get(action_type, "Character takes an unknown action") diff --git a/backend/app/services/narrative/character_engine.py b/backend/app/services/narrative/character_engine.py new file mode 100644 index 0000000000..4b67b29e3f --- /dev/null +++ b/backend/app/services/narrative/character_engine.py @@ -0,0 +1,129 @@ +"""Extended character profiles, emotional state, and arc detection. + +This module maintains character state beyond what OASIS tracks — backstory, +motivations, emotional state (six-dimension vector), relationships, and arc +stage. State is updated each round based on actions the character takes. +""" +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, # neutral-positive baseline; others start at 0 + "surprise": 0.0, +} + + +def create_initial_character( + char_id: str, + name: str, + backstory: str = "", + motivations: list | None = None, + personality: list | None = 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 [], + "status": "alive", + "emotional_state": { + "current": dict(INITIAL_EMOTIONAL_STATE), + "history": [], + }, + "relationships": {}, + "arc": {"archetype": None, "stage": "beginning", "key_moments": []}, + } + + +# ============================================================================ +# USER CONTRIBUTION POINT — Emotional deltas per action type +# ============================================================================ +# +# Each entry maps an OASIS action type to a dict of emotional changes the +# acting character experiences when they perform that action. Values are +# ADDED to current emotions (clamped to [0.0, 1.0]). +# +# Guidance: +# - Keep individual deltas small (between -0.15 and +0.15) so they +# accumulate gradually over many rounds. +# - Consider BOTH positive and negative effects per action: +# e.g. DISLIKE_POST → anger up, trust down +# - Missing actions default to no emotional change (character still acts +# but doesn't feel anything different about it). +# +# Actions to consider filling in (from action_mapper.py): +# CREATE_POST, LIKE_POST, REPOST, QUOTE_POST, FOLLOW, DO_NOTHING, +# CREATE_COMMENT, DISLIKE_POST, LIKE_COMMENT, DISLIKE_COMMENT, +# SEARCH_POSTS, SEARCH_USER, MUTE +# +# ⬇ REPLACE THE CONTENTS BELOW WITH YOUR CHOICES ⬇ +# ============================================================================ +ACTION_EMOTIONAL_DELTAS: Dict[str, Dict[str, float]] = { + # Speaking out publicly — small confidence bump + "CREATE_POST": {"joy": 0.04}, + # Agreeing with someone — builds trust and a little joy + "LIKE_POST": {"trust": 0.04, "joy": 0.02}, + # Spreading word of something — mild surprise/stimulation + "REPOST": {"surprise": 0.02}, + # Responding or debating — mild engagement charge + "QUOTE_POST": {"joy": 0.02, "surprise": 0.02}, + # Allying with someone — strong trust + joy bump + "FOLLOW": {"trust": 0.08, "joy": 0.04}, + # Observing in silence — passive sadness/fear creep over time + "DO_NOTHING": {"sadness": 0.02, "fear": 0.02}, + # Dialogue engagement — small positive + "CREATE_COMMENT": {"joy": 0.03}, + # Confronting/opposing — anger up, trust down (classic conflict) + "DISLIKE_POST": {"anger": 0.08, "trust": -0.04}, + # Validating a response — mild trust building + "LIKE_COMMENT": {"trust": 0.03}, + # Mocking/dismissing — mild anger + "DISLIKE_COMMENT": {"anger": 0.04}, + # Investigating — curiosity as surprise + "SEARCH_POSTS": {"surprise": 0.03}, + # Seeking out a specific person — slight fear (implies concern) + "SEARCH_USER": {"fear": 0.02, "surprise": 0.02}, + # Shunning/ignoring — sadness + trust loss + "MUTE": {"sadness": 0.03, "trust": -0.03}, +} +# ============================================================================ + + +def apply_action_emotional_delta(character: dict, action_type: str) -> None: + """Mutate character's emotional state based on an action they took. + + Emotions are clamped to [0.0, 1.0]. Unknown action types are a no-op. + """ + 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 the character roster for a single simulation.""" + + def __init__(self, store): + self.store = store + + def initialize_from_profiles(self, oasis_profiles: list) -> list: + """Bootstrap character roster from existing OASIS profile data.""" + characters = [] + for profile in oasis_profiles: + char_id = str(profile.get("user_id", profile.get("id", ""))) + char = create_initial_character( + char_id=char_id, + name=profile.get("name", "Unknown"), + ) + characters.append(char) + self.store.save_characters(characters) + return characters diff --git a/backend/app/services/narrative/god_mode.py b/backend/app/services/narrative/god_mode.py new file mode 100644 index 0000000000..9ae1b3a45d --- /dev/null +++ b/backend/app/services/narrative/god_mode.py @@ -0,0 +1,86 @@ +"""God Mode intervention handlers. + +All handlers mutate on-disk JSON under narrative/ and return the result. +Each intervention is logged to world_state.event_log for auditability. +""" +from typing import Optional + +from app.services.narrative.story_store import StoryStore +from app.services.narrative.world_state import WorldStateStore + + +def _current_round(sim_dir: str) -> int: + """Return the 'current' round: last translated beat's round + 1, or 1.""" + beats = StoryStore(sim_dir).get_all_beats() + if not beats: + return 1 + return beats[-1].get("round", 0) + 1 + + +def inject_event(sim_dir: str, description: str, round_num: Optional[int] = None) -> dict: + """Append a user-described event to the world event log.""" + world = WorldStateStore(sim_dir) + round_val = round_num if round_num is not None else _current_round(sim_dir) + return world.append_event({ + "type": "god_mode_injection", + "description": description, + "round": round_val, + }) + + +def modify_emotion(sim_dir: str, character_id: str, emotions: dict) -> dict: + """Overwrite specified emotion values for a character. Clamps to [0, 1]. + + Raises ValueError if character_id is not found. Unknown emotion keys are + silently ignored (they don't corrupt state; they just don't apply). + Logs the intervention to world_state.event_log for auditability. + """ + store = StoryStore(sim_dir) + characters = store.load_characters() + + target = next((c for c in characters if str(c.get("id")) == str(character_id)), None) + if target is None: + raise ValueError(f"character not found: {character_id}") + + current = target["emotional_state"]["current"] + changed = {} + for emo, val in emotions.items(): + if emo in current: + new_val = max(0.0, min(1.0, float(val))) + changed[emo] = {"from": current[emo], "to": new_val} + current[emo] = new_val + + store.save_characters(characters) + + WorldStateStore(sim_dir).append_event({ + "type": "god_mode_emotion_change", + "description": f"{target['name']} emotional state modified: {changed}", + "round": _current_round(sim_dir), + }) + + return target + + +def kill_character(sim_dir: str, character_id: str) -> dict: + """Mark a character as dead and append a death event to the world log. + + Death events are auto-appended so the LLM knows the character is gone + rather than silently omitting them from prose. + """ + store = StoryStore(sim_dir) + characters = store.load_characters() + + target = next((c for c in characters if str(c.get("id")) == str(character_id)), None) + if target is None: + raise ValueError(f"character not found: {character_id}") + + target["status"] = "dead" + store.save_characters(characters) + + WorldStateStore(sim_dir).append_event({ + "type": "god_mode_death", + "description": f"{target['name']} has died.", + "round": _current_round(sim_dir), + }) + + return target diff --git a/backend/app/services/narrative/narrative_translator.py b/backend/app/services/narrative/narrative_translator.py new file mode 100644 index 0000000000..085aaaaff0 --- /dev/null +++ b/backend/app/services/narrative/narrative_translator.py @@ -0,0 +1,334 @@ +"""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 + +from app.services.narrative.action_mapper import get_narrative_context + + +def _escape_braces(text: str) -> str: + """Escape { and } in user-supplied strings before str.format().""" + return text.replace("{", "{{").replace("}", "}}") + + +def _format_world_rules(world: dict) -> str: + rules = world.get("rules", []) + if not rules: + return "(none)" + return "; ".join(_escape_braces(r) for r in rules) + + +def _format_world_events(world: dict) -> str: + events = world.get("event_log", [])[-3:] + if not events: + return "(none)" + return "\n ".join( + f"(Round {e.get('round', '?')}) {_escape_braces(e.get('description', ''))}" + for e in events + ) + + +def _format_world_locations(world: dict) -> str: + locs = list(world.get("locations", {}).values())[:5] + if not locs: + return "(none)" + lines = [] + for l in locs: + name = _escape_braces(l.get("name", "")) + desc = _escape_braces(l.get("description", "")) + line = f"{name} — {desc}" + # Cinematic schema: if atmosphere is present, surface it to the LLM as + # a mood anchor for any scene set here. + atmosphere = l.get("atmosphere") + if atmosphere: + line += f" [atmosphere: {_escape_braces(atmosphere)}]" + lines.append(line) + return "\n ".join(lines) + + +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 + + +# --------------------------------------------------------------------------- +# Prose generation +# --------------------------------------------------------------------------- + +def call_llm(prompt: str) -> str: + """Send a single-turn prompt to the configured LLM and return the response. + + Wrapped as a module-level function (not a class method) so tests can patch + it trivially: `patch("app.services.narrative.narrative_translator.call_llm")`. + """ + # Lazy import — avoids forcing LLM config to be set during unit tests that + # never actually call the real LLM. + from app.utils.llm_client import LLMClient + + client = LLMClient() + return client.chat( + messages=[{"role": "user", "content": prompt}], + temperature=0.7, + max_tokens=1024, + ) + + +def _format_character_summary(character: dict, locations: dict | None = None) -> str: + emotions = character.get("emotional_state", {}).get("current", {}) + top = sorted(emotions.items(), key=lambda kv: -kv[1])[:2] + emo_str = ", ".join(f"{e[0]}={e[1]:.1f}" for e in top) or "neutral" + + loc_id = character.get("location") + loc_str = "" + if loc_id and locations and loc_id in locations: + loc_str = f" at {_escape_braces(locations[loc_id].get('name', loc_id))}" + + name = _escape_braces(character.get("name", "Unknown")) + return f"{name} (feeling: {emo_str}){loc_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", {}) or {} + content = args.get("content", "") + ctx = get_narrative_context(act) + if content: + return f'- {name}: {ctx}. Content: "{content}"' + return f"- {name}: {ctx}" + + +# ============================================================================ +# USER CONTRIBUTION POINT — Prose generation prompt +# ============================================================================ +# +# This template is rendered with four substitutions and sent to the LLM to +# generate each story beat. It is the single biggest lever for story quality. +# +# Substitution fields available: +# {tone} — user-selected tone, e.g. "dark fantasy" or "romantic comedy" +# {characters} — per-character one-liner with top emotions +# {actions} — bullet-point list of what each character did this round +# {previous} — prose from the last 1-2 beats (or "(first scene)") +# +# Design choices to consider: +# - Tense/POV (third-person past is most versatile) +# - Paragraph count (2-4 is a good range) +# - Show vs tell (instruct the model to show emotions through action/dialogue) +# - Continuity (tell it to continue naturally from {previous}) +# - How strictly to enforce tone +# +# A temporary minimal template is used below to make tests pass — REPLACE +# WITH YOUR OWN DESIGN to dial in story quality. +# ============================================================================ +# ============================================================================ +# USER CONTRIBUTION POINT — how strongly to force world events into prose +# ============================================================================ +# Three supported values: +# "soft" — "consider referencing the most recent world event if it fits" +# "medium" — "weave it in OR acknowledge its aftermath. Do not ignore it." +# "hard" — "the opening line MUST reference the most recent world event" +# Temporary default is "medium" — change after reviewing sample output. +# ============================================================================ +EVENT_ENFORCEMENT_STRENGTH = "hard" + +_ENFORCEMENT_PHRASES = { + "soft": "- Consider referencing the most recent world event if it fits naturally.", + "medium": "- Weave the most recent world event in, OR acknowledge its aftermath. Do not ignore it.", + "hard": "- The OPENING LINE of this passage MUST reference the most recent world event.", +} + + +PROSE_PROMPT_TEMPLATE = """You are a screenwriter-turned-novelist. Your voice is PUNCHY, CINEMATIC, and DIALOGUE-DRIVEN. + +Tone: {tone} + +World grounding: + Rules: {world_rules} + Recent events: + {world_events} + Known locations: + {world_locations} + +Previous scene: +{previous} + +Characters in this scene: +{characters} + +Events this round (translate these into a scene — do NOT list them): +{actions} + +Write a story passage following these rules: + +STRUCTURE +- 2 to 3 short paragraphs. No more. +- Stay under 180 words total. Economy over explanation. +- Third-person past tense. + +DIALOGUE +- Include at least 2 lines of spoken dialogue whenever characters interact. +- Dialogue does the heavy lifting — let characters reveal themselves through what they say (and don't say). +- Mix clipped lines with one longer beat. Rhythm matters. +- Use "said" sparingly. Trust the reader. + +CINEMATIC DETAIL +- Open on a concrete visual: a hand, a face, an object, the weather. +- One sharp sensory detail per paragraph. Not five. +- Cut hard between beats — no connective "meanwhile" or "then." +- If a character has a known location, root the scene there. +{event_enforcement} + +EMOTION +- Show it in bodies and voice — clenched jaw, dropped eyes, half-smile, a pause too long. +- Never name the emotion directly. No "she felt angry." No "he was sad." + +CONTINUITY +- If previous scene exists, echo one detail from it — a word, an image, a beat. The story should feel continuous. + +Write the prose only. No headings, no preamble, no meta commentary.""" +# ============================================================================ + + +def generate_prose(actions: list, characters: list, tone: str, + previous_beats: list, world: dict | None = None) -> str: + """Generate a narrative passage from a round's actions via the LLM.""" + if not actions: + return "A quiet pause settles over the scene. No one acts; no one speaks." + + world = world or {"rules": [], "locations": {}, "event_log": []} + locations = world.get("locations", {}) + + char_summaries = "\n".join(_format_character_summary(c, locations) for c in characters) + action_lines = "\n".join(_format_action_line(a) for a in actions) + prev_prose = "\n\n".join(_escape_braces(b.get("prose", "")) for b in previous_beats[-2:]) + + enforcement = _ENFORCEMENT_PHRASES.get( + EVENT_ENFORCEMENT_STRENGTH, _ENFORCEMENT_PHRASES["medium"] + ) + + prompt = PROSE_PROMPT_TEMPLATE.format( + tone=_escape_braces(tone), + world_rules=_format_world_rules(world), + world_events=_format_world_events(world), + world_locations=_format_world_locations(world), + characters=char_summaries or "(none)", + actions=action_lines, + previous=prev_prose or "(this is the first scene)", + event_enforcement=enforcement, + ) + return call_llm(prompt) + + +# --------------------------------------------------------------------------- +# Round orchestration — the entry point most callers use +# --------------------------------------------------------------------------- + +def translate_round(sim_dir: str, platform: str, target_round: int, tone: str = "neutral") -> dict: + """Translate a single simulation round into a story beat end-to-end. + + Steps: + 1. Read the round's actions from `{sim_dir}/{platform}/actions.jsonl` + (starting from the saved file offset). + 2. Load world state (rules, locations, event log) — empty if not present. + 3. Filter dead characters and their actions out before prose generation. + 4. Generate prose via the LLM using current character + world state. + 5. Apply emotional-state deltas for every action to living characters. + 6. Persist the new beat, updated character state, and new file offset. + + Returns the newly created story beat dict. + """ + # Lazy imports keep module import lightweight for unit tests of helpers + from app.services.narrative.story_store import StoryStore + from app.services.narrative.character_engine import apply_action_emotional_delta + from app.services.narrative.world_state import WorldStateStore + + store = StoryStore(sim_dir) + world = WorldStateStore(sim_dir).load() + + 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) + + # Filter dead characters and their actions + all_characters = store.load_characters() + alive_names = {c["name"] for c in all_characters if c.get("status", "alive") != "dead"} + living_characters = [c for c in all_characters if c["name"] in alive_names] + actions = [a for a in actions if a.get("agent_name") in alive_names] + + previous_beats = store.get_all_beats() + prose = generate_prose(actions, living_characters, tone, previous_beats, world) + + # De-duplicated list of character names that acted this round (living only) + involved = sorted({a.get("agent_name") for a in actions if a.get("agent_name")}) + + # Apply emotional deltas only to living characters + char_by_name = {c["name"]: c for c in all_characters} + for action in actions: + char = char_by_name.get(action.get("agent_name")) + if char and char.get("status", "alive") != "dead": + apply_action_emotional_delta(char, action.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 diff --git a/backend/app/services/narrative/story_store.py b/backend/app/services/narrative/story_store.py new file mode 100644 index 0000000000..5fb2fb5f1b --- /dev/null +++ b/backend/app/services/narrative/story_store.py @@ -0,0 +1,74 @@ +"""File-based persistence for narrative state. + +Each simulation gets a `narrative/` subdirectory inside its data dir, +containing three files: + - story_beats.json: chronological list of generated story passages + - translator_state.json: tracks file offset per platform's actions.jsonl + - characters.json: extended character profiles with emotional 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) -> None: + 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) diff --git a/backend/app/services/narrative/world_state.py b/backend/app/services/narrative/world_state.py new file mode 100644 index 0000000000..510dc27969 --- /dev/null +++ b/backend/app/services/narrative/world_state.py @@ -0,0 +1,59 @@ +"""World state CRUD: rules, locations, event log. + +Stored in narrative/world_state.json. Missing file is treated as an empty +world so existing simulations (from pre-God-Mode versions) continue to work +without migration. +""" +import os +import json + + +class WorldStateStore: + """Manages narrative/world_state.json 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.path = os.path.join(self.narrative_dir, "world_state.json") + + def _ensure_dir(self) -> None: + os.makedirs(self.narrative_dir, exist_ok=True) + + def load(self) -> dict: + if not os.path.exists(self.path): + return {"rules": [], "locations": {}, "event_log": []} + with open(self.path, "r", encoding="utf-8") as f: + world = json.load(f) + # Fill in missing keys for forward compatibility + world.setdefault("rules", []) + world.setdefault("locations", {}) + world.setdefault("event_log", []) + return world + + def save(self, world: dict) -> None: + self._ensure_dir() + with open(self.path, "w", encoding="utf-8") as f: + json.dump(world, f, ensure_ascii=False, indent=2) + + def set_rules(self, rules: list[str]) -> None: + world = self.load() + world["rules"] = list(rules) + self.save(world) + + def upsert_location(self, location: dict) -> dict: + """Insert or update a location by id. Returns the stored entry.""" + if "id" not in location: + raise ValueError("location requires 'id'") + world = self.load() + world["locations"][location["id"]] = location + self.save(world) + return location + + def append_event(self, event: dict) -> dict: + """Append an event to event_log, assigning evt_N id automatically.""" + world = self.load() + event = dict(event) + event["id"] = f"evt_{len(world['event_log']) + 1}" + world["event_log"].append(event) + self.save(world) + return event diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/test_action_mapper.py b/backend/tests/test_action_mapper.py new file mode 100644 index 0000000000..edc2fd3210 --- /dev/null +++ b/backend/tests/test_action_mapper.py @@ -0,0 +1,21 @@ +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() diff --git a/backend/tests/test_character_engine.py b/backend/tests/test_character_engine.py new file mode 100644 index 0000000000..dcad377fb3 --- /dev/null +++ b/backend/tests/test_character_engine.py @@ -0,0 +1,47 @@ +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 + # Trust starts at a neutral-positive baseline, not zero + assert char["emotional_state"]["current"]["trust"] == 0.5 + + +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") + # Hammer a single action many times to exceed the clamp + for _ in range(20): + apply_action_emotional_delta(char, "DISLIKE_POST") + for emo, val in char["emotional_state"]["current"].items(): + assert 0.0 <= val <= 1.0, f"{emo}={val} outside [0,1]" + + +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 + + +def test_create_initial_character_sets_status_alive(): + char = create_initial_character(char_id="x", name="X") + assert char["status"] == "alive" diff --git a/backend/tests/test_god_mode.py b/backend/tests/test_god_mode.py new file mode 100644 index 0000000000..4ff5148ae4 --- /dev/null +++ b/backend/tests/test_god_mode.py @@ -0,0 +1,113 @@ +import os +import tempfile +import pytest +from app.services.narrative.god_mode import inject_event +from app.services.narrative.story_store import StoryStore +from app.services.narrative.world_state import WorldStateStore + + +@pytest.fixture +def temp_sim_dir(): + with tempfile.TemporaryDirectory() as d: + sim_dir = os.path.join(d, "sim_test") + os.makedirs(sim_dir) + yield sim_dir + + +def test_inject_event_appends_to_log(temp_sim_dir): + evt = inject_event(temp_sim_dir, description="A storm arrives.", round_num=5) + assert evt["description"] == "A storm arrives." + assert evt["round"] == 5 + assert evt["id"] == "evt_1" + assert evt["type"] == "god_mode_injection" + + log = WorldStateStore(temp_sim_dir).load()["event_log"] + assert len(log) == 1 + + +def test_inject_event_defaults_round_to_beats_plus_one(temp_sim_dir): + store = StoryStore(temp_sim_dir) + store.append_beat({"round": 3, "prose": "beat 3"}) + + evt = inject_event(temp_sim_dir, description="auto round") + assert evt["round"] == 4 + + +def test_inject_event_defaults_round_to_one_when_no_beats(temp_sim_dir): + evt = inject_event(temp_sim_dir, description="first event") + assert evt["round"] == 1 + + +# ---- modify_emotion ---- +from app.services.narrative.god_mode import modify_emotion + + +def _seed_character(sim_dir, char_id="1", name="Elena"): + store = StoryStore(sim_dir) + neutral = {k: 0.0 for k in ["anger", "fear", "joy", "sadness", "surprise"]} + store.save_characters([{ + "id": char_id, "name": name, "status": "alive", + "emotional_state": {"current": {**neutral, "trust": 0.5}, "history": []}, + }]) + return store + + +def test_modify_emotion_overwrites_specified_emotions(temp_sim_dir): + _seed_character(temp_sim_dir) + result = modify_emotion(temp_sim_dir, "1", {"anger": 0.8, "joy": 0.2}) + + assert result["emotional_state"]["current"]["anger"] == 0.8 + assert result["emotional_state"]["current"]["joy"] == 0.2 + assert result["emotional_state"]["current"]["trust"] == 0.5 + + +def test_modify_emotion_clamps(temp_sim_dir): + _seed_character(temp_sim_dir) + result = modify_emotion(temp_sim_dir, "1", {"anger": 1.5, "fear": -0.3}) + assert result["emotional_state"]["current"]["anger"] == 1.0 + assert result["emotional_state"]["current"]["fear"] == 0.0 + + +def test_modify_emotion_character_not_found_raises(temp_sim_dir): + _seed_character(temp_sim_dir) + with pytest.raises(ValueError, match="not found"): + modify_emotion(temp_sim_dir, "nonexistent", {"anger": 0.5}) + + +def test_modify_emotion_audit_logs_to_event_log(temp_sim_dir): + _seed_character(temp_sim_dir) + modify_emotion(temp_sim_dir, "1", {"anger": 0.8}) + + log = WorldStateStore(temp_sim_dir).load()["event_log"] + assert len(log) == 1 + assert log[0]["type"] == "god_mode_emotion_change" + assert "Elena" in log[0]["description"] + + +# ---- kill_character ---- +from app.services.narrative.god_mode import kill_character + + +def test_kill_character_sets_status_dead(temp_sim_dir): + _seed_character(temp_sim_dir) + result = kill_character(temp_sim_dir, "1") + assert result["status"] == "dead" + + chars = StoryStore(temp_sim_dir).load_characters() + assert chars[0]["status"] == "dead" + + +def test_kill_character_auto_appends_death_event(temp_sim_dir): + _seed_character(temp_sim_dir) + kill_character(temp_sim_dir, "1") + + log = WorldStateStore(temp_sim_dir).load()["event_log"] + death_events = [e for e in log if e["type"] == "god_mode_death"] + assert len(death_events) == 1 + assert "Elena" in death_events[0]["description"] + + +def test_kill_character_not_found_raises(temp_sim_dir): + _seed_character(temp_sim_dir) + with pytest.raises(ValueError, match="not found"): + kill_character(temp_sim_dir, "nonexistent") diff --git a/backend/tests/test_narrative_e2e.py b/backend/tests/test_narrative_e2e.py new file mode 100644 index 0000000000..71583c9db1 --- /dev/null +++ b/backend/tests/test_narrative_e2e.py @@ -0,0 +1,165 @@ +"""End-to-end test: simulated actions.jsonl → translate → verify story + state.""" +import os +import json +from unittest.mock import patch + +from app.services.narrative.story_store import StoryStore +from app.services.narrative.narrative_translator import translate_round + + +def test_full_pipeline_from_fake_simulation(tmp_path): + sim_dir = str(tmp_path / "sim_e2e") + os.makedirs(os.path.join(sim_dir, "twitter")) + + actions_path = os.path.join(sim_dir, "twitter", "actions.jsonl") + actions = [ + {"round": 1, "agent_id": 1, "agent_name": "Elena", "action_type": "CREATE_POST", + "action_args": {"content": "The council must fall."}, "success": True, "timestamp": "t"}, + {"round": 1, "agent_id": 2, "agent_name": "Marcus", "action_type": "DISLIKE_POST", + "action_args": {"post_id": 1}, "success": True, "timestamp": "t"}, + {"event_type": "round_end", "round": 1, "timestamp": "t"}, + {"round": 2, "agent_id": 1, "agent_name": "Elena", "action_type": "REPOST", + "action_args": {}, "success": True, "timestamp": "t"}, + {"round": 2, "agent_id": 2, "agent_name": "Marcus", "action_type": "FOLLOW", + "action_args": {"target": 3}, "success": True, "timestamp": "t"}, + {"event_type": "round_end", "round": 2, "timestamp": "t"}, + ] + with open(actions_path, "w") as f: + for a in actions: + f.write(json.dumps(a) + "\n") + + store = StoryStore(sim_dir) + neutral_emotions = {k: 0.0 for k in ["anger", "fear", "joy", "sadness", "surprise"]} + store.save_characters([ + {"id": "1", "name": "Elena", + "emotional_state": {"current": {**neutral_emotions, "trust": 0.5}, "history": []}}, + {"id": "2", "name": "Marcus", + "emotional_state": {"current": {**neutral_emotions, "trust": 0.5}, "history": []}}, + ]) + + with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm: + mock_llm.side_effect = [ + "Elena addressed the gathering. Marcus's face darkened.", + "Elena's message spread through the quarter like a spark.", + ] + beat1 = translate_round(sim_dir, "twitter", 1, "dark fantasy") + beat2 = translate_round(sim_dir, "twitter", 2, "dark fantasy") + + # Beats are correctly attributed and sequenced + assert beat1["round"] == 1 + assert beat2["round"] == 2 + assert "Elena" in beat1["characters"] + assert "Marcus" in beat1["characters"] + assert beat1["action_count"] == 2 + + # Both beats persisted + all_beats = store.get_all_beats() + assert len(all_beats) == 2 + assert all_beats[0]["prose"] == "Elena addressed the gathering. Marcus's face darkened." + + # Characters evolved per the emotional delta rules + chars = {c["name"]: c for c in store.load_characters()} + + # Marcus DISLIKE_POST'd in round 1 → anger should be > 0 (delta 0.08) + assert chars["Marcus"]["emotional_state"]["current"]["anger"] > 0.0 + # Marcus FOLLOW'd in round 2 → trust should have climbed above baseline (delta 0.08) + assert chars["Marcus"]["emotional_state"]["current"]["trust"] > 0.5 + # Elena CREATE_POST'd then REPOST'd → joy and surprise both bumped + assert chars["Elena"]["emotional_state"]["current"]["joy"] > 0.0 + assert chars["Elena"]["emotional_state"]["current"]["surprise"] > 0.0 + + # Translator state advanced — next call for round 3 would resume, not re-read + assert store.get_file_offset("twitter") > 0 + + +# --------------------------------------------------------------------------- +# God Mode integration tests +# --------------------------------------------------------------------------- +from app.services.narrative.god_mode import inject_event, kill_character + + +def test_injected_event_appears_in_next_round_prompt(tmp_path): + sim_dir = str(tmp_path / "sim_evt") + os.makedirs(os.path.join(sim_dir, "twitter")) + actions_path = os.path.join(sim_dir, "twitter", "actions.jsonl") + + lines = [ + {"round": 1, "agent_id": 1, "agent_name": "Elena", "action_type": "CREATE_POST", + "action_args": {"content": "x"}, "success": True, "timestamp": "t"}, + {"event_type": "round_end", "round": 1, "timestamp": "t"}, + {"round": 2, "agent_id": 1, "agent_name": "Elena", "action_type": "REPOST", + "action_args": {}, "success": True, "timestamp": "t"}, + {"event_type": "round_end", "round": 2, "timestamp": "t"}, + ] + with open(actions_path, "w") as f: + for a in lines: + f.write(json.dumps(a) + "\n") + + store = StoryStore(sim_dir) + neutral = {k: 0.0 for k in ["anger", "fear", "joy", "sadness", "surprise"]} + store.save_characters([{ + "id": "1", "name": "Elena", "status": "alive", + "emotional_state": {"current": {**neutral, "trust": 0.5}, "history": []}, + }]) + + with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm: + mock_llm.return_value = "beat" + translate_round(sim_dir, "twitter", 1, "noir") + # Inject between rounds + inject_event(sim_dir, description="A mysterious letter arrives.") + translate_round(sim_dir, "twitter", 2, "noir") + + # Round-2 prompt should contain the injected event + round2_prompt = mock_llm.call_args_list[1][0][0] + assert "mysterious letter" in round2_prompt + + +def test_killed_character_filtered_from_next_round(tmp_path): + sim_dir = str(tmp_path / "sim_kill") + os.makedirs(os.path.join(sim_dir, "twitter")) + actions_path = os.path.join(sim_dir, "twitter", "actions.jsonl") + + lines = [ + {"round": 1, "agent_id": 1, "agent_name": "Elena", "action_type": "CREATE_POST", + "action_args": {"content": "x"}, "success": True, "timestamp": "t"}, + {"round": 1, "agent_id": 2, "agent_name": "Marcus", "action_type": "DISLIKE_POST", + "action_args": {}, "success": True, "timestamp": "t"}, + {"event_type": "round_end", "round": 1, "timestamp": "t"}, + # Round 2: Elena (alive) and Marcus (will be killed) both act — filter + # should keep Elena and drop Marcus + {"round": 2, "agent_id": 1, "agent_name": "Elena", "action_type": "QUOTE_POST", + "action_args": {}, "success": True, "timestamp": "t"}, + {"round": 2, "agent_id": 2, "agent_name": "Marcus", "action_type": "REPOST", + "action_args": {}, "success": True, "timestamp": "t"}, + {"event_type": "round_end", "round": 2, "timestamp": "t"}, + ] + with open(actions_path, "w") as f: + for a in lines: + f.write(json.dumps(a) + "\n") + + store = StoryStore(sim_dir) + neutral = {k: 0.0 for k in ["anger", "fear", "joy", "sadness", "surprise"]} + store.save_characters([ + {"id": "1", "name": "Elena", "status": "alive", + "emotional_state": {"current": {**neutral, "trust": 0.5}, "history": []}}, + {"id": "2", "name": "Marcus", "status": "alive", + "emotional_state": {"current": {**neutral, "trust": 0.5}, "history": []}}, + ]) + + with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm: + mock_llm.return_value = "beat" + translate_round(sim_dir, "twitter", 1, "noir") + # Kill Marcus between rounds + kill_character(sim_dir, "2") + beat2 = translate_round(sim_dir, "twitter", 2, "noir") + + # Marcus acted in round 2 but is dead — his name should not be in + # the beat's 'characters' list (which reflects who participated) + assert "Marcus" not in beat2["characters"] + + # Round-2 prompt's characters-in-scene section must not include Marcus. + # Marcus may appear in the event log as a death event; we exclude him + # from the "feeling:" lines which describe living characters. + round2_prompt = mock_llm.call_args_list[1][0][0] + char_lines = [l for l in round2_prompt.split("\n") if "feeling:" in l] + assert not any("Marcus" in l for l in char_lines) diff --git a/backend/tests/test_narrative_translator.py b/backend/tests/test_narrative_translator.py new file mode 100644 index 0000000000..0a9d91bdf2 --- /dev/null +++ b/backend/tests/test_narrative_translator.py @@ -0,0 +1,174 @@ +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 + + +# ---- Prose generation tests ---- +from unittest.mock import patch +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 passed to the LLM mentioned both characters + sent_prompt = mock_llm.call_args[0][0] + assert "Elena" in sent_prompt + assert "Marcus" in sent_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() + + +# ---- Round orchestration tests ---- +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 line in lines: + f.write(json.dumps(line) + "\n") + + store = StoryStore(sim_dir) + store.save_characters([{ + "id": "1", "name": "Alice", + "emotional_state": {"current": {k: 0.0 for k in ["anger","fear","joy","sadness","trust","surprise"]}}, + }]) + + 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 + + +def test_generate_prose_includes_world_context(): + actions = [{"agent_name": "Alice", "action_type": "CREATE_POST", "action_args": {}}] + characters = [ + {"id": "1", "name": "Alice", "status": "alive", "location": "tower", + "emotional_state": {"current": {"anger": 0, "fear": 0, "joy": 0, + "sadness": 0, "trust": 0.5, "surprise": 0}}}, + ] + world = { + "rules": ["Magic is forbidden", "Winter is near"], + "locations": {"tower": {"id": "tower", "name": "The Tower", "description": "tall"}}, + "event_log": [{"id": "evt_1", "round": 1, "type": "god_mode_injection", + "description": "A stranger arrived."}], + } + + with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm: + mock_llm.return_value = "prose" + generate_prose(actions, characters, tone="noir", previous_beats=[], world=world) + + prompt = mock_llm.call_args[0][0] + assert "Magic is forbidden" in prompt + assert "A stranger arrived" in prompt + assert "The Tower" in prompt + + +def test_location_atmosphere_surfaces_in_prompt(): + """Cinematic location schema — atmosphere should reach the LLM.""" + actions = [{"agent_name": "Alice", "action_type": "CREATE_POST", "action_args": {}}] + characters = [ + {"id": "1", "name": "Alice", "status": "alive", + "emotional_state": {"current": {"anger": 0, "fear": 0, "joy": 0, + "sadness": 0, "trust": 0.5, "surprise": 0}}}, + ] + world = { + "rules": [], + "locations": { + "tower": { + "id": "tower", + "name": "The Iron Tower", + "description": "dark spire", + "atmosphere": "oppressive silence, dust in shafts of cold light", + } + }, + "event_log": [], + } + + with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm: + mock_llm.return_value = "prose" + generate_prose(actions, characters, tone="noir", previous_beats=[], world=world) + + prompt = mock_llm.call_args[0][0] + assert "atmosphere" in prompt.lower() + assert "oppressive silence" in prompt diff --git a/backend/tests/test_story_store.py b/backend/tests/test_story_store.py new file mode 100644 index 0000000000..6d9f5ccc39 --- /dev/null +++ b/backend/tests/test_story_store.py @@ -0,0 +1,47 @@ +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) diff --git a/backend/tests/test_world_state.py b/backend/tests/test_world_state.py new file mode 100644 index 0000000000..d5ae49d790 --- /dev/null +++ b/backend/tests/test_world_state.py @@ -0,0 +1,45 @@ +import os +import tempfile +import pytest +from app.services.narrative.world_state import WorldStateStore + + +@pytest.fixture +def temp_sim_dir(): + with tempfile.TemporaryDirectory() as d: + sim_dir = os.path.join(d, "sim_test") + os.makedirs(sim_dir) + yield sim_dir + + +def test_load_returns_empty_world_when_missing(temp_sim_dir): + store = WorldStateStore(temp_sim_dir) + world = store.load() + assert world == {"rules": [], "locations": {}, "event_log": []} + + +def test_set_rules_replaces_previous(temp_sim_dir): + store = WorldStateStore(temp_sim_dir) + store.set_rules(["rule 1", "rule 2"]) + assert store.load()["rules"] == ["rule 1", "rule 2"] + store.set_rules(["only rule"]) + assert store.load()["rules"] == ["only rule"] + + +def test_upsert_location_adds_and_updates(temp_sim_dir): + store = WorldStateStore(temp_sim_dir) + store.upsert_location({"id": "tower", "name": "The Tower", "description": "tall"}) + assert store.load()["locations"]["tower"]["name"] == "The Tower" + + store.upsert_location({"id": "tower", "name": "The Iron Tower", "description": "dark"}) + assert store.load()["locations"]["tower"]["name"] == "The Iron Tower" + + +def test_append_event_auto_ids_sequentially(temp_sim_dir): + store = WorldStateStore(temp_sim_dir) + e1 = store.append_event({"type": "custom", "description": "one", "round": 1}) + e2 = store.append_event({"type": "custom", "description": "two", "round": 2}) + + assert e1["id"] == "evt_1" + assert e2["id"] == "evt_2" + assert store.load()["event_log"][-1]["description"] == "two" 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 0000000000..7bc254596e --- /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 `