fix: let voice early-plan turns answer from the FAQ knowledge base
deploy / deploy (push) Successful in 32s

The speculative "early plan" turn (computed on partial ASR, before the
caller finishes talking) can win the race and get spoken as the actual
reply, but it unconditionally skipped KB search and answered common
questions (schedule/address/price/status/problem) with a hardcoded
clarifying question even when the FAQ already had the answer.

KB search is a cheap in-memory lexical scan over a DB-cached row set,
so it fits the early-plan latency budget unlike a real LLM call. Now
early-plan runs it and, on a match, answers from the KB snippet
(intent resolved via normalize_intent) instead of guessing a generic
clarifying question; with no match it falls back to the prior
behavior unchanged. operator_request is unaffected.
This commit is contained in:
2026-08-31 00:38:06 +05:00
parent d2438b6954
commit 8ced7a59e3
2 changed files with 139 additions and 17 deletions
+66 -17
View File
@@ -19,6 +19,7 @@ from services.shared.ai_context_summary import (
) )
from services.shared.core import new_id, utc_now_iso from services.shared.core import new_id, utc_now_iso
from services.shared.db import get_session 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.models import VoiceAIStartIn, VoiceAIStartOut, VoiceAITurnDecisionOut, VoiceAITurnIn, VoiceStartResult
from services.shared.sql_models import ( from services.shared.sql_models import (
AISessionRow, AISessionRow,
@@ -1534,6 +1535,7 @@ def _voice_early_plan(
transcript_text: str, transcript_text: str,
context_summary: str | dict[str, Any] | None = None, context_summary: str | dict[str, Any] | None = None,
request_metadata: dict[str, Any] | None = None, request_metadata: dict[str, Any] | None = None,
kb_results: list[Any] = (),
) -> dict[str, Any]: ) -> dict[str, Any]:
v2_metadata = _voice_v2_metadata(transcript_text, request_metadata) v2_metadata = _voice_v2_metadata(transcript_text, request_metadata)
payload = request_metadata if isinstance(request_metadata, dict) else {} payload = request_metadata if isinstance(request_metadata, dict) else {}
@@ -1576,10 +1578,56 @@ def _voice_early_plan(
"reply_phase": "early_plan", "reply_phase": "early_plan",
}, },
} }
if early_intent in {"schedule", "address", "price", "status", "problem", "operator_request"}: 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"}:
reply_text = _voice_summary_slot_prompt(language, context_summary) or _voice_topic_prompt(language, [transcript_text]) 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 not reply_text and language == "kz":
if early_intent == "schedule": if early_intent == "schedule":
reply_text = "Qai filialdyn, mekenjaidyn nemese qalanyng jumys uaqyty qyzyqtyratynyn aitnyz." reply_text = "Qai filialdyn, mekenjaidyn nemese qalanyng jumys uaqyty qyzyqtyratynyn aitnyz."
@@ -1605,15 +1653,11 @@ def _voice_early_plan(
if reply_text: if reply_text:
return { return {
"language": language, "language": language,
"intent": "handoff_request" if early_intent == "operator_request" else "clarification", "intent": "clarification",
"reply_text": _voice_compact_reply_text(reply_text, language=language), "reply_text": _voice_compact_reply_text(reply_text, language=language),
"confidence": 0.62, "confidence": 0.62,
"needs_handoff": early_intent == "operator_request", "needs_handoff": False,
"handoff_reason": ( "handoff_reason": None,
"Запрос требует участия живого оператора."
if early_intent == "operator_request"
else None
),
"case_action": "keep_open", "case_action": "keep_open",
"kb_refs": [], "kb_refs": [],
"summary_text": "Early domain plan is prepared.", "summary_text": "Early domain plan is prepared.",
@@ -1836,6 +1880,7 @@ def _voice_decision(
transcript_text=transcript_text, transcript_text=transcript_text,
context_summary=context_summary, context_summary=context_summary,
request_metadata=request_metadata, request_metadata=request_metadata,
kb_results=kb_results,
) )
if persona.is_identity_request(normalized): if persona.is_identity_request(normalized):
@@ -2537,13 +2582,17 @@ 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_json = dump_context_summary(user_context_summary)
ai_session.context_summary_updated_at = now ai_session.context_summary_updated_at = now
kb_results = [] # Unlike name extraction/context-summary writes above (skipped for
if not early_plan_only: # early_plan_only since they're stateful and heavier), KB search is a
kb_results = app._kb_search( # cheap in-memory lexical scan over a DB-cached row set (see
session, # _load_kb_rows_cached) and comfortably fits the early-plan latency
payload.transcript_text, # budget, so it always runs — this lets _voice_early_plan answer from
language=ai_session.language, # the FAQ instead of guessing a generic clarifying question.
) kb_results = app._kb_search(
session,
payload.transcript_text,
language=ai_session.language,
)
disclosure_required = voice_session.disclosure_played_at is None disclosure_required = voice_session.disclosure_played_at is None
decision = _voice_decision( decision = _voice_decision(
language=ai_session.language or "ru", language=ai_session.language or "ru",
+73
View File
@@ -1589,6 +1589,79 @@ def test_voice_v2_streaming_duplex_early_plan_returns_domain_followup_without_ll
assert decision["metadata"]["early_intent"] == "schedule" 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 test_voice_decision_hearing_check_keeps_active_topic_without_llm(monkeypatch):
def _unexpected_llm(messages, **kwargs): def _unexpected_llm(messages, **kwargs):
raise AssertionError(f"LLM should not be called for hearing check: {messages!r}") raise AssertionError(f"LLM should not be called for hearing check: {messages!r}")