666 lines
28 KiB
Python
666 lines
28 KiB
Python
"""Platform read model for full PMFlow AI run audits."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Any
|
||
|
||
from sqlalchemy import and_, func, or_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..models import (
|
||
AICallLog,
|
||
PMFlowAgentArtifact,
|
||
PMFlowAgentMemory,
|
||
PMFlowAgentObservation,
|
||
PMFlowAgentRun,
|
||
PMFlowAgentStep,
|
||
PMFlowAgentVerification,
|
||
PMFlowAIMessage,
|
||
PMFlowAIThread,
|
||
)
|
||
from .ai_call_trace_service import AICallTraceService
|
||
|
||
|
||
def _utc_iso(value: datetime | None) -> str | None:
|
||
if value is None:
|
||
return None
|
||
if value.tzinfo is None:
|
||
value = value.replace(tzinfo=timezone.utc)
|
||
return value.astimezone(timezone.utc).isoformat()
|
||
|
||
|
||
def _derived_started_at(completed_at: datetime | None, duration_ms: int | None) -> datetime | None:
|
||
if completed_at is None or duration_ms is None:
|
||
return None
|
||
try:
|
||
duration = int(duration_ms)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if duration < 0:
|
||
return None
|
||
return completed_at - timedelta(milliseconds=duration)
|
||
|
||
|
||
def _local_tz() -> timezone:
|
||
tz = datetime.now().astimezone().tzinfo
|
||
return tz if isinstance(tz, timezone) else timezone.utc
|
||
|
||
|
||
def _coerce_time(value: Any) -> datetime | None:
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, datetime):
|
||
parsed = value
|
||
else:
|
||
try:
|
||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if parsed.tzinfo is None:
|
||
parsed = parsed.replace(tzinfo=_local_tz())
|
||
return parsed
|
||
|
||
|
||
def _local_iso(value: Any) -> str | None:
|
||
parsed = _coerce_time(value)
|
||
return parsed.isoformat() if parsed else None
|
||
|
||
|
||
def _derived_started_iso(completed_at: Any, duration_ms: Any) -> str | None:
|
||
completed = _coerce_time(completed_at)
|
||
if completed is None or duration_ms is None:
|
||
return None
|
||
try:
|
||
duration = int(duration_ms)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if duration < 0:
|
||
return None
|
||
return (completed - timedelta(milliseconds=duration)).isoformat()
|
||
|
||
|
||
def _timeline_sort_timestamp(value: Any) -> float:
|
||
parsed = _coerce_time(value)
|
||
if parsed is None:
|
||
return float("inf")
|
||
return parsed.timestamp()
|
||
|
||
|
||
def _timeline_description(value: Any, *, fallback: str = "") -> str:
|
||
text = " ".join(str(value or "").strip().split())
|
||
return text[:500] or fallback
|
||
|
||
|
||
class AgentRunAuditService:
|
||
def __init__(self, db: AsyncSession) -> None:
|
||
self.db = db
|
||
|
||
async def list_runs(
|
||
self,
|
||
*,
|
||
page: int = 1,
|
||
page_size: int = 30,
|
||
tenant_id: int | None = None,
|
||
user_id: int | None = None,
|
||
status: str | None = None,
|
||
source_channel: str | 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._run_filters(
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
status=status,
|
||
source_channel=source_channel,
|
||
keyword=keyword,
|
||
since_hours=since_hours,
|
||
)
|
||
where_clause = and_(*filters) if filters else None
|
||
count_stmt = select(func.count(PMFlowAgentRun.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(PMFlowAgentRun)
|
||
.order_by(PMFlowAgentRun.started_at.desc(), PMFlowAgentRun.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())
|
||
run_ids = [str(row.public_run_id) for row in rows if row.public_run_id]
|
||
counts = await self._counts_for_runs(run_ids)
|
||
return {
|
||
"summary": await self._summary(filters=filters),
|
||
"items": [self._run_summary(row, counts=counts.get(str(row.public_run_id), {})) for row in rows],
|
||
"page": page,
|
||
"page_size": page_size,
|
||
"total": total,
|
||
"has_next": page * page_size < total,
|
||
}
|
||
|
||
async def get_run(self, public_run_id: str) -> dict[str, Any]:
|
||
run_id = str(public_run_id or "").strip()
|
||
row = (
|
||
await self.db.execute(
|
||
select(PMFlowAgentRun)
|
||
.where(PMFlowAgentRun.public_run_id == run_id)
|
||
.order_by(PMFlowAgentRun.id.desc())
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is None:
|
||
raise ValueError("链路审计记录不存在")
|
||
steps = list(
|
||
(
|
||
await self.db.execute(
|
||
select(PMFlowAgentStep)
|
||
.where(and_(PMFlowAgentStep.tenant_id == int(row.tenant_id), PMFlowAgentStep.run_id == int(row.id)))
|
||
.order_by(PMFlowAgentStep.step_index.asc(), PMFlowAgentStep.id.asc())
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
observations = list(
|
||
(
|
||
await self.db.execute(
|
||
select(PMFlowAgentObservation)
|
||
.where(and_(PMFlowAgentObservation.tenant_id == int(row.tenant_id), PMFlowAgentObservation.run_id == int(row.id)))
|
||
.order_by(PMFlowAgentObservation.observed_at.asc(), PMFlowAgentObservation.id.asc())
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
artifacts = list(
|
||
(
|
||
await self.db.execute(
|
||
select(PMFlowAgentArtifact)
|
||
.where(and_(PMFlowAgentArtifact.tenant_id == int(row.tenant_id), PMFlowAgentArtifact.run_id == int(row.id)))
|
||
.order_by(PMFlowAgentArtifact.id.asc())
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
verifications = list(
|
||
(
|
||
await self.db.execute(
|
||
select(PMFlowAgentVerification)
|
||
.where(and_(PMFlowAgentVerification.tenant_id == int(row.tenant_id), PMFlowAgentVerification.run_id == int(row.id)))
|
||
.order_by(PMFlowAgentVerification.verified_at.asc(), PMFlowAgentVerification.id.asc())
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
memories = list(
|
||
(
|
||
await self.db.execute(
|
||
select(PMFlowAgentMemory)
|
||
.where(PMFlowAgentMemory.run_id == int(row.id))
|
||
.order_by(PMFlowAgentMemory.id.asc())
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
messages = await self._messages_for_run(row)
|
||
ai_calls = await self._ai_calls_for_run(str(row.public_run_id))
|
||
counts = (await self._counts_for_runs([str(row.public_run_id)])).get(str(row.public_run_id), {})
|
||
return {
|
||
**self._run_summary(row, counts=counts),
|
||
"messages": messages,
|
||
"steps": [self._step_detail(item, ai_calls=ai_calls) for item in steps],
|
||
"observations": [self._observation_detail(item) for item in observations],
|
||
"artifacts": [self._artifact_detail(item) for item in artifacts],
|
||
"verifications": [self._verification_detail(item) for item in verifications],
|
||
"memories": [self._memory_detail(item) for item in memories],
|
||
"ai_calls": ai_calls,
|
||
"timeline": self._timeline(row=row, steps=steps, observations=observations, verifications=verifications, ai_calls=ai_calls),
|
||
"audit_policy": {
|
||
"visibility": "platform_only",
|
||
"payload_policy": "展示平台脱敏后的链路参数、上游 API 请求/响应、耗时和质量门禁;客户侧不暴露这些内部诊断。",
|
||
},
|
||
}
|
||
|
||
def _run_filters(
|
||
self,
|
||
*,
|
||
tenant_id: int | None,
|
||
user_id: int | None,
|
||
status: str | None,
|
||
source_channel: str | None,
|
||
keyword: str | None,
|
||
since_hours: int | None,
|
||
) -> list[Any]:
|
||
filters: list[Any] = []
|
||
if since_hours and int(since_hours) > 0:
|
||
filters.append(PMFlowAgentRun.started_at >= datetime.utcnow() - timedelta(hours=min(int(since_hours), 24 * 365)))
|
||
if tenant_id:
|
||
filters.append(PMFlowAgentRun.tenant_id == int(tenant_id))
|
||
if user_id:
|
||
filters.append(PMFlowAgentRun.user_id == int(user_id))
|
||
if status and status != "all":
|
||
filters.append(PMFlowAgentRun.status == str(status).strip())
|
||
if source_channel:
|
||
filters.append(PMFlowAgentRun.source_channel == str(source_channel).strip())
|
||
if keyword:
|
||
value = f"%{str(keyword).strip()}%"
|
||
filters.append(
|
||
or_(
|
||
PMFlowAgentRun.public_run_id.ilike(value),
|
||
PMFlowAgentRun.title.ilike(value),
|
||
PMFlowAgentRun.goal.ilike(value),
|
||
PMFlowAgentRun.final_answer.ilike(value),
|
||
PMFlowAgentRun.source_ref.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(PMFlowAgentRun.id))
|
||
if where_clause is not None:
|
||
stmt = stmt.where(where_clause)
|
||
total = int((await self.db.execute(stmt)).scalar() or 0)
|
||
status_stmt = select(PMFlowAgentRun.status, func.count(PMFlowAgentRun.id)).group_by(PMFlowAgentRun.status)
|
||
if where_clause is not None:
|
||
status_stmt = status_stmt.where(where_clause)
|
||
status_counts = {str(status or "unknown"): int(count or 0) for status, count in (await self.db.execute(status_stmt)).all()}
|
||
run_id_stmt = select(PMFlowAgentRun.public_run_id)
|
||
if where_clause is not None:
|
||
run_id_stmt = run_id_stmt.where(where_clause)
|
||
call_stmt = select(func.count(AICallLog.id)).where(AICallLog.agent_run_id.in_(run_id_stmt))
|
||
return {
|
||
"total_runs": total,
|
||
"status_counts": status_counts,
|
||
"linked_ai_calls": int((await self.db.execute(call_stmt)).scalar() or 0),
|
||
}
|
||
|
||
async def _counts_for_runs(self, run_ids: list[str]) -> dict[str, dict[str, int]]:
|
||
if not run_ids:
|
||
return {}
|
||
result: dict[str, dict[str, int]] = {run_id: {} for run_id in run_ids}
|
||
call_rows = await self.db.execute(
|
||
select(AICallLog.agent_run_id, func.count(AICallLog.id), func.sum(AICallLog.latency_ms))
|
||
.where(AICallLog.agent_run_id.in_(run_ids))
|
||
.group_by(AICallLog.agent_run_id)
|
||
)
|
||
for run_id, count, latency in call_rows.all():
|
||
result.setdefault(str(run_id), {})["ai_call_count"] = int(count or 0)
|
||
result[str(run_id)]["ai_call_latency_ms"] = int(latency or 0)
|
||
return result
|
||
|
||
def _run_summary(self, row: PMFlowAgentRun, *, counts: dict[str, int]) -> dict[str, Any]:
|
||
duration_ms = None
|
||
if row.started_at and row.completed_at:
|
||
duration_ms = max(0, int((row.completed_at - row.started_at).total_seconds() * 1000))
|
||
metadata = row.run_metadata if isinstance(row.run_metadata, dict) else {}
|
||
return {
|
||
"id": int(row.id),
|
||
"public_run_id": row.public_run_id,
|
||
"tenant_id": int(row.tenant_id),
|
||
"user_id": int(row.user_id) if row.user_id is not None else None,
|
||
"thread_id": int(row.thread_id) if row.thread_id is not None else None,
|
||
"qa_session_id": int(row.qa_session_id) if row.qa_session_id is not None else None,
|
||
"workspace_scope": row.workspace_scope,
|
||
"surface": row.surface,
|
||
"presentation_mode": row.presentation_mode,
|
||
"source_channel": row.source_channel,
|
||
"source_ref": row.source_ref,
|
||
"title": row.title,
|
||
"goal": row.goal,
|
||
"status": row.status,
|
||
"status_label": self._status_label(row.status),
|
||
"active_object_refs": row.active_object_refs if isinstance(row.active_object_refs, list) else [],
|
||
"final_answer_preview": self._preview(row.final_answer),
|
||
"last_error": row.last_error,
|
||
"started_at": _utc_iso(row.started_at),
|
||
"completed_at": _utc_iso(row.completed_at),
|
||
"duration_ms": duration_ms,
|
||
"step_count": int(metadata.get("step_count") or 0),
|
||
"observation_count": int(metadata.get("observation_count") or 0),
|
||
"artifact_count": int(metadata.get("artifact_count") or 0),
|
||
"verification_count": int(metadata.get("verification_count") or 0),
|
||
"tool_call_count": int(metadata.get("tool_call_count") or 0),
|
||
"iterations": int(metadata.get("iterations") or 0),
|
||
"ai_call_count": int(counts.get("ai_call_count") or 0),
|
||
"ai_call_latency_ms": int(counts.get("ai_call_latency_ms") or 0),
|
||
"run_metadata": metadata,
|
||
}
|
||
|
||
async def _messages_for_run(self, row: PMFlowAgentRun) -> list[dict[str, Any]]:
|
||
if not row.public_run_id:
|
||
return []
|
||
result = await self.db.execute(
|
||
select(PMFlowAIMessage, PMFlowAIThread.public_thread_id)
|
||
.join(PMFlowAIThread, PMFlowAIThread.id == PMFlowAIMessage.thread_id)
|
||
.where(
|
||
and_(
|
||
PMFlowAIMessage.tenant_id == int(row.tenant_id),
|
||
PMFlowAIMessage.run_public_id == str(row.public_run_id),
|
||
)
|
||
)
|
||
.order_by(PMFlowAIMessage.message_index.asc(), PMFlowAIMessage.id.asc())
|
||
)
|
||
return [
|
||
{
|
||
"id": int(message.id),
|
||
"public_thread_id": public_thread_id,
|
||
"public_message_id": message.public_message_id,
|
||
"role": message.role,
|
||
"message_index": int(message.message_index or 0),
|
||
"content": message.content,
|
||
"payload": message.message_payload if isinstance(message.message_payload, dict) else {},
|
||
"sent_at": _utc_iso(message.sent_at),
|
||
}
|
||
for message, public_thread_id in result.all()
|
||
]
|
||
|
||
async def _ai_calls_for_run(self, public_run_id: str) -> list[dict[str, Any]]:
|
||
rows = list(
|
||
(
|
||
await self.db.execute(
|
||
select(AICallLog)
|
||
.where(AICallLog.agent_run_id == public_run_id)
|
||
.order_by(AICallLog.created_at.asc(), AICallLog.id.asc())
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
if not rows:
|
||
return []
|
||
trace_service = AICallTraceService(self.db)
|
||
runtime_index = await trace_service._runtime_display_index(rows)
|
||
operator_index = await trace_service._operator_user_index(rows)
|
||
return [
|
||
{
|
||
**trace_service._row_detail(row, runtime_index=runtime_index, operator_user_index=operator_index),
|
||
"agent_run_id": row.agent_run_id,
|
||
"agent_step_key": row.agent_step_key,
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
def _step_detail(self, row: PMFlowAgentStep, *, ai_calls: list[dict[str, Any]]) -> dict[str, Any]:
|
||
payload = row.step_payload if isinstance(row.step_payload, dict) else {}
|
||
linked_calls = [
|
||
self._ai_call_ref(item)
|
||
for item in ai_calls
|
||
if self._ai_call_matches_step(item, row=row, payload=payload)
|
||
]
|
||
return {
|
||
"id": int(row.id),
|
||
"public_step_id": row.public_step_id,
|
||
"step_index": int(row.step_index or 0),
|
||
"step_kind": row.step_kind,
|
||
"status": row.status,
|
||
"input_summary": row.input_summary,
|
||
"output_summary": row.output_summary,
|
||
"tool_name": row.tool_name,
|
||
"duration_ms": row.duration_ms,
|
||
"step_payload": payload,
|
||
"linked_ai_calls": linked_calls,
|
||
"started_at": _utc_iso(row.started_at),
|
||
"completed_at": _utc_iso(row.completed_at),
|
||
}
|
||
|
||
@staticmethod
|
||
def _ai_call_matches_step(item: dict[str, Any], *, row: PMFlowAgentStep, payload: dict[str, Any]) -> bool:
|
||
key = str(item.get("agent_step_key") or "").strip()
|
||
if not key:
|
||
return False
|
||
key_kind_map = {
|
||
"agent_goal_frame": "understand_goal",
|
||
"agent_step_planner": "planner",
|
||
"agent_evidence_normalizer": "extract_claims",
|
||
"agent_state_critic": "critic",
|
||
"agent_answer_runtime": "answer",
|
||
"agent_answer_reviser": "answer",
|
||
}
|
||
candidates = {
|
||
str(row.step_kind or "").strip(),
|
||
str(row.tool_name or "").strip(),
|
||
str(payload.get("kind") or "").strip(),
|
||
str(payload.get("label") or "").strip(),
|
||
}
|
||
mapped = key_kind_map.get(key)
|
||
if mapped and mapped in candidates:
|
||
return True
|
||
return key in candidates or any(candidate and (candidate in key or key in candidate) for candidate in candidates)
|
||
|
||
@staticmethod
|
||
def _ai_call_ref(item: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"id": item.get("id"),
|
||
"trace_id": item.get("trace_id"),
|
||
"function_name": item.get("function_name"),
|
||
"function_label": item.get("function_label"),
|
||
"status": item.get("status"),
|
||
"status_label": item.get("status_label"),
|
||
"provider_label": item.get("provider_label"),
|
||
"model": item.get("model"),
|
||
"latency_ms": item.get("latency_ms"),
|
||
"tokens_used": item.get("tokens_used"),
|
||
}
|
||
|
||
@staticmethod
|
||
def _observation_detail(row: PMFlowAgentObservation) -> dict[str, Any]:
|
||
return {
|
||
"id": int(row.id),
|
||
"step_id": int(row.step_id) if row.step_id is not None else None,
|
||
"public_observation_id": row.public_observation_id,
|
||
"source_kind": row.source_kind,
|
||
"customer_safe_summary": row.customer_safe_summary,
|
||
"confidence": row.confidence,
|
||
"facts": row.facts if isinstance(row.facts, list) else [],
|
||
"gaps": row.gaps if isinstance(row.gaps, list) else [],
|
||
"raw_payload": row.raw_payload if isinstance(row.raw_payload, dict) else {},
|
||
"observed_at": _utc_iso(row.observed_at),
|
||
}
|
||
|
||
@staticmethod
|
||
def _artifact_detail(row: PMFlowAgentArtifact) -> dict[str, Any]:
|
||
return {
|
||
"id": int(row.id),
|
||
"step_id": int(row.step_id) if row.step_id is not None else None,
|
||
"public_artifact_id": row.public_artifact_id,
|
||
"artifact_kind": row.artifact_kind,
|
||
"title": row.title,
|
||
"status": row.status,
|
||
"payload": row.payload if isinstance(row.payload, dict) else {},
|
||
"customer_safe_payload": row.customer_safe_payload if isinstance(row.customer_safe_payload, dict) else {},
|
||
"created_by_step_id": row.created_by_step_id,
|
||
}
|
||
|
||
@staticmethod
|
||
def _verification_detail(row: PMFlowAgentVerification) -> dict[str, Any]:
|
||
return {
|
||
"id": int(row.id),
|
||
"step_id": int(row.step_id) if row.step_id is not None else None,
|
||
"public_verification_id": row.public_verification_id,
|
||
"target_ref": row.target_ref,
|
||
"result": row.result,
|
||
"retry_would_change_state": bool(row.retry_would_change_state),
|
||
"findings": row.findings if isinstance(row.findings, list) else [],
|
||
"verification_payload": row.verification_payload if isinstance(row.verification_payload, dict) else {},
|
||
"verified_at": _utc_iso(row.verified_at),
|
||
}
|
||
|
||
@staticmethod
|
||
def _memory_detail(row: PMFlowAgentMemory) -> dict[str, Any]:
|
||
return {
|
||
"id": int(row.id),
|
||
"memory_kind": row.memory_kind,
|
||
"scope_ref": row.scope_ref,
|
||
"outcome_signal": row.outcome_signal,
|
||
"content": row.content if isinstance(row.content, dict) else {},
|
||
"memory_payload": row.memory_payload if isinstance(row.memory_payload, dict) else {},
|
||
}
|
||
|
||
@staticmethod
|
||
def _timeline(
|
||
*,
|
||
row: PMFlowAgentRun,
|
||
steps: list[PMFlowAgentStep],
|
||
observations: list[PMFlowAgentObservation],
|
||
verifications: list[PMFlowAgentVerification],
|
||
ai_calls: list[dict[str, Any]],
|
||
) -> list[dict[str, Any]]:
|
||
items: list[dict[str, Any]] = []
|
||
run_duration_ms = None
|
||
if row.started_at and row.completed_at:
|
||
run_duration_ms = max(0, int((row.completed_at - row.started_at).total_seconds() * 1000))
|
||
if row.started_at:
|
||
items.append(
|
||
{
|
||
"type": "run_started",
|
||
"label": "触发链路",
|
||
"description": _timeline_description(row.goal, fallback="用户端触发后,平台创建本轮 Agent Run。"),
|
||
"at": _utc_iso(row.started_at),
|
||
"started_at": _utc_iso(row.started_at),
|
||
"completed_at": None,
|
||
"duration_ms": None,
|
||
"status": row.status,
|
||
}
|
||
)
|
||
for step in steps:
|
||
step_started_at = step.started_at or _derived_started_at(step.completed_at, step.duration_ms)
|
||
step_description = _timeline_description(
|
||
step.output_summary or step.input_summary,
|
||
fallback="该节点已执行,未写入额外摘要。",
|
||
)
|
||
items.append(
|
||
{
|
||
"type": "step",
|
||
"label": step.step_payload.get("label") if isinstance(step.step_payload, dict) else step.step_kind,
|
||
"description": step_description,
|
||
"ref": step.public_step_id,
|
||
"at": _utc_iso(step.completed_at),
|
||
"started_at": _utc_iso(step_started_at),
|
||
"completed_at": _utc_iso(step.completed_at),
|
||
"duration_ms": step.duration_ms,
|
||
"status": step.status,
|
||
}
|
||
)
|
||
for observation in observations:
|
||
items.append(
|
||
{
|
||
"type": "observation",
|
||
"label": observation.customer_safe_summary or observation.source_kind,
|
||
"description": _timeline_description(
|
||
observation.customer_safe_summary,
|
||
fallback=f"观察记录包含 {len(observation.facts if isinstance(observation.facts, list) else [])} 条事实和 {len(observation.gaps if isinstance(observation.gaps, list) else [])} 个缺口。",
|
||
),
|
||
"ref": observation.public_observation_id,
|
||
"at": _utc_iso(observation.observed_at),
|
||
"started_at": None,
|
||
"completed_at": _utc_iso(observation.observed_at),
|
||
"duration_ms": None,
|
||
"status": observation.confidence or "unknown",
|
||
}
|
||
)
|
||
for call in ai_calls:
|
||
call_latency = call.get("latency_ms")
|
||
call_created_at = _local_iso(call.get("created_at"))
|
||
call_label = call.get("function_label") or call.get("function_name")
|
||
provider_label = call.get("provider_label") or call.get("provider_code") or call.get("provider") or "未知供应商"
|
||
call_status = call.get("status_label") or call.get("status") or "未知状态"
|
||
call_preview = _timeline_description(
|
||
call.get("response_preview") or call.get("error_summary"),
|
||
fallback="",
|
||
)
|
||
call_description = (
|
||
f"{call_label or '外部 API'} 输出摘要:{call_preview}"
|
||
if call_preview
|
||
else f"{call_label or '外部 API'} 没有记录响应摘要,本条只保留供应商、模型、状态、耗时和 Trace。"
|
||
)
|
||
items.append(
|
||
{
|
||
"type": "external_api",
|
||
"label": call_label,
|
||
"description": _timeline_description(
|
||
f"{call_description} 调用信息:{provider_label} / {call.get('model') or '未知模型'},状态:{call_status},Trace:{call.get('trace_id') or call.get('id') or '-'}。",
|
||
),
|
||
"ref": call.get("trace_id") or call.get("id"),
|
||
"at": call_created_at,
|
||
"started_at": _derived_started_iso(call_created_at, call_latency),
|
||
"completed_at": call_created_at,
|
||
"duration_ms": call_latency,
|
||
"status": call.get("status"),
|
||
}
|
||
)
|
||
for verification in verifications:
|
||
findings = verification.findings if isinstance(verification.findings, list) else []
|
||
finding_summary = ""
|
||
if findings:
|
||
first = findings[0]
|
||
finding_summary = first.get("summary") if isinstance(first, dict) else str(first)
|
||
items.append(
|
||
{
|
||
"type": "verification",
|
||
"label": verification.target_ref or verification.public_verification_id,
|
||
"description": _timeline_description(
|
||
finding_summary,
|
||
fallback=f"质量检查结果:{verification.result or '未知'};retry_would_change_state={bool(verification.retry_would_change_state)}。",
|
||
),
|
||
"ref": verification.public_verification_id,
|
||
"at": _utc_iso(verification.verified_at),
|
||
"started_at": None,
|
||
"completed_at": _utc_iso(verification.verified_at),
|
||
"duration_ms": None,
|
||
"status": verification.result,
|
||
}
|
||
)
|
||
if row.completed_at:
|
||
items.append(
|
||
{
|
||
"type": "run_completed",
|
||
"label": "链路完成",
|
||
"description": _timeline_description(
|
||
row.final_answer,
|
||
fallback=f"本轮 Agent Run 已停止,最终状态:{row.status or '未知'}。",
|
||
),
|
||
"at": _utc_iso(row.completed_at),
|
||
"started_at": _utc_iso(row.started_at),
|
||
"completed_at": _utc_iso(row.completed_at),
|
||
"duration_ms": run_duration_ms,
|
||
"status": row.status,
|
||
}
|
||
)
|
||
def sort_key(item: dict[str, Any]) -> tuple[float, int]:
|
||
item_type = str(item.get("type") or "")
|
||
if item_type == "run_started":
|
||
return (_timeline_sort_timestamp(item.get("started_at") or item.get("at")), 0)
|
||
if item_type == "run_completed":
|
||
return (float("inf"), 9)
|
||
return (_timeline_sort_timestamp(item.get("started_at") or item.get("at") or item.get("completed_at")), 1)
|
||
|
||
return sorted(items, key=sort_key)
|
||
|
||
@staticmethod
|
||
def _preview(value: Any, *, limit: int = 500) -> str:
|
||
return " ".join(str(value or "").strip().split())[:limit]
|
||
|
||
@staticmethod
|
||
def _status_label(status: str | None) -> str:
|
||
text = str(status or "").strip()
|
||
return {
|
||
"completed": "已完成",
|
||
"answered": "已回答",
|
||
"failed": "失败",
|
||
"blocked": "已阻断",
|
||
"active": "运行中",
|
||
"waiting_for_user": "等待用户",
|
||
}.get(text, text or "未知")
|
||
|
||
|
||
__all__ = ["AgentRunAuditService"]
|