AgentEvalTool/tests/unit/test_http_channel_and_rules.py
sinohqb 12481cd1b8 v0.3-s1: 规则层异步化 + 工具函数去重 + HTTP 通道
## 核心变更

### 规则层全面异步化(DEBT-1)
- EvalRule.evaluate() 签名改为 async def,全量同步改造(无兼容层)
- LlmScoreRule._call_llm: requests.post → httpx.AsyncClient,彻底消除事件循环阻塞
- engine._save_rule_results: rule.evaluate() → await rule.evaluate()

### 工具函数去重(DEBT-2)
- 新建 agenteval/utils/llm.py,统一三个函数:
  - extract_reply_text (原 5 处重复)
  - extract_content_from_llm_response (原 2 处重复)
  - parse_json_from_llm_text (统一 LLM 输出 JSON 解析)
- engine.py / llm_score.py / runs.py / report.py 全部切换到 utils.llm

### HTTP 通用通道(S1-3)
- 新建 channels/http.py (HttpChannel)
  - 配置化 send_url / reply_url 模板 ({message}, {msg_id} 占位)
  - dot-path 提取 msg_id 和 reply_text
  - 可选 reply_ready_path 就绪标志
  - 长连接 AsyncClient 复用
- ChannelFactory 注册 ChannelType.HTTP → HttpChannel

### 测试
- 新增 tests/unit/test_http_channel_and_rules.py (19 个测试)
- _get_path / health_check / send / poll_reply / 超时 / 就绪标志 / async 规则评估
- 测试总数:24 → 43,全部通过

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 10:52:32 +08:00

214 lines
7.1 KiB
Python

"""Unit tests for HttpChannel and async rule evaluation."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agenteval.channels.http import HttpChannel, _get_path
from agenteval.channels.base import SendResult
from agenteval.evaluation.rules.keyword import KeywordMatchRule
from agenteval.evaluation.rules.response_time import ResponseTimeRule
from agenteval.models import Case, CaseType, Expectation, Turn
# ── _get_path helper ─────────────────────────────────────────────────────
def test_get_path_simple():
assert _get_path({"id": "abc"}, "id") == "abc"
def test_get_path_nested():
assert _get_path({"a": {"b": {"c": 42}}}, "a.b.c") == 42
def test_get_path_missing():
assert _get_path({"a": 1}, "a.b") is None
def test_get_path_none_data():
assert _get_path(None, "x") is None
def test_get_path_list_index():
assert _get_path({"items": ["x", "y"]}, "items.1") == "y"
# ── HttpChannel ──────────────────────────────────────────────────────────
def _make_channel(**extra) -> HttpChannel:
config = {
"send_url": "http://mock/send",
"reply_url": "http://mock/reply/{msg_id}",
"health_url": "http://mock/health",
**extra,
}
return HttpChannel(config)
async def test_http_health_check_ok():
ch = _make_channel()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
with patch.object(ch._client, "get", new=AsyncMock(return_value=mock_resp)):
result = await ch.health_check()
assert result.ok is True
assert "200" in result.message
async def test_http_health_check_fail():
ch = _make_channel()
with patch.object(ch._client, "get", new=AsyncMock(side_effect=Exception("conn refused"))):
result = await ch.health_check()
assert result.ok is False
assert "conn refused" in result.message
async def test_http_send_extracts_msg_id():
ch = _make_channel(msg_id_path="data.id")
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value={"data": {"id": "msg-42"}})
with patch.object(ch._client, "post", new=AsyncMock(return_value=mock_resp)):
result = await ch.send("hello")
assert result.ok is True
assert result.question_msg_id == "msg-42"
async def test_http_send_failure():
ch = _make_channel()
with patch.object(ch._client, "post", new=AsyncMock(side_effect=Exception("timeout"))):
result = await ch.send("hi")
assert result.ok is False
assert "timeout" in result.error
async def test_http_poll_reply_found():
ch = _make_channel(reply_path="answer")
call_count = {"n": 0}
async def mock_get(url, **kwargs):
call_count["n"] += 1
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value={"answer": "Hello world"})
return mock_resp
with patch.object(ch._client, "get", new=mock_get):
reply = await ch.poll_reply("msg-1", timeout=5.0)
assert reply is not None
assert reply.content == "Hello world"
assert reply.question_msg_id == "msg-1"
async def test_http_poll_reply_timeout():
ch = _make_channel(reply_path="missing_field", poll_interval=0.05)
async def mock_get(url, **kwargs):
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value={}) # field not present
return mock_resp
with patch.object(ch._client, "get", new=mock_get):
reply = await ch.poll_reply("msg-1", timeout=0.15, poll_interval=0.05)
assert reply is None
async def test_http_poll_reply_readiness_flag():
ch = _make_channel(reply_path="text", reply_ready_path="ready")
responses = [
{"ready": False, "text": "not ready"},
{"ready": True, "text": "final answer"},
]
call_idx = {"n": 0}
async def mock_get(url, **kwargs):
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value=responses[min(call_idx["n"], 1)])
call_idx["n"] += 1
return mock_resp
with patch.object(ch._client, "get", new=mock_get):
reply = await ch.poll_reply("msg-1", timeout=5.0, poll_interval=0.05)
assert reply is not None
assert reply.content == "final answer"
# ── Async rule evaluation ────────────────────────────────────────────────
def _make_turn(reply_text: str, latency_ms: int = 100) -> Turn:
return Turn(
id="t1", run_id="r1", case_id="c1", round_index=1,
reply={"msgBody": {"content": reply_text}},
latency_ms=latency_ms,
)
def _make_case(*, keywords: list[str] | None = None, max_ms: int | None = None) -> Case:
return Case(
id="c1", type=CaseType.SINGLE, messages=["hi"],
expectations=Expectation(
keywords_include=keywords or [],
response_time_max_ms=max_ms,
),
)
async def test_keyword_rule_is_async_and_passes():
rule = KeywordMatchRule({"keywords": ["hello", "world"]})
turn = _make_turn("hello world here")
result = await rule.evaluate(_make_case(), [turn])
assert result.passed is True
assert result.score == 1.0
async def test_keyword_rule_fails_missing_keyword():
rule = KeywordMatchRule({"keywords": ["missing"]})
turn = _make_turn("some other text")
result = await rule.evaluate(_make_case(), [turn])
assert result.passed is False
assert "missing" in result.reason
async def test_keyword_rule_fails_excluded_keyword():
rule = KeywordMatchRule({"exclude_keywords": ["banned"]})
turn = _make_turn("this is banned content")
result = await rule.evaluate(_make_case(), [turn])
assert result.passed is False
async def test_response_time_rule_is_async_and_passes():
rule = ResponseTimeRule({"max_ms": 500})
turn = _make_turn("ok", latency_ms=200)
result = await rule.evaluate(_make_case(), [turn])
assert result.passed is True
async def test_response_time_rule_fails_over_threshold():
rule = ResponseTimeRule({"max_ms": 100})
turn = _make_turn("slow response", latency_ms=5000)
result = await rule.evaluate(_make_case(), [turn])
assert result.passed is False
assert "5000ms" in result.reason
async def test_response_time_uses_expectation_fallback():
rule = ResponseTimeRule({}) # no max_ms in params
case = _make_case(max_ms=200)
turn = _make_turn("ok", latency_ms=100)
result = await rule.evaluate(case, [turn])
assert result.passed is True
async def test_rules_empty_dialog_fail():
keyword_rule = KeywordMatchRule({"keywords": ["x"]})
rt_rule = ResponseTimeRule({"max_ms": 1000})
case = _make_case()
for rule in [keyword_rule, rt_rule]:
result = await rule.evaluate(case, [])
assert result.passed is False
assert "无回复" in result.reason