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