Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c261d08036 | ||
|
|
78dfe6a9e1 | ||
|
|
f978702a6c | ||
|
|
f82f27c035 | ||
|
|
75d105f636 | ||
|
|
9bf367abf4 | ||
|
|
3c5c233071 |
@@ -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} "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -956,6 +956,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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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