"""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.scored_llm.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.scored_llm.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.scored_llm.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.scored_llm.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.scored_llm.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.scored_llm.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.scored_llm.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.scored_llm.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"] async def test_llm_score_multiturn_uses_last_sent_not_prev_reply(): """Regression: for multi-turn dialogs the question must come from the LAST turn's sent_message (the user's question), NOT dialog[-2].reply (the previous AI reply). The old bug fed two AI replies as a Q&A pair, so the judge LLM scored everything 0.""" rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 5}) captured_payload = {} async def capture_post(url, **kwargs): captured_payload.update(kwargs.get("json", {})) return _make_llm_response(score=9.0) dialog = [ _turn("第一轮AI回复", sent_text="第一轮用户问题"), _turn("第二轮AI回复", sent_text="第二轮用户问题"), _turn("第三轮AI回复", sent_text="第三轮用户问题"), ] with patch("agenteval.evaluation.rules.scored_llm.httpx.AsyncClient") as MockClient: instance = MockClient.return_value.__aenter__.return_value instance.post = AsyncMock(side_effect=capture_post) await rule.evaluate(_case(), dialog) user_msg = next(m for m in captured_payload["messages"] if m["role"] == "user") # 问题必须是最后一轮用户发送的问题 assert "第三轮用户问题" in user_msg["content"] # 回复必须是最后一轮 AI 回复 assert "第三轮AI回复" in user_msg["content"] # 绝不能把上一轮 AI 回复当成"用户问题" assert "第二轮AI回复" not in user_msg["content"] async def test_llm_score_reason_includes_llm_detail(): """The rule's reason should surface the judge LLM's own reason for diagnosis.""" rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 5}) mock_resp = _make_llm_response(score=3.0, reason="回复偏离主题") with patch("agenteval.evaluation.rules.scored_llm.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 assert "回复偏离主题" in result.reason