HomePilot / backend /app /interactive /playback /scene_memory.py
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
5.55 kB
"""
Scene memory β€” rolling context for the real-time scene planner.
PLAY-1/8. Pure, synchronous snapshot builder β€” no LLM calls here.
PLAY-2's scene planner will read from this module on every turn
and call ``set_synopsis`` when it decides the rolling one-liner
needs a refresh.
Persistence model:
- Character state (mood / affinity / outfit) comes from
``ix_character_state``.
- Transcript turns come from ``ix_session_turns``.
- The rolling synopsis lives in a module-level cache keyed by
session id β€” the same in-process pattern used by
``interaction/cooldown.py``. Short live-play sessions don't
need DB-backed continuity; if that changes, promoting this
dict to an additive DB column is a single-patch swap that
keeps the public API stable.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from .. import repo, store
_DEFAULT_RECENT_N = 6
_DEFAULT_REFRESH_EVERY = 6
@dataclass(frozen=True)
class TurnSnapshot:
"""One row from ``ix_session_turns``, prompt-friendly."""
role: str # 'viewer' | 'character' | 'system'
text: str
action_id: str = ""
node_id: str = ""
created_at: str = ""
@dataclass(frozen=True)
class SceneMemory:
"""Everything the scene planner needs, in one bundle.
Frozen so the planner can't accidentally mutate the snapshot
between turns.
"""
session_id: str
experience_id: str
persona_id: str
current_node_id: str
mood: str
affinity_score: float
outfit_state: Dict[str, Any]
recent_turns: List[TurnSnapshot]
total_turns: int
synopsis: str
turns_since_synopsis: int
# ``(synopsis_text, total_turns_at_time_of_set)``
_SYNOPSIS_CACHE: Dict[str, Tuple[str, int]] = {}
def build_scene_memory(
session_id: str, *, recent_n: int = _DEFAULT_RECENT_N,
) -> Optional[SceneMemory]:
"""Assemble a snapshot or return ``None`` if the session is gone."""
sess = repo.get_session(session_id)
if not sess:
return None
cs = _fetch_character_state(session_id)
turns = _fetch_recent_turns(session_id, recent_n)
total = _count_turns(session_id)
synopsis_text, at_count = _SYNOPSIS_CACHE.get(session_id, ("", 0))
turns_since = max(0, total - at_count)
mood = str((cs.get("mood") if cs else None) or "neutral")
affinity = float((cs.get("affinity_score") if cs else None) or 0.5)
outfit = dict((cs.get("outfit_state") if cs else None) or {})
persona = str((cs.get("persona_id") if cs else None) or "")
return SceneMemory(
session_id=session_id,
experience_id=sess.experience_id,
persona_id=persona,
current_node_id=sess.current_node_id or "",
mood=mood,
affinity_score=affinity,
outfit_state=outfit,
recent_turns=turns,
total_turns=total,
synopsis=synopsis_text,
turns_since_synopsis=turns_since,
)
def set_synopsis(session_id: str, synopsis: str, at_turn_count: int) -> None:
"""Store a freshly-generated synopsis for this session."""
_SYNOPSIS_CACHE[session_id] = (synopsis, int(at_turn_count))
def should_refresh_synopsis(
memory: SceneMemory, *, every: int = _DEFAULT_REFRESH_EVERY,
) -> bool:
"""Tell the caller whether enough new turns have accumulated.
Bootstrap: the very first refresh fires at half the cadence so
the planner gets an early anchor instead of a completely
context-less first few scenes.
"""
if every <= 0:
return False
if not memory.synopsis:
return memory.total_turns >= max(1, every // 2)
return memory.turns_since_synopsis >= every
def reset_session(session_id: str) -> None:
"""Drop the synopsis cache entry for a session (useful in tests)."""
_SYNOPSIS_CACHE.pop(session_id, None)
# ── Internals ───────────────────────────────────────────────────
def _fetch_character_state(session_id: str) -> Optional[Dict[str, Any]]:
store.ensure_schema()
with store._conn() as con:
row = con.execute(
"SELECT * FROM ix_character_state WHERE session_id = ?", (session_id,),
).fetchone()
if not row:
return None
return store.row_to_dict(row, json_fields=("outfit_state", "recent_flags"))
def _fetch_recent_turns(session_id: str, n: int) -> List[TurnSnapshot]:
store.ensure_schema()
with store._conn() as con:
rows = con.execute(
"SELECT turn_role, text, action_id, node_id, created_at "
"FROM ix_session_turns WHERE session_id = ? "
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
(session_id, max(1, min(n, 50))),
).fetchall()
out: List[TurnSnapshot] = []
for r in rows:
d = dict(r)
out.append(TurnSnapshot(
role=str(d.get("turn_role") or ""),
text=str(d.get("text") or ""),
action_id=str(d.get("action_id") or ""),
node_id=str(d.get("node_id") or ""),
created_at=str(d.get("created_at") or ""),
))
out.reverse() # chronological for prompt building
return out
def _count_turns(session_id: str) -> int:
store.ensure_schema()
with store._conn() as con:
row = con.execute(
"SELECT COUNT(*) AS c FROM ix_session_turns WHERE session_id = ?",
(session_id,),
).fetchone()
return int(row["c"] if row else 0)