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:
parent
983a58d013
commit
9c01afa79b
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
53
backend/agenteval/evaluation/run_summary.py
Normal file
53
backend/agenteval/evaluation/run_summary.py
Normal 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 {},
|
||||
)
|
||||
@ -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)
|
||||
|
||||
116
tests/unit/test_build_run_summary.py
Normal file
116
tests/unit/test_build_run_summary.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user