fix(voice): fast fallback for off-domain requests
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user