3484 lines
173 KiB
Python
3484 lines
173 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.ai_function_runtime_profile_service import AIFunctionRuntimeProfileService
|
||
from ..services.external_query_service.registry import ExternalProviderRegistry
|
||
from ..services.ai_routing_scene_contracts import AI_SCENE_REGISTRY, scene_contract
|
||
from ..services.model.model_config import (
|
||
build_routing_config_snapshot,
|
||
find_matching_rule,
|
||
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"
|
||
CAPABILITY_SLOT_INTEGRATION_TYPE = "ai_capability_slots"
|
||
PLATFORM_RUNTIME_TENANT_ID = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||
CAPABILITY_PROBE_EVIDENCE_TTL_DAYS = int(os.getenv("AI_CAPABILITY_PROBE_EVIDENCE_TTL_DAYS", "90") or "90")
|
||
CAPABILITY_PROBE_KEYS = (
|
||
"basic_chat",
|
||
"model_self_report",
|
||
"structured_output",
|
||
"json_schema",
|
||
"tool_calling",
|
||
"streaming",
|
||
"long_context_32k",
|
||
"long_context_128k",
|
||
"vision_input",
|
||
"audio_input",
|
||
"private_deployment",
|
||
"external_search_ready",
|
||
"long_context",
|
||
)
|
||
CAPABILITY_PROBE_STATUSES = {"untested", "running", "passed", "failed", "partial"}
|
||
CAPABILITY_EVIDENCE_STATUSES = {"verified", "failed", "skipped", "not_probeable", "declared_only"}
|
||
LIVE_EVIDENCE_STRENGTHS = {"live_probe", "smoke_probe", "provider_contract", "operator_verified"}
|
||
DECLARED_EVIDENCE_STRENGTHS = {"declared_only", "manual", "inferred", "model_self_report"}
|
||
DECLARED_PROBE_REQUIREMENT_FLAGS = {
|
||
"structured_output": "structured_output",
|
||
"json_schema": "json_schema",
|
||
"tool_calling": "tool_calling",
|
||
"long_context": "long_context",
|
||
"vision_input": "vision",
|
||
"audio_input": "audio",
|
||
"private_deployment": "private_deployment",
|
||
"external_search_ready": "external_search_ready",
|
||
}
|
||
CAPABILITY_DECLARED_FIELDS_META = "capability_declared_fields"
|
||
PROVIDER_MODEL_CONTEXT_CONTRACTS = {
|
||
("dashscope", "qwen-plus"): {
|
||
"context_tokens": 1_000_000,
|
||
"max_input_tokens": 997_952,
|
||
"source": "aliyun_model_studio_official_spec",
|
||
"label": "阿里云百炼 Qwen-Plus 官方模型规格",
|
||
},
|
||
("zhipu", "glm-5.1"): {
|
||
"context_tokens": 200_000,
|
||
"max_input_tokens": 200_000,
|
||
"max_output_tokens": 128_000,
|
||
"source": "zhipu_bigmodel_official_spec",
|
||
"label": "智谱 BigModel GLM-5.1 官方模型规格",
|
||
},
|
||
}
|
||
|
||
|
||
def _provider_model_context_contract(provider: Any, model: Any) -> dict[str, Any] | None:
|
||
normalized_provider = str(provider or "").strip().lower()
|
||
normalized_model = str(model or "").strip().lower()
|
||
if not normalized_provider or not normalized_model:
|
||
return None
|
||
exact = PROVIDER_MODEL_CONTEXT_CONTRACTS.get((normalized_provider, normalized_model))
|
||
if exact:
|
||
return dict(exact)
|
||
if normalized_provider == "dashscope" and normalized_model.startswith("qwen-plus-"):
|
||
return dict(PROVIDER_MODEL_CONTEXT_CONTRACTS[("dashscope", "qwen-plus")])
|
||
return None
|
||
|
||
|
||
def _explicit_declared_capability_fields(source: dict[str, Any]) -> set[str]:
|
||
raw_fields = source.get(CAPABILITY_DECLARED_FIELDS_META)
|
||
allowed = set(DECLARED_PROBE_REQUIREMENT_FLAGS.values())
|
||
if isinstance(raw_fields, list):
|
||
return {str(item) for item in raw_fields if str(item) in allowed}
|
||
return {field for field in allowed if field in source}
|
||
|
||
|
||
def _probe_status_declared_scope(declared: dict[str, Any], explicit_fields: set[str]) -> dict[str, Any]:
|
||
if not explicit_fields:
|
||
return {}
|
||
scoped = dict(declared)
|
||
for declared_flag in DECLARED_PROBE_REQUIREMENT_FLAGS.values():
|
||
if declared_flag not in explicit_fields:
|
||
scoped[declared_flag] = False
|
||
return scoped
|
||
|
||
PROVIDER_LABELS = {
|
||
"dashscope": "Qwen / DashScope",
|
||
"openai": "OpenAI 兼容网关",
|
||
"zhipu": "智谱 BigModel",
|
||
"deepseek": "DeepSeek",
|
||
"ollama": "本地 Ollama",
|
||
}
|
||
|
||
SLOT_LABELS = {
|
||
"low": "快速槽",
|
||
"high": "深度槽",
|
||
"structured": "结构化槽",
|
||
"long_context": "长上下文槽",
|
||
"tool": "工具槽",
|
||
"external_query": "外部查询槽",
|
||
}
|
||
OBSERVABLE_RUNTIME_SLOTS = ("low", "high", "structured", "long_context", "tool", "external_query")
|
||
SLOT_RECOMMENDATION_FAILURE_RATE_THRESHOLD = float(os.getenv("AI_SLOT_RECOMMENDATION_FAILURE_RATE_THRESHOLD", "0.3") or "0.3")
|
||
SLOT_RECOMMENDATION_FALLBACK_RATE_THRESHOLD = float(os.getenv("AI_SLOT_RECOMMENDATION_FALLBACK_RATE_THRESHOLD", "0.3") or "0.3")
|
||
SLOT_RECOMMENDATION_DEGRADED_RATE_THRESHOLD = float(os.getenv("AI_SLOT_RECOMMENDATION_DEGRADED_RATE_THRESHOLD", "0.2") or "0.2")
|
||
SLOT_RECOMMENDATION_MIN_CALLS = int(os.getenv("AI_SLOT_RECOMMENDATION_MIN_CALLS", "3") or "3")
|
||
LOW_SLOT_LATENCY_WARNING_MS = int(os.getenv("AI_LOW_SLOT_LATENCY_WARNING_MS", "3000") or "3000")
|
||
LOW_SLOT_COST_WARNING = float(os.getenv("AI_LOW_SLOT_COST_WARNING", "1.0") or "1.0")
|
||
|
||
SLOT_SPECS = {
|
||
"low": {
|
||
"label": "快速槽",
|
||
"summary": "轻量理解、润色、低风险草案与快速分流;失败后可升级到深度槽。",
|
||
"configurable": True,
|
||
"protected": False,
|
||
"default_failure_policy": "upgrade_to_high",
|
||
},
|
||
"high": {
|
||
"label": "深度槽",
|
||
"summary": "结构化、复杂推理、人员匹配和保护场景;默认不允许客户空间策略降级。",
|
||
"configurable": True,
|
||
"protected": True,
|
||
"default_failure_policy": "explicit_fail",
|
||
},
|
||
"structured": {
|
||
"label": "结构化槽",
|
||
"summary": "面向 JSON/DSL/抽取类输出,发布后由结构化生成 runtime 消费。",
|
||
"configurable": True,
|
||
"protected": True,
|
||
"default_failure_policy": "explicit_fail",
|
||
},
|
||
"long_context": {
|
||
"label": "长上下文槽",
|
||
"summary": "面向长文档、长线程和跨项目上下文,发布后仅由显式长上下文调用消费。",
|
||
"configurable": True,
|
||
"protected": True,
|
||
"default_failure_policy": "try_fallback_then_fail",
|
||
},
|
||
"tool": {
|
||
"label": "工具槽",
|
||
"summary": "面向工具调用和 MCP 执行编排,发布后仅由显式工具规划调用消费。",
|
||
"configurable": True,
|
||
"protected": True,
|
||
"default_failure_policy": "explicit_fail",
|
||
},
|
||
"external_query": {
|
||
"label": "外部查询槽",
|
||
"summary": "正式外部查询 runtime 槽;绑定 external provider registry / MCP adapter / bounded live search fallback 的受控执行链,只在 route_mode=external 后消费。",
|
||
"configurable": True,
|
||
"protected": True,
|
||
"default_failure_policy": "explicit_fail",
|
||
"runtime_binding": "external_provider_registry",
|
||
},
|
||
"private": {
|
||
"label": "私有槽",
|
||
"summary": "候选槽,不接 runtime;面向私有部署或客户隔离运行时,等待租户隔离和合规门禁接入。",
|
||
"configurable": False,
|
||
"protected": True,
|
||
"default_failure_policy": "explicit_fail",
|
||
},
|
||
"multimodal": {
|
||
"label": "多模态槽",
|
||
"summary": "候选槽,不接 runtime;面向图片、语音、文件理解,等待多模态能力矩阵接入。",
|
||
"configurable": False,
|
||
"protected": True,
|
||
"default_failure_policy": "try_fallback_then_fail",
|
||
},
|
||
}
|
||
|
||
CAPABILITY_LABELS = {
|
||
"structured_output": "结构化输出",
|
||
"json_schema": "JSON Schema",
|
||
"tool_calling": "工具调用",
|
||
"long_context": "长上下文",
|
||
"vision": "视觉理解",
|
||
"audio": "音频理解",
|
||
"local_only": "本地运行",
|
||
"private_deployment": "私有部署",
|
||
"external_search_ready": "外部查询链路",
|
||
}
|
||
|
||
SLOT_REQUIREMENTS = {
|
||
"low": {
|
||
"summary": "低成本、低延迟、可用连接;不要求强结构化能力。",
|
||
"required_flags": [],
|
||
"forbidden_flags": [],
|
||
"min_context_tokens": None,
|
||
"any_flags": [],
|
||
},
|
||
"high": {
|
||
"summary": "复杂推理和保护场景;禁止仅本地运行时。",
|
||
"required_flags": [],
|
||
"forbidden_flags": ["local_only"],
|
||
"min_context_tokens": None,
|
||
"any_flags": [],
|
||
},
|
||
"structured": {
|
||
"summary": "必须同时具备结构化输出和 JSON Schema 能力。",
|
||
"required_flags": ["structured_output", "json_schema"],
|
||
"forbidden_flags": [],
|
||
"min_context_tokens": None,
|
||
"any_flags": [],
|
||
},
|
||
"long_context": {
|
||
"summary": "必须支持至少 32K 上下文窗口。",
|
||
"required_flags": ["long_context"],
|
||
"forbidden_flags": [],
|
||
"min_context_tokens": 32768,
|
||
"any_flags": [],
|
||
},
|
||
"tool": {
|
||
"summary": "必须支持工具调用或 function calling。",
|
||
"required_flags": ["tool_calling"],
|
||
"forbidden_flags": [],
|
||
"min_context_tokens": None,
|
||
"any_flags": [],
|
||
},
|
||
"external_query": {
|
||
"summary": "必须接入外部查询 provider registry 或等价实时检索适配器。",
|
||
"required_flags": ["external_search_ready"],
|
||
"forbidden_flags": [],
|
||
"min_context_tokens": None,
|
||
"any_flags": [],
|
||
},
|
||
"private": {
|
||
"summary": "必须满足私有部署或本地隔离运行要求。",
|
||
"required_flags": ["private_deployment"],
|
||
"forbidden_flags": [],
|
||
"min_context_tokens": None,
|
||
"any_flags": [],
|
||
},
|
||
"multimodal": {
|
||
"summary": "必须至少具备视觉或音频一种多模态理解能力。",
|
||
"required_flags": [],
|
||
"forbidden_flags": [],
|
||
"min_context_tokens": None,
|
||
"any_flags": ["vision", "audio"],
|
||
},
|
||
}
|
||
|
||
FAILURE_POLICY_LABELS = {
|
||
"upgrade_to_high": "失败后升级深度槽",
|
||
"try_fallback_then_fail": "先走备用链路,仍失败则显式失败",
|
||
"explicit_fail": "显式失败,不静默降级",
|
||
}
|
||
|
||
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 = {
|
||
scene_key: list(scene.get("function_names", []))
|
||
for scene_key, scene in AI_SCENE_REGISTRY.items()
|
||
}
|
||
|
||
SCENE_LABELS = {
|
||
scene_key: str(scene.get("label") or scene_key)
|
||
for scene_key, scene in AI_SCENE_REGISTRY.items()
|
||
}
|
||
|
||
SCENE_SLOT_REQUIREMENTS = {
|
||
scene_key: list(scene.get("required_slots", []))
|
||
for scene_key, scene in AI_SCENE_REGISTRY.items()
|
||
}
|
||
|
||
SCENE_DEFAULT_SLOT = {
|
||
scene_key: str(scene.get("default_slot") or "")
|
||
for scene_key, scene in AI_SCENE_REGISTRY.items()
|
||
}
|
||
|
||
PROTECTED_SCENE_KEYS = {
|
||
scene_key
|
||
for scene_key, scene in AI_SCENE_REGISTRY.items()
|
||
if bool(scene.get("protected")) and scene.get("lifecycle") == "active"
|
||
}
|
||
|
||
SCENE_FUNCTION_SLOT_ALLOWLIST = {
|
||
scene_key: set(scene.get("allowed_slots", []))
|
||
for scene_key, scene in AI_SCENE_REGISTRY.items()
|
||
if scene.get("allowed_slots")
|
||
}
|
||
|
||
DEFAULT_SCENE_POLICIES = {
|
||
scene_key: str(scene.get("summary") or "")
|
||
for scene_key, scene in AI_SCENE_REGISTRY.items()
|
||
}
|
||
|
||
|
||
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 _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
|
||
|
||
|
||
def _json_clone(value: Any) -> Any:
|
||
try:
|
||
return json.loads(json.dumps(value, ensure_ascii=False))
|
||
except Exception:
|
||
return value
|
||
|
||
|
||
def _release_version() -> str:
|
||
return f"slot-v{now_cn_naive().strftime('%Y%m%d%H%M%S')}"
|
||
|
||
|
||
def _empty_capability_probe(source: str = "inferred") -> dict[str, Any]:
|
||
normalized_source = str(source or "inferred").strip().lower()
|
||
if normalized_source not in {"inferred", "manual", "probed"}:
|
||
normalized_source = "inferred"
|
||
return {
|
||
"status": "untested",
|
||
"last_verified_at": None,
|
||
"verified_capabilities": {key: False for key in CAPABILITY_PROBE_KEYS},
|
||
"evidence": {},
|
||
"source": normalized_source,
|
||
}
|
||
|
||
|
||
def _evidence_strength_from_row(row: dict[str, Any], *, key: str) -> str:
|
||
strength = str(row.get("evidence_strength") or "").strip().lower()
|
||
if strength:
|
||
return strength
|
||
source = str(row.get("source") or "").strip().lower()
|
||
status = str(row.get("status") or "").strip().lower()
|
||
message = str(row.get("message") or row.get("summary") or "").strip()
|
||
if source in {"manual", "default", "inferred"}:
|
||
return source
|
||
if key in {"long_context", "long_context_32k", "long_context_128k"} and ("声明" in message or "declared" in message.lower()):
|
||
return "declared_only"
|
||
if status == "verified" and source in {"probe", "probed"}:
|
||
return "live_probe"
|
||
return "unknown"
|
||
|
||
|
||
def _probe_row_is_verified(row: dict[str, Any], *, key: str) -> bool:
|
||
if not isinstance(row, dict):
|
||
return False
|
||
status = str(row.get("status") or "").strip().lower()
|
||
ok = row.get("ok")
|
||
if ok is not True and status != "verified":
|
||
return False
|
||
return _evidence_strength_from_row(row, key=key) in LIVE_EVIDENCE_STRENGTHS
|
||
|
||
|
||
def _derive_effective_capabilities(*, declared: dict[str, Any], probe: dict[str, Any]) -> dict[str, bool]:
|
||
evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {}
|
||
|
||
def _row_for(key: str) -> dict[str, Any] | None:
|
||
row = evidence.get(key)
|
||
if isinstance(row, dict):
|
||
return row
|
||
flat = declared.get("capability_evidence") if isinstance(declared.get("capability_evidence"), dict) else {}
|
||
flat_row = flat.get(key)
|
||
return flat_row if isinstance(flat_row, dict) else None
|
||
|
||
def _verified(key: str) -> bool:
|
||
row = _row_for(key)
|
||
if isinstance(row, dict) and _probe_row_is_verified(row, key=key):
|
||
return True
|
||
return False
|
||
|
||
def _verified_min_tokens(key: str, min_tokens: int) -> bool:
|
||
row = _row_for(key)
|
||
if not isinstance(row, dict) or not _probe_row_is_verified(row, key=key):
|
||
return False
|
||
try:
|
||
tested_tokens = int(row.get("tested_tokens") or row.get("max_context_tokens") or 0)
|
||
except (TypeError, ValueError):
|
||
tested_tokens = 0
|
||
return tested_tokens >= min_tokens
|
||
|
||
long_context_verified = _verified("long_context") or _verified("long_context_32k") or _verified("long_context_128k")
|
||
long_32k_verified = _verified_min_tokens("long_context", 32768) or _verified_min_tokens("long_context_32k", 32768)
|
||
long_128k_verified = _verified_min_tokens("long_context", 128000) or _verified_min_tokens("long_context_128k", 128000)
|
||
return {
|
||
"basic_chat": _verified("basic_chat"),
|
||
"structured_output": _verified("structured_output"),
|
||
"json_schema": _verified("json_schema"),
|
||
"tool_calling": _verified("tool_calling"),
|
||
"streaming": _verified("streaming"),
|
||
"long_context": long_context_verified,
|
||
"long_context_32k": long_32k_verified,
|
||
"long_context_128k": long_128k_verified,
|
||
"vision_input": _verified("vision_input"),
|
||
"audio_input": _verified("audio_input"),
|
||
"private_deployment": _verified("private_deployment"),
|
||
"external_search_ready": _verified("external_search_ready"),
|
||
"vision": _verified("vision_input"),
|
||
"audio": _verified("audio_input"),
|
||
}
|
||
|
||
|
||
def _capability_evidence_is_in_scope(key: str, row: dict[str, Any], *, declared: dict[str, Any]) -> bool:
|
||
normalized_key = str(key or "").strip()
|
||
if normalized_key == "model_self_report":
|
||
return False
|
||
if normalized_key == "basic_chat":
|
||
return True
|
||
if normalized_key == "structured_output":
|
||
return bool(declared.get("structured_output"))
|
||
if normalized_key == "json_schema":
|
||
return bool(declared.get("json_schema"))
|
||
if normalized_key == "tool_calling":
|
||
return bool(declared.get("tool_calling"))
|
||
if normalized_key == "streaming":
|
||
return _probe_row_is_verified(row, key=normalized_key)
|
||
if normalized_key == "long_context":
|
||
return bool(declared.get("long_context")) or _probe_row_is_verified(row, key=normalized_key)
|
||
if normalized_key in {"long_context_32k", "long_context_128k"}:
|
||
try:
|
||
declared_tokens = int(declared.get("max_context_tokens") or 0)
|
||
except (TypeError, ValueError):
|
||
declared_tokens = 0
|
||
required_tokens = 128000 if normalized_key == "long_context_128k" else 32768
|
||
return (bool(declared.get("long_context")) and declared_tokens >= required_tokens) or _probe_row_is_verified(row, key=normalized_key)
|
||
if normalized_key == "vision_input":
|
||
return bool(declared.get("vision")) or _probe_row_is_verified(row, key=normalized_key)
|
||
if normalized_key == "audio_input":
|
||
return bool(declared.get("audio")) or _probe_row_is_verified(row, key=normalized_key)
|
||
return True
|
||
|
||
|
||
def _summarize_capability_probe_status(
|
||
*,
|
||
declared: dict[str, Any],
|
||
probe: dict[str, Any],
|
||
missing_scope_declared: dict[str, Any] | None = None,
|
||
) -> str:
|
||
evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {}
|
||
long_context_row = evidence.get("long_context") if isinstance(evidence.get("long_context"), dict) else None
|
||
covered_long_context_thresholds: set[str] = set()
|
||
if isinstance(long_context_row, dict) and _probe_row_is_verified(long_context_row, key="long_context"):
|
||
try:
|
||
verified_tokens = int(long_context_row.get("tested_tokens") or long_context_row.get("max_context_tokens") or 0)
|
||
except (TypeError, ValueError):
|
||
verified_tokens = 0
|
||
if verified_tokens >= 32768:
|
||
covered_long_context_thresholds.add("long_context_32k")
|
||
if verified_tokens >= 128000:
|
||
covered_long_context_thresholds.add("long_context_128k")
|
||
scoped = {
|
||
str(key): value
|
||
for key, value in evidence.items()
|
||
if isinstance(value, dict)
|
||
and str(key) not in covered_long_context_thresholds
|
||
and _capability_evidence_is_in_scope(str(key), value, declared=declared)
|
||
}
|
||
placeholder_scope = missing_scope_declared if isinstance(missing_scope_declared, dict) else declared
|
||
for key, declared_flag in DECLARED_PROBE_REQUIREMENT_FLAGS.items():
|
||
if bool(placeholder_scope.get(declared_flag)) and key not in scoped:
|
||
scoped[key] = {
|
||
"status": "declared_only",
|
||
"ok": None,
|
||
"evidence_strength": "declared_only",
|
||
"summary": "已声明但尚未获得真实探测或人工确认依据",
|
||
}
|
||
if not scoped:
|
||
return "untested"
|
||
verified = sum(1 for key, value in scoped.items() if _probe_row_is_verified(value, key=key))
|
||
failed = sum(
|
||
1
|
||
for value in scoped.values()
|
||
if str(value.get("status") or "").strip().lower() == "failed" or (value.get("ok") is False and str(value.get("status") or "").strip().lower() not in {"skipped", "not_probeable", "declared_only"})
|
||
)
|
||
neutral = len(scoped) - verified - failed
|
||
if verified and not failed and not neutral:
|
||
return "passed"
|
||
if verified and (failed or neutral):
|
||
return "partial"
|
||
if failed:
|
||
return "failed"
|
||
return "untested"
|
||
|
||
|
||
def _normalize_legacy_long_context_probe(*, declared: dict[str, Any], probe: dict[str, Any], flat_evidence: dict[str, Any]) -> None:
|
||
if not bool(declared.get("long_context")):
|
||
return
|
||
try:
|
||
declared_tokens = int(declared.get("max_context_tokens") or 0)
|
||
except (TypeError, ValueError):
|
||
declared_tokens = 0
|
||
if declared_tokens <= 0:
|
||
return
|
||
evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {}
|
||
row = evidence.get("long_context")
|
||
if not isinstance(row, dict):
|
||
return
|
||
status = str(row.get("status") or "").strip().lower()
|
||
failure_code = str(row.get("failure_code") or "").strip()
|
||
text = " ".join(str(row.get(key) or "") for key in ("summary", "error_summary", "request_summary", "response_summary"))
|
||
if status != "failed" or failure_code != "declared_window_too_small" or "32768" not in text:
|
||
return
|
||
message = f"长上下文真实 smoke 需要高成本调用,本次只记录声明窗口 {declared_tokens} tokens,不作为槽位发布 verified 证据"
|
||
normalized_row = dict(row)
|
||
normalized_row.update(
|
||
{
|
||
"ok": False,
|
||
"status": "declared_only",
|
||
"summary": message,
|
||
"error_summary": "",
|
||
"evidence_strength": "declared_only",
|
||
"failure_code": None,
|
||
"tested_tokens": declared_tokens,
|
||
}
|
||
)
|
||
evidence["long_context"] = normalized_row
|
||
flat_evidence["long_context"] = {
|
||
**dict(flat_evidence.get("long_context") or {}),
|
||
"status": "declared_only",
|
||
"source": "probe",
|
||
"message": message,
|
||
"evidence_strength": "declared_only",
|
||
"failure_code": None,
|
||
"max_context_tokens": declared_tokens,
|
||
}
|
||
|
||
|
||
def _probe_source_from_flat(source: Any) -> str:
|
||
normalized = str(source or "").strip().lower()
|
||
if normalized in {"probe", "mixed", "probed"}:
|
||
return "probed"
|
||
if normalized == "manual":
|
||
return "manual"
|
||
return "inferred"
|
||
|
||
|
||
def _flat_source_from_probe(source: Any, *, fallback: str = "default") -> str:
|
||
normalized = str(source or "").strip().lower()
|
||
if normalized == "probed":
|
||
return "probe"
|
||
if normalized == "manual":
|
||
return "manual"
|
||
return fallback if fallback in {"default", "manual", "probe", "mixed"} else "default"
|
||
|
||
|
||
def _normalize_capability_probe(
|
||
raw: Any,
|
||
*,
|
||
flat_evidence: dict[str, Any],
|
||
flat_status: Any,
|
||
flat_last_verified_at: Any,
|
||
flat_source: Any,
|
||
) -> dict[str, Any]:
|
||
source = raw if isinstance(raw, dict) else {}
|
||
probe = _empty_capability_probe(_probe_source_from_flat(flat_source))
|
||
raw_source = str(source.get("source") or "").strip().lower()
|
||
if raw_source in {"inferred", "manual", "probed"}:
|
||
probe["source"] = raw_source
|
||
raw_status = str(source.get("status") or flat_status or "").strip().lower()
|
||
if raw_status in CAPABILITY_PROBE_STATUSES:
|
||
probe["status"] = raw_status
|
||
raw_last_verified_at = str(source.get("last_verified_at") or flat_last_verified_at or "").strip()
|
||
probe["last_verified_at"] = raw_last_verified_at or None
|
||
|
||
verified_capabilities = source.get("verified_capabilities") if isinstance(source.get("verified_capabilities"), dict) else {}
|
||
for key in CAPABILITY_PROBE_KEYS:
|
||
probe["verified_capabilities"][key] = bool(verified_capabilities.get(key, False))
|
||
|
||
nested_evidence = source.get("evidence") if isinstance(source.get("evidence"), dict) else {}
|
||
evidence_keys = list(dict.fromkeys([*CAPABILITY_PROBE_KEYS, *nested_evidence.keys(), *flat_evidence.keys()]))
|
||
normalized_evidence: dict[str, dict[str, Any]] = {}
|
||
for key in evidence_keys:
|
||
nested_row = nested_evidence.get(key) if isinstance(nested_evidence.get(key), dict) else {}
|
||
flat_row = flat_evidence.get(key) if isinstance(flat_evidence.get(key), dict) else {}
|
||
if not nested_row and not flat_row:
|
||
continue
|
||
raw_status = str(nested_row.get("status") or flat_row.get("status") or "").strip().lower()
|
||
ok_value = nested_row.get("ok")
|
||
if not isinstance(ok_value, bool):
|
||
ok_value = raw_status == "verified"
|
||
checked_at = str(nested_row.get("checked_at") or flat_row.get("verified_at") or probe["last_verified_at"] or "").strip() or None
|
||
summary = str(nested_row.get("summary") or flat_row.get("message") or "").strip()
|
||
error_summary = str(nested_row.get("error_summary") or ("" if ok_value else flat_row.get("message") or "")).strip()
|
||
evidence_strength = _evidence_strength_from_row({**flat_row, **nested_row, "status": raw_status}, key=str(key))
|
||
if evidence_strength == "unknown" and ok_value is True and probe.get("source") == "probed":
|
||
message = str(summary or error_summary or "")
|
||
evidence_strength = (
|
||
"declared_only"
|
||
if str(key) in {"long_context", "long_context_32k", "long_context_128k"} and ("声明" in message or "declared" in message.lower())
|
||
else "live_probe"
|
||
)
|
||
row = {
|
||
"ok": bool(ok_value),
|
||
"status": raw_status if raw_status in CAPABILITY_EVIDENCE_STATUSES else ("verified" if ok_value else "failed"),
|
||
"method": str(nested_row.get("method") or flat_row.get("method") or "").strip() or None,
|
||
"evidence_strength": evidence_strength,
|
||
"checked_at": checked_at,
|
||
"summary": summary[:300],
|
||
"error_summary": error_summary[:300],
|
||
"failure_code": str(nested_row.get("failure_code") or flat_row.get("failure_code") or "").strip() or None,
|
||
"request_summary": str(nested_row.get("request_summary") or flat_row.get("request_summary") or "").strip()[:500],
|
||
"response_summary": str(nested_row.get("response_summary") or flat_row.get("response_summary") or "").strip()[:500],
|
||
"latency_ms": nested_row.get("latency_ms", flat_row.get("latency_ms")),
|
||
"source": str(nested_row.get("source") or flat_row.get("source") or "").strip() or None,
|
||
}
|
||
for passthrough_key in ("source_type", "source_url", "source_note", "verified_by", "verified_at", "expires_at"):
|
||
passthrough_value = nested_row.get(passthrough_key, flat_row.get(passthrough_key))
|
||
if passthrough_value not in (None, ""):
|
||
row[passthrough_key] = passthrough_value
|
||
if key in {"long_context", "long_context_32k", "long_context_128k"}:
|
||
tested_tokens = nested_row.get("tested_tokens", flat_row.get("max_context_tokens"))
|
||
try:
|
||
row["tested_tokens"] = int(tested_tokens) if tested_tokens is not None and tested_tokens != "" else None
|
||
except (TypeError, ValueError):
|
||
row["tested_tokens"] = None
|
||
model_self_report = nested_row.get("model_self_report", flat_row.get("model_self_report"))
|
||
if str(key) == "model_self_report" and isinstance(model_self_report, dict):
|
||
row["model_self_report"] = model_self_report
|
||
normalized_evidence[key] = row
|
||
probe["verified_capabilities"][key] = _probe_row_is_verified(row, key=str(key))
|
||
probe["evidence"] = normalized_evidence
|
||
|
||
if probe["status"] == "untested" and normalized_evidence:
|
||
ok_count = sum(1 for item in normalized_evidence.values() if item.get("ok") is True)
|
||
failed_count = sum(1 for item in normalized_evidence.values() if item.get("ok") is not True)
|
||
probe["status"] = "passed" if ok_count and not failed_count else ("partial" if ok_count else "failed")
|
||
return probe
|
||
|
||
|
||
def _flat_evidence_from_probe(probe: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||
evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {}
|
||
flattened: dict[str, dict[str, Any]] = {}
|
||
for key, value in evidence.items():
|
||
if not isinstance(value, dict):
|
||
continue
|
||
flattened[str(key)] = {
|
||
"status": str(value.get("status") or ("verified" if value.get("ok") is True else "failed")),
|
||
"source": value.get("source") or "probe",
|
||
"verified_at": value.get("checked_at"),
|
||
"message": str(value.get("summary") or value.get("error_summary") or "")[:300],
|
||
"latency_ms": value.get("latency_ms"),
|
||
"method": value.get("method"),
|
||
"evidence_strength": value.get("evidence_strength"),
|
||
"failure_code": value.get("failure_code"),
|
||
"request_summary": value.get("request_summary"),
|
||
"response_summary": value.get("response_summary"),
|
||
}
|
||
if value.get("tested_tokens") is not None:
|
||
flattened[str(key)]["max_context_tokens"] = value.get("tested_tokens")
|
||
for passthrough_key in ("source_type", "source_url", "source_note", "verified_by", "expires_at"):
|
||
if value.get(passthrough_key) not in (None, ""):
|
||
flattened[str(key)][passthrough_key] = value.get(passthrough_key)
|
||
if str(key) == "model_self_report" and isinstance(value.get("model_self_report"), dict):
|
||
flattened[str(key)]["model_self_report"] = value.get("model_self_report")
|
||
return flattened
|
||
|
||
|
||
def _parse_iso_datetime(value: Any) -> datetime | None:
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return None
|
||
try:
|
||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||
except ValueError:
|
||
return None
|
||
if parsed.tzinfo is not None:
|
||
parsed = parsed.replace(tzinfo=None)
|
||
return parsed
|
||
|
||
|
||
def _default_engine_capabilities(provider: str, model: str = "") -> dict[str, Any]:
|
||
normalized_provider = str(provider or "").strip().lower()
|
||
normalized_model = str(model or "").strip().lower()
|
||
is_local = normalized_provider == "ollama"
|
||
is_zhipu = normalized_provider == "zhipu"
|
||
is_high_reasoning = normalized_provider in {"openai", "deepseek", "zhipu"} or "glm-5" in normalized_model
|
||
is_dashscope = normalized_provider == "dashscope"
|
||
context_contract = _provider_model_context_contract(normalized_provider, normalized_model)
|
||
default_context_tokens = (
|
||
int(context_contract["context_tokens"])
|
||
if context_contract
|
||
else (8192 if is_local else (128000 if is_high_reasoning else 32768))
|
||
)
|
||
return {
|
||
"structured_output": not is_local,
|
||
"json_schema": normalized_provider in {"openai", "deepseek", "zhipu"} or is_dashscope,
|
||
"tool_calling": normalized_provider in {"openai", "deepseek", "dashscope", "zhipu"},
|
||
"long_context": not is_local,
|
||
"max_context_tokens": default_context_tokens,
|
||
"vision": False,
|
||
"audio": False,
|
||
"local_only": is_local,
|
||
"private_deployment": is_local,
|
||
"external_search_ready": False,
|
||
"avg_latency_ms": None,
|
||
"cost_tier": "high" if is_zhipu and "glm-5.1" in normalized_model else ("low" if normalized_provider in {"dashscope", "ollama"} else "medium"),
|
||
"reliability_tier": "local" if is_local else ("high" if is_high_reasoning else "standard"),
|
||
"capability_source": "default",
|
||
"capability_probe_status": "untested",
|
||
"capability_last_verified_at": None,
|
||
"capability_blocking_reasons": [],
|
||
"capability_evidence": {},
|
||
"capability_probe": _empty_capability_probe("inferred"),
|
||
"declared_capabilities": {},
|
||
"effective_capabilities": {},
|
||
}
|
||
|
||
|
||
def _normalize_engine_capabilities(raw: Any, *, provider: str, model: str = "") -> dict[str, Any]:
|
||
defaults = _default_engine_capabilities(provider, model)
|
||
source = raw if isinstance(raw, dict) else {}
|
||
|
||
def _bool(key: str) -> bool:
|
||
return bool(source[key]) if key in source else bool(defaults[key])
|
||
|
||
def _int_or_none(key: str) -> int | None:
|
||
value = source.get(key, defaults.get(key))
|
||
if value is None or value == "":
|
||
return None
|
||
try:
|
||
return max(0, int(value))
|
||
except (TypeError, ValueError):
|
||
return defaults.get(key)
|
||
|
||
def _tier(key: str, allowed: set[str]) -> str:
|
||
value = str(source.get(key) or defaults.get(key) or "").strip().lower()
|
||
return value if value in allowed else str(defaults.get(key) or "")
|
||
|
||
evidence = source.get("capability_evidence") if isinstance(source.get("capability_evidence"), dict) else {}
|
||
blocking_reasons = source.get("capability_blocking_reasons") if isinstance(source.get("capability_blocking_reasons"), list) else []
|
||
metadata_keys = {
|
||
"capability_source",
|
||
"capability_probe_status",
|
||
"capability_last_verified_at",
|
||
"capability_blocking_reasons",
|
||
"capability_evidence",
|
||
"capability_probe",
|
||
"declared_capabilities",
|
||
"effective_capabilities",
|
||
CAPABILITY_DECLARED_FIELDS_META,
|
||
}
|
||
has_saved_capability_fields = any(key in source for key in defaults if key not in metadata_keys)
|
||
capability_source = str(source.get("capability_source") or "").strip().lower()
|
||
if capability_source not in {"default", "manual", "probe", "mixed"}:
|
||
capability_source = "manual" if has_saved_capability_fields else "default"
|
||
probe_status = str(source.get("capability_probe_status") or "").strip().lower()
|
||
if probe_status not in {"untested", "running", "passed", "partial", "failed"}:
|
||
probe_status = "untested"
|
||
capability_probe = _normalize_capability_probe(
|
||
source.get("capability_probe"),
|
||
flat_evidence=evidence,
|
||
flat_status=probe_status,
|
||
flat_last_verified_at=source.get("capability_last_verified_at"),
|
||
flat_source=capability_source,
|
||
)
|
||
declared_capabilities = {
|
||
"structured_output": _bool("structured_output"),
|
||
"json_schema": _bool("json_schema"),
|
||
"tool_calling": _bool("tool_calling"),
|
||
"long_context": _bool("long_context"),
|
||
"max_context_tokens": _int_or_none("max_context_tokens"),
|
||
"vision": _bool("vision"),
|
||
"audio": _bool("audio"),
|
||
"local_only": _bool("local_only"),
|
||
"private_deployment": _bool("private_deployment"),
|
||
"external_search_ready": _bool("external_search_ready"),
|
||
"avg_latency_ms": _int_or_none("avg_latency_ms"),
|
||
"cost_tier": _tier("cost_tier", {"low", "medium", "high", "unknown"}),
|
||
"reliability_tier": _tier("reliability_tier", {"standard", "high", "local", "unknown"}),
|
||
}
|
||
explicit_declared_fields = _explicit_declared_capability_fields(source)
|
||
_normalize_legacy_long_context_probe(declared=declared_capabilities, probe=capability_probe, flat_evidence=evidence)
|
||
if not evidence and capability_probe.get("evidence"):
|
||
evidence = _flat_evidence_from_probe(capability_probe)
|
||
if not blocking_reasons:
|
||
blocking_reasons = [
|
||
f"{key}: {str(value.get('error_summary') or '')[:180]}"
|
||
for key, value in (capability_probe.get("evidence") or {}).items()
|
||
if (
|
||
isinstance(value, dict)
|
||
and _capability_evidence_is_in_scope(str(key), value, declared=declared_capabilities)
|
||
and value.get("ok") is not True
|
||
and str(value.get("error_summary") or "").strip()
|
||
)
|
||
]
|
||
missing_scope_declared = _probe_status_declared_scope(declared_capabilities, explicit_declared_fields)
|
||
scoped_probe_status = _summarize_capability_probe_status(
|
||
declared=declared_capabilities,
|
||
probe=capability_probe,
|
||
missing_scope_declared=missing_scope_declared,
|
||
)
|
||
if scoped_probe_status != "untested" or probe_status == "untested":
|
||
probe_status = scoped_probe_status
|
||
capability_probe["status"] = probe_status
|
||
last_verified_at = str(source.get("capability_last_verified_at") or capability_probe.get("last_verified_at") or "").strip() or None
|
||
if capability_source == "default" and capability_probe.get("source") in {"manual", "probed"}:
|
||
capability_source = _flat_source_from_probe(capability_probe.get("source"), fallback=capability_source)
|
||
normalized = {
|
||
**declared_capabilities,
|
||
"capability_source": capability_source,
|
||
"capability_probe_status": probe_status,
|
||
"capability_last_verified_at": last_verified_at,
|
||
"capability_blocking_reasons": [str(item)[:220] for item in blocking_reasons if str(item or "").strip()][:8],
|
||
"capability_evidence": {
|
||
str(key): value
|
||
for key, value in evidence.items()
|
||
if str(key or "").strip() and isinstance(value, dict)
|
||
},
|
||
"capability_probe": capability_probe,
|
||
"declared_capabilities": {
|
||
"structured_output": declared_capabilities["structured_output"],
|
||
"json_schema": declared_capabilities["json_schema"],
|
||
"tool_calling": declared_capabilities["tool_calling"],
|
||
"long_context": declared_capabilities["long_context"],
|
||
"max_context_tokens": declared_capabilities["max_context_tokens"],
|
||
"vision": declared_capabilities["vision"],
|
||
"audio": declared_capabilities["audio"],
|
||
"private_deployment": declared_capabilities["private_deployment"],
|
||
"external_search_ready": declared_capabilities["external_search_ready"],
|
||
},
|
||
CAPABILITY_DECLARED_FIELDS_META: sorted(explicit_declared_fields),
|
||
}
|
||
normalized["effective_capabilities"] = _derive_effective_capabilities(declared=normalized, probe=capability_probe)
|
||
return normalized
|
||
|
||
|
||
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)
|
||
slot_bundle = self._normalize_capability_slot_bundle(
|
||
await self._load_integration_config(CAPABILITY_SLOT_INTEGRATION_TYPE)
|
||
)
|
||
capability_slots = self._build_capability_slots(
|
||
engine_bundle=engine_bundle,
|
||
slot_bundle=slot_bundle,
|
||
config_bundle=bundle,
|
||
stage="active",
|
||
)
|
||
slots = [slot for slot in capability_slots if slot.get("slot_key") in {"low", "high"}]
|
||
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,
|
||
"capability_slots": capability_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()
|
||
capability_slot_release = await self.get_capability_slots()
|
||
function_runtime_profiles = await AIFunctionRuntimeProfileService(self.db).get_profiles()
|
||
function_observability = await self._function_runtime_observability(hours=24 * 7)
|
||
scene_routes = self._build_scene_routes(
|
||
routing_bundle=routing_bundle,
|
||
config_bundle=config_bundle,
|
||
function_runtime_profiles=function_runtime_profiles,
|
||
function_observability=function_observability,
|
||
)
|
||
scene_failure_rates = await self._scene_failure_rates(hours=24 * 7)
|
||
scene_slot_matrix = self._build_scene_slot_matrix(
|
||
scene_routes=scene_routes,
|
||
capability_slot_release=capability_slot_release,
|
||
scene_failure_rates=scene_failure_rates,
|
||
)
|
||
slot_observability = await self._build_slot_observability(
|
||
capability_slot_release=capability_slot_release,
|
||
)
|
||
slot_recommendations = self._build_slot_recommendations(
|
||
slots=overview.get("capability_slots", []),
|
||
slot_observability=slot_observability,
|
||
)
|
||
return {
|
||
"overview": overview,
|
||
"providers": self._build_providers(engine_bundle=engine_bundle),
|
||
"capability_slots": overview.get("capability_slots", []),
|
||
"capability_slot_release": capability_slot_release,
|
||
"function_runtime_profiles": function_runtime_profiles,
|
||
"scene_routes": scene_routes,
|
||
"scene_slot_matrix": scene_slot_matrix,
|
||
"slot_observability": slot_observability,
|
||
"slot_recommendations": slot_recommendations,
|
||
"protected_functions": sorted(PROTECTED_FUNCTIONS),
|
||
"recent_failures": await self._recent_failures(limit=8),
|
||
"diagnostics": overview["diagnostics"],
|
||
"audit_hint": "供应商、槽位、路由和团队策略变更都会写入操作日志。",
|
||
}
|
||
|
||
async def get_capability_slots(self) -> dict[str, Any]:
|
||
bundle = load_model_config()
|
||
engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE)
|
||
slot_bundle = self._normalize_capability_slot_bundle(
|
||
await self._load_integration_config(CAPABILITY_SLOT_INTEGRATION_TYPE)
|
||
)
|
||
active_slots = self._build_capability_slots(
|
||
engine_bundle=engine_bundle,
|
||
slot_bundle=slot_bundle,
|
||
config_bundle=bundle,
|
||
stage="active",
|
||
)
|
||
draft_slots = self._build_capability_slots(
|
||
engine_bundle=engine_bundle,
|
||
slot_bundle=slot_bundle,
|
||
config_bundle=bundle,
|
||
stage="draft",
|
||
)
|
||
validation = self.validate_capability_slot_bundle(
|
||
engine_bundle=engine_bundle,
|
||
slot_bundle=slot_bundle,
|
||
stage="draft",
|
||
)
|
||
return {
|
||
"version": str(slot_bundle.get("active_version") or slot_bundle.get("version") or "unpublished").strip(),
|
||
"active_version": str(slot_bundle.get("active_version") or "unpublished").strip(),
|
||
"draft_version": str(slot_bundle.get("draft_version") or "draft").strip(),
|
||
"published_at": _iso((slot_bundle.get("active") or {}).get("published_at") if isinstance(slot_bundle.get("active"), dict) else None),
|
||
"published_by": (slot_bundle.get("active") or {}).get("published_by") if isinstance(slot_bundle.get("active"), dict) else None,
|
||
"updated_at": _iso((slot_bundle.get("draft") or {}).get("updated_at") if isinstance(slot_bundle.get("draft"), dict) else None),
|
||
"updated_by": (slot_bundle.get("draft") or {}).get("updated_by") if isinstance(slot_bundle.get("draft"), dict) else None,
|
||
"dirty": self._capability_slot_dirty(slot_bundle),
|
||
"validation": validation,
|
||
"history": self._capability_slot_history(slot_bundle),
|
||
"slots": draft_slots,
|
||
"active_slots": active_slots,
|
||
"draft_slots": draft_slots,
|
||
}
|
||
|
||
async def update_capability_slot(
|
||
self,
|
||
*,
|
||
slot_key: str,
|
||
payload: dict[str, Any],
|
||
updated_by: int,
|
||
) -> dict[str, Any]:
|
||
key = str(slot_key or "").strip().lower()
|
||
if key not in SLOT_SPECS:
|
||
raise ValueError("能力槽位不存在")
|
||
if not bool((SLOT_SPECS.get(key) or {}).get("configurable", False)):
|
||
raise ValueError("该能力槽当前为候选槽,不接 runtime,不能配置运行时")
|
||
if key == "external_query":
|
||
return await self._update_external_query_slot(payload=payload, updated_by=updated_by)
|
||
engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE)
|
||
engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)]
|
||
engine_by_id = {str(item.get("id") or ""): item for item in engines if str(item.get("id") or "")}
|
||
|
||
primary_engine_id = str(payload.get("primary_engine_id") or "").strip()
|
||
if primary_engine_id and primary_engine_id not in engine_by_id:
|
||
raise ValueError("主运行时不存在,请先在供应商与凭据中新增连接")
|
||
if primary_engine_id and not bool(engine_by_id[primary_engine_id].get("enabled", True)):
|
||
raise ValueError("主运行时已停用,不能绑定到能力槽")
|
||
|
||
fallback_engine_ids: list[str] = []
|
||
for item in payload.get("fallback_engine_ids") or []:
|
||
engine_id = str(item or "").strip()
|
||
if not engine_id or engine_id == primary_engine_id or engine_id in fallback_engine_ids:
|
||
continue
|
||
if engine_id not in engine_by_id:
|
||
raise ValueError("备用运行时不存在,请刷新后重试")
|
||
if not bool(engine_by_id[engine_id].get("enabled", True)):
|
||
raise ValueError("备用运行时已停用,不能绑定到能力槽")
|
||
fallback_engine_ids.append(engine_id)
|
||
|
||
failure_policy = str(payload.get("failure_policy") or SLOT_SPECS[key]["default_failure_policy"]).strip()
|
||
if failure_policy not in FAILURE_POLICY_LABELS:
|
||
raise ValueError("失败策略不支持")
|
||
allow_scene_downgrade = bool(payload.get("allow_scene_downgrade", False))
|
||
if key == "high" and allow_scene_downgrade:
|
||
raise ValueError("深度槽属于保护槽,不允许客户空间策略降级")
|
||
|
||
integration, slot_bundle = await self._get_or_init_capability_slot_bundle()
|
||
slots = self._slot_config_map(slot_bundle, stage="draft")
|
||
slots[key] = {
|
||
"primary_engine_id": primary_engine_id or None,
|
||
"fallback_engine_ids": fallback_engine_ids,
|
||
"failure_policy": failure_policy,
|
||
"allow_scene_downgrade": allow_scene_downgrade,
|
||
"updated_at": now_cn_naive().isoformat(),
|
||
"updated_by": int(updated_by),
|
||
}
|
||
draft = slot_bundle.get("draft") if isinstance(slot_bundle.get("draft"), dict) else {}
|
||
draft["slots"] = slots
|
||
draft["updated_at"] = now_cn_naive().isoformat()
|
||
draft["updated_by"] = int(updated_by)
|
||
slot_bundle["draft"] = draft
|
||
slot_bundle["draft_version"] = "draft"
|
||
integration.config = json.loads(json.dumps(slot_bundle, ensure_ascii=False))
|
||
integration.is_active = True
|
||
await self.db.flush()
|
||
return await self.get_capability_slots()
|
||
|
||
async def _update_external_query_slot(self, *, payload: dict[str, Any], updated_by: int) -> dict[str, Any]:
|
||
registry = self._external_query_registry_status(binding=payload)
|
||
provider_ids = {str(item.get("id") or "") for item in registry.get("providers", []) if str(item.get("id") or "")}
|
||
primary_provider_id = str(payload.get("primary_provider_id") or payload.get("primary_engine_id") or "").strip()
|
||
if primary_provider_id and primary_provider_id not in provider_ids:
|
||
raise ValueError("外部查询主 provider 不存在,请刷新 provider registry 后重试")
|
||
fallback_provider_ids: list[str] = []
|
||
raw_fallback_provider_ids = payload.get("fallback_provider_ids")
|
||
if not isinstance(raw_fallback_provider_ids, list):
|
||
raw_fallback_provider_ids = payload.get("fallback_engine_ids")
|
||
for item in raw_fallback_provider_ids or []:
|
||
provider_id = str(item or "").strip()
|
||
if not provider_id or provider_id == primary_provider_id or provider_id in fallback_provider_ids:
|
||
continue
|
||
if provider_id not in provider_ids:
|
||
raise ValueError("外部查询备用 provider 不存在,请刷新 provider registry 后重试")
|
||
fallback_provider_ids.append(provider_id)
|
||
failure_policy = str(payload.get("failure_policy") or "explicit_fail").strip()
|
||
if failure_policy != "explicit_fail":
|
||
raise ValueError("外部查询槽失败策略必须为显式失败,禁止回退到普通 LLM 编造答案")
|
||
integration, slot_bundle = await self._get_or_init_capability_slot_bundle()
|
||
slots = self._slot_config_map(slot_bundle, stage="draft")
|
||
slots["external_query"] = {
|
||
"primary_provider_id": primary_provider_id or None,
|
||
"fallback_provider_ids": fallback_provider_ids,
|
||
"verifier_required": True,
|
||
"source_filtering_required": True,
|
||
"failure_policy": "explicit_fail",
|
||
"allow_scene_downgrade": False,
|
||
"updated_at": now_cn_naive().isoformat(),
|
||
"updated_by": int(updated_by),
|
||
}
|
||
draft = slot_bundle.get("draft") if isinstance(slot_bundle.get("draft"), dict) else {}
|
||
draft["slots"] = slots
|
||
draft["updated_at"] = now_cn_naive().isoformat()
|
||
draft["updated_by"] = int(updated_by)
|
||
slot_bundle["draft"] = draft
|
||
slot_bundle["draft_version"] = "draft"
|
||
integration.config = json.loads(json.dumps(slot_bundle, ensure_ascii=False))
|
||
integration.is_active = True
|
||
await self.db.flush()
|
||
return await self.get_capability_slots()
|
||
|
||
async def validate_capability_slots(self) -> dict[str, Any]:
|
||
engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE)
|
||
slot_bundle = self._normalize_capability_slot_bundle(
|
||
await self._load_integration_config(CAPABILITY_SLOT_INTEGRATION_TYPE)
|
||
)
|
||
return self.validate_capability_slot_bundle(
|
||
engine_bundle=engine_bundle,
|
||
slot_bundle=slot_bundle,
|
||
stage="draft",
|
||
)
|
||
|
||
async def publish_capability_slots(self, *, published_by: int, summary: str | None = None) -> dict[str, Any]:
|
||
engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE)
|
||
integration, slot_bundle = await self._get_or_init_capability_slot_bundle()
|
||
validation = self.validate_capability_slot_bundle(
|
||
engine_bundle=engine_bundle,
|
||
slot_bundle=slot_bundle,
|
||
stage="draft",
|
||
)
|
||
blocking = [item for item in validation.get("items", []) if item.get("severity") == "critical"]
|
||
if blocking:
|
||
raise ValueError("草稿存在阻塞项,不能发布")
|
||
version = _release_version()
|
||
now = now_cn_naive().isoformat()
|
||
draft_slots = self._slot_config_map(slot_bundle, stage="draft")
|
||
slot_bundle["active"] = {
|
||
"slots": _json_clone(draft_slots),
|
||
"published_at": now,
|
||
"published_by": int(published_by),
|
||
}
|
||
slot_bundle["active_version"] = version
|
||
slot_bundle["version"] = version
|
||
history = self._capability_slot_history(slot_bundle)
|
||
history.insert(
|
||
0,
|
||
{
|
||
"version": version,
|
||
"published_at": now,
|
||
"published_by": int(published_by),
|
||
"summary": str(summary or "发布能力槽位草稿").strip()[:200],
|
||
"slots": _json_clone(draft_slots),
|
||
},
|
||
)
|
||
slot_bundle["history"] = history[:10]
|
||
integration.config = json.loads(json.dumps(slot_bundle, ensure_ascii=False))
|
||
integration.is_active = True
|
||
await self.db.flush()
|
||
return await self.get_capability_slots()
|
||
|
||
async def rollback_capability_slots(self, *, target_version: str | None, rolled_back_by: int) -> dict[str, Any]:
|
||
integration, slot_bundle = await self._get_or_init_capability_slot_bundle()
|
||
history = self._capability_slot_history(slot_bundle)
|
||
current_version = str(slot_bundle.get("active_version") or "").strip()
|
||
candidates = [item for item in history if str(item.get("version") or "").strip() != current_version]
|
||
if target_version:
|
||
target = next((item for item in history if str(item.get("version") or "").strip() == str(target_version).strip()), None)
|
||
else:
|
||
target = candidates[0] if candidates else None
|
||
if not isinstance(target, dict) or not isinstance(target.get("slots"), dict):
|
||
raise ValueError("没有可回滚的已发布版本")
|
||
version = _release_version()
|
||
now = now_cn_naive().isoformat()
|
||
restored_slots = _json_clone(target["slots"])
|
||
slot_bundle["active"] = {
|
||
"slots": restored_slots,
|
||
"published_at": now,
|
||
"published_by": int(rolled_back_by),
|
||
"rolled_back_from": str(target.get("version") or ""),
|
||
}
|
||
slot_bundle["draft"] = {
|
||
"slots": _json_clone(restored_slots),
|
||
"updated_at": now,
|
||
"updated_by": int(rolled_back_by),
|
||
}
|
||
slot_bundle["active_version"] = version
|
||
slot_bundle["draft_version"] = "draft"
|
||
slot_bundle["version"] = version
|
||
history.insert(
|
||
0,
|
||
{
|
||
"version": version,
|
||
"published_at": now,
|
||
"published_by": int(rolled_back_by),
|
||
"summary": f"回滚到 {target.get('version') or '上一版本'}",
|
||
"slots": _json_clone(restored_slots),
|
||
},
|
||
)
|
||
slot_bundle["history"] = history[:10]
|
||
integration.config = json.loads(json.dumps(slot_bundle, ensure_ascii=False))
|
||
integration.is_active = True
|
||
await self.db.flush()
|
||
return await self.get_capability_slots()
|
||
|
||
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]:
|
||
tenant_id = (
|
||
PLATFORM_RUNTIME_TENANT_ID
|
||
if integration_type in {LLM_ENGINE_INTEGRATION_TYPE, MODEL_ROUTING_INTEGRATION_TYPE, CAPABILITY_SLOT_INTEGRATION_TYPE}
|
||
else self.tenant_id
|
||
)
|
||
row = (
|
||
await self.db.execute(
|
||
select(Integration).where(
|
||
and_(
|
||
Integration.tenant_id == 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
|
||
|
||
async def _get_or_init_capability_slot_bundle(self) -> tuple[Integration, dict[str, Any]]:
|
||
row = (
|
||
await self.db.execute(
|
||
select(Integration).where(
|
||
and_(
|
||
Integration.tenant_id == PLATFORM_RUNTIME_TENANT_ID,
|
||
Integration.type == CAPABILITY_SLOT_INTEGRATION_TYPE,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is None:
|
||
row = Integration(
|
||
tenant_id=PLATFORM_RUNTIME_TENANT_ID,
|
||
type=CAPABILITY_SLOT_INTEGRATION_TYPE,
|
||
config=self._normalize_capability_slot_bundle({}),
|
||
is_active=True,
|
||
)
|
||
self.db.add(row)
|
||
await self.db.flush()
|
||
bundle = self._normalize_capability_slot_bundle(_safe_dict(row.config))
|
||
return row, bundle
|
||
|
||
@staticmethod
|
||
def _normalize_capability_slot_bundle(raw: dict[str, Any]) -> dict[str, Any]:
|
||
bundle = _safe_dict(raw)
|
||
legacy_slots = bundle.get("slots") if isinstance(bundle.get("slots"), dict) else {}
|
||
active = bundle.get("active") if isinstance(bundle.get("active"), dict) else {}
|
||
draft = bundle.get("draft") if isinstance(bundle.get("draft"), dict) else {}
|
||
active_slots = active.get("slots") if isinstance(active.get("slots"), dict) else legacy_slots
|
||
draft_slots = draft.get("slots") if isinstance(draft.get("slots"), dict) else active_slots
|
||
active_version = str(bundle.get("active_version") or bundle.get("version") or "").strip()
|
||
if not active_version:
|
||
active_version = "unpublished"
|
||
normalized = {
|
||
"version": active_version,
|
||
"active_version": active_version,
|
||
"draft_version": str(bundle.get("draft_version") or "draft").strip() or "draft",
|
||
"active": {
|
||
**{key: value for key, value in active.items() if key != "slots"},
|
||
"slots": _json_clone(active_slots),
|
||
},
|
||
"draft": {
|
||
**{key: value for key, value in draft.items() if key != "slots"},
|
||
"slots": _json_clone(draft_slots),
|
||
},
|
||
"history": bundle.get("history") if isinstance(bundle.get("history"), list) else [],
|
||
}
|
||
if "published_at" not in normalized["active"] and bundle.get("updated_at"):
|
||
normalized["active"]["published_at"] = bundle.get("updated_at")
|
||
if "updated_at" not in normalized["draft"] and bundle.get("updated_at"):
|
||
normalized["draft"]["updated_at"] = bundle.get("updated_at")
|
||
return normalized
|
||
|
||
@staticmethod
|
||
def _slot_config_map(slot_bundle: dict[str, Any], *, stage: str) -> dict[str, Any]:
|
||
normalized_stage = "draft" if stage == "draft" else "active"
|
||
stage_bundle = slot_bundle.get(normalized_stage) if isinstance(slot_bundle.get(normalized_stage), dict) else {}
|
||
slots = stage_bundle.get("slots") if isinstance(stage_bundle.get("slots"), dict) else {}
|
||
return _json_clone(slots) if isinstance(slots, dict) else {}
|
||
|
||
@staticmethod
|
||
def _capability_slot_dirty(slot_bundle: dict[str, Any]) -> bool:
|
||
return _json_clone(AIRoutingGovernanceService._slot_config_map(slot_bundle, stage="active")) != _json_clone(
|
||
AIRoutingGovernanceService._slot_config_map(slot_bundle, stage="draft")
|
||
)
|
||
|
||
@staticmethod
|
||
def _capability_slot_history(slot_bundle: dict[str, Any]) -> list[dict[str, Any]]:
|
||
history = slot_bundle.get("history") if isinstance(slot_bundle.get("history"), list) else []
|
||
return [item for item in history if isinstance(item, dict)]
|
||
|
||
def validate_capability_slot_bundle(
|
||
self,
|
||
*,
|
||
engine_bundle: dict[str, Any],
|
||
slot_bundle: dict[str, Any],
|
||
stage: str,
|
||
) -> dict[str, Any]:
|
||
engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)]
|
||
engine_by_id = {str(item.get("id") or ""): item for item in engines if str(item.get("id") or "")}
|
||
slots = self._slot_config_map(slot_bundle, stage=stage)
|
||
items: list[dict[str, Any]] = []
|
||
enabled_reserved_slots = [
|
||
key
|
||
for key in SLOT_SPECS
|
||
if key not in {"low", "high"}
|
||
and isinstance(slots.get(key), dict)
|
||
and (
|
||
str((slots.get(key) or {}).get("primary_engine_id") or "").strip()
|
||
or str((slots.get(key) or {}).get("primary_provider_id") or "").strip()
|
||
or bool((slots.get(key) or {}).get("fallback_engine_ids"))
|
||
or bool((slots.get(key) or {}).get("fallback_provider_ids"))
|
||
)
|
||
]
|
||
for slot_key in ["low", "high", *enabled_reserved_slots]:
|
||
saved = slots.get(slot_key) if isinstance(slots.get(slot_key), dict) else {}
|
||
primary_engine_id = str(saved.get("primary_engine_id") or "").strip()
|
||
slot_label = str((SLOT_SPECS.get(slot_key) or {}).get("label") or SLOT_LABELS.get(slot_key, slot_key))
|
||
if slot_key == "external_query":
|
||
items.extend(self._external_query_slot_risks(saved=saved, slot_label=slot_label))
|
||
continue
|
||
if not primary_engine_id:
|
||
items.append(self._risk(f"{slot_key}_primary_missing", "critical", f"{slot_label}未选择主运行时", "必须先从供应商与凭据中选择已启用运行时。"))
|
||
continue
|
||
primary_engine = engine_by_id.get(primary_engine_id)
|
||
if not primary_engine:
|
||
items.append(self._risk(f"{slot_key}_primary_missing_provider", "critical", f"{slot_label}主运行时不存在", "请刷新供应商列表或重新选择运行时。"))
|
||
continue
|
||
if not bool(primary_engine.get("enabled", True)):
|
||
items.append(self._risk(f"{slot_key}_primary_disabled", "critical", f"{slot_label}主运行时已停用", "发布前必须启用运行时或重新选择。"))
|
||
status = str(primary_engine.get("connection_status") or "untested").strip()
|
||
if status == "test_failed":
|
||
items.append(self._risk(f"{slot_key}_primary_test_failed", "critical", f"{slot_label}主运行时测试失败", "发布前必须先修复供应商连接。"))
|
||
elif status != "available":
|
||
items.append(self._risk(f"{slot_key}_primary_untested", "warning", f"{slot_label}主运行时未完成连通测试", "建议先在供应商与凭据中测试通过再发布。"))
|
||
provider = str(primary_engine.get("provider") or "").strip().lower()
|
||
if slot_key == "high" and provider == "ollama":
|
||
items.append(self._risk("high_slot_ollama_blocked", "critical", "深度槽不能绑定本地 Ollama", "深度槽承载结构化、复杂推理和保护场景,不能使用仅知识库问答运行时。"))
|
||
items.extend(
|
||
self._engine_requirement_risks(
|
||
slot_key=slot_key,
|
||
slot_label=slot_label,
|
||
engine=primary_engine,
|
||
role_label="主运行时",
|
||
)
|
||
)
|
||
if slot_key == "low":
|
||
primary_coverage = self._engine_capability_coverage(slot_key=slot_key, engine=primary_engine)
|
||
primary_probe = (primary_coverage.get("capabilities") or {}).get("capability_probe")
|
||
primary_probe_status = str((primary_probe or {}).get("status") or "untested").strip().lower() if isinstance(primary_probe, dict) else "untested"
|
||
primary_probe_source = str((primary_probe or {}).get("source") or "inferred").strip().lower() if isinstance(primary_probe, dict) else "inferred"
|
||
if primary_probe_status == "untested" or primary_probe_source != "probed":
|
||
items.append(
|
||
self._risk(
|
||
"low_primary_capability_evidence_untested",
|
||
"warning",
|
||
"快速槽主运行时缺少 capability probe",
|
||
"快速槽可先发布,但当前能力仅来自默认推断或人工标注,建议补一次供应商能力探测。",
|
||
)
|
||
)
|
||
fallback_ids = saved.get("fallback_engine_ids") if isinstance(saved.get("fallback_engine_ids"), list) else []
|
||
for fallback_id_raw in fallback_ids:
|
||
fallback_id = str(fallback_id_raw or "").strip()
|
||
if not fallback_id:
|
||
continue
|
||
if fallback_id == primary_engine_id:
|
||
items.append(self._risk(f"{slot_key}_fallback_duplicate_primary", "warning", f"{slot_label}备用链路重复主运行时", "重复运行时不会提供真实容灾能力。"))
|
||
continue
|
||
fallback_engine = engine_by_id.get(fallback_id)
|
||
if not fallback_engine:
|
||
items.append(self._risk(f"{slot_key}_fallback_missing", "critical", f"{slot_label}备用运行时不存在", "请移除失效备用项。"))
|
||
continue
|
||
if not bool(fallback_engine.get("enabled", True)):
|
||
items.append(self._risk(f"{slot_key}_fallback_disabled", "critical", f"{slot_label}备用运行时已停用", "请启用备用运行时或移除该备用项。"))
|
||
items.extend(
|
||
self._engine_requirement_risks(
|
||
slot_key=slot_key,
|
||
slot_label=slot_label,
|
||
engine=fallback_engine,
|
||
role_label="备用运行时",
|
||
)
|
||
)
|
||
if self._same_engine_identity(primary_engine, fallback_engine):
|
||
items.append(self._risk(f"{slot_key}_fallback_same_runtime", "warning", f"{slot_label}备用链路与主运行时相同", "相同 provider/model/base_url 只能规避少量连接状态问题,不能提升模型能力。"))
|
||
if slot_key == "high" and bool(saved.get("allow_scene_downgrade", False)):
|
||
items.append(self._risk("high_downgrade_blocked", "critical", "深度槽不允许被场景策略降级", "保护场景必须显式失败或走高阶修复,不能静默降级。"))
|
||
if not items:
|
||
items.append(self._risk("ok", "ok", "草稿可发布", "当前草稿未触发阻塞项。"))
|
||
return {
|
||
"stage": stage,
|
||
"can_publish": not any(item.get("severity") == "critical" for item in items),
|
||
"summary": self._diagnostic_summary(items),
|
||
"items": items,
|
||
}
|
||
|
||
def _external_query_slot_risks(self, *, saved: dict[str, Any], slot_label: str) -> list[dict[str, Any]]:
|
||
registry = self._external_query_registry_status(binding=saved)
|
||
providers = [item for item in registry.get("providers", []) if isinstance(item, dict)]
|
||
provider_by_id = {str(item.get("id") or ""): item for item in providers if str(item.get("id") or "")}
|
||
primary_provider_id = str(saved.get("primary_provider_id") or saved.get("primary_engine_id") or "").strip()
|
||
fallback_provider_ids = [
|
||
str(item or "").strip()
|
||
for item in list(saved.get("fallback_provider_ids") or saved.get("fallback_engine_ids") or [])
|
||
if str(item or "").strip()
|
||
]
|
||
risks: list[dict[str, Any]] = []
|
||
if not primary_provider_id:
|
||
risks.append(self._risk("external_query_primary_provider_missing", "critical", f"{slot_label}未选择主外部 provider", "必须先绑定 MCP adapter 或 bounded live search fallback。"))
|
||
return risks
|
||
primary = provider_by_id.get(primary_provider_id)
|
||
if not primary:
|
||
risks.append(self._risk("external_query_primary_provider_unknown", "critical", f"{slot_label}主 provider 不存在", "请刷新 provider registry 后重新选择外部查询 provider。"))
|
||
elif not bool(primary.get("available")):
|
||
risks.append(self._risk("external_query_primary_provider_unavailable", "critical", f"{slot_label}主 provider 不可用", "发布前必须恢复 MCP adapter 或 bounded live search fallback。"))
|
||
for provider_id in fallback_provider_ids:
|
||
if provider_id == primary_provider_id:
|
||
risks.append(self._risk("external_query_fallback_duplicate_primary", "warning", f"{slot_label}备用 provider 重复主 provider", "重复 provider 不会提供真实外部查询容灾。"))
|
||
continue
|
||
provider = provider_by_id.get(provider_id)
|
||
if not provider:
|
||
risks.append(self._risk("external_query_fallback_provider_unknown", "critical", f"{slot_label}备用 provider 不存在", "请移除失效备用 provider。"))
|
||
elif not bool(provider.get("available")):
|
||
risks.append(self._risk("external_query_fallback_provider_unavailable", "critical", f"{slot_label}备用 provider 不可用", "请恢复该 provider 或从 fallback 链移除。"))
|
||
selected_ids = [primary_provider_id, *fallback_provider_ids]
|
||
selected_available = [
|
||
provider_by_id[item]
|
||
for item in selected_ids
|
||
if item in provider_by_id and bool(provider_by_id[item].get("available"))
|
||
]
|
||
if not selected_available:
|
||
risks.append(self._risk("external_query_provider_unavailable", "critical", f"{slot_label}没有可用外部 provider", "至少需要一个可用的 MCP adapter 或 bounded live search fallback。"))
|
||
if not any(bool(item.get("available")) for item in providers):
|
||
risks.append(self._risk("external_query_registry_empty", "critical", "外部 provider registry 不可用", "发布前必须至少启用一个受控外部查询 provider。"))
|
||
if not (
|
||
bool(registry.get("mcp_adapter_available"))
|
||
or bool(registry.get("live_search_fallback_available"))
|
||
or bool(registry.get("dashscope_responses_available"))
|
||
):
|
||
risks.append(self._risk("external_query_adapter_missing", "critical", "外部查询执行链不可用", "必须启用 DashScope Responses、MCP adapter 或 bounded live search fallback 中至少一个执行链。"))
|
||
verifier = registry.get("verifier") if isinstance(registry.get("verifier"), dict) else {}
|
||
if not bool(saved.get("verifier_required", True)):
|
||
risks.append(self._risk("external_query_verifier_not_required", "critical", "外部查询未强制 verifier", "外部查询槽必须开启 verifier_required,禁止未验证来源直接出答案。"))
|
||
if not bool(verifier.get("available")):
|
||
risks.append(self._risk("external_query_verifier_unavailable", "critical", "外部查询 verifier 不可用", "发布前必须恢复答案验证器。"))
|
||
source_filtering = registry.get("source_filtering") if isinstance(registry.get("source_filtering"), dict) else {}
|
||
if not bool(saved.get("source_filtering_required", True)):
|
||
risks.append(self._risk("external_query_source_filtering_not_required", "critical", "外部查询未强制来源过滤", "外部查询槽必须开启 source_filtering_required,禁止内部来源混入外部答案。"))
|
||
if not bool(source_filtering.get("fail_closed")):
|
||
risks.append(self._risk("external_query_source_filtering_open", "critical", "外部来源过滤未 fail-closed", "来源过滤必须 fail-closed,无法确认公开来源时只能显式失败或 freshness guard。"))
|
||
if str(saved.get("failure_policy") or "explicit_fail").strip() != "explicit_fail":
|
||
risks.append(self._risk("external_query_failure_policy_invalid", "critical", "外部查询失败策略不合规", "外部查询失败必须显式失败或 freshness guard,禁止回退普通 LLM。"))
|
||
return risks
|
||
|
||
def _engine_requirement_risks(
|
||
self,
|
||
*,
|
||
slot_key: str,
|
||
slot_label: str,
|
||
engine: dict[str, Any],
|
||
role_label: str,
|
||
) -> list[dict[str, Any]]:
|
||
coverage = self._engine_capability_coverage(slot_key=slot_key, engine=engine)
|
||
if bool(coverage.get("satisfied", True)):
|
||
return []
|
||
risks: list[dict[str, Any]] = []
|
||
missing = "、".join(str(item) for item in coverage.get("missing_labels", []) if str(item or "").strip())
|
||
missing_evidence = "、".join(str(item) for item in coverage.get("missing_evidence_labels", []) if str(item or "").strip())
|
||
stale_evidence = "、".join(str(item) for item in coverage.get("stale_evidence_labels", []) if str(item or "").strip())
|
||
if missing:
|
||
risks.append(
|
||
self._risk(
|
||
f"{slot_key}_{role_label}_capability_missing",
|
||
"critical",
|
||
f"{slot_label}{role_label}缺少能力",
|
||
f"缺少:{missing};请在供应商与凭据中选择符合要求的运行时,或先修正供应商能力配置。",
|
||
)
|
||
)
|
||
if missing_evidence:
|
||
risks.append(
|
||
self._risk(
|
||
f"{slot_key}_{role_label}_capability_evidence_missing",
|
||
"critical",
|
||
f"{slot_label}{role_label}缺少能力探测证据",
|
||
f"缺少探测证据:{missing_evidence};请先对供应商执行 capability probe,再发布能力槽位。",
|
||
)
|
||
)
|
||
if stale_evidence:
|
||
risks.append(
|
||
self._risk(
|
||
f"{slot_key}_{role_label}_capability_evidence_stale",
|
||
"critical",
|
||
f"{slot_label}{role_label}能力探测证据已过期",
|
||
f"证据过期:{stale_evidence};请重新执行 capability probe 后再发布能力槽位。",
|
||
)
|
||
)
|
||
if risks:
|
||
return risks
|
||
return [
|
||
self._risk(
|
||
f"{slot_key}_{role_label}_capability_missing",
|
||
"critical",
|
||
f"{slot_label}{role_label}不满足能力要求",
|
||
"能力矩阵未满足;请在供应商与凭据中补齐能力标签,或选择符合要求的运行时。",
|
||
)
|
||
]
|
||
|
||
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 {"pmflow_ai_route_planning", "pmflow_ai_context_governance"}:
|
||
return "语义治理"
|
||
if key == "pmflow_ai_answer_presentation":
|
||
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]]:
|
||
return [
|
||
slot
|
||
for slot in self._build_capability_slots(
|
||
engine_bundle=engine_bundle,
|
||
slot_bundle={},
|
||
config_bundle=config_bundle,
|
||
stage="active",
|
||
)
|
||
if slot.get("slot_key") in {"low", "high"}
|
||
]
|
||
|
||
def _build_capability_slots(
|
||
self,
|
||
*,
|
||
engine_bundle: dict[str, Any],
|
||
slot_bundle: dict[str, Any],
|
||
config_bundle: dict[str, Any],
|
||
stage: str = "active",
|
||
) -> list[dict[str, Any]]:
|
||
engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)]
|
||
engine_by_id = {str(item.get("id") or ""): item for item in engines if str(item.get("id") or "")}
|
||
normalized_bundle = self._normalize_capability_slot_bundle(slot_bundle)
|
||
saved_slots = self._slot_config_map(normalized_bundle, stage=stage)
|
||
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 slot_key, spec in SLOT_SPECS.items():
|
||
saved = saved_slots.get(slot_key) if isinstance(saved_slots.get(slot_key), dict) else {}
|
||
if slot_key == "external_query":
|
||
slots.append(
|
||
self._build_external_query_slot_summary(
|
||
saved=saved,
|
||
spec=spec,
|
||
stage=stage,
|
||
)
|
||
)
|
||
continue
|
||
if slot_key in {"low", "high"}:
|
||
defaults = tier_defaults.get(slot_key) if isinstance(tier_defaults.get(slot_key), dict) else {}
|
||
inferred_engine = self._infer_engine_for_tier(engines=engines, defaults=defaults)
|
||
else:
|
||
defaults = {}
|
||
inferred_engine = None
|
||
primary_engine_id = str(saved.get("primary_engine_id") or "").strip()
|
||
primary_engine = engine_by_id.get(primary_engine_id) if primary_engine_id else inferred_engine
|
||
fallback_engine_ids = [
|
||
str(item or "").strip()
|
||
for item in saved.get("fallback_engine_ids", [])
|
||
if str(item or "").strip() in engine_by_id
|
||
] if isinstance(saved.get("fallback_engine_ids"), list) else []
|
||
fallback_engines = [self._slot_engine_summary(engine_by_id[item]) for item in fallback_engine_ids]
|
||
provider = str((primary_engine or {}).get("provider") or defaults.get("provider") or "").strip().lower()
|
||
model = str((primary_engine or {}).get("model") or defaults.get("model") or "").strip()
|
||
base_url = str((primary_engine or {}).get("base_url") or defaults.get("base_url") or "").strip()
|
||
configured = bool(primary_engine and (bool(primary_engine.get("enabled", True)) or provider == "ollama"))
|
||
source = "槽位绑定" if primary_engine_id else ("供应商配置" if primary_engine else "未配置")
|
||
api_key_masked = _mask_secret(str((primary_engine or {}).get("api_key") or "")) if primary_engine else ""
|
||
failure_policy = str(saved.get("failure_policy") or spec["default_failure_policy"]).strip()
|
||
if failure_policy not in FAILURE_POLICY_LABELS:
|
||
failure_policy = str(spec["default_failure_policy"])
|
||
candidate_engines: list[dict[str, Any]] = []
|
||
blocked_candidate_engines: list[dict[str, Any]] = []
|
||
for item in engines:
|
||
if not bool(item.get("enabled", True)):
|
||
continue
|
||
summary = self._slot_engine_summary(item)
|
||
coverage = self._engine_capability_coverage(slot_key=slot_key, engine=item)
|
||
if not summary:
|
||
continue
|
||
if bool(coverage.get("satisfied", True)):
|
||
candidate_engines.append(summary)
|
||
continue
|
||
blocked_candidate_engines.append(
|
||
{
|
||
**summary,
|
||
"gap_reasons": self._coverage_gap_reasons(coverage),
|
||
}
|
||
)
|
||
slots.append(
|
||
{
|
||
"slot_key": slot_key,
|
||
"tier": slot_key,
|
||
"label": str(spec["label"]),
|
||
"summary": str(spec["summary"]),
|
||
"configurable": bool(spec["configurable"]),
|
||
"protected": bool(spec["protected"]),
|
||
"provider": provider,
|
||
"provider_label": PROVIDER_LABELS.get(provider, provider or "未配置"),
|
||
"model": model,
|
||
"base_url": base_url,
|
||
"configured": configured,
|
||
"config_source": source,
|
||
"api_key_masked": api_key_masked,
|
||
"primary_engine_id": str((primary_engine or {}).get("id") or "") or None,
|
||
"primary_engine": self._slot_engine_summary(primary_engine),
|
||
"fallback_engine_ids": fallback_engine_ids,
|
||
"fallback_engines": fallback_engines,
|
||
"requirements": self._slot_requirement_summary(slot_key),
|
||
"capability_coverage": self._engine_capability_coverage(slot_key=slot_key, engine=primary_engine),
|
||
"candidate_engine_count": len([item for item in candidate_engines if item]),
|
||
"candidate_engines": [item for item in candidate_engines if item][:5],
|
||
"blocked_candidate_engine_count": len([item for item in blocked_candidate_engines if item]),
|
||
"blocked_candidate_engines": [item for item in blocked_candidate_engines if item][:5],
|
||
"failure_policy": failure_policy,
|
||
"failure_policy_label": FAILURE_POLICY_LABELS[failure_policy],
|
||
"allow_scene_downgrade": bool(saved.get("allow_scene_downgrade", False)),
|
||
"updated_at": _iso(saved.get("updated_at")),
|
||
"updated_by": saved.get("updated_by"),
|
||
}
|
||
)
|
||
return slots
|
||
|
||
def _build_external_query_slot_summary(
|
||
self,
|
||
*,
|
||
saved: dict[str, Any],
|
||
spec: dict[str, Any],
|
||
stage: str,
|
||
) -> dict[str, Any]:
|
||
registry = self._external_query_registry_status(binding=saved)
|
||
provider_by_id = {str(item.get("id") or ""): item for item in registry.get("providers", []) if isinstance(item, dict) and str(item.get("id") or "")}
|
||
primary_provider_id = str(saved.get("primary_provider_id") or saved.get("primary_engine_id") or "").strip()
|
||
fallback_provider_ids = [
|
||
str(item or "").strip()
|
||
for item in list(saved.get("fallback_provider_ids") or saved.get("fallback_engine_ids") or [])
|
||
if str(item or "").strip() in provider_by_id and str(item or "").strip() != primary_provider_id
|
||
]
|
||
primary_provider = provider_by_id.get(primary_provider_id) if primary_provider_id else None
|
||
fallback_providers = [provider_by_id[item] for item in fallback_provider_ids if item in provider_by_id]
|
||
configured = bool(primary_provider_id and primary_provider and bool(primary_provider.get("available")))
|
||
coverage = self._external_query_capability_coverage(saved=saved, registry=registry)
|
||
failure_policy = str(saved.get("failure_policy") or spec["default_failure_policy"]).strip()
|
||
if failure_policy != "explicit_fail":
|
||
failure_policy = "explicit_fail"
|
||
return {
|
||
"slot_key": "external_query",
|
||
"tier": "external_query",
|
||
"label": str(spec["label"]),
|
||
"summary": str(spec["summary"]),
|
||
"configurable": bool(spec["configurable"]),
|
||
"protected": bool(spec["protected"]),
|
||
"provider": "external_registry",
|
||
"provider_label": "External Provider Registry",
|
||
"model": str((primary_provider or {}).get("label") or ""),
|
||
"base_url": "external_query_service",
|
||
"configured": configured,
|
||
"config_source": "槽位绑定" if primary_provider_id else "未配置",
|
||
"api_key_masked": "",
|
||
"primary_engine_id": None,
|
||
"primary_engine": None,
|
||
"fallback_engine_ids": [],
|
||
"fallback_engines": [],
|
||
"primary_provider_id": primary_provider_id or None,
|
||
"primary_provider": primary_provider,
|
||
"fallback_provider_ids": fallback_provider_ids,
|
||
"fallback_providers": fallback_providers,
|
||
"verifier_required": True,
|
||
"source_filtering_required": True,
|
||
"external_provider_registry": registry,
|
||
"requirements": self._slot_requirement_summary("external_query"),
|
||
"capability_coverage": coverage,
|
||
"candidate_engine_count": int(registry.get("available_provider_count") or 0),
|
||
"candidate_engines": [],
|
||
"blocked_candidate_engine_count": len([item for item in registry.get("providers", []) if isinstance(item, dict) and not bool(item.get("available"))]),
|
||
"blocked_candidate_engines": [],
|
||
"candidate_external_providers": [item for item in registry.get("providers", []) if isinstance(item, dict) and bool(item.get("available"))],
|
||
"blocked_external_providers": [item for item in registry.get("providers", []) if isinstance(item, dict) and not bool(item.get("available"))],
|
||
"failure_policy": failure_policy,
|
||
"failure_policy_label": FAILURE_POLICY_LABELS[failure_policy],
|
||
"allow_scene_downgrade": False,
|
||
"updated_at": _iso(saved.get("updated_at")),
|
||
"updated_by": saved.get("updated_by"),
|
||
"stage": stage,
|
||
}
|
||
|
||
@staticmethod
|
||
def _external_query_registry_status(*, binding: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
return ExternalProviderRegistry.status_snapshot(binding=binding)
|
||
|
||
def _external_query_capability_coverage(self, *, saved: dict[str, Any], registry: dict[str, Any]) -> dict[str, Any]:
|
||
missing: list[str] = []
|
||
missing_labels: list[str] = []
|
||
primary_provider_id = str(saved.get("primary_provider_id") or saved.get("primary_engine_id") or "").strip()
|
||
providers = [item for item in registry.get("providers", []) if isinstance(item, dict)]
|
||
provider_by_id = {str(item.get("id") or ""): item for item in providers if str(item.get("id") or "")}
|
||
if not primary_provider_id:
|
||
missing.append("primary_provider")
|
||
missing_labels.append("未选择主外部 provider")
|
||
elif primary_provider_id not in provider_by_id:
|
||
missing.append("primary_provider_unknown")
|
||
missing_labels.append("主外部 provider 不存在")
|
||
elif not bool(provider_by_id[primary_provider_id].get("available")):
|
||
missing.append("primary_provider_unavailable")
|
||
missing_labels.append("主外部 provider 不可用")
|
||
if not (
|
||
bool(registry.get("mcp_adapter_available"))
|
||
or bool(registry.get("live_search_fallback_available"))
|
||
or bool(registry.get("dashscope_responses_available"))
|
||
):
|
||
missing.append("external_adapter")
|
||
missing_labels.append("DashScope Responses / MCP adapter / bounded live search fallback 不可用")
|
||
verifier = registry.get("verifier") if isinstance(registry.get("verifier"), dict) else {}
|
||
if not bool(verifier.get("available")):
|
||
missing.append("verifier")
|
||
missing_labels.append("外部答案 verifier 不可用")
|
||
source_filtering = registry.get("source_filtering") if isinstance(registry.get("source_filtering"), dict) else {}
|
||
if not bool(source_filtering.get("fail_closed")):
|
||
missing.append("source_filtering")
|
||
missing_labels.append("来源过滤未 fail-closed")
|
||
if str(saved.get("failure_policy") or "explicit_fail").strip() != "explicit_fail":
|
||
missing.append("failure_policy")
|
||
missing_labels.append("失败策略必须显式失败")
|
||
return {
|
||
"satisfied": len(missing) == 0,
|
||
"missing": missing,
|
||
"missing_labels": missing_labels,
|
||
"missing_evidence": [],
|
||
"missing_evidence_labels": [],
|
||
"stale_evidence": [],
|
||
"stale_evidence_labels": [],
|
||
"capabilities": {
|
||
"external_search_ready": len(missing) == 0,
|
||
"capability_source": "registry",
|
||
"capability_probe_status": "not_required",
|
||
"capability_last_verified_at": None,
|
||
"capability_blocking_reasons": missing_labels,
|
||
},
|
||
}
|
||
|
||
@staticmethod
|
||
def _infer_engine_for_tier(*, engines: list[dict[str, Any]], defaults: dict[str, Any]) -> dict[str, Any] | None:
|
||
provider = str(defaults.get("provider") or "").strip().lower()
|
||
model = str(defaults.get("model") or "").strip().lower()
|
||
for item in engines:
|
||
if not bool(item.get("enabled", True)):
|
||
continue
|
||
if str(item.get("provider") or "").strip().lower() != provider:
|
||
continue
|
||
item_model = str(item.get("model") or "").strip().lower()
|
||
if model and item_model and item_model != model:
|
||
continue
|
||
return item
|
||
for item in engines:
|
||
if bool(item.get("enabled", True)) and str(item.get("provider") or "").strip().lower() == provider:
|
||
return item
|
||
return None
|
||
|
||
@staticmethod
|
||
def _slot_engine_summary(engine: dict[str, Any] | None) -> dict[str, Any] | None:
|
||
if not isinstance(engine, dict):
|
||
return None
|
||
provider = str(engine.get("provider") or "").strip().lower()
|
||
connection_status = str(engine.get("connection_status") or "untested").strip() or "untested"
|
||
capabilities = _normalize_engine_capabilities(
|
||
engine.get("capabilities"),
|
||
provider=provider,
|
||
model=str(engine.get("model") or ""),
|
||
)
|
||
return {
|
||
"id": str(engine.get("id") or ""),
|
||
"name": str(engine.get("name") or PROVIDER_LABELS.get(provider, provider)).strip(),
|
||
"provider": provider,
|
||
"provider_label": PROVIDER_LABELS.get(provider, provider),
|
||
"model": str(engine.get("model") or ""),
|
||
"base_url": str(engine.get("base_url") or ""),
|
||
"enabled": bool(engine.get("enabled", True)),
|
||
"connection_status": connection_status,
|
||
"api_key_masked": _mask_secret(str(engine.get("api_key") or "")),
|
||
"capabilities": capabilities,
|
||
}
|
||
|
||
@staticmethod
|
||
def _slot_requirement_summary(slot_key: str) -> dict[str, Any]:
|
||
requirement = SLOT_REQUIREMENTS.get(slot_key, {})
|
||
required_flags = [str(item) for item in requirement.get("required_flags", []) if str(item or "").strip()]
|
||
forbidden_flags = [str(item) for item in requirement.get("forbidden_flags", []) if str(item or "").strip()]
|
||
any_flags = [str(item) for item in requirement.get("any_flags", []) if str(item or "").strip()]
|
||
min_context = requirement.get("min_context_tokens")
|
||
labels = [CAPABILITY_LABELS.get(item, item) for item in required_flags]
|
||
if any_flags:
|
||
labels.append(" / ".join(CAPABILITY_LABELS.get(item, item) for item in any_flags))
|
||
if min_context:
|
||
labels.append(f"上下文 >= {int(min_context):,} tokens")
|
||
if forbidden_flags:
|
||
labels.extend(f"禁止 {CAPABILITY_LABELS.get(item, item)}" for item in forbidden_flags)
|
||
return {
|
||
"summary": str(requirement.get("summary") or ""),
|
||
"required_flags": required_flags,
|
||
"forbidden_flags": forbidden_flags,
|
||
"any_flags": any_flags,
|
||
"min_context_tokens": min_context,
|
||
"labels": labels,
|
||
}
|
||
|
||
@classmethod
|
||
def _engine_capability_coverage(cls, *, slot_key: str, engine: dict[str, Any] | None) -> dict[str, Any]:
|
||
requirement = SLOT_REQUIREMENTS.get(slot_key, {})
|
||
if not isinstance(engine, dict):
|
||
return {
|
||
"satisfied": False,
|
||
"missing": ["runtime"],
|
||
"missing_labels": ["未选择运行时"],
|
||
"missing_evidence": [],
|
||
"missing_evidence_labels": [],
|
||
"stale_evidence": [],
|
||
"stale_evidence_labels": [],
|
||
"capabilities": _normalize_engine_capabilities({}, provider="", model=""),
|
||
}
|
||
provider = str(engine.get("provider") or "").strip().lower()
|
||
capabilities = _normalize_engine_capabilities(
|
||
engine.get("capabilities"),
|
||
provider=provider,
|
||
model=str(engine.get("model") or ""),
|
||
)
|
||
missing: list[str] = []
|
||
missing_labels: list[str] = []
|
||
missing_evidence: list[str] = []
|
||
missing_evidence_labels: list[str] = []
|
||
stale_evidence: list[str] = []
|
||
stale_evidence_labels: list[str] = []
|
||
|
||
def _has_verified_evidence(key: str) -> bool:
|
||
effective_key = "long_context_32k" if key == "long_context" else key
|
||
effective = capabilities.get("effective_capabilities") if isinstance(capabilities.get("effective_capabilities"), dict) else {}
|
||
if bool(effective.get(effective_key) or effective.get(key)):
|
||
return True
|
||
probe = capabilities.get("capability_probe")
|
||
if isinstance(probe, dict):
|
||
probe_source = str(probe.get("source") or "").strip().lower()
|
||
probe_evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {}
|
||
probe_row = probe_evidence.get(effective_key) or probe_evidence.get(key)
|
||
if isinstance(probe_row, dict) and probe_source == "probed" and _probe_row_is_verified(probe_row, key=effective_key):
|
||
return True
|
||
evidence = capabilities.get("capability_evidence")
|
||
if not isinstance(evidence, dict):
|
||
return False
|
||
row = evidence.get(effective_key) or evidence.get(key)
|
||
if not isinstance(row, dict):
|
||
return False
|
||
return _probe_row_is_verified(row, key=effective_key)
|
||
|
||
def _evidence_row(key: str) -> dict[str, Any] | None:
|
||
probe = capabilities.get("capability_probe")
|
||
evidence_key = "long_context_32k" if key == "long_context" else key
|
||
if isinstance(probe, dict):
|
||
probe_evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {}
|
||
probe_row = probe_evidence.get(evidence_key) or probe_evidence.get(key)
|
||
if isinstance(probe_row, dict):
|
||
return probe_row
|
||
evidence = capabilities.get("capability_evidence")
|
||
if isinstance(evidence, dict):
|
||
row = evidence.get(evidence_key) or evidence.get(key)
|
||
if isinstance(row, dict):
|
||
return row
|
||
return None
|
||
|
||
def _evidence_checked_at(key: str) -> str | None:
|
||
row = _evidence_row(key)
|
||
if not isinstance(row, dict):
|
||
return None
|
||
checked_at = str(row.get("checked_at") or row.get("verified_at") or "").strip()
|
||
if checked_at:
|
||
return checked_at
|
||
return None
|
||
|
||
def _has_stale_evidence(key: str) -> bool:
|
||
row = _evidence_row(key)
|
||
if isinstance(row, dict):
|
||
expires_at = str(row.get("expires_at") or "").strip()
|
||
parsed_expires_at = _parse_iso_datetime(expires_at) if expires_at else None
|
||
if parsed_expires_at is not None and parsed_expires_at < now_cn_naive():
|
||
return True
|
||
checked_at = _evidence_checked_at(key)
|
||
if not checked_at:
|
||
return False
|
||
parsed = _parse_iso_datetime(checked_at)
|
||
if parsed is None:
|
||
return False
|
||
return parsed < (now_cn_naive() - timedelta(days=CAPABILITY_PROBE_EVIDENCE_TTL_DAYS))
|
||
|
||
def _requires_probe_evidence(key: str) -> bool:
|
||
if slot_key not in {"structured", "tool", "long_context"}:
|
||
return False
|
||
return key in {"structured_output", "json_schema", "tool_calling", "long_context", "max_context_tokens"}
|
||
|
||
def _record_capability_gap(key: str, label: str) -> None:
|
||
if not bool(capabilities.get(key)):
|
||
missing.append(key)
|
||
missing_labels.append(label)
|
||
return
|
||
evidence_key = "long_context" if key == "max_context_tokens" else key
|
||
if _requires_probe_evidence(key) and not _has_verified_evidence(evidence_key):
|
||
missing_evidence.append(evidence_key)
|
||
missing_evidence_labels.append(label)
|
||
elif _requires_probe_evidence(key) and _has_stale_evidence(evidence_key):
|
||
stale_evidence.append(evidence_key)
|
||
stale_evidence_labels.append(label)
|
||
|
||
for flag in requirement.get("required_flags", []) or []:
|
||
key = str(flag)
|
||
_record_capability_gap(key, CAPABILITY_LABELS.get(key, key))
|
||
any_flags = [str(item) for item in requirement.get("any_flags", []) or [] if str(item or "").strip()]
|
||
if any_flags and not any(bool(capabilities.get(item)) for item in any_flags):
|
||
missing.append("any:" + ",".join(any_flags))
|
||
missing_labels.append(" 或 ".join(CAPABILITY_LABELS.get(item, item) for item in any_flags))
|
||
elif any_flags and slot_key in {"structured", "tool", "long_context"}:
|
||
verified_any = any(bool(capabilities.get(item)) and _has_verified_evidence(item) for item in any_flags)
|
||
if not verified_any:
|
||
missing_evidence.append("any:" + ",".join(any_flags))
|
||
missing_evidence_labels.append(" 或 ".join(CAPABILITY_LABELS.get(item, item) for item in any_flags))
|
||
elif any(_has_stale_evidence(item) for item in any_flags if bool(capabilities.get(item))):
|
||
stale_evidence.append("any:" + ",".join(any_flags))
|
||
stale_evidence_labels.append(" 或 ".join(CAPABILITY_LABELS.get(item, item) for item in any_flags))
|
||
for flag in requirement.get("forbidden_flags", []) or []:
|
||
key = str(flag)
|
||
if bool(capabilities.get(key)):
|
||
missing.append(f"forbidden:{key}")
|
||
missing_labels.append(f"禁止 {CAPABILITY_LABELS.get(key, key)}")
|
||
min_context = requirement.get("min_context_tokens")
|
||
if min_context:
|
||
actual = int(capabilities.get("max_context_tokens") or 0)
|
||
if actual < int(min_context):
|
||
missing.append("max_context_tokens")
|
||
missing_labels.append(f"上下文窗口不足 {int(min_context):,} tokens")
|
||
elif _requires_probe_evidence("max_context_tokens") and not _has_verified_evidence("long_context"):
|
||
missing_evidence.append("long_context")
|
||
missing_evidence_labels.append(f"上下文 >= {int(min_context):,} tokens")
|
||
elif _requires_probe_evidence("max_context_tokens") and _has_stale_evidence("long_context"):
|
||
stale_evidence.append("long_context")
|
||
stale_evidence_labels.append(f"上下文 >= {int(min_context):,} tokens")
|
||
return {
|
||
"satisfied": len(missing) == 0 and len(missing_evidence) == 0 and len(stale_evidence) == 0,
|
||
"missing": missing,
|
||
"missing_labels": missing_labels,
|
||
"missing_evidence": missing_evidence,
|
||
"missing_evidence_labels": missing_evidence_labels,
|
||
"stale_evidence": stale_evidence,
|
||
"stale_evidence_labels": stale_evidence_labels,
|
||
"capabilities": capabilities,
|
||
}
|
||
|
||
@staticmethod
|
||
def _coverage_gap_reasons(coverage: dict[str, Any]) -> list[str]:
|
||
reasons: list[str] = []
|
||
missing_labels = coverage.get("missing_labels") if isinstance(coverage.get("missing_labels"), list) else []
|
||
missing_evidence_labels = coverage.get("missing_evidence_labels") if isinstance(coverage.get("missing_evidence_labels"), list) else []
|
||
stale_evidence_labels = coverage.get("stale_evidence_labels") if isinstance(coverage.get("stale_evidence_labels"), list) else []
|
||
if missing_labels:
|
||
reasons.append("缺能力:" + "、".join(str(item) for item in missing_labels if str(item or "").strip()))
|
||
if missing_evidence_labels:
|
||
reasons.append("缺证据:" + "、".join(str(item) for item in missing_evidence_labels if str(item or "").strip()))
|
||
if stale_evidence_labels:
|
||
reasons.append("证据过期:" + "、".join(str(item) for item in stale_evidence_labels if str(item or "").strip()))
|
||
return [item for item in reasons if item][:6]
|
||
|
||
def _build_providers(self, *, engine_bundle: dict[str, Any]) -> list[dict[str, Any]]:
|
||
default_fallback_id = str(engine_bundle.get("default_fallback_engine_id") or 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()
|
||
enabled = bool(item.get("enabled", True))
|
||
is_default_fallback = str(item.get("id") or "") == default_fallback_id
|
||
connection_status = str(item.get("connection_status") or "untested").strip() or "untested"
|
||
status_label = self._provider_status_label(
|
||
enabled=enabled,
|
||
is_default_fallback=is_default_fallback,
|
||
connection_status=connection_status,
|
||
)
|
||
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": is_default_fallback,
|
||
"enabled": enabled,
|
||
"is_default_fallback": is_default_fallback,
|
||
"connection_status": connection_status,
|
||
"last_test_status": str(item.get("last_test_status") or ""),
|
||
"last_test_at": _iso(item.get("last_test_at")),
|
||
"last_error_summary": str(item.get("last_error_summary") or ""),
|
||
"status_label": status_label,
|
||
"updated_at": _iso(item.get("updated_at")),
|
||
"available_actions": self._provider_action_permissions(
|
||
enabled=enabled,
|
||
is_default_fallback=is_default_fallback,
|
||
connection_status=connection_status,
|
||
),
|
||
"capabilities": _normalize_engine_capabilities(
|
||
item.get("capabilities"),
|
||
provider=provider,
|
||
model=str(item.get("model") or ""),
|
||
),
|
||
}
|
||
)
|
||
return providers
|
||
|
||
@staticmethod
|
||
def _provider_action_permissions(*, enabled: bool, is_default_fallback: bool, connection_status: str) -> dict[str, bool]:
|
||
connection_available = str(connection_status or "untested").strip().lower() == "available"
|
||
return {
|
||
"enable": not enabled,
|
||
"disable": enabled,
|
||
"edit": True,
|
||
"test": enabled,
|
||
"capability_probe": enabled and connection_available,
|
||
"set_default_fallback": enabled and connection_available and not is_default_fallback,
|
||
"delete": (not enabled) and not is_default_fallback,
|
||
}
|
||
|
||
@staticmethod
|
||
def _provider_status_label(*, enabled: bool, is_default_fallback: bool, connection_status: str) -> str:
|
||
_ = is_default_fallback
|
||
if not enabled:
|
||
return "已停用"
|
||
if connection_status == "available":
|
||
return "可用"
|
||
if connection_status == "test_failed":
|
||
return "测试失败"
|
||
return "未测试"
|
||
|
||
def _build_scene_routes(
|
||
self,
|
||
*,
|
||
routing_bundle: dict[str, Any],
|
||
config_bundle: dict[str, Any],
|
||
function_runtime_profiles: dict[str, Any] | None = None,
|
||
function_observability: dict[str, Any] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
mappings = routing_bundle.get("mappings") if isinstance(routing_bundle.get("mappings"), dict) else {}
|
||
profile_release = function_runtime_profiles if isinstance(function_runtime_profiles, dict) else {}
|
||
active_profiles = profile_release.get("active_profiles") if isinstance(profile_release.get("active_profiles"), dict) else {}
|
||
draft_profiles = profile_release.get("draft_profiles") if isinstance(profile_release.get("draft_profiles"), dict) else {}
|
||
draft_smoke = profile_release.get("draft_last_smoke") if isinstance(profile_release.get("draft_last_smoke"), dict) else {}
|
||
observability = function_observability if isinstance(function_observability, dict) else {}
|
||
routes: list[dict[str, Any]] = []
|
||
for scene_key, functions in SCENE_FUNCTIONS.items():
|
||
contract = scene_contract(scene_key)
|
||
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)
|
||
legacy_tier = override_tier or _normalize_tier((rule or {}).get("tier")) or "low"
|
||
runtime_profile = self._function_runtime_profile_summary(
|
||
function_name=function_name,
|
||
active_profile=active_profiles.get(function_name) if isinstance(active_profiles.get(function_name), dict) else None,
|
||
draft_profile=draft_profiles.get(function_name) if isinstance(draft_profiles.get(function_name), dict) else None,
|
||
active_version=str(profile_release.get("active_version") or ""),
|
||
draft_version=str(profile_release.get("draft_version") or "draft"),
|
||
dirty=bool(profile_release.get("dirty", False)),
|
||
draft_smoke=draft_smoke.get(function_name) if isinstance(draft_smoke.get(function_name), dict) else None,
|
||
observability=observability.get(function_name) if isinstance(observability.get(function_name), dict) else None,
|
||
)
|
||
profile_slot = str(runtime_profile.get("required_slot") or "").strip().lower()
|
||
tier = profile_slot or legacy_tier
|
||
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": "runtime_profile" if profile_slot else ("override" if override_tier else "default"),
|
||
"rule_name": "function_runtime_profile" if profile_slot else (str((rule or {}).get("name") or "") or None),
|
||
"legacy_tier": legacy_tier,
|
||
"legacy_rule_name": str((rule or {}).get("name") or "") or None,
|
||
"protected": function_name in PROTECTED_FUNCTIONS,
|
||
"runtime_profile": runtime_profile,
|
||
}
|
||
)
|
||
default_slot = str(SCENE_DEFAULT_SLOT.get(scene_key) or "").strip().lower()
|
||
primary_tier = "mixed" if len(set(tiers)) > 1 else (tiers[0] if tiers else default_slot)
|
||
routes.append(
|
||
{
|
||
"scene_key": scene_key,
|
||
"label": contract["label"],
|
||
"summary": DEFAULT_SCENE_POLICIES[scene_key],
|
||
"primary_slot_label": (
|
||
"混合策略"
|
||
if primary_tier == "mixed"
|
||
else str((SLOT_SPECS.get(primary_tier) or {}).get("label") or SLOT_LABELS.get(primary_tier, contract["expected_slot_label"]))
|
||
),
|
||
"scene_contract": contract,
|
||
"lifecycle": contract["lifecycle"],
|
||
"scene_family": contract["scene_family"],
|
||
"functions": function_rows,
|
||
}
|
||
)
|
||
return routes
|
||
|
||
def _build_scene_slot_matrix(
|
||
self,
|
||
*,
|
||
scene_routes: list[dict[str, Any]],
|
||
capability_slot_release: dict[str, Any],
|
||
scene_failure_rates: dict[str, dict[str, Any]] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
active_version = str(capability_slot_release.get("active_version") or "unpublished")
|
||
draft_version = str(capability_slot_release.get("draft_version") or "draft")
|
||
active_slots = [
|
||
item
|
||
for item in capability_slot_release.get("active_slots", [])
|
||
if isinstance(item, dict)
|
||
]
|
||
draft_slots = [
|
||
item
|
||
for item in capability_slot_release.get("draft_slots", [])
|
||
if isinstance(item, dict)
|
||
]
|
||
slot_by_key = {
|
||
str(item.get("slot_key") or item.get("tier") or "").strip().lower(): item
|
||
for item in active_slots
|
||
if str(item.get("slot_key") or item.get("tier") or "").strip()
|
||
}
|
||
draft_slot_by_key = {
|
||
str(item.get("slot_key") or item.get("tier") or "").strip().lower(): item
|
||
for item in draft_slots
|
||
if str(item.get("slot_key") or item.get("tier") or "").strip()
|
||
}
|
||
rows: list[dict[str, Any]] = []
|
||
for route in scene_routes:
|
||
scene_key = str(route.get("scene_key") or "").strip()
|
||
contract = route.get("scene_contract") if isinstance(route.get("scene_contract"), dict) else scene_contract(scene_key)
|
||
lifecycle = str(contract.get("lifecycle") or route.get("lifecycle") or "active")
|
||
current_slot = str(SCENE_DEFAULT_SLOT.get(scene_key) or "").strip().lower()
|
||
required_slots = [
|
||
item
|
||
for item in SCENE_SLOT_REQUIREMENTS.get(scene_key, [])
|
||
if str(item or "").strip()
|
||
]
|
||
function_slots = [
|
||
str(item.get("tier") or "").strip().lower()
|
||
for item in route.get("functions", [])
|
||
if isinstance(item, dict) and str(item.get("tier") or "").strip()
|
||
]
|
||
base_slots = [] if not current_slot or current_slot == "mixed" else [current_slot]
|
||
ordered_slots = list(dict.fromkeys([*base_slots, *required_slots, *function_slots]))
|
||
slot_rows = [
|
||
self._build_scene_slot_matrix_slot(
|
||
scene_key=scene_key,
|
||
slot_key=slot_key,
|
||
slot=slot_by_key.get(slot_key),
|
||
runtime_version=active_version,
|
||
is_required=slot_key in required_slots,
|
||
)
|
||
for slot_key in ordered_slots
|
||
]
|
||
route_risks = self._scene_route_slot_risks(scene_key=scene_key, route=route)
|
||
for risk in route_risks:
|
||
slot_key = str(risk.get("slot_key") or "").strip().lower()
|
||
target = next((item for item in slot_rows if item.get("slot_key") == slot_key), None)
|
||
if target is None:
|
||
target = self._build_scene_slot_matrix_slot(
|
||
scene_key=scene_key,
|
||
slot_key=slot_key,
|
||
slot=slot_by_key.get(slot_key),
|
||
runtime_version=active_version,
|
||
is_required=False,
|
||
)
|
||
slot_rows.append(target)
|
||
target["risk_level"] = "critical"
|
||
target["risk_label"] = "保护场景降级"
|
||
target["risk_reasons"] = list(dict.fromkeys([*target.get("risk_reasons", []), str(risk.get("message") or "")]))
|
||
risk_order = {"critical": 3, "warning": 2, "ok": 1}
|
||
scene_risk_level = max(
|
||
(str(item.get("risk_level") or "ok") for item in slot_rows),
|
||
key=lambda value: risk_order.get(value, 0),
|
||
default="ok",
|
||
)
|
||
active_slot = slot_by_key.get(current_slot) if current_slot != "mixed" else None
|
||
draft_slot = draft_slot_by_key.get(current_slot) if current_slot != "mixed" else None
|
||
failure_stats = (scene_failure_rates or {}).get(scene_key) if isinstance(scene_failure_rates, dict) else {}
|
||
required_capabilities = self._scene_required_capabilities(scene_key=scene_key, current_slot=current_slot)
|
||
scene_label = str(route.get("label") or SCENE_LABELS.get(scene_key, scene_key))
|
||
active_runtime = self._scene_runtime_summary(
|
||
slot_key=current_slot,
|
||
slot=active_slot,
|
||
version=active_version,
|
||
source="active",
|
||
)
|
||
draft_runtime = self._scene_runtime_summary(
|
||
slot_key=current_slot,
|
||
slot=draft_slot,
|
||
version=draft_version,
|
||
source="draft",
|
||
)
|
||
capability_gap = self._scene_capability_gap(slot_rows)
|
||
recent_failed_calls = int((failure_stats or {}).get("failed_calls") or 0)
|
||
recent_total_calls = int((failure_stats or {}).get("total_calls") or 0)
|
||
failure_rate = float((failure_stats or {}).get("failure_rate") or 0.0)
|
||
scene_health = self._scene_health(
|
||
lifecycle=lifecycle,
|
||
active_runtime=active_runtime,
|
||
draft_dirty=bool(capability_slot_release.get("dirty", False)),
|
||
capability_gap=capability_gap,
|
||
risk_level=scene_risk_level,
|
||
recent_failed_calls=recent_failed_calls,
|
||
recent_total_calls=recent_total_calls,
|
||
failure_rate=failure_rate,
|
||
)
|
||
release_gate = self._scene_release_gate(
|
||
lifecycle=lifecycle,
|
||
protected=scene_key in PROTECTED_SCENE_KEYS,
|
||
active_runtime=active_runtime,
|
||
draft_runtime=draft_runtime,
|
||
draft_dirty=bool(capability_slot_release.get("dirty", False)),
|
||
capability_gap=capability_gap,
|
||
risk_level=scene_risk_level,
|
||
recent_failed_calls=recent_failed_calls,
|
||
)
|
||
impact_summary = (
|
||
f"{scene_label} 是候选场景,当前仅展示运行合同,不参与 active 槽位发布影响。"
|
||
if lifecycle != "active"
|
||
else self._scene_impact_summary(
|
||
scene_label=scene_label,
|
||
current_slot=current_slot,
|
||
active_runtime=active_runtime,
|
||
draft_runtime=draft_runtime,
|
||
risk_level=scene_risk_level,
|
||
failure_rate=failure_rate,
|
||
)
|
||
)
|
||
rows.append(
|
||
{
|
||
"scene_key": scene_key,
|
||
"scene_label": scene_label,
|
||
"label": scene_label,
|
||
"summary": str(route.get("summary") or DEFAULT_SCENE_POLICIES.get(scene_key, "")),
|
||
"scene_contract": contract,
|
||
"lifecycle": lifecycle,
|
||
"scene_family": str(contract.get("scene_family") or route.get("scene_family") or "unknown"),
|
||
"protected": scene_key in PROTECTED_SCENE_KEYS,
|
||
"required_slot": current_slot,
|
||
"current_slot": current_slot,
|
||
"current_slot_label": (
|
||
"复杂度动态分流"
|
||
if current_slot == "mixed"
|
||
else str((SLOT_SPECS.get(current_slot) or {}).get("label") or SLOT_LABELS.get(current_slot, current_slot or contract.get("expected_slot_label") or "-"))
|
||
),
|
||
"required_capabilities": required_capabilities,
|
||
"active_slot_runtime": active_runtime,
|
||
"active_runtime": active_runtime,
|
||
"draft_slot_runtime": draft_runtime,
|
||
"draft_runtime": draft_runtime,
|
||
"active_version": active_version,
|
||
"draft_dirty": bool(capability_slot_release.get("dirty", False)),
|
||
"runtime_version": active_version,
|
||
"capability_gap": capability_gap,
|
||
"recent_failure_rate": float((failure_stats or {}).get("failure_rate") or 0.0),
|
||
"recent_failure_count": recent_failed_calls,
|
||
"recent_failed_calls": recent_failed_calls,
|
||
"recent_total_calls": recent_total_calls,
|
||
"scene_health": scene_health,
|
||
"release_gate": release_gate,
|
||
"primary_slot_label": str(route.get("primary_slot_label") or ""),
|
||
"risk_level": scene_risk_level,
|
||
"risk_label": self._scene_risk_label(scene_risk_level),
|
||
"impact_summary": impact_summary,
|
||
"slots": slot_rows,
|
||
"functions": route.get("functions", []),
|
||
"function_runtime_profiles": [
|
||
item.get("runtime_profile")
|
||
for item in route.get("functions", [])
|
||
if isinstance(item, dict) and isinstance(item.get("runtime_profile"), dict)
|
||
],
|
||
}
|
||
)
|
||
return rows
|
||
|
||
@staticmethod
|
||
def _scene_capability_gap(slot_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||
missing: list[str] = []
|
||
missing_evidence: list[str] = []
|
||
stale_evidence: list[str] = []
|
||
reasons: list[str] = []
|
||
for slot in slot_rows:
|
||
coverage = slot.get("capability_coverage") if isinstance(slot.get("capability_coverage"), dict) else {}
|
||
missing.extend(str(item) for item in coverage.get("missing_labels", []) if str(item or "").strip())
|
||
missing_evidence.extend(str(item) for item in coverage.get("missing_evidence_labels", []) if str(item or "").strip())
|
||
stale_evidence.extend(str(item) for item in coverage.get("stale_evidence_labels", []) if str(item or "").strip())
|
||
reasons.extend(str(item) for item in slot.get("risk_reasons", []) if str(item or "").strip())
|
||
unique_missing = list(dict.fromkeys(missing))
|
||
unique_missing_evidence = list(dict.fromkeys(missing_evidence))
|
||
unique_stale_evidence = list(dict.fromkeys(stale_evidence))
|
||
unique_reasons = list(dict.fromkeys(reasons))
|
||
return {
|
||
"missing": unique_missing,
|
||
"missing_evidence": unique_missing_evidence,
|
||
"stale_evidence": unique_stale_evidence,
|
||
"reasons": unique_reasons,
|
||
"has_gap": bool(unique_missing or unique_missing_evidence or unique_stale_evidence or unique_reasons),
|
||
"summary": ";".join(unique_reasons[:3])
|
||
or (
|
||
"、".join(
|
||
[
|
||
*[f"缺能力:{item}" for item in unique_missing],
|
||
*[f"缺证据:{item}" for item in unique_missing_evidence],
|
||
*[f"证据过期:{item}" for item in unique_stale_evidence],
|
||
][:3]
|
||
)
|
||
)
|
||
or "无能力缺口",
|
||
}
|
||
|
||
@staticmethod
|
||
def _scene_health(
|
||
*,
|
||
lifecycle: str,
|
||
active_runtime: dict[str, Any],
|
||
draft_dirty: bool,
|
||
capability_gap: dict[str, Any],
|
||
risk_level: str,
|
||
recent_failed_calls: int,
|
||
recent_total_calls: int,
|
||
failure_rate: float,
|
||
) -> dict[str, Any]:
|
||
if lifecycle != "active":
|
||
return {
|
||
"status": "unknown",
|
||
"score": 0,
|
||
"blockers": [],
|
||
"warnings": ["候选场景仅展示运行合同,暂不参与 active 发布门禁。"],
|
||
"evidence_summary": "candidate scene,无运行样本要求。",
|
||
"last_observed_at": None,
|
||
}
|
||
blockers: list[str] = []
|
||
warnings: list[str] = []
|
||
if not bool(active_runtime.get("configured")):
|
||
blockers.append("Active runtime 未绑定")
|
||
if bool(capability_gap.get("has_gap")):
|
||
blockers.extend(str(item) for item in capability_gap.get("reasons", []) if str(item or "").strip())
|
||
for label in capability_gap.get("missing", []) or []:
|
||
blockers.append(f"缺能力:{label}")
|
||
for label in capability_gap.get("missing_evidence", []) or []:
|
||
blockers.append(f"缺证据:{label}")
|
||
for label in capability_gap.get("stale_evidence", []) or []:
|
||
blockers.append(f"证据过期:{label}")
|
||
if risk_level == "critical":
|
||
blockers.append("保护场景存在 critical 路由或槽位风险")
|
||
if recent_total_calls == 0:
|
||
warnings.append("暂无 7d 运行样本")
|
||
elif failure_rate >= 0.3:
|
||
warnings.append(f"7d 失败率 {round(failure_rate * 100)}%")
|
||
elif recent_failed_calls > 0:
|
||
warnings.append(f"7d 失败样本 {recent_failed_calls} 次")
|
||
if draft_dirty:
|
||
warnings.append("存在未发布 draft 影响")
|
||
unique_blockers = list(dict.fromkeys(blockers))
|
||
unique_warnings = list(dict.fromkeys(warnings))
|
||
if unique_blockers:
|
||
status = "blocked"
|
||
score = 30
|
||
elif recent_total_calls == 0:
|
||
status = "unknown"
|
||
score = 55
|
||
elif unique_warnings:
|
||
status = "warning"
|
||
score = 70
|
||
else:
|
||
status = "healthy"
|
||
score = 95
|
||
evidence_summary = (
|
||
";".join(unique_blockers[:2] or unique_warnings[:2])
|
||
or f"7d 样本 {recent_total_calls} 次,失败 {recent_failed_calls} 次。"
|
||
)
|
||
return {
|
||
"status": status,
|
||
"score": score,
|
||
"blockers": unique_blockers,
|
||
"warnings": unique_warnings,
|
||
"evidence_summary": evidence_summary,
|
||
"last_observed_at": None,
|
||
}
|
||
|
||
@staticmethod
|
||
def _scene_release_gate(
|
||
*,
|
||
lifecycle: str,
|
||
protected: bool,
|
||
active_runtime: dict[str, Any],
|
||
draft_runtime: dict[str, Any],
|
||
draft_dirty: bool,
|
||
capability_gap: dict[str, Any],
|
||
risk_level: str,
|
||
recent_failed_calls: int,
|
||
) -> dict[str, Any]:
|
||
affected_by_draft = bool(draft_dirty)
|
||
active_label = str(active_runtime.get("runtime_label") or "")
|
||
draft_label = str(draft_runtime.get("runtime_label") or "")
|
||
if draft_label and draft_label != active_label:
|
||
affected_by_draft = True
|
||
if lifecycle != "active":
|
||
return {
|
||
"publish_blocked": False,
|
||
"blocking_reason": None,
|
||
"affected_by_draft": False,
|
||
"protected_scene_regression": False,
|
||
"suggested_action": "none",
|
||
}
|
||
protected_scene_regression = bool(protected and (risk_level == "critical" or capability_gap.get("has_gap") or not active_runtime.get("configured")))
|
||
publish_blocked = bool(protected_scene_regression)
|
||
blocking_reason = None
|
||
suggested_action = "none"
|
||
if not bool(active_runtime.get("configured")):
|
||
blocking_reason = "Active runtime 未绑定"
|
||
suggested_action = "configure_slot"
|
||
elif (capability_gap.get("missing_evidence") or capability_gap.get("stale_evidence")):
|
||
blocking_reason = capability_gap.get("summary") or "能力证据缺失或过期"
|
||
suggested_action = "probe_capability"
|
||
elif capability_gap.get("missing"):
|
||
blocking_reason = capability_gap.get("summary") or "缺少场景要求的 runtime 能力"
|
||
suggested_action = "add_provider"
|
||
elif recent_failed_calls > 0:
|
||
suggested_action = "inspect_failures"
|
||
return {
|
||
"publish_blocked": publish_blocked,
|
||
"blocking_reason": blocking_reason,
|
||
"affected_by_draft": affected_by_draft,
|
||
"protected_scene_regression": protected_scene_regression,
|
||
"suggested_action": suggested_action,
|
||
}
|
||
|
||
def _scene_required_capabilities(self, *, scene_key: str, current_slot: str) -> list[str]:
|
||
slot_keys = list(SCENE_SLOT_REQUIREMENTS.get(scene_key, []))
|
||
if current_slot and current_slot != "mixed":
|
||
slot_keys.insert(0, current_slot)
|
||
labels: list[str] = []
|
||
for slot_key in dict.fromkeys(slot_keys):
|
||
requirement_labels = self._slot_requirement_summary(str(slot_key)).get("labels") or []
|
||
if requirement_labels:
|
||
labels.extend(str(item) for item in requirement_labels if str(item or "").strip())
|
||
else:
|
||
slot_label = str((SLOT_SPECS.get(str(slot_key)) or {}).get("label") or SLOT_LABELS.get(str(slot_key), str(slot_key)))
|
||
labels.append(f"{slot_label}可用连接")
|
||
return list(dict.fromkeys(labels))
|
||
|
||
def _scene_runtime_summary(
|
||
self,
|
||
*,
|
||
slot_key: str,
|
||
slot: dict[str, Any] | None,
|
||
version: str,
|
||
source: str,
|
||
) -> dict[str, Any]:
|
||
normalized_slot = str(slot_key or "").strip().lower()
|
||
slot_label = "复杂度动态分流" if normalized_slot == "mixed" else str((SLOT_SPECS.get(normalized_slot) or {}).get("label") or SLOT_LABELS.get(normalized_slot, normalized_slot or "-"))
|
||
if normalized_slot == "mixed":
|
||
return {
|
||
"slot_key": normalized_slot,
|
||
"slot_label": slot_label,
|
||
"version": version,
|
||
"configured": True,
|
||
"runtime_label": "按问题复杂度在快速槽/深度槽之间分流",
|
||
"runtime_source": source,
|
||
"primary_engine_id": None,
|
||
}
|
||
primary_engine = (slot or {}).get("primary_engine") if isinstance((slot or {}).get("primary_engine"), dict) else None
|
||
primary_provider = (slot or {}).get("primary_provider") if isinstance((slot or {}).get("primary_provider"), dict) else None
|
||
runtime_label = (
|
||
f"{primary_engine.get('name')} · {primary_engine.get('provider_label')} / {primary_engine.get('model')}"
|
||
if primary_engine
|
||
else (
|
||
f"{primary_provider.get('label')} · external_query_service"
|
||
if primary_provider
|
||
else (
|
||
f"{(slot or {}).get('provider_label')} / {(slot or {}).get('model')}"
|
||
if bool((slot or {}).get("configured"))
|
||
else "未绑定运行时"
|
||
)
|
||
)
|
||
)
|
||
return {
|
||
"slot_key": normalized_slot,
|
||
"slot_label": slot_label,
|
||
"version": version,
|
||
"configured": bool((slot or {}).get("configured")),
|
||
"runtime_label": runtime_label,
|
||
"runtime_source": str((slot or {}).get("config_source") or source),
|
||
"primary_engine_id": str((slot or {}).get("primary_engine_id") or "") or None,
|
||
"primary_provider_id": str((slot or {}).get("primary_provider_id") or "") or None,
|
||
}
|
||
|
||
@staticmethod
|
||
def _function_runtime_profile_summary(
|
||
*,
|
||
function_name: str,
|
||
active_profile: dict[str, Any] | None,
|
||
draft_profile: dict[str, Any] | None,
|
||
active_version: str,
|
||
draft_version: str,
|
||
dirty: bool,
|
||
draft_smoke: dict[str, Any] | None,
|
||
observability: dict[str, Any] | None,
|
||
) -> dict[str, Any]:
|
||
active = _safe_dict(active_profile)
|
||
draft = _safe_dict(draft_profile) or active
|
||
profile_dirty = active != draft
|
||
scene_key = str(draft.get("scene_key") or active.get("scene_key") or "")
|
||
scene = scene_contract(scene_key)
|
||
slot_keys = [
|
||
str(item or "").strip()
|
||
for item in list(scene.get("allowed_slots") or scene.get("required_slots") or [])
|
||
if str(item or "").strip()
|
||
]
|
||
required_slot = str(draft.get("required_slot") or active.get("required_slot") or "")
|
||
if required_slot and required_slot not in slot_keys:
|
||
slot_keys.insert(0, required_slot)
|
||
health_policy = _safe_dict(draft.get("health_policy") or active.get("health_policy"))
|
||
return {
|
||
"function_name": function_name,
|
||
"label": str(draft.get("label") or function_name),
|
||
"scene_key": scene_key,
|
||
"prompt_key": str(draft.get("prompt_key") or active.get("prompt_key") or ""),
|
||
"required_slot": required_slot,
|
||
"slot_options": [
|
||
{
|
||
"slot_key": slot_key,
|
||
"label": str((SLOT_SPECS.get(slot_key) or {}).get("label") or SLOT_LABELS.get(slot_key, slot_key)),
|
||
}
|
||
for slot_key in dict.fromkeys(slot_keys)
|
||
],
|
||
"response_contract": str(draft.get("response_contract") or active.get("response_contract") or ""),
|
||
"active_version": active_version,
|
||
"draft_version": draft_version,
|
||
"dirty": bool(dirty or profile_dirty),
|
||
"active": {
|
||
"max_tokens": int(active.get("max_tokens") or 0),
|
||
"repair_max_tokens": int(active.get("repair_max_tokens") or 0),
|
||
"temperature": float(active.get("temperature") or 0.0),
|
||
"foreground_wait_seconds": int(active.get("foreground_wait_seconds") or 0),
|
||
"provider_timeout_seconds": int(active.get("provider_timeout_seconds") or 0),
|
||
"job_timeout_seconds": int(active.get("job_timeout_seconds") or 0),
|
||
"max_retries": int(active.get("max_retries") or 0),
|
||
"large_input_policy": str(active.get("large_input_policy") or ""),
|
||
"response_mode": str(active.get("response_mode") or ""),
|
||
"response_format": str(active.get("response_format") or ""),
|
||
"runtime_mode": str(active.get("runtime_mode") or "active"),
|
||
"quality_gate_mode": str(active.get("quality_gate_mode") or "strict"),
|
||
},
|
||
"draft": {
|
||
"max_tokens": int(draft.get("max_tokens") or active.get("max_tokens") or 0),
|
||
"repair_max_tokens": int(draft.get("repair_max_tokens") or active.get("repair_max_tokens") or 0),
|
||
"temperature": float(draft.get("temperature") or active.get("temperature") or 0.0),
|
||
"foreground_wait_seconds": int(draft.get("foreground_wait_seconds") or active.get("foreground_wait_seconds") or 0),
|
||
"provider_timeout_seconds": int(draft.get("provider_timeout_seconds") or active.get("provider_timeout_seconds") or 0),
|
||
"job_timeout_seconds": int(draft.get("job_timeout_seconds") or active.get("job_timeout_seconds") or 0),
|
||
"max_retries": int(draft.get("max_retries") or active.get("max_retries") or 0),
|
||
"large_input_policy": str(draft.get("large_input_policy") or active.get("large_input_policy") or ""),
|
||
"response_mode": str(draft.get("response_mode") or active.get("response_mode") or ""),
|
||
"response_format": str(draft.get("response_format") or active.get("response_format") or ""),
|
||
"runtime_mode": str(draft.get("runtime_mode") or active.get("runtime_mode") or "active"),
|
||
"quality_gate_mode": str(draft.get("quality_gate_mode") or active.get("quality_gate_mode") or "strict"),
|
||
},
|
||
"limits": {
|
||
"max_tokens_min": int(draft.get("max_tokens_min") or active.get("max_tokens_min") or 0),
|
||
"max_tokens_max": int(draft.get("max_tokens_max") or active.get("max_tokens_max") or 0),
|
||
"recommended_min_tokens": int(draft.get("recommended_min_tokens") or active.get("recommended_min_tokens") or 0),
|
||
},
|
||
"retry_policy": _safe_dict(draft.get("retry_policy") or active.get("retry_policy")),
|
||
"health_policy": health_policy,
|
||
"smoke_supported": AIFunctionRuntimeProfileService.smoke_supported(function_name),
|
||
"smoke_required_for_publish": bool(health_policy.get("smoke_required_for_publish")),
|
||
"cost_policy": _safe_dict(draft.get("cost_policy") or active.get("cost_policy")),
|
||
"draft_smoke": _safe_dict(draft_smoke),
|
||
"observability": _safe_dict(observability),
|
||
}
|
||
|
||
@staticmethod
|
||
def _scene_impact_summary(
|
||
*,
|
||
scene_label: str,
|
||
current_slot: str,
|
||
active_runtime: dict[str, Any],
|
||
draft_runtime: dict[str, Any],
|
||
risk_level: str,
|
||
failure_rate: float,
|
||
) -> str:
|
||
slot_label = str(active_runtime.get("slot_label") or current_slot or "-")
|
||
active_label = str(active_runtime.get("runtime_label") or "未绑定运行时")
|
||
draft_label = str(draft_runtime.get("runtime_label") or "未绑定运行时")
|
||
if risk_level == "critical":
|
||
return f"{scene_label} 当前依赖 {slot_label},存在阻塞风险;发布前必须先修复槽位能力或运行时绑定。"
|
||
if active_label != draft_label:
|
||
return f"发布草稿会把 {scene_label} 的 {slot_label} 从 {active_label} 切换为 {draft_label}。"
|
||
if failure_rate > 0:
|
||
return f"{scene_label} 当前由 {slot_label} 承载,近 7 天失败率 {round(failure_rate * 100)}%,发布前建议复核证据。"
|
||
return f"{scene_label} 当前由 {slot_label} 承载,active runtime 为 {active_label}。"
|
||
|
||
def _build_scene_slot_matrix_slot(
|
||
self,
|
||
*,
|
||
scene_key: str,
|
||
slot_key: str,
|
||
slot: dict[str, Any] | None,
|
||
runtime_version: str,
|
||
is_required: bool,
|
||
) -> dict[str, Any]:
|
||
slot_key = str(slot_key or "").strip().lower()
|
||
slot_label = str((slot or {}).get("label") or (SLOT_SPECS.get(slot_key) or {}).get("label") or SLOT_LABELS.get(slot_key, slot_key))
|
||
coverage = (slot or {}).get("capability_coverage") if isinstance((slot or {}).get("capability_coverage"), dict) else {}
|
||
risk_reasons: list[str] = []
|
||
configured = bool((slot or {}).get("configured"))
|
||
if not configured:
|
||
risk_reasons.append("未发布 active slot 绑定")
|
||
if coverage and not bool(coverage.get("satisfied", True)):
|
||
missing = [str(item) for item in coverage.get("missing_labels", []) if str(item or "").strip()]
|
||
missing_evidence = [str(item) for item in coverage.get("missing_evidence_labels", []) if str(item or "").strip()]
|
||
stale_evidence = [str(item) for item in coverage.get("stale_evidence_labels", []) if str(item or "").strip()]
|
||
if missing:
|
||
risk_reasons.append(f"缺能力:{'、'.join(missing)}")
|
||
if missing_evidence:
|
||
risk_reasons.append(f"缺证据:{'、'.join(missing_evidence)}")
|
||
if stale_evidence:
|
||
risk_reasons.append(f"证据过期:{'、'.join(stale_evidence)}")
|
||
risk_level = "ok"
|
||
if risk_reasons:
|
||
risk_level = "critical" if scene_key in PROTECTED_SCENE_KEYS or is_required else "warning"
|
||
primary_engine = (slot or {}).get("primary_engine") if isinstance((slot or {}).get("primary_engine"), dict) else None
|
||
primary_provider = (slot or {}).get("primary_provider") if isinstance((slot or {}).get("primary_provider"), dict) else None
|
||
return {
|
||
"slot_key": slot_key,
|
||
"slot_label": slot_label,
|
||
"required": bool(is_required),
|
||
"configured": configured,
|
||
"runtime_version": runtime_version,
|
||
"runtime_source": str((slot or {}).get("config_source") or "未发布"),
|
||
"primary_engine_id": str((slot or {}).get("primary_engine_id") or "") or None,
|
||
"primary_provider_id": str((slot or {}).get("primary_provider_id") or "") or None,
|
||
"primary_engine_label": (
|
||
f"{primary_engine.get('name')} · {primary_engine.get('provider_label')} / {primary_engine.get('model')}"
|
||
if primary_engine
|
||
else (
|
||
f"{primary_provider.get('label')} · external_query_service"
|
||
if primary_provider
|
||
else (
|
||
f"{(slot or {}).get('provider_label')} / {(slot or {}).get('model')}"
|
||
if configured
|
||
else "未发布 active runtime"
|
||
)
|
||
)
|
||
),
|
||
"risk_level": risk_level,
|
||
"risk_label": self._scene_risk_label(risk_level),
|
||
"risk_reasons": risk_reasons,
|
||
"capability_coverage": coverage,
|
||
}
|
||
|
||
@staticmethod
|
||
def _scene_route_slot_risks(*, scene_key: str, route: dict[str, Any]) -> list[dict[str, Any]]:
|
||
allowed = SCENE_FUNCTION_SLOT_ALLOWLIST.get(scene_key)
|
||
if not allowed:
|
||
return []
|
||
risks: list[dict[str, Any]] = []
|
||
for item in route.get("functions", []):
|
||
if not isinstance(item, dict) or not bool(item.get("protected")):
|
||
continue
|
||
slot_key = str(item.get("tier") or "").strip().lower()
|
||
if slot_key and slot_key not in allowed:
|
||
risks.append(
|
||
{
|
||
"slot_key": slot_key,
|
||
"message": f"{item.get('display_name') or item.get('function_name')} 被路由到 {SLOT_LABELS.get(slot_key, slot_key)},不满足该保护场景的能力要求。",
|
||
}
|
||
)
|
||
return risks
|
||
|
||
@staticmethod
|
||
def _scene_risk_label(level: str) -> str:
|
||
if level == "critical":
|
||
return "阻塞"
|
||
if level == "warning":
|
||
return "需关注"
|
||
return "正常"
|
||
|
||
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),
|
||
}
|
||
|
||
def _build_slot_recommendations(
|
||
self,
|
||
*,
|
||
slots: list[dict[str, Any]],
|
||
slot_observability: list[dict[str, Any]],
|
||
) -> list[dict[str, Any]]:
|
||
observability_by_slot = {
|
||
str(item.get("slot_key") or "").strip().lower(): item
|
||
for item in slot_observability
|
||
if isinstance(item, dict)
|
||
}
|
||
recommendations: list[dict[str, Any]] = []
|
||
for slot in slots:
|
||
if not isinstance(slot, dict):
|
||
continue
|
||
slot_key = str(slot.get("slot_key") or slot.get("tier") or "").strip().lower()
|
||
if slot_key not in OBSERVABLE_RUNTIME_SLOTS:
|
||
continue
|
||
recommendations.extend(
|
||
self._slot_evidence_recommendations(slot_key=slot_key, slot=slot)
|
||
)
|
||
recommendations.extend(
|
||
self._slot_observability_recommendations(
|
||
slot_key=slot_key,
|
||
slot=slot,
|
||
observability=observability_by_slot.get(slot_key) or {},
|
||
)
|
||
)
|
||
if slot_key in {"structured", "tool", "long_context"} and int(slot.get("candidate_engine_count") or 0) == 0:
|
||
recommendations.append(
|
||
self._slot_recommendation(
|
||
severity="warning",
|
||
slot_key=slot_key,
|
||
code=f"{slot_key}_no_candidate_runtime",
|
||
reason="该槽位当前没有满足能力要求和证据要求的候选 runtime。",
|
||
evidence_summary="候选 runtime 数量为 0;不满足要求的 runtime 需要补能力、补证据或启用连接。",
|
||
suggested_action="回到供应商与凭据补齐能力标签、API Key、连接状态,或执行 capability probe;必要时接入新的 runtime。",
|
||
)
|
||
)
|
||
return recommendations[:20]
|
||
|
||
def _slot_evidence_recommendations(self, *, slot_key: str, slot: dict[str, Any]) -> list[dict[str, Any]]:
|
||
coverage = slot.get("capability_coverage") if isinstance(slot.get("capability_coverage"), dict) else {}
|
||
missing_evidence_labels = [str(item) for item in coverage.get("missing_evidence_labels", []) if str(item or "").strip()]
|
||
stale_evidence_labels = [str(item) for item in coverage.get("stale_evidence_labels", []) if str(item or "").strip()]
|
||
items: list[dict[str, Any]] = []
|
||
if missing_evidence_labels:
|
||
items.append(
|
||
self._slot_recommendation(
|
||
severity="critical" if slot_key in {"structured", "tool", "long_context"} else "warning",
|
||
slot_key=slot_key,
|
||
code=f"{slot_key}_probe_evidence_missing",
|
||
reason="槽位主 runtime 缺少 verified capability evidence。",
|
||
evidence_summary="缺证据:" + "、".join(missing_evidence_labels),
|
||
suggested_action="先在供应商与凭据执行 capability probe,再回到槽位页校验发布。",
|
||
target_engine_id=str(slot.get("primary_engine_id") or "") or None,
|
||
)
|
||
)
|
||
if stale_evidence_labels:
|
||
items.append(
|
||
self._slot_recommendation(
|
||
severity="warning",
|
||
slot_key=slot_key,
|
||
code=f"{slot_key}_probe_evidence_stale",
|
||
reason="槽位主 runtime 的 capability evidence 已过期。",
|
||
evidence_summary="证据过期:" + "、".join(stale_evidence_labels),
|
||
suggested_action="重新执行 capability probe,确认 runtime 仍满足该槽位能力要求。",
|
||
target_engine_id=str(slot.get("primary_engine_id") or "") or None,
|
||
)
|
||
)
|
||
return items
|
||
|
||
def _slot_observability_recommendations(
|
||
self,
|
||
*,
|
||
slot_key: str,
|
||
slot: dict[str, Any],
|
||
observability: dict[str, Any],
|
||
) -> list[dict[str, Any]]:
|
||
windows = observability.get("windows") if isinstance(observability.get("windows"), dict) else {}
|
||
last_7d = windows.get("last_7d") if isinstance(windows.get("last_7d"), dict) else {}
|
||
if last_7d.get("status") != "sampled":
|
||
return []
|
||
total_calls = int(last_7d.get("total_calls") or 0)
|
||
recommendations: list[dict[str, Any]] = []
|
||
if total_calls >= SLOT_RECOMMENDATION_MIN_CALLS:
|
||
failure_rate = float(last_7d.get("failure_rate") or 0)
|
||
if failure_rate >= SLOT_RECOMMENDATION_FAILURE_RATE_THRESHOLD:
|
||
target = self._first_candidate_engine_id(slot)
|
||
recommendations.append(
|
||
self._slot_recommendation(
|
||
severity="warning",
|
||
slot_key=slot_key,
|
||
code=f"{slot_key}_high_failure_rate",
|
||
reason="该 active slot 近 7 天失败率偏高。",
|
||
evidence_summary=f"7d 调用 {total_calls} 次,失败率 {round(failure_rate * 100)}%。",
|
||
suggested_action="优先补 fallback;若已有候选 runtime,评估切换主 runtime 或调整 fallback chain。",
|
||
target_engine_id=target,
|
||
)
|
||
)
|
||
fallback_hits = int(last_7d.get("fallback_hits") or 0)
|
||
fallback_rate = float(last_7d.get("fallback_rate") or 0)
|
||
if fallback_hits > 0 and fallback_rate >= SLOT_RECOMMENDATION_FALLBACK_RATE_THRESHOLD:
|
||
target = self._first_fallback_engine_id(slot)
|
||
recommendations.append(
|
||
self._slot_recommendation(
|
||
severity="info",
|
||
slot_key=slot_key,
|
||
code=f"{slot_key}_fallback_primary_candidate",
|
||
reason="该 active slot 近 7 天频繁命中 fallback。",
|
||
evidence_summary=f"7d fallback 命中 {fallback_hits} 次,占比 {round(fallback_rate * 100)}%。",
|
||
suggested_action="评估将表现稳定的 fallback runtime 提升为 primary,或保留当前主运行时并补充备用链路。",
|
||
target_engine_id=target,
|
||
)
|
||
)
|
||
degraded_calls = int(last_7d.get("degraded_calls") or 0)
|
||
degraded_rate = float(last_7d.get("degraded_rate") or 0)
|
||
if degraded_calls > 0 and degraded_rate >= SLOT_RECOMMENDATION_DEGRADED_RATE_THRESHOLD:
|
||
recommendations.append(
|
||
self._slot_recommendation(
|
||
severity="critical",
|
||
slot_key=slot_key,
|
||
code=f"{slot_key}_active_slot_degraded",
|
||
reason="该 active slot 发生 degraded runtime 路径。",
|
||
evidence_summary=f"7d degraded 调用 {degraded_calls} 次,占比 {round(degraded_rate * 100)}%。",
|
||
suggested_action="修复当前 active slot 的 runtime/证据,必要时回滚到上一稳定 active 版本。",
|
||
)
|
||
)
|
||
if slot_key == "low":
|
||
recommendations.extend(self._low_slot_cost_latency_recommendations(slot=slot, window=last_7d))
|
||
return recommendations
|
||
|
||
def _low_slot_cost_latency_recommendations(self, *, slot: dict[str, Any], window: dict[str, Any]) -> list[dict[str, Any]]:
|
||
total_calls = int(window.get("total_calls") or 0)
|
||
if total_calls < SLOT_RECOMMENDATION_MIN_CALLS:
|
||
return []
|
||
average_latency_ms = window.get("average_latency_ms")
|
||
estimated_cost = float(window.get("estimated_cost") or 0)
|
||
latency_high = isinstance(average_latency_ms, (int, float)) and float(average_latency_ms) >= LOW_SLOT_LATENCY_WARNING_MS
|
||
cost_high = estimated_cost >= LOW_SLOT_COST_WARNING
|
||
if not latency_high and not cost_high:
|
||
return []
|
||
target = self._low_cost_latency_candidate(slot)
|
||
if not target:
|
||
return []
|
||
reasons = []
|
||
if latency_high:
|
||
reasons.append(f"平均延迟 {round(float(average_latency_ms))}ms")
|
||
if cost_high:
|
||
reasons.append(f"7d 估算成本 ¥{estimated_cost:.2f}")
|
||
return [
|
||
self._slot_recommendation(
|
||
severity="info",
|
||
slot_key="low",
|
||
code="low_cost_latency_candidate",
|
||
reason="快速槽的成本或延迟出现异常。",
|
||
evidence_summary=";".join(reasons) + ";成本/延迟来自 ai_call_logs 估算。",
|
||
suggested_action="评估切换到低成本或低延迟候选 runtime;先保存 draft 并通过发布校验,不要直接发布。",
|
||
target_engine_id=target,
|
||
)
|
||
]
|
||
|
||
@staticmethod
|
||
def _slot_recommendation(
|
||
*,
|
||
severity: str,
|
||
slot_key: str,
|
||
code: str,
|
||
reason: str,
|
||
evidence_summary: str,
|
||
suggested_action: str,
|
||
target_engine_id: str | None = None,
|
||
) -> dict[str, Any]:
|
||
normalized_severity = str(severity or "info").strip().lower()
|
||
if normalized_severity not in {"critical", "warning", "info"}:
|
||
normalized_severity = "info"
|
||
slot_label = str((SLOT_SPECS.get(slot_key) or {}).get("label") or SLOT_LABELS.get(slot_key, slot_key))
|
||
return {
|
||
"severity": normalized_severity,
|
||
"slot_key": slot_key,
|
||
"slot_label": slot_label,
|
||
"code": code,
|
||
"reason": reason,
|
||
"evidence_summary": evidence_summary,
|
||
"suggested_action": suggested_action,
|
||
"target_engine_id": target_engine_id,
|
||
}
|
||
|
||
@staticmethod
|
||
def _first_candidate_engine_id(slot: dict[str, Any]) -> str | None:
|
||
candidates = slot.get("candidate_engines") if isinstance(slot.get("candidate_engines"), list) else []
|
||
current = str(slot.get("primary_engine_id") or "")
|
||
for candidate in candidates:
|
||
if not isinstance(candidate, dict):
|
||
continue
|
||
candidate_id = str(candidate.get("id") or "").strip()
|
||
if candidate_id and candidate_id != current:
|
||
return candidate_id
|
||
return None
|
||
|
||
@staticmethod
|
||
def _first_fallback_engine_id(slot: dict[str, Any]) -> str | None:
|
||
fallback_ids = slot.get("fallback_engine_ids") if isinstance(slot.get("fallback_engine_ids"), list) else []
|
||
for fallback_id in fallback_ids:
|
||
value = str(fallback_id or "").strip()
|
||
if value:
|
||
return value
|
||
fallback_engines = slot.get("fallback_engines") if isinstance(slot.get("fallback_engines"), list) else []
|
||
for fallback in fallback_engines:
|
||
if isinstance(fallback, dict) and str(fallback.get("id") or "").strip():
|
||
return str(fallback.get("id") or "").strip()
|
||
return None
|
||
|
||
def _low_cost_latency_candidate(self, slot: dict[str, Any]) -> str | None:
|
||
candidates = slot.get("candidate_engines") if isinstance(slot.get("candidate_engines"), list) else []
|
||
current = str(slot.get("primary_engine_id") or "")
|
||
for candidate in candidates:
|
||
if not isinstance(candidate, dict):
|
||
continue
|
||
candidate_id = str(candidate.get("id") or "").strip()
|
||
if not candidate_id or candidate_id == current:
|
||
continue
|
||
capabilities = candidate.get("capabilities") if isinstance(candidate.get("capabilities"), dict) else {}
|
||
cost_tier = str(capabilities.get("cost_tier") or "").strip().lower()
|
||
avg_latency = capabilities.get("avg_latency_ms")
|
||
low_latency = isinstance(avg_latency, (int, float)) and float(avg_latency) < LOW_SLOT_LATENCY_WARNING_MS
|
||
if cost_tier == "low" or low_latency:
|
||
return candidate_id
|
||
return self._first_candidate_engine_id(slot)
|
||
|
||
async def _build_slot_observability(self, *, capability_slot_release: dict[str, Any]) -> list[dict[str, Any]]:
|
||
active_slots = {
|
||
str(slot.get("slot_key") or slot.get("tier") or "").strip().lower(): slot
|
||
for slot in capability_slot_release.get("active_slots", [])
|
||
if isinstance(slot, dict)
|
||
}
|
||
active_version = str(capability_slot_release.get("active_version") or "unpublished").strip()
|
||
return [
|
||
{
|
||
"slot_key": slot_key,
|
||
"slot_label": str((SLOT_SPECS.get(slot_key) or {}).get("label") or SLOT_LABELS.get(slot_key, slot_key)),
|
||
"active_version": active_version,
|
||
"active_runtime": self._scene_runtime_summary(
|
||
slot_key=slot_key,
|
||
slot=active_slots.get(slot_key),
|
||
version=active_version,
|
||
source="active",
|
||
),
|
||
"windows": {
|
||
"last_24h": await self._slot_observability_window(slot_key=slot_key, hours=24, sample_limit=4),
|
||
"last_7d": await self._slot_observability_window(slot_key=slot_key, hours=24 * 7, sample_limit=4),
|
||
},
|
||
}
|
||
for slot_key in OBSERVABLE_RUNTIME_SLOTS
|
||
]
|
||
|
||
async def _slot_observability_window(self, *, slot_key: str, hours: int, sample_limit: 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,
|
||
)
|
||
)
|
||
.order_by(AICallLog.created_at.desc(), AICallLog.id.desc())
|
||
)
|
||
).scalars().all()
|
||
matched: list[tuple[AICallLog, dict[str, Any]]] = []
|
||
for row in rows:
|
||
signal = self._row_slot_observability_signal(row)
|
||
if signal.get("slot_key") == slot_key:
|
||
matched.append((row, signal))
|
||
if not matched:
|
||
return self._empty_slot_observability_window(hours=hours)
|
||
|
||
total = len(matched)
|
||
failed = sum(1 for row, _signal in matched if self._is_failed_status(str(row.status or "")))
|
||
fallback_hits = sum(1 for _row, signal in matched if int(signal.get("slot_fallback_index") or 0) > 0)
|
||
degraded_calls = sum(
|
||
1
|
||
for _row, signal in matched
|
||
if bool(signal.get("slot_degraded")) or signal.get("slot_runtime_source") == "active_slot_degraded"
|
||
)
|
||
active_slot_calls = sum(1 for _row, signal in matched if signal.get("slot_runtime_source") == "active_slot")
|
||
degraded_source_calls = sum(1 for _row, signal in matched if signal.get("slot_runtime_source") == "active_slot_degraded")
|
||
total_cost = sum(float(row.cost or Decimal("0")) for row, _signal in matched)
|
||
latencies = [
|
||
latency
|
||
for latency in (self._row_latency_ms(row) for row, _signal in matched)
|
||
if latency is not None
|
||
]
|
||
failure_samples: list[dict[str, Any]] = []
|
||
for row, signal in matched:
|
||
if not self._is_failed_status(str(row.status or "")):
|
||
continue
|
||
failure_samples.append(self._slot_failure_sample(row=row, signal=signal))
|
||
if len(failure_samples) >= sample_limit:
|
||
break
|
||
return {
|
||
"window_hours": hours,
|
||
"status": "sampled",
|
||
"sample_state_label": "已采集运行样本",
|
||
"total_calls": total,
|
||
"failed_calls": failed,
|
||
"failure_rate": round(failed / total, 4) if total else 0.0,
|
||
"fallback_hits": fallback_hits,
|
||
"fallback_rate": round(fallback_hits / total, 4) if total else 0.0,
|
||
"degraded_calls": degraded_calls,
|
||
"degraded_rate": round(degraded_calls / total, 4) if total else 0.0,
|
||
"active_slot_calls": active_slot_calls,
|
||
"active_slot_degraded_calls": degraded_source_calls,
|
||
"average_latency_ms": round(sum(latencies) / len(latencies), 2) if latencies else None,
|
||
"estimated_cost": round(total_cost, 6),
|
||
"estimated_cost_source": "ai_call_logs.cost 估算值,非供应商精确账单",
|
||
"latency_source": "ai_call_logs.latency_ms" if latencies else "当前调用日志未记录延迟",
|
||
"recent_failure_samples": failure_samples,
|
||
}
|
||
|
||
@staticmethod
|
||
def _empty_slot_observability_window(*, hours: int) -> dict[str, Any]:
|
||
return {
|
||
"window_hours": hours,
|
||
"status": "empty",
|
||
"sample_state_label": "暂无运行样本",
|
||
"total_calls": 0,
|
||
"failed_calls": 0,
|
||
"failure_rate": 0.0,
|
||
"fallback_hits": 0,
|
||
"fallback_rate": 0.0,
|
||
"degraded_calls": 0,
|
||
"degraded_rate": 0.0,
|
||
"active_slot_calls": 0,
|
||
"active_slot_degraded_calls": 0,
|
||
"average_latency_ms": None,
|
||
"estimated_cost": 0.0,
|
||
"estimated_cost_source": "ai_call_logs.cost 估算值,非供应商精确账单",
|
||
"latency_source": "当前调用日志未记录延迟",
|
||
"recent_failure_samples": [],
|
||
}
|
||
|
||
def _row_slot_observability_signal(self, row: AICallLog) -> dict[str, Any]:
|
||
meta = self._row_routing_meta(row)
|
||
slot_key = str(
|
||
meta.get("slot_key")
|
||
or getattr(row, "slot_key", None)
|
||
or row.executed_tier
|
||
or row.planned_tier
|
||
or ""
|
||
).strip().lower()
|
||
if slot_key not in OBSERVABLE_RUNTIME_SLOTS:
|
||
return {}
|
||
runtime_source = str(meta.get("slot_runtime_source") or getattr(row, "slot_runtime_source", None) or "").strip().lower()
|
||
routing_decision = str(row.routing_decision or "").strip().lower()
|
||
degraded = bool(meta.get("slot_degraded", False) or getattr(row, "slot_degraded", False))
|
||
if routing_decision == "structured_slot_degraded":
|
||
runtime_source = "active_slot_degraded"
|
||
degraded = True
|
||
if runtime_source not in {"active_slot", "active_slot_degraded"}:
|
||
runtime_source = "active_slot"
|
||
fallback_index = self._safe_int(meta.get("slot_fallback_index") if "slot_fallback_index" in meta else getattr(row, "slot_fallback_index", None))
|
||
return {
|
||
"slot_key": slot_key,
|
||
"slot_runtime_source": runtime_source,
|
||
"slot_fallback_index": fallback_index,
|
||
"slot_degraded": degraded,
|
||
}
|
||
|
||
@staticmethod
|
||
def _row_routing_meta(row: AICallLog) -> dict[str, Any]:
|
||
raw = getattr(row, "routing_meta", None)
|
||
if isinstance(raw, dict):
|
||
return raw
|
||
if isinstance(raw, str) and raw.strip():
|
||
try:
|
||
parsed = json.loads(raw)
|
||
return parsed if isinstance(parsed, dict) else {}
|
||
except Exception:
|
||
return {}
|
||
return {}
|
||
|
||
@staticmethod
|
||
def _row_latency_ms(row: AICallLog) -> float | None:
|
||
raw = getattr(row, "latency_ms", None) or getattr(row, "duration_ms", None)
|
||
try:
|
||
value = float(raw)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return value if value >= 0 else None
|
||
|
||
@staticmethod
|
||
def _safe_int(raw: Any) -> int:
|
||
try:
|
||
return int(raw)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
def _slot_failure_sample(self, *, row: AICallLog, signal: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"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 "")),
|
||
"slot_runtime_source": str(signal.get("slot_runtime_source") or "active_slot"),
|
||
"slot_fallback_index": int(signal.get("slot_fallback_index") or 0),
|
||
"created_at": _iso(row.created_at),
|
||
}
|
||
|
||
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 _scene_failure_rates(self, *, hours: int) -> dict[str, 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()
|
||
function_to_scene = {
|
||
function_name: scene_key
|
||
for scene_key, functions in SCENE_FUNCTIONS.items()
|
||
for function_name in functions
|
||
}
|
||
stats: dict[str, dict[str, int]] = {
|
||
scene_key: {"total_calls": 0, "failed_calls": 0}
|
||
for scene_key in SCENE_LABELS
|
||
}
|
||
for row in rows:
|
||
scene_key = function_to_scene.get(str(row.function_name or ""))
|
||
if not scene_key:
|
||
continue
|
||
stats[scene_key]["total_calls"] += 1
|
||
if self._is_failed_status(str(row.status or "")):
|
||
stats[scene_key]["failed_calls"] += 1
|
||
return {
|
||
scene_key: {
|
||
"total_calls": values["total_calls"],
|
||
"failed_calls": values["failed_calls"],
|
||
"failure_rate": round(values["failed_calls"] / values["total_calls"], 4) if values["total_calls"] else 0.0,
|
||
}
|
||
for scene_key, values in stats.items()
|
||
}
|
||
|
||
async def _function_runtime_observability(self, *, hours: int) -> dict[str, dict[str, Any]]:
|
||
since = now_cn_naive() - timedelta(hours=hours)
|
||
function_names = sorted({function for functions in SCENE_FUNCTIONS.values() for function in functions})
|
||
rows = (
|
||
await self.db.execute(
|
||
select(AICallLog).where(
|
||
and_(
|
||
AICallLog.tenant_id.in_([self.tenant_id, PLATFORM_RUNTIME_TENANT_ID]),
|
||
AICallLog.function_name.in_(function_names),
|
||
AICallLog.created_at >= since,
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
grouped: dict[str, list[AICallLog]] = {}
|
||
for row in rows:
|
||
grouped.setdefault(str(row.function_name or ""), []).append(row)
|
||
|
||
def _completion_tokens(row: AICallLog) -> int:
|
||
payload = row.provider_response_payload if isinstance(row.provider_response_payload, dict) else {}
|
||
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
|
||
return int(usage.get("completion_tokens") or 0)
|
||
|
||
def _finish_reason(row: AICallLog) -> str:
|
||
payload = row.provider_response_payload if isinstance(row.provider_response_payload, dict) else {}
|
||
choices = payload.get("choices") if isinstance(payload.get("choices"), list) else []
|
||
choice = choices[0] if choices and isinstance(choices[0], dict) else {}
|
||
return str(choice.get("finish_reason") or "").strip()
|
||
|
||
def _percentile(values: list[int], percentile: float) -> int:
|
||
ordered = sorted(value for value in values if value > 0)
|
||
if not ordered:
|
||
return 0
|
||
index = min(len(ordered) - 1, max(0, int(round((len(ordered) - 1) * percentile))))
|
||
return ordered[index]
|
||
|
||
result: dict[str, dict[str, Any]] = {}
|
||
for function_name, function_rows in grouped.items():
|
||
latest = max(function_rows, key=lambda item: (item.created_at, item.id))
|
||
failed = [row for row in function_rows if self._is_failed_status(str(row.status or ""))]
|
||
finish_reasons = [_finish_reason(row) for row in function_rows]
|
||
completion_tokens = [_completion_tokens(row) for row in function_rows]
|
||
result[function_name] = {
|
||
"window_hours": hours,
|
||
"total_calls": len(function_rows),
|
||
"failed_calls": len(failed),
|
||
"failure_rate": round(len(failed) / len(function_rows), 4) if function_rows else 0.0,
|
||
"length_finish_count": sum(1 for item in finish_reasons if item in {"length", "max_tokens"}),
|
||
"structured_json_invalid_count": sum(1 for row in function_rows if str(row.status or "") in {"structured_empty", "structured_json_invalid"}),
|
||
"completion_tokens_p50": _percentile(completion_tokens, 0.5),
|
||
"completion_tokens_p95": _percentile(completion_tokens, 0.95),
|
||
"latest_status": str(latest.status or ""),
|
||
"latest_finish_reason": _finish_reason(latest),
|
||
"latest_call_id": int(latest.id),
|
||
"latest_created_at": latest.created_at.isoformat() if latest.created_at else None,
|
||
}
|
||
return result
|
||
|
||
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]] = []
|
||
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", "快速槽和深度槽指向同一运行时", "低阶失败后升级无法获得更强模型能力。"))
|
||
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
|
||
|
||
@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 _same_engine_identity(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()
|
||
function_to_scene = {
|
||
function_name: scene_key
|
||
for scene_key, functions in SCENE_FUNCTIONS.items()
|
||
for function_name in functions
|
||
}
|
||
failures: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
if not self._is_failed_status(str(row.status or "")):
|
||
continue
|
||
scene_key = function_to_scene.get(str(row.function_name or ""))
|
||
failures.append(
|
||
{
|
||
"function_label": self._function_display_name(str(row.function_name or "")),
|
||
"function_name": str(row.function_name or ""),
|
||
"scene_key": scene_key,
|
||
"scene_label": SCENE_LABELS.get(scene_key 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": "语义上下文决策",
|
||
"pmflow_ai_route_planner": "旧 PMFlow AI 路由规划(已下线)",
|
||
"rag_query_semantics": "问答语义与来源策略",
|
||
"rag_followup_anchor_semantics": "续问锚点归位",
|
||
"rag_pending_clarification_selection_semantics": "待澄清项选择",
|
||
"customer_answer_composer": "客户答案表达生成",
|
||
"customer_answer_verifier": "客户答案质检",
|
||
"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"),
|
||
}
|