PMFlow/backend/app/services/prompt_profile_service.py

243 lines
9.3 KiB
Python

"""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
from ..utils.timezone import now_cn_aware
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 now_cn_aware().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()