Files
call-center/services/supervisor_service/app.py
T
Yera AllandClaude Opus 4.6 6798320209 fix: remove dead code duplicates, add SQL LIMIT across all services
- Remove duplicate function definitions with hardcoded "AI-оператор" strings
  (ai_voice_runtime, ai_orchestrator, voice_name_config, voice.py)
- Remove unreachable dead code after return in ai_voice_runtime
- Add SQL LIMIT to 17 unbounded queries across 12 services to prevent OOM
- Move Python-side filtering to SQL WHERE in reporting_service
- Downgrade 19 logger.warning to logger.info for normal-flow events in media_runtime

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 12:04:26 +05:00

277 lines
9.1 KiB
Python

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