654 lines
30 KiB
Python
654 lines
30 KiB
Python
"""AI call trace read model for platform AI Ops."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from datetime import datetime, timedelta
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from sqlalchemy import and_, func, or_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..models import AICallLog, Integration
|
||
from ..services.ai_scene_runtime_policy import AISceneRuntimePolicyService
|
||
from ..services.ai_routing_scene_contracts import AI_SCENE_REGISTRY
|
||
|
||
|
||
LLM_ENGINE_INTEGRATION_TYPE = "llm_engine"
|
||
PROVIDER_LABELS = {
|
||
"dashscope": "Qwen / DashScope",
|
||
"openai": "OpenAI 兼容网关",
|
||
"deepseek": "DeepSeek",
|
||
"ollama": "本地 Ollama",
|
||
}
|
||
EXTRA_SCENE_LABELS = {
|
||
"platform_ai_ops": "平台 AI 运营",
|
||
}
|
||
FUNCTION_LABELS = {
|
||
"probe_engine_capabilities": "供应商能力探测汇总(历史兼容)",
|
||
"test_engine_connection": "供应商连接测试",
|
||
"pmflow_launch_draft_v1": "新项目草案生成",
|
||
"pmflow_change_review_extraction_v1": "项目主链变更抽取",
|
||
"space_saved_idea_enrichment_v1": "空间想法结构化",
|
||
}
|
||
PROBE_TARGET_LABELS = {
|
||
"basic_chat": "基础聊天",
|
||
"model_self_report": "模型自报",
|
||
"structured_output": "结构化输出",
|
||
"json_schema": "JSON Schema",
|
||
"tool_calling": "工具调用",
|
||
"streaming": "流式响应",
|
||
"long_context": "长上下文",
|
||
"long_context_32k": "长上下文 32K",
|
||
"long_context_128k": "长上下文 128K",
|
||
"vision_input": "视觉输入",
|
||
"audio_input": "音频输入",
|
||
}
|
||
SENSITIVE_PATTERNS = (
|
||
re.compile(r"(?i)(sk-[a-z0-9][a-z0-9_\-]{12,})"),
|
||
re.compile(r"(?i)(api[_-]?key|authorization|access[_-]?token|refresh[_-]?token|secret)(\s*[=:]\s*)([^\s,;,;]+)"),
|
||
re.compile(r"(?i)(bearer\s+)([a-z0-9._\-]{16,})"),
|
||
re.compile(r"(?<!\d)(1[3-9]\d{9})(?!\d)"),
|
||
re.compile(r"([A-Za-z0-9._%+-]{2,})@([A-Za-z0-9.-]+\.[A-Za-z]{2,})"),
|
||
)
|
||
|
||
SCENE_LABELS = {key: str(value.get("label") or key) for key, value in AI_SCENE_REGISTRY.items()}
|
||
SCENE_BY_FUNCTION = {
|
||
str(function_name): scene_key
|
||
for scene_key, scene in AI_SCENE_REGISTRY.items()
|
||
for function_name in (scene.get("function_names") or [])
|
||
}
|
||
|
||
|
||
class AICallTraceService:
|
||
def __init__(self, db: AsyncSession) -> None:
|
||
self.db = db
|
||
|
||
async def list_traces(
|
||
self,
|
||
*,
|
||
page: int = 1,
|
||
page_size: int = 30,
|
||
status: str | None = None,
|
||
provider: str | None = None,
|
||
model: str | None = None,
|
||
function_name: str | None = None,
|
||
scene_key: str | None = None,
|
||
slot_key: str | None = None,
|
||
prompt_key: str | None = None,
|
||
prompt_source: str | None = None,
|
||
prompt_release_id: int | None = None,
|
||
trace_id: str | None = None,
|
||
tenant_id: int | None = None,
|
||
keyword: str | None = None,
|
||
since_hours: int | None = 168,
|
||
) -> dict[str, Any]:
|
||
page = max(1, int(page or 1))
|
||
page_size = min(max(1, int(page_size or 30)), 100)
|
||
filters = self._build_filters(
|
||
status=status,
|
||
provider=provider,
|
||
model=model,
|
||
function_name=function_name,
|
||
scene_key=scene_key,
|
||
slot_key=slot_key,
|
||
prompt_key=prompt_key,
|
||
prompt_source=prompt_source,
|
||
prompt_release_id=prompt_release_id,
|
||
trace_id=trace_id,
|
||
tenant_id=tenant_id,
|
||
keyword=keyword,
|
||
since_hours=since_hours,
|
||
)
|
||
where_clause = and_(*filters) if filters else None
|
||
count_stmt = select(func.count(AICallLog.id))
|
||
if where_clause is not None:
|
||
count_stmt = count_stmt.where(where_clause)
|
||
total = int((await self.db.execute(count_stmt)).scalar() or 0)
|
||
stmt = select(AICallLog).order_by(AICallLog.created_at.desc(), AICallLog.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||
if where_clause is not None:
|
||
stmt = stmt.where(where_clause)
|
||
rows = list((await self.db.execute(stmt)).scalars().all())
|
||
runtime_index = await self._runtime_display_index(rows)
|
||
return {
|
||
"summary": await self._summary(filters=filters),
|
||
"items": [self._row_summary(row, runtime_index=runtime_index) for row in rows],
|
||
"page": page,
|
||
"page_size": page_size,
|
||
"total": total,
|
||
"has_next": page * page_size < total,
|
||
}
|
||
|
||
async def get_trace(self, trace_id_or_log_id: str) -> dict[str, Any]:
|
||
text = str(trace_id_or_log_id or "").strip()
|
||
stmt = select(AICallLog)
|
||
if text.isdigit():
|
||
stmt = stmt.where(AICallLog.id == int(text))
|
||
else:
|
||
stmt = stmt.where(AICallLog.trace_id == text)
|
||
row = (await self.db.execute(stmt.order_by(AICallLog.created_at.desc(), AICallLog.id.desc()).limit(1))).scalar_one_or_none()
|
||
if not row:
|
||
raise ValueError("AI 调用记录不存在")
|
||
runtime_index = await self._runtime_display_index([row])
|
||
return self._row_detail(row, runtime_index=runtime_index)
|
||
|
||
def _build_filters(
|
||
self,
|
||
*,
|
||
status: str | None,
|
||
provider: str | None,
|
||
model: str | None,
|
||
function_name: str | None,
|
||
scene_key: str | None,
|
||
slot_key: str | None,
|
||
prompt_key: str | None,
|
||
prompt_source: str | None,
|
||
prompt_release_id: int | None,
|
||
trace_id: str | None,
|
||
tenant_id: int | None,
|
||
keyword: str | None,
|
||
since_hours: int | None,
|
||
) -> list[Any]:
|
||
filters: list[Any] = []
|
||
if since_hours and int(since_hours) > 0:
|
||
filters.append(AICallLog.created_at >= datetime.now() - timedelta(hours=min(int(since_hours), 24 * 365)))
|
||
if not function_name:
|
||
filters.append(AICallLog.function_name != "probe_engine_capabilities")
|
||
if status and status != "all":
|
||
filters.append(AICallLog.status == status)
|
||
if provider:
|
||
filters.append(AICallLog.provider == provider)
|
||
if model:
|
||
filters.append(AICallLog.model.ilike(f"%{model.strip()}%"))
|
||
if function_name:
|
||
filters.append(AICallLog.function_name.ilike(f"%{function_name.strip()}%"))
|
||
if scene_key:
|
||
scene_functions = [
|
||
str(function_name)
|
||
for function_name in (AI_SCENE_REGISTRY.get(scene_key, {}).get("function_names") or [])
|
||
if str(function_name).strip()
|
||
]
|
||
if scene_functions:
|
||
filters.append(AICallLog.function_name.in_(scene_functions))
|
||
else:
|
||
filters.append(AICallLog.function_name == "__no_scene_match__")
|
||
if slot_key:
|
||
filters.append(or_(AICallLog.executed_tier == slot_key, AICallLog.planned_tier == slot_key))
|
||
if prompt_key:
|
||
filters.append(AICallLog.prompt_key == str(prompt_key).strip())
|
||
if prompt_source:
|
||
filters.append(AICallLog.prompt_source == str(prompt_source).strip())
|
||
if prompt_release_id is not None:
|
||
filters.append(AICallLog.prompt_release_id == int(prompt_release_id))
|
||
if trace_id:
|
||
filters.append(AICallLog.trace_id.ilike(f"%{trace_id.strip()}%"))
|
||
if tenant_id:
|
||
filters.append(AICallLog.tenant_id == int(tenant_id))
|
||
if keyword:
|
||
value = f"%{keyword.strip()}%"
|
||
filters.append(
|
||
or_(
|
||
AICallLog.function_name.ilike(value),
|
||
AICallLog.prompt_key.ilike(value),
|
||
AICallLog.prompt.ilike(value),
|
||
AICallLog.response.ilike(value),
|
||
)
|
||
)
|
||
return filters
|
||
|
||
async def _summary(self, *, filters: list[Any]) -> dict[str, Any]:
|
||
where_clause = and_(*filters) if filters else None
|
||
stmt = select(
|
||
func.count(AICallLog.id),
|
||
func.sum(AICallLog.tokens_used),
|
||
func.sum(AICallLog.cost),
|
||
func.avg(AICallLog.latency_ms),
|
||
)
|
||
if where_clause is not None:
|
||
stmt = stmt.where(where_clause)
|
||
total, tokens, cost, latency = (await self.db.execute(stmt)).one()
|
||
status_rows_stmt = select(AICallLog.status, func.count(AICallLog.id)).group_by(AICallLog.status)
|
||
provider_rows_stmt = select(AICallLog.provider, func.count(AICallLog.id)).group_by(AICallLog.provider).order_by(func.count(AICallLog.id).desc()).limit(5)
|
||
if where_clause is not None:
|
||
status_rows_stmt = status_rows_stmt.where(where_clause)
|
||
provider_rows_stmt = provider_rows_stmt.where(where_clause)
|
||
statuses = {str(status or "unknown"): int(count or 0) for status, count in (await self.db.execute(status_rows_stmt)).all()}
|
||
top_providers = [
|
||
{
|
||
"provider": str(provider or "unknown"),
|
||
"provider_code": str(provider or "unknown"),
|
||
"provider_label": self._provider_label(provider),
|
||
"count": int(count or 0),
|
||
}
|
||
for provider, count in (await self.db.execute(provider_rows_stmt)).all()
|
||
]
|
||
total_count = int(total or 0)
|
||
failed_count = sum(count for status, count in statuses.items() if self._is_failed_status(status))
|
||
return {
|
||
"total_calls": total_count,
|
||
"success_calls": max(total_count - failed_count, 0),
|
||
"failed_calls": failed_count,
|
||
"success_rate": round((total_count - failed_count) / total_count, 4) if total_count else 0,
|
||
"tokens_used": int(tokens or 0),
|
||
"estimated_cost": self._number(cost),
|
||
"average_latency_ms": round(float(latency), 2) if latency is not None else None,
|
||
"status_counts": statuses,
|
||
"top_providers": top_providers,
|
||
}
|
||
|
||
def _row_summary(self, row: AICallLog, *, runtime_index: dict[str, dict[str, str]] | None = None) -> dict[str, Any]:
|
||
meta = self._row_meta(row)
|
||
scene_key = self._scene_key(row, meta)
|
||
slot_key = self._slot_key(row, meta)
|
||
runtime = self._runtime_display(row, meta, runtime_index or {})
|
||
provider_usage = self._provider_usage(row, meta)
|
||
return {
|
||
"id": row.id,
|
||
"trace_id": row.trace_id,
|
||
"tenant_id": row.tenant_id,
|
||
"user_id": row.user_id,
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
"function_name": row.function_name,
|
||
"function_label": self._function_label(row.function_name),
|
||
"scene_key": scene_key,
|
||
"scene_label": self._scene_label(scene_key),
|
||
"status": row.status,
|
||
"status_label": self._status_label(row.status),
|
||
"provider": runtime["provider_code"] or None,
|
||
"provider_code": runtime["provider_code"] or None,
|
||
"provider_label": runtime["provider_label"] or None,
|
||
"runtime_label": runtime["runtime_label"] or None,
|
||
"engine_id": runtime["engine_id"] or None,
|
||
"engine_name": runtime["engine_name"] or None,
|
||
"model": row.model or str(meta.get("model") or "") or None,
|
||
"slot_key": slot_key,
|
||
"slot_label": self._slot_label(slot_key),
|
||
"prompt_key": row.prompt_key or str(meta.get("prompt_key") or "") or None,
|
||
"prompt_release_id": row.prompt_release_id if row.prompt_release_id is not None else meta.get("prompt_release_id"),
|
||
"prompt_source": row.prompt_source or str(meta.get("prompt_source") or "") or None,
|
||
"routing_decision": row.routing_decision,
|
||
"route_source": row.route_source,
|
||
"route_rule_name": row.route_rule_name,
|
||
"tokens_used": int(row.tokens_used or 0),
|
||
"prompt_tokens": provider_usage.get("prompt_tokens"),
|
||
"completion_tokens": provider_usage.get("completion_tokens"),
|
||
"total_tokens": provider_usage.get("total_tokens"),
|
||
"request_max_tokens": self._request_max_tokens(row),
|
||
"finish_reason": self._finish_reason(row),
|
||
"function_runtime_max_tokens": meta.get("function_runtime_max_tokens"),
|
||
"function_runtime_profile_version": meta.get("function_runtime_profile_version") or None,
|
||
"estimated_cost": self._number(row.cost),
|
||
"latency_ms": row.latency_ms,
|
||
"prompt_preview": self._preview(row.prompt),
|
||
"response_preview": self._preview(row.response or ""),
|
||
"error_summary": self._error_summary(row),
|
||
"error_diagnosis": self._error_diagnosis(row, meta),
|
||
}
|
||
|
||
def _row_detail(self, row: AICallLog, *, runtime_index: dict[str, dict[str, str]] | None = None) -> dict[str, Any]:
|
||
meta = self._row_meta(row)
|
||
summary = self._row_summary(row, runtime_index=runtime_index)
|
||
return {
|
||
**summary,
|
||
"request": {
|
||
"prompt_redacted": self._redact(row.prompt),
|
||
"prompt_chars": len(row.prompt or ""),
|
||
"provider_request_payload_redacted": self._redact_payload(row.provider_request_payload),
|
||
"provider_request_payload_chars": self._payload_chars(row.provider_request_payload),
|
||
"provider_payload_missing_reason": str(meta.get("upstream_missing_reason") or "").strip() or None,
|
||
"upstream_request_sent": meta.get("upstream_request_sent") if isinstance(meta.get("upstream_request_sent"), bool) else None,
|
||
},
|
||
"response": {
|
||
"response_redacted": self._redact(row.response or ""),
|
||
"response_chars": len(row.response or ""),
|
||
"looks_truncated": "[ai_call_log_truncated]" in str(row.response or ""),
|
||
"provider_response_payload_redacted": self._redact_payload(row.provider_response_payload),
|
||
"provider_response_payload_chars": self._payload_chars(row.provider_response_payload),
|
||
"provider_attempts_redacted": self._redact_payload(row.provider_attempts),
|
||
"provider_attempts_count": len(row.provider_attempts or []) if isinstance(row.provider_attempts, list) else 0,
|
||
},
|
||
"routing": {
|
||
"planned_tier": row.planned_tier,
|
||
"executed_tier": row.executed_tier,
|
||
"routing_decision": row.routing_decision,
|
||
"route_source": row.route_source,
|
||
"route_rule_name": row.route_rule_name,
|
||
"routing_config_version": row.routing_config_version,
|
||
"routing_config_updated_at": row.routing_config_updated_at,
|
||
"routing_meta": meta,
|
||
},
|
||
"probe": self._probe_detail(meta),
|
||
"audit": {
|
||
"raw_payload_available": bool(row.provider_request_payload or row.provider_response_payload or row.provider_attempts),
|
||
"raw_payload_policy": "已落库真实上游 provider request/response payload;平台审计详情默认展示完整脱敏视图,不在客户侧暴露。",
|
||
},
|
||
"runtime_policy": self._runtime_policy_detail(row, meta),
|
||
}
|
||
|
||
@staticmethod
|
||
def _provider_usage(row: AICallLog, meta: dict[str, Any]) -> dict[str, 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 {}
|
||
if not usage and isinstance(meta.get("provider_usage"), dict):
|
||
usage = meta.get("provider_usage") or {}
|
||
result: dict[str, int] = {}
|
||
for source_key, target_key in (
|
||
("prompt_tokens", "prompt_tokens"),
|
||
("completion_tokens", "completion_tokens"),
|
||
("total_tokens", "total_tokens"),
|
||
("input_tokens", "prompt_tokens"),
|
||
("output_tokens", "completion_tokens"),
|
||
):
|
||
if source_key not in usage:
|
||
continue
|
||
try:
|
||
value = int(usage.get(source_key) or 0)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if value > 0:
|
||
result[target_key] = value
|
||
if "total_tokens" not in result and (result.get("prompt_tokens") or result.get("completion_tokens")):
|
||
result["total_tokens"] = int(result.get("prompt_tokens") or 0) + int(result.get("completion_tokens") or 0)
|
||
return result
|
||
|
||
@staticmethod
|
||
def _request_max_tokens(row: AICallLog) -> int | None:
|
||
payload = row.provider_request_payload if isinstance(row.provider_request_payload, dict) else {}
|
||
try:
|
||
value = int(payload.get("max_tokens") or 0)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return value if value > 0 else None
|
||
|
||
@staticmethod
|
||
def _finish_reason(row: AICallLog) -> str | None:
|
||
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 {}
|
||
text = str(choice.get("finish_reason") or "").strip()
|
||
return text or None
|
||
|
||
@staticmethod
|
||
def _probe_detail(meta: dict[str, Any]) -> dict[str, Any] | None:
|
||
if not isinstance(meta, dict):
|
||
return None
|
||
probe_run_id = str(meta.get("probe_run_id") or "").strip()
|
||
probe_target = str(meta.get("probe_target") or "").strip()
|
||
probe_targets = meta.get("probe_targets") if isinstance(meta.get("probe_targets"), list) else []
|
||
if not probe_run_id and not probe_target and not probe_targets:
|
||
return None
|
||
return {
|
||
"probe_run_id": probe_run_id or None,
|
||
"mode": str(meta.get("probe_mode") or "").strip() or None,
|
||
"target": probe_target or None,
|
||
"target_label": PROBE_TARGET_LABELS.get(probe_target, probe_target) if probe_target else None,
|
||
"targets": probe_targets,
|
||
"method": str(meta.get("probe_method") or "").strip() or None,
|
||
"status": str(meta.get("probe_status") or "").strip() or None,
|
||
"evidence_strength": str(meta.get("evidence_strength") or "").strip() or None,
|
||
"failure_code": str(meta.get("failure_code") or "").strip() or None,
|
||
"summary": meta.get("probe_summary") if isinstance(meta.get("probe_summary"), dict) else {},
|
||
"direct_operation": bool(meta.get("direct_operation")),
|
||
}
|
||
|
||
@staticmethod
|
||
def _row_meta(row: AICallLog) -> dict[str, Any]:
|
||
if isinstance(row.routing_meta, dict):
|
||
return row.routing_meta
|
||
response = str(row.response or "")
|
||
if response.strip().startswith("{"):
|
||
try:
|
||
parsed = json.loads(response)
|
||
if isinstance(parsed, dict) and isinstance(parsed.get("routing_meta"), dict):
|
||
return parsed["routing_meta"]
|
||
except Exception:
|
||
return {}
|
||
return {}
|
||
|
||
def _redact_payload(self, payload: Any) -> str:
|
||
if payload is None:
|
||
return ""
|
||
try:
|
||
text = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
||
except Exception:
|
||
text = str(payload)
|
||
return self._redact(text)
|
||
|
||
@staticmethod
|
||
def _payload_chars(payload: Any) -> int:
|
||
if payload is None:
|
||
return 0
|
||
try:
|
||
return len(json.dumps(payload, ensure_ascii=False, default=str))
|
||
except Exception:
|
||
return len(str(payload))
|
||
|
||
async def _runtime_display_index(self, rows: list[AICallLog]) -> dict[str, dict[str, str]]:
|
||
tenant_ids = {int(row.tenant_id) for row in rows if row.tenant_id is not None}
|
||
if not tenant_ids:
|
||
return {}
|
||
stmt = select(Integration).where(
|
||
Integration.type == LLM_ENGINE_INTEGRATION_TYPE,
|
||
Integration.tenant_id.in_(tenant_ids),
|
||
)
|
||
result = await self.db.execute(stmt)
|
||
entries: dict[str, dict[str, str]] = {}
|
||
provider_model_candidates: dict[str, list[dict[str, str]]] = {}
|
||
for integration in result.scalars().all():
|
||
config = integration.config if isinstance(integration.config, dict) else {}
|
||
for engine in config.get("engines") or []:
|
||
if not isinstance(engine, dict):
|
||
continue
|
||
provider_code = str(engine.get("provider") or "").strip().lower()
|
||
model = str(engine.get("model") or "").strip()
|
||
engine_id = str(engine.get("id") or "").strip()
|
||
item = {
|
||
"provider_code": provider_code,
|
||
"provider_label": self._provider_label(provider_code),
|
||
"model": model,
|
||
"engine_id": engine_id,
|
||
"engine_name": str(engine.get("name") or "").strip(),
|
||
}
|
||
if engine_id:
|
||
entries[self._runtime_key(integration.tenant_id, "engine", engine_id)] = item
|
||
if provider_code and model:
|
||
key = self._runtime_key(integration.tenant_id, "provider_model", f"{provider_code}:{model}")
|
||
provider_model_candidates.setdefault(key, []).append(item)
|
||
for key, candidates in provider_model_candidates.items():
|
||
names = {item.get("engine_name") or "" for item in candidates}
|
||
if len(candidates) == 1 or len(names) == 1:
|
||
entries[key] = candidates[0]
|
||
return entries
|
||
|
||
def _runtime_display(self, row: AICallLog, meta: dict[str, Any], runtime_index: dict[str, dict[str, str]]) -> dict[str, str]:
|
||
provider_code = str(row.provider or meta.get("provider_code") or meta.get("provider") or "").strip().lower()
|
||
model = str(row.model or meta.get("model") or "").strip()
|
||
engine_id = str(meta.get("engine_id") or "").strip()
|
||
matched = {}
|
||
if engine_id:
|
||
matched = runtime_index.get(self._runtime_key(row.tenant_id, "engine", engine_id), {})
|
||
if not matched and provider_code and model:
|
||
matched = runtime_index.get(self._runtime_key(row.tenant_id, "provider_model", f"{provider_code}:{model}"), {})
|
||
provider_label = str(meta.get("provider_label") or matched.get("provider_label") or self._provider_label(provider_code)).strip()
|
||
engine_name = str(meta.get("engine_name") or matched.get("engine_name") or "").strip()
|
||
return {
|
||
"provider_code": provider_code,
|
||
"provider_label": provider_label,
|
||
"runtime_label": engine_name or provider_label or provider_code,
|
||
"engine_id": engine_id or str(matched.get("engine_id") or "").strip(),
|
||
"engine_name": engine_name,
|
||
}
|
||
|
||
@staticmethod
|
||
def _runtime_key(tenant_id: Any, kind: str, value: str) -> str:
|
||
return f"{int(tenant_id or 0)}:{kind}:{value}"
|
||
|
||
@staticmethod
|
||
def _provider_label(provider: Any) -> str:
|
||
value = str(provider or "").strip().lower()
|
||
if not value or value == "unknown":
|
||
return "unknown" if value == "unknown" else ""
|
||
return PROVIDER_LABELS.get(value, value)
|
||
|
||
@staticmethod
|
||
def _number(value: Any) -> float:
|
||
if value is None:
|
||
return 0.0
|
||
if isinstance(value, Decimal):
|
||
return float(value)
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
@staticmethod
|
||
def _scene_key(row: AICallLog, meta: dict[str, Any]) -> str | None:
|
||
scene_key = str(meta.get("scene_key") or meta.get("business_scene") or "").strip()
|
||
if scene_key:
|
||
return scene_key
|
||
return SCENE_BY_FUNCTION.get(str(row.function_name or ""))
|
||
|
||
@staticmethod
|
||
def _scene_label(scene_key: str | None) -> str:
|
||
key = str(scene_key or "").strip()
|
||
if not key:
|
||
return "未归类"
|
||
return EXTRA_SCENE_LABELS.get(key) or SCENE_LABELS.get(key, key)
|
||
|
||
@staticmethod
|
||
def _function_label(function_name: str | None) -> str:
|
||
value = str(function_name or "").strip()
|
||
if value.startswith("probe_capability_"):
|
||
target = value.removeprefix("probe_capability_")
|
||
return f"能力探测:{PROBE_TARGET_LABELS.get(target, target)}"
|
||
return FUNCTION_LABELS.get(value, value or "未记录")
|
||
|
||
@staticmethod
|
||
def _slot_key(row: AICallLog, meta: dict[str, Any]) -> str | None:
|
||
return (
|
||
str(meta.get("slot_key") or "").strip()
|
||
or str(row.executed_tier or "").strip()
|
||
or str(row.planned_tier or "").strip()
|
||
or None
|
||
)
|
||
|
||
@staticmethod
|
||
def _slot_label(slot_key: str | None) -> str:
|
||
return {
|
||
"low": "快速槽",
|
||
"high": "深度槽",
|
||
"structured": "结构化槽",
|
||
"long_context": "长上下文槽",
|
||
"tool": "工具槽",
|
||
"external_query": "外部查询槽",
|
||
}.get(str(slot_key or ""), str(slot_key or "未标注"))
|
||
|
||
@staticmethod
|
||
def _status_label(status: str | None) -> str:
|
||
text = str(status or "").strip()
|
||
if text in {"success", "ok"}:
|
||
return "成功"
|
||
if text == "structured_timeout":
|
||
return "模型超时"
|
||
if text in {"structured_json_invalid", "structured_invalid"}:
|
||
return "结构化 JSON 无效"
|
||
if text == "structured_empty":
|
||
return "模型空响应"
|
||
if text == "structured_http_error":
|
||
return "供应商 HTTP 异常"
|
||
if "structured" in text:
|
||
return "结构化失败"
|
||
if "capability_probe" in text or text.startswith("capability_"):
|
||
return "能力探测"
|
||
if text in {"failed", "error"} or "fail" in text:
|
||
return "失败"
|
||
return text or "未知"
|
||
|
||
@staticmethod
|
||
def _is_failed_status(status: str) -> bool:
|
||
text = str(status or "").strip().lower()
|
||
return text not in {"success", "ok"} and (
|
||
"fail" in text or "error" in text or "invalid" in text or "empty" in text or "timeout" in text
|
||
)
|
||
|
||
@classmethod
|
||
def _error_summary(cls, row: AICallLog) -> str | None:
|
||
if not cls._is_failed_status(str(row.status or "")):
|
||
return None
|
||
response = str(row.response or "").strip()
|
||
if response.startswith("[error]"):
|
||
return cls._redact(response[:300])
|
||
return cls._redact(response[:180]) if response else cls._status_label(row.status)
|
||
|
||
@classmethod
|
||
def _error_diagnosis(cls, row: AICallLog, meta: dict[str, Any]) -> dict[str, Any] | None:
|
||
status = str(row.status or "").strip().lower()
|
||
attempts = row.provider_attempts if isinstance(row.provider_attempts, list) else []
|
||
attempt_errors = [
|
||
{
|
||
"status": str(item.get("status") or ""),
|
||
"error_type": str(item.get("error_type") or ""),
|
||
"error": cls._redact(str(item.get("error") or "")[:300]),
|
||
}
|
||
for item in attempts
|
||
if isinstance(item, dict) and (item.get("error_type") or item.get("error") or item.get("status") == "request_error")
|
||
]
|
||
if status == "structured_timeout":
|
||
recommendation = AISceneRuntimePolicyService.timeout_recommendation(status=status, routing_meta=meta) or {}
|
||
return {
|
||
"code": "model_read_timeout",
|
||
"label": "模型读超时",
|
||
"summary": "上游模型在规定时间内没有返回结构化草案或结构化结果。",
|
||
"attempt_count": len(attempts),
|
||
"attempt_errors": attempt_errors,
|
||
"recommendation": recommendation,
|
||
}
|
||
if status in {"structured_json_invalid", "structured_invalid", "structured_empty", "structured_http_error"}:
|
||
return {
|
||
"code": status,
|
||
"label": cls._status_label(status),
|
||
"summary": cls._error_summary(row) or cls._status_label(status),
|
||
"attempt_count": len(attempts),
|
||
"attempt_errors": attempt_errors,
|
||
}
|
||
return None
|
||
|
||
@staticmethod
|
||
def _runtime_policy_detail(row: AICallLog, meta: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"policy_id": str(meta.get("scene_timeout_policy_id") or "").strip() or None,
|
||
"policy_version": str(meta.get("scene_timeout_policy_version") or "").strip() or None,
|
||
"input_bucket": str(meta.get("input_bucket") or "").strip() or None,
|
||
"input_tokens_estimate": meta.get("input_tokens_estimate"),
|
||
"foreground_wait_seconds": meta.get("foreground_wait_seconds"),
|
||
"provider_timeout_seconds": meta.get("provider_timeout_seconds"),
|
||
"job_timeout_seconds": meta.get("job_timeout_seconds"),
|
||
"max_retries": meta.get("max_retries"),
|
||
"scene_fallback_policy": str(meta.get("scene_fallback_policy") or "").strip() or None,
|
||
"large_input_policy": str(meta.get("large_input_policy") or "").strip() or None,
|
||
"timeout_policy_source": str(meta.get("timeout_policy_source") or "").strip() or None,
|
||
"timeout_policy_reason": str(meta.get("timeout_policy_reason") or "").strip() or None,
|
||
"latency_ms": row.latency_ms,
|
||
}
|
||
|
||
@classmethod
|
||
def _preview(cls, text: str) -> str:
|
||
redacted = cls._redact(text)
|
||
return redacted[:180] + ("..." if len(redacted) > 180 else "")
|
||
|
||
@staticmethod
|
||
def _redact(text: str) -> str:
|
||
value = str(text or "")
|
||
for pattern in SENSITIVE_PATTERNS:
|
||
if pattern.pattern.startswith("([A-Za-z0-9"):
|
||
value = pattern.sub(lambda match: f"{match.group(1)[:2]}***@{match.group(2)}", value)
|
||
elif "api" in pattern.pattern.lower() or "authorization" in pattern.pattern.lower():
|
||
value = pattern.sub(lambda match: f"{match.group(1)}{match.group(2)}[REDACTED]", value)
|
||
elif "bearer" in pattern.pattern.lower():
|
||
value = pattern.sub(lambda match: f"{match.group(1)}[REDACTED]", value)
|
||
else:
|
||
value = pattern.sub("[REDACTED]", value)
|
||
return value
|