HomePilot / backend /app /interactive /playback /scene_planner.py
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
11.3 kB
"""
Scene planner — turns one chat turn into the next scene plan.
PLAY-2/8. Phase-1 implementation is a heuristic composer: it
classifies the viewer turn with the existing policy classifier,
picks a reply template keyed by (intent, mood, affinity tier),
and builds a deterministic image / video prompt anchored on the
persona + current mood + a verbatim quote from the viewer.
This keeps the subsystem shippable without an LLM roundtrip and
gives us a testable, deterministic contract. Phase-2 swaps
``_compose`` for an LLM call that returns the same ``ScenePlan``
dataclass — nothing downstream changes.
The planner NEVER enforces safety. Route-level code
(``check_free_input`` in ``policy/decision.py``) decides whether
to call the planner at all; universal-block intents never even
reach this module.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from ..policy.classifier import IntentMatch, classify_intent
from .edit_recipes import EditRecipe
from .scene_memory import SceneMemory
# ── Public surface ──────────────────────────────────────────────
@dataclass(frozen=True)
class ScenePlan:
"""What the scene planner hands to downstream video generation.
Every field is deterministic for a given (memory, text) pair
so tests can pin exact output without mocking randomness.
"""
reply_text: str
narration: str
scene_prompt: str
duration_sec: int
mood_delta: Dict[str, Any] = field(default_factory=dict)
topic_continuity: str = ""
intent_code: str = "smalltalk"
confidence: float = 0.5
# Persona Live Play: when the LLM classified the turn into a
# specific edit kind (pose / outfit / expression / …) we carry
# the resolved recipe here so the render adapter can switch
# from txt2img to the matching img2img / inpaint workflow on
# the persona's canonical portrait. ``None`` means "no recipe
# applies — run the default txt2img path", which is how every
# standard branching project stays unchanged.
edit_recipe: Optional[EditRecipe] = None
def plan_next_scene(
memory: SceneMemory,
viewer_text: str,
*,
persona_hint: str = "",
duration_sec: int = 5,
) -> ScenePlan:
"""Plan the next scene for a given viewer turn.
``persona_hint`` is a free-form descriptor (hair, outfit, vibe)
that anchors the image prompt so identity stays stable across
clips. The asset-library lookup that feeds this in PLAY-3 will
plug a reference image + LoRA on top for identity lock.
"""
text = (viewer_text or "").strip()
classification = classify_intent(text) if text else IntentMatch(
intent_code="smalltalk", confidence=0.0, matched_pattern="",
)
return _compose(
memory=memory,
text=text,
classification=classification,
persona_hint=persona_hint,
duration_sec=duration_sec,
)
async def plan_next_scene_async(
memory: SceneMemory,
viewer_text: str,
*,
persona_hint: str = "",
duration_sec: int = 5,
persona_project_id: str = "",
persona_label: str = "",
allow_explicit: bool = False,
) -> ScenePlan:
"""Async variant that tries the LLM composer before falling
back to the deterministic heuristic.
The two composers share the exact same input + output contract
so routes can swap between sync and async without any
downstream awareness. The LLM call is feature-flagged by
``INTERACTIVE_PLAYBACK_LLM`` — when unset this function does
exactly what ``plan_next_scene`` does.
Failure of the LLM never surfaces to the caller: any timeout
/ network error / malformed JSON returns a heuristic plan so
the player keeps a pulse.
"""
# Local import so the heuristic path never pays the LLM
# composer import cost when the flag is off.
from .llm_composer import compose_with_llm
text = (viewer_text or "").strip()
classification = classify_intent(text) if text else IntentMatch(
intent_code="smalltalk", confidence=0.0, matched_pattern="",
)
llm_plan = await compose_with_llm(
memory, text, classification,
persona_hint=persona_hint, duration_sec=duration_sec,
persona_project_id=persona_project_id,
persona_label=persona_label,
synopsis=memory.synopsis,
allow_explicit=allow_explicit,
)
if llm_plan is not None:
return llm_plan
return _compose(
memory=memory, text=text, classification=classification,
persona_hint=persona_hint, duration_sec=duration_sec,
)
def synthesize_synopsis(memory: SceneMemory) -> str:
"""Compact, deterministic synopsis of the conversation so far.
Phase-1 heuristic: keep the opening viewer prompt + the last
character reply + a mood / affinity stamp. Phase-2 replaces
this with an LLM-generated sentence that captures the arc.
"""
turns = memory.recent_turns
if not turns:
return ""
opener = next((t for t in turns if t.role == "viewer"), None)
last_char = next((t for t in reversed(turns) if t.role == "character"), None)
parts: List[str] = []
if opener and opener.text:
parts.append(f"Opened with: {_short(opener.text, 80)}")
if last_char and last_char.text:
parts.append(f"Last reply: {_short(last_char.text, 80)}")
parts.append(f"Mood {memory.mood}, affinity {int(memory.affinity_score * 100)}%.")
return " ".join(parts)
# ── Internals ───────────────────────────────────────────────────
_AFFINITY_TIERS = (
(0.75, "close"),
(0.5, "warm"),
(0.2, "friendly"),
(0.0, "stranger"),
)
def _affinity_tier(score: float) -> str:
for threshold, label in _AFFINITY_TIERS:
if score >= threshold:
return label
return "stranger"
# Template keys are (intent_bucket, affinity_tier). Falls back to
# ("smalltalk", tier) when the intent isn't covered explicitly.
_REPLY_TEMPLATES: Dict[str, Dict[str, str]] = {
"greeting": {
"stranger": "Hi there — good to finally see you.",
"friendly": "Hey you, welcome back.",
"warm": "There you are — I was hoping you'd show up.",
"close": "Hey trouble, come sit with me.",
},
"compliment": {
"stranger": "That's kind of you, thank you.",
"friendly": "Oh — you're being sweet.",
"warm": "You always know what to say, don't you?",
"close": "Keep talking like that, I might not let you leave.",
},
"question": {
"stranger": "Hmm, let me think about that for a second.",
"friendly": "Good question — here's how I'd put it.",
"warm": "Ask me anything. Really.",
"close": "You and your questions. Come closer.",
},
"flirt": {
"stranger": "Oh? We're going there already?",
"friendly": "Careful — I flirt back.",
"warm": "Is that how it's going to be tonight?",
"close": "You're in a mood. I like it.",
},
"smalltalk": {
"stranger": "Mhm — go on, I'm listening.",
"friendly": "Tell me more.",
"warm": "I like where this is going.",
"close": "Keep going. I'm right here.",
},
}
_MOOD_SCENE_TAG = {
"neutral": "calm gaze, soft diffuse light",
"shy": "glancing away, warm backlight, subtle smile",
"flirty": "slow half-smile, lowered eyelashes, warm rim light",
"playful": "quick grin, candid pose, dynamic expression",
"warm": "open expression, soft golden lighting",
"cold": "steady stare, cool blue tint",
}
_INTENT_BUCKETS = {
# classifier may return nuanced codes; bucket them to the few
# templates we actually maintain.
"greeting": "greeting",
"compliment": "compliment",
"tease": "flirt",
"flirt": "flirt",
"affection": "flirt",
"question": "question",
"request_info": "question",
"smalltalk": "smalltalk",
}
_MOOD_SHIFT_RULES: List[Dict[str, Any]] = [
# (intent, condition, mood, affinity_delta)
{"intent": "greeting", "mood": None, "affinity": 0.02},
{"intent": "compliment", "mood": "warm", "affinity": 0.04},
{"intent": "flirt", "mood": "flirty", "affinity": 0.05},
{"intent": "tease", "mood": "playful", "affinity": 0.03},
{"intent": "affection", "mood": "warm", "affinity": 0.05},
]
def _compose(
memory: SceneMemory,
text: str,
classification: IntentMatch,
persona_hint: str,
duration_sec: int,
) -> ScenePlan:
intent = classification.intent_code or "smalltalk"
bucket = _INTENT_BUCKETS.get(intent, "smalltalk")
tier = _affinity_tier(memory.affinity_score)
reply = _REPLY_TEMPLATES.get(bucket, _REPLY_TEMPLATES["smalltalk"])[tier]
mood_tag = _MOOD_SCENE_TAG.get(memory.mood, _MOOD_SCENE_TAG["neutral"])
continuity = _short(text, 120) if text else ""
scene_prompt = _build_scene_prompt(
persona_hint=persona_hint,
mood=memory.mood,
mood_tag=mood_tag,
outfit=memory.outfit_state,
continuity=continuity,
intent_bucket=bucket,
)
narration = reply # phase-1: the character says what the chat bubble shows.
mood_delta = _mood_delta_for(intent)
return ScenePlan(
reply_text=reply,
narration=narration,
scene_prompt=scene_prompt,
duration_sec=max(2, min(int(duration_sec), 15)),
mood_delta=mood_delta,
topic_continuity=continuity,
intent_code=intent,
confidence=float(classification.confidence or 0.0),
)
def _build_scene_prompt(
*, persona_hint: str, mood: str, mood_tag: str,
outfit: Dict[str, Any], continuity: str, intent_bucket: str,
) -> str:
parts: List[str] = []
if persona_hint:
parts.append(persona_hint.strip())
parts.append(f"mood {mood}")
parts.append(mood_tag)
outfit_note = _outfit_note(outfit)
if outfit_note:
parts.append(outfit_note)
parts.append(f"scene beat: {intent_bucket}")
if continuity:
parts.append(f'responding to: "{continuity}"')
return ", ".join(p for p in parts if p)
def _outfit_note(outfit: Dict[str, Any]) -> str:
if not outfit:
return ""
bits: List[str] = []
for key in ("top", "bottom", "accessory", "hair"):
val = outfit.get(key)
if isinstance(val, str) and val:
bits.append(f"{key} {val}")
return ", ".join(bits)
def _mood_delta_for(intent: str) -> Dict[str, Any]:
for rule in _MOOD_SHIFT_RULES:
if rule["intent"] == intent:
out: Dict[str, Any] = {}
if rule["mood"]:
out["mood"] = rule["mood"]
if rule["affinity"]:
out["affinity"] = rule["affinity"]
return out
return {}
_WS_RE = re.compile(r"\s+")
def _short(text: str, limit: int) -> str:
cleaned = _WS_RE.sub(" ", text.strip())
if len(cleaned) <= limit:
return cleaned
return cleaned[: limit - 1].rstrip() + "…"