Files

194 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "
"but never at the cost of dropping a required fact. "
"Use as many short sentences as needed to cover every material fact from the grounding kb_results "
"snippet completely - required steps, codes, commands, deadlines, amounts, and conditions - typically "
"two to four short sentences; never silently omit or shorten out a required step just to sound brief. "
"At most one clarifying question per turn. "
"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. "
"After you deliver a complete answer grounded in kb_results (not when you are asking a clarifying question, "
"handling an identity/off-topic reply, or closing the call), end reply_text with a brief natural check such as «Ответила ли я на ваш вопрос?» in Russian, or its natural Kazakh equivalent, phrased differently each time so it does not sound scripted. "
"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. "
"For `intent`: if your reply is grounded in one of the provided kb_results, set intent to that snippet's "
"intent_code exactly as given (do not translate, reformat, or invent your own code). If no kb_results were "
"used, use one of these fixed values as appropriate: identity_question, handoff_request, sensitive_request, "
"resolution_confirmed, clarification, kb_answer, unknown. Never invent a new intent value outside of these "
"two sources — the platform discards anything else."
)