Files
call-center/services/routing_service/engine.py
T
arys 1dcfaf46cf 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.
2026-08-30 14:19:23 +05:00

168 lines
4.7 KiB
Python

from __future__ import annotations
import json
from datetime import datetime
from sqlalchemy import select, text
from services.shared.core import utc_now_iso
from services.shared.sql_models import AgentRow
def parse_list_json(raw: str | None) -> list[str]:
if not raw:
return []
try:
data = json.loads(raw)
except (TypeError, ValueError):
return []
if isinstance(data, list):
return [str(item) for item in data]
return []
def _agent_matches(
agent: AgentRow,
*,
level: str,
tenant_id: str | None,
required_skills: list[str],
exclude_agent_ids: set[str],
) -> bool:
if agent.agent_id in exclude_agent_ids:
return False
if not agent.enabled:
return False
if agent.level != level:
return False
if agent.status != "AVAILABLE":
return False
tenant_ids = parse_list_json(agent.tenant_ids_json)
if tenant_id and tenant_ids and tenant_id not in tenant_ids:
return False
skills = set(parse_list_json(agent.skills_json))
if required_skills and not set(required_skills).issubset(skills):
return False
return True
def select_candidate_agents(
session,
*,
level: str,
tenant_id: str | None,
required_skills: list[str] | None = None,
exclude_agent_ids: list[str] | None = None,
) -> list[AgentRow]:
exclude = set(exclude_agent_ids or [])
skills = required_skills or []
rows = session.execute(
select(AgentRow).where(AgentRow.level == level, AgentRow.enabled.is_(True))
).scalars().all()
candidates = [
row
for row in rows
if _agent_matches(row, level=level, tenant_id=tenant_id, required_skills=skills, exclude_agent_ids=exclude)
]
candidates.sort(key=lambda a: (a.updated_at, a.calls_handled_count))
return candidates
def reserve_agent(
session,
*,
call_id: str,
level: str,
tenant_id: str | None,
required_skills: list[str] | None = None,
exclude_agent_ids: list[str] | None = None,
) -> AgentRow | None:
candidates = select_candidate_agents(
session,
level=level,
tenant_id=tenant_id,
required_skills=required_skills,
exclude_agent_ids=exclude_agent_ids,
)
now = utc_now_iso()
for candidate in candidates:
result = session.execute(
text(
"""
UPDATE agents SET status='RESERVED', current_call_id=:call_id, updated_at=:now
WHERE agent_id=:agent_id AND status='AVAILABLE'
"""
),
{"call_id": call_id, "now": now, "agent_id": candidate.agent_id},
)
if result.rowcount == 1:
session.commit()
session.refresh(candidate)
return candidate
session.rollback()
return None
def release_agent_by_call_id(session, *, call_id: str) -> AgentRow | None:
agent = session.execute(
select(AgentRow).where(AgentRow.current_call_id == call_id)
).scalar_one_or_none()
if agent is None:
return None
now = utc_now_iso()
agent.status = "AVAILABLE"
agent.current_call_id = None
agent.calls_handled_count = (agent.calls_handled_count or 0) + 1
agent.updated_at = now
session.commit()
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)
).scalar_one_or_none()
if agent is None:
return None
now = utc_now_iso()
agent.status = next_status
agent.current_call_id = None
agent.updated_at = now
session.commit()
return agent