fix: handle farewells and stabilize voice responses

This commit is contained in:
Konturai DevOps
2026-04-30 17:44:42 +05:00
parent fd89daf7a4
commit 18696d5f22
3 changed files with 89 additions and 8 deletions
+5 -5
View File
@@ -9,7 +9,7 @@ LLM_PROVIDER=openai
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_BASE_URL= OPENAI_BASE_URL=
OPENAI_LLM_MODEL=gpt-4o-mini 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_TIMEOUT_SECONDS=30
OPENAI_STT_MODEL=whisper-1 OPENAI_STT_MODEL=whisper-1
OPENAI_STT_LANGUAGE=ru OPENAI_STT_LANGUAGE=ru
@@ -26,7 +26,7 @@ OLLAMA_LLM_NUM_CTX=1024
STT_PROVIDER=openai STT_PROVIDER=openai
STT_FALLBACK_PROVIDER=elevenlabs STT_FALLBACK_PROVIDER=elevenlabs
STT_PROMPT=Это разговор на русском языке, но могут встречаться казахские имена: Айбын, Магжан, Бауыржан, Асель. STT_PROMPT=Это русская речь. Точно распознавай имена, фамилии, слова благодарности и прощания. Возможны имена: Айнур, Айгерим, Алия, Данияр, Азамат, Мадина, Нурсултан. Номера телефонов записывай цифрами.
TTS_PROVIDER=elevenlabs TTS_PROVIDER=elevenlabs
ENABLE_AUDIO_DUMP=false ENABLE_AUDIO_DUMP=false
@@ -54,12 +54,12 @@ YANDEX_STT_TOPIC=general
VAD_THRESHOLD=0.5 VAD_THRESHOLD=0.5
VAD_NEGATIVE_THRESHOLD= VAD_NEGATIVE_THRESHOLD=
VAD_SILENCE_TIMEOUT_MS=350 VAD_SILENCE_TIMEOUT_MS=420
VAD_SPEECH_PAD_MS=64 VAD_SPEECH_PAD_MS=120
VAD_MIN_SPEECH_DURATION_MS=0 VAD_MIN_SPEECH_DURATION_MS=0
VAD_USE_ONNX=false VAD_USE_ONNX=false
REALTIME_VOICE_INITIAL_GREETING_TEXT=Здравствуйте! Меня зовут Айнур, я эй-ай-ассистент компании ДиджиОпс. Как я могу к вам обращаться? REALTIME_VOICE_INITIAL_GREETING_TEXT=Здравствуйте! Меня зовут Айнур, я эй-ай-ассистент компании ДиджиОпс. Как я могу к вам обращаться?
SEMANTIC_ENDPOINTING_HOLD_MS=600 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_CHARS=10
REALTIME_VOICE_TTS_CHUNK_SOFT_MIN_WORDS=2 REALTIME_VOICE_TTS_CHUNK_SOFT_MIN_WORDS=2
+3 -3
View File
@@ -17,11 +17,11 @@ LOGGER = logging.getLogger("uvicorn.error")
DEFAULT_FILLER_TEXTS: dict[str, tuple[str, ...]] = { DEFAULT_FILLER_TEXTS: dict[str, tuple[str, ...]] = {
"serper": ( "serper": (
"Сейчас уточню.", "Сейчас уточню.",
"Минуту, пожалуйста.", "Проверяю информацию.",
), ),
"generic": ( "generic": (
"Секунду.", "Сейчас отвечу.",
"Минуту.", "Уточняю.",
), ),
} }
+81
View File
@@ -52,6 +52,49 @@ SEMANTIC_ENDPOINTING_HOLD_MS = _semantic_hold_ms()
FILLER_AUDIO_DELAY_MS = _filler_delay_ms() FILLER_AUDIO_DELAY_MS = _filler_delay_ms()
TTS_CHUNK_SOFT_MIN_CHARS = _tts_chunk_soft_min_chars() TTS_CHUNK_SOFT_MIN_CHARS = _tts_chunk_soft_min_chars()
TTS_CHUNK_SOFT_MIN_WORDS = _tts_chunk_soft_min_words() 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 = { SEMANTIC_CONTINUATION_TOKENS = {
"а", "а",
"в", "в",
@@ -927,6 +970,20 @@ class CallSession:
await self._assistant_task await self._assistant_task
await self.transport.close() 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( def _start_assistant_turn(
self, self,
*, *,
@@ -1047,6 +1104,26 @@ class CallSession:
) )
self._set_state(SessionState.LISTENING, reason="name collection completed") self._set_state(SessionState.LISTENING, reason="name collection completed")
return 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() sentence_queue = asyncio.Queue()
self._sentence_queue = sentence_queue self._sentence_queue = sentence_queue
playback_task = asyncio.create_task( playback_task = asyncio.create_task(
@@ -1066,6 +1143,7 @@ class CallSession:
epoch=epoch, epoch=epoch,
tool_name=None, tool_name=None,
started_monotonic=llm_started_monotonic, started_monotonic=llm_started_monotonic,
suppress=_is_farewell_transcript(transcript),
), ),
name=f"{self.session_id}-filler-delay-{epoch}", name=f"{self.session_id}-filler-delay-{epoch}",
) )
@@ -1618,7 +1696,10 @@ class CallSession:
epoch: int, epoch: int,
tool_name: str | None, tool_name: str | None,
started_monotonic: float, started_monotonic: float,
suppress: bool = False,
) -> None: ) -> None:
if suppress:
return
if FILLER_AUDIO_DELAY_MS <= 0: if FILLER_AUDIO_DELAY_MS <= 0:
self._start_filler_audio( self._start_filler_audio(
epoch=epoch, epoch=epoch,