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
+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"
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}")