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:
@@ -0,0 +1,190 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import services.asterisk_bridge_service.voice_ai as voice_ai
|
||||
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, AsteriskCallLinkRow, EscalationRow
|
||||
|
||||
|
||||
def _make_agent(session, *, extension: str, status: str = "AVAILABLE"):
|
||||
now = utc_now_iso()
|
||||
row = AgentRow(
|
||||
agent_id=new_id("agt"),
|
||||
tenant_ids_json="[]",
|
||||
extension=extension,
|
||||
endpoint=None,
|
||||
display_name=f"Agent {extension}",
|
||||
level="L2",
|
||||
skills_json="[]",
|
||||
status=status,
|
||||
max_concurrent_calls=1,
|
||||
enabled=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def _make_call_link(session, *, call_id: str):
|
||||
now = utc_now_iso()
|
||||
link = AsteriskCallLinkRow(
|
||||
call_id=call_id,
|
||||
linked_id=call_id,
|
||||
queue_code="voice_lab_ai",
|
||||
queue_id="que_test",
|
||||
interaction_id="int_test",
|
||||
status="active",
|
||||
telephony_status="ringing",
|
||||
channel_name=f"PJSIP/tele2-kazgaz-{call_id}",
|
||||
current_level="L2",
|
||||
started_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(link)
|
||||
session.commit()
|
||||
session.refresh(link)
|
||||
return link
|
||||
|
||||
|
||||
def _make_ringing_escalation(session, *, call_id: str, agent: AgentRow):
|
||||
now = utc_now_iso()
|
||||
escalation = EscalationRow(
|
||||
escalation_id=new_id("esc"),
|
||||
call_id=call_id,
|
||||
tenant_id=None,
|
||||
from_level="L1",
|
||||
to_level="L2",
|
||||
reason_code="AI_UNABLE_TO_RESOLVE",
|
||||
required_skills_json="[]",
|
||||
priority=3,
|
||||
status="ringing",
|
||||
assigned_agent_id=agent.extension,
|
||||
real_agent_id=agent.agent_id,
|
||||
attempted_agent_ids_json=json.dumps([agent.agent_id]),
|
||||
requested_at=now,
|
||||
)
|
||||
session.add(escalation)
|
||||
session.commit()
|
||||
session.refresh(escalation)
|
||||
return escalation
|
||||
|
||||
|
||||
def _patch_routing_over_http(monkeypatch, session):
|
||||
"""Make voice_ai's HTTP-facing routing helpers operate on the same test session directly."""
|
||||
|
||||
def fake_reserve(*, call_id, level, tenant_id, required_skills=None, exclude_agent_ids=None):
|
||||
agent = routing_engine.reserve_agent(
|
||||
session,
|
||||
call_id=call_id,
|
||||
level=level,
|
||||
tenant_id=tenant_id,
|
||||
required_skills=required_skills,
|
||||
exclude_agent_ids=exclude_agent_ids,
|
||||
)
|
||||
if agent is None:
|
||||
return None
|
||||
return {
|
||||
"agent_id": agent.agent_id,
|
||||
"extension": agent.extension,
|
||||
"endpoint": agent.endpoint,
|
||||
"display_name": agent.display_name,
|
||||
}
|
||||
|
||||
def fake_release_by_agent_id(agent_id, *, next_status="AVAILABLE"):
|
||||
routing_engine.release_agent_by_id(session, agent_id=agent_id, next_status=next_status)
|
||||
|
||||
def fake_set_status(agent_id, status):
|
||||
routing_engine.set_agent_status(session, agent_id=agent_id, status=status)
|
||||
|
||||
monkeypatch.setattr(voice_ai, "_reserve_routing_agent", fake_reserve)
|
||||
monkeypatch.setattr(voice_ai, "routing_release_by_agent_id", fake_release_by_agent_id)
|
||||
monkeypatch.setattr(voice_ai, "set_routing_agent_status", fake_set_status)
|
||||
monkeypatch.setattr(voice_ai, "_append_escalation_timeline", lambda *a, **k: None)
|
||||
monkeypatch.setattr(voice_ai, "_emit_escalation_event", lambda *a, **k: None)
|
||||
monkeypatch.setattr(voice_ai, "_resolve_handoff_channel", lambda session, link: link.channel_name)
|
||||
|
||||
|
||||
def test_retry_escalation_no_answer_moves_to_next_available_agent(monkeypatch):
|
||||
init_sql_schema()
|
||||
redirected_to: list[str] = []
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
_patch_routing_over_http(monkeypatch, session)
|
||||
monkeypatch.setattr(
|
||||
voice_ai,
|
||||
"_redirect_channel_to_agent",
|
||||
lambda *, channel, extension: redirected_to.append(extension),
|
||||
)
|
||||
agent_a = _make_agent(session, extension="2001")
|
||||
agent_b = _make_agent(session, extension="2002")
|
||||
routing_engine.reserve_agent(session, call_id="call-retry-1", level="L2", tenant_id=None, required_skills=[])
|
||||
link = _make_call_link(session, call_id="call-retry-1")
|
||||
escalation = _make_ringing_escalation(session, call_id="call-retry-1", agent=agent_a)
|
||||
|
||||
voice_ai.retry_escalation_no_answer(session, call_id="call-retry-1", dial_outcome="NOANSWER")
|
||||
|
||||
session.refresh(escalation)
|
||||
session.refresh(agent_a)
|
||||
session.refresh(agent_b)
|
||||
|
||||
assert escalation.attempt_count == 1
|
||||
assert escalation.status == "ringing"
|
||||
assert escalation.real_agent_id == agent_b.agent_id
|
||||
assert escalation.assigned_agent_id == agent_b.extension
|
||||
assert json.loads(escalation.attempted_agent_ids_json) == [agent_a.agent_id, agent_b.agent_id]
|
||||
|
||||
assert agent_a.status == "AVAILABLE"
|
||||
assert agent_a.current_call_id is None
|
||||
assert agent_b.status == "RINGING"
|
||||
assert agent_b.current_call_id == "call-retry-1"
|
||||
|
||||
assert redirected_to == [agent_b.extension]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_retry_escalation_no_answer_exhausts_pool_marks_failed(monkeypatch):
|
||||
init_sql_schema()
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
_patch_routing_over_http(monkeypatch, session)
|
||||
monkeypatch.setattr(voice_ai, "_redirect_channel_to_agent", lambda **kwargs: None)
|
||||
agent_a = _make_agent(session, extension="3001")
|
||||
routing_engine.reserve_agent(session, call_id="call-retry-2", level="L2", tenant_id=None, required_skills=[])
|
||||
link = _make_call_link(session, call_id="call-retry-2")
|
||||
escalation = _make_ringing_escalation(session, call_id="call-retry-2", agent=agent_a)
|
||||
|
||||
voice_ai.retry_escalation_no_answer(session, call_id="call-retry-2", dial_outcome="NOANSWER")
|
||||
|
||||
session.refresh(escalation)
|
||||
session.refresh(agent_a)
|
||||
|
||||
assert escalation.attempt_count == 1
|
||||
assert escalation.status == "failed"
|
||||
assert agent_a.status == "AVAILABLE"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_retry_escalation_no_answer_ignores_calls_without_ringing_escalation(monkeypatch):
|
||||
init_sql_schema()
|
||||
called = []
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
_patch_routing_over_http(monkeypatch, session)
|
||||
monkeypatch.setattr(voice_ai, "_redirect_channel_to_agent", lambda **kwargs: called.append(kwargs))
|
||||
_make_call_link(session, call_id="call-no-escalation")
|
||||
voice_ai.retry_escalation_no_answer(session, call_id="call-no-escalation", dial_outcome="NOANSWER")
|
||||
assert called == []
|
||||
finally:
|
||||
session.close()
|
||||
Reference in New Issue
Block a user