Compare commits
12
Commits
8ced7a59e3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c12bca29b | ||
|
|
c261d08036 | ||
|
|
78dfe6a9e1 | ||
|
|
f978702a6c | ||
|
|
f82f27c035 | ||
|
|
75d105f636 | ||
|
|
9bf367abf4 | ||
|
|
3c5c233071 | ||
|
|
99d169ec67 | ||
|
|
9fdaa9472f | ||
|
|
1dc37d2764 | ||
|
|
8382dfa9ba |
@@ -10,10 +10,10 @@ ALLOW_LEGACY_HEADER_AUTH=0
|
||||
|
||||
AI_PROVIDER=openai_compatible
|
||||
AI_API_BASE=https://api.openai.com/v1
|
||||
AI_API_KEY=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
|
||||
AI_MODEL=gpt-4o-mini
|
||||
AI_API_KEY=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
|
||||
AI_MODEL=gpt-5-mini
|
||||
AI_TIMEOUT_SECONDS=30
|
||||
AI_VOICE_AI_TIMEOUT_SECONDS=10
|
||||
AI_VOICE_AI_TIMEOUT_SECONDS=15
|
||||
AI_WEB_SEARCH_ENABLED=1
|
||||
AI_WEB_SEARCH_MAX_RESULTS=5
|
||||
AI_WEB_SEARCH_GL=kz
|
||||
|
||||
@@ -2357,13 +2357,18 @@ def _request_structured_model_decision(
|
||||
if not _ai_api_base() or not _ai_api_key():
|
||||
raise RuntimeError("AI_API_BASE / AI_API_KEY are required for openai_compatible provider")
|
||||
started = time.perf_counter()
|
||||
payload = {
|
||||
"model": _ai_model(),
|
||||
"temperature": 0.2,
|
||||
"max_tokens": _ai_decision_max_tokens(),
|
||||
model = _ai_model()
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"response_format": {"type": "json_object"},
|
||||
"messages": messages,
|
||||
}
|
||||
if model.startswith("gpt-5"):
|
||||
payload["max_completion_tokens"] = _ai_decision_max_tokens()
|
||||
payload["reasoning_effort"] = "minimal"
|
||||
else:
|
||||
payload["temperature"] = 0.2
|
||||
payload["max_tokens"] = _ai_decision_max_tokens()
|
||||
effective_timeout = timeout_seconds if timeout_seconds is not None else _ai_timeout_seconds()
|
||||
with httpx.Client(timeout=effective_timeout) as client:
|
||||
response = client.post(
|
||||
|
||||
@@ -135,8 +135,12 @@ def human_fallback_reply(language: str, *, is_greeting: bool = False) -> str:
|
||||
def operator_system_prompt(*, language: str, channel_label: str, is_voice: bool, config: Any | None = None) -> str:
|
||||
preferred_language = "Kazakh" if str(language or "").strip().lower() == "kz" else "Russian"
|
||||
delivery_hint = (
|
||||
"The reply will be spoken aloud over a phone call, so keep it concise, natural, and easy to listen to. "
|
||||
"Prefer one or two short sentences and at most one clarifying question. "
|
||||
"The reply will be spoken aloud over a phone call, so keep it concise, natural, and easy to listen to, "
|
||||
"but never at the cost of dropping a required fact. "
|
||||
"Use as many short sentences as needed to cover every material fact from the grounding kb_results "
|
||||
"snippet completely - required steps, codes, commands, deadlines, amounts, and conditions - typically "
|
||||
"two to four short sentences; never silently omit or shorten out a required step just to sound brief. "
|
||||
"At most one clarifying question per turn. "
|
||||
"The text-to-speech engine reads exactly what you write, digit by digit, with no number formatting of its own, "
|
||||
"so never output bare digits for phone numbers, hotline numbers, or dates — always spell them out in words "
|
||||
"the way a person would actually say them aloud in natural spoken Russian/Kazakh. "
|
||||
@@ -174,6 +178,8 @@ def operator_system_prompt(*, language: str, channel_label: str, is_voice: bool,
|
||||
"Paraphrase them naturally instead of quoting them verbatim. "
|
||||
"Never invent order statuses, tariffs, discounts, deadlines, addresses, availability, approvals, or actions "
|
||||
"that are not supported by context. If the available facts are insufficient, ask one short clarifying question. "
|
||||
"After you deliver a complete answer grounded in kb_results (not when you are asking a clarifying question, "
|
||||
"handling an identity/off-topic reply, or closing the call), end reply_text with a brief natural check such as «Ответила ли я на ваш вопрос?» in Russian, or its natural Kazakh equivalent, phrased differently each time so it does not sound scripted. "
|
||||
"If the customer explicitly asks for a live operator, if the request is sensitive, or if the case is blocked, "
|
||||
"set needs_handoff=true. "
|
||||
f"{delivery_hint} "
|
||||
|
||||
@@ -19,7 +19,6 @@ 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,
|
||||
@@ -1535,7 +1534,6 @@ 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 {}
|
||||
@@ -1578,56 +1576,10 @@ def _voice_early_plan(
|
||||
"reply_phase": "early_plan",
|
||||
},
|
||||
}
|
||||
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"}:
|
||||
if early_intent in {"schedule", "address", "price", "status", "problem", "operator_request"}:
|
||||
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."
|
||||
@@ -1653,11 +1605,15 @@ def _voice_early_plan(
|
||||
if reply_text:
|
||||
return {
|
||||
"language": language,
|
||||
"intent": "clarification",
|
||||
"intent": "handoff_request" if early_intent == "operator_request" else "clarification",
|
||||
"reply_text": _voice_compact_reply_text(reply_text, language=language),
|
||||
"confidence": 0.62,
|
||||
"needs_handoff": False,
|
||||
"handoff_reason": None,
|
||||
"needs_handoff": early_intent == "operator_request",
|
||||
"handoff_reason": (
|
||||
"Запрос требует участия живого оператора."
|
||||
if early_intent == "operator_request"
|
||||
else None
|
||||
),
|
||||
"case_action": "keep_open",
|
||||
"kb_refs": [],
|
||||
"summary_text": "Early domain plan is prepared.",
|
||||
@@ -1880,7 +1836,6 @@ 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):
|
||||
@@ -2582,17 +2537,13 @@ 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
|
||||
# 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,
|
||||
)
|
||||
kb_results = []
|
||||
if not early_plan_only:
|
||||
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",
|
||||
|
||||
@@ -1335,6 +1335,27 @@ def _process_voice_ai_turn_sync(
|
||||
payload: VoiceAITurnIn,
|
||||
*,
|
||||
auto_handoff: bool,
|
||||
) -> VoiceAITurnDecisionOut:
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
return _process_voice_ai_turn_sync_once(session_id, payload, auto_handoff=auto_handoff)
|
||||
except HTTPException as exc:
|
||||
if attempt == 0 and exc.status_code == 502 and "deadlock detected" in str(exc.detail).lower():
|
||||
logging.getLogger(__name__).warning("voice_turn_deadlock_retry session_id=%s", session_id)
|
||||
last_exc = exc
|
||||
continue
|
||||
raise
|
||||
if last_exc is not None:
|
||||
raise last_exc
|
||||
raise RuntimeError("unreachable")
|
||||
|
||||
|
||||
def _process_voice_ai_turn_sync_once(
|
||||
session_id: str,
|
||||
payload: VoiceAITurnIn,
|
||||
*,
|
||||
auto_handoff: bool,
|
||||
) -> VoiceAITurnDecisionOut:
|
||||
session = get_session()
|
||||
voice_session = None
|
||||
|
||||
@@ -416,6 +416,13 @@ class AudioSocketMediaRuntime:
|
||||
return "Техникалық ақау шықты. Оператормен қосамын."
|
||||
return "Возникла техническая проблема со связью. Соединяю с оператором."
|
||||
|
||||
@staticmethod
|
||||
def _handoff_unavailable_text(language: str | None) -> str:
|
||||
normalized = str(language or "").strip().lower()
|
||||
if normalized == "kz":
|
||||
return "Кешіріңіз, дәл қазір операторлардың барлығы бос емес. Мен сұрағыңызға көмектесуді жалғастырамын."
|
||||
return "Извините, сейчас все операторы заняты и не отвечают. Я продолжу помогать вам сама, задайте свой вопрос."
|
||||
|
||||
@staticmethod
|
||||
def _should_use_emotive_ack(registration: MediaRegistration, language: str | None) -> bool:
|
||||
if not registration.voice_v2_emotive_ack:
|
||||
@@ -956,6 +963,11 @@ class AudioSocketMediaRuntime:
|
||||
bool(partial.is_final),
|
||||
transcript_text[:160],
|
||||
)
|
||||
# Fresh speech content is still arriving, so push the
|
||||
# no-speech silence-timeout deadline forward instead of
|
||||
# interrupting a caller who is actively mid-utterance.
|
||||
if actor.state == "listening":
|
||||
actor.listening_since_monotonic = time.monotonic()
|
||||
actor.partial_transcript = transcript_text
|
||||
self._update_stable_partial_transcript(actor, transcript_text, provider_stable=bool(partial.is_stable or partial.is_final))
|
||||
intent = self._detect_early_intent(transcript_text)
|
||||
@@ -1890,6 +1902,19 @@ class AudioSocketMediaRuntime:
|
||||
actor.registration.voice_session_id,
|
||||
str(exc)[:500],
|
||||
)
|
||||
# The caller was already told "one moment, connecting you" -
|
||||
# leaving them in dead silence when the transfer times out is
|
||||
# worse than an AI-handled fallback, so tell them and keep going.
|
||||
if not actor.closed:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._speak_reply(
|
||||
actor,
|
||||
self._handoff_unavailable_text(actor.registration.language),
|
||||
is_greeting=False,
|
||||
reply_phase="handoff_unavailable",
|
||||
)
|
||||
if not actor.closed:
|
||||
await self._set_actor_state(actor, "listening")
|
||||
|
||||
actor.handoff_task = asyncio.create_task(_run())
|
||||
return actor.handoff_task
|
||||
|
||||
@@ -1233,7 +1233,7 @@ def process_recording_ready(
|
||||
|
||||
|
||||
_NO_ANSWER_DIAL_STATUSES = {"NOANSWER", "BUSY", "CANCEL", "CHANUNAVAIL", "CONGESTION"}
|
||||
_NO_ANSWER_HANGUP_CAUSES = {"17", "18", "19", "21", "34", "38"}
|
||||
_NO_ANSWER_HANGUP_CAUSES = {"1", "3", "17", "18", "19", "20", "21", "22", "34", "38"}
|
||||
|
||||
|
||||
def process_agent_dial_outcome(session, row: AsteriskEventLogRow, payload: dict[str, Any]) -> None:
|
||||
|
||||
@@ -938,7 +938,11 @@ def retry_escalation_no_answer(session, *, call_id: str, dial_outcome: str) -> N
|
||||
)
|
||||
|
||||
attempted_ids = json.loads(escalation.attempted_agent_ids_json or "[]")
|
||||
channel = _resolve_handoff_channel(session, link)
|
||||
# Use the already-known, actively-maintained channel name directly instead of
|
||||
# _resolve_handoff_channel()'s live AMI CoreShowChannels re-discovery: that
|
||||
# round-trip can take ~10s, which races (and loses) against the dialplan's
|
||||
# own short MusicOnHold-then-hangup wait window for this exact retry path.
|
||||
channel = str(link.channel_name or "").strip() or _resolve_handoff_channel(session, link)
|
||||
required_skills = json.loads(escalation.required_skills_json or "[]")
|
||||
next_agent = _reserve_routing_agent(
|
||||
call_id=call_id,
|
||||
|
||||
@@ -19,6 +19,34 @@ _MIN_SOFT_MATCH_LENGTH = 4
|
||||
_NON_WORD_RE = re.compile(r"[^\w]+", re.UNICODE)
|
||||
_SPACE_RE = re.compile(r"\s+")
|
||||
|
||||
# Common RU/KZ greetings, confirmations, pronouns, and particles that carry no
|
||||
# topical signal on their own. Without this, a caller utterance as thin as
|
||||
# "да" or "хорошо" could still exact-token-match some unrelated KB article
|
||||
# that happens to contain that word in its body, and get returned as the
|
||||
# top/only search result — read back to the caller as if it were the answer
|
||||
# to their question. Filtering these keeps _score_row's exact-token match
|
||||
# meaningful: a match now requires an actual content word.
|
||||
_STOPWORDS = frozenset(
|
||||
{
|
||||
# RU: greetings / confirmations / fillers
|
||||
"алло", "ага", "да", "неа", "нет", "ой", "ок", "окей", "угу", "ясно",
|
||||
"ладно", "хорошо", "понял", "поняла", "привет", "здравствуйте",
|
||||
"добрый", "день", "вечер", "утро", "слышу", "слышно", "спасибо",
|
||||
"пожалуйста", "извините", "простите", "алло",
|
||||
# RU: pronouns / conjunctions / particles with no topical content
|
||||
"я", "ты", "вы", "мы", "он", "она", "они", "это", "то", "и", "а",
|
||||
"но", "или", "что", "как", "где", "когда", "если", "чтобы", "для",
|
||||
"из", "по", "на", "в", "с", "у", "о", "же", "ли", "бы", "не", "ну",
|
||||
"вот", "просто", "есть", "быть", "можно", "нужно", "надо", "уже",
|
||||
# KZ: greetings / confirmations / fillers
|
||||
"иә", "ия", "жоқ", "жарайды", "түсінікті", "рахмет", "сәлем",
|
||||
"сәлеметсіз", "бе", "кешіріңіз",
|
||||
# KZ: pronouns / conjunctions / particles
|
||||
"мен", "сен", "сіз", "біз", "ол", "олар", "және", "бірақ", "немесе",
|
||||
"не", "қалай", "қайда", "қашан", "үшін", "туралы",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class KBSearchRow(Protocol):
|
||||
id: int
|
||||
@@ -38,7 +66,11 @@ def normalize_kb_text(value: str | None) -> str:
|
||||
|
||||
|
||||
def tokenize_kb_text(value: str | None) -> list[str]:
|
||||
return [token for token in normalize_kb_text(value).split(" ") if len(token) >= _MIN_TOKEN_LENGTH]
|
||||
return [
|
||||
token
|
||||
for token in normalize_kb_text(value).split(" ")
|
||||
if len(token) >= _MIN_TOKEN_LENGTH and token not in _STOPWORDS
|
||||
]
|
||||
|
||||
|
||||
def search_kb_rows(
|
||||
|
||||
@@ -363,6 +363,12 @@ class VoiceEventIn(BaseModel):
|
||||
"recording.ready",
|
||||
"call.connected",
|
||||
"call.transferred",
|
||||
"AgentReserved",
|
||||
"AgentRinging",
|
||||
"AgentNoAnswer",
|
||||
"AgentConnected",
|
||||
"TransferCompleted",
|
||||
"TransferFailed",
|
||||
]
|
||||
call_id: str
|
||||
interaction_id: str | None = None
|
||||
|
||||
@@ -1589,79 +1589,6 @@ 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}")
|
||||
|
||||
@@ -114,6 +114,61 @@ def test_search_kb_rows_prefers_title_and_tags_over_body_only_mentions():
|
||||
assert results[0].title == rows[0].title
|
||||
|
||||
|
||||
def test_search_kb_rows_ignores_stopword_only_query():
|
||||
# A caller confirming the language ("да") should never surface an
|
||||
# unrelated FAQ article just because that article's body happens to
|
||||
# contain the word "да" somewhere in ordinary prose.
|
||||
rows = [
|
||||
_row(
|
||||
row_id=1,
|
||||
title="Активация ваучера",
|
||||
body="Да, подтвердите СМС с номера 1414 командой 21*1.",
|
||||
tags=["ваучер"],
|
||||
),
|
||||
]
|
||||
|
||||
assert search_kb_rows(rows, "да", limit=5) == []
|
||||
assert search_kb_rows(rows, "Хорошо", limit=5) == []
|
||||
|
||||
|
||||
def test_search_kb_rows_still_matches_real_content_word_amid_fillers():
|
||||
rows = [
|
||||
_row(
|
||||
row_id=1,
|
||||
title="Активация ваучера",
|
||||
body="Подтвердите СМС с номера 1414 командой 21*1.",
|
||||
tags=["ваучер"],
|
||||
),
|
||||
_row(
|
||||
row_id=2,
|
||||
title="График работы",
|
||||
body="Филиалы работают с 9 до 18.",
|
||||
tags=["график"],
|
||||
),
|
||||
]
|
||||
|
||||
results = search_kb_rows(rows, "да, у меня вопрос про ваучер", limit=5)
|
||||
|
||||
assert results
|
||||
assert results[0].title == "Активация ваучера"
|
||||
|
||||
|
||||
def test_search_kb_rows_single_real_word_query_still_matches():
|
||||
rows = [
|
||||
_row(
|
||||
row_id=1,
|
||||
title="Активация ваучера",
|
||||
body="Подтвердите СМС с номера 1414 командой 21*1.",
|
||||
tags=["ваучер"],
|
||||
),
|
||||
]
|
||||
|
||||
results = search_kb_rows(rows, "ваучер", limit=5)
|
||||
|
||||
assert results
|
||||
assert results[0].title == "Активация ваучера"
|
||||
|
||||
|
||||
def test_search_kb_rows_breaks_ties_by_newer_id():
|
||||
older = _row(
|
||||
row_id=10,
|
||||
|
||||
Reference in New Issue
Block a user