HomePilot / backend /app /interactive /script /generator.py
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
5.47 kB
"""
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