8 Commits
Author SHA1 Message Date
arys 99d169ec67 fix: stop the no-answer retry from racing the dialplan hangup + fix event catalog validation
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.
2026-08-31 01:03:47 +05:00
arys 8382dfa9ba fix: treat AST_CAUSE no-route/unallocated as a no-answer retry outcome
process_agent_dial_outcome only recognized hangup causes 17/18/19/21/34/38.
When the reserved agent's AOR has zero registered contacts (e.g. the
softphone dropped, or nobody ever registered), Asterisk immediately
hangs up with cause 3 (no route to destination) instead of running a
Dial() long enough to produce a DialEnd/NOANSWER at all - so the retry
listener silently ignored it and the escalation was left dangling in
'ringing' status (the agent itself still got released via the
call-ended fallback path, but no retry to the next agent was ever
attempted and the escalation record never reflects the failure).

Added causes 1 (unallocated number), 3 (no route), 20 (subscriber
absent), 22 (number changed) alongside the existing set.
2026-08-31 00:43:20 +05:00
arys 2cb358e80d fix: track escalation for legacy AI voice handoff path
request_handoff() (called by ai_voice_runtime_service for every real
call handoff, the only handoff path production calls actually use)
reserved an agent from the same routing pool as create_escalation()
but never created an EscalationRow, so the Phase 2 no-answer-retry
listener (DialEnd/Hangup) could never find it. A failed transfer
(no SIP registration, no answer, redirect error) left the agent
stuck in RESERVED forever with no retry to the next agent.

Now creates an EscalationRow (status=ringing) alongside the agent
reservation, releases the agent + marks the escalation failed if the
AMI Redirect itself errors immediately, and lets the existing
DialEnd/Hangup handler drive no-answer retry / release exactly like
the /escalations endpoint already does.

Reproduced live: call handed off to extension 2002 with no SIP
contact registered -> immediate hangup, cause=3, both pool agents
stuck in RESERVED indefinitely (had to release manually via psql).
2026-08-31 00:07:57 +05:00
arys 1dcfaf46cf feat: no-answer retry, agent status machine, escalation events/timeline (ТЗ §13-15,22,25-27,37, AC-08,16-19)
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.
2026-08-30 14:19:23 +05:00
arys 82c89eff5a feat: give aimaq voice AI a Qazaqgaz Aimaq persona (Zhanna) and language-choice greeting
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.
2026-08-30 12:22:00 +05:00
arys fe8598e09f fix: TTS ack-bank served stale voice after voice-config changes; add website/English pronunciation rules
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 'эговкз').
2026-08-30 11:39:14 +05:00
arys d876a67b3a fix: instruct voice AI to spell out phone numbers and dates in natural spoken Russian
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.
2026-08-30 11:21:42 +05:00
arys f53af1f7dc sales fix 2026-05-09 09:16:06 +05:00