Files
call-center/tests/test_routing_engine.py
T
Hermes Agent 2243f305b8 feat: L1->L2 agent pool and routing engine for voice escalation
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.
2026-08-28 16:22:32 +05:00

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()