Phase 2 of the L1->L2 routing engine (Phase 1: MR!4).
- ami_loop() now also captures native AMI DialEnd/Hangup frames (not
only UserEvent), needed to detect that an escalated agent did not
answer. No dialplan change required - Redirect already routes the
client channel into an existing Dial()-based transfer context, so
Asterisk emits these events on its own; the listener just wasn't
reading them before.
- retry_escalation_no_answer(): on NOANSWER/BUSY/CANCEL/CHANUNAVAIL/
CONGESTION, releases the non-answering agent, excludes it, and
reserves+redirects to the next available agent via the routing
engine's existing exclude_agent_ids support. Exhausted pool marks
the escalation failed and leaves the call with the AI instead of
dropping the client (ТЗ §32).
- Agent status now actually moves through
RESERVED -> RINGING -> TALKING -> AFTER_CALL_WORK -> AVAILABLE
instead of staying stuck on RESERVED for the whole call; a new
acw_sweep_loop background thread (same pattern as the existing
failed_retry_loop) times out AFTER_CALL_WORK back to AVAILABLE.
- escalations gains attempt_count/real_agent_id/attempted_agent_ids_json
(migration 0033); fixes a latent bug where assigned_agent_id stored
the SIP extension instead of the real agent_id despite routing-service
already returning it in RoutingAgentReserveOut.
- Every transition now records an interaction timeline entry and
publishes the ТЗ §25 event catalog (AgentReserved/AgentRinging/
AgentNoAnswer/AgentConnected/TransferCompleted/TransferFailed)
through the existing emit_voice_event/EventOutboxRow idempotent path.
Not in this MR (see plan): SLA config, Callback, L3 (needs real
technical agents from the business), metrics.
Both services/ai_orchestrator_service/voice.py and the aimaq persona/DOMAIN
SCOPE prompt were bind-mounted straight from the host on the aimaq stack,
bypassing git and CI entirely since they were first hand-edited in prod.
voice.py: merged the host's live business logic (gas/aimaq domain keyword
list, off-domain Kazakh/Russian replies, disabled re-correction of an already
obtained name) with the timeout_seconds fix from 09bcf74 that never reached
aimaq because the bind mount blocked it.
aimaq.env.production: replaced the AI_OPERATOR_* env values (which were never
interpolated -- {agent_name}/{company_name} would have been read literally)
with the final resolved Zhanna/Kazakgaz Aimaq text including the DOMAIN SCOPE
clause, matching what was actually live on the host.
Parametrize ai_operator_default_config() via AI_OPERATOR_* env vars,
falling back to the existing hardcoded defaults so the other two stacks
(call-center, sales-call-center) that share this code are unaffected.
Set aimaq-only overrides matching the client-provided script (Скрипт
ии-оператора КГА.docx): agent renamed to Жанна, company to Казакгаз
Аймак, the voice greeting now asks the caller whether Russian or
Kazakh is more convenient before anything else, and the base system
prompt instructs the model to commit to whichever language the caller
picks for the rest of the call, ask how to address them, and confirm
there's nothing else before saying goodbye.
RuntimeConfiguredTTSProvider (the live 'dynamic' TTS provider, resolves
voice from DB-backed config) never overrode cache_fingerprint(), so it
fell back to the base class's empty string. PrebakedAckBank keys its
on-disk cache on that fingerprint, so short filler phrases like
'Секунду' kept serving audio baked with the previous ElevenLabs voice
even after a voice change, while full LLM replies (cached inside the
resolved provider itself, keyed on its own voice id) already used the
new voice — explaining why callers heard two different voices in the
same call. Fix: delegate cache_fingerprint() to the resolved provider.
Also extend the voice delivery_hint so the model transliterates website
addresses and English words/abbreviations into spoken Cyrillic instead
of leaving raw Latin text for the TTS engine to mangle (egov.kz was
coming out as 'эговкз').
TTS reads raw text digit-by-digit with no number/date normalization layer.
Short numbers like 1414 were read as a single 4-digit number (tysyacha
chetyresta chetyrnadtsat) instead of a spoken code, and dates like '25
chisla' were read in the wrong grammatical case (dvadtsat pyat chislo
instead of dvadtsat pyatogo chisla). Extend the voice delivery_hint with
explicit spell-out rules so the model itself produces already-correct
spoken-form text.
Found during production smoke test: if the AMI Redirect call in
create_escalation() raises (channel gone, AMI hiccup), the agent stays
RESERVED forever with no owning call — orphaned out of the pool until
someone fixes it by hand. Now releases the agent and marks the
escalation failed before re-raising as a 502.
Enables the new Routing Engine for the Tele2 Kazgaz DID +77476456048
(lands on queue 7100/voice_lab_ai): AI handoff now reserves a real L2
agent from the pool instead of the static extension redirect.
- 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
Streaming TTS caused poor voice quality in live testing on Creator
plan too — not just a quota-era fluke. Reverting to non-streaming
synthesis until the root cause is understood.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
Lower AI_VOICE_VAD_TRAILING_SILENCE_MS from 500 to 350 so the bot
starts responding sooner after the caller stops speaking.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Swap RU and KK TTS voice IDs to "Nataly Mi Soft voice" — a soft,
gentle, young female voice verified for Russian on eleven_turbo_v2_5.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AudioSocket was delivering 16kHz audio despite the telecom-kz trunk being
codec-restricted to alaw/ulaw, causing the 8k->16k ASR resample to double
an already-16kHz stream to an effective 32kHz labeled as 16000 Hz -
audible as slow, deep-pitched, unintelligible speech. Force
audioread/writeformat=slin before AudioSocket() so the channel always
delivers narrowband 8kHz, matching every rate assumption in the runtime.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add sync_ai_operator_config_from_code() which overwrites the DB-cached
ai_operator_settings row from ai_operator_default_config() on startup
of ai_orchestrator_service and ai_voice_runtime_service. Greeting and
system prompt changes now go through git + deploy instead of manual
psql/API edits to prod. Also adds a root README pointing to existing docs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>