Add configurable AI operator persona
This commit is contained in:
@@ -648,6 +648,7 @@ def contracts() -> dict:
|
||||
"/ai/analytics/voice-name-flow/timeseries",
|
||||
"/ai/analytics/drilldown",
|
||||
"/ai/analytics/sessions/*",
|
||||
"/ai/operator/config",
|
||||
],
|
||||
"stage_17": [
|
||||
"/ai/voice/sessions/*",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS ai_operator_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
settings_key VARCHAR(64) NOT NULL UNIQUE,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at VARCHAR(64) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_operator_settings_key
|
||||
ON ai_operator_settings(settings_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_operator_settings_updated_at
|
||||
ON ai_operator_settings(updated_at);
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS ai_operator_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
settings_key TEXT NOT NULL UNIQUE,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_operator_settings_key
|
||||
ON ai_operator_settings(settings_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_operator_settings_updated_at
|
||||
ON ai_operator_settings(updated_at);
|
||||
@@ -45,6 +45,8 @@ from services.shared.models import (
|
||||
AIAnalyticsTimeseriesPointOut,
|
||||
AIAnalyticsTotalsOut,
|
||||
AIAnalyticsWindowOut,
|
||||
AIOperatorConfig,
|
||||
AIOperatorConfigOut,
|
||||
VoiceNameFlowAnalyticsBreakdownsOut,
|
||||
VoiceNameFlowAnalyticsCoverageOut,
|
||||
VoiceNameFlowAnalyticsFiltersOut,
|
||||
@@ -92,6 +94,11 @@ from services.ai_orchestrator_service.voice_name_config import (
|
||||
load_voice_name_collection_config,
|
||||
save_voice_name_collection_config,
|
||||
)
|
||||
from services.shared.ai_operator_config import (
|
||||
load_ai_operator_config,
|
||||
load_effective_ai_operator_config,
|
||||
save_ai_operator_config,
|
||||
)
|
||||
from services.shared.voice_tts_config import load_voice_tts_config, save_voice_tts_config
|
||||
|
||||
app = FastAPI(title="ai-orchestrator-service", version="1.0.0")
|
||||
@@ -1452,6 +1459,10 @@ def _ai_model() -> str:
|
||||
return os.getenv("AI_MODEL", "stub-telegram-assistant").strip() or "stub-telegram-assistant"
|
||||
|
||||
|
||||
def _load_ai_operator_config(session) -> AIOperatorConfig:
|
||||
return load_effective_ai_operator_config(session)
|
||||
|
||||
|
||||
def _ai_timeout_seconds() -> float:
|
||||
return max(3.0, _float_env("AI_TIMEOUT_SECONDS", 20.0))
|
||||
|
||||
@@ -2217,6 +2228,7 @@ def _openai_prompt(
|
||||
conversation_summary_text: str = "",
|
||||
channel_label: str = "Telegram",
|
||||
channel_key: str = "telegram",
|
||||
operator_config: AIOperatorConfig | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
customer_summary = {
|
||||
"customer_id": customer.customer_id if customer else interaction.customer_id,
|
||||
@@ -2246,6 +2258,7 @@ def _openai_prompt(
|
||||
language=language,
|
||||
channel_label=channel_label,
|
||||
is_voice=False,
|
||||
config=operator_config,
|
||||
)
|
||||
user_prompt = {
|
||||
"customer": customer_summary,
|
||||
@@ -2309,6 +2322,7 @@ def _openai_compatible_decision(
|
||||
conversation_summary_text: str = "",
|
||||
channel_label: str = "Telegram",
|
||||
channel_key: str = "telegram",
|
||||
operator_config: AIOperatorConfig | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return _request_structured_model_decision(
|
||||
_openai_prompt(
|
||||
@@ -2321,6 +2335,7 @@ def _openai_compatible_decision(
|
||||
conversation_summary_text=conversation_summary_text,
|
||||
channel_label=channel_label,
|
||||
channel_key=channel_key,
|
||||
operator_config=operator_config,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2356,8 +2371,20 @@ def _stub_decision(
|
||||
last_user_message: TelegramMessageRow,
|
||||
kb_results: list[KBArticleRow],
|
||||
language: str,
|
||||
operator_config: AIOperatorConfig | None = None,
|
||||
) -> dict[str, Any]:
|
||||
text = last_user_message.text
|
||||
if persona.is_identity_request(text):
|
||||
return {
|
||||
"language": language,
|
||||
"intent": "identity_question",
|
||||
"reply_text": persona.identity_reply(language, operator_config),
|
||||
"confidence": 0.98,
|
||||
"needs_handoff": False,
|
||||
"handoff_reason": None,
|
||||
"case_action": "keep_open",
|
||||
"kb_refs": [],
|
||||
}
|
||||
if _looks_like_human_request(text):
|
||||
return {
|
||||
"language": language,
|
||||
@@ -2467,10 +2494,28 @@ def _decide_reply(
|
||||
conversation_summary_text: str = "",
|
||||
channel_label: str = "Telegram",
|
||||
channel_key: str = "telegram",
|
||||
operator_config: AIOperatorConfig | None = None,
|
||||
) -> dict[str, Any]:
|
||||
last_user_message = next((item for item in reversed(messages) if item.author_type == "customer"), None)
|
||||
if last_user_message is None:
|
||||
raise RuntimeError("No customer message found for AI decision")
|
||||
if persona.is_identity_request(last_user_message.text):
|
||||
return _sanitize_decision(
|
||||
{
|
||||
"language": language,
|
||||
"intent": "identity_question",
|
||||
"reply_text": persona.identity_reply(language, operator_config),
|
||||
"confidence": 0.98,
|
||||
"needs_handoff": False,
|
||||
"handoff_reason": None,
|
||||
"case_action": "keep_open",
|
||||
"kb_refs": [],
|
||||
"_model": "operator_identity_policy",
|
||||
"_latency_ms": 1,
|
||||
"_finish_reason": "stop",
|
||||
},
|
||||
fallback_language=language,
|
||||
)
|
||||
if _ai_provider() == "openai_compatible":
|
||||
raw = _openai_compatible_decision(
|
||||
customer=customer,
|
||||
@@ -2482,6 +2527,7 @@ def _decide_reply(
|
||||
conversation_summary_text=conversation_summary_text,
|
||||
channel_label=channel_label,
|
||||
channel_key=channel_key,
|
||||
operator_config=operator_config,
|
||||
)
|
||||
else:
|
||||
raw = _stub_decision(
|
||||
@@ -2490,6 +2536,7 @@ def _decide_reply(
|
||||
last_user_message=last_user_message,
|
||||
kb_results=kb_results,
|
||||
language=language,
|
||||
operator_config=operator_config,
|
||||
)
|
||||
raw["_model"] = _ai_model()
|
||||
raw["_latency_ms"] = 1
|
||||
@@ -2663,6 +2710,7 @@ def _process_job(job_id: str) -> dict[str, Any]:
|
||||
|
||||
messages = _last_messages(session, thread.thread_id, _ai_max_context_messages())
|
||||
kb_results = _kb_search(session, trigger_message.text, language=ai_session.language)
|
||||
operator_config = _load_ai_operator_config(session)
|
||||
decision = _decide_reply(
|
||||
customer=customer,
|
||||
interaction=interaction,
|
||||
@@ -2671,6 +2719,7 @@ def _process_job(job_id: str) -> dict[str, Any]:
|
||||
kb_results=kb_results,
|
||||
language=ai_session.language or "ru",
|
||||
conversation_summary_text=conversation_summary_text,
|
||||
operator_config=operator_config,
|
||||
)
|
||||
decision = _apply_always_reply_mode(decision, last_user_text=trigger_message.text)
|
||||
_record_turn(
|
||||
@@ -2930,6 +2979,7 @@ def _process_whatsapp_job(job_id: str) -> dict[str, Any]:
|
||||
trigger_message.text,
|
||||
language=ai_session.language,
|
||||
)[: _ai_whatsapp_max_kb_results()]
|
||||
operator_config = _load_ai_operator_config(session)
|
||||
decision = _decide_reply(
|
||||
customer=customer,
|
||||
interaction=interaction,
|
||||
@@ -2940,6 +2990,7 @@ def _process_whatsapp_job(job_id: str) -> dict[str, Any]:
|
||||
conversation_summary_text=conversation_summary_text,
|
||||
channel_label="WhatsApp",
|
||||
channel_key="whatsapp",
|
||||
operator_config=operator_config,
|
||||
)
|
||||
decision = _apply_always_reply_mode(
|
||||
decision,
|
||||
@@ -3363,6 +3414,29 @@ def ai_analytics_session_detail(
|
||||
session.close()
|
||||
|
||||
|
||||
@app.get("/ai/operator/config", response_model=AIOperatorConfigOut)
|
||||
def get_ai_operator_config(
|
||||
_: dict = Depends(require_roles(Role.ADMIN)),
|
||||
) -> AIOperatorConfigOut:
|
||||
session = get_session()
|
||||
try:
|
||||
return load_ai_operator_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.put("/ai/operator/config", response_model=AIOperatorConfigOut)
|
||||
def put_ai_operator_config(
|
||||
payload: AIOperatorConfig,
|
||||
_: dict = Depends(require_roles(Role.ADMIN)),
|
||||
) -> AIOperatorConfigOut:
|
||||
session = get_session()
|
||||
try:
|
||||
return save_ai_operator_config(session, payload)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.post("/ai/voice/sessions/{session_id}/start")
|
||||
def start_voice_ai_session(
|
||||
session_id: str,
|
||||
@@ -3641,7 +3715,3 @@ def pause_whatsapp_thread_ai(
|
||||
return {"ok": True, "status": "human_owned", "thread_id": thread.thread_id}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,73 @@
|
||||
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:
|
||||
@@ -27,10 +94,20 @@ def voice_disclosure_prefix(language: str) -> str:
|
||||
return "Отвечаю от имени линии поддержки компании. "
|
||||
|
||||
|
||||
def voice_greeting(language: str) -> str:
|
||||
def identity_reply(language: str, config: Any | None = None) -> str:
|
||||
if str(language or "").strip().lower() == "kz":
|
||||
return "Сәлеметсіз бе. Сұрағыңызды айтыңызшы, көмектесуге тырысамын."
|
||||
return "Здравствуйте. Подскажите, пожалуйста, чем помочь."
|
||||
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:
|
||||
@@ -55,7 +132,7 @@ def human_fallback_reply(language: str, *, is_greeting: bool = False) -> str:
|
||||
return "Понял вас. Уточните, пожалуйста, детальнее, и я сразу продолжу."
|
||||
|
||||
|
||||
def operator_system_prompt(*, language: str, channel_label: str, is_voice: bool) -> str:
|
||||
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. "
|
||||
@@ -63,10 +140,23 @@ def operator_system_prompt(*, language: str, channel_label: str, is_voice: bool)
|
||||
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. "
|
||||
"Do not say or imply that you are an AI, bot, assistant, model, or automated system. "
|
||||
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. "
|
||||
|
||||
@@ -55,8 +55,8 @@ def _voice_disclosure_prefix(language: str) -> str:
|
||||
return persona.voice_disclosure_prefix(language)
|
||||
|
||||
|
||||
def _voice_greeting(language: str) -> str:
|
||||
return persona.voice_greeting(language)
|
||||
def _voice_greeting(language: str, operator_config: Any | None = None) -> str:
|
||||
return persona.voice_greeting(language, operator_config)
|
||||
|
||||
|
||||
def _voice_handoff_reply(language: str) -> str:
|
||||
@@ -1557,6 +1557,7 @@ def _voice_llm_prompt_messages(
|
||||
kb_results: list[Any],
|
||||
name_value: str | None,
|
||||
name_status: str | None,
|
||||
operator_config: Any | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
app = _app()
|
||||
history = [
|
||||
@@ -1604,6 +1605,7 @@ def _voice_llm_prompt_messages(
|
||||
language=language,
|
||||
channel_label="Voice",
|
||||
is_voice=True,
|
||||
config=operator_config,
|
||||
)
|
||||
if name_status in ("name_not_obtained", "name_followup_required"):
|
||||
system_prompt += (
|
||||
@@ -1642,6 +1644,7 @@ def _voice_llm_decision(
|
||||
kb_results: list[Any],
|
||||
name_value: str | None,
|
||||
name_status: str | None,
|
||||
operator_config: Any | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
app = _app()
|
||||
if _voice_policy_mode() not in {"llm_guarded", "v2_fast_conversational", "v2_streaming_duplex"}:
|
||||
@@ -1660,6 +1663,7 @@ def _voice_llm_decision(
|
||||
kb_results=kb_results,
|
||||
name_value=name_value,
|
||||
name_status=name_status,
|
||||
operator_config=operator_config,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
@@ -1699,10 +1703,26 @@ def _voice_decision_legacy(
|
||||
transcript_window: list[VoiceTranscriptSegmentRow],
|
||||
kb_results: list[Any],
|
||||
disclosure_required: bool,
|
||||
operator_config: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
app = _app()
|
||||
normalized = str(transcript_text or "").strip()
|
||||
lower_text = normalized.lower()
|
||||
if persona.is_identity_request(normalized):
|
||||
reply_text = persona.identity_reply(language, operator_config)
|
||||
return {
|
||||
"language": language,
|
||||
"intent": "identity_question",
|
||||
"reply_text": reply_text,
|
||||
"confidence": 0.98,
|
||||
"needs_handoff": False,
|
||||
"handoff_reason": None,
|
||||
"case_action": "keep_open",
|
||||
"kb_refs": [],
|
||||
"summary_text": "AI ответил на вопрос о своей личности.",
|
||||
"model": "operator_identity_policy",
|
||||
"latency_ms": 1,
|
||||
}
|
||||
needs_handoff = app._looks_like_human_request(lower_text) or app._is_sensitive_request(lower_text)
|
||||
model = app._ai_model()
|
||||
if needs_handoff:
|
||||
@@ -1773,11 +1793,27 @@ def _voice_decision(
|
||||
disclosure_required: bool,
|
||||
customer_name_value: str | None = None,
|
||||
customer_name_status: str | None = None,
|
||||
operator_config: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
app = _app()
|
||||
normalized = str(transcript_text or "").strip()
|
||||
lower_text = normalized.lower()
|
||||
caller_texts = _voice_recent_caller_texts(transcript_window)
|
||||
if persona.is_identity_request(normalized):
|
||||
reply_text = persona.identity_reply(language, operator_config)
|
||||
return {
|
||||
"language": language,
|
||||
"intent": "identity_question",
|
||||
"reply_text": reply_text,
|
||||
"confidence": 0.98,
|
||||
"needs_handoff": False,
|
||||
"handoff_reason": None,
|
||||
"case_action": "keep_open",
|
||||
"kb_refs": [],
|
||||
"summary_text": "AI ответил на вопрос о своей личности.",
|
||||
"model": "operator_identity_policy",
|
||||
"latency_ms": 1,
|
||||
}
|
||||
needs_handoff = app._looks_like_human_request(lower_text) or app._is_sensitive_request(lower_text)
|
||||
model = app._ai_model()
|
||||
if needs_handoff:
|
||||
@@ -1877,6 +1913,7 @@ def _voice_decision(
|
||||
customer_name_value: str | None = None,
|
||||
customer_name_status: str | None = None,
|
||||
request_metadata: dict[str, Any] | None = None,
|
||||
operator_config: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
app = _app()
|
||||
normalized = str(transcript_text or "").strip()
|
||||
@@ -1895,6 +1932,25 @@ def _voice_decision(
|
||||
request_metadata=request_metadata,
|
||||
)
|
||||
|
||||
if persona.is_identity_request(normalized):
|
||||
decision = {
|
||||
"language": language,
|
||||
"intent": "identity_question",
|
||||
"reply_text": persona.identity_reply(language, operator_config),
|
||||
"confidence": 0.98,
|
||||
"needs_handoff": False,
|
||||
"handoff_reason": None,
|
||||
"case_action": "keep_open",
|
||||
"kb_refs": [],
|
||||
"summary_text": "AI ответил на вопрос о своей личности.",
|
||||
"model": "operator_identity_policy",
|
||||
"latency_ms": 1,
|
||||
}
|
||||
if v2_metadata:
|
||||
decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
|
||||
decision["metadata"] = {**v2_metadata, "reply_phase": "final"}
|
||||
return decision
|
||||
|
||||
if app._looks_like_human_request(lower_text) or app._is_sensitive_request(lower_text):
|
||||
return {
|
||||
"language": language,
|
||||
@@ -2003,6 +2059,7 @@ def _voice_decision(
|
||||
kb_results=kb_results,
|
||||
name_value=customer_name_value or (customer.display_name if customer else None),
|
||||
name_status=customer_name_status,
|
||||
operator_config=operator_config,
|
||||
)
|
||||
if llm_decision is not None:
|
||||
if v2_metadata:
|
||||
@@ -2065,6 +2122,7 @@ def _voice_decision(
|
||||
|
||||
|
||||
def start_voice_session(session_id: str, payload: VoiceAIStartIn) -> VoiceAIStartOut:
|
||||
app = _app()
|
||||
session = get_session()
|
||||
try:
|
||||
voice_session = session.execute(
|
||||
@@ -2085,6 +2143,7 @@ def start_voice_session(session_id: str, payload: VoiceAIStartIn) -> VoiceAIStar
|
||||
voice_session.voice_start_language or voice_session.language,
|
||||
)
|
||||
config = load_effective_voice_name_collection_config(session)
|
||||
operator_config = app._load_ai_operator_config(session)
|
||||
customer_id = payload.customer_id or _resolve_or_create_voice_customer_id(
|
||||
session,
|
||||
interaction=interaction,
|
||||
@@ -2289,7 +2348,7 @@ def start_voice_session(session_id: str, payload: VoiceAIStartIn) -> VoiceAIStar
|
||||
source=name_source,
|
||||
resolved_at=name_resolved_at,
|
||||
)
|
||||
greeting_text = _voice_greeting(language)
|
||||
greeting_text = _voice_greeting(language, operator_config)
|
||||
if name_status == "name_followup_required":
|
||||
if config.downstream.uncertain_name_behavior == "finalize_immediately" and name_value:
|
||||
finalized_name = _finalize_customer_name(
|
||||
@@ -2402,6 +2461,7 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe
|
||||
ai_session.customer_id = customer_id
|
||||
ai_session.language = str(payload.language or voice_session.language or "ru").strip() or "ru"
|
||||
config = load_effective_voice_name_collection_config(session)
|
||||
operator_config = app._load_ai_operator_config(session)
|
||||
|
||||
if not early_plan_only:
|
||||
_record_voice_ai_turn(
|
||||
@@ -2591,6 +2651,7 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe
|
||||
customer_name_value=effective_name_value,
|
||||
customer_name_status=effective_name_status,
|
||||
request_metadata=request_metadata,
|
||||
operator_config=operator_config,
|
||||
)
|
||||
decision["reply_text"] = _voice_postprocess_reply_text(
|
||||
language=decision["language"],
|
||||
@@ -2790,5 +2851,3 @@ def close_voice_session(session_id: str) -> dict[str, Any]:
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ from services.ai_voice_runtime_service.audiosocket import normalize_media_uuid
|
||||
from services.ai_voice_runtime_service.media_runtime import AudioSocketMediaRuntime, MediaRegistration
|
||||
from services.ai_voice_runtime_service.providers.asr import build_asr_provider, build_streaming_asr_provider
|
||||
from services.ai_voice_runtime_service.runtime_tts_provider import RuntimeConfiguredTTSProvider
|
||||
from services.ai_orchestrator_service import operator_persona as persona
|
||||
from services.shared.ai_operator_config import load_effective_ai_operator_config
|
||||
from services.shared.core import Role, new_id, utc_now_iso
|
||||
from services.shared.db import get_session
|
||||
from services.shared.models import (
|
||||
@@ -284,12 +286,10 @@ def _voice_start_name_prompt(language: str | None) -> str:
|
||||
return "Здравствуйте. Назовите, пожалуйста, ваше имя."
|
||||
|
||||
|
||||
def _default_voice_greeting(language: str | None, *, agent_profile: str = "voice_support") -> str:
|
||||
def _default_voice_greeting(language: str | None, *, agent_profile: str = "voice_support", operator_config: Any | None = None) -> str:
|
||||
if str(agent_profile or "").strip() == "voice_start":
|
||||
return _voice_start_name_prompt(language)
|
||||
if str(language or "").strip() == "kz":
|
||||
return "Сәлеметсіз бе. Сұрағыңызды айтыңызшы, көмектесуге тырысамын."
|
||||
return "Здравствуйте. Подскажите, пожалуйста, чем помочь."
|
||||
return persona.voice_greeting(str(language or "ru").strip() or "ru", operator_config)
|
||||
|
||||
|
||||
_ASR_PROVIDER = build_asr_provider(_asr_provider_name())
|
||||
@@ -1585,6 +1585,7 @@ def create_voice_ai_session(
|
||||
str(payload.agent_profile or "").strip() == "voice_start"
|
||||
or str(payload_metadata.get("stage") or "").strip() == "voice_start"
|
||||
)
|
||||
operator_config = load_effective_ai_operator_config(session)
|
||||
if voice_session is None:
|
||||
voice_session = VoiceAISessionRow(
|
||||
session_id=new_id("avs"),
|
||||
@@ -1636,7 +1637,11 @@ def create_voice_ai_session(
|
||||
should_start_async = stage_changed or not str(voice_session.ai_session_id or "").strip()
|
||||
voice_session.updated_at = now
|
||||
_apply_voice_start_metadata(voice_session, payload_metadata)
|
||||
greeting_text = _default_voice_greeting(language, agent_profile=payload.agent_profile)
|
||||
greeting_text = _default_voice_greeting(
|
||||
language,
|
||||
agent_profile=payload.agent_profile,
|
||||
operator_config=operator_config,
|
||||
)
|
||||
if not is_voice_start:
|
||||
if voice_session.ai_session_id is None or should_start_async:
|
||||
_queue_new_greeting_segment(session, voice_session, greeting_text)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from services.shared.core import utc_now_iso
|
||||
from services.shared.models import AIOperatorConfig, AIOperatorConfigOut
|
||||
from services.shared.sql_models import AIOperatorSettingsRow
|
||||
|
||||
|
||||
AI_OPERATOR_SETTINGS_KEY = "global"
|
||||
|
||||
|
||||
def ai_operator_default_config() -> AIOperatorConfig:
|
||||
return AIOperatorConfig(
|
||||
agent_name="Айнур",
|
||||
company_name="DigiOps",
|
||||
base_system_prompt=(
|
||||
"Ты Айнур, единый ИИ-оператор контакт-центра DigiOps для звонков, Telegram и других каналов. "
|
||||
"Всегда сохраняй одну и ту же личность: тебя зовут Айнур. Если клиент спрашивает, кто ты или как "
|
||||
"тебя зовут, отвечай, что ты Айнур. Отвечай естественно, кратко и по делу. Когда говоришь о себе, "
|
||||
"используй женский род: могла, смогла, сделала, готова, проверила, нашла. Не завершай диалог "
|
||||
"самостоятельно и говори «до свидания» только если клиент явно попрощался или попросил завершить "
|
||||
"разговор. Телефонные номера читай по цифрам. Не используй Markdown, URL или таблицы."
|
||||
),
|
||||
identity_reply_ru="Я Айнур, оператор контакт-центра DigiOps. Чем могу помочь?",
|
||||
identity_reply_kz="Мен Айнурмын, DigiOps байланыс орталығының операторымын. Қалай көмектесе аламын?",
|
||||
voice_greeting_ru="Здравствуйте. Я Айнур. Подскажите, пожалуйста, чем помочь.",
|
||||
voice_greeting_kz="Сәлеметсіз бе. Мен Айнурмын. Қалай көмектесе аламын?",
|
||||
)
|
||||
|
||||
|
||||
def _settings_row(session) -> AIOperatorSettingsRow | None:
|
||||
return session.execute(
|
||||
select(AIOperatorSettingsRow).where(AIOperatorSettingsRow.settings_key == AI_OPERATOR_SETTINGS_KEY)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _normalize_config_payload(raw: Any) -> AIOperatorConfig:
|
||||
if isinstance(raw, AIOperatorConfig):
|
||||
return raw
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("AI operator config payload must be an object")
|
||||
return AIOperatorConfig.model_validate(raw)
|
||||
|
||||
|
||||
def load_ai_operator_config(session) -> AIOperatorConfigOut:
|
||||
default_config = ai_operator_default_config()
|
||||
row = _settings_row(session)
|
||||
if row is None:
|
||||
return AIOperatorConfigOut(config=default_config, updated_at=None, source="defaults")
|
||||
try:
|
||||
parsed = json.loads(row.config_json or "{}")
|
||||
config = _normalize_config_payload(parsed)
|
||||
return AIOperatorConfigOut(config=config, updated_at=row.updated_at, source="database")
|
||||
except Exception:
|
||||
return AIOperatorConfigOut(config=default_config, updated_at=row.updated_at, source="defaults")
|
||||
|
||||
|
||||
def load_effective_ai_operator_config(session) -> AIOperatorConfig:
|
||||
return load_ai_operator_config(session).config
|
||||
|
||||
|
||||
def save_ai_operator_config(
|
||||
session,
|
||||
config_payload: AIOperatorConfig | dict[str, Any],
|
||||
) -> AIOperatorConfigOut:
|
||||
config = _normalize_config_payload(config_payload)
|
||||
row = _settings_row(session)
|
||||
now = utc_now_iso()
|
||||
if row is None:
|
||||
row = AIOperatorSettingsRow(
|
||||
settings_key=AI_OPERATOR_SETTINGS_KEY,
|
||||
config_json="{}",
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
row.config_json = json.dumps(config.model_dump(), ensure_ascii=False)
|
||||
row.updated_at = now
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return AIOperatorConfigOut(config=config, updated_at=row.updated_at, source="database")
|
||||
@@ -151,6 +151,39 @@ class VoiceTTSConfigOut(BaseModel):
|
||||
voice_options: dict[str, dict[str, list[VoiceTTSVoiceOption]]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AIOperatorConfig(BaseModel):
|
||||
agent_name: str = Field(default="Айнур", min_length=1)
|
||||
company_name: str = Field(default="DigiOps", min_length=1)
|
||||
base_system_prompt: str = Field(min_length=1)
|
||||
identity_reply_ru: str = Field(min_length=1)
|
||||
identity_reply_kz: str = Field(min_length=1)
|
||||
voice_greeting_ru: str = Field(min_length=1)
|
||||
voice_greeting_kz: str = Field(min_length=1)
|
||||
|
||||
@field_validator(
|
||||
"agent_name",
|
||||
"company_name",
|
||||
"base_system_prompt",
|
||||
"identity_reply_ru",
|
||||
"identity_reply_kz",
|
||||
"voice_greeting_ru",
|
||||
"voice_greeting_kz",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def _trim_required_text(cls, value: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("Text value is required")
|
||||
return normalized
|
||||
|
||||
|
||||
class AIOperatorConfigOut(BaseModel):
|
||||
config: AIOperatorConfig
|
||||
updated_at: str | None = None
|
||||
source: Literal["defaults", "database"] = "defaults"
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
service: str
|
||||
|
||||
@@ -157,6 +157,15 @@ class VoiceTTSSettingsRow(Base):
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class AIOperatorSettingsRow(Base):
|
||||
__tablename__ = "ai_operator_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
settings_key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
config_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class RoutingCounter(Base):
|
||||
__tablename__ = "routing_counters"
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ ai_module = importlib.import_module("services.ai_orchestrator_service.app")
|
||||
ai_app = ai_module.app
|
||||
voice_module = importlib.import_module("services.ai_orchestrator_service.voice")
|
||||
voice_config_module = importlib.import_module("services.ai_orchestrator_service.voice_name_config")
|
||||
ai_operator_config_module = importlib.import_module("services.shared.ai_operator_config")
|
||||
voice_tts_config_module = importlib.import_module("services.shared.voice_tts_config")
|
||||
from services.interaction_service.app import app as interaction_app
|
||||
from services.shared.core import new_id, utc_now_iso
|
||||
@@ -19,6 +20,7 @@ from services.shared.db import get_session
|
||||
from services.shared.models import VoiceAIStartIn, VoiceAITurnIn
|
||||
from services.shared.sql_models import (
|
||||
AIJobRow,
|
||||
AIOperatorSettingsRow,
|
||||
AISessionRow,
|
||||
AITurnRow,
|
||||
AsteriskCallLinkRow,
|
||||
@@ -90,6 +92,27 @@ def reset_voice_tts_settings():
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_ai_operator_settings():
|
||||
session = get_session()
|
||||
try:
|
||||
row = session.execute(select(AIOperatorSettingsRow)).scalar_one_or_none()
|
||||
if row is not None:
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
yield
|
||||
session = get_session()
|
||||
try:
|
||||
row = session.execute(select(AIOperatorSettingsRow)).scalar_one_or_none()
|
||||
if row is not None:
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _u(value: str) -> str:
|
||||
return value.encode("ascii").decode("unicode_escape")
|
||||
|
||||
@@ -2880,6 +2903,62 @@ def test_voice_tts_config_put_persists_custom_payload():
|
||||
session.close()
|
||||
|
||||
|
||||
def test_ai_operator_config_get_returns_ainur_defaults():
|
||||
ai_client = TestClient(ai_app)
|
||||
|
||||
response = ai_client.get("/ai/operator/config", headers=admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["source"] == "defaults"
|
||||
assert payload["updated_at"] is None
|
||||
assert payload["config"]["agent_name"] == "Айнур"
|
||||
assert "Айнур" in payload["config"]["base_system_prompt"]
|
||||
assert "Айнур" in payload["config"]["identity_reply_ru"]
|
||||
|
||||
|
||||
def test_ai_operator_config_put_persists_custom_prompt():
|
||||
ai_client = TestClient(ai_app)
|
||||
payload = ai_operator_config_module.ai_operator_default_config().model_dump()
|
||||
payload["company_name"] = "Kazakhtelecom"
|
||||
payload["base_system_prompt"] = "Ты {agent_name}, единый оператор {company_name}."
|
||||
payload["identity_reply_ru"] = "Я {agent_name}, оператор {company_name}."
|
||||
|
||||
response = ai_client.put("/ai/operator/config", headers=admin_headers(), json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
saved = response.json()
|
||||
assert saved["source"] == "database"
|
||||
assert saved["config"]["company_name"] == "Kazakhtelecom"
|
||||
assert saved["config"]["base_system_prompt"] == "Ты {agent_name}, единый оператор {company_name}."
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
row = session.execute(select(AIOperatorSettingsRow)).scalar_one()
|
||||
assert "Kazakhtelecom" in row.config_json
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_identity_question_uses_same_ainur_reply_without_handoff():
|
||||
operator_config = ai_operator_config_module.ai_operator_default_config()
|
||||
|
||||
decision = ai_module._decide_reply(
|
||||
customer=None,
|
||||
interaction=SimpleNamespace(),
|
||||
thread=SimpleNamespace(),
|
||||
messages=[SimpleNamespace(author_type="customer", text="Скажи мне, кто ты такой")],
|
||||
kb_results=[],
|
||||
language="ru",
|
||||
operator_config=operator_config,
|
||||
)
|
||||
|
||||
assert decision["intent"] == "identity_question"
|
||||
assert "Айнур" in decision["reply_text"]
|
||||
assert decision["needs_handoff"] is False
|
||||
assert decision["_model"] == "operator_identity_policy"
|
||||
|
||||
|
||||
def test_voice_start_disabled_hands_off_without_prompt():
|
||||
session = get_session()
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user