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.
185 lines
6.2 KiB
Python
185 lines
6.2 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
import json
|
|
|
|
from sqlalchemy import select
|
|
|
|
from services.routing_service import engine as routing_engine
|
|
from services.shared.core import new_id, utc_now_iso
|
|
from services.shared.db import get_session
|
|
from services.shared.sql_init import init_sql_schema
|
|
from services.shared.sql_models import AgentRow
|
|
|
|
|
|
def _make_agent(session, *, level="L2", tenant_ids=None, skills=None, status="AVAILABLE", enabled=True):
|
|
now = utc_now_iso()
|
|
row = AgentRow(
|
|
agent_id=new_id("agt"),
|
|
tenant_ids_json=json.dumps(tenant_ids or []),
|
|
extension="2001",
|
|
endpoint=None,
|
|
display_name="Test Agent",
|
|
level=level,
|
|
skills_json=json.dumps(skills or []),
|
|
status=status,
|
|
max_concurrent_calls=1,
|
|
enabled=enabled,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
session.add(row)
|
|
session.commit()
|
|
session.refresh(row)
|
|
return row
|
|
|
|
|
|
def test_reserve_agent_picks_available_agent_of_requested_level():
|
|
init_sql_schema()
|
|
session = get_session()
|
|
try:
|
|
agent_l2 = _make_agent(session, level="L2")
|
|
_make_agent(session, level="L3")
|
|
|
|
reserved = routing_engine.reserve_agent(
|
|
session,
|
|
call_id="call-1",
|
|
level="L2",
|
|
tenant_id=None,
|
|
required_skills=[],
|
|
)
|
|
assert reserved is not None
|
|
assert reserved.agent_id == agent_l2.agent_id
|
|
assert reserved.status == "RESERVED"
|
|
assert reserved.current_call_id == "call-1"
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def test_reserve_agent_does_not_double_reserve():
|
|
init_sql_schema()
|
|
session = get_session()
|
|
try:
|
|
_make_agent(session, level="L2")
|
|
|
|
first = routing_engine.reserve_agent(session, call_id="call-a", level="L2", tenant_id=None, required_skills=[])
|
|
second = routing_engine.reserve_agent(session, call_id="call-b", level="L2", tenant_id=None, required_skills=[])
|
|
assert first is not None
|
|
assert second is None
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def test_reserve_agent_filters_by_tenant_and_skills():
|
|
init_sql_schema()
|
|
session = get_session()
|
|
try:
|
|
wrong_tenant = _make_agent(session, level="L2", tenant_ids=["other"])
|
|
no_skill = _make_agent(session, level="L2", tenant_ids=["konturai"], skills=[])
|
|
matching = _make_agent(session, level="L2", tenant_ids=["konturai"], skills=["billing"])
|
|
|
|
reserved = routing_engine.reserve_agent(
|
|
session,
|
|
call_id="call-skill",
|
|
level="L2",
|
|
tenant_id="konturai",
|
|
required_skills=["billing"],
|
|
)
|
|
assert reserved is not None
|
|
assert reserved.agent_id == matching.agent_id
|
|
assert reserved.agent_id != wrong_tenant.agent_id
|
|
assert reserved.agent_id != no_skill.agent_id
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def test_release_agent_by_call_id_makes_agent_available_again():
|
|
init_sql_schema()
|
|
session = get_session()
|
|
try:
|
|
agent = _make_agent(session, level="L2")
|
|
reserved = routing_engine.reserve_agent(session, call_id="call-z", level="L2", tenant_id=None, required_skills=[])
|
|
assert reserved is not None
|
|
|
|
released = routing_engine.release_agent_by_call_id(session, call_id="call-z")
|
|
assert released is not None
|
|
assert released.agent_id == agent.agent_id
|
|
assert released.status == "AVAILABLE"
|
|
assert released.current_call_id is None
|
|
assert released.calls_handled_count == 1
|
|
|
|
again = routing_engine.reserve_agent(session, call_id="call-again", level="L2", tenant_id=None, required_skills=[])
|
|
assert again is not None
|
|
assert again.agent_id == agent.agent_id
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def test_reserve_agent_excludes_disabled_and_excluded_ids():
|
|
init_sql_schema()
|
|
session = get_session()
|
|
try:
|
|
disabled = _make_agent(session, level="L2", enabled=False)
|
|
excluded = _make_agent(session, level="L2")
|
|
available = _make_agent(session, level="L2")
|
|
|
|
reserved = routing_engine.reserve_agent(
|
|
session,
|
|
call_id="call-exc",
|
|
level="L2",
|
|
tenant_id=None,
|
|
required_skills=[],
|
|
exclude_agent_ids=[excluded.agent_id],
|
|
)
|
|
assert reserved is not None
|
|
assert reserved.agent_id == available.agent_id
|
|
assert reserved.agent_id != disabled.agent_id
|
|
assert reserved.agent_id != excluded.agent_id
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def test_set_agent_status_transitions_ringing_to_talking():
|
|
init_sql_schema()
|
|
session = get_session()
|
|
try:
|
|
agent = _make_agent(session, level="L2")
|
|
routing_engine.reserve_agent(session, call_id="call-status", level="L2", tenant_id=None, required_skills=[])
|
|
|
|
ringing = routing_engine.set_agent_status(session, agent_id=agent.agent_id, status="RINGING")
|
|
assert ringing is not None
|
|
assert ringing.status == "RINGING"
|
|
|
|
talking = routing_engine.set_agent_status(session, agent_id=agent.agent_id, status="TALKING")
|
|
assert talking.status == "TALKING"
|
|
|
|
missing = routing_engine.set_agent_status(session, agent_id="unknown-agent", status="AVAILABLE")
|
|
assert missing is None
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def test_sweep_after_call_work_releases_only_expired_agents():
|
|
init_sql_schema()
|
|
session = get_session()
|
|
try:
|
|
stale = _make_agent(session, level="L2", status="AFTER_CALL_WORK")
|
|
fresh = _make_agent(session, level="L2", status="AFTER_CALL_WORK")
|
|
|
|
stale_ts = (datetime.now(timezone.utc) - timedelta(seconds=120)).replace(microsecond=0).isoformat()
|
|
stale.updated_at = stale_ts
|
|
stale.current_call_id = "call-stale"
|
|
session.commit()
|
|
|
|
swept = routing_engine.sweep_after_call_work(session, older_than_seconds=30)
|
|
swept_ids = {a.agent_id for a in swept}
|
|
|
|
assert stale.agent_id in swept_ids
|
|
assert fresh.agent_id not in swept_ids
|
|
|
|
session.refresh(stale)
|
|
session.refresh(fresh)
|
|
assert stale.status == "AVAILABLE"
|
|
assert stale.current_call_id is None
|
|
assert fresh.status == "AFTER_CALL_WORK"
|
|
finally:
|
|
session.close()
|