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
+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"}: