From 5588f99db99ce2c3b18cfc38ce849a15bb63c94a Mon Sep 17 00:00:00 2001 From: Yera All Date: Sat, 11 Apr 2026 01:55:28 +0500 Subject: [PATCH] fix(voice): fast fallback for off-domain requests --- services/ai_orchestrator_service/voice.py | 111 ++++++++++++++++++++++ tests/test_ai_orchestrator_service.py | 40 ++++++++ 2 files changed, 151 insertions(+) diff --git a/services/ai_orchestrator_service/voice.py b/services/ai_orchestrator_service/voice.py index 2bed35f..8df1e71 100644 --- a/services/ai_orchestrator_service/voice.py +++ b/services/ai_orchestrator_service/voice.py @@ -906,6 +906,97 @@ def _voice_generic_prompt(language: str) -> str: return "Чтобы помочь быстрее, скажите в двух словах, что вам нужно: график работы, статус заявки, тариф или оператор." +def _voice_has_service_topic(text: str | None) -> bool: + normalized = _voice_text_key(text) + if not normalized: + return False + service_markers = ( + "график", + "время работы", + "режим работы", + "статус", + "заявк", + "заказ", + "обращен", + "запрос", + "тариф", + "стоимост", + "цен", + "оплат", + "услуг", + "адрес", + "филиал", + "город", + "офис", + "отделени", + "не работает", + "ошибк", + "проблем", + "сбой", + "интернет", + "связь", + "оператор", + "менеджер", + "сотрудник", + "компан", + "подключ", + "доставк", + ) + return any(marker in normalized for marker in service_markers) + + +def _voice_is_off_domain_request(text: str | None) -> bool: + normalized = _voice_text_key(text) + if not normalized or _voice_has_service_topic(normalized): + return False + broad_markers = ( + "ядерн", + "реактор", + "космос", + "планет", + "математ", + "теорем", + "физик", + "хими", + "биолог", + "истори", + "рецепт", + "борщ", + "салат", + "анекдот", + "стих", + "програм", + "python", + "java", + "javascript", + "погод", + ) + if any(marker in normalized for marker in broad_markers): + return True + broad_openers = ( + "как работает", + "как устроен", + "что такое", + "объясни", + "расскажи про", + "почему", + ) + return any(normalized.startswith(prefix) for prefix in broad_openers) + + +def _voice_off_domain_reply(language: str) -> tuple[str, str]: + if language == "kz": + return ( + "Men kompaniyamyzdyn qyzmetteri men otinishteri boiynsha komek bere alamyn. " + "Eger suraq bizdin qyzmetke qatysty bolsa, qysqasha naqtylaңыз. Qalasaңыз, operatorga qosamyn.", + "AI qongyraudyn taqyrybyn kompaniya qyzmetteri sheginde naqtylaudy usyndy.", + ) + return ( + "Я помогу по вопросам наших услуг и обращений. Если вопрос связан с нашей компанией, скажите коротко, что именно нужно. Если хотите, сразу соединю с оператором.", + "AI мягко вернул разговор к вопросам компании и предложил перевод на оператора.", + ) + + def _voice_confusion_prompt(language: str, caller_texts: list[str]) -> str: topic_prompt = _voice_topic_prompt(language, caller_texts) if topic_prompt: @@ -1504,6 +1595,26 @@ def _voice_decision( "latency_ms": 1, } + if not kb_results and _voice_is_off_domain_request(normalized): + reply_text, summary_text = _voice_off_domain_reply(language) + decision = { + "language": language, + "intent": "clarification", + "reply_text": reply_text, + "confidence": 0.58, + "needs_handoff": False, + "handoff_reason": None, + "case_action": "keep_open", + "kb_refs": [], + "summary_text": summary_text, + "model": "voice_policy_off_domain", + "latency_ms": 1, + } + if v2_metadata: + decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language) + decision["metadata"] = v2_metadata + return decision + llm_decision = _voice_llm_decision( language=language, customer=customer, diff --git a/tests/test_ai_orchestrator_service.py b/tests/test_ai_orchestrator_service.py index 569a9fe..ba45d78 100644 --- a/tests/test_ai_orchestrator_service.py +++ b/tests/test_ai_orchestrator_service.py @@ -1356,6 +1356,46 @@ def test_voice_v2_fast_conversational_adds_ack_metadata_and_compacts_reply(monke assert len(decision["reply_text"]) <= 180 +def test_voice_v2_off_domain_request_returns_fast_operator_fallback_without_llm(monkeypatch): + monkeypatch.setenv("AI_VOICE_POLICY_MODE", "v2_fast_conversational") + + def _unexpected_llm(messages): + raise AssertionError(f"LLM should not be called for off-domain fallback: {messages!r}") + + monkeypatch.setattr(ai_module, "_request_structured_model_decision", _unexpected_llm) + + decision = voice_module._voice_decision( + language="ru", + customer=None, + interaction=SimpleNamespace(interaction_id="int_voice_off_domain", status="new", queue_id="que_voice", subject="unknown"), + transcript_text="Мне надо узнать, как работает ядерный реактор.", + transcript_window=[ + SimpleNamespace( + speaker="caller", + text="Мне надо узнать, как работает ядерный реактор.", + sequence_no=1, + source_type="voice_asr", + barge_in_interrupted=False, + created_at=utc_now_iso(), + ) + ], + kb_results=[], + disclosure_required=False, + request_metadata={"voice_v2_enabled": True, "response_plan_id": "rsp_off_domain"}, + ) + + assert decision["intent"] == "clarification" + assert decision["needs_handoff"] is False + assert decision["model"] == "voice_policy_off_domain" + assert "наших услуг" in decision["reply_text"].lower() + assert "оператор" in decision["reply_text"].lower() + assert "ai" not in decision["reply_text"].lower() + assert "база знаний" not in decision["reply_text"].lower() + assert decision["metadata"]["voice_v2_enabled"] is True + assert decision["metadata"]["early_intent"] == "unknown" + assert decision["metadata"]["response_plan_id"] == "rsp_off_domain" + + def test_ai_enqueue_creates_outbound_ai_reply_and_delivery_flow(monkeypatch): monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") monkeypatch.setenv("AI_PROVIDER", "stub")