feat(narrative): add LLM-driven prose generation

Module-level call_llm() wrapper (patchable by tests) drives
PROSE_PROMPT_TEMPLATE rendering with character summaries, actions,
previous beats, and user-selected tone. Prompt tuned for punchy,
cinematic, dialogue-heavy voice.

Tests: 5/5 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
anadoris007 2026-04-20 22:00:29 +05:30
parent 052fd87acd
commit 9db7b399cc
2 changed files with 161 additions and 0 deletions

View File

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

View File

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