"""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"), }