AgentEvalTool/tests/unit/test_report.py
sinohqb f285738f6d refactor(report): split report generation from pure rendering
report.py mixed DB-reading generation with string formatting: the four
render_*_report(run_id, session) functions each re-fetched via
generate_report, so the HTML/Markdown/JSON formatting was welded to storage
and could not be unit-tested from a plain dict. Extract the formatting into a
new pure report_render module whose renderers take the already-built report
dict (no session, no storage import). Migrate every caller to generate-then-
render, delete the old coupled renderers with no back-compat shim, and drop
the _aggregate_runs middle-man alias in favour of metrics.aggregate_runs.
2026-07-31 10:19:04 +08:00

412 lines
16 KiB
Python
Raw 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.

"""Unit tests for report generation: generate_report, generate_compare_report, render_markdown_report."""
import pytest
from sqlmodel import Session, SQLModel, create_engine
from agenteval.evaluation.report import (
generate_compare_report,
generate_report,
)
from agenteval.evaluation.report_render import render_json, render_markdown
from agenteval.models import (
Case, CaseType, EvalResult, EvalRun, EvalTarget, RunStatus, Scenario, Turn,
PlatformType, ChannelType, TargetStatus,
)
from agenteval.storage.repository import ResultRepository, RunRepository, ScenarioRepository, TargetRepository
@pytest.fixture()
def report_session(tmp_path):
from agenteval.storage.db import ( # noqa: F401
EvalResultDB, EvalRunDB, EvalTargetDB, FileCategoryDB, FileRecordDB, ScenarioDB, TurnDB,
)
engine = create_engine(
f"sqlite:///{tmp_path / 'report_test.db'}",
connect_args={"check_same_thread": False},
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
try:
yield session
finally:
session.close()
engine.dispose()
def _seed_run(
session: Session,
*,
pass_rate: float = 1.0,
n_cases: int = 1,
scenario_id: str | None = None,
scenario_version: int = 1,
connectivity_cases: int = 0,
errored_cases: int = 0,
) -> str:
"""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(
name="测试对象",
platform=PlatformType.AI_DIGITAL_EMPLOYEE,
channel_type=ChannelType.TUTU_API,
channel_config={},
status=TargetStatus.ACTIVE,
)
target = TargetRepository(session).create(target)
if scenario_id is None:
scenario = Scenario(
name="测试场景",
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_id = scenario.id
run = EvalRun(
target_id=target.id,
scenario_id=scenario_id,
scenario_version=scenario_version,
status=RunStatus.COMPLETED,
)
run = RunRepository(session).create(run)
run_repo = RunRepository(session)
result_repo = ResultRepository(session)
total = n_cases
passed = int(total * pass_rate)
for i in range(n_cases):
turn = Turn(
run_id=run.id,
case_id=f"c{i}",
round_index=1,
sent_message={"msgBody": {"content": f"问题{i}"}},
reply={"msgBody": {"content": f"回答{i}"}},
latency_ms=200,
)
result_repo.save_turn(turn)
db_turn = run_repo.get_turns(run.id)[-1]
eval_result = EvalResult(
run_id=run.id,
case_id=f"c{i}",
turn_id=db_turn.id or "",
rule_type="keyword_match",
passed=(i < passed),
score=1.0 if i < passed else 0.0,
reason="通过" if i < passed else "失败",
)
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 = {
"total_cases": all_total,
"passed_cases": all_passed,
"failed_cases": all_total - all_passed,
"total_rules": total,
"passed_rules": passed,
"pass_rate": round(all_passed / all_total, 4) if all_total else 0.0,
}
RunRepository(session).update(run)
return run.id
# ── generate_report ───────────────────────────────────────────────────────
def test_generate_report_structure(report_session):
run_id = _seed_run(report_session)
report = generate_report(run_id, report_session)
assert report["run_id"] == run_id
assert report["target_name"] == "测试对象"
assert report["scenario_name"] == "测试场景"
assert report["status"] == "completed"
assert "summary" in report
assert "cases" in report
def test_generate_report_summary_values(report_session):
run_id = _seed_run(report_session, pass_rate=1.0, n_cases=2)
report = generate_report(run_id, report_session)
s = report["summary"]
assert s["total_cases"] == 2
assert s["passed_cases"] == 2
assert s["pass_rate"] == 1.0
def test_generate_report_partial_pass(report_session):
run_id = _seed_run(report_session, pass_rate=0.5, n_cases=2)
report = generate_report(run_id, report_session)
s = report["summary"]
assert s["passed_cases"] == 1
assert s["failed_cases"] == 1
def test_generate_report_cases_contain_turns_and_results(report_session):
run_id = _seed_run(report_session, n_cases=1)
report = generate_report(run_id, report_session)
assert len(report["cases"]) == 1
case = report["cases"][0]
assert len(case["turns"]) == 1
assert len(case["results"]) == 1
assert case["turns"][0]["sent_text"] == "问题0"
assert case["turns"][0]["latency_ms"] == 200
def test_generate_report_not_found_raises(report_session):
with pytest.raises(ValueError, match="run not found"):
generate_report("no-such-id", report_session)
# ── generate_compare_report ───────────────────────────────────────────────
def _scenario_of(session: Session, run_id: str) -> str:
return RunRepository(session).get(run_id).scenario_id
def test_compare_report_structure(report_session):
run_id_a = _seed_run(report_session, pass_rate=1.0, n_cases=2)
sid = _scenario_of(report_session, run_id_a)
run_id_b = _seed_run(report_session, pass_rate=0.5, n_cases=2, scenario_id=sid)
result = generate_compare_report(run_id_a, run_id_b, report_session)
assert "run_a" in result
assert "run_b" in result
assert "delta" in result
assert "cases" in result
assert result["run_a"]["run_id"] == run_id_a
assert result["run_b"]["run_id"] == run_id_b
def test_compare_report_delta(report_session):
run_id_a = _seed_run(report_session, pass_rate=0.5, n_cases=2)
sid = _scenario_of(report_session, run_id_a)
run_id_b = _seed_run(report_session, pass_rate=1.0, n_cases=2, scenario_id=sid)
result = generate_compare_report(run_id_a, run_id_b, report_session)
assert result["delta"]["pass_rate"] > 0 # B improved over A
def test_compare_report_changed_cases(report_session):
run_id_a = _seed_run(report_session, pass_rate=1.0, n_cases=2)
sid = _scenario_of(report_session, run_id_a)
run_id_b = _seed_run(report_session, pass_rate=0.5, n_cases=2, scenario_id=sid)
result = generate_compare_report(run_id_a, run_id_b, report_session)
# At least one case changed (A all-pass vs B half-pass)
assert result["changed_cases"] >= 1
def test_compare_report_case_level(report_session):
run_id_a = _seed_run(report_session, n_cases=1)
sid = _scenario_of(report_session, run_id_a)
run_id_b = _seed_run(report_session, n_cases=1, scenario_id=sid)
result = generate_compare_report(run_id_a, run_id_b, report_session)
assert len(result["cases"]) >= 1
case = result["cases"][0]
assert "run_a_passed" in case
assert "run_b_passed" in case
assert "changed" in case
def test_compare_report_different_scenarios_rejected(report_session):
run_id_a = _seed_run(report_session, n_cases=1)
run_id_b = _seed_run(report_session, n_cases=1) # separate scenario
with pytest.raises(ValueError, match="same scenario"):
generate_compare_report(run_id_a, run_id_b, report_session)
def test_compare_report_cross_version_rejected(report_session):
"""同场景不同考纲版本不可比ticket 05 / ADR-0001"""
run_id_a = _seed_run(report_session, n_cases=1)
sid = _scenario_of(report_session, run_id_a)
run_id_b = _seed_run(report_session, n_cases=1, scenario_id=sid, scenario_version=2)
with pytest.raises(ValueError, match="version"):
generate_compare_report(run_id_a, run_id_b, report_session)
def test_compare_report_same_version_allowed(report_session):
run_id_a = _seed_run(report_session, n_cases=1, scenario_version=2)
sid = _scenario_of(report_session, run_id_a)
run_id_b = _seed_run(report_session, n_cases=1, scenario_id=sid, scenario_version=2)
result = generate_compare_report(run_id_a, run_id_b, report_session)
assert result["run_a"]["scenario_version"] == 2
assert result["run_b"]["scenario_version"] == 2
# ── 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(generate_report(run_id, report_session))
assert "连通用例" in md
assert "判定型通过率" in md
# ── render_markdown_report ────────────────────────────────────────────────
def test_render_markdown_contains_header(report_session):
run_id = _seed_run(report_session)
md = render_markdown(generate_report(run_id, report_session))
assert "# 评测报告" in md
def test_render_markdown_contains_summary_table(report_session):
run_id = _seed_run(report_session)
md = render_markdown(generate_report(run_id, report_session))
assert "## 汇总" in md
assert "| 指标 | 数值 |" in md
assert "通过率" in md
def test_render_markdown_contains_case_section(report_session):
run_id = _seed_run(report_session, n_cases=1)
md = render_markdown(generate_report(run_id, report_session))
assert "## 用例明细" in md
assert "### " in md # case header
def test_render_markdown_contains_rule_table(report_session):
run_id = _seed_run(report_session)
md = render_markdown(generate_report(run_id, report_session))
assert "**规则评估结果**" in md
assert "keyword_match" in md
# ── render_json_report ────────────────────────────────────────────────────
def test_render_json_report_is_valid_json(report_session):
import json
run_id = _seed_run(report_session)
json_text = render_json(generate_report(run_id, report_session))
parsed = json.loads(json_text)
assert parsed["run_id"] == run_id
# ── 权威判定消费judgement 语义收敛) ────────────────────────────────────
def test_case_dict_contains_passed_fallback(report_session):
"""老 run无 case_outcomespassed 反推得出,故障用例=FalseADR-0002"""
run_id = _seed_run(report_session, pass_rate=0.5, n_cases=2,
connectivity_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["c0"]["passed"] is True
assert by_id["c1"]["passed"] is False
assert by_id["conn0"]["passed"] is True
assert by_id["err0"]["passed"] is False
def test_markdown_errored_case_shows_failed_badge(report_session):
"""故障用例(无结果且非连通)在 MD 中必须 ❌ —— 此前 all([]) 误判 ✅。"""
run_id = _seed_run(report_session, n_cases=1, errored_cases=1)
md = render_markdown(generate_report(run_id, report_session))
assert "❌ 用例 `err0`" in md
assert "✅ 用例 `err0`" not in md
def test_authoritative_case_outcomes_override_reconstruction(report_session):
"""summary.case_outcomes 是权威判定:与规则结果反推冲突时以权威为准。"""
run_id = _seed_run(report_session, pass_rate=1.0, n_cases=1)
repo = RunRepository(report_session)
run = repo.get(run_id)
# 模拟 weighted 阈值未达:规则单条通过但用例判失败(反推 all() 会误判 True
run.summary = {**run.summary.model_dump(), "case_outcomes": {"c0": {"passed": False, "connectivity": False}}}
repo.update(run)
report = generate_report(run_id, report_session)
assert report["cases"][0]["passed"] is False
md = render_markdown(generate_report(run_id, report_session))
assert "❌ 用例 `c0`" in md
def test_compare_errored_case_counts_as_failed(report_session):
"""对比中故障用例判 False 而非 NoneADR-0002 服务视角)。"""
run_id_a = _seed_run(report_session, n_cases=1, errored_cases=1)
sid = _scenario_of(report_session, run_id_a)
run_id_b = _seed_run(report_session, n_cases=1, errored_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["err0"]["run_a_passed"] is False
assert by_id["err0"]["run_b_passed"] is False
assert by_id["err0"]["changed"] is False