## T1: P0 测试补全(+67 个测试)
- test_utils_llm.py: extract_reply_text / extract_content_from_llm_response / parse_json_from_llm_text 各边界
- test_file_repository.py: 分类 CRUD / 树形结构 / 级联删除 / 文件创建/查询/删除/物理文件清理
- test_report.py: generate_report / generate_compare_report / render_markdown / render_json
- test_llm_score.py: OpenAI 格式 / Anthropic content-block 格式 / JSON 回退解析 / 异常降级
## T2: P1 测试补全(+28 个测试)
- test_scenarios.py: 模板列表/字段完整性/规则类型有效性 + YAML/JSON 加载/校验
- test_webhook.py: 未配置不发送 / 正确 payload / secret header / 异常静默忽略
- test_reports_api.py: GET /reports/{id} / /html / /json / /markdown / /compare 集成测试
## UTC 时区根本修复
- storage/db.py: 新增 iso_utc() 函数,确保所有 datetime 序列化输出带 Z 后缀
- runs.py / files.py / report.py: 6 处 .isoformat() → iso_utc()
- 前端 toDate() 兜底仍保留(向下兼容),但后端不再输出无时区时间戳
Co-Authored-By: Claude <noreply@anthropic.com>
192 lines
7.9 KiB
Python
192 lines
7.9 KiB
Python
"""Unit tests for LlmScoreRule — mocking httpx to avoid real API calls."""
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from agenteval.evaluation.rules.llm_score import LlmScoreRule
|
|
from agenteval.models import Case, CaseType, Turn
|
|
|
|
|
|
def _turn(reply_text: str, sent_text: str = "问题", latency_ms: int = 500) -> Turn:
|
|
return Turn(
|
|
id="t1", run_id="r1", case_id="c1", round_index=1,
|
|
sent_message={"msgBody": {"content": sent_text}},
|
|
reply={"msgBody": {"content": reply_text}},
|
|
latency_ms=latency_ms,
|
|
)
|
|
|
|
|
|
def _case() -> Case:
|
|
return Case(id="c1", type=CaseType.SINGLE, messages=["hi"])
|
|
|
|
|
|
def _make_llm_response(score: float, reason: str = "ok") -> MagicMock:
|
|
mock_resp = MagicMock()
|
|
mock_resp.raise_for_status = MagicMock()
|
|
mock_resp.json = MagicMock(return_value={
|
|
"choices": [{"message": {"content": json.dumps({"score": score, "reason": reason})}}]
|
|
})
|
|
return mock_resp
|
|
|
|
|
|
def _make_content_block_response(score: float, reason: str = "ok") -> MagicMock:
|
|
"""Simulate Anthropic content-block-array format."""
|
|
mock_resp = MagicMock()
|
|
mock_resp.raise_for_status = MagicMock()
|
|
mock_resp.json = MagicMock(return_value={
|
|
"choices": [{
|
|
"message": {
|
|
"content": [
|
|
{"type": "text", "text": json.dumps({"score": score, "reason": reason})}
|
|
]
|
|
}
|
|
}]
|
|
})
|
|
return mock_resp
|
|
|
|
|
|
# ── basic evaluate ────────────────────────────────────────────────────────
|
|
|
|
async def test_llm_score_no_api_url_fails():
|
|
rule = LlmScoreRule({"criteria": "礼貌"})
|
|
result = await rule.evaluate(_case(), [_turn("回答内容")])
|
|
assert result.passed is False
|
|
assert "api_url" in result.reason
|
|
|
|
|
|
async def test_llm_score_empty_dialog_fails():
|
|
rule = LlmScoreRule({"api_url": "http://mock", "min_score": 7})
|
|
result = await rule.evaluate(_case(), [])
|
|
assert result.passed is False
|
|
assert "无回复" in result.reason
|
|
|
|
|
|
# ── OpenAI format ─────────────────────────────────────────────────────────
|
|
|
|
async def test_llm_score_passes_above_threshold():
|
|
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 6})
|
|
mock_resp = _make_llm_response(score=8.0, reason="很好")
|
|
|
|
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
|
|
instance = MockClient.return_value.__aenter__.return_value
|
|
instance.post = AsyncMock(return_value=mock_resp)
|
|
result = await rule.evaluate(_case(), [_turn("优质回答")])
|
|
|
|
assert result.passed is True
|
|
assert result.score == pytest.approx(0.8)
|
|
assert "8" in result.reason
|
|
|
|
|
|
async def test_llm_score_fails_below_threshold():
|
|
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 7})
|
|
mock_resp = _make_llm_response(score=4.0, reason="较差")
|
|
|
|
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
|
|
instance = MockClient.return_value.__aenter__.return_value
|
|
instance.post = AsyncMock(return_value=mock_resp)
|
|
result = await rule.evaluate(_case(), [_turn("差劲回答")])
|
|
|
|
assert result.passed is False
|
|
assert result.score == pytest.approx(0.4)
|
|
|
|
|
|
async def test_llm_score_clamps_score_to_0_10():
|
|
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 5})
|
|
# API returns out-of-range score
|
|
mock_resp = _make_llm_response(score=12.0)
|
|
|
|
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
|
|
instance = MockClient.return_value.__aenter__.return_value
|
|
instance.post = AsyncMock(return_value=mock_resp)
|
|
result = await rule.evaluate(_case(), [_turn("answer")])
|
|
|
|
assert result.score == pytest.approx(1.0) # clamped 10/10 = 1.0
|
|
|
|
|
|
# ── Anthropic content-block format ────────────────────────────────────────
|
|
|
|
async def test_llm_score_handles_content_block_array():
|
|
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 6})
|
|
mock_resp = _make_content_block_response(score=7.5)
|
|
|
|
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
|
|
instance = MockClient.return_value.__aenter__.return_value
|
|
instance.post = AsyncMock(return_value=mock_resp)
|
|
result = await rule.evaluate(_case(), [_turn("answer")])
|
|
|
|
assert result.passed is True
|
|
assert result.score == pytest.approx(0.75)
|
|
|
|
|
|
# ── JSON fallback parsing ─────────────────────────────────────────────────
|
|
|
|
async def test_llm_score_parses_json_with_preamble():
|
|
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 6})
|
|
mock_resp = MagicMock()
|
|
mock_resp.raise_for_status = MagicMock()
|
|
mock_resp.json = MagicMock(return_value={
|
|
"choices": [{"message": {"content": 'Sure! Here is the result: {"score": 7, "reason": "decent"}'}}]
|
|
})
|
|
|
|
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
|
|
instance = MockClient.return_value.__aenter__.return_value
|
|
instance.post = AsyncMock(return_value=mock_resp)
|
|
result = await rule.evaluate(_case(), [_turn("answer")])
|
|
|
|
assert result.passed is True
|
|
|
|
|
|
# ── error handling ────────────────────────────────────────────────────────
|
|
|
|
async def test_llm_score_api_error_fails_gracefully():
|
|
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 6})
|
|
|
|
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
|
|
instance = MockClient.return_value.__aenter__.return_value
|
|
instance.post = AsyncMock(side_effect=Exception("connection timeout"))
|
|
result = await rule.evaluate(_case(), [_turn("answer")])
|
|
|
|
assert result.passed is False
|
|
assert "timeout" in result.reason.lower() or "LLM" in result.reason
|
|
|
|
|
|
async def test_llm_score_empty_content_fails():
|
|
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 6})
|
|
mock_resp = MagicMock()
|
|
mock_resp.raise_for_status = MagicMock()
|
|
mock_resp.json = MagicMock(return_value={"choices": []})
|
|
|
|
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
|
|
instance = MockClient.return_value.__aenter__.return_value
|
|
instance.post = AsyncMock(return_value=mock_resp)
|
|
result = await rule.evaluate(_case(), [_turn("answer")])
|
|
|
|
assert result.passed is False
|
|
|
|
|
|
# ── question extraction ───────────────────────────────────────────────────
|
|
|
|
async def test_llm_score_extracts_question_from_sent_message():
|
|
"""Verifies that sent_message is used as the question when dialog has 1 turn."""
|
|
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 5})
|
|
captured_payload = {}
|
|
|
|
mock_resp = _make_llm_response(score=8.0)
|
|
|
|
async def capture_post(url, **kwargs):
|
|
captured_payload.update(kwargs.get("json", {}))
|
|
return mock_resp
|
|
|
|
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
|
|
instance = MockClient.return_value.__aenter__.return_value
|
|
instance.post = AsyncMock(side_effect=capture_post)
|
|
await rule.evaluate(_case(), [_turn("答案内容", sent_text="这是用户的问题")])
|
|
|
|
# The user_prompt should contain the sent question text
|
|
messages = captured_payload.get("messages", [])
|
|
user_msg = next((m for m in messages if m["role"] == "user"), None)
|
|
assert user_msg is not None
|
|
assert "这是用户的问题" in user_msg["content"]
|