PMFlow/backend/app/services/ai_call_trace_service.py

704 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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, PMFlowLaunchDraftJob
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 兼容网关",
"zhipu": "智谱 BigModel",
"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": "空间想法结构化",
"pmflow_ai_route_planner": "旧 PMFlow AI 路由规划(已下线)",
"rag_query_semantics": "问答语义与来源策略",
"pmflow_semantic_context_decision": "上下文与主链边界决策",
"rag_followup_anchor_semantics": "续问锚点归位",
"rag_pending_clarification_selection_semantics": "待澄清项选择",
"rag_answer_scene_semantics": "问答场景选择",
"customer_answer_composer": "客户答案表达生成",
"customer_answer_verifier": "客户答案质检",
"pmflow_ai_operating_mission": "Space AI Operating Mission 生成",
}
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)
operator_user_index = await self._operator_user_index(rows)
return {
"summary": await self._summary(filters=filters),
"items": [
self._row_summary(row, runtime_index=runtime_index, operator_user_index=operator_user_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])
operator_user_index = await self._operator_user_index([row])
return self._row_detail(row, runtime_index=runtime_index, operator_user_index=operator_user_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,
operator_user_index: dict[int, int] | 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)
user_id = row.user_id if row.user_id is not None else (operator_user_index or {}).get(int(row.id or 0))
return {
"id": row.id,
"trace_id": row.trace_id,
"tenant_id": row.tenant_id,
"user_id": 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,
operator_user_index: dict[int, int] | None = None,
) -> dict[str, Any]:
meta = self._row_meta(row)
summary = self._row_summary(row, runtime_index=runtime_index, operator_user_index=operator_user_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
async def _operator_user_index(self, rows: list[AICallLog]) -> dict[int, int]:
log_jobs: list[tuple[int, str]] = []
for row in rows:
if row.user_id is not None:
continue
meta = self._row_meta(row)
launch_job_id = str(meta.get("launch_job_id") or "").strip()
if row.id is not None and launch_job_id:
log_jobs.append((int(row.id), launch_job_id))
if not log_jobs:
return {}
job_ids = sorted({job_id for _, job_id in log_jobs})
result = await self.db.execute(
select(PMFlowLaunchDraftJob.job_id, PMFlowLaunchDraftJob.user_id).where(
PMFlowLaunchDraftJob.job_id.in_(job_ids)
)
)
job_users = {str(job_id): int(user_id) for job_id, user_id in result.all() if user_id is not None}
return {log_id: job_users[job_id] for log_id, job_id in log_jobs if job_id in job_users}
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"),
"allow_low_to_high_escalation": meta.get("allow_low_to_high_escalation"),
"disable_thinking": meta.get("disable_thinking"),
"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