File size: 5,472 Bytes
921d377 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | """
Narration / dialogue / CTA generator.
``fill_scripts(graph, intent, profile)`` walks every node in a
BranchGraph and populates its ``narration`` + ``title`` fields
according to the node's kind and position in the graph. Also
writes ``image_prompt`` / ``video_prompt`` strings that the asset
adapter layer consumes downstream.
Phase 1 uses deterministic templates per node kind so tests are
reproducible. Phase 2 swaps the template calls for LLM prompts
behind the same signature.
"""
from __future__ import annotations
from typing import Optional
from ..branching.graph import BranchGraph, GraphNode
from ..planner.intent import Intent
from ..policy.profiles import PolicyProfile, load_profile
from .safety_rewriter import rewrite_for_safety
from .tone import ToneSpec, apply_tone, default_tone_for_mode, estimate_duration_sec
# Per-kind narration templates. {objective}/{topic}/{title} fill at runtime.
_TEMPLATES = {
"scene": "Welcome. {objective}. Here's what happens next in the journey.",
"decision": "You have a choice. Pick the path that feels right.",
"merge": "Paths converge here. Let's continue.",
"assessment": "Quick check β what do you think about {topic}?",
"remediation": "Let's look at that one more time. {topic} is important becauseβ¦",
"ending": "Thanks for playing through {objective}. Come back anytime.",
}
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Single-node generator (exposed for router-driven regeneration)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def generate_narration_for_node(
node: GraphNode,
intent: Intent,
*,
tone: Optional[ToneSpec] = None,
profile: Optional[PolicyProfile] = None,
) -> str:
"""Produce narration for a single node.
Applies (1) template substitution, (2) tone shaping,
(3) safety rewriter using the active policy profile.
"""
tone = tone or default_tone_for_mode(intent.mode)
profile = profile or load_profile(intent.mode) or load_profile("sfw_general")
# ``profile`` narrowed for mypy: load_profile guarantees non-None
# for any of the built-in mode ids.
assert profile is not None
template = _TEMPLATES.get(node.kind, _TEMPLATES["scene"])
raw = template.format(
objective=intent.objective or "our story",
topic=intent.topic or "this topic",
title=node.title or "this step",
)
shaped = apply_tone(raw, tone)
safe = rewrite_for_safety(shaped, profile).text
return safe
def _title_for(node: GraphNode, intent: Intent) -> str:
if node.title:
return node.title
if node.is_entry:
return "Introduction"
if node.kind == "ending":
return "Epilogue"
if node.kind == "decision":
return "Choose"
return f"{node.kind.title()} step"
def _image_prompt(node: GraphNode, intent: Intent) -> str:
base = f"cinematic still, {intent.mode.replace('_', ' ')} scene"
if intent.topic:
base += f", about {intent.topic}"
if node.kind == "decision":
base += ", character facing two choices"
elif node.kind == "ending":
base += ", resolution, warm lighting"
elif node.kind == "assessment":
base += ", thoughtful look"
elif node.kind == "remediation":
base += ", teaching gesture"
return base
def _video_prompt(node: GraphNode, intent: Intent) -> str:
return _image_prompt(node, intent) + ", short 4-second clip, subtle motion"
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Graph-level fill
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def fill_scripts(
graph: BranchGraph,
intent: Intent,
*,
profile: Optional[PolicyProfile] = None,
) -> BranchGraph:
"""Populate narration/title/image_prompt/video_prompt on every
node. Idempotent β nodes that already have non-empty narration
are skipped so designer edits are preserved when re-running the
generator after a partial manual edit.
"""
tone = default_tone_for_mode(intent.mode)
profile = profile or load_profile(intent.mode) or load_profile("sfw_general")
assert profile is not None
for node in graph.nodes:
if not node.title:
node.title = _title_for(node, intent)
if not node.narration:
node.narration = generate_narration_for_node(
node, intent, tone=tone, profile=profile,
)
# image_prompt / video_prompt are stored on the metadata dict
# for now β the real ix_nodes columns get populated when the
# caller persists the graph via the repo.
node.metadata.setdefault("image_prompt", _image_prompt(node, intent))
node.metadata.setdefault("video_prompt", _video_prompt(node, intent))
node.metadata.setdefault(
"duration_sec",
estimate_duration_sec(node.narration, tone),
)
return graph
|