AgentEvalTool/tests/unit/test_engine.py
sinohqb 5dd1bc8535 feat(engine): expectations now additive with explicit rules (ticket 01)
期望始终派生隐式判定并与显式规则叠加执行:rule_logic 只组合显式规则,
期望是叠加其上的硬约束,任一不满足即用例不通过。隐式判定以 EvalResult
同构落库,reason 前缀 [期望] 标明来源。连通用例(无规则无期望)行为不变。
2026-07-29 10:24:01 +08:00

440 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 the async EvalEngine.
Focus on the new async behavior: cooperative cancellation, configurable
timeouts, and concurrent case execution via the semaphore.
"""
import asyncio
from typing import Any
import pytest
from agenteval.channels.base import SendResult
from agenteval.evaluation.engine import CancelledError, EvalEngine, TimeoutConfig
from agenteval.models import (
Case, CaseType, ChannelType, EvalTarget, Expectation, PlatformType,
RunStatus, Scenario, TargetStatus,
)
from agenteval.storage.repository import RunRepository
from tests.unit.mock_channel import MockChannel
def _make_target() -> EvalTarget:
return EvalTarget(
id="t-1",
name="mock-target",
platform=PlatformType.AI_DIGITAL_EMPLOYEE,
channel_type=ChannelType.TUTU_API,
channel_config={
"base_url": "http://mock",
"token": "x",
"tenant": "t",
"chat_channel_id": "c",
"chat_contact_id": "u",
},
status=TargetStatus.ACTIVE,
)
def _build_engine(
scenario: Scenario,
channel: MockChannel,
*,
cancel_token: asyncio.Event | None = None,
timeout_config: TimeoutConfig | None = None,
max_concurrent_cases: int = 1,
session=None,
) -> EvalEngine:
"""Construct an EvalEngine with a MockChannel injected.
The factory still runs (needs a valid channel_config on the target) but
we immediately replace the channel with the mock.
"""
engine = EvalEngine(
target=_make_target(),
scenario=scenario,
session=session,
cancel_token=cancel_token or asyncio.Event(),
timeout_config=timeout_config,
max_concurrent_cases=max_concurrent_cases,
)
engine.channel = channel
return engine
# ── basic happy path ─────────────────────────────────────────────────────
async def test_run_single_case_completes(db_session):
scenario = Scenario(
id="s-1", name="single",
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hello"])],
)
channel = MockChannel()
engine = _build_engine(scenario, channel, session=db_session)
run = await engine.run()
assert run.status == RunStatus.COMPLETED
assert run.summary["total_cases"] == 1
assert run.summary["passed_cases"] == 1
assert channel.send_calls == 1
assert channel.poll_calls == 1
async def test_run_multi_turn_collects_all_turns(db_session):
scenario = Scenario(
id="s-1", name="multi",
cases=[Case(id="c1", type=CaseType.MULTI_TURN, messages=["a", "b", "c"])],
)
channel = MockChannel()
engine = _build_engine(scenario, channel, session=db_session)
run = await engine.run()
assert run.status == RunStatus.COMPLETED
assert channel.sent == ["a", "b", "c"]
assert channel.send_calls == 3
# Each turn polls once.
assert channel.poll_calls == 3
async def test_run_multiple_cases(db_session):
scenario = Scenario(
id="s-1", name="multi-case",
cases=[
Case(id="c1", type=CaseType.SINGLE, messages=["one"]),
Case(id="c2", type=CaseType.SINGLE, messages=["two"]),
Case(id="c3", type=CaseType.SINGLE, messages=["three"]),
],
)
channel = MockChannel()
engine = _build_engine(scenario, channel, session=db_session)
run = await engine.run()
assert run.status == RunStatus.COMPLETED
assert run.summary["total_cases"] == 3
assert channel.sent == ["one", "two", "three"]
# ── progress callback ────────────────────────────────────────────────────
async def test_progress_callback_receives_events(db_session):
scenario = Scenario(
id="s-1", name="single",
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
)
events: list[tuple[str, dict]] = []
async def cb(event: str, data: dict[str, Any]) -> None:
events.append((event, data))
channel = MockChannel()
engine = _build_engine(scenario, channel, session=db_session)
await engine.run(progress_callback=cb)
event_names = [e[0] for e in events]
assert "case_start" in event_names
assert "turn_start" in event_names
assert "turn_end" in event_names
assert "case_end" in event_names
assert "run_completed" in event_names
async def test_sync_progress_callback_also_works(db_session):
"""Engine must accept sync callbacks (CLI uses them)."""
scenario = Scenario(
id="s-1", name="single",
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
)
seen: list[str] = []
def sync_cb(event: str, data: dict) -> None:
seen.append(event)
channel = MockChannel()
engine = _build_engine(scenario, channel, session=db_session)
await engine.run(progress_callback=sync_cb)
assert "run_completed" in seen
# ── cancellation ─────────────────────────────────────────────────────────
async def test_cancel_before_run_marks_failed(db_session):
scenario = Scenario(
id="s-1", name="single",
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
)
cancel_token = asyncio.Event()
cancel_token.set() # already cancelled
channel = MockChannel()
engine = _build_engine(scenario, channel, cancel_token=cancel_token, session=db_session)
run = await engine.run()
assert run.status == RunStatus.FAILED
assert run.summary["error"]["code"] == "cancelled_by_user"
# Engine must NOT have called the channel.
assert channel.send_calls == 0
async def test_cancel_mid_run_stops_after_current_case(db_session):
"""Cancelling between cases should stop further cases from running."""
scenario = Scenario(
id="s-1", name="multi",
cases=[
Case(id="c1", type=CaseType.SINGLE, messages=["a"]),
Case(id="c2", type=CaseType.SINGLE, messages=["b"]),
Case(id="c3", type=CaseType.SINGLE, messages=["c"]),
],
)
cancel_token = asyncio.Event()
# Slow channel so we have time to set the cancel token from another task.
channel = MockChannel(reply_delay=0.05)
async def cancel_soon() -> None:
await asyncio.sleep(0.08)
cancel_token.set()
engine = _build_engine(scenario, channel, cancel_token=cancel_token, session=db_session)
run, _ = await asyncio.gather(
engine.run(),
cancel_soon(),
)
assert run.status == RunStatus.FAILED
assert run.summary["error"]["code"] == "cancelled_by_user"
# At least one case ran, but not all three.
assert 1 <= channel.send_calls < 3
# ── timeouts ─────────────────────────────────────────────────────────────
async def test_poll_timeout_records_missing_reply(db_session):
"""When the channel returns no reply within the timeout, the turn is saved
with reply=None but the engine keeps going (doesn't crash)."""
scenario = Scenario(
id="s-1", name="single",
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
)
channel = MockChannel(missing_reply=True)
engine = _build_engine(
scenario, channel,
timeout_config=TimeoutConfig(poll_reply=0.1),
session=db_session,
)
run = await engine.run()
# Engine completes (doesn't hang), turn has no reply.
assert run.status == RunStatus.COMPLETED
turns = RunRepository(db_session).get_turns(run.id)
assert len(turns) == 1
assert turns[0].reply is None
# ── concurrency ──────────────────────────────────────────────────────────
async def test_concurrent_cases_respect_semaphore(db_session):
"""With max_concurrent_cases=2, at most 2 cases should be in-flight."""
scenario = Scenario(
id="s-1", name="concurrent",
cases=[
Case(id=f"c{i}", type=CaseType.SINGLE, messages=[f"m{i}"])
for i in range(5)
],
)
in_flight = {"n": 0, "max": 0}
lock = asyncio.Lock()
class TrackedChannel(MockChannel):
async def send(self, content: str, **kwargs):
async with lock:
in_flight["n"] += 1
in_flight["max"] = max(in_flight["max"], in_flight["n"])
await asyncio.sleep(0.05)
result = await super().send(content, **kwargs)
async with lock:
in_flight["n"] -= 1
return result
channel = TrackedChannel()
engine = _build_engine(
scenario, channel,
max_concurrent_cases=2,
session=db_session,
)
run = await engine.run()
assert run.status == RunStatus.COMPLETED
assert channel.send_calls == 5
assert in_flight["max"] <= 2
# ── send failure ─────────────────────────────────────────────────────────
async def test_send_failure_aborts_case(db_session):
"""When channel.send fails, the case is marked failed but the engine
continues with the next case."""
scenario = Scenario(
id="s-1", name="mixed",
cases=[
Case(id="c1", type=CaseType.SINGLE, messages=["a"]),
Case(id="c2", type=CaseType.SINGLE, messages=["b"]),
],
)
call_count = {"n": 0}
class FlakeyChannel(MockChannel):
async def send(self, content, **kwargs):
call_count["n"] += 1
if call_count["n"] == 1:
return SendResult(ok=False, error="boom")
return await super().send(content, **kwargs)
channel = FlakeyChannel()
engine = _build_engine(scenario, channel, session=db_session)
run = await engine.run()
assert run.status == RunStatus.COMPLETED
assert run.summary["failed_cases"] == 1
assert run.summary["passed_cases"] == 1
# ── dynamic case generation failure ──────────────────────────────────────
async def test_dynamic_generation_failure_records_case_error(db_session):
"""A dynamic case whose message generation fails must persist the reason
into run.summary.case_errors — not just emit it transiently. Otherwise a
run shows 0 rules / failed with no discoverable cause."""
scenario = Scenario(
id="s-1", name="dynamic",
cases=[Case(id="dyn-1", type=CaseType.DYNAMIC, prompt="生成问题", turns=3)],
llm_config=None, # 缺 llm_config → 生成消息立即失败
)
channel = MockChannel()
engine = _build_engine(scenario, channel, session=db_session)
run = await engine.run()
assert run.status == RunStatus.COMPLETED
assert run.summary["failed_cases"] == 1
assert run.summary["total_rules"] == 0
# 关键:失败原因被持久化到 summary可在报告 / DB 查看
assert "case_errors" in run.summary
assert run.summary["case_errors"][0]["case_id"] == "dyn-1"
assert "llm_config" in run.summary["case_errors"][0]["error"]
# 被测通道不应被调用(生成阶段就失败了)
assert channel.send_calls == 0
# ── expectation + explicit rules are additive (ticket 01) ────────────────
# MockChannel replies "echo: q-1", so keyword "echo" passes, "__NOPE__" fails.
from agenteval.models import EvalRuleConfig, RuleLogic # noqa: E402
def _case_with(
*,
rules: list[EvalRuleConfig] | None = None,
expectations: Expectation | None = None,
rule_logic: RuleLogic = RuleLogic.ALL,
rule_pass_threshold: float = 0.6,
) -> Case:
return Case(
id="c1", type=CaseType.SINGLE, messages=["hi"],
eval_rules=rules or [],
expectations=expectations or Expectation(),
rule_logic=rule_logic,
rule_pass_threshold=rule_pass_threshold,
)
async def test_expectation_fails_case_even_when_rules_pass(db_session):
"""期望不满足 → 用例不通过,即使显式规则全部通过。"""
scenario = Scenario(id="s1", name="s", cases=[_case_with(
rules=[EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]})],
expectations=Expectation(keywords_include=["__NOPE__"]),
)])
channel = MockChannel()
engine = _build_engine(scenario, channel, session=db_session)
run = await engine.run()
assert run.status == RunStatus.COMPLETED
assert run.summary["failed_cases"] == 1
async def test_expectation_and_rules_both_pass(db_session):
"""期望与规则都满足 → 通过且期望派生判定同构落库、reason 可辨识来源。"""
scenario = Scenario(id="s1", name="s", cases=[_case_with(
rules=[EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]})],
expectations=Expectation(keywords_include=["echo"], response_time_max_ms=99999),
)])
channel = MockChannel()
engine = _build_engine(scenario, channel, session=db_session)
run = await engine.run()
assert run.summary["passed_cases"] == 1
results = RunRepository(db_session).get_results(run.id)
# 1 显式规则 + 2 期望派生keyword + response_time
assert len(results) == 3
implicit = [r for r in results if "期望" in r.reason]
assert len(implicit) == 2
assert all(r.passed for r in results)
async def test_implicit_expectation_not_in_any_combination(db_session):
"""rule_logic=ANY 只组合显式规则:期望通过不能救活全败的显式规则组。"""
scenario = Scenario(id="s1", name="s", cases=[_case_with(
rules=[EvalRuleConfig(type="keyword_match", params={"keywords": ["__NOPE__"]})],
expectations=Expectation(keywords_include=["echo"]), # 通过
rule_logic=RuleLogic.ANY,
)])
channel = MockChannel()
engine = _build_engine(scenario, channel, session=db_session)
run = await engine.run()
assert run.summary["failed_cases"] == 1
async def test_implicit_expectation_is_hard_constraint_over_weighted(db_session):
"""rule_logic=WEIGHTED 达标但期望不满足 → 仍不通过(期望是硬约束)。"""
scenario = Scenario(id="s1", name="s", cases=[_case_with(
rules=[EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}, weight=1.0)],
expectations=Expectation(keywords_include=["__NOPE__"]),
rule_logic=RuleLogic.WEIGHTED,
rule_pass_threshold=0.5, # 显式加权得分 1.0 ≥ 0.5
)])
channel = MockChannel()
engine = _build_engine(scenario, channel, session=db_session)
run = await engine.run()
assert run.summary["failed_cases"] == 1
async def test_pure_expectation_case_behavior_unchanged(db_session):
"""纯期望用例(无显式规则):满足通过、不满足失败,与升级前一致。"""
scenario = Scenario(id="s1", name="s", cases=[
Case(id="ok", type=CaseType.SINGLE, messages=["hi"],
expectations=Expectation(keywords_include=["echo"])),
Case(id="bad", type=CaseType.SINGLE, messages=["hi"],
expectations=Expectation(keywords_include=["__NOPE__"])),
])
channel = MockChannel()
engine = _build_engine(scenario, channel, session=db_session)
run = await engine.run()
assert run.summary["passed_cases"] == 1
assert run.summary["failed_cases"] == 1