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