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