239 lines
7 KiB
Python
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()
|