feat: no-answer retry, agent status machine, escalation events/timeline (ТЗ §13-15,22,25-27,37, AC-08,16-19)
Phase 2 of the L1->L2 routing engine (Phase 1: MR!4). - ami_loop() now also captures native AMI DialEnd/Hangup frames (not only UserEvent), needed to detect that an escalated agent did not answer. No dialplan change required - Redirect already routes the client channel into an existing Dial()-based transfer context, so Asterisk emits these events on its own; the listener just wasn't reading them before. - retry_escalation_no_answer(): on NOANSWER/BUSY/CANCEL/CHANUNAVAIL/ CONGESTION, releases the non-answering agent, excludes it, and reserves+redirects to the next available agent via the routing engine's existing exclude_agent_ids support. Exhausted pool marks the escalation failed and leaves the call with the AI instead of dropping the client (ТЗ §32). - Agent status now actually moves through RESERVED -> RINGING -> TALKING -> AFTER_CALL_WORK -> AVAILABLE instead of staying stuck on RESERVED for the whole call; a new acw_sweep_loop background thread (same pattern as the existing failed_retry_loop) times out AFTER_CALL_WORK back to AVAILABLE. - escalations gains attempt_count/real_agent_id/attempted_agent_ids_json (migration 0033); fixes a latent bug where assigned_agent_id stored the SIP extension instead of the real agent_id despite routing-service already returning it in RoutingAgentReserveOut. - Every transition now records an interaction timeline entry and publishes the ТЗ §25 event catalog (AgentReserved/AgentRinging/ AgentNoAnswer/AgentConnected/TransferCompleted/TransferFailed) through the existing emit_voice_event/EventOutboxRow idempotent path. Not in this MR (see plan): SLA config, Callback, L3 (needs real technical agents from the business), metrics.
This commit is contained in:
@@ -18,6 +18,7 @@ from services.shared.models import (
|
||||
QueueOut,
|
||||
RoutingAgentReserveIn,
|
||||
RoutingAgentReserveOut,
|
||||
RoutingAgentStatusIn,
|
||||
)
|
||||
from services.shared.security import require_roles
|
||||
from services.shared.sql_init import init_sql_schema
|
||||
@@ -343,6 +344,7 @@ def _escalation_to_out(row: EscalationRow) -> EscalationOut:
|
||||
summary=row.summary,
|
||||
status=row.status,
|
||||
assigned_agent_id=row.assigned_agent_id,
|
||||
attempt_count=row.attempt_count or 0,
|
||||
requested_at=row.requested_at,
|
||||
connected_at=row.connected_at,
|
||||
completed_at=row.completed_at,
|
||||
@@ -399,11 +401,32 @@ def release_agent_endpoint(
|
||||
_: dict = Depends(require_roles(Role.ADMIN)),
|
||||
) -> dict:
|
||||
call_id = str(payload.get("call_id") or "").strip()
|
||||
if not call_id:
|
||||
raise HTTPException(status_code=400, detail="call_id is required")
|
||||
agent_id = str(payload.get("agent_id") or "").strip()
|
||||
next_status = str(payload.get("next_status") or "AVAILABLE").strip() or "AVAILABLE"
|
||||
if not call_id and not agent_id:
|
||||
raise HTTPException(status_code=400, detail="call_id or agent_id is required")
|
||||
session = get_session()
|
||||
try:
|
||||
agent = routing_engine.release_agent_by_call_id(session, call_id=call_id)
|
||||
return {"call_id": call_id, "released_agent_id": agent.agent_id if agent else None}
|
||||
if agent_id:
|
||||
agent = routing_engine.release_agent_by_id(session, agent_id=agent_id, next_status=next_status)
|
||||
else:
|
||||
agent = routing_engine.release_agent_by_call_id(session, call_id=call_id)
|
||||
return {"call_id": call_id or None, "released_agent_id": agent.agent_id if agent else None}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.patch("/internal/routing/agents/{agent_id}/status")
|
||||
def set_agent_status_endpoint(
|
||||
agent_id: str,
|
||||
payload: RoutingAgentStatusIn,
|
||||
_: dict = Depends(require_roles(Role.ADMIN)),
|
||||
) -> dict:
|
||||
session = get_session()
|
||||
try:
|
||||
agent = routing_engine.set_agent_status(session, agent_id=agent_id, status=payload.status)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return {"agent_id": agent.agent_id, "status": agent.status}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, text
|
||||
|
||||
@@ -117,6 +118,41 @@ def release_agent_by_call_id(session, *, call_id: str) -> AgentRow | None:
|
||||
return agent
|
||||
|
||||
|
||||
def set_agent_status(session, *, agent_id: str, status: str) -> AgentRow | None:
|
||||
agent = session.execute(
|
||||
select(AgentRow).where(AgentRow.agent_id == agent_id)
|
||||
).scalar_one_or_none()
|
||||
if agent is None:
|
||||
return None
|
||||
agent.status = status
|
||||
agent.updated_at = utc_now_iso()
|
||||
session.commit()
|
||||
return agent
|
||||
|
||||
|
||||
def sweep_after_call_work(session, *, older_than_seconds: int) -> list[AgentRow]:
|
||||
cutoff = utc_now_iso()
|
||||
rows = session.execute(
|
||||
select(AgentRow).where(AgentRow.status == "AFTER_CALL_WORK")
|
||||
).scalars().all()
|
||||
swept: list[AgentRow] = []
|
||||
for agent in rows:
|
||||
try:
|
||||
age_seconds = (
|
||||
datetime.fromisoformat(cutoff) - datetime.fromisoformat(str(agent.updated_at))
|
||||
).total_seconds()
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if age_seconds >= older_than_seconds:
|
||||
agent.status = "AVAILABLE"
|
||||
agent.current_call_id = None
|
||||
agent.updated_at = cutoff
|
||||
swept.append(agent)
|
||||
if swept:
|
||||
session.commit()
|
||||
return swept
|
||||
|
||||
|
||||
def release_agent_by_id(session, *, agent_id: str, next_status: str = "AVAILABLE") -> AgentRow | None:
|
||||
agent = session.execute(
|
||||
select(AgentRow).where(AgentRow.agent_id == agent_id)
|
||||
|
||||
Reference in New Issue
Block a user