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.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
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 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
|
||||
Reference in New Issue
Block a user