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