RuntimeConfiguredTTSProvider (the live 'dynamic' TTS provider, resolves voice from DB-backed config) never overrode cache_fingerprint(), so it fell back to the base class's empty string. PrebakedAckBank keys its on-disk cache on that fingerprint, so short filler phrases like 'Секунду' kept serving audio baked with the previous ElevenLabs voice even after a voice change, while full LLM replies (cached inside the resolved provider itself, keyed on its own voice id) already used the new voice — explaining why callers heard two different voices in the same call. Fix: delegate cache_fingerprint() to the resolved provider. Also extend the voice delivery_hint so the model transliterates website addresses and English words/abbreviations into spoken Cyrillic instead of leaving raw Latin text for the TTS engine to mangle (egov.kz was coming out as 'эговкз').
183 lines
9.2 KiB
Python
183 lines
9.2 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
from typing import Any
|
||
|
||
|
||
_IDENTITY_PATTERNS: tuple[str, ...] = (
|
||
"кто ты",
|
||
"кто вы",
|
||
"ты кто",
|
||
"вы кто",
|
||
"как тебя зовут",
|
||
"как вас зовут",
|
||
"твое имя",
|
||
"твоё имя",
|
||
"ваше имя",
|
||
"представься",
|
||
"скажи кто ты",
|
||
"скажи мне кто ты",
|
||
"робот ты",
|
||
"ты робот",
|
||
"ты бот",
|
||
"ты ии",
|
||
"ты искусственный интеллект",
|
||
"ты оператор",
|
||
"вы оператор",
|
||
"who are you",
|
||
"what is your name",
|
||
"сен кімсің",
|
||
"сіз кімсіз",
|
||
"атың кім",
|
||
"атыңыз кім",
|
||
)
|
||
|
||
|
||
def _config_value(config: Any | None, name: str, default: str) -> str:
|
||
if config is None:
|
||
return default
|
||
value = getattr(config, name, None)
|
||
if value is None and isinstance(config, dict):
|
||
value = config.get(name)
|
||
normalized = str(value or "").strip()
|
||
return normalized or default
|
||
|
||
|
||
def _agent_name(config: Any | None) -> str:
|
||
return _config_value(config, "agent_name", "Айнур")
|
||
|
||
|
||
def _company_name(config: Any | None) -> str:
|
||
return _config_value(config, "company_name", "DigiOps")
|
||
|
||
|
||
def _format_config_text(text: str, config: Any | None) -> str:
|
||
return (
|
||
str(text or "")
|
||
.replace("{agent_name}", _agent_name(config))
|
||
.replace("{company_name}", _company_name(config))
|
||
.strip()
|
||
)
|
||
|
||
|
||
def is_identity_request(text: str) -> bool:
|
||
normalized = str(text or "").strip().lower().replace("ё", "е")
|
||
if not normalized:
|
||
return False
|
||
normalized = re.sub(r"[^\w\sәіңғүұқөһ-]+", " ", normalized, flags=re.UNICODE)
|
||
compact = " ".join(normalized.split())
|
||
return any(pattern.replace("ё", "е") in compact for pattern in _IDENTITY_PATTERNS)
|
||
|
||
|
||
def customer_persona_mode() -> str:
|
||
return str(os.getenv("AI_CUSTOMER_PERSONA_MODE", "operator_humanlike") or "operator_humanlike").strip().lower()
|
||
|
||
|
||
def disclosure_mode() -> str:
|
||
return str(os.getenv("AI_DISCLOSURE_MODE", "hidden") or "hidden").strip().lower()
|
||
|
||
|
||
def voice_policy_mode() -> str:
|
||
return str(os.getenv("AI_VOICE_POLICY_MODE", "llm_guarded") or "llm_guarded").strip().lower()
|
||
|
||
|
||
def disclosure_hidden() -> bool:
|
||
return disclosure_mode() == "hidden"
|
||
|
||
|
||
def voice_disclosure_prefix(language: str) -> str:
|
||
if disclosure_hidden():
|
||
return ""
|
||
if str(language or "").strip().lower() == "kz":
|
||
return "Men kompaniya atynan jauap berip turmyn. "
|
||
return "Отвечаю от имени линии поддержки компании. "
|
||
|
||
|
||
def identity_reply(language: str, config: Any | None = None) -> str:
|
||
if str(language or "").strip().lower() == "kz":
|
||
default = f"Мен {_agent_name(config)}мын, {_company_name(config)} байланыс орталығының операторымын. Қалай көмектесе аламын?"
|
||
return _format_config_text(_config_value(config, "identity_reply_kz", default), config)
|
||
default = f"Я {_agent_name(config)}, оператор контакт-центра {_company_name(config)}. Чем могу помочь?"
|
||
return _format_config_text(_config_value(config, "identity_reply_ru", default), config)
|
||
|
||
|
||
def voice_greeting(language: str, config: Any | None = None) -> str:
|
||
if str(language or "").strip().lower() == "kz":
|
||
default = f"Сәлеметсіз бе. Мен {_agent_name(config)}мын. Қалай көмектесе аламын?"
|
||
return _format_config_text(_config_value(config, "voice_greeting_kz", default), config)
|
||
default = f"Здравствуйте. Я {_agent_name(config)}. Подскажите, пожалуйста, чем помочь. (тест деплоя)"
|
||
return _format_config_text(_config_value(config, "voice_greeting_ru", default), config)
|
||
|
||
|
||
def voice_handoff_reply(language: str) -> str:
|
||
if str(language or "").strip().lower() == "kz":
|
||
return "Бір сәт, сізді операторға қосамын."
|
||
return "Секунду, соединяю вас с оператором."
|
||
|
||
|
||
def text_resolution_reply(language: str) -> str:
|
||
if str(language or "").strip().lower() == "kz":
|
||
return "Жақсы, белгілеп қоямын. Қажет болса, осы жерден қайта жаза аласыз."
|
||
return "Хорошо, отмечу это. Если понадобится, можно продолжить здесь."
|
||
|
||
|
||
def human_fallback_reply(language: str, *, is_greeting: bool = False) -> str:
|
||
if str(language or "").strip().lower() == "kz":
|
||
if is_greeting:
|
||
return "Сәлеметсіз бе. Сұрағыңызды жазыңыз не айтыңыз, көмектесуге тырысамын."
|
||
return "Түсіндім. Нақтырақ айтып жіберсеңіз, бірден жалғастырамын."
|
||
if is_greeting:
|
||
return "Здравствуйте. Напишите или коротко расскажите, чем помочь."
|
||
return "Понял вас. Уточните, пожалуйста, детальнее, и я сразу продолжу."
|
||
|
||
|
||
def operator_system_prompt(*, language: str, channel_label: str, is_voice: bool, config: Any | None = None) -> str:
|
||
preferred_language = "Kazakh" if str(language or "").strip().lower() == "kz" else "Russian"
|
||
delivery_hint = (
|
||
"The reply will be spoken aloud over a phone call, so keep it concise, natural, and easy to listen to. "
|
||
"Prefer one or two short sentences and at most one clarifying question. "
|
||
"The text-to-speech engine reads exactly what you write, digit by digit, with no number formatting of its own, "
|
||
"so never output bare digits for phone numbers, hotline numbers, or dates — always spell them out in words "
|
||
"the way a person would actually say them aloud in natural spoken Russian/Kazakh. "
|
||
"Short hotline or service numbers (e.g. 1414, 109) must be spelled out the way people say them as a code, "
|
||
"grouped and read naturally (\"1414\" as \"четырнадцать четырнадцать\", not \"тысяча четыреста четырнадцать\"). "
|
||
"Calendar dates must use the correct spoken grammatical case (\"25 числа\" as \"двадцать пятого числа\", "
|
||
"not \"двадцать пять число\"; \"14 марта\" as \"четырнадцатого марта\"). "
|
||
"Never leave a website address, domain, or English word/abbreviation in raw Latin script — the TTS engine "
|
||
"slurs it into gibberish (e.g. \"egov.kz\" comes out as \"эговкз\"). Transliterate it into how a person "
|
||
"actually pronounces it aloud, with an explicit pause word for punctuation: write \"egov.kz\" as "
|
||
"\"игов точка кэ-зэт\", write \".kz\"/\".com\" as \"точка кэ-зэт\"/\"точка ком\", spell out an acronym or "
|
||
"English word phonetically in Cyrillic (\"IT\" as \"ай-ти\", \"email\" as \"имейл\")."
|
||
if is_voice
|
||
else "The reply should read like a concise message from a live first-line operator."
|
||
)
|
||
default_base_prompt = (
|
||
f"Ты {_agent_name(config)}, единый оператор контакт-центра {_company_name(config)} для звонков, "
|
||
"Telegram и других каналов. Всегда сохраняй одну и ту же личность. Когда говоришь о себе, "
|
||
"используй женский род. Не завершай диалог самостоятельно."
|
||
)
|
||
base_prompt = _format_config_text(_config_value(config, "base_system_prompt", default_base_prompt), config)
|
||
identity_instruction = (
|
||
f"If the customer asks who you are or what your name is, answer as {_agent_name(config)}. "
|
||
f"For {preferred_language}, use this identity reply when appropriate: "
|
||
f"{identity_reply(language, config)}"
|
||
)
|
||
return (
|
||
f"{base_prompt}\n\n"
|
||
f"You are a first-line company operator handling customer conversations in {channel_label}. "
|
||
f"Reply in {preferred_language}. Speak naturally, warmly, and confidently like a human operator. "
|
||
f"Your name is {_agent_name(config)} and you must not introduce yourself with any other name. "
|
||
f"{identity_instruction} "
|
||
"Do not mention a knowledge base, snippets, internal notes, policies, or hidden tools. "
|
||
"Use the provided business context, conversation history, and KB snippets only as factual sources. "
|
||
"Paraphrase them naturally instead of quoting them verbatim. "
|
||
"Never invent order statuses, tariffs, discounts, deadlines, addresses, availability, approvals, or actions "
|
||
"that are not supported by context. If the available facts are insufficient, ask one short clarifying question. "
|
||
"If the customer explicitly asks for a live operator, if the request is sensitive, or if the case is blocked, "
|
||
"set needs_handoff=true. "
|
||
f"{delivery_hint} "
|
||
"Return only a JSON object with keys: language, intent, reply_text, extracted_name, confidence, needs_handoff, "
|
||
"handoff_reason, case_action, kb_refs. case_action must be one of none, close, escalate, keep_open."
|
||
)
|