From 18696d5f22f8d3fd354f159055151ccdd3c7b1d5 Mon Sep 17 00:00:00 2001 From: Konturai DevOps Date: Thu, 30 Apr 2026 17:44:42 +0500 Subject: [PATCH] fix: handle farewells and stabilize voice responses --- .env.example | 10 +++--- core/filler_audio.py | 6 ++-- core/session.py | 81 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 754e2a5..c845802 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,7 @@ LLM_PROVIDER=openai OPENAI_API_KEY= OPENAI_BASE_URL= OPENAI_LLM_MODEL=gpt-4o-mini -OPENAI_LLM_SYSTEM_PROMPT=You are Ainur, a concise Russian-speaking voice agent for the DigiOps contact center. Always answer in Russian, naturally and briefly for voice playback. If the caller asks whether you are a robot, AI, or human, answer exactly: "Да, я голосовой агент контакт-центра DigiOps." Read phone numbers digit by digit. Prefer clear stress-friendly wording. Do not use markdown, URLs, or tables. +OPENAI_LLM_SYSTEM_PROMPT=You are Ainur, a concise Russian-speaking voice agent for the DigiOps contact center. Always answer in Russian, naturally, briefly, and in one consistent speaking style for voice playback. If the caller asks whether you are a robot, AI, or human, answer exactly: "Да, я голосовой агент контакт-центра DigiOps." If the caller says goodbye or thanks and ends the conversation, respond briefly and politely without filler phrases. Read phone numbers digit by digit. Prefer clear stress-friendly wording. Do not use markdown, URLs, or tables. OPENAI_TIMEOUT_SECONDS=30 OPENAI_STT_MODEL=whisper-1 OPENAI_STT_LANGUAGE=ru @@ -26,7 +26,7 @@ OLLAMA_LLM_NUM_CTX=1024 STT_PROVIDER=openai STT_FALLBACK_PROVIDER=elevenlabs -STT_PROMPT=Это разговор на русском языке, но могут встречаться казахские имена: Айбын, Магжан, Бауыржан, Асель. +STT_PROMPT=Это русская речь. Точно распознавай имена, фамилии, слова благодарности и прощания. Возможны имена: Айнур, Айгерим, Алия, Данияр, Азамат, Мадина, Нурсултан. Номера телефонов записывай цифрами. TTS_PROVIDER=elevenlabs ENABLE_AUDIO_DUMP=false @@ -54,12 +54,12 @@ YANDEX_STT_TOPIC=general VAD_THRESHOLD=0.5 VAD_NEGATIVE_THRESHOLD= -VAD_SILENCE_TIMEOUT_MS=350 -VAD_SPEECH_PAD_MS=64 +VAD_SILENCE_TIMEOUT_MS=420 +VAD_SPEECH_PAD_MS=120 VAD_MIN_SPEECH_DURATION_MS=0 VAD_USE_ONNX=false REALTIME_VOICE_INITIAL_GREETING_TEXT=Здравствуйте! Меня зовут Айнур, я эй-ай-ассистент компании ДиджиОпс. Как я могу к вам обращаться? SEMANTIC_ENDPOINTING_HOLD_MS=600 -REALTIME_VOICE_FILLER_DELAY_MS=250 +REALTIME_VOICE_FILLER_DELAY_MS=200 REALTIME_VOICE_TTS_CHUNK_SOFT_MIN_CHARS=10 REALTIME_VOICE_TTS_CHUNK_SOFT_MIN_WORDS=2 diff --git a/core/filler_audio.py b/core/filler_audio.py index 36c9dd1..7b292ad 100644 --- a/core/filler_audio.py +++ b/core/filler_audio.py @@ -17,11 +17,11 @@ LOGGER = logging.getLogger("uvicorn.error") DEFAULT_FILLER_TEXTS: dict[str, tuple[str, ...]] = { "serper": ( "Сейчас уточню.", - "Минуту, пожалуйста.", + "Проверяю информацию.", ), "generic": ( - "Секунду.", - "Минуту.", + "Сейчас отвечу.", + "Уточняю.", ), } diff --git a/core/session.py b/core/session.py index 3bc34f4..a1533ee 100644 --- a/core/session.py +++ b/core/session.py @@ -52,6 +52,49 @@ SEMANTIC_ENDPOINTING_HOLD_MS = _semantic_hold_ms() 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({ + "спасибо", + "благодарю", + "до свидания", + "всего доброго", + "пока", + "до встречи", + "хорошего дня", + "goodbye", + "bye", +}) +_FAREWELL_QUERY_MARKERS: frozenset[str] = frozenset({ + "вопрос", + "подскаж", + "скажи", + "почему", + "как", + "что", + "где", + "когда", + "можно", + "нужно", + "хочу", + "мне", +}) +FAREWELL_RESPONSE_TEXT = "Спасибо за обращение в DigiOps. Всего доброго, до свидания!" + + +def _is_farewell_transcript(text: str) -> bool: + normalized = _voice_text_key(text) + return any(marker in normalized for marker in _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): + return False + if any(marker in normalized for marker in _FAREWELL_QUERY_MARKERS): + return False + word_count = len(re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", normalized, flags=re.UNICODE)) + return word_count <= 10 + + SEMANTIC_CONTINUATION_TOKENS = { "а", "в", @@ -927,6 +970,20 @@ class CallSession: await self._assistant_task await self.transport.close() + async def _close_after_farewell(self) -> None: + if self._closed: + return + self._closed = True + LOGGER.info( + "realtime session %s closing transport after farewell: state=%s epoch=%s conversation_entries=%s", + self.session_id, + self.state.value, + self.generation_epoch, + len(self._conversation), + ) + await self._cancel_live_stt_stream() + await self.transport.close() + def _start_assistant_turn( self, *, @@ -1047,6 +1104,26 @@ class CallSession: ) self._set_state(SessionState.LISTENING, reason="name collection completed") return + if _is_terminal_farewell(transcript): + LOGGER.info( + "realtime session %s farewell response prepared: epoch=%s text=%r", + self.session_id, + epoch, + _preview_text(FAREWELL_RESPONSE_TEXT), + ) + await self._play_prepared_response( + epoch=epoch, + response_text=FAREWELL_RESPONSE_TEXT, + actually_spoken_chunks=actually_spoken_chunks, + ) + self._ensure_generation(epoch) + self._commit_assistant_context( + llm_generated_text=FAREWELL_RESPONSE_TEXT, + actually_spoken_chunks=actually_spoken_chunks, + interrupted=False, + ) + await self._close_after_farewell() + return sentence_queue = asyncio.Queue() self._sentence_queue = sentence_queue playback_task = asyncio.create_task( @@ -1066,6 +1143,7 @@ class CallSession: epoch=epoch, tool_name=None, started_monotonic=llm_started_monotonic, + suppress=_is_farewell_transcript(transcript), ), name=f"{self.session_id}-filler-delay-{epoch}", ) @@ -1618,7 +1696,10 @@ class CallSession: epoch: int, tool_name: str | None, started_monotonic: float, + suppress: bool = False, ) -> None: + if suppress: + return if FILLER_AUDIO_DELAY_MS <= 0: self._start_filler_audio( epoch=epoch,