This commit is contained in:
Konturai DevOps
2026-05-02 00:48:40 +05:00
parent 676d7155bd
commit 8a83a4d575
6 changed files with 501 additions and 63 deletions
+120
View File
@@ -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
View File
@@ -245,6 +245,54 @@ def _preview_text(text: str, *, limit: int = 160) -> str:
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:
if not audio_bytes or sample_rate_hz <= 0:
return 0
@@ -993,6 +1041,7 @@ class CallSession:
self._personalized_greeting_template = DEFAULT_PERSONALIZED_GREETING_TEMPLATE
self._live_stt_stream: BaseSTTStream | None = None
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._semantic_hold_silence_timeout_ms = max(self._default_vad_silence_timeout_ms, SEMANTIC_ENDPOINTING_HOLD_MS)
LOGGER.info(
@@ -1235,6 +1284,18 @@ class CallSession:
)
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(
"realtime session %s transcript accepted: epoch=%s chars=%s text=%r",
self.session_id,
@@ -1334,7 +1395,11 @@ class CallSession:
),
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)
if event.type == "tool_call_start":
LOGGER.info(
@@ -1546,7 +1611,11 @@ class CallSession:
await self._stt.transcribe(
audio_bytes,
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,
)
).strip()
@@ -1825,7 +1894,8 @@ class CallSession:
sentence_queue=sentence_queue,
started_holder=tts_started_monotonic,
actually_spoken_chunks=actually_spoken_chunks,
)
),
language_code=_tts_language_for(self._session_language),
):
self._ensure_generation(epoch)
if not audio_chunk:
@@ -2061,6 +2131,7 @@ class CallSession:
live_stt_stream = await self._stt.start_stream(
partial_callback=self._handle_partial_transcript,
keyterms=None,
language_code=_stt_language_for(self._session_language),
)
except Exception:
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(
utterance_audio,
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,
)
+32 -7
View File
@@ -47,20 +47,32 @@ class BaseSTT(ABC):
*,
partial_callback: PartialTranscriptCallback | None = None,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
) -> BaseSTTStream | None:
del partial_callback, keyterms
del partial_callback, keyterms, language_code
return None
class BaseLLM(ABC):
@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
class BaseTTS(ABC):
@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
@@ -106,8 +118,9 @@ class MockSTT(BaseSTT):
*,
partial_callback: PartialTranscriptCallback | None = None,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
) -> BaseSTTStream | None:
del keyterms
del keyterms, language_code
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._scripted_responses = list(scripted_responses or [])
async def generate_stream(self, text: str, context: list) -> AsyncGenerator[LLMStreamEvent, None]:
del context
async def generate_stream(
self,
text: str,
context: list,
*,
language_code: str | None = None,
) -> AsyncGenerator[LLMStreamEvent, None]:
del context, language_code
response_text = (
self._scripted_responses.pop(0)
if self._scripted_responses
@@ -152,7 +171,13 @@ class MockTTS(BaseTTS):
self._amplitude = amplitude
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:
normalized = text.strip()
if not normalized:
+192 -11
View File
@@ -31,6 +31,45 @@ _WEB_SEARCH_POLICY = (
"Если для поиска не хватает города, даты или объекта, задай короткий уточняющий вопрос."
)
_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(
r"("
@@ -72,9 +111,11 @@ def _with_conversation_close_policy(system_prompt: str) -> str:
def _with_runtime_policies(system_prompt: str) -> str:
prompt = _with_conversation_close_policy(system_prompt)
if _WEB_SEARCH_POLICY_MARKER in prompt:
if _WEB_SEARCH_POLICY_MARKER not in prompt:
prompt = 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
return f"{prompt}\n\n{_WEB_SEARCH_POLICY}" if prompt else _WEB_SEARCH_POLICY
def _should_force_live_search(text: str) -> bool:
@@ -198,11 +239,17 @@ class OpenAILLM(BaseLLM):
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:
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)
turn_started_monotonic = time.perf_counter()
LOGGER.info(
@@ -478,6 +525,8 @@ class OpenAILLM(BaseLLM):
)
if name == "serper":
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:
result = f"Tool `{name}` is not supported by this runtime."
LOGGER.info(
@@ -547,6 +596,64 @@ class OpenAILLM(BaseLLM):
LOGGER.info("Serper summary built: chars=%s preview=%r", len(summary), _preview_text(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):
try:
import aiohttp
@@ -564,9 +671,11 @@ class OpenAILLM(BaseLLM):
return self._serper_session
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 [
tools: list[dict[str, Any]] = []
if self._serper_api_key:
tools.append(
{
"type": "function",
"function": {
@@ -603,7 +712,55 @@ class OpenAILLM(BaseLLM):
},
},
}
]
)
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):
if self._client is not None:
@@ -633,10 +790,19 @@ class OpenAILLM(BaseLLM):
def _supports_custom_temperature(self) -> bool:
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]] = []
if 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]] = []
for entry in context:
@@ -780,8 +946,14 @@ class OllamaLLM(BaseLLM):
self._num_ctx or "default",
)
async def generate_stream(self, text: str, context: list) -> AsyncGenerator[LLMStreamEvent, None]:
messages = self._build_messages(text, context)
async def generate_stream(
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}
if self._num_predict > 0:
options["num_predict"] = self._num_predict
@@ -874,10 +1046,19 @@ class OllamaLLM(BaseLLM):
self._client = httpx.AsyncClient(timeout=timeout)
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]] = []
if 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]] = []
for entry in context:
+31 -1
View File
@@ -663,15 +663,18 @@ class ElevenLabsSTT(BaseSTT):
*,
partial_callback: PartialTranscriptCallback | None = None,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
) -> BaseSTTStream | None:
if not self._use_realtime:
return None
if not self._api_key:
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(
sample_rate_hz=self._target_sample_rate_hz,
keyterms=keyterms,
language_code=effective_language_code,
)
normalized_keyterm_count = len(
_normalize_keyterms(
@@ -684,7 +687,7 @@ class ElevenLabsSTT(BaseSTT):
"ElevenLabs STT live stream starting: realtime_model=%s sample_rate=%s language=%s keyterms=%s",
self._realtime_model_id,
self._target_sample_rate_hz,
self._language_code,
effective_language_code,
normalized_keyterm_count,
)
stream = ElevenLabsRealtimeSTTStream(
@@ -1211,6 +1214,33 @@ class FallbackSTT(BaseSTT):
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:
for provider in (self._primary, self._fallback):
close = getattr(provider, "close", None)
+13 -6
View File
@@ -166,7 +166,12 @@ class ElevenLabsTTS(BaseTTS):
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:
raise RuntimeError("ELEVENLABS_API_KEY is required for ElevenLabs TTS")
if not self._voice_id:
@@ -178,7 +183,8 @@ class ElevenLabsTTS(BaseTTS):
except Exception as exc: # noqa: BLE001
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()
audio_chunk_count = 0
audio_byte_count = 0
@@ -193,7 +199,7 @@ class ElevenLabsTTS(BaseTTS):
self._output_format,
self._provider_sample_rate_hz,
self._target_sample_rate_hz,
self._language_code,
effective_language_code,
self._auto_mode,
)
try:
@@ -290,7 +296,7 @@ class ElevenLabsTTS(BaseTTS):
except OSError as 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 = {
"model_id": self._model_id,
"output_format": self._output_format,
@@ -299,8 +305,9 @@ class ElevenLabsTTS(BaseTTS):
"sync_alignment": "false",
"apply_text_normalization": "auto",
}
if self._language_code:
query["language_code"] = self._language_code
effective_language = (str(language_code).strip() if language_code else "") or self._language_code
if effective_language:
query["language_code"] = effective_language
encoded_voice_id = quote(self._voice_id, safe="")
return f"{self._ws_base}/v1/text-to-speech/{encoded_voice_id}/stream-input?{urlencode(query)}"