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.config import get_settings
|
||||||
from agenteval.evaluation.judgement import CaseOutcome, RuleOutcome, combine_case_outcome
|
from agenteval.evaluation.judgement import CaseOutcome, RuleOutcome, combine_case_outcome
|
||||||
from agenteval.evaluation.rules import RuleResult, get_rule
|
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.model_gateway import ModelGateway
|
||||||
from agenteval.models import (
|
from agenteval.models import (
|
||||||
Case,
|
Case,
|
||||||
@ -130,9 +131,7 @@ class EvalEngine:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
total_cases = len(self.scenario.cases)
|
total_cases = len(self.scenario.cases)
|
||||||
passed_cases = 0
|
case_outcomes: dict[str, CaseOutcome] = {}
|
||||||
failed_cases = 0
|
|
||||||
case_outcomes: dict[str, dict[str, bool]] = {}
|
|
||||||
|
|
||||||
for idx, case in enumerate(self.scenario.cases, start=1):
|
for idx, case in enumerate(self.scenario.cases, start=1):
|
||||||
self._check_cancel()
|
self._check_cancel()
|
||||||
@ -151,11 +150,7 @@ class EvalEngine:
|
|||||||
case,
|
case,
|
||||||
progress_callback,
|
progress_callback,
|
||||||
)
|
)
|
||||||
case_outcomes[case.id] = {"passed": outcome.passed, "connectivity": outcome.connectivity}
|
case_outcomes[case.id] = outcome
|
||||||
if outcome.passed:
|
|
||||||
passed_cases += 1
|
|
||||||
else:
|
|
||||||
failed_cases += 1
|
|
||||||
await self._emit(
|
await self._emit(
|
||||||
progress_callback,
|
progress_callback,
|
||||||
"case_end",
|
"case_end",
|
||||||
@ -170,34 +165,18 @@ class EvalEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
results = self.run_repo.get_results(run.id)
|
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)
|
turns = self.run_repo.get_turns(run.id)
|
||||||
latencies = [t.latency_ms for t in turns if t.latency_ms is not None]
|
summary = build_run_summary(
|
||||||
avg_latency_ms = round(sum(latencies) / len(latencies), 1) if latencies else None
|
case_outcomes=case_outcomes,
|
||||||
|
latencies=[t.latency_ms for t in turns if t.latency_ms is not None],
|
||||||
summary = {
|
rule_passes=[r.passed for r in results],
|
||||||
"total_cases": total_cases,
|
case_errors=self._case_errors or None,
|
||||||
"passed_cases": passed_cases,
|
model_configs=(
|
||||||
"failed_cases": failed_cases,
|
{purpose.value: config.snapshot() for purpose, config in self._resolved_models.items()}
|
||||||
"total_rules": total_rules,
|
if self._resolved_models
|
||||||
"passed_rules": passed_rules,
|
else None
|
||||||
# 通过率是用例级口径(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()
|
|
||||||
}
|
|
||||||
run.status = RunStatus.COMPLETED
|
run.status = RunStatus.COMPLETED
|
||||||
run.completed_at = utc_now()
|
run.completed_at = utc_now()
|
||||||
run.summary = summary
|
run.summary = summary
|
||||||
@ -206,7 +185,7 @@ class EvalEngine:
|
|||||||
"run_completed",
|
"run_completed",
|
||||||
{
|
{
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"summary": summary,
|
"summary": summary.model_dump(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except CancelledError:
|
except CancelledError:
|
||||||
|
|||||||
@ -103,8 +103,10 @@ def generate_report(run_id: str, session=None) -> dict[str, Any]:
|
|||||||
passed_cases = summary.passed_cases
|
passed_cases = summary.passed_cases
|
||||||
connectivity_count = sum(1 for c in cases if c["connectivity"])
|
connectivity_count = sum(1 for c in cases if c["connectivity"])
|
||||||
judged_total = total_cases - connectivity_count
|
judged_total = total_cases - connectivity_count
|
||||||
# 连通用例按引擎口径计通过,判定型通过数 = 总通过数 - 连通用例数
|
# 判定型通过率由 build_run_summary 入库,读路径只读;老 run 缺字段时按同一口径回退近似
|
||||||
judged_pass_rate = round((passed_cases - connectivity_count) / judged_total, 4) if judged_total > 0 else None
|
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 {
|
return {
|
||||||
"run_id": run.id,
|
"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
|
passed_rules: int = 0
|
||||||
# 用例级通过率,含执行失败(ADR-0002);失败/取消的 run 无此值
|
# 用例级通过率,含执行失败(ADR-0002);失败/取消的 run 无此值
|
||||||
pass_rate: Optional[float] = None
|
pass_rate: Optional[float] = None
|
||||||
|
# 判定型通过率:连通用例从分子分母双双剔除;无判定型用例时为空
|
||||||
|
judged_pass_rate: Optional[float] = None
|
||||||
avg_latency_ms: Optional[float] = None
|
avg_latency_ms: Optional[float] = None
|
||||||
case_outcomes: dict[str, CaseOutcomeSummary] = Field(default_factory=dict)
|
case_outcomes: dict[str, CaseOutcomeSummary] = Field(default_factory=dict)
|
||||||
case_errors: list[dict[str, str]] = Field(default_factory=list)
|
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