refactor(engine): extract build_run_summary pure seam

Single-run summary口径 (pass_rate / judged_pass_rate / avg_latency /
connectivity split) was inlined in run(), reachable only by driving a
whole async run, and report.py recomputed judged_pass_rate independently.
Extract build_run_summary — a pure function parallel to aggregate_runs
(cross-run) and combine_case_outcome (case-level). run() now collects
material and delegates; judged_pass_rate is stored in RunSummary so the
report reads it instead of recomputing.
This commit is contained in:
sinohqb 2026-07-31 14:20:51 +08:00
parent 983a58d013
commit 9c01afa79b
5 changed files with 190 additions and 38 deletions

View File

@ -15,6 +15,7 @@ from agenteval.channels.factory import ChannelFactory
from agenteval.config import get_settings
from agenteval.evaluation.judgement import CaseOutcome, RuleOutcome, combine_case_outcome
from agenteval.evaluation.rules import RuleResult, get_rule
from agenteval.evaluation.run_summary import build_run_summary
from agenteval.model_gateway import ModelGateway
from agenteval.models import (
Case,
@ -130,9 +131,7 @@ class EvalEngine:
try:
total_cases = len(self.scenario.cases)
passed_cases = 0
failed_cases = 0
case_outcomes: dict[str, dict[str, bool]] = {}
case_outcomes: dict[str, CaseOutcome] = {}
for idx, case in enumerate(self.scenario.cases, start=1):
self._check_cancel()
@ -151,11 +150,7 @@ class EvalEngine:
case,
progress_callback,
)
case_outcomes[case.id] = {"passed": outcome.passed, "connectivity": outcome.connectivity}
if outcome.passed:
passed_cases += 1
else:
failed_cases += 1
case_outcomes[case.id] = outcome
await self._emit(
progress_callback,
"case_end",
@ -170,34 +165,18 @@ class EvalEngine:
)
results = self.run_repo.get_results(run.id)
total_rules = len(results)
passed_rules = sum(1 for r in results if r.passed)
turns = self.run_repo.get_turns(run.id)
latencies = [t.latency_ms for t in turns if t.latency_ms is not None]
avg_latency_ms = round(sum(latencies) / len(latencies), 1) if latencies else None
summary = {
"total_cases": total_cases,
"passed_cases": passed_cases,
"failed_cases": failed_cases,
"total_rules": total_rules,
"passed_rules": passed_rules,
# 通过率是用例级口径CONTEXT.md规则级数字保留在 passed_rules/total_rules
"pass_rate": round(passed_cases / total_cases, 4) if total_cases else 0.0,
# 平均时延(毫秒),供活动周期报告的时延轴聚合;无回复轮不计入
"avg_latency_ms": avg_latency_ms,
# 逐用例权威判定judgement.py 算一次),报告/对比/渲染层只读不重算
"case_outcomes": case_outcomes,
}
# Surface fatal case-level errors (e.g. dynamic generation failures)
# so the report / DB record shows *why* a run produced no results.
if self._case_errors:
summary["case_errors"] = self._case_errors
if self._resolved_models:
summary["model_configs"] = {
purpose.value: config.snapshot() for purpose, config in self._resolved_models.items()
}
summary = build_run_summary(
case_outcomes=case_outcomes,
latencies=[t.latency_ms for t in turns if t.latency_ms is not None],
rule_passes=[r.passed for r in results],
case_errors=self._case_errors or None,
model_configs=(
{purpose.value: config.snapshot() for purpose, config in self._resolved_models.items()}
if self._resolved_models
else None
),
)
run.status = RunStatus.COMPLETED
run.completed_at = utc_now()
run.summary = summary
@ -206,7 +185,7 @@ class EvalEngine:
"run_completed",
{
"status": "completed",
"summary": summary,
"summary": summary.model_dump(),
},
)
except CancelledError:

View File

@ -103,8 +103,10 @@ def generate_report(run_id: str, session=None) -> dict[str, Any]:
passed_cases = summary.passed_cases
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
# 判定型通过率由 build_run_summary 入库,读路径只读;老 run 缺字段时按同一口径回退近似
judged_pass_rate = summary.judged_pass_rate
if judged_pass_rate is None and judged_total > 0:
judged_pass_rate = round((passed_cases - connectivity_count) / judged_total, 4)
return {
"run_id": run.id,

View File

@ -0,0 +1,53 @@
"""Single-run summary aggregation — the pure落点 for one run's口径.
Parallel to ``metrics.aggregate_runs`` (cross-run) and
``judgement.combine_case_outcome`` (case-level): given the authoritative
per-case outcomes plus raw latency/rule material, compute the run's
summary口径 once. No IO the engine collects material and calls this; DB
writes and event emits stay in the caller. See CONTEXT.md (通过率) / ADR-0002.
"""
from typing import Any, Optional, Sequence
from agenteval.evaluation.judgement import CaseOutcome
from agenteval.models import CaseOutcomeSummary, RunSummary
def build_run_summary(
*,
case_outcomes: dict[str, CaseOutcome],
latencies: Sequence[float],
rule_passes: Sequence[bool],
case_errors: Optional[list[dict[str, str]]] = None,
model_configs: Optional[dict[str, Any]] = None,
) -> RunSummary:
"""Compute a run's summary口径 from its authoritative case outcomes."""
total_cases = len(case_outcomes)
passed_cases = sum(1 for o in case_outcomes.values() if o.passed)
connectivity_count = sum(1 for o in case_outcomes.values() if o.connectivity)
pass_rate = round(passed_cases / total_cases, 4) if total_cases else 0.0
# 连通用例按引擎口径计通过,判定型通过数 = 总通过数 - 连通用例数
judged_total = total_cases - connectivity_count
judged_pass_rate = (
round((passed_cases - connectivity_count) / judged_total, 4) if judged_total > 0 else None
)
avg_latency_ms = round(sum(latencies) / len(latencies), 1) if latencies else None
return RunSummary(
total_cases=total_cases,
passed_cases=passed_cases,
failed_cases=total_cases - passed_cases,
total_rules=len(rule_passes),
passed_rules=sum(1 for p in rule_passes if p),
pass_rate=pass_rate,
judged_pass_rate=judged_pass_rate,
avg_latency_ms=avg_latency_ms,
case_outcomes={
case_id: CaseOutcomeSummary(passed=o.passed, connectivity=o.connectivity)
for case_id, o in case_outcomes.items()
},
case_errors=case_errors or [],
model_configs=model_configs or {},
)

View File

@ -187,6 +187,8 @@ class RunSummary(BaseModel):
passed_rules: int = 0
# 用例级通过率含执行失败ADR-0002失败/取消的 run 无此值
pass_rate: Optional[float] = None
# 判定型通过率:连通用例从分子分母双双剔除;无判定型用例时为空
judged_pass_rate: Optional[float] = None
avg_latency_ms: Optional[float] = None
case_outcomes: dict[str, CaseOutcomeSummary] = Field(default_factory=dict)
case_errors: list[dict[str, str]] = Field(default_factory=list)

View File

@ -0,0 +1,116 @@
"""Unit tests for the pure single-run summary seam.
build_run_summary is the single reusable落点 for one run's口径 (pass_rate /
judged_pass_rate / avg_latency / connectivity split), parallel to
metrics.aggregate_runs (cross-run) and judgement.combine_case_outcome
(case-level). No IO fed constructed material, asserted directly.
"""
from agenteval.evaluation.judgement import CaseOutcome
from agenteval.evaluation.run_summary import build_run_summary
def _outcomes(**kw: CaseOutcome) -> dict[str, CaseOutcome]:
return dict(kw)
def test_all_judged_passing():
summary = build_run_summary(
case_outcomes=_outcomes(
a=CaseOutcome(passed=True, connectivity=False),
b=CaseOutcome(passed=True, connectivity=False),
),
latencies=[100.0, 300.0],
rule_passes=[True, True, True],
)
assert summary.total_cases == 2
assert summary.passed_cases == 2
assert summary.failed_cases == 0
assert summary.pass_rate == 1.0
assert summary.judged_pass_rate == 1.0
assert summary.total_rules == 3
assert summary.passed_rules == 3
assert summary.avg_latency_ms == 200.0
def test_mixed_pass_fail():
summary = build_run_summary(
case_outcomes=_outcomes(
a=CaseOutcome(passed=True, connectivity=False),
b=CaseOutcome(passed=False, connectivity=False),
c=CaseOutcome(passed=False, connectivity=False),
),
latencies=[],
rule_passes=[True, False],
)
assert summary.total_cases == 3
assert summary.passed_cases == 1
assert summary.failed_cases == 2
assert summary.pass_rate == 0.3333
assert summary.judged_pass_rate == 0.3333
assert summary.passed_rules == 1
assert summary.total_rules == 2
assert summary.avg_latency_ms is None
def test_connectivity_excluded_from_judged_rate():
# 连通用例计入总通过率但从判定型口径的分子分母双双剔除report.py 现口径)
summary = build_run_summary(
case_outcomes=_outcomes(
conn=CaseOutcome(passed=True, connectivity=True),
judged_pass=CaseOutcome(passed=True, connectivity=False),
judged_fail=CaseOutcome(passed=False, connectivity=False),
),
latencies=[50.0],
rule_passes=[True, False],
)
assert summary.total_cases == 3
assert summary.passed_cases == 2 # conn + judged_pass
assert summary.pass_rate == 0.6667
# judged: 分母 = 3 - 1 连通 = 2分子 = 2 通过 - 1 连通 = 1
assert summary.judged_pass_rate == 0.5
def test_all_connectivity_yields_no_judged_rate():
summary = build_run_summary(
case_outcomes=_outcomes(
a=CaseOutcome(passed=True, connectivity=True),
b=CaseOutcome(passed=True, connectivity=True),
),
latencies=[10.0],
rule_passes=[],
)
assert summary.pass_rate == 1.0
assert summary.judged_pass_rate is None # judged_total == 0
def test_empty_run():
summary = build_run_summary(case_outcomes={}, latencies=[], rule_passes=[])
assert summary.total_cases == 0
assert summary.pass_rate == 0.0
assert summary.judged_pass_rate is None
assert summary.avg_latency_ms is None
def test_case_errors_and_model_configs_passed_through():
errors = [{"case_id": "x", "message": "boom"}]
configs = {"judge": {"model": "gpt-4o"}}
summary = build_run_summary(
case_outcomes=_outcomes(x=CaseOutcome(passed=False, connectivity=False)),
latencies=[],
rule_passes=[],
case_errors=errors,
model_configs=configs,
)
assert summary.case_errors == errors
assert summary.model_configs == configs
def test_case_outcomes_persisted_as_verdict_snapshot():
summary = build_run_summary(
case_outcomes=_outcomes(a=CaseOutcome(passed=True, connectivity=True)),
latencies=[],
rule_passes=[],
)
assert summary.case_outcomes["a"].passed is True
assert summary.case_outcomes["a"].connectivity is True