Add configurable AI operator persona
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user