diff --git a/gateway/app.py b/gateway/app.py index 241d674..4c4cfcc 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -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/*", diff --git a/migrations/sql/0030_ai_operator_config_postgres.sql b/migrations/sql/0030_ai_operator_config_postgres.sql new file mode 100644 index 0000000..e4fdd78 --- /dev/null +++ b/migrations/sql/0030_ai_operator_config_postgres.sql @@ -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); diff --git a/migrations/sql/0030_ai_operator_config_sqlite.sql b/migrations/sql/0030_ai_operator_config_sqlite.sql new file mode 100644 index 0000000..ce6aa52 --- /dev/null +++ b/migrations/sql/0030_ai_operator_config_sqlite.sql @@ -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); diff --git a/services/ai_orchestrator_service/app.py b/services/ai_orchestrator_service/app.py index 55a8c0b..3fc1e2b 100644 --- a/services/ai_orchestrator_service/app.py +++ b/services/ai_orchestrator_service/app.py @@ -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() - - - - diff --git a/services/ai_orchestrator_service/operator_persona.py b/services/ai_orchestrator_service/operator_persona.py index 720d8ef..91acfcc 100644 --- a/services/ai_orchestrator_service/operator_persona.py +++ b/services/ai_orchestrator_service/operator_persona.py @@ -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. " diff --git a/services/ai_orchestrator_service/voice.py b/services/ai_orchestrator_service/voice.py index 0a1fc32..527373e 100644 --- a/services/ai_orchestrator_service/voice.py +++ b/services/ai_orchestrator_service/voice.py @@ -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() - - diff --git a/services/ai_voice_runtime_service/app.py b/services/ai_voice_runtime_service/app.py index 446e36b..b9188d3 100644 --- a/services/ai_voice_runtime_service/app.py +++ b/services/ai_voice_runtime_service/app.py @@ -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) diff --git a/services/shared/ai_operator_config.py b/services/shared/ai_operator_config.py new file mode 100644 index 0000000..aa23787 --- /dev/null +++ b/services/shared/ai_operator_config.py @@ -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") diff --git a/services/shared/models.py b/services/shared/models.py index b6739f8..2e7be51 100644 --- a/services/shared/models.py +++ b/services/shared/models.py @@ -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 diff --git a/services/shared/sql_models.py b/services/shared/sql_models.py index fe35b12..b7d8477 100644 --- a/services/shared/sql_models.py +++ b/services/shared/sql_models.py @@ -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" diff --git a/tests/test_ai_orchestrator_service.py b/tests/test_ai_orchestrator_service.py index 5011133..70dbb36 100644 --- a/tests/test_ai_orchestrator_service.py +++ b/tests/test_ai_orchestrator_service.py @@ -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: