HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
17.8 kB
"""
SQLite DDL + low-level row CRUD for the interactive service.
Batch 2/8 β€” schema only. Higher-level logic lives in ``repo.py``
on top of these primitives; routers never touch store.py directly.
All tables are prefixed ``ix_`` to keep the interactive namespace
separated from other HomePilot modules (studio_*, voice_call_*,
users, file_assets, etc.). No foreign keys into non-interactive
tables β€” links are stored as string ids and resolved in code, so
dropping the whole interactive subsystem ( = DROP TABLE ix_* )
leaves the rest of the DB untouched.
Schema stability: this is v1. Future migrations live in a parallel
``migrations/`` package if / when they're needed.
"""
from __future__ import annotations
import json
import sqlite3
import time
import uuid
from contextlib import contextmanager
from typing import Any, Dict, Iterator, List, Optional, Tuple
from ..storage import _get_db_path
# ── Schema ────────────────────────────────────────────────────────
# Fifteen tables. Ordering matters only for documentation; SQLite
# doesn't enforce FKs by default and we never declare them on
# non-interactive targets.
_DDL: List[str] = [
# 1. ix_experiences β€” top-level interactive experience
"""
CREATE TABLE IF NOT EXISTS ix_experiences (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
studio_video_id TEXT DEFAULT '',
title TEXT NOT NULL,
description TEXT DEFAULT '',
objective TEXT DEFAULT '',
experience_mode TEXT NOT NULL DEFAULT 'sfw_general',
policy_profile_id TEXT NOT NULL DEFAULT 'sfw_general',
audience_profile TEXT DEFAULT '{}',
project_type TEXT NOT NULL DEFAULT 'standard',
branch_count INTEGER DEFAULT 0,
max_depth INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'draft',
tags TEXT DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_experiences_user ON ix_experiences(user_id)",
"CREATE INDEX IF NOT EXISTS idx_ix_experiences_mode ON ix_experiences(experience_mode)",
# 2. ix_nodes β€” scenes in the branch graph
"""
CREATE TABLE IF NOT EXISTS ix_nodes (
id TEXT PRIMARY KEY,
experience_id TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'scene',
title TEXT DEFAULT '',
narration TEXT DEFAULT '',
image_prompt TEXT DEFAULT '',
video_prompt TEXT DEFAULT '',
duration_sec INTEGER DEFAULT 5,
storyboard TEXT DEFAULT '{}',
interaction_layout TEXT DEFAULT '{}',
asset_ids TEXT DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_nodes_experience ON ix_nodes(experience_id)",
# 3. ix_edges β€” directed transitions
"""
CREATE TABLE IF NOT EXISTS ix_edges (
id TEXT PRIMARY KEY,
experience_id TEXT NOT NULL,
from_node_id TEXT NOT NULL,
to_node_id TEXT NOT NULL,
trigger_kind TEXT NOT NULL,
trigger_payload TEXT DEFAULT '{}',
ordinal INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_edges_experience ON ix_edges(experience_id)",
"CREATE INDEX IF NOT EXISTS idx_ix_edges_from ON ix_edges(from_node_id)",
# 4. ix_node_variants β€” language variants of a node
"""
CREATE TABLE IF NOT EXISTS ix_node_variants (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
language TEXT NOT NULL,
narration TEXT DEFAULT '',
subtitles TEXT DEFAULT '',
audio_asset_id TEXT DEFAULT '',
video_asset_id TEXT DEFAULT '',
UNIQUE(node_id, language)
)
""",
# 5. ix_sessions β€” per-viewer playback session
"""
CREATE TABLE IF NOT EXISTS ix_sessions (
id TEXT PRIMARY KEY,
experience_id TEXT NOT NULL,
viewer_ref TEXT DEFAULT '',
current_node_id TEXT DEFAULT '',
language TEXT DEFAULT 'en',
personalization TEXT DEFAULT '{}',
consent_version TEXT DEFAULT '',
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_event_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_sessions_experience ON ix_sessions(experience_id)",
# 6. ix_session_events β€” analytics event log
"""
CREATE TABLE IF NOT EXISTS ix_session_events (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
ts DATETIME DEFAULT CURRENT_TIMESTAMP,
event_kind TEXT NOT NULL,
node_id TEXT DEFAULT '',
edge_id TEXT DEFAULT '',
action_id TEXT DEFAULT '',
payload TEXT DEFAULT '{}'
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_session_events_session ON ix_session_events(session_id)",
# 7. ix_session_turns β€” chat transcript
"""
CREATE TABLE IF NOT EXISTS ix_session_turns (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
turn_role TEXT NOT NULL,
text TEXT NOT NULL,
action_id TEXT DEFAULT '',
node_id TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_session_turns_session ON ix_session_turns(session_id)",
# 8. ix_character_state β€” live persona state per session
"""
CREATE TABLE IF NOT EXISTS ix_character_state (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
persona_id TEXT NOT NULL DEFAULT '',
mood TEXT DEFAULT 'neutral',
affinity_score REAL DEFAULT 0.5,
outfit_state TEXT DEFAULT '{}',
recent_flags TEXT DEFAULT '[]',
language TEXT DEFAULT 'en',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id)
)
""",
# 9. ix_character_assets β€” reusable character media library
"""
CREATE TABLE IF NOT EXISTS ix_character_assets (
id TEXT PRIMARY KEY,
persona_id TEXT NOT NULL,
asset_id TEXT NOT NULL,
kind TEXT NOT NULL,
mood_tags TEXT DEFAULT '[]',
action_tags TEXT DEFAULT '[]',
language TEXT DEFAULT '',
outfit_tags TEXT DEFAULT '[]',
duration_sec REAL DEFAULT 0,
intensity REAL DEFAULT 0.5,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_character_assets_persona ON ix_character_assets(persona_id)",
# 10. ix_action_catalog β€” actions offered to the viewer
"""
CREATE TABLE IF NOT EXISTS ix_action_catalog (
id TEXT PRIMARY KEY,
experience_id TEXT NOT NULL,
label TEXT NOT NULL,
intent_code TEXT DEFAULT '',
required_level INTEGER DEFAULT 1,
required_scheme TEXT DEFAULT 'xp_level',
required_metric_key TEXT DEFAULT 'level',
policy_scope TEXT DEFAULT '[]',
cooldown_sec INTEGER DEFAULT 0,
mood_delta TEXT DEFAULT '{}',
xp_award INTEGER DEFAULT 0,
max_uses_per_session INTEGER DEFAULT 0,
repeat_penalty REAL DEFAULT 0,
requires_consent TEXT DEFAULT '',
applicable_modes TEXT DEFAULT '[]',
category TEXT NOT NULL DEFAULT 'expression',
edit_recipe TEXT DEFAULT '{}',
ordinal INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_action_catalog_experience ON ix_action_catalog(experience_id)",
# 11. ix_session_progress β€” generic progression metrics
"""
CREATE TABLE IF NOT EXISTS ix_session_progress (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
scheme TEXT NOT NULL,
metric_key TEXT NOT NULL,
metric_value REAL NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, scheme, metric_key)
)
""",
# 12. ix_personalization_rules β€” viewer-aware routing rules
"""
CREATE TABLE IF NOT EXISTS ix_personalization_rules (
id TEXT PRIMARY KEY,
experience_id TEXT NOT NULL,
name TEXT NOT NULL,
condition TEXT NOT NULL DEFAULT '{}',
action TEXT NOT NULL DEFAULT '{}',
priority INTEGER DEFAULT 100,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_pers_rules_experience ON ix_personalization_rules(experience_id)",
# 13. ix_intent_map β€” free-text intent β†’ action routing
"""
CREATE TABLE IF NOT EXISTS ix_intent_map (
id TEXT PRIMARY KEY,
experience_id TEXT NOT NULL,
intent_code TEXT NOT NULL,
action_id TEXT DEFAULT '',
fallback_node_id TEXT DEFAULT '',
priority INTEGER DEFAULT 100,
applicable_modes TEXT DEFAULT '[]'
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_intent_map_experience ON ix_intent_map(experience_id)",
# 14. ix_publications β€” published channel snapshots
"""
CREATE TABLE IF NOT EXISTS ix_publications (
id TEXT PRIMARY KEY,
experience_id TEXT NOT NULL,
channel TEXT NOT NULL,
manifest_url TEXT DEFAULT '',
version INTEGER DEFAULT 1,
metadata TEXT DEFAULT '{}',
published_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_publications_experience ON ix_publications(experience_id)",
# 15. ix_qa_reports β€” snapshot of last QA run
"""
CREATE TABLE IF NOT EXISTS ix_qa_reports (
id TEXT PRIMARY KEY,
experience_id TEXT NOT NULL,
kind TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '{}',
issues TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_qa_reports_experience ON ix_qa_reports(experience_id)",
# 16. ix_persona_sessions β€” persona-live progression state
"""
CREATE TABLE IF NOT EXISTS ix_persona_sessions (
id TEXT PRIMARY KEY,
persona_id TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'image',
current_level INTEGER DEFAULT 1,
xp INTEGER DEFAULT 0,
last_dialogue TEXT DEFAULT '',
scene_context TEXT DEFAULT '{}',
scene_memory TEXT DEFAULT '{}',
emotional_state TEXT DEFAULT '{}',
current_version_id TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_persona_sessions_persona ON ix_persona_sessions(persona_id)",
# 17. ix_persona_versions β€” immutable render/version tape
"""
CREATE TABLE IF NOT EXISTS ix_persona_versions (
id TEXT PRIMARY KEY,
persona_id TEXT NOT NULL,
session_id TEXT NOT NULL,
image_url TEXT DEFAULT '',
thumb_url TEXT DEFAULT '',
recipe TEXT DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"CREATE INDEX IF NOT EXISTS idx_ix_persona_versions_session ON ix_persona_versions(session_id)",
]
# ── Connection helpers ────────────────────────────────────────────
@contextmanager
def _conn() -> Iterator[sqlite3.Connection]:
"""Short-lived SQLite connection. Same DB as everything else."""
con = sqlite3.connect(_get_db_path())
con.row_factory = sqlite3.Row
try:
yield con
finally:
try:
con.close()
except Exception:
pass
def ensure_schema() -> None:
"""Idempotently apply the interactive DDL.
Safe to call from anywhere β€” FastAPI startup, migrations, tests.
All statements are ``CREATE TABLE IF NOT EXISTS`` / ``CREATE
INDEX IF NOT EXISTS`` so repeat invocations are no-ops.
"""
with _conn() as con:
cur = con.cursor()
for stmt in _DDL:
cur.execute(stmt)
_ensure_column(
cur, "ix_experiences", "project_type",
"TEXT NOT NULL DEFAULT 'standard'",
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_ix_experiences_project_type "
"ON ix_experiences(project_type)",
)
_ensure_column(
cur, "ix_action_catalog", "category",
"TEXT NOT NULL DEFAULT 'expression'",
)
_ensure_column(
cur, "ix_action_catalog", "edit_recipe",
"TEXT DEFAULT '{}'",
)
_ensure_column(
cur, "ix_persona_sessions", "mode",
"TEXT NOT NULL DEFAULT 'image'",
)
_ensure_column(
cur, "ix_persona_sessions", "last_dialogue",
"TEXT DEFAULT ''",
)
_ensure_column(
cur, "ix_persona_sessions", "scene_context",
"TEXT DEFAULT '{}'",
)
_ensure_column(
cur, "ix_persona_sessions", "scene_memory",
"TEXT DEFAULT '{}'",
)
_ensure_column(
cur, "ix_persona_sessions", "emotional_state",
"TEXT DEFAULT '{}'",
)
con.commit()
def _ensure_column(cur: sqlite3.Cursor, table: str, column: str, ddl: str) -> None:
"""Idempotent additive migration helper.
SQLite only recently gained ``ADD COLUMN IF NOT EXISTS`` support.
We stay portable by inspecting ``PRAGMA table_info`` and applying
``ALTER TABLE`` only when needed.
"""
rows = cur.execute(f"PRAGMA table_info({table})").fetchall()
existing = {str(r[1]) for r in rows}
if column in existing:
return
cur.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
# ── Id generation ─────────────────────────────────────────────────
def new_id(prefix: str) -> str:
"""Generate a short prefixed id. Uses uuid4 hex (no dashes).
Prefixes are deliberately short β€” ``ixe``, ``ixn``, ``ixe``
collide, so we use distinct ones per table:
ixe β†’ experience
ixn β†’ node
ixg β†’ edge (graph edge)
ixs β†’ session
ixv β†’ node variant
ixt β†’ session turn
ixa β†’ character asset / action catalog entry (context disambiguates)
ixp β†’ progress row / publication (context disambiguates)
ixr β†’ personalization rule / QA report
ixm β†’ intent map entry
ixc β†’ character state
"""
return f"{prefix}_{uuid.uuid4().hex[:18]}"
def now_iso() -> str:
"""ISO timestamp for manually-set updated_at fields."""
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
# ── JSON helpers (centralised so the row β†’ dict conversion is uniform) ──
def _parse_json(val: Any, default: Any) -> Any:
if val is None or val == "":
return default
if isinstance(val, (dict, list)):
return val
try:
return json.loads(val)
except (TypeError, ValueError):
return default
def _dump_json(val: Any) -> str:
try:
return json.dumps(val, separators=(",", ":"))
except (TypeError, ValueError):
return "{}"
def row_to_dict(row: sqlite3.Row, json_fields: Tuple[str, ...]) -> Dict[str, Any]:
"""Convert a Row into a dict, JSON-decoding the named fields.
``json_fields`` that are missing from the row are silently
skipped β€” safe for partial SELECTs.
"""
out: Dict[str, Any] = {k: row[k] for k in row.keys()}
for k in json_fields:
if k in out:
default: Any = {} if k.endswith("_state") or k == "storyboard" or k == "interaction_layout" \
or k == "personalization" or k == "audience_profile" \
or k == "condition" or k == "action" or k == "trigger_payload" \
or k == "mood_delta" or k == "outfit_state" or k == "metadata" \
or k == "payload" or k == "summary" else []
out[k] = _parse_json(out[k], default)
return out