.
This commit is contained in:
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from realtime_voice_service.crm_client import _issue_service_token
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled() -> bool:
|
||||||
|
return os.getenv("CALLBACK_DISPATCH_ENABLED", "1").strip().lower() in {"1", "true", "yes"}
|
||||||
|
|
||||||
|
|
||||||
|
def _bridge_base_url() -> str:
|
||||||
|
return str(
|
||||||
|
os.getenv("ASTERISK_BRIDGE_SERVICE_URL", "http://asterisk-bridge-service:8000")
|
||||||
|
).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _secret() -> str:
|
||||||
|
return str(os.getenv("CRM_APP_TOKEN_SECRET", "")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _request_headers() -> dict[str, str] | None:
|
||||||
|
secret = _secret()
|
||||||
|
if not secret:
|
||||||
|
return None
|
||||||
|
return {"Authorization": f"Bearer {_issue_service_token(secret)}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def schedule_callback(
|
||||||
|
*,
|
||||||
|
phone: str,
|
||||||
|
in_minutes: int | None = None,
|
||||||
|
scheduled_for: str | None = None,
|
||||||
|
reason: str | None = None,
|
||||||
|
customer_name: str | None = None,
|
||||||
|
source_call_id: str | None = None,
|
||||||
|
source_interaction_id: str | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not _enabled():
|
||||||
|
LOGGER.info("callback dispatch disabled by env")
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = _request_headers()
|
||||||
|
if headers is None:
|
||||||
|
LOGGER.warning("callback client: CRM_APP_TOKEN_SECRET is not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {"phone": phone}
|
||||||
|
if in_minutes is not None:
|
||||||
|
payload["in_minutes"] = int(in_minutes)
|
||||||
|
if scheduled_for:
|
||||||
|
payload["scheduled_for"] = scheduled_for
|
||||||
|
if reason:
|
||||||
|
payload["reason"] = reason
|
||||||
|
if customer_name:
|
||||||
|
payload["customer_name"] = customer_name
|
||||||
|
if source_call_id:
|
||||||
|
payload["source_call_id"] = source_call_id
|
||||||
|
if source_interaction_id:
|
||||||
|
payload["source_interaction_id"] = source_interaction_id
|
||||||
|
if idempotency_key:
|
||||||
|
payload["idempotency_key"] = idempotency_key
|
||||||
|
|
||||||
|
url = f"{_bridge_base_url()}/asterisk/callbacks"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
|
resp = await client.post(url, json=payload, headers=headers)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
data = resp.json()
|
||||||
|
LOGGER.info(
|
||||||
|
"callback scheduled: job_id=%s phone=%s scheduled_for=%s",
|
||||||
|
data.get("job_id"),
|
||||||
|
data.get("phone_e164"),
|
||||||
|
data.get("scheduled_for"),
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
if resp.status_code == 409:
|
||||||
|
LOGGER.info("callback already scheduled for phone=%s: %s", phone, resp.text[:200])
|
||||||
|
return {"status": "conflict", "detail": resp.text}
|
||||||
|
LOGGER.warning(
|
||||||
|
"callback schedule failed: status=%s body=%s",
|
||||||
|
resp.status_code,
|
||||||
|
resp.text[:300],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
LOGGER.exception("callback client: request failed phone=%s", phone)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_callback(*, job_id: str) -> dict[str, Any] | None:
|
||||||
|
if not _enabled():
|
||||||
|
return None
|
||||||
|
headers = _request_headers()
|
||||||
|
if headers is None:
|
||||||
|
return None
|
||||||
|
url = f"{_bridge_base_url()}/asterisk/callbacks/{job_id}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
|
resp = await client.delete(url, headers=headers)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
LOGGER.warning(
|
||||||
|
"callback cancel failed: job_id=%s status=%s body=%s",
|
||||||
|
job_id,
|
||||||
|
resp.status_code,
|
||||||
|
resp.text[:300],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
LOGGER.exception("callback client: cancel failed job_id=%s", job_id)
|
||||||
|
return None
|
||||||
+79
-4
@@ -245,6 +245,54 @@ def _preview_text(text: str, *, limit: int = 160) -> str:
|
|||||||
return f"{normalized[:limit]}..."
|
return f"{normalized[:limit]}..."
|
||||||
|
|
||||||
|
|
||||||
|
_KAZAKH_SPECIFIC_LETTERS = set("әғқңөұүһі")
|
||||||
|
_RUSSIAN_SPECIFIC_LETTERS = set("ыэъё")
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_session_language(text: str) -> str | None:
|
||||||
|
normalized = str(text or "").strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
has_kazakh = any(ch in _KAZAKH_SPECIFIC_LETTERS for ch in normalized)
|
||||||
|
if has_kazakh:
|
||||||
|
return "kk"
|
||||||
|
has_russian = any(ch in _RUSSIAN_SPECIFIC_LETTERS for ch in normalized)
|
||||||
|
if has_russian:
|
||||||
|
return "ru"
|
||||||
|
cyrillic_chars = sum(1 for ch in normalized if "а" <= ch <= "я" or ch == "ё")
|
||||||
|
if cyrillic_chars >= 2:
|
||||||
|
return "ru"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_TTS_LANGUAGE_CODE_MAP = {
|
||||||
|
"ru": "ru",
|
||||||
|
"kk": "kk",
|
||||||
|
}
|
||||||
|
|
||||||
|
_STT_YANDEX_LANGUAGE_MAP = {
|
||||||
|
"ru": "ru-RU",
|
||||||
|
"kk": "kk-KZ",
|
||||||
|
}
|
||||||
|
|
||||||
|
_STT_ELEVENLABS_LANGUAGE_MAP = {
|
||||||
|
"ru": "rus",
|
||||||
|
"kk": "kaz",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tts_language_for(session_language: str | None) -> str | None:
|
||||||
|
if not session_language:
|
||||||
|
return None
|
||||||
|
return _TTS_LANGUAGE_CODE_MAP.get(session_language.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _stt_language_for(session_language: str | None) -> str | None:
|
||||||
|
if not session_language:
|
||||||
|
return None
|
||||||
|
return session_language.lower() if session_language.lower() in {"ru", "kk"} else None
|
||||||
|
|
||||||
|
|
||||||
def _audio_duration_ms(audio_bytes: bytes, *, sample_rate_hz: int) -> int:
|
def _audio_duration_ms(audio_bytes: bytes, *, sample_rate_hz: int) -> int:
|
||||||
if not audio_bytes or sample_rate_hz <= 0:
|
if not audio_bytes or sample_rate_hz <= 0:
|
||||||
return 0
|
return 0
|
||||||
@@ -993,6 +1041,7 @@ class CallSession:
|
|||||||
self._personalized_greeting_template = DEFAULT_PERSONALIZED_GREETING_TEMPLATE
|
self._personalized_greeting_template = DEFAULT_PERSONALIZED_GREETING_TEMPLATE
|
||||||
self._live_stt_stream: BaseSTTStream | None = None
|
self._live_stt_stream: BaseSTTStream | None = None
|
||||||
self._latest_partial_transcript = ""
|
self._latest_partial_transcript = ""
|
||||||
|
self._session_language: str | None = None
|
||||||
self._default_vad_silence_timeout_ms = getattr(self._vad, "default_speech_end_silence_ms", 550)
|
self._default_vad_silence_timeout_ms = getattr(self._vad, "default_speech_end_silence_ms", 550)
|
||||||
self._semantic_hold_silence_timeout_ms = max(self._default_vad_silence_timeout_ms, SEMANTIC_ENDPOINTING_HOLD_MS)
|
self._semantic_hold_silence_timeout_ms = max(self._default_vad_silence_timeout_ms, SEMANTIC_ENDPOINTING_HOLD_MS)
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
@@ -1235,6 +1284,18 @@ class CallSession:
|
|||||||
)
|
)
|
||||||
self._active_user_transcript = transcript
|
self._active_user_transcript = transcript
|
||||||
|
|
||||||
|
if self._session_language is None:
|
||||||
|
detected_language = _detect_session_language(transcript)
|
||||||
|
if detected_language:
|
||||||
|
self._session_language = detected_language
|
||||||
|
LOGGER.info(
|
||||||
|
"realtime session %s language locked: epoch=%s language=%s transcript=%r",
|
||||||
|
self.session_id,
|
||||||
|
epoch,
|
||||||
|
detected_language,
|
||||||
|
_preview_text(transcript),
|
||||||
|
)
|
||||||
|
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"realtime session %s transcript accepted: epoch=%s chars=%s text=%r",
|
"realtime session %s transcript accepted: epoch=%s chars=%s text=%r",
|
||||||
self.session_id,
|
self.session_id,
|
||||||
@@ -1334,7 +1395,11 @@ class CallSession:
|
|||||||
),
|
),
|
||||||
name=f"{self.session_id}-filler-delay-{epoch}",
|
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(),
|
||||||
|
language_code=self._session_language,
|
||||||
|
):
|
||||||
self._ensure_generation(epoch)
|
self._ensure_generation(epoch)
|
||||||
if event.type == "tool_call_start":
|
if event.type == "tool_call_start":
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
@@ -1546,7 +1611,11 @@ class CallSession:
|
|||||||
await self._stt.transcribe(
|
await self._stt.transcribe(
|
||||||
audio_bytes,
|
audio_bytes,
|
||||||
keyterms=self._name_capture_keyterms() if self._awaiting_customer_name else None,
|
keyterms=self._name_capture_keyterms() if self._awaiting_customer_name else None,
|
||||||
language_code=self._name_capture_language_code() if self._awaiting_customer_name else None,
|
language_code=(
|
||||||
|
self._name_capture_language_code()
|
||||||
|
if self._awaiting_customer_name
|
||||||
|
else _stt_language_for(self._session_language)
|
||||||
|
),
|
||||||
force_batch=self._awaiting_customer_name,
|
force_batch=self._awaiting_customer_name,
|
||||||
)
|
)
|
||||||
).strip()
|
).strip()
|
||||||
@@ -1825,7 +1894,8 @@ class CallSession:
|
|||||||
sentence_queue=sentence_queue,
|
sentence_queue=sentence_queue,
|
||||||
started_holder=tts_started_monotonic,
|
started_holder=tts_started_monotonic,
|
||||||
actually_spoken_chunks=actually_spoken_chunks,
|
actually_spoken_chunks=actually_spoken_chunks,
|
||||||
)
|
),
|
||||||
|
language_code=_tts_language_for(self._session_language),
|
||||||
):
|
):
|
||||||
self._ensure_generation(epoch)
|
self._ensure_generation(epoch)
|
||||||
if not audio_chunk:
|
if not audio_chunk:
|
||||||
@@ -2061,6 +2131,7 @@ class CallSession:
|
|||||||
live_stt_stream = await self._stt.start_stream(
|
live_stt_stream = await self._stt.start_stream(
|
||||||
partial_callback=self._handle_partial_transcript,
|
partial_callback=self._handle_partial_transcript,
|
||||||
keyterms=None,
|
keyterms=None,
|
||||||
|
language_code=_stt_language_for(self._session_language),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOGGER.exception("realtime session %s failed to start live STT stream", self.session_id)
|
LOGGER.exception("realtime session %s failed to start live STT stream", self.session_id)
|
||||||
@@ -2137,7 +2208,11 @@ class CallSession:
|
|||||||
return await self._stt.transcribe(
|
return await self._stt.transcribe(
|
||||||
utterance_audio,
|
utterance_audio,
|
||||||
keyterms=self._name_capture_keyterms() if self._awaiting_customer_name else None,
|
keyterms=self._name_capture_keyterms() if self._awaiting_customer_name else None,
|
||||||
language_code=self._name_capture_language_code() if self._awaiting_customer_name else None,
|
language_code=(
|
||||||
|
self._name_capture_language_code()
|
||||||
|
if self._awaiting_customer_name
|
||||||
|
else _stt_language_for(self._session_language)
|
||||||
|
),
|
||||||
force_batch=self._awaiting_customer_name,
|
force_batch=self._awaiting_customer_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+32
-7
@@ -47,20 +47,32 @@ class BaseSTT(ABC):
|
|||||||
*,
|
*,
|
||||||
partial_callback: PartialTranscriptCallback | None = None,
|
partial_callback: PartialTranscriptCallback | None = None,
|
||||||
keyterms: Sequence[str] | None = None,
|
keyterms: Sequence[str] | None = None,
|
||||||
|
language_code: str | None = None,
|
||||||
) -> BaseSTTStream | None:
|
) -> BaseSTTStream | None:
|
||||||
del partial_callback, keyterms
|
del partial_callback, keyterms, language_code
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class BaseLLM(ABC):
|
class BaseLLM(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def generate_stream(self, text: str, context: list) -> AsyncGenerator[LLMStreamEvent, None]:
|
async def generate_stream(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
context: list,
|
||||||
|
*,
|
||||||
|
language_code: str | None = None,
|
||||||
|
) -> AsyncGenerator[LLMStreamEvent, None]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class BaseTTS(ABC):
|
class BaseTTS(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def synthesize_stream(self, text_stream: AsyncIterable[str]) -> AsyncGenerator[bytes, None]:
|
async def synthesize_stream(
|
||||||
|
self,
|
||||||
|
text_stream: AsyncIterable[str],
|
||||||
|
*,
|
||||||
|
language_code: str | None = None,
|
||||||
|
) -> AsyncGenerator[bytes, None]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
@@ -106,8 +118,9 @@ class MockSTT(BaseSTT):
|
|||||||
*,
|
*,
|
||||||
partial_callback: PartialTranscriptCallback | None = None,
|
partial_callback: PartialTranscriptCallback | None = None,
|
||||||
keyterms: Sequence[str] | None = None,
|
keyterms: Sequence[str] | None = None,
|
||||||
|
language_code: str | None = None,
|
||||||
) -> BaseSTTStream | None:
|
) -> BaseSTTStream | None:
|
||||||
del keyterms
|
del keyterms, language_code
|
||||||
return _MockSTTStream(parent=self, partial_callback=partial_callback)
|
return _MockSTTStream(parent=self, partial_callback=partial_callback)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,8 +134,14 @@ class MockLLM(BaseLLM):
|
|||||||
self._token_delay_ms = max(token_delay_ms, 0)
|
self._token_delay_ms = max(token_delay_ms, 0)
|
||||||
self._scripted_responses = list(scripted_responses or [])
|
self._scripted_responses = list(scripted_responses or [])
|
||||||
|
|
||||||
async def generate_stream(self, text: str, context: list) -> AsyncGenerator[LLMStreamEvent, None]:
|
async def generate_stream(
|
||||||
del context
|
self,
|
||||||
|
text: str,
|
||||||
|
context: list,
|
||||||
|
*,
|
||||||
|
language_code: str | None = None,
|
||||||
|
) -> AsyncGenerator[LLMStreamEvent, None]:
|
||||||
|
del context, language_code
|
||||||
response_text = (
|
response_text = (
|
||||||
self._scripted_responses.pop(0)
|
self._scripted_responses.pop(0)
|
||||||
if self._scripted_responses
|
if self._scripted_responses
|
||||||
@@ -152,7 +171,13 @@ class MockTTS(BaseTTS):
|
|||||||
self._amplitude = amplitude
|
self._amplitude = amplitude
|
||||||
self._milliseconds_per_word = max(milliseconds_per_word, 40)
|
self._milliseconds_per_word = max(milliseconds_per_word, 40)
|
||||||
|
|
||||||
async def synthesize_stream(self, text_stream: AsyncIterable[str]) -> AsyncGenerator[bytes, None]:
|
async def synthesize_stream(
|
||||||
|
self,
|
||||||
|
text_stream: AsyncIterable[str],
|
||||||
|
*,
|
||||||
|
language_code: str | None = None,
|
||||||
|
) -> AsyncGenerator[bytes, None]:
|
||||||
|
del language_code
|
||||||
async for text in text_stream:
|
async for text in text_stream:
|
||||||
normalized = text.strip()
|
normalized = text.strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
|
|||||||
+226
-45
@@ -31,6 +31,45 @@ _WEB_SEARCH_POLICY = (
|
|||||||
"Если для поиска не хватает города, даты или объекта, задай короткий уточняющий вопрос."
|
"Если для поиска не хватает города, даты или объекта, задай короткий уточняющий вопрос."
|
||||||
)
|
)
|
||||||
_WEB_SEARCH_POLICY_MARKER = "если клиент спрашивает про погоду"
|
_WEB_SEARCH_POLICY_MARKER = "если клиент спрашивает про погоду"
|
||||||
|
_CALLBACK_POLICY = (
|
||||||
|
"Правило перезвона: если клиент просит перезвонить позже (через N минут, в определённое время или просит "
|
||||||
|
"связаться с ним позже), вызови инструмент schedule_callback с номером телефона клиента и временем. "
|
||||||
|
"Если номер не назван — попроси клиента продиктовать его. После успешного вызова инструмента подтверди "
|
||||||
|
"клиенту коротко на русском, в какое время мы перезвоним. Не вызывай инструмент несколько раз подряд "
|
||||||
|
"и не обещай перезвон до того, как инструмент вернул успех."
|
||||||
|
)
|
||||||
|
_CALLBACK_POLICY_MARKER = "если клиент просит перезвонить позже"
|
||||||
|
_LANGUAGE_DIRECTIVES = {
|
||||||
|
"ru": (
|
||||||
|
"Язык диалога: русский. Отвечай только на русском языке, естественно и кратко, в одном спокойном "
|
||||||
|
"стиле для голоса. Не переключайся на другие языки, даже если в запросе клиента есть отдельные "
|
||||||
|
"слова на другом языке."
|
||||||
|
),
|
||||||
|
"kk": (
|
||||||
|
"Сөйлесу тілі: қазақ тілі. Тек қазақ тілінде, табиғи және қысқа жауап бер, дауыс үшін бір қалыпты "
|
||||||
|
"стильде. Клиенттің сөзінде бөтен тілден бөлек сөздер болса да, басқа тілге ауыспа."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_language_code(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
raw = str(value).strip().lower().replace("_", "-")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if raw in {"ru", "rus", "ru-ru"}:
|
||||||
|
return "ru"
|
||||||
|
if raw in {"kk", "kaz", "kz", "kk-kz", "kz-kz"}:
|
||||||
|
return "kk"
|
||||||
|
return raw[:2] or None
|
||||||
|
|
||||||
|
|
||||||
|
def _language_directive(language_code: str | None) -> str | None:
|
||||||
|
normalized = _normalize_language_code(language_code)
|
||||||
|
if normalized is None:
|
||||||
|
return None
|
||||||
|
return _LANGUAGE_DIRECTIVES.get(normalized)
|
||||||
|
|
||||||
_LIVE_SEARCH_PATTERN = re.compile(
|
_LIVE_SEARCH_PATTERN = re.compile(
|
||||||
r"("
|
r"("
|
||||||
@@ -72,9 +111,11 @@ def _with_conversation_close_policy(system_prompt: str) -> str:
|
|||||||
|
|
||||||
def _with_runtime_policies(system_prompt: str) -> str:
|
def _with_runtime_policies(system_prompt: str) -> str:
|
||||||
prompt = _with_conversation_close_policy(system_prompt)
|
prompt = _with_conversation_close_policy(system_prompt)
|
||||||
if _WEB_SEARCH_POLICY_MARKER in prompt:
|
if _WEB_SEARCH_POLICY_MARKER not in prompt:
|
||||||
return prompt
|
prompt = f"{prompt}\n\n{_WEB_SEARCH_POLICY}" if prompt else _WEB_SEARCH_POLICY
|
||||||
return f"{prompt}\n\n{_WEB_SEARCH_POLICY}" if prompt else _WEB_SEARCH_POLICY
|
if _CALLBACK_POLICY_MARKER not in prompt:
|
||||||
|
prompt = f"{prompt}\n\n{_CALLBACK_POLICY}" if prompt else _CALLBACK_POLICY
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
def _should_force_live_search(text: str) -> bool:
|
def _should_force_live_search(text: str) -> bool:
|
||||||
@@ -198,11 +239,17 @@ class OpenAILLM(BaseLLM):
|
|||||||
self._max_tool_roundtrips,
|
self._max_tool_roundtrips,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def generate_stream(self, text: str, context: list) -> AsyncGenerator[LLMStreamEvent, None]:
|
async def generate_stream(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
context: list,
|
||||||
|
*,
|
||||||
|
language_code: str | None = None,
|
||||||
|
) -> AsyncGenerator[LLMStreamEvent, None]:
|
||||||
if not self._api_key:
|
if not self._api_key:
|
||||||
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, language_code=language_code)
|
||||||
force_live_search = _should_force_live_search(text)
|
force_live_search = _should_force_live_search(text)
|
||||||
turn_started_monotonic = time.perf_counter()
|
turn_started_monotonic = time.perf_counter()
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
@@ -478,6 +525,8 @@ class OpenAILLM(BaseLLM):
|
|||||||
)
|
)
|
||||||
if name == "serper":
|
if name == "serper":
|
||||||
result = await self._run_serper_tool(arguments)
|
result = await self._run_serper_tool(arguments)
|
||||||
|
elif name == "schedule_callback":
|
||||||
|
result = await self._run_schedule_callback_tool(arguments, tool_call_id=str(tool_call.get("id") or ""))
|
||||||
else:
|
else:
|
||||||
result = f"Tool `{name}` is not supported by this runtime."
|
result = f"Tool `{name}` is not supported by this runtime."
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
@@ -547,6 +596,64 @@ class OpenAILLM(BaseLLM):
|
|||||||
LOGGER.info("Serper summary built: chars=%s preview=%r", len(summary), _preview_text(summary))
|
LOGGER.info("Serper summary built: chars=%s preview=%r", len(summary), _preview_text(summary))
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
async def _run_schedule_callback_tool(self, arguments: dict[str, Any], *, tool_call_id: str) -> str:
|
||||||
|
from realtime_voice_service import callback_client
|
||||||
|
|
||||||
|
phone = str(arguments.get("phone") or "").strip()
|
||||||
|
if not phone:
|
||||||
|
return "Не указан номер телефона. Попроси клиента продиктовать номер."
|
||||||
|
|
||||||
|
in_minutes = arguments.get("in_minutes")
|
||||||
|
scheduled_for = arguments.get("scheduled_for")
|
||||||
|
if in_minutes is None and not scheduled_for:
|
||||||
|
return "Не указано время: передай in_minutes или scheduled_for."
|
||||||
|
|
||||||
|
try:
|
||||||
|
in_minutes_int = int(in_minutes) if in_minutes is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
in_minutes_int = None
|
||||||
|
|
||||||
|
reason = str(arguments.get("reason") or "").strip() or None
|
||||||
|
customer_name = str(arguments.get("customer_name") or "").strip() or None
|
||||||
|
idempotency_key = (tool_call_id or "")[:128] or None
|
||||||
|
|
||||||
|
LOGGER.info(
|
||||||
|
"schedule_callback tool start: phone=%s in_minutes=%s scheduled_for=%s reason=%r",
|
||||||
|
phone,
|
||||||
|
in_minutes_int,
|
||||||
|
scheduled_for,
|
||||||
|
_preview_text(reason or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await callback_client.schedule_callback(
|
||||||
|
phone=phone,
|
||||||
|
in_minutes=in_minutes_int,
|
||||||
|
scheduled_for=str(scheduled_for) if scheduled_for else None,
|
||||||
|
reason=reason,
|
||||||
|
customer_name=customer_name,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return (
|
||||||
|
"Не удалось запланировать перезвон: сервис недоступен. Извинись и предложи "
|
||||||
|
"клиенту перезвонить самостоятельно."
|
||||||
|
)
|
||||||
|
if result.get("status") == "conflict":
|
||||||
|
return (
|
||||||
|
"На этот номер уже запланирован активный перезвон. Сообщи клиенту, что "
|
||||||
|
"перезвон уже в очереди."
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled = result.get("scheduled_for") or scheduled_for or "soon"
|
||||||
|
phone_e164 = result.get("phone_e164") or phone
|
||||||
|
job_id = result.get("job_id") or ""
|
||||||
|
return (
|
||||||
|
f"Перезвон запланирован: job_id={job_id}, номер {phone_e164}, время {scheduled} (UTC). "
|
||||||
|
"Подтверди клиенту коротко на русском, в какое примерно время мы перезвоним "
|
||||||
|
"(переведи UTC во время клиента, если знаешь его часовой пояс — иначе скажи в минутах от сейчас)."
|
||||||
|
)
|
||||||
|
|
||||||
async def _get_serper_session(self):
|
async def _get_serper_session(self):
|
||||||
try:
|
try:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -564,46 +671,96 @@ class OpenAILLM(BaseLLM):
|
|||||||
return self._serper_session
|
return self._serper_session
|
||||||
|
|
||||||
def _build_tools(self) -> list[dict[str, Any]] | None:
|
def _build_tools(self) -> list[dict[str, Any]] | None:
|
||||||
if not self._enable_tools or not self._serper_api_key:
|
if not self._enable_tools:
|
||||||
return None
|
return None
|
||||||
return [
|
tools: list[dict[str, Any]] = []
|
||||||
{
|
if self._serper_api_key:
|
||||||
"type": "function",
|
tools.append(
|
||||||
"function": {
|
{
|
||||||
"name": "serper",
|
"type": "function",
|
||||||
"description": (
|
"function": {
|
||||||
"Search the public web for recent or external information when the user asks "
|
"name": "serper",
|
||||||
"about weather, current facts, news, prices, currency rates, websites, company data, "
|
"description": (
|
||||||
"schedules, addresses, phone numbers, or anything requiring live search."
|
"Search the public web for recent or external information when the user asks "
|
||||||
),
|
"about weather, current facts, news, prices, currency rates, websites, company data, "
|
||||||
"parameters": {
|
"schedules, addresses, phone numbers, or anything requiring live search."
|
||||||
"type": "object",
|
),
|
||||||
"properties": {
|
"parameters": {
|
||||||
"query": {
|
"type": "object",
|
||||||
"type": "string",
|
"properties": {
|
||||||
"description": "Precise search query to send to Serper.",
|
"query": {
|
||||||
},
|
"type": "string",
|
||||||
"num": {
|
"description": "Precise search query to send to Serper.",
|
||||||
"type": "integer",
|
},
|
||||||
"description": "How many results to fetch, usually 3 to 5.",
|
"num": {
|
||||||
"minimum": 1,
|
"type": "integer",
|
||||||
"maximum": 10,
|
"description": "How many results to fetch, usually 3 to 5.",
|
||||||
},
|
"minimum": 1,
|
||||||
"hl": {
|
"maximum": 10,
|
||||||
"type": "string",
|
},
|
||||||
"description": "UI language code, for example ru or en.",
|
"hl": {
|
||||||
},
|
"type": "string",
|
||||||
"gl": {
|
"description": "UI language code, for example ru or en.",
|
||||||
"type": "string",
|
},
|
||||||
"description": "Country code for result localization, for example kz or us.",
|
"gl": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Country code for result localization, for example kz or us.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
"required": ["query"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
}
|
)
|
||||||
]
|
if _read_bool_env("CALLBACK_DISPATCH_ENABLED", True):
|
||||||
|
tools.append(
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "schedule_callback",
|
||||||
|
"description": (
|
||||||
|
"Schedule an outbound callback to the customer when they ask to be called back later. "
|
||||||
|
"The system will dial the customer at the requested time and reconnect them with the AI. "
|
||||||
|
"Use only when the customer explicitly requests a callback. After the tool succeeds, "
|
||||||
|
"confirm the callback time briefly in Russian."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"phone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Customer phone number. Accepts +7XXXXXXXXXX, 8XXXXXXXXXX, or any "
|
||||||
|
"format with digits — the backend normalizes it."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"in_minutes": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "How many minutes from now to call back. Use either in_minutes or scheduled_for.",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 10080,
|
||||||
|
},
|
||||||
|
"scheduled_for": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ISO-8601 UTC datetime to dial at, e.g. 2026-05-02T14:30:00Z. Use either this or in_minutes.",
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "One short sentence in Russian: why the callback is needed (the customer's topic).",
|
||||||
|
},
|
||||||
|
"customer_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Customer's name if already known from the conversation.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["phone"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tools or None
|
||||||
|
|
||||||
def _get_client(self):
|
def _get_client(self):
|
||||||
if self._client is not None:
|
if self._client is not None:
|
||||||
@@ -633,10 +790,19 @@ class OpenAILLM(BaseLLM):
|
|||||||
def _supports_custom_temperature(self) -> bool:
|
def _supports_custom_temperature(self) -> bool:
|
||||||
return not self._model.lower().startswith("gpt-5")
|
return not self._model.lower().startswith("gpt-5")
|
||||||
|
|
||||||
def _build_messages(self, text: str, context: list) -> list[dict[str, Any]]:
|
def _build_messages(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
context: list,
|
||||||
|
*,
|
||||||
|
language_code: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
messages: list[dict[str, Any]] = []
|
messages: list[dict[str, Any]] = []
|
||||||
if self._system_prompt:
|
if self._system_prompt:
|
||||||
messages.append({"role": "system", "content": self._system_prompt})
|
messages.append({"role": "system", "content": self._system_prompt})
|
||||||
|
language_directive = _language_directive(language_code)
|
||||||
|
if language_directive:
|
||||||
|
messages.append({"role": "system", "content": language_directive})
|
||||||
|
|
||||||
context_messages: list[dict[str, Any]] = []
|
context_messages: list[dict[str, Any]] = []
|
||||||
for entry in context:
|
for entry in context:
|
||||||
@@ -780,8 +946,14 @@ class OllamaLLM(BaseLLM):
|
|||||||
self._num_ctx or "default",
|
self._num_ctx or "default",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def generate_stream(self, text: str, context: list) -> AsyncGenerator[LLMStreamEvent, None]:
|
async def generate_stream(
|
||||||
messages = self._build_messages(text, context)
|
self,
|
||||||
|
text: str,
|
||||||
|
context: list,
|
||||||
|
*,
|
||||||
|
language_code: str | None = None,
|
||||||
|
) -> AsyncGenerator[LLMStreamEvent, None]:
|
||||||
|
messages = self._build_messages(text, context, language_code=language_code)
|
||||||
options: dict[str, Any] = {"temperature": self._temperature}
|
options: dict[str, Any] = {"temperature": self._temperature}
|
||||||
if self._num_predict > 0:
|
if self._num_predict > 0:
|
||||||
options["num_predict"] = self._num_predict
|
options["num_predict"] = self._num_predict
|
||||||
@@ -874,10 +1046,19 @@ class OllamaLLM(BaseLLM):
|
|||||||
self._client = httpx.AsyncClient(timeout=timeout)
|
self._client = httpx.AsyncClient(timeout=timeout)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
def _build_messages(self, text: str, context: list) -> list[dict[str, Any]]:
|
def _build_messages(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
context: list,
|
||||||
|
*,
|
||||||
|
language_code: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
messages: list[dict[str, Any]] = []
|
messages: list[dict[str, Any]] = []
|
||||||
if self._system_prompt:
|
if self._system_prompt:
|
||||||
messages.append({"role": "system", "content": self._system_prompt})
|
messages.append({"role": "system", "content": self._system_prompt})
|
||||||
|
language_directive = _language_directive(language_code)
|
||||||
|
if language_directive:
|
||||||
|
messages.append({"role": "system", "content": language_directive})
|
||||||
|
|
||||||
context_messages: list[dict[str, Any]] = []
|
context_messages: list[dict[str, Any]] = []
|
||||||
for entry in context:
|
for entry in context:
|
||||||
|
|||||||
+31
-1
@@ -663,15 +663,18 @@ class ElevenLabsSTT(BaseSTT):
|
|||||||
*,
|
*,
|
||||||
partial_callback: PartialTranscriptCallback | None = None,
|
partial_callback: PartialTranscriptCallback | None = None,
|
||||||
keyterms: Sequence[str] | None = None,
|
keyterms: Sequence[str] | None = None,
|
||||||
|
language_code: str | None = None,
|
||||||
) -> BaseSTTStream | None:
|
) -> BaseSTTStream | None:
|
||||||
if not self._use_realtime:
|
if not self._use_realtime:
|
||||||
return None
|
return None
|
||||||
if not self._api_key:
|
if not self._api_key:
|
||||||
raise RuntimeError("ELEVENLABS_API_KEY is required for ElevenLabs STT")
|
raise RuntimeError("ELEVENLABS_API_KEY is required for ElevenLabs STT")
|
||||||
|
|
||||||
|
effective_language_code = _normalize_elevenlabs_language_code(language_code) or self._language_code
|
||||||
websocket_url = self._build_realtime_websocket_url(
|
websocket_url = self._build_realtime_websocket_url(
|
||||||
sample_rate_hz=self._target_sample_rate_hz,
|
sample_rate_hz=self._target_sample_rate_hz,
|
||||||
keyterms=keyterms,
|
keyterms=keyterms,
|
||||||
|
language_code=effective_language_code,
|
||||||
)
|
)
|
||||||
normalized_keyterm_count = len(
|
normalized_keyterm_count = len(
|
||||||
_normalize_keyterms(
|
_normalize_keyterms(
|
||||||
@@ -684,7 +687,7 @@ class ElevenLabsSTT(BaseSTT):
|
|||||||
"ElevenLabs STT live stream starting: realtime_model=%s sample_rate=%s language=%s keyterms=%s",
|
"ElevenLabs STT live stream starting: realtime_model=%s sample_rate=%s language=%s keyterms=%s",
|
||||||
self._realtime_model_id,
|
self._realtime_model_id,
|
||||||
self._target_sample_rate_hz,
|
self._target_sample_rate_hz,
|
||||||
self._language_code,
|
effective_language_code,
|
||||||
normalized_keyterm_count,
|
normalized_keyterm_count,
|
||||||
)
|
)
|
||||||
stream = ElevenLabsRealtimeSTTStream(
|
stream = ElevenLabsRealtimeSTTStream(
|
||||||
@@ -1211,6 +1214,33 @@ class FallbackSTT(BaseSTT):
|
|||||||
force_batch=force_batch,
|
force_batch=force_batch,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def start_stream(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
partial_callback: PartialTranscriptCallback | None = None,
|
||||||
|
keyterms: Sequence[str] | None = None,
|
||||||
|
language_code: str | None = None,
|
||||||
|
) -> BaseSTTStream | None:
|
||||||
|
try:
|
||||||
|
stream = await self._primary.start_stream(
|
||||||
|
partial_callback=partial_callback,
|
||||||
|
keyterms=keyterms,
|
||||||
|
language_code=language_code,
|
||||||
|
)
|
||||||
|
if stream is not None:
|
||||||
|
return stream
|
||||||
|
except Exception:
|
||||||
|
LOGGER.exception(
|
||||||
|
"Primary STT start_stream failed; falling back: primary=%s fallback=%s",
|
||||||
|
type(self._primary).__name__,
|
||||||
|
type(self._fallback).__name__,
|
||||||
|
)
|
||||||
|
return await self._fallback.start_stream(
|
||||||
|
partial_callback=partial_callback,
|
||||||
|
keyterms=keyterms,
|
||||||
|
language_code=language_code,
|
||||||
|
)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
for provider in (self._primary, self._fallback):
|
for provider in (self._primary, self._fallback):
|
||||||
close = getattr(provider, "close", None)
|
close = getattr(provider, "close", None)
|
||||||
|
|||||||
+13
-6
@@ -166,7 +166,12 @@ class ElevenLabsTTS(BaseTTS):
|
|||||||
self._voice_settings,
|
self._voice_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def synthesize_stream(self, text_stream: AsyncIterable[str]) -> AsyncGenerator[bytes, None]:
|
async def synthesize_stream(
|
||||||
|
self,
|
||||||
|
text_stream: AsyncIterable[str],
|
||||||
|
*,
|
||||||
|
language_code: str | None = None,
|
||||||
|
) -> AsyncGenerator[bytes, None]:
|
||||||
if not self._api_key:
|
if not self._api_key:
|
||||||
raise RuntimeError("ELEVENLABS_API_KEY is required for ElevenLabs TTS")
|
raise RuntimeError("ELEVENLABS_API_KEY is required for ElevenLabs TTS")
|
||||||
if not self._voice_id:
|
if not self._voice_id:
|
||||||
@@ -178,7 +183,8 @@ class ElevenLabsTTS(BaseTTS):
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise RuntimeError("The `websockets` package is required for ElevenLabs TTS WebSocket streaming") from exc
|
raise RuntimeError("The `websockets` package is required for ElevenLabs TTS WebSocket streaming") from exc
|
||||||
|
|
||||||
websocket_url = self._build_websocket_url()
|
effective_language_code = (str(language_code).strip() if language_code else "") or self._language_code
|
||||||
|
websocket_url = self._build_websocket_url(language_code=effective_language_code)
|
||||||
started_monotonic = time.perf_counter()
|
started_monotonic = time.perf_counter()
|
||||||
audio_chunk_count = 0
|
audio_chunk_count = 0
|
||||||
audio_byte_count = 0
|
audio_byte_count = 0
|
||||||
@@ -193,7 +199,7 @@ class ElevenLabsTTS(BaseTTS):
|
|||||||
self._output_format,
|
self._output_format,
|
||||||
self._provider_sample_rate_hz,
|
self._provider_sample_rate_hz,
|
||||||
self._target_sample_rate_hz,
|
self._target_sample_rate_hz,
|
||||||
self._language_code,
|
effective_language_code,
|
||||||
self._auto_mode,
|
self._auto_mode,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -290,7 +296,7 @@ class ElevenLabsTTS(BaseTTS):
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise RuntimeError("ElevenLabs TTS WebSocket connection failed") from exc
|
raise RuntimeError("ElevenLabs TTS WebSocket connection failed") from exc
|
||||||
|
|
||||||
def _build_websocket_url(self) -> str:
|
def _build_websocket_url(self, *, language_code: str | None = None) -> str:
|
||||||
query = {
|
query = {
|
||||||
"model_id": self._model_id,
|
"model_id": self._model_id,
|
||||||
"output_format": self._output_format,
|
"output_format": self._output_format,
|
||||||
@@ -299,8 +305,9 @@ class ElevenLabsTTS(BaseTTS):
|
|||||||
"sync_alignment": "false",
|
"sync_alignment": "false",
|
||||||
"apply_text_normalization": "auto",
|
"apply_text_normalization": "auto",
|
||||||
}
|
}
|
||||||
if self._language_code:
|
effective_language = (str(language_code).strip() if language_code else "") or self._language_code
|
||||||
query["language_code"] = self._language_code
|
if effective_language:
|
||||||
|
query["language_code"] = effective_language
|
||||||
encoded_voice_id = quote(self._voice_id, safe="")
|
encoded_voice_id = quote(self._voice_id, safe="")
|
||||||
return f"{self._ws_base}/v1/text-to-speech/{encoded_voice_id}/stream-input?{urlencode(query)}"
|
return f"{self._ws_base}/v1/text-to-speech/{encoded_voice_id}/stream-input?{urlencode(query)}"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user