File size: 17,785 Bytes
921d377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
"""
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