This commit is contained in:
Magzhan Zhumabayev
2026-05-01 17:33:48 +05:00
parent 22ec07f757
commit 1f07c710e1
3 changed files with 106 additions and 10 deletions
+7
View File
@@ -10,7 +10,14 @@ OPENAI_API_KEY=
OPENAI_BASE_URL=
OPENAI_LLM_MODEL=gpt-4o-mini
OPENAI_LLM_SYSTEM_PROMPT=Ты Айнур, лаконичный русскоязычный голосовой оператор контакт-центра DigiOps. Всегда отвечай на русском естественно, кратко и в одном спокойном стиле для голосового воспроизведения. Никогда не говори фразы ожидания вроде "минуту" или "секунду". Если нужна короткая фраза ожидания, голосовой сервис добавит её отдельно, поэтому отвечай сразу и не добавляй собственную фразу ожидания. Если клиент спрашивает, робот ты, ИИ или человек, отвечай ровно: "Да, я голосовой агент контакт-центра DigiOps." Не завершай диалог самостоятельно. Говори «до свидания» только если клиент явно попрощался или попросил завершить разговор. Простые «спасибо» или «благодарю» не считай просьбой завершить разговор. Телефонные номера читай по цифрам. Предпочитай понятные формулировки, удобные для ударения, и избегай неоднозначных по ударению слов. Не используй Markdown, URL или таблицы.
OPENAI_LLM_ENABLE_TOOLS=true
OPENAI_LLM_MAX_TOOL_ROUNDTRIPS=2
OPENAI_TIMEOUT_SECONDS=30
SERPER_API_KEY=
SERPER_API_BASE=https://google.serper.dev
SERPER_SEARCH_GL=kz
SERPER_SEARCH_HL=ru
SERPER_SEARCH_NUM=5
OPENAI_STT_MODEL=whisper-1
OPENAI_STT_LANGUAGE=ru
OPENAI_STT_TIMEOUT_SECONDS=30
+23 -5
View File
@@ -1140,7 +1140,7 @@ class CallSession:
actually_spoken_chunks: list[str] = []
assistant_audio_started = [False]
transcript = ""
filler_started = False
filler_started = asyncio.Event()
self._set_active_unanswered_turn(epoch=epoch, utterance_audio=utterance_audio)
try:
LOGGER.info(
@@ -1276,6 +1276,7 @@ class CallSession:
epoch=epoch,
tool_name=None,
started_monotonic=llm_started_monotonic,
filler_started=filler_started,
suppress=_is_terminal_farewell(transcript) or _is_farewell_transcript(transcript),
),
name=f"{self.session_id}-filler-delay-{epoch}",
@@ -1289,16 +1290,16 @@ class CallSession:
epoch,
event.name,
event.tool_call_id,
filler_started,
filler_started.is_set(),
)
if not filler_trigger_task.done():
filler_trigger_task.cancel()
if not filler_started:
filler_started = True
if not filler_started.is_set():
self._start_filler_audio(
epoch=epoch,
tool_name=event.name,
started_monotonic=llm_started_monotonic,
filler_started=filler_started,
)
continue
@@ -1837,6 +1838,7 @@ class CallSession:
epoch: int,
tool_name: str | None,
started_monotonic: float,
filler_started: asyncio.Event,
suppress: bool = False,
) -> None:
if suppress:
@@ -1846,16 +1848,18 @@ class CallSession:
epoch=epoch,
tool_name=tool_name,
started_monotonic=started_monotonic,
filler_started=filler_started,
)
return
await asyncio.sleep(FILLER_AUDIO_DELAY_MS / 1000.0)
self._ensure_generation(epoch)
if self._active_answer_audio_started:
if self._active_answer_audio_started or filler_started.is_set():
return
self._start_filler_audio(
epoch=epoch,
tool_name=tool_name,
started_monotonic=started_monotonic,
filler_started=filler_started,
)
def _start_filler_audio(
@@ -1864,9 +1868,19 @@ class CallSession:
epoch: int,
tool_name: str | None,
started_monotonic: float,
filler_started: asyncio.Event,
) -> None:
if filler_started.is_set():
LOGGER.info(
"realtime session %s filler audio skipped: already started for current turn epoch=%s tool=%s",
self.session_id,
epoch,
tool_name or "generic",
)
return
if self._filler_task is not None and not self._filler_task.done():
return
filler_started.set()
LOGGER.info(
"realtime session %s filler audio scheduled: epoch=%s tool=%s",
self.session_id,
@@ -1878,6 +1892,7 @@ class CallSession:
epoch=epoch,
tool_name=tool_name,
started_monotonic=started_monotonic,
filler_started=filler_started,
),
name=f"{self.session_id}-filler-{epoch}",
)
@@ -1888,9 +1903,11 @@ class CallSession:
epoch: int,
tool_name: str | None,
started_monotonic: float,
filler_started: asyncio.Event,
) -> None:
if self._filler_audio is None:
LOGGER.info("realtime session %s filler audio skipped: no library", self.session_id)
filler_started.clear()
return
filler_pcm = self._filler_audio.pick(tool_name)
if not filler_pcm:
@@ -1899,6 +1916,7 @@ class CallSession:
self.session_id,
tool_name or "generic",
)
filler_started.clear()
return
first_audio_seen = False
sent_chunks = 0
+76 -5
View File
@@ -5,6 +5,7 @@ import inspect
import json
import logging
import os
import re
import time
from collections.abc import AsyncGenerator
from typing import Any
@@ -23,6 +24,41 @@ _CONVERSATION_CLOSE_POLICY = (
"на нее отвечай коротко и оставляй диалог открытым."
)
_CONVERSATION_CLOSE_POLICY_MARKER = "ИИ-оператор не должен самостоятельно завершать разговор"
_WEB_SEARCH_POLICY = (
"Правило актуальной информации: если клиент спрашивает про погоду, новости, курсы валют, цены, расписания, "
"статус компаний, сайты, адреса, телефоны, события, сегодняшние или текущие факты, не отвечай по памяти. "
"Сначала используй Serper для поиска в интернете, затем дай короткий устный ответ на русском. "
"Если для поиска не хватает города, даты или объекта, задай короткий уточняющий вопрос."
)
_WEB_SEARCH_POLICY_MARKER = "если клиент спрашивает про погоду"
_LIVE_SEARCH_PATTERN = re.compile(
r"("
r"\bпогод\w*\b|"
r"\bауа\s*рай\w*\b|"
r"\bweather\b|"
r"\bновост\w*\b|"
r"\bкурс\w*\b|"
r"\bвалют\w*\b|"
r"\bдоллар\w*\b|"
r"\bевро\b|"
r"\bцен[ауы]\w*\b|"
r"\bсколько\s+стоит\b|"
r"\bрасписани\w*\b|"
r"\bсегодня\b|"
r"\bзавтра\b|"
r"\bсейчас\b|"
r"\bактуальн\w*\b|"
r"\bтекущ\w*\b|"
r"\bпоследн\w*\b|"
r"\bсвеж\w*\b|"
r"\bнайди\b|"
r"\bпоищи\b|"
r"\bинтернет\w*\b|"
r"\bсайт\w*\b"
r")",
flags=re.IGNORECASE,
)
def _with_conversation_close_policy(system_prompt: str) -> str:
@@ -34,6 +70,17 @@ def _with_conversation_close_policy(system_prompt: str) -> str:
return f"{normalized_prompt}\n\n{_CONVERSATION_CLOSE_POLICY}"
def _with_runtime_policies(system_prompt: str) -> str:
prompt = _with_conversation_close_policy(system_prompt)
if _WEB_SEARCH_POLICY_MARKER in prompt:
return prompt
return f"{prompt}\n\n{_WEB_SEARCH_POLICY}" if prompt else _WEB_SEARCH_POLICY
def _should_force_live_search(text: str) -> bool:
return bool(_LIVE_SEARCH_PATTERN.search(str(text or "")))
def _preview_text(text: str, *, limit: int = 160) -> str:
normalized = " ".join(str(text or "").split())
if len(normalized) <= limit:
@@ -107,7 +154,7 @@ class OpenAILLM(BaseLLM):
"Ты — дружелюбный, живой и эмпатичный голосовой ИИ-ассистент. Отвечай кратко, как в реальном диалоге. Используй разговорный стиль. Чтобы синтезатор речи (TTS) читал аббревиатуры и английские термины без акцента, пиши их русскими буквами так, как они произносятся (например, 'ай-ти' вместо 'IT', 'би-ту-би' вместо 'B2B', 'си-эр-эм' вместо 'CRM').",
)
)
self._system_prompt = _with_conversation_close_policy(str(raw_system_prompt).strip())
self._system_prompt = _with_runtime_policies(str(raw_system_prompt).strip())
self._timeout_seconds = max(float(timeout_seconds if timeout_seconds is not None else _timeout_seconds()), 1.0)
self._temperature = max(min(float(temperature), 2.0), 0.0)
self._reasoning_effort = str(
@@ -156,17 +203,25 @@ class OpenAILLM(BaseLLM):
raise RuntimeError("OPENAI_API_KEY is required for OpenAI LLM")
messages = self._build_messages(text, context)
force_live_search = _should_force_live_search(text)
turn_started_monotonic = time.perf_counter()
LOGGER.info(
"OpenAI LLM turn start: model=%s input_chars=%s context_entries=%s messages=%s "
"tools_available=%s input_preview=%r",
"tools_available=%s force_live_search=%s input_preview=%r",
self._model,
len(text),
len(context),
len(messages),
bool(self._build_tools()),
force_live_search,
_preview_text(text),
)
if force_live_search and not self._build_tools():
LOGGER.warning(
"OpenAI LLM live search requested but Serper tool is unavailable: tools_enabled=%s serper_configured=%s",
self._enable_tools,
bool(self._serper_api_key),
)
for round_index in range(self._max_tool_roundtrips + 1):
tool_buffers: dict[int, dict[str, str]] = {}
announced_tool_indexes: set[int] = set()
@@ -177,6 +232,7 @@ class OpenAILLM(BaseLLM):
messages=messages,
tool_buffers=tool_buffers,
announced_tool_indexes=announced_tool_indexes,
force_tool_name="serper" if round_index == 0 and force_live_search else None,
):
if event.type == "text":
content = str(event.content or "")
@@ -280,6 +336,7 @@ class OpenAILLM(BaseLLM):
tool_buffers: dict[int, dict[str, str]],
announced_tool_indexes: set[int],
enable_tools: bool = True,
force_tool_name: str | None = None,
) -> AsyncGenerator[LLMStreamEvent, None]:
client = self._get_client()
request_kwargs: dict[str, Any] = {
@@ -297,12 +354,25 @@ class OpenAILLM(BaseLLM):
request_kwargs["reasoning_effort"] = reasoning_effort
if tools:
request_kwargs["tools"] = tools
request_kwargs["tool_choice"] = "auto"
tool_names = {
str((tool.get("function") or {}).get("name") or "").strip()
for tool in tools
if isinstance(tool, dict)
}
if force_tool_name and force_tool_name in tool_names:
request_kwargs["tool_choice"] = {
"type": "function",
"function": {"name": force_tool_name},
}
else:
request_kwargs["tool_choice"] = "auto"
LOGGER.info(
"OpenAI LLM stream request: model=%s messages=%s tools=%s temperature=%s reasoning_effort=%s enable_tools=%s",
"OpenAI LLM stream request: model=%s messages=%s tools=%s tool_choice=%s temperature=%s "
"reasoning_effort=%s enable_tools=%s",
self._model,
len(messages),
len(tools or []),
request_kwargs.get("tool_choice", "none"),
self._temperature if self._supports_custom_temperature() else "default",
reasoning_effort or "default",
enable_tools,
@@ -503,7 +573,8 @@ class OpenAILLM(BaseLLM):
"name": "serper",
"description": (
"Search the public web for recent or external information when the user asks "
"about current facts, websites, company data, schedules, or anything requiring live search."
"about weather, current facts, news, prices, currency rates, websites, company data, "
"schedules, addresses, phone numbers, or anything requiring live search."
),
"parameters": {
"type": "object",