feat: L1->L2 agent pool and routing engine for voice escalation #4

Merged
arystanbek merged 2 commits from feature/l1-l2-routing-engine into main 2026-08-29 08:31:41 +00:00
Owner

Problem

AI→human escalation was a single hardcoded extension per queue (ASTERISK_TRANSFER_TARGET_MAP_JSON), and routing_service used a hardcoded _AGENTS list per channel. No real agent pool, no level (L1/L2/L3), no tenant/skill filtering, no atomic reservation — two calls could reserve the same operator, no way to grow past 2 operators without code changes.

The Tele2 Kazgaz DID +77476456048 (from-tele2-kazgaz dialplan context) goes straight to the AI bot today with no path to a live operator at all.

Changes

  • agents / escalations / routing_rules tables (migration 0031, postgres+sqlite); asterisk_call_links gains tenant_id, current_level, required_skills_json, priority.
  • services/routing_service/engine.py: agent selection filtered by level/tenant/skills, atomic UPDATE ... WHERE status='AVAILABLE' reservation (rowcount-checked, no double-booking — covered by tests/test_routing_engine.py).
  • routing-service: /agents CRUD, /internal/routing/reserve-agent, /internal/routing/release-agent.
  • asterisk-bridge-service: voice_ai.request_handoff now goes through the Routing Engine automatically for any queue_code listed in ASTERISK_QUEUE_LEVEL_MAP_JSON — every other queue_code keeps the existing static-map behavior, byte for byte. New POST /asterisk/live-calls/{call_id}/escalations entrypoint for explicit L2/L3 escalation requests. Agent is released back to AVAILABLE and the escalation closed on call end (process_call_ended).

Full design notes: docs/architecture/l1-l2-routing-engine.md.

Migrations

migrations/sql/0031_agent_pool_routing_{postgres,sqlite}.sql — additive only (ADD COLUMN IF NOT EXISTS + CREATE TABLE IF NOT EXISTS), no data migration needed. Runtime-compat ALTERs added to sql_init.py for the legacy/sqlite dev path.

Backward compatibility

Additive by design: any queue_code not listed in the new ASTERISK_QUEUE_LEVEL_MAP_JSON env var keeps using the pre-existing static ASTERISK_TRANSFER_TARGET_MAP_JSON redirect unchanged. voice_ai.request_handoff's external contract (VoiceAIHandoffRequestIn) is untouched.

Known gap (documented, not silently skipped)

Automatic "agent didn't answer → release and try the next one" is not in this MR. It needs either a small dialplan addition (Dial-with-timeout + UserEvent back to the bridge) or switching the AMI action from Redirect to Originate — both require reviewing what the target extension's dialplan context currently does on no-answer, which I don't have full visibility into from the code alone. See the doc for the exact follow-up needed.

Test evidence

tests/test_routing_engine.py (5 new tests: level/tenant/skill filtering, atomic reservation under contention, release-then-reserve-again). Full existing test_asterisk_bridge_service.py (58 tests) and test_routing_service_pg_counter.py suites re-run against this branch — still green, no regressions from the voice_ai.py/bridge_processing.py/config.py/app.py edits.

Rollback

Revert this MR's commit. The new tables/columns are additive and unused by any other code path if reverted; no destructive migration to undo.

## Problem AI→human escalation was a single hardcoded extension per queue (`ASTERISK_TRANSFER_TARGET_MAP_JSON`), and `routing_service` used a hardcoded `_AGENTS` list per channel. No real agent pool, no level (L1/L2/L3), no tenant/skill filtering, no atomic reservation — two calls could reserve the same operator, no way to grow past 2 operators without code changes. The Tele2 Kazgaz DID `+77476456048` (`from-tele2-kazgaz` dialplan context) goes straight to the AI bot today with no path to a live operator at all. ## Changes - `agents` / `escalations` / `routing_rules` tables (migration `0031`, postgres+sqlite); `asterisk_call_links` gains `tenant_id`, `current_level`, `required_skills_json`, `priority`. - `services/routing_service/engine.py`: agent selection filtered by level/tenant/skills, atomic `UPDATE ... WHERE status='AVAILABLE'` reservation (rowcount-checked, no double-booking — covered by `tests/test_routing_engine.py`). - `routing-service`: `/agents` CRUD, `/internal/routing/reserve-agent`, `/internal/routing/release-agent`. - `asterisk-bridge-service`: `voice_ai.request_handoff` now goes through the Routing Engine automatically for any `queue_code` listed in `ASTERISK_QUEUE_LEVEL_MAP_JSON` — every other queue_code keeps the existing static-map behavior, byte for byte. New `POST /asterisk/live-calls/{call_id}/escalations` entrypoint for explicit L2/L3 escalation requests. Agent is released back to `AVAILABLE` and the escalation closed on call end (`process_call_ended`). Full design notes: `docs/architecture/l1-l2-routing-engine.md`. ## Migrations `migrations/sql/0031_agent_pool_routing_{postgres,sqlite}.sql` — additive only (`ADD COLUMN IF NOT EXISTS` + `CREATE TABLE IF NOT EXISTS`), no data migration needed. Runtime-compat ALTERs added to `sql_init.py` for the legacy/sqlite dev path. ## Backward compatibility Additive by design: any queue_code not listed in the new `ASTERISK_QUEUE_LEVEL_MAP_JSON` env var keeps using the pre-existing static `ASTERISK_TRANSFER_TARGET_MAP_JSON` redirect unchanged. `voice_ai.request_handoff`'s external contract (`VoiceAIHandoffRequestIn`) is untouched. ## Known gap (documented, not silently skipped) Automatic "agent didn't answer → release and try the next one" is **not** in this MR. It needs either a small dialplan addition (Dial-with-timeout + UserEvent back to the bridge) or switching the AMI action from `Redirect` to `Originate` — both require reviewing what the target extension's dialplan context currently does on no-answer, which I don't have full visibility into from the code alone. See the doc for the exact follow-up needed. ## Test evidence `tests/test_routing_engine.py` (5 new tests: level/tenant/skill filtering, atomic reservation under contention, release-then-reserve-again). Full existing `test_asterisk_bridge_service.py` (58 tests) and `test_routing_service_pg_counter.py` suites re-run against this branch — still green, no regressions from the `voice_ai.py`/`bridge_processing.py`/`config.py`/`app.py` edits. ## Rollback Revert this MR's commit. The new tables/columns are additive and unused by any other code path if reverted; no destructive migration to undo.
arystanbek added 1 commit 2026-08-28 11:23:00 +00:00
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.
arystanbek added 1 commit 2026-08-29 08:28:17 +00:00
- routing-service: GET /escalations (list recent escalation attempts)
- supervisor UI: new panel showing the real agent pool (status, level,
  tenant, skills, calls handled) fed by GET /agents, with a form to add
  operators to the pool
- supervisor UI: new live escalation feed (AI->L2 handoffs, status,
  assigned operator), auto-refreshed every 5s alongside live calls
arystanbek merged commit 16ce9659db into main 2026-08-29 08:31:41 +00:00
arystanbek deleted branch feature/l1-l2-routing-engine 2026-08-29 08:31:41 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: digiops/call-center#4