ligbox-ops-platform/projects/ops-desk/api/app/assist_store.py
Ligbox Spec Hub 821675ab4a Reorganize monorepo into projects/wizard, ops-desk, finance
Specs stay at repo root (cross-VM). Move deploy and code into logical
projects with README per domain, updated manifest.yaml, and symlinks at
legacy paths for VM122 backward compatibility.
2026-06-19 18:55:03 +00:00

239 lines
7 KiB
Python

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