PMFlow/backend/app/services/ai_routing_governance_servi...

587 lines
27 KiB
Python

"""AI routing and provider governance aggregation service."""
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import AICallLog, Integration
from ..services.model.model_config import (
build_routing_config_snapshot,
find_matching_rule,
get_provider_defaults,
get_tier_defaults,
load_model_config,
)
from ..utils.timezone import now_cn_naive
TEAM_POLICY_INTEGRATION_TYPE = "ai_team_policy"
LLM_ENGINE_INTEGRATION_TYPE = "llm_engine"
MODEL_ROUTING_INTEGRATION_TYPE = "model_routing"
PROVIDER_LABELS = {
"glm": "Qwen / DashScope",
"openai": "OpenAI 兼容网关",
"deepseek": "DeepSeek",
"ollama": "本地 Ollama",
}
SLOT_LABELS = {
"low": "快速槽",
"high": "深度槽",
}
PROTECTED_FUNCTIONS = {
"adaptive_rag_answer_deep",
"pmflow_launch_draft_v1",
"pmflow_requirement_plan_draft",
"pmflow_requirement_plan_round",
"project_requirement_extraction",
"generate_task_breakdown",
}
SCENE_FUNCTIONS = {
"project_launch": ["pmflow_launch_draft_v1", "project_from_text_analysis", "project_from_text_charter"],
"requirement_structuring": ["project_requirement_extraction", "pmflow_requirement_plan_draft", "pmflow_requirement_plan_round"],
"task_breakdown": ["generate_task_breakdown", "decompose_tasks_from_charter", "sstc_ai_breakdown"],
"assignee_matching": ["recommend_assignees_for_task"],
"internal_qa": ["adaptive_rag_answer_normal", "adaptive_rag_answer_deep", "rag_db_project_semantic_retrieval"],
"external_query": ["external_query_plan", "adaptive_rag_answer"],
"feishu_understanding": ["feishu_level_classification", "feishu_signal_extraction", "pmflow_semantic_context_decision"],
"feishu_reply": ["feishu_humanized_reply", "feishu_qa_direct_natural_reply"],
}
SCENE_LABELS = {
"project_launch": "项目启动草案",
"requirement_structuring": "需求结构化",
"task_breakdown": "任务拆解",
"assignee_matching": "人员匹配",
"internal_qa": "内部问答",
"external_query": "外部实时查询",
"feishu_understanding": "飞书理解",
"feishu_reply": "飞书回复",
}
DEFAULT_SCENE_POLICIES = {
"project_launch": "快速起草,失败后深度修复,结果必须进入确认",
"requirement_structuring": "深度优先,保护项目真相层入口",
"task_breakdown": "快速结构化,失败后深度修复,仍失败则标记待补充",
"assignee_matching": "深度分析,必须结合人员能力图谱",
"internal_qa": "先走受控内部查询,再按复杂度组织答案",
"external_query": "外部查询必须经过计划、来源校验和答案验证",
"feishu_understanding": "快速理解与分流,低置信度进入澄清",
"feishu_reply": "快速润色,不改变业务结论",
}
def _safe_dict(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)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
def _mask_secret(secret: str) -> str:
value = str(secret or "").strip()
if not value:
return ""
if len(value) <= 8:
return "*" * len(value)
return f"{value[:4]}...{value[-4:]}"
def _looks_placeholder(value: str) -> bool:
text = str(value or "").strip().lower()
return bool(text) and any(marker in text for marker in ("placeholder", "dummy", "test-key", "local-placeholder"))
def _normalize_tier(value: Any) -> str:
text = str(value or "").strip().lower()
return text if text in {"low", "high"} else ""
def _iso(value: Any) -> str | None:
if isinstance(value, datetime):
return value.isoformat()
text = str(value or "").strip()
return text or None
class AIRoutingGovernanceService:
def __init__(self, db: AsyncSession, *, tenant_id: int):
self.db = db
self.tenant_id = int(tenant_id)
async def get_overview(self) -> dict[str, Any]:
bundle = load_model_config()
engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE)
routing_bundle = await self._load_integration_config(MODEL_ROUTING_INTEGRATION_TYPE)
slots = self._build_slots(engine_bundle=engine_bundle, config_bundle=bundle)
metrics = await self._build_metrics()
diagnostics = await self._build_diagnostics(
engine_bundle=engine_bundle,
routing_bundle=routing_bundle,
slots=slots,
metrics=metrics,
)
snapshot = build_routing_config_snapshot(bundle=bundle)
return {
"config_version": snapshot.get("routing_config_version") or "",
"config_updated_at": snapshot.get("routing_config_updated_at") or "",
"source_of_truth": "PMFlow 本地路由层 baseline",
"slots": slots,
"metrics": metrics,
"diagnostics": diagnostics,
"latency_status": "暂未采集平均耗时",
}
async def get_admin_console(self) -> dict[str, Any]:
overview = await self.get_overview()
engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE)
routing_bundle = await self._load_integration_config(MODEL_ROUTING_INTEGRATION_TYPE)
config_bundle = load_model_config()
return {
"overview": overview,
"providers": self._build_providers(engine_bundle=engine_bundle),
"scene_routes": self._build_scene_routes(routing_bundle=routing_bundle, config_bundle=config_bundle),
"protected_functions": sorted(PROTECTED_FUNCTIONS),
"recent_failures": await self._recent_failures(limit=8),
"diagnostics": overview["diagnostics"],
"audit_hint": "策略变更会写入操作日志,底层模型默认值仍由 PMFlow 本地路由层 baseline 管理。",
}
async def run_diagnostics(self) -> dict[str, Any]:
overview = await self.get_overview()
return {
"checked_at": now_cn_naive().isoformat(),
"items": overview["diagnostics"],
"summary": self._diagnostic_summary(overview["diagnostics"]),
}
async def get_team_policy(self) -> dict[str, Any]:
saved = await self._load_integration_config(TEAM_POLICY_INTEGRATION_TYPE)
return self._merge_team_policy(saved)
async def update_team_policy(self, *, payload: dict[str, Any], updated_by: int) -> dict[str, Any]:
normalized = self._merge_team_policy(payload)
normalized["updated_by"] = int(updated_by)
normalized["updated_at"] = now_cn_naive().isoformat()
row = await self._get_or_init_integration(TEAM_POLICY_INTEGRATION_TYPE, default_config={})
row.config = json.loads(json.dumps(normalized, ensure_ascii=False))
row.is_active = True
await self.db.flush()
return normalized
async def _load_integration_config(self, integration_type: str) -> dict[str, Any]:
row = (
await self.db.execute(
select(Integration).where(
and_(
Integration.tenant_id == self.tenant_id,
Integration.type == integration_type,
)
)
)
).scalar_one_or_none()
return _safe_dict(row.config if row else {})
async def _get_or_init_integration(self, integration_type: str, *, default_config: dict[str, Any]) -> Integration:
row = (
await self.db.execute(
select(Integration).where(
and_(
Integration.tenant_id == self.tenant_id,
Integration.type == integration_type,
)
)
)
).scalar_one_or_none()
if row is None:
row = Integration(
tenant_id=self.tenant_id,
type=integration_type,
config=json.loads(json.dumps(default_config, ensure_ascii=False)),
is_active=True,
)
self.db.add(row)
await self.db.flush()
return row
def _merge_team_policy(self, saved: dict[str, Any]) -> dict[str, Any]:
strategy = str(saved.get("strategy") or "balanced").strip()
legacy_strategy_map = {"quality": "quality_first", "speed": "speed_first"}
strategy = legacy_strategy_map.get(strategy, strategy)
if strategy not in {"quality_first", "balanced", "speed_first"}:
strategy = "balanced"
saved_scenes = {
str(item.get("key") or ""): item
for item in saved.get("scenes", [])
if isinstance(item, dict) and str(item.get("key") or "") in SCENE_LABELS
}
scenes: list[dict[str, Any]] = []
for key, label in SCENE_LABELS.items():
item = saved_scenes.get(key, {})
scenes.append(
{
"key": key,
"label": label,
"policy": str(item.get("policy") or DEFAULT_SCENE_POLICIES[key]).strip(),
"mode": str(item.get("mode") or self._default_scene_mode(key)).strip(),
"human_confirmation": bool(item.get("human_confirmation", key in {"project_launch", "requirement_structuring", "task_breakdown"})),
"protected": bool(item.get("protected", key in {"project_launch", "requirement_structuring", "task_breakdown", "assignee_matching"})),
}
)
budget_guard = saved.get("budget_guard") if isinstance(saved.get("budget_guard"), dict) else {}
return {
"strategy": strategy,
"strategy_label": {"quality_first": "质量优先", "balanced": "成本均衡", "speed_first": "速度优先"}[strategy],
"scenes": scenes,
"automation_boundary": str(
saved.get("automation_boundary")
or "高风险写操作只生成待确认草案,禁止模型直接改生产状态。"
).strip(),
"budget_guard": {
"enabled": bool(budget_guard.get("enabled", False)),
"monthly_budget_cny": budget_guard.get("monthly_budget_cny"),
"alert_threshold_ratio": float(budget_guard.get("alert_threshold_ratio", 0.8) or 0.8),
},
"quality_targets": self._normalize_quality_targets(saved.get("quality_targets")),
"updated_at": str(saved.get("updated_at") or "") or None,
"updated_by": saved.get("updated_by"),
}
@staticmethod
def _normalize_quality_targets(value: Any) -> list[str]:
if isinstance(value, list):
items = [str(item).strip() for item in value if str(item or "").strip()]
return items[:8] if items else AIRoutingGovernanceService._default_quality_targets()
return AIRoutingGovernanceService._default_quality_targets()
@staticmethod
def _default_quality_targets() -> list[str]:
return [
"项目启动、需求结构化、任务拆解失败时必须显式进入待确认或失败态",
"内部问答必须保留来源合同,外部查询必须经过实时来源校验",
"保护场景不得被租户覆盖降级到快速槽",
]
@staticmethod
def _default_scene_mode(key: str) -> str:
if key in {"requirement_structuring", "assignee_matching"}:
return "深度优先"
if key == "external_query":
return "外部校验"
if key in {"feishu_understanding", "feishu_reply"}:
return "快速处理"
return "快速起草,失败升级"
def _build_slots(self, *, engine_bundle: dict[str, Any], config_bundle: dict[str, Any]) -> list[dict[str, Any]]:
engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)]
tier_defaults = config_bundle.get("tier_defaults") if isinstance(config_bundle.get("tier_defaults"), dict) else get_tier_defaults()
slots: list[dict[str, Any]] = []
for tier in ("low", "high"):
defaults = tier_defaults.get(tier) if isinstance(tier_defaults.get(tier), dict) else {}
provider = str(defaults.get("provider") or "").strip().lower()
model = str(defaults.get("model") or "").strip()
base_url = str(defaults.get("base_url") or "").strip()
matching_engine = next(
(item for item in engines if str(item.get("provider") or "").strip().lower() == provider),
None,
)
env_configured = bool(os.getenv(str(defaults.get("api_env") or "").strip(), "").strip()) if defaults.get("api_env") else False
source = "租户配置" if matching_engine else "环境变量" if env_configured else "未配置"
api_key_masked = _mask_secret(str((matching_engine or {}).get("api_key") or "")) if matching_engine else ""
slots.append(
{
"tier": tier,
"label": SLOT_LABELS[tier],
"provider": provider,
"provider_label": PROVIDER_LABELS.get(provider, provider or "未配置"),
"model": model,
"base_url": base_url,
"configured": bool(matching_engine or env_configured or provider == "ollama"),
"config_source": source,
"api_key_masked": api_key_masked,
}
)
return slots
def _build_providers(self, *, engine_bundle: dict[str, Any]) -> list[dict[str, Any]]:
active_id = str(engine_bundle.get("active_engine_id") or "")
engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)]
providers = []
for item in engines:
provider = str(item.get("provider") or "").strip().lower()
providers.append(
{
"id": str(item.get("id") or ""),
"name": str(item.get("name") or PROVIDER_LABELS.get(provider, provider)).strip(),
"provider": provider,
"provider_label": PROVIDER_LABELS.get(provider, provider),
"model": str(item.get("model") or ""),
"base_url": str(item.get("base_url") or ""),
"api_key_masked": _mask_secret(str(item.get("api_key") or "")),
"active": str(item.get("id") or "") == active_id,
"status_label": "已启用" if str(item.get("id") or "") == active_id else "已配置",
"updated_at": _iso(item.get("updated_at")),
}
)
return providers
def _build_scene_routes(self, *, routing_bundle: dict[str, Any], config_bundle: dict[str, Any]) -> list[dict[str, Any]]:
mappings = routing_bundle.get("mappings") if isinstance(routing_bundle.get("mappings"), dict) else {}
routes: list[dict[str, Any]] = []
for scene_key, functions in SCENE_FUNCTIONS.items():
function_rows: list[dict[str, Any]] = []
tiers: list[str] = []
for function_name in functions:
override = mappings.get(function_name) if isinstance(mappings.get(function_name), dict) else None
override_tier = _normalize_tier((override or {}).get("tier_alias") or (override or {}).get("tier"))
rule = find_matching_rule(function_name, tier_filter={override_tier} if override_tier else None, bundle=config_bundle)
tier = override_tier or _normalize_tier((rule or {}).get("tier")) or "low"
tiers.append(tier)
function_rows.append(
{
"function_name": function_name,
"display_name": self._function_display_name(function_name),
"tier": tier,
"slot_label": SLOT_LABELS.get(tier, tier),
"source": "override" if override_tier else "default",
"rule_name": str((rule or {}).get("name") or "") or None,
}
)
primary_tier = "mixed" if len(set(tiers)) > 1 else (tiers[0] if tiers else "low")
routes.append(
{
"scene_key": scene_key,
"label": SCENE_LABELS[scene_key],
"summary": DEFAULT_SCENE_POLICIES[scene_key],
"primary_slot_label": "混合策略" if primary_tier == "mixed" else SLOT_LABELS.get(primary_tier, "快速槽"),
"functions": function_rows,
}
)
return routes
async def _build_metrics(self) -> dict[str, Any]:
return {
"last_24h": await self._window_metrics(hours=24),
"last_7d": await self._window_metrics(hours=24 * 7),
}
async def _window_metrics(self, *, hours: int) -> dict[str, Any]:
since = now_cn_naive() - timedelta(hours=hours)
rows = (
await self.db.execute(
select(AICallLog).where(
and_(
AICallLog.tenant_id == self.tenant_id,
AICallLog.created_at >= since,
)
)
)
).scalars().all()
total = len(rows)
failed = sum(1 for row in rows if self._is_failed_status(str(row.status or "")))
upgraded = sum(
1
for row in rows
if str(row.planned_tier or "").strip()
and str(row.executed_tier or "").strip()
and str(row.planned_tier or "").strip() != str(row.executed_tier or "").strip()
)
structured_failed = sum(1 for row in rows if self._is_structured_failure(str(row.status or "")))
total_cost = sum(float(row.cost or Decimal("0")) for row in rows)
return {
"total_calls": total,
"failed_calls": failed,
"failure_rate": round(failed / total, 4) if total else 0.0,
"upgraded_calls": upgraded,
"upgrade_rate": round(upgraded / total, 4) if total else 0.0,
"structured_failed_calls": structured_failed,
"estimated_cost": round(total_cost, 6),
"average_latency_ms": None,
}
async def _build_diagnostics(
self,
*,
engine_bundle: dict[str, Any],
routing_bundle: dict[str, Any],
slots: list[dict[str, Any]],
metrics: dict[str, Any],
) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
items.extend(self._env_override_diagnostics(engine_bundle=engine_bundle))
if any(not slot.get("configured") for slot in slots if slot.get("tier") == "high"):
items.append(self._risk("high_unavailable", "critical", "深度槽未配置可用供应商", "项目启动、需求结构化和复杂问答可能显式失败。"))
if len(slots) == 2 and self._same_slot_runtime(slots[0], slots[1]):
items.append(self._risk("same_slot_runtime", "warning", "快速槽和深度槽指向同一运行时", "低阶失败后升级无法获得更强模型能力。"))
items.extend(self._protected_downgrade_diagnostics(routing_bundle=routing_bundle))
last_24h = metrics.get("last_24h") if isinstance(metrics.get("last_24h"), dict) else {}
if int(last_24h.get("total_calls") or 0) >= 3 and float(last_24h.get("failure_rate") or 0) >= 0.3:
items.append(self._risk("high_failure_rate", "warning", "最近 24 小时 AI 调用失败率偏高", "建议先检查供应商 Key、额度、结构化输出和高阶槽位。"))
if int(last_24h.get("structured_failed_calls") or 0) >= 3:
items.append(self._risk("structured_failure_rate", "warning", "结构化输出失败偏多", "项目启动、任务拆解和语义事实抽取可能进入待补充或失败状态。"))
if self._ollama_runtime_risk(engine_bundle=engine_bundle, slots=slots):
items.append(self._risk("ollama_scope_risk", "warning", "本地 Ollama 只能用于知识库问答", "若被绑定到项目创建或任务拆解,会被运行边界拦截。"))
if not items:
items.append({"code": "ok", "severity": "ok", "title": "未发现明显配置风险", "message": "当前供应商、槽位和路由配置未触发内置风险规则。"})
return items
def _env_override_diagnostics(self, *, engine_bundle: dict[str, Any]) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
defaults = get_provider_defaults()
engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)]
for engine in engines:
provider = str(engine.get("provider") or "").strip().lower()
provider_defaults = defaults.get(provider) if isinstance(defaults.get(provider), dict) else {}
api_env = str(provider_defaults.get("api_env") or "").strip()
if not api_env:
continue
env_value = str(os.getenv(api_env, "") or "").strip()
db_value = str(engine.get("api_key") or "").strip()
if not env_value or not db_value or env_value == db_value:
continue
severity = "critical" if _looks_placeholder(env_value) else "warning"
items.append(
self._risk(
"env_key_override",
severity,
f"{PROVIDER_LABELS.get(provider, provider)} 环境变量正在覆盖租户密钥",
"运行时会优先使用环境变量中的密钥;若该值是占位符,会导致供应商请求失败。",
)
)
return items
def _protected_downgrade_diagnostics(self, *, routing_bundle: dict[str, Any]) -> list[dict[str, Any]]:
mappings = routing_bundle.get("mappings") if isinstance(routing_bundle.get("mappings"), dict) else {}
downgraded = []
for function_name in PROTECTED_FUNCTIONS:
override = mappings.get(function_name) if isinstance(mappings.get(function_name), dict) else None
tier = _normalize_tier((override or {}).get("tier_alias") or (override or {}).get("tier"))
if tier == "low":
downgraded.append(function_name)
if not downgraded:
return []
return [
self._risk(
"protected_function_downgraded",
"critical",
"保护场景被覆盖到快速槽",
f"涉及 {len(downgraded)} 个保护函数;这些场景应保持深度或 low-first 高阶修复。",
)
]
@staticmethod
def _risk(code: str, severity: str, title: str, message: str) -> dict[str, Any]:
return {"code": code, "severity": severity, "title": title, "message": message}
@staticmethod
def _same_slot_runtime(a: dict[str, Any], b: dict[str, Any]) -> bool:
return (
str(a.get("provider") or "").strip().lower() == str(b.get("provider") or "").strip().lower()
and str(a.get("model") or "").strip().lower() == str(b.get("model") or "").strip().lower()
and str(a.get("base_url") or "").strip().rstrip("/").lower()
== str(b.get("base_url") or "").strip().rstrip("/").lower()
)
@staticmethod
def _ollama_runtime_risk(*, engine_bundle: dict[str, Any], slots: list[dict[str, Any]]) -> bool:
settings = engine_bundle.get("settings") if isinstance(engine_bundle.get("settings"), dict) else {}
if bool(settings.get("enable_local_kb_qa")):
return False
return any(str(slot.get("provider") or "").strip().lower() == "ollama" for slot in slots)
@staticmethod
def _is_failed_status(status: str) -> bool:
normalized = str(status or "").strip().lower()
if not normalized:
return True
return not (
normalized.startswith("success")
or normalized in {"ok", "success_cache_hit", "success_failover"}
)
@staticmethod
def _is_structured_failure(status: str) -> bool:
normalized = str(status or "").strip().lower()
return "structured" in normalized and any(marker in normalized for marker in ("error", "empty", "invalid", "failed"))
async def _recent_failures(self, *, limit: int) -> list[dict[str, Any]]:
rows = (
await self.db.execute(
select(AICallLog)
.where(AICallLog.tenant_id == self.tenant_id)
.order_by(AICallLog.created_at.desc(), AICallLog.id.desc())
.limit(80)
)
).scalars().all()
failures: list[dict[str, Any]] = []
for row in rows:
if not self._is_failed_status(str(row.status or "")):
continue
failures.append(
{
"function_label": self._function_display_name(str(row.function_name or "")),
"function_name": str(row.function_name or ""),
"status_label": self._status_label(str(row.status or "")),
"planned_slot": SLOT_LABELS.get(str(row.planned_tier or "").strip().lower(), str(row.planned_tier or "") or "-"),
"executed_slot": SLOT_LABELS.get(str(row.executed_tier or "").strip().lower(), str(row.executed_tier or "") or "-"),
"created_at": _iso(row.created_at),
}
)
if len(failures) >= limit:
break
return failures
@staticmethod
def _function_display_name(function_name: str) -> str:
labels = {
"pmflow_launch_draft_v1": "项目启动草案",
"pmflow_semantic_context_decision": "语义上下文决策",
"adaptive_rag_answer_deep": "深度问答生成",
"adaptive_rag_answer_normal": "常规问答生成",
"generate_task_breakdown": "任务拆解",
"rag_db_project_semantic_retrieval": "项目语义检索",
"feishu_humanized_reply": "飞书回复润色",
"signal_planner_analyze": "信号规划分析",
}
return labels.get(str(function_name or "").strip(), str(function_name or "").strip() or "未知调用")
@staticmethod
def _status_label(status: str) -> str:
normalized = str(status or "").strip().lower()
if "http" in normalized:
return "供应商请求失败"
if "empty" in normalized:
return "模型返回为空"
if "invalid" in normalized:
return "结构化结果不合法"
if "cache" in normalized:
return "缓存命中"
if normalized.startswith("success"):
return "成功"
return "调用失败"
@staticmethod
def _diagnostic_summary(items: list[dict[str, Any]]) -> dict[str, int]:
return {
"critical": sum(1 for item in items if item.get("severity") == "critical"),
"warning": sum(1 for item in items if item.get("severity") == "warning"),
"ok": sum(1 for item in items if item.get("severity") == "ok"),
}