Add configurable AI operator persona

This commit is contained in:
Magzhan Zhumabayev
2026-05-11 17:37:26 +05:00
parent 67a0255c0d
commit ce3ba270aa
11 changed files with 473 additions and 19 deletions
+1
View File
@@ -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);
+74 -4
View File
@@ -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. "
+64 -5
View File
@@ -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()
+10 -5
View File
@@ -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)
+84
View File
@@ -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")
+33
View File
@@ -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
+9
View File
@@ -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"
+79
View File
@@ -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: