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.
168 lines
4.7 KiB
Python
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
|