diff --git a/backend/app/services/narrative/narrative_translator.py b/backend/app/services/narrative/narrative_translator.py index f6ca2839..9f432745 100644 --- a/backend/app/services/narrative/narrative_translator.py +++ b/backend/app/services/narrative/narrative_translator.py @@ -8,6 +8,8 @@ import os import json from typing import List, Tuple +from app.services.narrative.action_mapper import get_narrative_context + def read_actions_for_round( jsonl_path: str, start_offset: int, target_round: int @@ -58,3 +60,126 @@ def read_actions_for_round( 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) -> 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" + return f"{character.get('name', 'Unknown')} (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", {}) 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. +# ============================================================================ +PROSE_PROMPT_TEMPLATE = """You are a screenwriter-turned-novelist. Your voice is PUNCHY, CINEMATIC, and DIALOGUE-DRIVEN. + +Tone: {tone} + +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." + +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) -> 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." + + 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 or "(none)", + actions=action_lines, + previous=prev_prose or "(this is the first scene)", + ) + return call_llm(prompt) diff --git a/backend/tests/test_narrative_translator.py b/backend/tests/test_narrative_translator.py index 750c7843..f9b2397d 100644 --- a/backend/tests/test_narrative_translator.py +++ b/backend/tests/test_narrative_translator.py @@ -43,3 +43,39 @@ 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()