Replaces the hardcoded single-extension redirect for AI->human call
escalation with a real Agent Pool + Routing Engine:
- agents/escalations/routing_rules tables (migration 0031), asterisk_call_links
gains tenant_id/current_level/required_skills_json/priority.
- services/routing_service/engine.py: level/tenant/skill filtered agent
selection with atomic (CAS) reservation, no double-booking.
- routing-service: /agents CRUD + /internal/routing/reserve-agent and
/internal/routing/release-agent.
- asterisk-bridge-service: voice_ai.request_handoff now uses the Routing
Engine automatically for any queue_code configured in
ASTERISK_QUEUE_LEVEL_MAP_JSON (all other queue_codes keep the existing
static ASTERISK_TRANSFER_TARGET_MAP_JSON behavior unchanged); new
POST /asterisk/live-calls/{call_id}/escalations entrypoint; agent is
released back to AVAILABLE and the escalation closed when the call ends.
Targets the Tele2 Kazgaz DID +77476456048 (from-tele2-kazgaz context) as the
first queue wired to real L2 routing instead of AI-only.
Known gap (documented in docs/architecture/l1-l2-routing-engine.md):
automatic no-answer retry-to-next-agent needs a small, separately reviewed
dialplan change and is left for a follow-up MR rather than guessed at blind.
Tests: services/routing_service/engine.py covered by
tests/test_routing_engine.py (selection filtering, atomic reservation,
release); existing test_asterisk_bridge_service.py and
test_routing_service_pg_counter.py suites still pass unmodified.
137 lines
4.4 KiB
Python
137 lines
4.4 KiB
Python
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()
|