This commit is contained in:
Konturai DevOps
2026-05-01 17:18:35 +05:00
parent b7e40de7a1
commit d756003036
10 changed files with 977 additions and 37 deletions
+591
View File
@@ -0,0 +1,591 @@
"""Kazakh first names for biasing the STT during the initial name-capture turn.
Sized for ElevenLabs Scribe v2 *batch* keyterms cap (1000 entries, 50 chars
each). The Realtime path is intentionally skipped during the first turn —
see CallSession._start_live_stt_stream — so we use the larger batch ceiling
to maximize coverage for Kazakh-origin names. Russian-origin names are
excluded; they are already recognized correctly without biasing.
"""
from __future__ import annotations
TOP_KAZAKH_NAMES: tuple[str, ...] = (
# ---- Male ----
"Абай",
"Абдрахман",
"Абдулла",
"Абдулмажид",
"Абдулжалил",
"Абжан",
"Абильхан",
"Абубакир",
"Абыз",
"Абылай",
"Абылайхан",
"Адал",
"Адахан",
"Адельжан",
"Адиль",
"Адильбек",
"Адильхан",
"Азамат",
"Азат",
"Азиз",
"Азизбек",
"Айбар",
"Айбат",
"Айбек",
"Айбол",
"Айболат",
"Айдан",
"Айдар",
"Айдарбек",
"Айдархан",
"Айдос",
"Айкын",
"Айнабек",
"Айнур",
"Айсен",
"Айсултан",
"Айткали",
"Айткеш",
"Айтуар",
"Акан",
"Акбар",
"Акжан",
"Акжол",
"Аким",
"Аксултан",
"Алау",
"Алдар",
"Алдияр",
"Алибек",
"Алим",
"Алимхан",
"Алишер",
"Алмагамбет",
"Алмаз",
"Алмас",
"Алпамыс",
"Алтай",
"Алтынбек",
"Аман",
"Аманбай",
"Аманжол",
"Аманкос",
"Аманкельды",
"Амангельды",
"Амир",
"Амиржан",
"Амирхан",
"Амре",
"Анар",
"Анарбай",
"Анарбек",
"Анвар",
"Аркат",
"Ардак",
"Арман",
"Арнур",
"Арсен",
"Арслан",
"Арыстан",
"Асан",
"Асет",
"Асим",
"Аскар",
"Аскат",
"Аскер",
"Аслан",
"Атабек",
"Аубакир",
"Ауез",
"Аян",
"Аяс",
"Бакдаулет",
"Бакир",
"Бактияр",
"Бактыбай",
"Бакыт",
"Бакытбек",
"Бакытжан",
"Балапан",
"Балгабай",
"Барлыбай",
"Бауыржан",
"Баян",
"Бекарыс",
"Бекасыл",
"Бекболат",
"Бекжан",
"Бекзат",
"Бексултан",
"Берген",
"Берик",
"Биржан",
"Болат",
"Болатбек",
"Болатхан",
"Бостандык",
"Бухар",
"Габидолла",
"Габит",
"Гали",
"Галым",
"Галымжан",
"Гани",
"Гумар",
"Гумарбек",
"Дамир",
"Данияр",
"Дархан",
"Дастан",
"Даулет",
"Дауренбек",
"Даурен",
"Дидар",
"Динмухамед",
"Динислам",
"Дияз",
"Досмухамед",
"Досжан",
"Досымбек",
"Дулат",
"Едил",
"Едыге",
"Ельжан",
"Еламан",
"Ерасыл",
"Ерболат",
"Ерзат",
"Ержан",
"Ерик",
"Еркебулан",
"Еркетай",
"Еркин",
"Ерлан",
"Ерлик",
"Ермагамбет",
"Ермек",
"Ермухан",
"Ернар",
"Ернур",
"Есенгали",
"Есим",
"Ескендир",
"Есламбек",
"Жайдар",
"Жакау",
"Жалгас",
"Жамал",
"Жамшидбек",
"Жанабат",
"Жанат",
"Жанболат",
"Жангильды",
"Жангир",
"Жанибек",
"Жасулан",
"Жаугашты",
"Жиен",
"Жолан",
"Жомарт",
"Жубан",
"Жумабай",
"Жумабек",
"Жумагали",
"Жумаш",
"Зейнолла",
"Зекен",
"Зейнулла",
"Зердебай",
"Идрис",
"Илиас",
"Ильяс",
"Иман",
"Имран",
"Иса",
"Искандер",
"Кабылбек",
"Кадыр",
"Кадыржан",
"Кайдар",
"Кайрат",
"Кайыргали",
"Кайсар",
"Канат",
"Канатбек",
"Канжар",
"Канипа",
"Карим",
"Касен",
"Касым",
"Кенес",
"Кенесары",
"Кенжебай",
"Куандык",
"Куаныш",
"Куат",
"Курманбай",
"Кылыш",
"Лукман",
"Магжан",
"Мадияр",
"Майлы",
"Майлыбай",
"Максат",
"Максут",
"Манар",
"Манас",
"Манатай",
"Маралбек",
"Марат",
"Мардан",
"Маулен",
"Мерген",
"Мирас",
"Мукагали",
"Мукаш",
"Муратбек",
"Мурат",
"Мухамед",
"Мухамеджан",
"Мухтар",
"Мырзабек",
"Мырзахан",
"Мырзахмет",
"Назар",
"Наурыз",
"Несип",
"Нияз",
"Нурадил",
"Нурбек",
"Нурбол",
"Нурдаулет",
"Нуржан",
"Нурлан",
"Нурлыбек",
"Нурмагамбет",
"Нурмурат",
"Нурсадык",
"Нурсат",
"Нурсултан",
"Нуртас",
"Нурым",
"Олжас",
"Омар",
"Ораз",
"Оразбай",
"Оразалы",
"Орак",
"Оралбек",
"Оралхан",
"Орынбай",
"Райымбек",
"Райыс",
"Рамазан",
"Расул",
"Рауан",
"Рахман",
"Рахмет",
"Раушан",
"Ринат",
"Рустам",
"Рустем",
"Сабит",
"Сабыр",
"Сагиман",
"Сагынтай",
"Сагынбай",
"Саду",
"Саин",
"Сайрам",
"Сакен",
"Самат",
"Самет",
"Санжар",
"Сапар",
"Саркен",
"Сасан",
"Сейтжан",
"Серик",
"Серикбай",
"Серикбек",
"Серикжан",
"Султан",
"Султанбек",
"Султанмурат",
"Сұңғат",
"Сұңқар",
"Талгат",
"Талгатбек",
"Тамерлан",
"Танат",
"Темирбек",
"Темирболат",
"Темирлан",
"Темиржан",
"Тилеген",
"Тимур",
"Толкын",
"Толеген",
"Тулеген",
"Турар",
"Турсын",
"Турсунбай",
"Узакбай",
"Улан",
"Уразалы",
"Усербай",
"Утеген",
"Файзулла",
"Фарит",
"Фархад",
"Хабиб",
"Хайрат",
"Хамза",
"Чингиз",
"Шалабай",
"Шамиль",
"Шерхан",
"Шернияз",
"Шынгысхан",
"Эльдар",
"Эрнар",
"Юсуф",
"Якуб",
# ---- Female ----
"Аделя",
"Адина",
"Адия",
"Азиля",
"Айгерим",
"Айгуль",
"Айдай",
"Айдана",
"Айзада",
"Айжамал",
"Айжан",
"Айзере",
"Айман",
"Айна",
"Айнагуль",
"Айнура",
"Айсара",
"Айсауле",
"Айсулу",
"Айша",
"Айя",
"Айяна",
"Акбота",
"Акерке",
"Аксана",
"Акмарал",
"Акниет",
"Акшолпан",
"Алина",
"Алия",
"Алмагуль",
"Алтын",
"Алтынай",
"Алуа",
"Альбина",
"Альмира",
"Альфия",
"Анел",
"Анелия",
"Аннур",
"Аружан",
"Аруна",
"Асем",
"Асемгуль",
"Асель",
"Асима",
"Асия",
"Асылзада",
"Асылзат",
"Асылым",
"Аяжан",
"Аяна",
"Аяулым",
"Багдат",
"Багила",
"Бадигуль",
"Балауса",
"Балгуль",
"Балжан",
"Балнур",
"Балым",
"Бахытгуль",
"Бахытжан",
"Бибигуль",
"Бибинур",
"Бикеш",
"Венера",
"Газиза",
"Гайша",
"Галия",
"Гаухар",
"Гаухарбану",
"Гулим",
"Гулбану",
"Гулбаршын",
"Гулдана",
"Гулден",
"Гулжан",
"Гулзада",
"Гулзара",
"Гулмира",
"Гулназ",
"Гулнар",
"Гулсая",
"Гулсара",
"Гулсина",
"Гулсум",
"Гулшат",
"Гульдана",
"Гульден",
"Гульжан",
"Гульзада",
"Гульзара",
"Гульмира",
"Гульназ",
"Гульнар",
"Гульсая",
"Гульсина",
"Гульсум",
"Гульшат",
"Гульбану",
"Гульбаршын",
"Гулнара",
"Гультас",
"Дамира",
"Дана",
"Данара",
"Данель",
"Дария",
"Дарига",
"Дарика",
"Дилда",
"Дильдар",
"Дильнара",
"Дильназ",
"Дина",
"Динара",
"Динель",
"Жадыра",
"Жайна",
"Жанаргуль",
"Жанар",
"Жанбал",
"Жанна",
"Жания",
"Жасмин",
"Жибек",
"Жулдыз",
"Зайнаб",
"Замзагуль",
"Замира",
"Зара",
"Зарема",
"Зарина",
"Зарифа",
"Захира",
"Зейнеп",
"Зира",
"Зулайхан",
"Зухра",
"Иасмин",
"Инара",
"Индира",
"Камажай",
"Камила",
"Камилла",
"Карлыга",
"Карлыгаш",
"Касиет",
"Кулжайнар",
"Кулзада",
"Куляш",
"Куралай",
"Кымбат",
"Лаура",
"Лейла",
"Леяла",
"Лиана",
"Луиза",
"Мадина",
"Майра",
"Майя",
"Мақпал",
"Малика",
"Манара",
"Манагуль",
"Маржан",
"Маржанкуль",
"Мариямкуль",
"Мариям",
"Мариам",
"Меруерт",
"Мира",
"Молдир",
"Мунира",
"Назым",
"Назгуль",
"Назифа",
"Наргиз",
"Нурай",
"Нурбала",
"Нургуль",
"Нуржамал",
"Нурзада",
"Нуриля",
"Нурлыгуль",
"Перизат",
"Райхан",
"Рузия",
"Сабина",
"Сагила",
"Сайраш",
"Салима",
"Салтанат",
"Самал",
"Самира",
"Сандугаш",
"Сапура",
"Сара",
"Саулет",
"Сауле",
"Сахида",
"Симбат",
"Сулу",
"Сулушаш",
"Тазагуль",
"Тогжан",
"Толгана",
"Толганай",
"Томирис",
"Турсынай",
"Тұрсынгүл",
"Улжан",
"Улпан",
"Умит",
"Умитжан",
"Фариза",
"Фарзана",
"Фатима",
"Хадиша",
"Хайша",
"Шамшия",
"Шарбану",
"Шарифа",
"Шолпан",
"Шынар",
"Эльмира",
"Энлик",
"Юлдыз",
)
__all__ = ("TOP_KAZAKH_NAMES",)
+106 -9
View File
@@ -15,6 +15,7 @@ from collections.abc import Iterable
from enum import Enum
from realtime_voice_service.core.filler_audio import FillerAudioLibrary
from realtime_voice_service.core.kazakh_names import TOP_KAZAKH_NAMES
from realtime_voice_service.core.vad import BaseVAD, SileroVADDetector
from realtime_voice_service.providers.base import BaseLLM, BaseSTT, BaseSTTStream, BaseTTS, MockLLM, MockSTT, MockTTS
from realtime_voice_service.transports.base import BaseMediaTransport
@@ -53,7 +54,7 @@ FILLER_AUDIO_DELAY_MS = _filler_delay_ms()
TTS_CHUNK_SOFT_MIN_CHARS = _tts_chunk_soft_min_chars()
TTS_CHUNK_SOFT_MIN_WORDS = _tts_chunk_soft_min_words()
_FAREWELL_MARKERS: frozenset[str] = frozenset({
_COURTESY_OR_FAREWELL_MARKERS: frozenset[str] = frozenset({
"спасибо",
"благодарю",
"до свидания",
@@ -77,6 +78,48 @@ _FAREWELL_MARKERS: frozenset[str] = frozenset({
"bye",
})
_TERMINAL_DIRECT_FAREWELL_MARKERS: frozenset[str] = frozenset({
"до свидания",
"всего доброго",
"пока",
"до встречи",
"хорошего дня",
"goodbye",
"bye",
})
_TERMINAL_CLOSE_REQUEST_MARKERS: frozenset[str] = frozenset({
"можно завершить",
"можем завершить",
"можете завершать",
"давайте завершим",
"давайте закончим",
"завершить разговор",
"закончить разговор",
"заканчиваем разговор",
"завершайте разговор",
"кладу трубку",
"положу трубку",
})
_TERMINAL_NO_MORE_HELP_MARKERS: frozenset[str] = frozenset({
"ничего не нужно",
"ничего не надо",
"мне ничего не нужно",
"мне ничего не надо",
"больше ничего",
"больше ничего не нужно",
"больше ничего не надо",
"ничего больше не нужно",
"ничего больше не надо",
"это все",
"это всё",
"на этом все",
"на этом всё",
"вопросов нет",
"больше вопросов нет",
})
_FAREWELL_QUERY_MARKERS: frozenset[str] = frozenset({
"вопрос",
"подскаж",
@@ -90,7 +133,6 @@ _FAREWELL_QUERY_MARKERS: frozenset[str] = frozenset({
"нужно ли",
"нужно еще",
"нужно ещё",
"хочу",
"еще",
"ещё",
"теперь",
@@ -107,22 +149,63 @@ _FAREWELL_QUERY_MARKERS: frozenset[str] = frozenset({
"нужна",
"нужны",
})
_FAREWELL_HARD_QUERY_MARKERS: frozenset[str] = frozenset(
marker for marker in _FAREWELL_QUERY_MARKERS if marker not in {"вопрос", "можно"}
)
FAREWELL_RESPONSE_TEXT = "Спасибо за обращение в DigiOps. Всего доброго, до свидания!"
def _contains_voice_phrase(normalized_text: str, phrase: str) -> bool:
phrase_parts = [re.escape(part) for part in phrase.split()]
if not phrase_parts:
return False
phrase_pattern = r"\s+".join(phrase_parts)
return re.search(
rf"(?<![^\W\d_]){phrase_pattern}(?![^\W\d_])",
normalized_text,
flags=re.UNICODE,
) is not None
def _ends_with_voice_phrase(normalized_text: str, phrase: str) -> bool:
phrase_parts = [re.escape(part) for part in phrase.split()]
if not phrase_parts:
return False
phrase_pattern = r"\s+".join(phrase_parts)
return re.search(
rf"(?<![^\W\d_]){phrase_pattern}(?:\s+(?:спасибо|благодарю))?$",
normalized_text,
flags=re.UNICODE,
) is not None
def _is_farewell_transcript(text: str) -> bool:
normalized = _voice_text_key(text)
return any(marker in normalized for marker in _FAREWELL_MARKERS)
return any(_contains_voice_phrase(normalized, marker) for marker in _COURTESY_OR_FAREWELL_MARKERS)
def _is_terminal_farewell(text: str) -> bool:
normalized = _voice_text_key(text)
if not any(marker in normalized for marker in _FAREWELL_MARKERS):
has_direct_farewell = any(
_contains_voice_phrase(normalized, marker)
for marker in _TERMINAL_DIRECT_FAREWELL_MARKERS
)
has_close_request = any(
_contains_voice_phrase(normalized, marker)
for marker in _TERMINAL_CLOSE_REQUEST_MARKERS
)
has_no_more_help = any(
_ends_with_voice_phrase(normalized, marker)
for marker in _TERMINAL_NO_MORE_HELP_MARKERS
)
if not (has_direct_farewell or has_close_request or has_no_more_help):
return False
if any(marker in normalized for marker in _FAREWELL_QUERY_MARKERS):
query_markers = _FAREWELL_QUERY_MARKERS if has_direct_farewell else _FAREWELL_HARD_QUERY_MARKERS
if any(marker in normalized for marker in query_markers):
return False
word_count = len(re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", normalized, flags=re.UNICODE))
return word_count <= 10
return word_count <= 16
SEMANTIC_CONTINUATION_TOKENS = {
@@ -1405,7 +1488,12 @@ class CallSession:
len(audio_bytes),
_audio_duration_ms(audio_bytes, sample_rate_hz=self.transport.sample_rate_hz),
)
pending_transcript = (await self._stt.transcribe(audio_bytes)).strip()
pending_transcript = (
await self._stt.transcribe(
audio_bytes,
keyterms=self._name_capture_keyterms() if self._awaiting_customer_name else None,
)
).strip()
except Exception:
LOGGER.exception("realtime session %s failed to transcribe pending unanswered audio", self.session_id)
continue
@@ -1421,6 +1509,9 @@ class CallSession:
)
return pending_texts
def _name_capture_keyterms(self) -> tuple[str, ...]:
return TOP_KAZAKH_NAMES
def _build_name_collection_response(self, transcript: str) -> str | None:
if not self._awaiting_customer_name:
return None
@@ -1880,7 +1971,10 @@ class CallSession:
self._reset_semantic_endpointing()
try:
LOGGER.info("realtime session %s live STT stream start requested", self.session_id)
live_stt_stream = await self._stt.start_stream(partial_callback=self._handle_partial_transcript)
live_stt_stream = await self._stt.start_stream(
partial_callback=self._handle_partial_transcript,
keyterms=self._name_capture_keyterms() if self._awaiting_customer_name else None,
)
except Exception:
LOGGER.exception("realtime session %s failed to start live STT stream", self.session_id)
return
@@ -1953,7 +2047,10 @@ class CallSession:
len(utterance_audio),
_audio_duration_ms(utterance_audio, sample_rate_hz=self.transport.sample_rate_hz),
)
return await self._stt.transcribe(utterance_audio)
return await self._stt.transcribe(
utterance_audio,
keyterms=self._name_capture_keyterms() if self._awaiting_customer_name else None,
)
def _maybe_dump_audio(self, audio_bytes: bytes) -> None:
if str(os.getenv("ENABLE_AUDIO_DUMP", "")).strip().lower() not in {"1", "true", "yes", "on"}: