feat(report): annotate connectivity cases and add judged pass rate (ticket 02)

报告层推导连通用例标记(无判定结果 + 每轮有回复 + 无用例级错误),
summary 新增 connectivity_cases 与 judged_pass_rate(无判定型用例时为 null)。
对比报告同步标注且连通用例按引擎口径计通过;总通过率口径不变(ADR-0002)。
This commit is contained in:
sinohqb 2026-07-29 10:32:56 +08:00
parent 5dd1bc8535
commit 8a526599ab
4 changed files with 167 additions and 24 deletions

View File

@ -4,10 +4,10 @@
**Blocked by:** None — can start immediately. **Blocked by:** None — can start immediately.
**Status:** ready-for-agent **Status:** done
- [ ] 单次报告中连通用例带明确标记,前端可见 - [x] 单次报告中连通用例带明确标记,前端可见
- [ ] 对比报告中连通用例同样标注 - [x] 对比报告中连通用例同样标注
- [ ] summary 含连通用例数与判定型通过率;全为连通用例时判定型通过率不除零 - [x] summary 含连通用例数与判定型通过率;全为连通用例时判定型通过率不除零
- [ ] 总通过率数值与升级前一致(口径未变) - [x] 总通过率数值与升级前一致(口径未变)
- [ ] 报告单元测试覆盖标注与判定型通过率计算(先例:现有报告测试) - [x] 报告单元测试覆盖标注与判定型通过率计算(先例:现有报告测试)

View File

@ -111,9 +111,11 @@ def generate_report(run_id: str, session=None) -> dict[str, Any]:
# Group by case # Group by case
case_map: dict[str, dict[str, Any]] = {} case_map: dict[str, dict[str, Any]] = {}
for turn in turns: for turn in turns:
case_map.setdefault(turn.case_id, {"turns": [], "results": []}) case_map.setdefault(turn.case_id, {"turns": [], "results": [], "all_replied": True})
sent = turn.get_sent_message() sent = turn.get_sent_message()
reply = turn.get_reply() reply = turn.get_reply()
if reply is None:
case_map[turn.case_id]["all_replied"] = False
case_map[turn.case_id]["turns"].append( case_map[turn.case_id]["turns"].append(
{ {
"round": turn.round_index, "round": turn.round_index,
@ -125,7 +127,7 @@ def generate_report(run_id: str, session=None) -> dict[str, Any]:
) )
for result in results: for result in results:
case_map.setdefault(result.case_id, {"turns": [], "results": []}) case_map.setdefault(result.case_id, {"turns": [], "results": [], "all_replied": True})
case_map[result.case_id]["results"].append( case_map[result.case_id]["results"].append(
{ {
"rule_type": result.rule_type, "rule_type": result.rule_type,
@ -135,18 +137,35 @@ def generate_report(run_id: str, session=None) -> dict[str, Any]:
} }
) )
summary = run.summary or {}
errored_case_ids = {e.get("case_id") for e in summary.get("case_errors", [])}
cases = [] cases = []
for case_id in sorted(case_map.keys()): for case_id in sorted(case_map.keys()):
item = case_map[case_id] item = case_map[case_id]
# 连通用例无任何判定结果且每轮都收到回复、无用例级错误CONTEXT.md
connectivity = (
not item["results"]
and bool(item["turns"])
and item["all_replied"]
and case_id not in errored_case_ids
)
cases.append( cases.append(
{ {
"case_id": case_id, "case_id": case_id,
"connectivity": connectivity,
"turns": sorted(item["turns"], key=lambda x: x["round"]), "turns": sorted(item["turns"], key=lambda x: x["round"]),
"results": item["results"], "results": item["results"],
} }
) )
summary = run.summary or {} total_cases = summary.get("total_cases", 0)
passed_cases = summary.get("passed_cases", 0)
connectivity_count = sum(1 for c in cases if c["connectivity"])
judged_total = total_cases - connectivity_count
# 连通用例按引擎口径计通过,判定型通过数 = 总通过数 - 连通用例数
judged_pass_rate = round((passed_cases - connectivity_count) / judged_total, 4) if judged_total > 0 else None
return { return {
"run_id": run.id, "run_id": run.id,
"target_id": run.target_id, "target_id": run.target_id,
@ -157,12 +176,14 @@ def generate_report(run_id: str, session=None) -> dict[str, Any]:
"started_at": iso_utc(run.started_at), "started_at": iso_utc(run.started_at),
"completed_at": iso_utc(run.completed_at), "completed_at": iso_utc(run.completed_at),
"summary": { "summary": {
"total_cases": summary.get("total_cases", 0), "total_cases": total_cases,
"passed_cases": summary.get("passed_cases", 0), "passed_cases": passed_cases,
"failed_cases": summary.get("failed_cases", 0), "failed_cases": summary.get("failed_cases", 0),
"total_rules": summary.get("total_rules", 0), "total_rules": summary.get("total_rules", 0),
"passed_rules": summary.get("passed_rules", 0), "passed_rules": summary.get("passed_rules", 0),
"pass_rate": summary.get("pass_rate", 0.0), "pass_rate": summary.get("pass_rate", 0.0),
"connectivity_cases": connectivity_count,
"judged_pass_rate": judged_pass_rate,
}, },
"cases": cases, "cases": cases,
} }
@ -194,6 +215,9 @@ def generate_compare_report(run_id_1: str, run_id_2: str, session=None) -> dict[
def _case_passed(c): def _case_passed(c):
if not c: if not c:
return None return None
if c.get("connectivity"):
# 连通用例收到回复即通过(引擎口径)
return True
results = c.get("results", []) results = c.get("results", [])
if not results: if not results:
# No rule results (e.g. errored case) must not count as passed. # No rule results (e.g. errored case) must not count as passed.
@ -203,6 +227,7 @@ def generate_compare_report(run_id_1: str, run_id_2: str, session=None) -> dict[
case_diffs.append( case_diffs.append(
{ {
"case_id": cid, "case_id": cid,
"connectivity": bool((ca and ca.get("connectivity")) or (cb and cb.get("connectivity"))),
"run_a_passed": _case_passed(ca), "run_a_passed": _case_passed(ca),
"run_b_passed": _case_passed(cb), "run_b_passed": _case_passed(cb),
"changed": _case_passed(ca) != _case_passed(cb), "changed": _case_passed(ca) != _case_passed(cb),
@ -242,6 +267,8 @@ def render_markdown_report(run_id: str, session=None) -> str:
"""Render a report as Markdown string.""" """Render a report as Markdown string."""
report = generate_report(run_id, session) report = generate_report(run_id, session)
s = report["summary"] s = report["summary"]
judged_rate = s.get("judged_pass_rate")
judged_rate_text = "" if judged_rate is None else f"{judged_rate * 100:.1f}%"
lines: list[str] = [ lines: list[str] = [
f"# 评测报告 — {report.get('scenario_name', run_id)}", f"# 评测报告 — {report.get('scenario_name', run_id)}",
"", "",
@ -261,15 +288,23 @@ def render_markdown_report(run_id: str, session=None) -> str:
f"| 总规则数 | {s['total_rules']} |", f"| 总规则数 | {s['total_rules']} |",
f"| 通过规则 | {s['passed_rules']} |", f"| 通过规则 | {s['passed_rules']} |",
f"| 通过率 | {s['pass_rate'] * 100:.1f}% |", f"| 通过率 | {s['pass_rate'] * 100:.1f}% |",
f"| 连通用例 | {s.get('connectivity_cases', 0)} |",
f"| 判定型通过率 | {judged_rate_text} |",
"", "",
"## 用例明细", "## 用例明细",
"", "",
] ]
for case in report.get("cases", []): for case in report.get("cases", []):
case_passed = all(r["passed"] for r in case.get("results", [])) if case.get("connectivity"):
badge = "" if case_passed else "" badge = "🔗"
lines.append(f"### {badge} 用例 `{case['case_id']}`") else:
case_passed = all(r["passed"] for r in case.get("results", []))
badge = "" if case_passed else ""
title = f"### {badge} 用例 `{case['case_id']}`"
if case.get("connectivity"):
title += "(连通用例,未配置判定标准)"
lines.append(title)
lines.append("") lines.append("")
for turn in case.get("turns", []): for turn in case.get("turns", []):

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { import {
Button, Card, Col, Collapse, Descriptions, Empty, Row, Segmented, Alert, Button, Card, Col, Collapse, Descriptions, Empty, Row, Segmented,
Select, Space, Spin, Statistic, Table, Tag, Tooltip, Badge, message, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Badge, message,
} from 'antd' } from 'antd'
import { import {
@ -29,6 +29,7 @@ interface RuleResultData {
interface CaseReport { interface CaseReport {
case_id: string case_id: string
connectivity: boolean
turns: TurnData[] turns: TurnData[]
results: RuleResultData[] results: RuleResultData[]
} }
@ -47,6 +48,8 @@ interface Report {
total_rules: number total_rules: number
passed_rules: number passed_rules: number
pass_rate: number pass_rate: number
connectivity_cases: number
judged_pass_rate: number | null
} }
cases: CaseReport[] cases: CaseReport[]
} }
@ -57,6 +60,7 @@ interface CompareResult {
delta: { pass_rate: number; passed_cases: number; passed_rules: number } delta: { pass_rate: number; passed_cases: number; passed_rules: number }
cases: Array<{ cases: Array<{
case_id: string case_id: string
connectivity: boolean
run_a_passed: boolean | null run_a_passed: boolean | null
run_b_passed: boolean | null run_b_passed: boolean | null
changed: boolean changed: boolean
@ -364,6 +368,14 @@ function SingleReportView({ report }: { report: Report | null }) {
</Col> </Col>
</Row> </Row>
{report.summary.connectivity_cases > 0 && (
<Alert type="info" showIcon style={{ marginBottom: 16 }}
message={`本次运行含 ${report.summary.connectivity_cases} 个连通用例(未配置判定标准,收到回复即通过)` +
(report.summary.judged_pass_rate != null
? `,判定型通过率 ${(report.summary.judged_pass_rate * 100).toFixed(1)}%`
: ',无判定型用例')} />
)}
<Card style={{ marginBottom: 16 }}> <Card style={{ marginBottom: 16 }}>
<Descriptions size="small" column={2}> <Descriptions size="small" column={2}>
<Descriptions.Item label="评测对象">{report.target_name}</Descriptions.Item> <Descriptions.Item label="评测对象">{report.target_name}</Descriptions.Item>
@ -379,9 +391,11 @@ function SingleReportView({ report }: { report: Report | null }) {
label: ( label: (
<Space> <Space>
<span style={{ fontWeight: 500 }}>{c.case_id}</span> <span style={{ fontWeight: 500 }}>{c.case_id}</span>
{c.results.every((r) => r.passed) {c.connectivity
? <Tag color="success"></Tag> ? <Tag color="blue"></Tag>
: <Tag color="error"></Tag>} : c.results.every((r) => r.passed)
? <Tag color="success"></Tag>
: <Tag color="error"></Tag>}
<Tag>{c.turns.length} </Tag> <Tag>{c.turns.length} </Tag>
</Space> </Space>
), ),
@ -501,6 +515,7 @@ function CompareView({ result }: { result: CompareResult | null }) {
<Space> <Space>
{r.changed && <Badge dot color="orange" />} {r.changed && <Badge dot color="orange" />}
<span style={{ fontWeight: r.changed ? 600 : 400 }}>{id}</span> <span style={{ fontWeight: r.changed ? 600 : 400 }}>{id}</span>
{r.connectivity && <Tag color="blue"></Tag>}
</Space> </Space>
), ),
}, },

View File

@ -40,8 +40,14 @@ def _seed_run(
pass_rate: float = 1.0, pass_rate: float = 1.0,
n_cases: int = 1, n_cases: int = 1,
scenario_id: str | None = None, scenario_id: str | None = None,
connectivity_cases: int = 0,
errored_cases: int = 0,
) -> str: ) -> str:
"""Create a minimal completed run with real data in the DB and return run_id.""" """Create a minimal completed run with real data in the DB and return run_id.
connectivity_cases: extra cases with replied turns but no rule results.
errored_cases: extra cases with a missing-reply turn and no rule results.
"""
target = EvalTarget( target = EvalTarget(
name="测试对象", name="测试对象",
platform=PlatformType.AI_DIGITAL_EMPLOYEE, platform=PlatformType.AI_DIGITAL_EMPLOYEE,
@ -54,7 +60,11 @@ def _seed_run(
if scenario_id is None: if scenario_id is None:
scenario = Scenario( scenario = Scenario(
name="测试场景", name="测试场景",
cases=[Case(id=f"c{i}", type=CaseType.SINGLE, messages=["hi"]) for i in range(n_cases)], cases=(
[Case(id=f"c{i}", type=CaseType.SINGLE, messages=["hi"]) for i in range(n_cases)]
+ [Case(id=f"conn{i}", type=CaseType.SINGLE, messages=["ping"]) for i in range(connectivity_cases)]
+ [Case(id=f"err{i}", type=CaseType.SINGLE, messages=["ping"]) for i in range(errored_cases)]
),
) )
scenario = ScenarioRepository(session).create(scenario) scenario = ScenarioRepository(session).create(scenario)
scenario_id = scenario.id scenario_id = scenario.id
@ -95,13 +105,35 @@ def _seed_run(
) )
result_repo.save_result(eval_result) result_repo.save_result(eval_result)
for i in range(connectivity_cases):
result_repo.save_turn(Turn(
run_id=run.id,
case_id=f"conn{i}",
round_index=1,
sent_message={"msgBody": {"content": "ping"}},
reply={"msgBody": {"content": "pong"}},
latency_ms=100,
))
for i in range(errored_cases):
result_repo.save_turn(Turn(
run_id=run.id,
case_id=f"err{i}",
round_index=1,
sent_message={"msgBody": {"content": "ping"}},
reply=None,
latency_ms=None,
))
all_total = total + connectivity_cases + errored_cases
all_passed = passed + connectivity_cases # 连通用例收到回复即通过(引擎口径)
run.summary = { run.summary = {
"total_cases": total, "total_cases": all_total,
"passed_cases": passed, "passed_cases": all_passed,
"failed_cases": total - passed, "failed_cases": all_total - all_passed,
"total_rules": total, "total_rules": total,
"passed_rules": passed, "passed_rules": passed,
"pass_rate": round(pass_rate, 4), "pass_rate": round(all_passed / all_total, 4) if all_total else 0.0,
} }
RunRepository(session).update(run) RunRepository(session).update(run)
return run.id return run.id
@ -210,6 +242,67 @@ def test_compare_report_different_scenarios_rejected(report_session):
generate_compare_report(run_id_a, run_id_b, report_session) generate_compare_report(run_id_a, run_id_b, report_session)
# ── connectivity case annotation (ticket 02) ─────────────────────────────
def test_report_marks_connectivity_case(report_session):
run_id = _seed_run(report_session, pass_rate=0.5, n_cases=2, connectivity_cases=1)
report = generate_report(run_id, report_session)
by_id = {c["case_id"]: c for c in report["cases"]}
assert by_id["conn0"]["connectivity"] is True
assert by_id["c0"]["connectivity"] is False
assert by_id["c1"]["connectivity"] is False
s = report["summary"]
assert s["connectivity_cases"] == 1
# 判定型通过率 = 判定型通过 1 ÷ 判定型总数 2
assert s["judged_pass_rate"] == 0.5
# 总通过率口径不变含连通用例ADR-0002
assert s["pass_rate"] == round(2 / 3, 4)
def test_errored_case_not_marked_connectivity(report_session):
"""无判定结果但缺回复的用例是执行失败,不是连通用例。"""
run_id = _seed_run(report_session, n_cases=1, errored_cases=1)
report = generate_report(run_id, report_session)
by_id = {c["case_id"]: c for c in report["cases"]}
assert by_id["err0"]["connectivity"] is False
assert report["summary"]["connectivity_cases"] == 0
def test_all_connectivity_judged_pass_rate_none(report_session):
"""全为连通用例时判定型通过率为 None不除零。"""
run_id = _seed_run(report_session, n_cases=0, connectivity_cases=2)
report = generate_report(run_id, report_session)
s = report["summary"]
assert s["connectivity_cases"] == 2
assert s["judged_pass_rate"] is None
def test_compare_report_marks_connectivity(report_session):
run_id_a = _seed_run(report_session, n_cases=1, connectivity_cases=1)
sid = _scenario_of(report_session, run_id_a)
run_id_b = _seed_run(report_session, n_cases=1, connectivity_cases=1, scenario_id=sid)
result = generate_compare_report(run_id_a, run_id_b, report_session)
by_id = {c["case_id"]: c for c in result["cases"]}
assert by_id["conn0"]["connectivity"] is True
assert by_id["c0"]["connectivity"] is False
# 连通用例双方均按引擎口径视为通过,不应标记 changed
assert by_id["conn0"]["run_a_passed"] is True
assert by_id["conn0"]["run_b_passed"] is True
assert by_id["conn0"]["changed"] is False
def test_markdown_report_shows_connectivity(report_session):
run_id = _seed_run(report_session, n_cases=1, connectivity_cases=1)
md = render_markdown_report(run_id, report_session)
assert "连通用例" in md
assert "判定型通过率" in md
# ── render_markdown_report ──────────────────────────────────────────────── # ── render_markdown_report ────────────────────────────────────────────────
def test_render_markdown_contains_header(report_session): def test_render_markdown_contains_header(report_session):