From 8ced7a59e3fdc98ce48308aaf7ff23259404ca0e Mon Sep 17 00:00:00 2001 From: didar Date: Mon, 31 Aug 2026 00:38:06 +0500 Subject: [PATCH] fix: let voice early-plan turns answer from the FAQ knowledge base 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. --- services/ai_orchestrator_service/voice.py | 83 ++++++++++++++++++----- tests/test_ai_orchestrator_service.py | 73 ++++++++++++++++++++ 2 files changed, 139 insertions(+), 17 deletions(-) diff --git a/services/ai_orchestrator_service/voice.py b/services/ai_orchestrator_service/voice.py index e4c4cfe..6b89914 100644 --- a/services/ai_orchestrator_service/voice.py +++ b/services/ai_orchestrator_service/voice.py @@ -19,6 +19,7 @@ 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, @@ -1534,6 +1535,7 @@ 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 {} @@ -1576,10 +1578,56 @@ def _voice_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]) - 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." @@ -1605,15 +1653,11 @@ def _voice_early_plan( if reply_text: return { "language": language, - "intent": "handoff_request" if early_intent == "operator_request" else "clarification", + "intent": "clarification", "reply_text": _voice_compact_reply_text(reply_text, language=language), "confidence": 0.62, - "needs_handoff": early_intent == "operator_request", - "handoff_reason": ( - "Запрос требует участия Р¶РёРІРѕРіРѕ оператора." - if early_intent == "operator_request" - else None - ), + "needs_handoff": False, + "handoff_reason": None, "case_action": "keep_open", "kb_refs": [], "summary_text": "Early domain plan is prepared.", @@ -1836,6 +1880,7 @@ 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): @@ -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_updated_at = now - kb_results = [] - if not early_plan_only: - kb_results = app._kb_search( - session, - payload.transcript_text, - language=ai_session.language, - ) + # 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, + ) disclosure_required = voice_session.disclosure_played_at is None decision = _voice_decision( language=ai_session.language or "ru", diff --git a/tests/test_ai_orchestrator_service.py b/tests/test_ai_orchestrator_service.py index a474649..880e8ce 100644 --- a/tests/test_ai_orchestrator_service.py +++ b/tests/test_ai_orchestrator_service.py @@ -1589,6 +1589,79 @@ 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}")