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.
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).
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.
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.
- 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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem
AI→human escalation was a single hardcoded extension per queue (
ASTERISK_TRANSFER_TARGET_MAP_JSON), androuting_serviceused a hardcoded_AGENTSlist 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-kazgazdialplan context) goes straight to the AI bot today with no path to a live operator at all.Changes
agents/escalations/routing_rulestables (migration0031, postgres+sqlite);asterisk_call_linksgainstenant_id,current_level,required_skills_json,priority.services/routing_service/engine.py: agent selection filtered by level/tenant/skills, atomicUPDATE ... WHERE status='AVAILABLE'reservation (rowcount-checked, no double-booking — covered bytests/test_routing_engine.py).routing-service:/agentsCRUD,/internal/routing/reserve-agent,/internal/routing/release-agent.asterisk-bridge-service:voice_ai.request_handoffnow goes through the Routing Engine automatically for anyqueue_codelisted inASTERISK_QUEUE_LEVEL_MAP_JSON— every other queue_code keeps the existing static-map behavior, byte for byte. NewPOST /asterisk/live-calls/{call_id}/escalationsentrypoint for explicit L2/L3 escalation requests. Agent is released back toAVAILABLEand 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 tosql_init.pyfor the legacy/sqlite dev path.Backward compatibility
Additive by design: any queue_code not listed in the new
ASTERISK_QUEUE_LEVEL_MAP_JSONenv var keeps using the pre-existing staticASTERISK_TRANSFER_TARGET_MAP_JSONredirect 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
RedirecttoOriginate— 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 existingtest_asterisk_bridge_service.py(58 tests) andtest_routing_service_pg_counter.pysuites re-run against this branch — still green, no regressions from thevoice_ai.py/bridge_processing.py/config.py/app.pyedits.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.
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.