fix(voice): fast fallback for off-domain requests

This commit is contained in:
Yera All
2026-04-11 01:55:28 +05:00
parent 6413beeba1
commit 5588f99db9
2 changed files with 151 additions and 0 deletions
+111
View File
@@ -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,
+40
View File
@@ -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")