AgentEvalTool/backend/agenteval/intelligent_eval/report.py
sinohqb eb4944a8bd feat(intelligent-eval): terminal-state discipline watchdogs (ADR-0011)
常见故障自愈有上限,超限收敛终态且可见:任务 attempts 上限、会话过期、
planning 双闸、executing 超窗兜底、触发失败计数判死、孤儿 agent 双管、
fire-and-forget 触发;open_session 预算硬闸门、settle 按终态区分、报告
scores 归一化;cron 池遗留面全删。
2026-08-20 14:34:17 +08:00

162 lines
5.9 KiB
Python
Raw Permalink 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.

"""Structured report for intelligent evaluation (票据 04).
Two concerns live here so they stay co-located:
* **Validation** — pydantic models describing the report contract OpenClaw
submits. A malformed body yields a 422 before any state change happens.
* **Rendering** — ``render_report_markdown`` is a pure function (dict in,
string out) with no DB or I/O, so it unit-tests trivially.
"""
import math
from typing import Any, Optional
from pydantic import BaseModel, Field, model_validator
class ReportEvidence(BaseModel):
session_id: str = ""
turn_index: Optional[int] = None
user_said: str = ""
assistant_replied: str = ""
class ReportFinding(BaseModel):
issue: str = Field(min_length=1)
severity: str = Field(min_length=1)
dimension: str = Field(min_length=1)
evidence: list[ReportEvidence] = Field(default_factory=list)
suggestion: Optional[str] = None
related_sop: Optional[str] = None
class ReportHighlight(BaseModel):
description: str = ""
dimension: Optional[str] = None
def normalize_scores(scores: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]:
"""归一 scores 到单一规范结构 ``{"overall": float|None, "dimensions": {维度: 分}}``。
Analyst LLM 输出不可控,可能是扁平 ``{维度: 分}`` 或嵌套
``{overall, dimensions}``;统一为嵌套结构,缺失 overall 时取维度平均,
非数值/非有限维度直接丢弃。ADR-0011下游前端/markdown不再做双格式兼容。
"""
if not scores:
return scores
nested = scores.get("dimensions")
source = nested if isinstance(nested, dict) else scores
dims = {
str(k): float(v)
for k, v in source.items()
if k != "overall" and isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
}
overall = scores.get("overall")
if not isinstance(overall, (int, float)) or isinstance(overall, bool) or not math.isfinite(overall):
overall = sum(dims.values()) / len(dims) if dims else None
return {"overall": float(overall) if overall is not None else None, "dimensions": dims}
class ReportModel(BaseModel):
summary: str = Field(min_length=1)
scores: Optional[dict[str, Any]] = None
findings: list[ReportFinding] = Field(default_factory=list)
highlights: list[ReportHighlight] = Field(default_factory=list)
priority_recommendations: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def _require_findings(self) -> "ReportModel":
if not self.findings:
raise ValueError("findings 不能为空")
return self
@model_validator(mode="after")
def _normalize_scores(self) -> "ReportModel":
self.scores = normalize_scores(self.scores)
return self
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def _severity_rank(severity: str) -> int:
return _SEVERITY_ORDER.get((severity or "").lower(), 99)
def render_report_markdown(report: dict[str, Any], *, name: str = "", eval_id: str = "") -> str:
"""Render a validated report dict to Markdown. Pure function, no I/O."""
validated = ReportModel.model_validate(report)
lines: list[str] = []
title = name or "智能评估报告"
lines.append(f"# {title}")
lines.append("")
if eval_id:
lines.append(f"> 评估 ID`{eval_id}`")
lines.append("")
lines.append("## 总体概述")
lines.append("")
lines.append(validated.summary.strip())
lines.append("")
if validated.scores:
lines.append("## 维度评分")
lines.append("")
lines.append("| 维度 | 分数 |")
lines.append("| --- | --- |")
overall = validated.scores.get("overall")
if overall is not None:
lines.append(f"| 综合 | {overall} |")
for dimension, score in validated.scores.get("dimensions", {}).items():
lines.append(f"| {dimension} | {score} |")
lines.append("")
lines.append("## 问题发现")
lines.append("")
findings = sorted(validated.findings, key=lambda f: _severity_rank(f.severity))
for index, finding in enumerate(findings, start=1):
lines.append(f"### {index}. {finding.issue}")
lines.append("")
lines.append(f"- **严重程度**{finding.severity}")
lines.append(f"- **维度**{finding.dimension}")
if finding.suggestion:
lines.append(f"- **建议**{finding.suggestion}")
if finding.related_sop:
lines.append(f"- **关联 SOP**{finding.related_sop}")
if finding.evidence:
lines.append("")
lines.append("**证据**")
lines.append("")
for ev in finding.evidence:
header_bits = []
if ev.session_id:
header_bits.append(f"会话 `{ev.session_id}`")
if ev.turn_index is not None:
header_bits.append(f"{ev.turn_index}")
header = "" + "".join(header_bits) + "" if header_bits else ""
lines.append(f"> {header}")
if ev.user_said:
lines.append(f"> **用户**{ev.user_said}")
if ev.assistant_replied:
lines.append(f"> **对象**{ev.assistant_replied}")
lines.append("")
lines.append("")
if validated.highlights:
lines.append("## 亮点")
lines.append("")
for highlight in validated.highlights:
suffix = f"{highlight.dimension}" if highlight.dimension else ""
lines.append(f"- {highlight.description}{suffix}")
lines.append("")
if validated.priority_recommendations:
lines.append("## 优先改进建议")
lines.append("")
for recommendation in validated.priority_recommendations:
lines.append(f"- {recommendation}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"