ligbox-ops-platform/api/app/assist_routes.py
Ligbox Spec Hub 3a2c64834b Initial import: ligbox-ops-platform + specs + LAPTOP + obsidian merge (CT130)
Source: VM122 /opt + obsidian-infra + LAPTOP
Hub: CT130 spec-hub 10.10.10.130
2026-06-19 17:26:41 +00:00

554 lines
22 KiB
Python

"""Assist / takeover API — Spec 010 Phase A."""
from __future__ import annotations
import os
import secrets
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter, Depends, HTTPException
from app import assist_catalog, assist_store, auth
from app.permissions import (
can_assist_handoff,
can_assist_takeover,
can_read_assist,
)
router = APIRouter(prefix="/api/v1/assist", tags=["assist"])
VM112_ASSIST_API = os.getenv("VM112_ASSIST_API_URL", os.getenv("VM112_API_URL", "http://10.10.10.112:8090"))
VM112_ASSIST_TOKEN = os.getenv("VM112_ASSIST_SERVICE_TOKEN", "")
DESK_ASSIST_ENABLED = os.getenv("DESK_ASSIST_ENABLED", "true").lower() in ("1", "true", "yes")
VM112_ASSIST_CALL = os.getenv("VM112_ASSIST_CALL_VM112", "false").lower() in ("1", "true", "yes")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _main():
from app import main as m
return m
def _session_meta(conn, session_id: str) -> dict:
m = _main()
meta = assist_store.session_funnel_meta(
conn, session_id, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
)
if not meta.get("session_id"):
raise HTTPException(404, "sessão não encontrada no funil")
if meta["funnel_rank"] == 0 and not meta.get("last_event_at"):
raise HTTPException(404, "sessão não encontrada no funil")
return meta
def _parse_payload(raw: str | None) -> dict:
m = _main()
return m._parse_payload(raw)
def _build_session_view(conn, session_id: str, user: auth.DeskUser) -> dict:
m = _main()
meta = _session_meta(conn, session_id)
assist = assist_store.get_active_assist(conn, session_id)
ticket_row = assist_store.find_ticket_by_session(conn, session_id)
ticket = None
if ticket_row:
full = conn.execute(
f"SELECT {m.TICKET_COLUMNS}, session_id, assist_mode, assisted_by, assisted_at, client_paused FROM tickets WHERE id = ?",
(int(ticket_row["id"]),),
).fetchone()
if full:
ticket = m._visible_ticket(m._enrich_ticket(full), user)
view = {
**meta,
"assist_status": assist["status"] if assist else None,
"assist_session_id": assist["id"] if assist else None,
"assisted_by": assist.get("initiated_by_user") if assist else (ticket or {}).get("assisted_by"),
"ticket_id": int(ticket_row["id"]) if ticket_row else None,
"ticket_status": ticket_row["status"] if ticket_row else None,
"assigned_to": ticket_row["assigned_to"] if ticket_row else None,
"can_takeover": meta["can_escalate"] and can_assist_takeover(user.role) and not (assist and assist.get("status") == "active"),
}
if ticket:
view["ticket"] = ticket
return view
def _call_vm112(
method: str,
path: str,
json_body: dict | None = None,
session_id: str | None = None,
) -> dict | None:
if not VM112_ASSIST_CALL:
return None
headers: dict[str, str] = {}
if VM112_ASSIST_TOKEN:
headers["X-Desk-Assist-Token"] = VM112_ASSIST_TOKEN
if session_id:
headers["X-Onboarding-Session"] = session_id
try:
with httpx.Client(timeout=30.0) as client:
r = client.request(method, f"{VM112_ASSIST_API.rstrip('/')}{path}", json=json_body, headers=headers)
if r.status_code >= 400:
return {"error": r.text[:200], "http_status": r.status_code}
return r.json() if r.headers.get("content-type", "").startswith("application/json") else {"ok": True}
except Exception as exc:
return {"error": str(exc)}
@router.get("/sessions")
def list_assist_sessions(user: auth.DeskUser = Depends(auth.get_current_user)):
if not DESK_ASSIST_ENABLED:
raise HTTPException(503, "assistência desactivada")
if not can_read_assist(user.role):
raise HTTPException(403, "permissão insuficiente")
m = _main()
with m.db() as conn:
funnel = m._funnel_summary(conn, window_hours=48)
session_ids = [s["session_id"] for s in funnel.get("active_sessions", []) if s.get("session_id")]
assist_map = assist_store.get_assist_state_map(conn, session_ids)
sessions = []
for item in funnel.get("active_sessions", []):
sid = item.get("session_id")
if not sid:
continue
assist = assist_map.get(sid)
ticket_row = assist_store.find_ticket_by_session(conn, sid)
status = "observing"
if assist and assist.get("status") == "active":
status = "assisting"
elif ticket_row and ticket_row["status"] in ("escalated", "assisting"):
status = ticket_row["status"]
meta = assist_store.session_funnel_meta(
conn, sid, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
)
sessions.append({
**item,
"assist_status": status,
"assisted_by": assist.get("initiated_by_user") if assist else None,
"assigned_to": ticket_row["assigned_to"] if ticket_row else None,
"can_escalate": meta.get("can_escalate", False),
})
return {"sessions": sessions, "window_hours": funnel.get("window_hours", 48)}
@router.get("/sessions/{session_id}")
def get_assist_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_read_assist(user.role):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
m = _main()
with m.db() as conn:
view = _build_session_view(conn, sid, user)
timeline = m._session_timeline(conn, sid)
from app.funnel_timing import apply_module_timing
enriched, timing_meta = apply_module_timing(timeline)
view["timeline"] = enriched
if timing_meta:
view["timing"] = timing_meta
actions = []
if view.get("assist_session_id"):
rows = conn.execute(
"""
SELECT actor, action, payload, created_at
FROM assist_actions WHERE assist_session_id = ?
ORDER BY id ASC LIMIT 50
""",
(view["assist_session_id"],),
).fetchall()
actions = [
{
"actor": r["actor"],
"action": r["action"],
"payload": _parse_payload(r["payload"]),
"created_at": r["created_at"],
}
for r in rows
]
view["actions"] = actions
return view
@router.post("/sessions/{session_id}/escalate")
def escalate_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_assist_takeover(user.role):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
now = _now()
m = _main()
with m.db() as conn:
meta = _session_meta(conn, sid)
if not meta["can_escalate"]:
raise HTTPException(400, "escalada só após validação de domínio")
active = assist_store.get_active_assist(conn, sid)
open_assist = assist_store.get_open_assist(conn, sid)
if active:
raise HTTPException(
409,
detail={
"message": "sessão já em assistência",
"assisted_by": active.get("initiated_by_user"),
},
)
if open_assist and open_assist.get("status") == "escalated" and open_assist.get("initiated_by_user") not in (None, user.username):
raise HTTPException(
409,
detail={
"message": "sessão já escalada por outro técnico",
"assisted_by": open_assist.get("initiated_by_user"),
},
)
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, meta.get("domain"))
conn.execute(
"""
UPDATE tickets
SET status = 'escalated', assigned_to = ?, assigned_at = ?, session_id = ?
WHERE id = ?
""",
(user.username, now, sid, ticket_id),
)
if open_assist and open_assist.get("status") == "escalated":
assist_id = int(open_assist["id"])
conn.execute(
"UPDATE assist_sessions SET initiated_by_user = ?, initiated_by = 'technician' WHERE id = ?",
(user.username, assist_id),
)
else:
cur = conn.execute(
"""
INSERT INTO assist_sessions
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain, started_at)
VALUES (?, ?, 'technician', ?, 'escalated', ?, ?, ?)
""",
(sid, ticket_id, user.username, meta.get("funnel_stage"), meta.get("domain"), now),
)
assist_id = int(cur.lastrowid)
assist_store.log_action(conn, assist_id, user.username, "escalate", {"source": "desk"})
conn.commit()
view = _build_session_view(conn, sid, user)
return {"status": "escalated", "ticket_id": ticket_id, "session": view}
@router.post("/sessions/{session_id}/takeover")
def takeover_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_assist_takeover(user.role):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
now = _now()
m = _main()
with m.db() as conn:
meta = _session_meta(conn, sid)
if not meta["can_escalate"]:
raise HTTPException(400, "takeover só após validação de domínio")
active = assist_store.get_active_assist(conn, sid)
open_assist = assist_store.get_open_assist(conn, sid)
if active and active.get("initiated_by_user") not in (None, user.username):
raise HTTPException(
409,
detail={
"message": "sessão já assumida por outro técnico",
"assisted_by": active.get("initiated_by_user"),
},
)
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, meta.get("domain"))
token_hash = assist_store.hash_token(secrets.token_urlsafe(32))
if open_assist and open_assist.get("status") == "escalated":
assist_id = int(open_assist["id"])
conn.execute(
"""
UPDATE assist_sessions
SET status = 'active', initiated_by_user = ?, takeover_token_hash = ?
WHERE id = ?
""",
(user.username, token_hash, assist_id),
)
elif active:
assist_id = int(active["id"])
else:
cur = conn.execute(
"""
INSERT INTO assist_sessions
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain,
takeover_token_hash, started_at)
VALUES (?, ?, 'technician', ?, 'active', ?, ?, ?, ?)
""",
(sid, ticket_id, user.username, meta.get("funnel_stage"), meta.get("domain"), token_hash, now),
)
assist_id = int(cur.lastrowid)
conn.execute(
"""
UPDATE tickets
SET status = 'assisting', assigned_to = ?, assigned_at = ?, session_id = ?,
assist_mode = 'asm', assisted_by = ?, assisted_at = ?, client_paused = 1
WHERE id = ?
""",
(user.username, now, sid, user.username, now, ticket_id),
)
assist_store.log_action(conn, assist_id, user.username, "takeover", {"phase": "A"})
conn.commit()
_call_vm112("POST", f"/onboarding/sessions/{sid}/pause", session_id=sid)
vm112_result = _call_vm112(
"POST",
f"/onboarding/sessions/{sid}/takeover",
{"technician": user.username},
session_id=sid,
)
if vm112_result and vm112_result.get("takeover_url"):
takeover_url = vm112_result["takeover_url"]
else:
base = os.getenv("VM112_WIZARD_URL", "https://onboard.ibytera.com")
takeover_url = f"{base.rstrip('/')}/assist/{sid}?desk=1"
return {
"status": "assisting",
"ticket_id": ticket_id,
"takeover_url": takeover_url,
"client_paused": True,
"vm112": vm112_result,
"note": "ASM activo — wizard VM112 pausado para o cliente",
}
@router.post("/sessions/{session_id}/handoff")
def handoff_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_assist_handoff(user.role, user.username):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
now = _now()
m = _main()
with m.db() as conn:
active = assist_store.get_active_assist(conn, sid)
if not active:
raise HTTPException(404, "nenhuma assistência activa")
if user.role == "technician" and active.get("initiated_by_user") not in (None, user.username):
raise HTTPException(403, "sessão de outro técnico")
ticket_row = assist_store.find_ticket_by_session(conn, sid)
ticket_id = int(ticket_row["id"]) if ticket_row else active.get("ticket_id")
conn.execute(
"UPDATE assist_sessions SET status = 'ended', ended_at = ? WHERE id = ?",
(now, int(active["id"])),
)
if ticket_id:
conn.execute(
"""
UPDATE tickets
SET status = 'resolved', client_paused = 0, assist_mode = NULL
WHERE id = ?
""",
(ticket_id,),
)
assist_store.log_action(conn, int(active["id"]), user.username, "handoff", {})
conn.commit()
_call_vm112("POST", f"/onboarding/sessions/{sid}/resume", session_id=sid)
return {"status": "handoff", "ticket_id": ticket_id}
@router.get("/sessions/{session_id}/links")
def session_external_links(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_read_assist(user.role):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
m = _main()
with m.db() as conn:
meta = _session_meta(conn, sid)
return {
"session_id": sid,
"domain": meta.get("domain"),
"links": assist_catalog.external_links(meta.get("domain")),
}
@router.get("/sessions/{session_id}/actions")
def list_session_actions(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_read_assist(user.role):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
m = _main()
with m.db() as conn:
meta = _session_meta(conn, sid)
assist = assist_store.get_active_assist(conn, sid)
ticket_row = assist_store.find_ticket_by_session(conn, sid)
is_assisting = bool(assist and assist.get("status") == "active") or (
ticket_row and ticket_row["status"] == "assisting"
)
actions = assist_catalog.list_actions_for_session(
meta.get("funnel_stage"), is_assisting, user.role
)
return {"session_id": sid, "actions": actions, "is_assisting": is_assisting}
@router.post("/sessions/{session_id}/actions/{action_id}")
def run_session_action(
session_id: str,
action_id: str,
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_assist_takeover(user.role):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
action = action_id.strip()
now = _now()
m = _main()
with m.db() as conn:
meta = _session_meta(conn, sid)
domain = (meta.get("domain") or "").strip()
if not domain:
raise HTTPException(400, "domínio não disponível na sessão")
assist = assist_store.get_active_assist(conn, sid)
open_assist = assist_store.get_open_assist(conn, sid)
assist_id = int(assist["id"]) if assist else (int(open_assist["id"]) if open_assist else None)
ticket_row = assist_store.find_ticket_by_session(conn, sid)
ticket_payload = _parse_payload(ticket_row["payload"]) if ticket_row else {}
is_assisting = bool(assist and assist.get("status") == "active")
if action == assist_catalog.ABORT_ACTION:
if user.role not in ("super_admin", "ops_lead"):
raise HTTPException(403, "abortar só ops_lead+")
if ticket_row:
conn.execute(
"UPDATE tickets SET status = 'closed', client_paused = 0, assist_mode = NULL WHERE id = ?",
(int(ticket_row["id"]),),
)
if assist:
conn.execute(
"UPDATE assist_sessions SET status = 'ended', ended_at = ? WHERE id = ?",
(now, int(assist["id"])),
)
if assist_id:
assist_store.log_action(conn, assist_id, user.username, f"action.{action}", {"domain": domain})
conn.commit()
_call_vm112("POST", f"/onboarding/sessions/{sid}/resume", session_id=sid)
return {"status": "aborted", "action": action, "session_id": sid}
if action == assist_catalog.MARK_STEP_ACTION:
if not is_assisting:
raise HTTPException(400, "acção só em modo assistindo")
if assist_id:
assist_store.log_action(conn, assist_id, user.username, f"action.{action}", {"domain": domain})
conn.commit()
return {"status": "ok", "action": action, "note": "passo marcado no audit log"}
if action not in assist_catalog.DESK_ACTIONS:
raise HTTPException(400, f"acção inválida: {action}")
spec = assist_catalog.DESK_ACTIONS[action]
if assist_catalog.funnel_rank(meta.get("funnel_stage")) < spec["min_rank"]:
raise HTTPException(400, "etapa do funil insuficiente para esta acção")
if action not in ("dns.revalidate", "account.retry_sync") and not is_assisting:
raise HTTPException(400, "assuma a sessão antes desta acção")
method, path, body = assist_catalog.build_vm112_request(action, domain, ticket_payload)
vm112_result = _call_vm112(method, path, body, session_id=sid)
if not assist_id:
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, domain)
cur = conn.execute(
"""
INSERT INTO assist_sessions
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain, started_at)
VALUES (?, ?, 'technician', ?, 'escalated', ?, ?, ?)
""",
(sid, ticket_id, user.username, meta.get("funnel_stage"), domain, now),
)
assist_id = int(cur.lastrowid)
assist_store.log_action(
conn,
assist_id,
user.username,
f"action.{action}",
{"domain": domain, "vm112": vm112_result},
)
conn.commit()
if vm112_result is None and not VM112_ASSIST_CALL:
return {
"status": "logged",
"action": action,
"note": "VM112_ASSIST_CALL_VM112=false — acção registada no audit",
}
if vm112_result and vm112_result.get("http_status", 0) >= 400:
raise HTTPException(502, detail={"message": "VM112 rejeitou acção", "vm112": vm112_result})
return {"status": "ok", "action": action, "vm112": vm112_result}
@router.get("/technicians/ranking")
def technicians_ranking(
window_days: int = 30,
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_read_assist(user.role):
raise HTTPException(403, "permissão insuficiente")
if user.role == "noc":
raise HTTPException(403, "permissão insuficiente")
days = max(1, min(window_days, 365))
m = _main()
with m.db() as conn:
ranking = assist_catalog.technician_ranking(conn, window_days=days)
return {"window_days": days, "ranking": ranking, "total": len(ranking)}
def process_assist_started(conn, body, now: str) -> dict:
m = _main()
sid = (body.session_id or "").strip()
technician = (body.data or {}).get("technician")
meta = assist_store.session_funnel_meta(
conn, sid, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
)
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, body.domain or meta.get("domain"))
conn.execute(
"""
UPDATE tickets
SET status = 'assisting', session_id = ?, assist_mode = 'asm',
assisted_by = ?, assisted_at = ?, client_paused = 1
WHERE id = ?
""",
(sid, technician, now, ticket_id),
)
return {"handled": True, "ticket_id": ticket_id, "session_id": sid}
def process_assist_ended(conn, body, now: str) -> dict:
sid = (body.session_id or "").strip()
ticket_row = assist_store.find_ticket_by_session(conn, sid)
if ticket_row:
conn.execute(
"UPDATE tickets SET status = 'resolved', client_paused = 0, assist_mode = NULL WHERE id = ?",
(int(ticket_row["id"]),),
)
return {"handled": True, "ticket_id": int(ticket_row["id"]), "session_id": sid}
return {"handled": False, "session_id": sid}
def process_escalation_webhook(conn, body, now: str) -> dict:
"""Handle onboarding.escalated / onboarding.failed from VM112."""
m = _main()
sid = (body.session_id or "").strip()
if not sid:
return {"handled": False, "reason": "missing session_id"}
meta = assist_store.session_funnel_meta(
conn, sid, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
)
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, body.domain or meta.get("domain"))
conn.execute(
"UPDATE tickets SET status = 'escalated', session_id = ? WHERE id = ?",
(sid, ticket_id),
)
if not assist_store.get_open_assist(conn, sid):
cur = conn.execute(
"""
INSERT INTO assist_sessions
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain, started_at)
VALUES (?, ?, 'client', NULL, 'escalated', ?, ?, ?)
""",
(sid, ticket_id, meta.get("funnel_stage"), body.domain or meta.get("domain"), now),
)
assist_store.log_action(conn, int(cur.lastrowid), "client", "escalate", {"event": body.event})
return {"handled": True, "ticket_id": ticket_id, "session_id": sid}