11 Commits
Author SHA1 Message Date
a.arystanbek c261d08036 feat: require complete KB-grounded answers (no dropped facts) + ask did-that-answer-your-question after each answer, per ai-operator spec
deploy / deploy (push) Failing after 4s
2026-08-30 21:22:54 +00:00
a.arystanbek 78dfe6a9e1 fix: do not fire no-speech reprompt while caller is still mid-utterance with live partial ASR
deploy / deploy (push) Failing after 3s
2026-08-30 21:19:02 +00:00
a.arystanbek f978702a6c fix: retry voice turn once on Postgres deadlock instead of failing the call with a technical-error handoff
deploy / deploy (push) Successful in 34s
2026-08-30 21:14:25 +00:00
a.arystanbek f82f27c035 fix: gpt-5-nano too unreliable for aimaq voice decisions, switch to gpt-5-mini
deploy / deploy (push) Failing after 6s
2026-08-30 21:04:55 +00:00
a.arystanbek 75d105f636 fix: support GPT-5 request contract (max_completion_tokens, reasoning_effort) and switch aimaq voice decisions to gpt-5-nano
deploy / deploy (push) Failing after 0s
2026-08-30 20:37:35 +00:00
didar 9bf367abf4 fix: filter RU/KZ stopwords from KB search so filler words can't false-match
deploy / deploy (push) Successful in 30s
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.
2026-08-31 01:08:11 +05:00
arystanbek 3c5c233071 Merge pull request 'fix: stop no-answer retry racing dialplan hangup + fix event catalog validation' (#11) from fix/retry-channel-resolution-and-event-catalog into main
deploy / deploy (push) Failing after 5s
2026-08-30 20:04:04 +00:00
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
didar 9fdaa9472f Revert "fix: let voice early-plan turns answer from the FAQ knowledge base"
deploy / deploy (push) Successful in 31s
This reverts commit 8ced7a59e3.
2026-08-31 00:54:38 +05:00
arystanbek 1dc37d2764 Merge pull request 'fix: treat AST_CAUSE no-route/unallocated as a no-answer retry outcome' (#10) from fix/no-route-retry-cause into main
deploy / deploy (push) Successful in 32s
2026-08-30 19:43:43 +00: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
12 changed files with 163 additions and 151 deletions
+3 -3
View File
@@ -10,10 +10,10 @@ ALLOW_LEGACY_HEADER_AUTH=0
AI_PROVIDER=openai_compatible
AI_API_BASE=https://api.openai.com/v1
AI_API_KEY=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
AI_MODEL=gpt-4o-mini
AI_API_KEY=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
AI_MODEL=gpt-5-mini
AI_TIMEOUT_SECONDS=30
AI_VOICE_AI_TIMEOUT_SECONDS=10
AI_VOICE_AI_TIMEOUT_SECONDS=15
AI_WEB_SEARCH_ENABLED=1
AI_WEB_SEARCH_MAX_RESULTS=5
AI_WEB_SEARCH_GL=kz
+9 -4
View File
@@ -2357,13 +2357,18 @@ def _request_structured_model_decision(
if not _ai_api_base() or not _ai_api_key():
raise RuntimeError("AI_API_BASE / AI_API_KEY are required for openai_compatible provider")
started = time.perf_counter()
payload = {
"model": _ai_model(),
"temperature": 0.2,
"max_tokens": _ai_decision_max_tokens(),
model = _ai_model()
payload: dict[str, Any] = {
"model": model,
"response_format": {"type": "json_object"},
"messages": messages,
}
if model.startswith("gpt-5"):
payload["max_completion_tokens"] = _ai_decision_max_tokens()
payload["reasoning_effort"] = "minimal"
else:
payload["temperature"] = 0.2
payload["max_tokens"] = _ai_decision_max_tokens()
effective_timeout = timeout_seconds if timeout_seconds is not None else _ai_timeout_seconds()
with httpx.Client(timeout=effective_timeout) as client:
response = client.post(
@@ -135,8 +135,12 @@ def human_fallback_reply(language: str, *, is_greeting: bool = False) -> str:
def operator_system_prompt(*, language: str, channel_label: str, is_voice: bool, config: Any | None = None) -> str:
preferred_language = "Kazakh" if str(language or "").strip().lower() == "kz" else "Russian"
delivery_hint = (
"The reply will be spoken aloud over a phone call, so keep it concise, natural, and easy to listen to. "
"Prefer one or two short sentences and at most one clarifying question. "
"The reply will be spoken aloud over a phone call, so keep it concise, natural, and easy to listen to, "
"but never at the cost of dropping a required fact. "
"Use as many short sentences as needed to cover every material fact from the grounding kb_results "
"snippet completely - required steps, codes, commands, deadlines, amounts, and conditions - typically "
"two to four short sentences; never silently omit or shorten out a required step just to sound brief. "
"At most one clarifying question per turn. "
"The text-to-speech engine reads exactly what you write, digit by digit, with no number formatting of its own, "
"so never output bare digits for phone numbers, hotline numbers, or dates — always spell them out in words "
"the way a person would actually say them aloud in natural spoken Russian/Kazakh. "
@@ -174,6 +178,8 @@ def operator_system_prompt(*, language: str, channel_label: str, is_voice: bool,
"Paraphrase them naturally instead of quoting them verbatim. "
"Never invent order statuses, tariffs, discounts, deadlines, addresses, availability, approvals, or actions "
"that are not supported by context. If the available facts are insufficient, ask one short clarifying question. "
"After you deliver a complete answer grounded in kb_results (not when you are asking a clarifying question, "
"handling an identity/off-topic reply, or closing the call), end reply_text with a brief natural check such as «Ответила ли я на ваш вопрос?» in Russian, or its natural Kazakh equivalent, phrased differently each time so it does not sound scripted. "
"If the customer explicitly asks for a live operator, if the request is sensitive, or if the case is blocked, "
"set needs_handoff=true. "
f"{delivery_hint} "
+17 -66
View File
@@ -19,7 +19,6 @@ from services.shared.ai_context_summary import (
)
from services.shared.core import new_id, utc_now_iso
from services.shared.db import get_session
from services.shared.intents import normalize_intent
from services.shared.models import VoiceAIStartIn, VoiceAIStartOut, VoiceAITurnDecisionOut, VoiceAITurnIn, VoiceStartResult
from services.shared.sql_models import (
AISessionRow,
@@ -1535,7 +1534,6 @@ def _voice_early_plan(
transcript_text: str,
context_summary: str | dict[str, Any] | None = None,
request_metadata: dict[str, Any] | None = None,
kb_results: list[Any] = (),
) -> dict[str, Any]:
v2_metadata = _voice_v2_metadata(transcript_text, request_metadata)
payload = request_metadata if isinstance(request_metadata, dict) else {}
@@ -1578,56 +1576,10 @@ def _voice_early_plan(
"reply_phase": "early_plan",
},
}
if early_intent == "operator_request":
return {
"language": language,
"intent": "handoff_request",
"reply_text": _voice_compact_reply_text(_voice_handoff_reply(language), language=language),
"confidence": 0.62,
"needs_handoff": True,
"handoff_reason": "Запрос требует участия живого оператора.",
"case_action": "keep_open",
"kb_refs": [],
"summary_text": "Early domain plan is prepared.",
"model": "voice_early_plan_domain",
"latency_ms": 1,
"metadata": {
**v2_metadata,
"reply_phase": "early_plan",
"early_intent": early_intent,
},
}
if early_intent in {"schedule", "address", "price", "status", "problem"} and kb_results:
# A cheap lexical KB lookup fits the early-plan latency budget (no LLM
# round-trip), unlike the properly grounded/paraphrased "final" decision.
# Answering from the KB here beats guessing a generic clarifying question
# when the FAQ already has the answer — see the plan doc for why this
# branch exists at all (the early reply can win the race and get spoken
# before the final, LLM-grounded decision is ready).
article = kb_results[0]
snippet = _app()._article_snippet(article, limit=220)
reply_text = f"Қысқаша айтайын: {snippet}" if language == "kz" else f"Коротко подскажу: {snippet}"
topic_code = getattr(article, "intent_code", None)
return {
"language": language,
"intent": normalize_intent(topic_code or "kb_answer", known_topic_codes=[topic_code] if topic_code else []),
"reply_text": _voice_compact_reply_text(reply_text, language=language),
"confidence": 0.7,
"needs_handoff": False,
"handoff_reason": None,
"case_action": "keep_open",
"kb_refs": [article.article_id],
"summary_text": "AI ответил по базе ЧЗВ на предварительной стадии.",
"model": "voice_early_plan_kb",
"latency_ms": 1,
"metadata": {
**v2_metadata,
"reply_phase": "early_plan",
"early_intent": early_intent,
},
}
if early_intent in {"schedule", "address", "price", "status", "problem"}:
if early_intent in {"schedule", "address", "price", "status", "problem", "operator_request"}:
reply_text = _voice_summary_slot_prompt(language, context_summary) or _voice_topic_prompt(language, [transcript_text])
if early_intent == "operator_request":
reply_text = _voice_handoff_reply(language)
if not reply_text and language == "kz":
if early_intent == "schedule":
reply_text = "Qai filialdyn, mekenjaidyn nemese qalanyng jumys uaqyty qyzyqtyratynyn aitnyz."
@@ -1653,11 +1605,15 @@ def _voice_early_plan(
if reply_text:
return {
"language": language,
"intent": "clarification",
"intent": "handoff_request" if early_intent == "operator_request" else "clarification",
"reply_text": _voice_compact_reply_text(reply_text, language=language),
"confidence": 0.62,
"needs_handoff": False,
"handoff_reason": None,
"needs_handoff": early_intent == "operator_request",
"handoff_reason": (
"Запрос требует участия живого оператора."
if early_intent == "operator_request"
else None
),
"case_action": "keep_open",
"kb_refs": [],
"summary_text": "Early domain plan is prepared.",
@@ -1880,7 +1836,6 @@ def _voice_decision(
transcript_text=transcript_text,
context_summary=context_summary,
request_metadata=request_metadata,
kb_results=kb_results,
)
if persona.is_identity_request(normalized):
@@ -2582,17 +2537,13 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe
)
ai_session.context_summary_json = dump_context_summary(user_context_summary)
ai_session.context_summary_updated_at = now
# Unlike name extraction/context-summary writes above (skipped for
# early_plan_only since they're stateful and heavier), KB search is a
# cheap in-memory lexical scan over a DB-cached row set (see
# _load_kb_rows_cached) and comfortably fits the early-plan latency
# budget, so it always runs — this lets _voice_early_plan answer from
# the FAQ instead of guessing a generic clarifying question.
kb_results = app._kb_search(
session,
payload.transcript_text,
language=ai_session.language,
)
kb_results = []
if not early_plan_only:
kb_results = app._kb_search(
session,
payload.transcript_text,
language=ai_session.language,
)
disclosure_required = voice_session.disclosure_played_at is None
decision = _voice_decision(
language=ai_session.language or "ru",
+21
View File
@@ -1335,6 +1335,27 @@ def _process_voice_ai_turn_sync(
payload: VoiceAITurnIn,
*,
auto_handoff: bool,
) -> VoiceAITurnDecisionOut:
last_exc: Exception | None = None
for attempt in range(2):
try:
return _process_voice_ai_turn_sync_once(session_id, payload, auto_handoff=auto_handoff)
except HTTPException as exc:
if attempt == 0 and exc.status_code == 502 and "deadlock detected" in str(exc.detail).lower():
logging.getLogger(__name__).warning("voice_turn_deadlock_retry session_id=%s", session_id)
last_exc = exc
continue
raise
if last_exc is not None:
raise last_exc
raise RuntimeError("unreachable")
def _process_voice_ai_turn_sync_once(
session_id: str,
payload: VoiceAITurnIn,
*,
auto_handoff: bool,
) -> VoiceAITurnDecisionOut:
session = get_session()
voice_session = None
@@ -956,6 +956,11 @@ class AudioSocketMediaRuntime:
bool(partial.is_final),
transcript_text[:160],
)
# Fresh speech content is still arriving, so push the
# no-speech silence-timeout deadline forward instead of
# interrupting a caller who is actively mid-utterance.
if actor.state == "listening":
actor.listening_since_monotonic = time.monotonic()
actor.partial_transcript = transcript_text
self._update_stable_partial_transcript(actor, transcript_text, provider_stable=bool(partial.is_stable or partial.is_final))
intent = self._detect_early_intent(transcript_text)
@@ -1233,7 +1233,7 @@ def process_recording_ready(
_NO_ANSWER_DIAL_STATUSES = {"NOANSWER", "BUSY", "CANCEL", "CHANUNAVAIL", "CONGESTION"}
_NO_ANSWER_HANGUP_CAUSES = {"17", "18", "19", "21", "34", "38"}
_NO_ANSWER_HANGUP_CAUSES = {"1", "3", "17", "18", "19", "20", "21", "22", "34", "38"}
def process_agent_dial_outcome(session, row: AsteriskEventLogRow, payload: dict[str, Any]) -> None:
+5 -1
View File
@@ -938,7 +938,11 @@ def retry_escalation_no_answer(session, *, call_id: str, dial_outcome: str) -> N
)
attempted_ids = json.loads(escalation.attempted_agent_ids_json or "[]")
channel = _resolve_handoff_channel(session, link)
# Use the already-known, actively-maintained channel name directly instead of
# _resolve_handoff_channel()'s live AMI CoreShowChannels re-discovery: that
# round-trip can take ~10s, which races (and loses) against the dialplan's
# own short MusicOnHold-then-hangup wait window for this exact retry path.
channel = str(link.channel_name or "").strip() or _resolve_handoff_channel(session, link)
required_skills = json.loads(escalation.required_skills_json or "[]")
next_agent = _reserve_routing_agent(
call_id=call_id,
+33 -1
View File
@@ -19,6 +19,34 @@ _MIN_SOFT_MATCH_LENGTH = 4
_NON_WORD_RE = re.compile(r"[^\w]+", re.UNICODE)
_SPACE_RE = re.compile(r"\s+")
# Common RU/KZ greetings, confirmations, pronouns, and particles that carry no
# topical signal on their own. Without this, a caller utterance as thin as
# "да" or "хорошо" could still exact-token-match some unrelated KB article
# that happens to contain that word in its body, and get returned as the
# top/only search result — read back to the caller as if it were the answer
# to their question. Filtering these keeps _score_row's exact-token match
# meaningful: a match now requires an actual content word.
_STOPWORDS = frozenset(
{
# RU: greetings / confirmations / fillers
"алло", "ага", "да", "неа", "нет", "ой", "ок", "окей", "угу", "ясно",
"ладно", "хорошо", "понял", "поняла", "привет", "здравствуйте",
"добрый", "день", "вечер", "утро", "слышу", "слышно", "спасибо",
"пожалуйста", "извините", "простите", "алло",
# RU: pronouns / conjunctions / particles with no topical content
"я", "ты", "вы", "мы", "он", "она", "они", "это", "то", "и", "а",
"но", "или", "что", "как", "где", "когда", "если", "чтобы", "для",
"из", "по", "на", "в", "с", "у", "о", "же", "ли", "бы", "не", "ну",
"вот", "просто", "есть", "быть", "можно", "нужно", "надо", "уже",
# KZ: greetings / confirmations / fillers
"иә", "ия", "жоқ", "жарайды", "түсінікті", "рахмет", "сәлем",
"сәлеметсіз", "бе", "кешіріңіз",
# KZ: pronouns / conjunctions / particles
"мен", "сен", "сіз", "біз", "ол", "олар", "және", "бірақ", "немесе",
"не", "қалай", "қайда", "қашан", "үшін", "туралы",
}
)
class KBSearchRow(Protocol):
id: int
@@ -38,7 +66,11 @@ def normalize_kb_text(value: str | None) -> str:
def tokenize_kb_text(value: str | None) -> list[str]:
return [token for token in normalize_kb_text(value).split(" ") if len(token) >= _MIN_TOKEN_LENGTH]
return [
token
for token in normalize_kb_text(value).split(" ")
if len(token) >= _MIN_TOKEN_LENGTH and token not in _STOPWORDS
]
def search_kb_rows(
+6
View File
@@ -363,6 +363,12 @@ class VoiceEventIn(BaseModel):
"recording.ready",
"call.connected",
"call.transferred",
"AgentReserved",
"AgentRinging",
"AgentNoAnswer",
"AgentConnected",
"TransferCompleted",
"TransferFailed",
]
call_id: str
interaction_id: str | None = None
-73
View File
@@ -1589,79 +1589,6 @@ def test_voice_v2_streaming_duplex_early_plan_returns_domain_followup_without_ll
assert decision["metadata"]["early_intent"] == "schedule"
def test_voice_v2_streaming_duplex_early_plan_answers_from_kb_without_llm(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "v2_streaming_duplex")
def _unexpected_llm(messages, **kwargs):
raise AssertionError(f"LLM should not be called for early plan: {messages!r}")
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _unexpected_llm)
kb_article = SimpleNamespace(
article_id="kba_early_schedule",
title="График работы филиалов",
body="Филиалы работают с понедельника по пятницу с 9:00 до 18:00.",
intent_code="BRANCH_SCHEDULE",
)
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_early_schedule_kb", status="new", queue_id="que_voice", subject="unknown"),
transcript_text="Мне надо узнать график работы",
transcript_window=[],
kb_results=[kb_article],
disclosure_required=False,
request_metadata={
"voice_v2_enabled": True,
"reply_phase": "early_plan",
"response_plan_id": "rsp_early_schedule_kb",
"early_intent": "schedule",
},
)
assert decision["model"] == "voice_early_plan_kb"
assert decision["intent"] == "BRANCH_SCHEDULE"
assert decision["kb_refs"] == ["kba_early_schedule"]
assert decision["needs_handoff"] is False
assert "9:00" in decision["reply_text"] or "9" in decision["reply_text"]
assert decision["metadata"]["reply_phase"] == "early_plan"
def test_voice_v2_streaming_duplex_early_plan_operator_request_skips_kb(monkeypatch):
monkeypatch.setenv("AI_VOICE_POLICY_MODE", "v2_streaming_duplex")
def _unexpected_llm(messages, **kwargs):
raise AssertionError(f"LLM should not be called for early plan: {messages!r}")
monkeypatch.setattr(ai_module, "_request_structured_model_decision", _unexpected_llm)
kb_article = SimpleNamespace(
article_id="kba_should_not_be_used",
title="Unrelated article",
body="Should not be referenced for an operator handoff.",
intent_code="SOMETHING_ELSE",
)
decision = voice_module._voice_decision(
language="ru",
customer=None,
interaction=SimpleNamespace(interaction_id="int_voice_early_operator", status="new", queue_id="que_voice", subject="unknown"),
transcript_text="Соедините меня с оператором",
transcript_window=[],
kb_results=[kb_article],
disclosure_required=False,
request_metadata={
"voice_v2_enabled": True,
"reply_phase": "early_plan",
"response_plan_id": "rsp_early_operator",
"early_intent": "operator_request",
},
)
assert decision["intent"] == "handoff_request"
assert decision["needs_handoff"] is True
assert decision["kb_refs"] == []
def test_voice_decision_hearing_check_keeps_active_topic_without_llm(monkeypatch):
def _unexpected_llm(messages, **kwargs):
raise AssertionError(f"LLM should not be called for hearing check: {messages!r}")
+55
View File
@@ -114,6 +114,61 @@ def test_search_kb_rows_prefers_title_and_tags_over_body_only_mentions():
assert results[0].title == rows[0].title
def test_search_kb_rows_ignores_stopword_only_query():
# A caller confirming the language ("да") should never surface an
# unrelated FAQ article just because that article's body happens to
# contain the word "да" somewhere in ordinary prose.
rows = [
_row(
row_id=1,
title="Активация ваучера",
body="Да, подтвердите СМС с номера 1414 командой 21*1.",
tags=["ваучер"],
),
]
assert search_kb_rows(rows, "да", limit=5) == []
assert search_kb_rows(rows, "Хорошо", limit=5) == []
def test_search_kb_rows_still_matches_real_content_word_amid_fillers():
rows = [
_row(
row_id=1,
title="Активация ваучера",
body="Подтвердите СМС с номера 1414 командой 21*1.",
tags=["ваучер"],
),
_row(
row_id=2,
title="График работы",
body="Филиалы работают с 9 до 18.",
tags=["график"],
),
]
results = search_kb_rows(rows, "да, у меня вопрос про ваучер", limit=5)
assert results
assert results[0].title == "Активация ваучера"
def test_search_kb_rows_single_real_word_query_still_matches():
rows = [
_row(
row_id=1,
title="Активация ваучера",
body="Подтвердите СМС с номера 1414 командой 21*1.",
tags=["ваучер"],
),
]
results = search_kb_rows(rows, "ваучер", limit=5)
assert results
assert results[0].title == "Активация ваучера"
def test_search_kb_rows_breaks_ties_by_newer_id():
older = _row(
row_id=10,