AgentEvalTool/tests/unit/test_report.py
sinohqb 770d260750
Some checks failed
CI / test (push) Failing after 39s
feat(report): compare requires same scenario version (ticket 05)
对比报告可比性收紧为同场景同考纲版本(ADR-0001):跨版本 API 返回 400
(detail 含双方版本号),报告生成层抛 ValueError;前端对比候选按
同场景 + 同版本过滤,A 变更后自动清空不可比的 B。文档"尚未实现"标注移除。
2026-07-29 11:21:52 +08:00

364 lines
14 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,
render_markdown_report,
render_json_report,
)
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_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_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_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_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_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_report(run_id, report_session)
parsed = json.loads(json_text)
assert parsed["run_id"] == run_id