"""AI 角色 Prompt 配置服务。""" from __future__ import annotations import json import re from datetime import datetime, timezone from typing import Any, Optional from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..config.ai_prompts import build_default_role_prompt_bundle from ..models import Integration INTEGRATION_TYPE = "ai_prompt_profiles" ROLE_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,31}$") MAX_ROLES = 12 def _safe_json(value: Any) -> dict[str, Any]: if isinstance(value, dict): try: return json.loads(json.dumps(value)) except Exception: return dict(value) if isinstance(value, str): try: parsed = json.loads(value) if isinstance(parsed, dict): return parsed except Exception: return {} return {} def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() class PromptProfileService: """管理租户级 AI 角色 Prompt 配置。""" async def get_profiles(self, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]: integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) normalized = self._normalize_bundle(bundle) if normalized != bundle: integration.config = json.loads(json.dumps(normalized, ensure_ascii=False)) integration.is_active = True await db.commit() return normalized async def update_profiles( self, db: AsyncSession, *, tenant_id: int, roles: list[dict[str, Any]], active_role_key: Optional[str] = None, ) -> dict[str, Any]: if not roles: raise ValueError("至少需要保留一个角色") if len(roles) > MAX_ROLES: raise ValueError(f"角色数量不能超过 {MAX_ROLES} 个") dedup: dict[str, dict[str, Any]] = {} for idx, raw in enumerate(roles): key = str(raw.get("role_key") or "").strip().lower() if not key: raise ValueError(f"第 {idx + 1} 个角色缺少 role_key") if not ROLE_KEY_RE.match(key): raise ValueError(f"角色标识 {key} 格式不正确,仅支持小写字母/数字/_/-") if key in dedup: raise ValueError(f"角色标识重复: {key}") role_name = str(raw.get("role_name") or "").strip() system_prompt = str(raw.get("system_prompt") or "").strip() if not role_name: raise ValueError(f"角色 {key} 缺少角色名称") if len(role_name) > 32: raise ValueError(f"角色 {key} 名称过长(最多 32 字)") if len(system_prompt) < 20: raise ValueError(f"角色 {key} 的提示词过短(至少 20 字)") if len(system_prompt) > 8000: raise ValueError(f"角色 {key} 的提示词过长(最多 8000 字)") role = { "role_key": key, "role_name": role_name, "role_description": str(raw.get("role_description") or "").strip()[:300], "system_prompt": system_prompt, "enabled": bool(raw.get("enabled", True)), "updated_at": str(raw.get("updated_at") or _now_iso()), } dedup[key] = role ordered_roles = list(dedup.values()) enabled_keys = [str(r["role_key"]) for r in ordered_roles if bool(r.get("enabled"))] if not enabled_keys: raise ValueError("至少需要启用一个角色") requested_active = str(active_role_key or "").strip().lower() if requested_active and requested_active in enabled_keys: active = requested_active else: active = enabled_keys[0] bundle = { "version": 1, "active_role_key": active, "roles": ordered_roles, "updated_at": _now_iso(), } integration, _ = await self._get_or_init_bundle(db, tenant_id=tenant_id) integration.config = json.loads(json.dumps(bundle, ensure_ascii=False)) integration.is_active = True await db.commit() return self._normalize_bundle(bundle) async def get_active_role(self, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]: data = await self.get_profiles(db, tenant_id=tenant_id) active_key = str(data.get("active_role_key") or "") roles = data.get("roles", []) if not isinstance(roles, list): roles = [] active_role = next((r for r in roles if str(r.get("role_key")) == active_key), None) if active_role is None and roles: active_role = roles[0] return { "active_role_key": active_key, "role": active_role, } async def _get_or_init_bundle( self, db: AsyncSession, *, tenant_id: int, ) -> tuple[Integration, dict[str, Any]]: result = await db.execute( select(Integration).where( and_( Integration.tenant_id == int(tenant_id), Integration.type == INTEGRATION_TYPE, ) ) ) integration = result.scalar_one_or_none() if integration is None: bundle = self._normalize_bundle(build_default_role_prompt_bundle()) integration = Integration( tenant_id=int(tenant_id), type=INTEGRATION_TYPE, config=json.loads(json.dumps(bundle, ensure_ascii=False)), is_active=True, ) db.add(integration) await db.commit() return integration, bundle bundle = self._normalize_bundle(_safe_json(integration.config)) return integration, bundle def _normalize_bundle(self, raw: dict[str, Any]) -> dict[str, Any]: defaults = build_default_role_prompt_bundle() default_roles_raw = defaults.get("roles", []) default_roles: dict[str, dict[str, Any]] = {} if isinstance(default_roles_raw, list): for row in default_roles_raw: if not isinstance(row, dict): continue key = str(row.get("role_key") or "").strip().lower() if key: default_roles[key] = { "role_key": key, "role_name": str(row.get("role_name") or key)[:32], "role_description": str(row.get("role_description") or "")[:300], "system_prompt": str(row.get("system_prompt") or "").strip(), "enabled": bool(row.get("enabled", True)), "updated_at": str(row.get("updated_at") or _now_iso()), } incoming_roles = raw.get("roles") if isinstance(raw.get("roles"), list) else [] roles: list[dict[str, Any]] = [] seen: set[str] = set() for item in incoming_roles: if not isinstance(item, dict): continue key = str(item.get("role_key") or "").strip().lower() if not key or key in seen or not ROLE_KEY_RE.match(key): continue seen.add(key) fallback = default_roles.get(key, {}) prompt_text = str(item.get("system_prompt") or fallback.get("system_prompt") or "").strip() if len(prompt_text) < 20: prompt_text = str(fallback.get("system_prompt") or "").strip() if len(prompt_text) < 20: continue roles.append( { "role_key": key, "role_name": str(item.get("role_name") or fallback.get("role_name") or key).strip()[:32] or key, "role_description": str(item.get("role_description") or fallback.get("role_description") or "").strip()[:300], "system_prompt": prompt_text[:8000], "enabled": bool(item.get("enabled", fallback.get("enabled", True))), "updated_at": str(item.get("updated_at") or _now_iso()), } ) if len(roles) >= MAX_ROLES: break if not roles: roles = list(default_roles.values())[:MAX_ROLES] enabled_keys = [str(r["role_key"]) for r in roles if bool(r.get("enabled"))] if not enabled_keys and roles: roles[0]["enabled"] = True enabled_keys = [str(roles[0]["role_key"])] active_raw = str(raw.get("active_role_key") or "").strip().lower() active_role_key = active_raw if active_raw in enabled_keys else (enabled_keys[0] if enabled_keys else str(roles[0]["role_key"])) return { "version": 1, "active_role_key": active_role_key, "roles": roles, "updated_at": str(raw.get("updated_at") or _now_iso()), "tips": { "active_role_key": "当前生效角色,影响 AI 回复语气与任务分派风格。", "system_prompt": "建议写清角色职责、回复风格、输出边界;支持后续扩展多个角色。", "role_key": "角色标识用于系统路由,保存后建议保持稳定。", }, } prompt_profile_service = PromptProfileService()