deploy / deploy (push) Successful in 30s
Centralizes fixed control intents and adds a data-driven intent_code field on kb_articles so many phrasings of the same FAQ question resolve to one stable code (e.g. VOUCHER_ACTIVATION) instead of a free-form, unvalidated string the LLM invented on the fly. - services/shared/intents.py: CONTROL_INTENTS + normalize_intent() - kb_articles.intent_code column (ORM + dev/sqlite runtime compat + migrations/sql/0034_* for postgres/sqlite) - kb_service CRUD exposes intent_code - orchestrator surfaces intent_code to the LLM and validates its intent output against control intents + the KB codes shown that turn - voice.py: _voice_early_intent_bucket renamed to _voice_ack_topic_bucket to stop it being conflated with the canonical FAQ intent
188 lines
9.8 KiB
Python
188 lines
9.8 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. "
|
||
"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."
|
||
)
|