| """ |
| 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 |
| 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_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) |
|
|
|
|
| |
|
|
| 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() |
| 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) |
|
|