fix: let voice early-plan turns answer from the FAQ knowledge base
deploy / deploy (push) Successful in 32s
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:
@@ -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",
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user