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>
A separate deployment on the same host (aimaq-call-center) already binds
127.0.0.1:9019, so this project's ai-voice-runtime-service could never
start there. Remap the host side to 9024 and point Asterisk's
AudioSocket target at the new port; the container still listens on 9019
internally.
The previous marker was added to operator_persona.py's inline fallback,
which only fires when config is None. Every real call path loads a
populated AIOperatorConfig via load_effective_ai_operator_config(),
so ai_operator_default_config() in services/shared/ai_operator_config.py
is the default that's actually served.
Two independent latency fixes for the voice-assistant reply pipeline,
both scoped to the parts of the flow that run regardless of whether
voice_v2 is enabled for a queue:
1. media_runtime._process_utterance: the v1/fallback turn path (used by
any queue not covered by AI_VOICE_V2_QUEUE_CODES) silently awaited the
full LLM decision with no audio playing at all, unlike the v2 path
which already has a decision-timeout ack. Give v1 the same behavior:
wait up to 600ms (_v1_ack_wait_seconds) for the decision, and if it's
still not ready, play a short "Секунду." filler via the existing
_emit_early_ack before the real reply, instead of leaving the caller
in silence for the full LLM+TTS round trip. Reuses the same ack
selection/playback code path v2 already exercises, so no new failure
modes - just an added timeout branch mirroring the existing v2 one.
2. ai_orchestrator_service._kb_search: every voice/chat turn re-ran a
full-table scan of kb_articles (all columns, including body text) and
rescored every row in Python, even though the KB rarely changes
mid-conversation. Added an in-process cache keyed by language, gated
on a cheap content fingerprint (row count + max id + max updated_at +
summed title/body/tags length, all computed server-side without
transferring the text columns). A fingerprint mismatch always
triggers a fresh fetch, so this can never serve stale results after
an insert/update/delete - unlike a naive TTL cache, which would have
been be wrong the moment a test (or a real KB edit) changed the table
within the cache window.
Note the first fingerprint design (count + max id + max updated_at
only) was insufficient: utc_now_iso() truncates to whole seconds and
SQLite reuses primary keys after a full-table delete, so two
different row sets written in the same wall-clock second could share
a fingerprint. Caught this via a real test failure
(test_ai_whatsapp_relaxed_kb_search_answers_phrase_query breaking
only when run after test_ai_orchestrator_service.py in the same
process) before it could reach production; the summed content-length
term closes the gap.
Added test_media_runtime_plays_filler_ack_when_v1_decision_is_slow
(asserts greeting -> ack -> reply delivery order when process_turn is
slow) and verified the KB cache against the full
test_ai_orchestrator_service.py + test_ai_whatsapp_orchestrator_service.py
suite plus a wider kb/orchestrator/whatsapp/telegram/voice-filtered run:
only the same pre-existing, already-documented failures remain (unrelated
sales_service test-isolation ordering, one known persona-prompt
assertion) - no new failures from either change.
Streaming the LLM decision itself (start speaking reply_text before the
full structured JSON response finishes generating) was scoped but
deliberately deferred: it needs incremental JSON parsing on top of SSE
streaming to detect when just the reply_text field is complete, shared
across both voice and text-channel decision paths - a separate,
higher-risk change that deserves its own PR and testing pass rather than
being bundled here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The AudioSocket/media_runtime playback pipeline already supports chunked
TTS streaming (voice_v2_streaming_tts), but every provider inherited the
base TTSProvider.synthesize_chunks(), which just called the blocking
synthesize() and yielded the entire finished audio as a single "chunk" -
so the caller waited for full-utterance synthesis before any playback
could start regardless of the flag.
ElevenLabs is the production default (AI_VOICE_TTS_PROVIDER=elevenlabs in
deployment/docker-compose.server.yml), so give it a real implementation
that POSTs to the /stream endpoint and yields audio as network chunks
arrive, instead of waiting for the whole response body. Chunk boundaries
are re-aligned to whole 16-bit PCM samples so a split sample at a network
read boundary can't corrupt playback. The full synthesized audio is still
written to the on-disk cache afterwards so repeat phrases stay fast and
skip the vendor call entirely, matching the existing synthesize() cache
behavior.
Added test_elevenlabs_tts_provider_streams_chunks_and_caches_full_audio to
cover: chunk splitting mid-sample gets re-aligned, all yielded chunks are
sample-aligned, the full audio round-trips through the cache, and a
cached synthesis is replayed without invoking the streaming endpoint
again.
Verified via tests/test_ai_voice_tts_provider.py (9/9 pass) and a wider
voice/tts-filtered run across the suite: the only failures present are
the same pre-existing, already-documented ones (sales_service test
cross-file isolation ordering, one known persona-prompt assertion) -
identical set to before this change, no new failures.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- docs: longread.md — deep architecture/flow review of the whole
platform (services, event bus reality vs docs, AI/ML stack honesty
check, tech debt inventory)
- ai_orchestrator_service/voice.py: drop _voice_decision_legacy
(unreferenced) and the shadowed first _voice_decision definition
(silently overwritten by the real one, dead code)
- ui/analyst/app.js: drop duplicate dead definitions of
loadSavedAnalyticsViews/saveAnalyticsView/deleteAnalyticsView and
the first loadAnalyticsTrend implementation, all shadowed by later
declarations in the same file; kept the intentional AI-mode
drilldown wrapper layer (openAnalyticsDrilldown/exportAnalyticsDrilldownCsv/etc.)
since that duplication is deliberate delegation, not dead code
- ui/operator/vendor/sip-0.21.2.min.js: remove byte-identical orphaned
duplicate of ui/operator/sip-0.21.2.min.js (unreferenced anywhere)
Verified via full pytest run: identical set of 97 pre-existing
failures before and after (sales_* test-isolation ordering issue and
one known persona-prompt test), no new regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>