"""Assist / takeover session storage — Spec 010 Phase A.""" from __future__ import annotations import hashlib import json import sqlite3 from datetime import datetime, timezone from typing import Any ASSIST_MIN_RANK = 2 # domain.validated ASSIST_MIN_STAGE = "domain_validated" TICKET_ASSIST_STATUSES = frozenset({"open", "escalated", "assisting", "resolved", "closed"}) TICKET_ACTIVE_STATUSES = frozenset({"open", "escalated", "assisting", "resolved"}) def init_assist_schema(conn: sqlite3.Connection) -> None: conn.executescript( """ CREATE TABLE IF NOT EXISTS assist_sessions ( id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, ticket_id INTEGER, initiated_by TEXT NOT NULL, initiated_by_user TEXT, status TEXT NOT NULL, funnel_stage TEXT, domain TEXT, takeover_token_hash TEXT, started_at TEXT NOT NULL, ended_at TEXT, audit_summary TEXT ); CREATE INDEX IF NOT EXISTS idx_assist_sessions_session ON assist_sessions(session_id); CREATE TABLE IF NOT EXISTS assist_actions ( id INTEGER PRIMARY KEY, assist_session_id INTEGER NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, payload TEXT, created_at TEXT NOT NULL ); """ ) cols = {row[1] for row in conn.execute("PRAGMA table_info(tickets)").fetchall()} for col, ddl in ( ("session_id", "TEXT"), ("assist_mode", "TEXT"), ("assisted_by", "TEXT"), ("assisted_at", "TEXT"), ("client_paused", "INTEGER NOT NULL DEFAULT 0"), ): if col not in cols: conn.execute(f"ALTER TABLE tickets ADD COLUMN {col} {ddl}") def _now() -> str: return datetime.now(timezone.utc).isoformat() def _parse_payload(raw: str | None) -> dict: if not raw: return {} try: return json.loads(raw) except json.JSONDecodeError: return {} def log_action( conn: sqlite3.Connection, assist_session_id: int, actor: str, action: str, payload: dict | None = None, ) -> None: conn.execute( """ INSERT INTO assist_actions (assist_session_id, actor, action, payload, created_at) VALUES (?, ?, ?, ?, ?) """, (assist_session_id, actor, action, json.dumps(payload or {}), _now()), ) def get_active_assist(conn: sqlite3.Connection, session_id: str) -> dict | None: sid = (session_id or "").strip() if not sid: return None row = conn.execute( """ SELECT * FROM assist_sessions WHERE session_id = ? AND status = 'active' AND ended_at IS NULL ORDER BY id DESC LIMIT 1 """, (sid,), ).fetchone() return dict(row) if row else None def get_open_assist(conn: sqlite3.Connection, session_id: str) -> dict | None: """Sessão de assistência aberta (escalated ou active).""" sid = (session_id or "").strip() if not sid: return None row = conn.execute( """ SELECT * FROM assist_sessions WHERE session_id = ? AND ended_at IS NULL AND status IN ('escalated', 'active') ORDER BY id DESC LIMIT 1 """, (sid,), ).fetchone() return dict(row) if row else None def get_assist_state_map(conn: sqlite3.Connection, session_ids: list[str]) -> dict[str, dict]: if not session_ids: return {} placeholders = ",".join("?" * len(session_ids)) rows = conn.execute( f""" SELECT a.* FROM assist_sessions a INNER JOIN ( SELECT session_id, MAX(id) AS max_id FROM assist_sessions WHERE session_id IN ({placeholders}) AND ended_at IS NULL GROUP BY session_id ) latest ON a.id = latest.max_id """, session_ids, ).fetchall() out: dict[str, dict] = {} for row in rows: item = dict(row) out[item["session_id"]] = item return out def session_funnel_meta( conn: sqlite3.Connection, session_id: str, funnel_event_rank: dict[str, int], funnel_stage_by_rank: dict[int, str], onboard_source: str = "vm112-onboard", ) -> dict[str, Any]: sid = (session_id or "").strip() rows = conn.execute( """ SELECT event_type, payload, created_at FROM webhook_events WHERE source = ? ORDER BY id ASC """, (onboard_source,), ).fetchall() max_rank = 0 domain = None failed = False last_event_at = None for row in rows: payload = _parse_payload(row["payload"]) if (payload.get("session_id") or "").strip() != sid: continue if payload.get("domain"): domain = payload.get("domain") last_event_at = row["created_at"] if row["event_type"] == "onboarding.failed": failed = True max_rank = max(max_rank, 99) else: rank = funnel_event_rank.get(row["event_type"], 0) if not failed: max_rank = max(max_rank, rank) if failed: stage = "failed" else: stage = funnel_stage_by_rank.get(max_rank, "started") return { "session_id": sid, "domain": domain, "funnel_stage": stage, "funnel_rank": max_rank, "can_escalate": max_rank >= ASSIST_MIN_RANK or failed, "last_event_at": last_event_at, } def find_ticket_by_session(conn: sqlite3.Connection, session_id: str) -> sqlite3.Row | None: sid = (session_id or "").strip() if not sid: return None row = conn.execute( "SELECT id, status, assigned_to, session_id, payload FROM tickets WHERE session_id = ? ORDER BY id DESC LIMIT 1", (sid,), ).fetchone() if row: return row rows = conn.execute("SELECT id, status, assigned_to, session_id, payload FROM tickets ORDER BY id DESC LIMIT 300").fetchall() for item in rows: payload = _parse_payload(item["payload"]) if (payload.get("session_id") or "").strip() == sid: return item return None def ensure_onboard_ticket( conn: sqlite3.Connection, session_id: str, domain: str | None, tenant_id: int = 1, ) -> int: existing = find_ticket_by_session(conn, session_id) if existing: return int(existing["id"]) now = _now() payload = json.dumps({ "event": "onboarding.escalated", "domain": domain, "session_id": session_id, "source": "vm112-onboard", "data": {"reason": "desk_assist"}, }) subject = f"[assist] {domain or 'sem dominio'} — {session_id[:12]}" cur = conn.execute( """ INSERT INTO tickets (tenant_id, subject, status, payload, created_at, session_id, assigned_to, assigned_at) VALUES (?, ?, 'escalated', ?, ?, ?, NULL, NULL) """, (tenant_id, subject, payload, now, session_id), ) return int(cur.lastrowid) def hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest()