.
This commit is contained in:
@@ -10,7 +10,14 @@ 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=Ты Айнур, лаконичный русскоязычный голосовой оператор контакт-центра DigiOps. Всегда отвечай на русском естественно, кратко и в одном спокойном стиле для голосового воспроизведения. Никогда не говори фразы ожидания вроде "минуту" или "секунду". Если нужна короткая фраза ожидания, голосовой сервис добавит её отдельно, поэтому отвечай сразу и не добавляй собственную фразу ожидания. Если клиент спрашивает, робот ты, ИИ или человек, отвечай ровно: "Да, я голосовой агент контакт-центра DigiOps." Не завершай диалог самостоятельно. Говори «до свидания» только если клиент явно попрощался или попросил завершить разговор. Простые «спасибо» или «благодарю» не считай просьбой завершить разговор. Телефонные номера читай по цифрам. Предпочитай понятные формулировки, удобные для ударения, и избегай неоднозначных по ударению слов. Не используй Markdown, URL или таблицы.
|
OPENAI_LLM_SYSTEM_PROMPT=Ты Айнур, лаконичный русскоязычный голосовой оператор контакт-центра DigiOps. Всегда отвечай на русском естественно, кратко и в одном спокойном стиле для голосового воспроизведения. Никогда не говори фразы ожидания вроде "минуту" или "секунду". Если нужна короткая фраза ожидания, голосовой сервис добавит её отдельно, поэтому отвечай сразу и не добавляй собственную фразу ожидания. Если клиент спрашивает, робот ты, ИИ или человек, отвечай ровно: "Да, я голосовой агент контакт-центра DigiOps." Не завершай диалог самостоятельно. Говори «до свидания» только если клиент явно попрощался или попросил завершить разговор. Простые «спасибо» или «благодарю» не считай просьбой завершить разговор. Телефонные номера читай по цифрам. Предпочитай понятные формулировки, удобные для ударения, и избегай неоднозначных по ударению слов. Не используй Markdown, URL или таблицы.
|
||||||
|
OPENAI_LLM_ENABLE_TOOLS=true
|
||||||
|
OPENAI_LLM_MAX_TOOL_ROUNDTRIPS=2
|
||||||
OPENAI_TIMEOUT_SECONDS=30
|
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_MODEL=whisper-1
|
||||||
OPENAI_STT_LANGUAGE=ru
|
OPENAI_STT_LANGUAGE=ru
|
||||||
OPENAI_STT_TIMEOUT_SECONDS=30
|
OPENAI_STT_TIMEOUT_SECONDS=30
|
||||||
|
|||||||
+23
-5
@@ -1140,7 +1140,7 @@ class CallSession:
|
|||||||
actually_spoken_chunks: list[str] = []
|
actually_spoken_chunks: list[str] = []
|
||||||
assistant_audio_started = [False]
|
assistant_audio_started = [False]
|
||||||
transcript = ""
|
transcript = ""
|
||||||
filler_started = False
|
filler_started = asyncio.Event()
|
||||||
self._set_active_unanswered_turn(epoch=epoch, utterance_audio=utterance_audio)
|
self._set_active_unanswered_turn(epoch=epoch, utterance_audio=utterance_audio)
|
||||||
try:
|
try:
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
@@ -1276,6 +1276,7 @@ class CallSession:
|
|||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
tool_name=None,
|
tool_name=None,
|
||||||
started_monotonic=llm_started_monotonic,
|
started_monotonic=llm_started_monotonic,
|
||||||
|
filler_started=filler_started,
|
||||||
suppress=_is_terminal_farewell(transcript) or _is_farewell_transcript(transcript),
|
suppress=_is_terminal_farewell(transcript) or _is_farewell_transcript(transcript),
|
||||||
),
|
),
|
||||||
name=f"{self.session_id}-filler-delay-{epoch}",
|
name=f"{self.session_id}-filler-delay-{epoch}",
|
||||||
@@ -1289,16 +1290,16 @@ class CallSession:
|
|||||||
epoch,
|
epoch,
|
||||||
event.name,
|
event.name,
|
||||||
event.tool_call_id,
|
event.tool_call_id,
|
||||||
filler_started,
|
filler_started.is_set(),
|
||||||
)
|
)
|
||||||
if not filler_trigger_task.done():
|
if not filler_trigger_task.done():
|
||||||
filler_trigger_task.cancel()
|
filler_trigger_task.cancel()
|
||||||
if not filler_started:
|
if not filler_started.is_set():
|
||||||
filler_started = True
|
|
||||||
self._start_filler_audio(
|
self._start_filler_audio(
|
||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
tool_name=event.name,
|
tool_name=event.name,
|
||||||
started_monotonic=llm_started_monotonic,
|
started_monotonic=llm_started_monotonic,
|
||||||
|
filler_started=filler_started,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1837,6 +1838,7 @@ class CallSession:
|
|||||||
epoch: int,
|
epoch: int,
|
||||||
tool_name: str | None,
|
tool_name: str | None,
|
||||||
started_monotonic: float,
|
started_monotonic: float,
|
||||||
|
filler_started: asyncio.Event,
|
||||||
suppress: bool = False,
|
suppress: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
if suppress:
|
if suppress:
|
||||||
@@ -1846,16 +1848,18 @@ class CallSession:
|
|||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
started_monotonic=started_monotonic,
|
started_monotonic=started_monotonic,
|
||||||
|
filler_started=filler_started,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
await asyncio.sleep(FILLER_AUDIO_DELAY_MS / 1000.0)
|
await asyncio.sleep(FILLER_AUDIO_DELAY_MS / 1000.0)
|
||||||
self._ensure_generation(epoch)
|
self._ensure_generation(epoch)
|
||||||
if self._active_answer_audio_started:
|
if self._active_answer_audio_started or filler_started.is_set():
|
||||||
return
|
return
|
||||||
self._start_filler_audio(
|
self._start_filler_audio(
|
||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
started_monotonic=started_monotonic,
|
started_monotonic=started_monotonic,
|
||||||
|
filler_started=filler_started,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _start_filler_audio(
|
def _start_filler_audio(
|
||||||
@@ -1864,9 +1868,19 @@ class CallSession:
|
|||||||
epoch: int,
|
epoch: int,
|
||||||
tool_name: str | None,
|
tool_name: str | None,
|
||||||
started_monotonic: float,
|
started_monotonic: float,
|
||||||
|
filler_started: asyncio.Event,
|
||||||
) -> None:
|
) -> 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():
|
if self._filler_task is not None and not self._filler_task.done():
|
||||||
return
|
return
|
||||||
|
filler_started.set()
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"realtime session %s filler audio scheduled: epoch=%s tool=%s",
|
"realtime session %s filler audio scheduled: epoch=%s tool=%s",
|
||||||
self.session_id,
|
self.session_id,
|
||||||
@@ -1878,6 +1892,7 @@ class CallSession:
|
|||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
started_monotonic=started_monotonic,
|
started_monotonic=started_monotonic,
|
||||||
|
filler_started=filler_started,
|
||||||
),
|
),
|
||||||
name=f"{self.session_id}-filler-{epoch}",
|
name=f"{self.session_id}-filler-{epoch}",
|
||||||
)
|
)
|
||||||
@@ -1888,9 +1903,11 @@ class CallSession:
|
|||||||
epoch: int,
|
epoch: int,
|
||||||
tool_name: str | None,
|
tool_name: str | None,
|
||||||
started_monotonic: float,
|
started_monotonic: float,
|
||||||
|
filler_started: asyncio.Event,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self._filler_audio is None:
|
if self._filler_audio is None:
|
||||||
LOGGER.info("realtime session %s filler audio skipped: no library", self.session_id)
|
LOGGER.info("realtime session %s filler audio skipped: no library", self.session_id)
|
||||||
|
filler_started.clear()
|
||||||
return
|
return
|
||||||
filler_pcm = self._filler_audio.pick(tool_name)
|
filler_pcm = self._filler_audio.pick(tool_name)
|
||||||
if not filler_pcm:
|
if not filler_pcm:
|
||||||
@@ -1899,6 +1916,7 @@ class CallSession:
|
|||||||
self.session_id,
|
self.session_id,
|
||||||
tool_name or "generic",
|
tool_name or "generic",
|
||||||
)
|
)
|
||||||
|
filler_started.clear()
|
||||||
return
|
return
|
||||||
first_audio_seen = False
|
first_audio_seen = False
|
||||||
sent_chunks = 0
|
sent_chunks = 0
|
||||||
|
|||||||
+76
-5
@@ -5,6 +5,7 @@ import inspect
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -23,6 +24,41 @@ _CONVERSATION_CLOSE_POLICY = (
|
|||||||
"на нее отвечай коротко и оставляй диалог открытым."
|
"на нее отвечай коротко и оставляй диалог открытым."
|
||||||
)
|
)
|
||||||
_CONVERSATION_CLOSE_POLICY_MARKER = "ИИ-оператор не должен самостоятельно завершать разговор"
|
_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:
|
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}"
|
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:
|
def _preview_text(text: str, *, limit: int = 160) -> str:
|
||||||
normalized = " ".join(str(text or "").split())
|
normalized = " ".join(str(text or "").split())
|
||||||
if len(normalized) <= limit:
|
if len(normalized) <= limit:
|
||||||
@@ -107,7 +154,7 @@ class OpenAILLM(BaseLLM):
|
|||||||
"Ты — дружелюбный, живой и эмпатичный голосовой ИИ-ассистент. Отвечай кратко, как в реальном диалоге. Используй разговорный стиль. Чтобы синтезатор речи (TTS) читал аббревиатуры и английские термины без акцента, пиши их русскими буквами так, как они произносятся (например, 'ай-ти' вместо 'IT', 'би-ту-би' вместо 'B2B', 'си-эр-эм' вместо 'CRM').",
|
"Ты — дружелюбный, живой и эмпатичный голосовой ИИ-ассистент. Отвечай кратко, как в реальном диалоге. Используй разговорный стиль. Чтобы синтезатор речи (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._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._temperature = max(min(float(temperature), 2.0), 0.0)
|
||||||
self._reasoning_effort = str(
|
self._reasoning_effort = str(
|
||||||
@@ -156,17 +203,25 @@ class OpenAILLM(BaseLLM):
|
|||||||
raise RuntimeError("OPENAI_API_KEY is required for OpenAI LLM")
|
raise RuntimeError("OPENAI_API_KEY is required for OpenAI LLM")
|
||||||
|
|
||||||
messages = self._build_messages(text, context)
|
messages = self._build_messages(text, context)
|
||||||
|
force_live_search = _should_force_live_search(text)
|
||||||
turn_started_monotonic = time.perf_counter()
|
turn_started_monotonic = time.perf_counter()
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"OpenAI LLM turn start: model=%s input_chars=%s context_entries=%s messages=%s "
|
"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,
|
self._model,
|
||||||
len(text),
|
len(text),
|
||||||
len(context),
|
len(context),
|
||||||
len(messages),
|
len(messages),
|
||||||
bool(self._build_tools()),
|
bool(self._build_tools()),
|
||||||
|
force_live_search,
|
||||||
_preview_text(text),
|
_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):
|
for round_index in range(self._max_tool_roundtrips + 1):
|
||||||
tool_buffers: dict[int, dict[str, str]] = {}
|
tool_buffers: dict[int, dict[str, str]] = {}
|
||||||
announced_tool_indexes: set[int] = set()
|
announced_tool_indexes: set[int] = set()
|
||||||
@@ -177,6 +232,7 @@ class OpenAILLM(BaseLLM):
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
tool_buffers=tool_buffers,
|
tool_buffers=tool_buffers,
|
||||||
announced_tool_indexes=announced_tool_indexes,
|
announced_tool_indexes=announced_tool_indexes,
|
||||||
|
force_tool_name="serper" if round_index == 0 and force_live_search else None,
|
||||||
):
|
):
|
||||||
if event.type == "text":
|
if event.type == "text":
|
||||||
content = str(event.content or "")
|
content = str(event.content or "")
|
||||||
@@ -280,6 +336,7 @@ class OpenAILLM(BaseLLM):
|
|||||||
tool_buffers: dict[int, dict[str, str]],
|
tool_buffers: dict[int, dict[str, str]],
|
||||||
announced_tool_indexes: set[int],
|
announced_tool_indexes: set[int],
|
||||||
enable_tools: bool = True,
|
enable_tools: bool = True,
|
||||||
|
force_tool_name: str | None = None,
|
||||||
) -> AsyncGenerator[LLMStreamEvent, None]:
|
) -> AsyncGenerator[LLMStreamEvent, None]:
|
||||||
client = self._get_client()
|
client = self._get_client()
|
||||||
request_kwargs: dict[str, Any] = {
|
request_kwargs: dict[str, Any] = {
|
||||||
@@ -297,12 +354,25 @@ class OpenAILLM(BaseLLM):
|
|||||||
request_kwargs["reasoning_effort"] = reasoning_effort
|
request_kwargs["reasoning_effort"] = reasoning_effort
|
||||||
if tools:
|
if tools:
|
||||||
request_kwargs["tools"] = 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(
|
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,
|
self._model,
|
||||||
len(messages),
|
len(messages),
|
||||||
len(tools or []),
|
len(tools or []),
|
||||||
|
request_kwargs.get("tool_choice", "none"),
|
||||||
self._temperature if self._supports_custom_temperature() else "default",
|
self._temperature if self._supports_custom_temperature() else "default",
|
||||||
reasoning_effort or "default",
|
reasoning_effort or "default",
|
||||||
enable_tools,
|
enable_tools,
|
||||||
@@ -503,7 +573,8 @@ class OpenAILLM(BaseLLM):
|
|||||||
"name": "serper",
|
"name": "serper",
|
||||||
"description": (
|
"description": (
|
||||||
"Search the public web for recent or external information when the user asks "
|
"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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
Reference in New Issue
Block a user