Files
call-center/services/ai_orchestrator_service/operator_persona.py
T
didar d2438b6954
deploy / deploy (push) Successful in 30s
feat: canonical intent taxonomy for AI operator (kb_answer -> intent_code)
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
2026-08-31 00:17:51 +05:00

188 lines
9.8 KiB
Python
Raw 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. "
"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."
)