from __future__ import annotations from collections import Counter import json from fastapi import Depends, FastAPI from sqlalchemy import select from services.shared.core import Role, utc_now_iso from services.shared.db import get_session from services.shared.event_bus import append_outbox_event, event_bus_enabled from services.shared.models import AgentStateIn, AgentStateOut, HealthResponse from services.shared.security import require_roles from services.shared.sql_init import init_sql_schema from services.shared.sql_models import ( Interaction, SupervisorAgentStateRow, SupervisorQueueSnapshotRow, VoiceEventRow, ) app = FastAPI(title="supervisor-service", version="1.1.0") init_sql_schema() def _agent_out(row: SupervisorAgentStateRow) -> AgentStateOut: return AgentStateOut( agent_id=row.agent_id, state=row.state, queue_id=row.queue_id, updated_at=row.updated_at, ) @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: return HealthResponse(status="ok", service="supervisor-service", version="v1.1") @app.post("/supervisor/agent-states", response_model=AgentStateOut) def set_agent_state( payload: AgentStateIn, _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), ) -> AgentStateOut: session = get_session() try: row = session.execute( select(SupervisorAgentStateRow).where(SupervisorAgentStateRow.agent_id == payload.agent_id) ).scalar_one_or_none() if not row: row = SupervisorAgentStateRow( agent_id=payload.agent_id, state=payload.state, queue_id=payload.queue_id, updated_at=utc_now_iso(), ) session.add(row) else: row.state = payload.state row.queue_id = payload.queue_id row.updated_at = utc_now_iso() if event_bus_enabled(): append_outbox_event( session, event_type="agent.state.changed", producer_service="supervisor-service", entity_type="agent_state", entity_id=payload.agent_id, payload={ "event": "agent.state.changed", "agent_id": payload.agent_id, "state": payload.state, "queue_id": payload.queue_id, "updated_at": row.updated_at, }, ) session.commit() session.refresh(row) return _agent_out(row) finally: session.close() @app.get("/supervisor/agents", response_model=list[AgentStateOut]) def list_agents( _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), ) -> list[AgentStateOut]: session = get_session() try: rows = session.execute( select(SupervisorAgentStateRow).order_by(SupervisorAgentStateRow.id.asc()) ).scalars().all() return [_agent_out(r) for r in rows] finally: session.close() @app.post("/supervisor/queue-metrics") def update_queue_metrics( queue_id: str, in_queue: int, avg_wait_seconds: int, _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), ) -> dict: session = get_session() try: row = session.execute( select(SupervisorQueueSnapshotRow).where(SupervisorQueueSnapshotRow.queue_id == queue_id) ).scalar_one_or_none() if not row: row = SupervisorQueueSnapshotRow( queue_id=queue_id, in_queue=in_queue, avg_wait_seconds=avg_wait_seconds, updated_at=utc_now_iso(), ) session.add(row) else: row.in_queue = in_queue row.avg_wait_seconds = avg_wait_seconds row.updated_at = utc_now_iso() session.commit() return { "queue_id": row.queue_id, "in_queue": row.in_queue, "avg_wait_seconds": row.avg_wait_seconds, "updated_at": row.updated_at, } finally: session.close() @app.get("/supervisor/realtime") def realtime( _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), ) -> dict: session = get_session() try: agents = session.execute( select(SupervisorAgentStateRow).order_by(SupervisorAgentStateRow.id.asc()) ).scalars().all() snapshots = session.execute( select(SupervisorQueueSnapshotRow).order_by(SupervisorQueueSnapshotRow.id.asc()) ).scalars().all() items = [ { "agent_id": a.agent_id, "state": a.state, "queue_id": a.queue_id, "updated_at": a.updated_at, } for a in agents ] status_counts = Counter(a["state"] for a in items) return { "agents": { "total": len(items), "by_state": dict(status_counts), "items": items, }, "queues": [ { "queue_id": q.queue_id, "in_queue": q.in_queue, "avg_wait_seconds": q.avg_wait_seconds, "updated_at": q.updated_at, } for q in snapshots ], "timestamp": utc_now_iso(), } finally: session.close() @app.get("/supervisor/live-calls") def live_calls( limit: int = 20, _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), ) -> dict: session = get_session() try: event_rows = session.execute( select(VoiceEventRow).order_by(VoiceEventRow.id.desc()).limit(500) ).scalars().all() interaction_rows = session.execute( select(Interaction).order_by(Interaction.id.desc()) ).scalars().all() interaction_by_id = {row.interaction_id: row for row in interaction_rows} calls_by_id: dict[str, dict] = {} tracked_types = {"call.started", "call.ended", "recording.ready"} for row in event_rows[:500]: payload = {} try: payload = json.loads(row.payload_json or "{}") except Exception: # noqa: BLE001 payload = {} if payload.get("source") != "asterisk": continue if row.event_type not in tracked_types: continue call = calls_by_id.get(row.call_id) or { "call_id": row.call_id, "interaction_id": row.interaction_id, "started_at": None, "ended_at": None, "has_recording": False, "last_at": row.created_at, } if row.interaction_id: call["interaction_id"] = row.interaction_id call["last_at"] = row.created_at or call["last_at"] if row.event_type == "call.started": call["started_at"] = row.created_at elif row.event_type == "call.ended": call["ended_at"] = row.created_at elif row.event_type == "recording.ready": call["has_recording"] = True calls_by_id[row.call_id] = call items = [] indexed_interaction_ids: set[str] = set() for call in calls_by_id.values(): interaction = interaction_by_id.get(call.get("interaction_id")) queue_id = interaction.queue_id if interaction else None status = "ended" if call.get("ended_at") else "active" items.append( { **call, "queue_id": queue_id, "status": status, } ) if call.get("interaction_id"): indexed_interaction_ids.add(str(call["interaction_id"])) # Fallback interactions are shown only when no Asterisk-linked calls were detected. # This keeps supervisor live-calls focused on real call_id-based telephony events. if not items: for row in interaction_rows: if row.channel != "voice": continue if not (row.subject or "").startswith("Inbound call"): continue if row.interaction_id in indexed_interaction_ids: continue items.append( { "call_id": f"interaction:{row.interaction_id}", "interaction_id": row.interaction_id, "started_at": row.created_at, "ended_at": row.updated_at if row.status == "closed" else None, "has_recording": False, "last_at": row.updated_at or row.created_at, "queue_id": row.queue_id, "status": row.status or "new", } ) items.sort(key=lambda item: item.get("last_at") or "", reverse=True) return { "items": items[: max(limit, 1)], "timestamp": utc_now_iso(), } finally: session.close()