search_kb_rows had no relevance floor: any exact-token hit, however
generic, scored above zero and could win as the top/only result. A
caller utterance as thin as a bare "да" (confirming the language) could
exact-match that same common word inside an unrelated FAQ article's
body and get returned as "the" answer, which then got read back
almost verbatim — this is what surfaced live as the AI unprompted
launching into a voucher-activation explanation right after the
customer confirmed Russian, having said nothing else.
tokenize_kb_text now drops a curated set of RU/KZ greetings,
confirmations, pronouns, and particles. A stopword-only query naturally
falls through to the existing "no query tokens -> no results" path
instead of returning a coincidental match; genuine single-content-word
queries (e.g. "ваучер") are unaffected. Applies to every channel that
calls _kb_search (voice, Telegram, WhatsApp), not just voice.
Two bugs found via a live test with two real registered browser softphones:
1. retry_escalation_no_answer() re-resolved the client channel via
_resolve_handoff_channel() -> a live AMI CoreShowChannels round-trip that
can take ~10s. The new mvpcc-transfer dialplan wait window (MusicOnHold,
also ~10s, added to give the backend time to redirect before the final
Hangup) was consistently LOST to this exact same duration: the backend's
AMI Redirect fired against a channel the dialplan had already hung up
('Channel does not exist: PJSIP/...', confirmed in escalation timeline).
Fixed by reusing the actively-maintained AsteriskCallLinkRow.channel_name
directly (unchanged for a PJSIP channel across Redirect between contexts
of the same call) instead of re-discovering it, falling back to the slow
path only if that field is empty.
2. VoiceEventIn.event_type is a pydantic Literal restricted to 6 legacy
values (call.started/ivr.completed/...). None of the Phase 2 event
catalog names (AgentReserved/AgentRinging/AgentNoAnswer/AgentConnected/
TransferCompleted/TransferFailed) were ever in it, so every single
_emit_escalation_event() call has been failing with 422 since Phase 2
shipped (swallowed silently by the broad except there) - confirmed by
calling app._emit_voice_event() directly against the running service.
Extended the Literal to include all six.
Centralizes fixed control intents and adds a data-driven intent_code
field on kb_articles so many phrasings of the same FAQ question
resolve to one stable code (e.g. VOUCHER_ACTIVATION) instead of a
free-form, unvalidated string the LLM invented on the fly.
- services/shared/intents.py: CONTROL_INTENTS + normalize_intent()
- kb_articles.intent_code column (ORM + dev/sqlite runtime compat +
migrations/sql/0034_* for postgres/sqlite)
- kb_service CRUD exposes intent_code
- orchestrator surfaces intent_code to the LLM and validates its
intent output against control intents + the KB codes shown that turn
- voice.py: _voice_early_intent_bucket renamed to _voice_ack_topic_bucket
to stop it being conflated with the canonical FAQ intent
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.
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.
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.
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>
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.