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.
76 lines
3.6 KiB
Markdown
76 lines
3.6 KiB
Markdown
# L1 -> L2 Routing Engine (Phase 1)
|
|
|
|
Implements a real Agent Pool + Routing Engine for voice escalation from
|
|
Voice AI (L1) to live L2 operators, replacing the previous static
|
|
single-extension redirect target.
|
|
|
|
## What changed
|
|
|
|
- `agents` table (`services/routing_service/engine.py`): agents are rows with
|
|
`level`, `tenant_ids`, `skills`, `status`, `max_concurrent_calls`, `enabled` -
|
|
not a hardcoded list. Selection filters by level/tenant/skills and picks the
|
|
longest-idle, least-loaded match. Reservation is an atomic
|
|
`UPDATE ... WHERE status='AVAILABLE'` (rowcount-checked), so two concurrent
|
|
calls can never reserve the same agent.
|
|
- `escalations` table: one row per escalation attempt (`from_level`,
|
|
`to_level`, `reason_code`, `required_skills`, `priority`, `status`).
|
|
- `routing_rules` table: seeded data (not code) describing which
|
|
level-to-level transitions are allowed; `L3` and multi-tenant rules can be
|
|
added later without code changes.
|
|
- `asterisk_call_links` gained `tenant_id`, `current_level`,
|
|
`required_skills_json`, `priority`.
|
|
- `POST /asterisk/live-calls/{call_id}/escalations` (asterisk-bridge-service):
|
|
explicit escalation entrypoint. `voice_ai.request_handoff` (used by the AI
|
|
runtime) now also uses the Agent Pool automatically for any `queue_code`
|
|
configured in `ASTERISK_QUEUE_LEVEL_MAP_JSON`; every other queue_code keeps
|
|
the old static `ASTERISK_TRANSFER_TARGET_MAP_JSON` behavior unchanged.
|
|
- `routing-service` gained `/agents` CRUD + `/internal/routing/reserve-agent`
|
|
and `/internal/routing/release-agent`, called by asterisk-bridge-service
|
|
the same way it already calls interaction-service/ai-voice-runtime-service
|
|
(bridge-issued bearer token, `ROUTING_SERVICE_URL`).
|
|
- Agent is released back to `AVAILABLE` when the call ends
|
|
(`process_call_ended` in `bridge_processing.py`), which also closes the
|
|
matching open `escalations` row.
|
|
|
|
## Config for a given DID/queue
|
|
|
|
```
|
|
ASTERISK_QUEUE_LEVEL_MAP_JSON={"<queue_code>":{"level":"L2","tenant_id":"konturai"}}
|
|
```
|
|
|
|
Any `queue_code` not listed here keeps behaving exactly as before (static
|
|
`ASTERISK_TRANSFER_TARGET_MAP_JSON` redirect) - this is additive, not a
|
|
replacement of the legacy mechanism.
|
|
|
|
Seed `agents` rows via `POST /agents` (admin/supervisor). Level `L2`,
|
|
`extension` must match a real SIP endpoint (currently only `2001`/`2002`
|
|
exist on the telecom server).
|
|
|
|
## Known gap - no-answer retry
|
|
|
|
Today the Routing Engine picks and reserves one agent and issues a single AMI
|
|
`Redirect` to their extension; if that extension does not answer, the caller
|
|
currently depends on whatever the dialplan does at that extension (unchanged
|
|
from before this change). Automatic "agent didn't answer -> release and pick
|
|
next" requires either:
|
|
|
|
1. A small dialplan addition at the target extension's context: `Dial` with a
|
|
fixed timeout and, on failure, a `UserEvent` back into the bridge (mirrors
|
|
the existing `MVPCCAIFallbackToHuman` pattern) so `asterisk_bridge_service`
|
|
can call `/internal/routing/reserve-agent` again with the failed agent in
|
|
`exclude_agent_ids`, or
|
|
2. Switching from `Redirect` to AMI `Originate` with `Timeout` and reacting to
|
|
`OriginateResponse`.
|
|
|
|
Left out of Phase 1 deliberately: changing dialplan behavior without full
|
|
visibility into what a given extension's context currently does on no-answer
|
|
risks silently dropping a live customer call. Needs a follow-up MR with an
|
|
explicit, reviewed dialplan change.
|
|
|
|
## Not in Phase 1
|
|
|
|
- L3 (technical specialists) - `agents.level='L3'` and `routing_rules` already
|
|
support it; enabling it is a data-only follow-up once real L3 agents exist.
|
|
- SLA config / callback (ТЗ sections 20-21).
|
|
- Metrics (ТЗ section 38).
|