feat: improve phone speech and reduce response gaps
This commit is contained in:
+4
-1
@@ -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 a concise voice assistant for the DigiOps contact center. Answer clearly and briefly.
|
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_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
|
||||||
@@ -60,3 +60,6 @@ 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_TTS_CHUNK_SOFT_MIN_CHARS=10
|
||||||
|
REALTIME_VOICE_TTS_CHUNK_SOFT_MIN_WORDS=2
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ LOGGER = logging.getLogger("uvicorn.error")
|
|||||||
|
|
||||||
DEFAULT_FILLER_TEXTS: dict[str, tuple[str, ...]] = {
|
DEFAULT_FILLER_TEXTS: dict[str, tuple[str, ...]] = {
|
||||||
"serper": (
|
"serper": (
|
||||||
"Одну минуту.",
|
"Сейчас уточню.",
|
||||||
"Минуточку, проверяю данные.",
|
"Минуту, пожалуйста.",
|
||||||
),
|
),
|
||||||
"generic": (
|
"generic": (
|
||||||
"Одну минуту.",
|
"Секунду.",
|
||||||
"Секундочку.",
|
"Минуту.",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+100
-6
@@ -21,16 +21,37 @@ from realtime_voice_service.transports.base import BaseMediaTransport
|
|||||||
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger("uvicorn.error")
|
LOGGER = logging.getLogger("uvicorn.error")
|
||||||
def _semantic_hold_ms() -> int:
|
|
||||||
raw = str(os.getenv("SEMANTIC_ENDPOINTING_HOLD_MS", "700")).strip()
|
|
||||||
|
def _read_bounded_int_env(name: str, default: int, *, minimum: int, maximum: int) -> int:
|
||||||
|
raw = str(os.getenv(name, str(default))).strip()
|
||||||
try:
|
try:
|
||||||
value = int(float(raw))
|
value = int(float(raw))
|
||||||
except Exception:
|
except Exception:
|
||||||
value = 700
|
value = default
|
||||||
return max(200, min(value, 2000))
|
return max(minimum, min(value, maximum))
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_hold_ms() -> int:
|
||||||
|
return _read_bounded_int_env("SEMANTIC_ENDPOINTING_HOLD_MS", 700, minimum=200, maximum=2000)
|
||||||
|
|
||||||
|
|
||||||
|
def _filler_delay_ms() -> int:
|
||||||
|
return _read_bounded_int_env("REALTIME_VOICE_FILLER_DELAY_MS", 250, minimum=0, maximum=3000)
|
||||||
|
|
||||||
|
|
||||||
|
def _tts_chunk_soft_min_chars() -> int:
|
||||||
|
return _read_bounded_int_env("REALTIME_VOICE_TTS_CHUNK_SOFT_MIN_CHARS", 10, minimum=1, maximum=80)
|
||||||
|
|
||||||
|
|
||||||
|
def _tts_chunk_soft_min_words() -> int:
|
||||||
|
return _read_bounded_int_env("REALTIME_VOICE_TTS_CHUNK_SOFT_MIN_WORDS", 2, minimum=1, maximum=10)
|
||||||
|
|
||||||
|
|
||||||
SEMANTIC_ENDPOINTING_HOLD_MS = _semantic_hold_ms()
|
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()
|
||||||
SEMANTIC_CONTINUATION_TOKENS = {
|
SEMANTIC_CONTINUATION_TOKENS = {
|
||||||
"а",
|
"а",
|
||||||
"в",
|
"в",
|
||||||
@@ -282,8 +303,39 @@ def _ru_date_words(day_raw: str, month_raw: str, year_raw: str) -> str:
|
|||||||
return f"{day_words} {month_words} {_ru_number_words(str(year))}"
|
return f"{day_words} {month_words} {_ru_number_words(str(year))}"
|
||||||
|
|
||||||
|
|
||||||
|
VOICE_PRONUNCIATION_REPLACEMENTS: tuple[tuple[str, str], ...] = (
|
||||||
|
(r"\bDigiOps\b", "ДиджиОпс"),
|
||||||
|
(r"\bA\.?I\.?\b", "эй-ай"),
|
||||||
|
(r"\bAI\b", "эй-ай"),
|
||||||
|
(r"\bCRM\b", "си-ар-эм"),
|
||||||
|
(r"\bB2B\b", "би-ту-би"),
|
||||||
|
(r"\bIT\b", "ай-ти"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_voice_pronunciation(text: str) -> str:
|
||||||
|
normalized = str(text or "")
|
||||||
|
for pattern, replacement in VOICE_PRONUNCIATION_REPLACEMENTS:
|
||||||
|
normalized = re.sub(pattern, replacement, normalized, flags=re.IGNORECASE)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _ru_phone_words(raw: str) -> str:
|
||||||
|
payload = str(raw or "")
|
||||||
|
digits = re.sub(r"\D+", "", payload)
|
||||||
|
if len(digits) < 7:
|
||||||
|
return _ru_number_words(payload)
|
||||||
|
prefix = "плюс " if payload.strip().startswith("+") else ""
|
||||||
|
return f"{prefix}{_ru_digit_sequence(digits)}".strip()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_voice_numbers(text: str) -> str:
|
def _normalize_voice_numbers(text: str) -> str:
|
||||||
normalized = str(text or "")
|
normalized = str(text or "")
|
||||||
|
normalized = re.sub(
|
||||||
|
r"(?<!\w)(\+?\d(?:[\s()\-]*\d){6,})(?!\w)",
|
||||||
|
lambda match: _ru_phone_words(match.group(1)),
|
||||||
|
normalized,
|
||||||
|
)
|
||||||
normalized = re.sub(
|
normalized = re.sub(
|
||||||
r"(?<!\w)(\d{1,2})[.](\d{1,2})[.](\d{2,4})(?!\w)",
|
r"(?<!\w)(\d{1,2})[.](\d{1,2})[.](\d{2,4})(?!\w)",
|
||||||
lambda match: _ru_date_words(match.group(1), match.group(2), match.group(3)),
|
lambda match: _ru_date_words(match.group(1), match.group(2), match.group(3)),
|
||||||
@@ -340,6 +392,7 @@ def _sanitize_voice_text(text: str) -> str:
|
|||||||
sanitized = re.sub(r"\[([^\]]+)\]\((?:https?://|www\.)[^)\s]+[^)]*\)", r"\1", sanitized)
|
sanitized = re.sub(r"\[([^\]]+)\]\((?:https?://|www\.)[^)\s]+[^)]*\)", r"\1", sanitized)
|
||||||
sanitized = re.sub(r"(?:https?://|www\.)\S+", "", sanitized)
|
sanitized = re.sub(r"(?:https?://|www\.)\S+", "", sanitized)
|
||||||
sanitized = sanitized.replace("[", "").replace("]", "").replace("(", "").replace(")", "")
|
sanitized = sanitized.replace("[", "").replace("]", "").replace("(", "").replace(")", "")
|
||||||
|
sanitized = _normalize_voice_pronunciation(sanitized)
|
||||||
sanitized = re.sub(r"\s+", " ", sanitized).strip()
|
sanitized = re.sub(r"\s+", " ", sanitized).strip()
|
||||||
sanitized = _normalize_voice_numbers(sanitized)
|
sanitized = _normalize_voice_numbers(sanitized)
|
||||||
sanitized = re.sub(r"\s+", " ", sanitized).strip()
|
sanitized = re.sub(r"\s+", " ", sanitized).strip()
|
||||||
@@ -628,8 +681,8 @@ class TextChunker:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
soft_min_chars: int = 18,
|
soft_min_chars: int = TTS_CHUNK_SOFT_MIN_CHARS,
|
||||||
soft_min_words: int = 3,
|
soft_min_words: int = TTS_CHUNK_SOFT_MIN_WORDS,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._buffer = ""
|
self._buffer = ""
|
||||||
self._soft_min_chars = max(soft_min_chars, 1)
|
self._soft_min_chars = max(soft_min_chars, 1)
|
||||||
@@ -1008,6 +1061,14 @@ class CallSession:
|
|||||||
llm_started_monotonic = time.perf_counter()
|
llm_started_monotonic = time.perf_counter()
|
||||||
chunker = TextChunker()
|
chunker = TextChunker()
|
||||||
first_token_seen = False
|
first_token_seen = False
|
||||||
|
filler_trigger_task = asyncio.create_task(
|
||||||
|
self._trigger_filler_audio_after_delay(
|
||||||
|
epoch=epoch,
|
||||||
|
tool_name=None,
|
||||||
|
started_monotonic=llm_started_monotonic,
|
||||||
|
),
|
||||||
|
name=f"{self.session_id}-filler-delay-{epoch}",
|
||||||
|
)
|
||||||
async for event in self._llm.generate_stream(transcript, self._build_llm_context()):
|
async for event in self._llm.generate_stream(transcript, self._build_llm_context()):
|
||||||
self._ensure_generation(epoch)
|
self._ensure_generation(epoch)
|
||||||
if event.type == "tool_call_start":
|
if event.type == "tool_call_start":
|
||||||
@@ -1019,6 +1080,8 @@ class CallSession:
|
|||||||
event.tool_call_id,
|
event.tool_call_id,
|
||||||
filler_started,
|
filler_started,
|
||||||
)
|
)
|
||||||
|
if not filler_trigger_task.done():
|
||||||
|
filler_trigger_task.cancel()
|
||||||
if not filler_started:
|
if not filler_started:
|
||||||
filler_started = True
|
filler_started = True
|
||||||
self._start_filler_audio(
|
self._start_filler_audio(
|
||||||
@@ -1033,6 +1096,8 @@ class CallSession:
|
|||||||
continue
|
continue
|
||||||
if not first_token_seen:
|
if not first_token_seen:
|
||||||
first_token_seen = True
|
first_token_seen = True
|
||||||
|
if not filler_trigger_task.done():
|
||||||
|
filler_trigger_task.cancel()
|
||||||
self._log_latency(
|
self._log_latency(
|
||||||
"ttft",
|
"ttft",
|
||||||
llm_started_monotonic,
|
llm_started_monotonic,
|
||||||
@@ -1120,6 +1185,11 @@ class CallSession:
|
|||||||
if epoch == self.generation_epoch and not self._closed:
|
if epoch == self.generation_epoch and not self._closed:
|
||||||
self._set_state(SessionState.LISTENING, reason="assistant generation failed")
|
self._set_state(SessionState.LISTENING, reason="assistant generation failed")
|
||||||
finally:
|
finally:
|
||||||
|
filler_trigger_task = locals().get("filler_trigger_task")
|
||||||
|
if filler_trigger_task is not None and not filler_trigger_task.done():
|
||||||
|
filler_trigger_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await filler_trigger_task
|
||||||
if stt_stream is not None:
|
if stt_stream is not None:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await stt_stream.cancel()
|
await stt_stream.cancel()
|
||||||
@@ -1542,6 +1612,30 @@ class CallSession:
|
|||||||
with contextlib.suppress(asyncio.CancelledError):
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
await filler_task
|
await filler_task
|
||||||
|
|
||||||
|
async def _trigger_filler_audio_after_delay(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
epoch: int,
|
||||||
|
tool_name: str | None,
|
||||||
|
started_monotonic: float,
|
||||||
|
) -> None:
|
||||||
|
if FILLER_AUDIO_DELAY_MS <= 0:
|
||||||
|
self._start_filler_audio(
|
||||||
|
epoch=epoch,
|
||||||
|
tool_name=tool_name,
|
||||||
|
started_monotonic=started_monotonic,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await asyncio.sleep(FILLER_AUDIO_DELAY_MS / 1000.0)
|
||||||
|
self._ensure_generation(epoch)
|
||||||
|
if self._active_answer_audio_started:
|
||||||
|
return
|
||||||
|
self._start_filler_audio(
|
||||||
|
epoch=epoch,
|
||||||
|
tool_name=tool_name,
|
||||||
|
started_monotonic=started_monotonic,
|
||||||
|
)
|
||||||
|
|
||||||
def _start_filler_audio(
|
def _start_filler_audio(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
Reference in New Issue
Block a user