""" Tests for services/quality/llm_judge.py Uses mocks for the OpenAI client — no actual API calls. """ import asyncio import json import pytest from unittest.mock import AsyncMock, MagicMock, patch from services.quality.llm_judge import ( LLMJudge, L1Result, L1ChunkVerdict, JUDGE_SYSTEM_PROMPT, make_judge_from_env, ) # ---------- Judge init ---------- class TestLLMJudgeInit: def test_requires_api_key(self): with pytest.raises(ValueError): LLMJudge(api_key="") def test_init_with_defaults(self): judge = LLMJudge(api_key="test-key") assert judge._api_key == "test-key" assert judge._model == "deepseek-chat" assert judge._base_url == "https://api.deepseek.com/v1" def test_init_with_custom_params(self): judge = LLMJudge( api_key="k", base_url="https://api.openai.com/v1/", model="gpt-4o-mini", timeout_seconds=15.0, ) assert judge._base_url == "https://api.openai.com/v1" # trailing slash stripped assert judge._model == "gpt-4o-mini" assert judge._timeout == 15.0 # ---------- Response parsing ---------- class TestParseResponse: def setup_method(self): self.judge = LLMJudge(api_key="test-key") def _make_response(self, content: str): response = MagicMock() response.choices = [MagicMock()] response.choices[0].message.content = content return response def test_parse_pure_json_array(self): content = json.dumps([ {"accurate": "yes", "fluent": "yes", "correct_language": "yes", "no_leaks": "yes", "reason": "good"} ]) verdicts = self.judge._parse_response(self._make_response(content), 1) assert len(verdicts) == 1 assert verdicts[0].passed is True assert verdicts[0].accurate is True assert verdicts[0].reason == "good" def test_parse_json_object_with_verdicts_key(self): content = json.dumps({"verdicts": [ {"accurate": "no", "fluent": "yes", "correct_language": "yes", "no_leaks": "yes", "reason": "lost meaning"} ]}) verdicts = self.judge._parse_response(self._make_response(content), 1) assert len(verdicts) == 1 assert verdicts[0].passed is False assert verdicts[0].accurate is False assert verdicts[0].fluent is True assert verdicts[0].reason == "lost meaning" def test_parse_json_with_markdown_fences(self): content = "```json\n" + json.dumps([ {"accurate": "yes", "fluent": "yes", "correct_language": "yes", "no_leaks": "yes", "reason": "ok"} ]) + "\n```" verdicts = self.judge._parse_response(self._make_response(content), 1) assert len(verdicts) == 1 assert verdicts[0].passed is True def test_parse_invalid_json_returns_empty(self): content = "not json at all" verdicts = self.judge._parse_response(self._make_response(content), 1) assert verdicts == [] def test_parse_partial_verdict_defaults_to_false(self): content = json.dumps([{"accurate": "yes"}]) # missing other fields verdicts = self.judge._parse_response(self._make_response(content), 1) assert len(verdicts) == 1 # All fields default to False when missing assert verdicts[0].passed is False assert verdicts[0].accurate is True assert verdicts[0].fluent is False assert verdicts[0].correct_language is False assert verdicts[0].no_leaks is False def test_parse_mixed_pass_fail(self): content = json.dumps([ {"accurate": "yes", "fluent": "yes", "correct_language": "yes", "no_leaks": "yes", "reason": "good"}, {"accurate": "no", "fluent": "yes", "correct_language": "yes", "no_leaks": "yes", "reason": "mistranslation"}, {"accurate": "yes", "fluent": "no", "correct_language": "yes", "no_leaks": "yes", "reason": "awkward"}, ]) verdicts = self.judge._parse_response(self._make_response(content), 3) assert len(verdicts) == 3 assert verdicts[0].passed is True assert verdicts[1].passed is False assert verdicts[2].passed is False # ---------- L1Result ---------- class TestL1Result: def test_passed_property(self): v = L1ChunkVerdict(accurate=True, fluent=True, correct_language=True, no_leaks=True) assert v.passed is True v_fail = L1ChunkVerdict(accurate=True, fluent=True, correct_language=True, no_leaks=False) assert v_fail.passed is False # ---------- Cost estimation ---------- class TestCostEstimation: def test_deepseek_estimate(self): judge = LLMJudge(api_key="k", model="deepseek-chat") cost = judge._estimate_cost(5) # 5 pairs: ~1450 input + 250 output tokens # Cost should be tiny (< $0.01) assert 0.0 < cost < 0.01 def test_gpt4o_mini_more_expensive(self): judge_ds = LLMJudge(api_key="k", model="deepseek-chat") judge_gpt = LLMJudge(api_key="k", model="gpt-4o-mini") assert judge_gpt._estimate_cost(5) > judge_ds._estimate_cost(5) def test_gemini_flash_cheaper(self): judge_gemini = LLMJudge(api_key="k", model="gemini-2.5-flash-lite") judge_gpt = LLMJudge(api_key="k", model="gpt-4o-mini") assert judge_gemini._estimate_cost(5) < judge_gpt._estimate_cost(5) # ---------- Judge batch (mocked API) ---------- class TestJudgeBatch: """Tests the full judge_batch flow with a mocked OpenAI client.""" def setup_method(self): self.judge = LLMJudge(api_key="test-key", timeout_seconds=5.0) def _mock_response_with_verdicts(self, verdicts_data): response = MagicMock() response.choices = [MagicMock()] response.choices[0].message.content = json.dumps(verdicts_data) return response def _run_judge_batch(self, verdicts_data): """Helper to run judge_batch with a mocked client.""" # Build the mock client BEFORE calling the method response = self._mock_response_with_verdicts(verdicts_data) mock_client = MagicMock() mock_client.chat = MagicMock() mock_client.chat.completions = MagicMock() mock_client.chat.completions.create = AsyncMock(return_value=response) self.judge._client = mock_client return asyncio.run(self.judge.judge_batch( [("Source 1", "Translation 1"), ("Source 2", "Translation 2")], target_lang="fr", target_lang_name="French", )) def test_all_pass_returns_pass(self): result = self._run_judge_batch([ {"accurate": "yes", "fluent": "yes", "correct_language": "yes", "no_leaks": "yes", "reason": "good"}, {"accurate": "yes", "fluent": "yes", "correct_language": "yes", "no_leaks": "yes", "reason": "good"}, ]) assert result.verdict == "pass" assert result.chunks_passed == 2 assert result.chunks_failed == 0 assert result.failure_rate == 0.0 def test_any_fail_returns_fail(self): result = self._run_judge_batch([ {"accurate": "yes", "fluent": "yes", "correct_language": "yes", "no_leaks": "yes", "reason": "good"}, {"accurate": "no", "fluent": "yes", "correct_language": "yes", "no_leaks": "yes", "reason": "mistranslation"}, ]) assert result.verdict == "fail" assert result.chunks_failed == 1 assert result.failure_rate == 0.5 def test_empty_pairs_returns_skip(self): result = asyncio.run(self.judge.judge_batch([], "fr", "French")) assert result.verdict == "skip" assert result.chunks_evaluated == 0 def test_api_error_returns_skip(self): # Set up a client that raises mock_client = MagicMock() mock_client.chat.completions.create = AsyncMock( side_effect=Exception("API down") ) self.judge._client = mock_client result = asyncio.run(self.judge.judge_batch( [("Source", "Translation")], "fr", "French" )) assert result.verdict == "skip" assert "API down" in result.error def test_timeout_returns_skip(self): import asyncio mock_client = MagicMock() async def slow_call(*args, **kwargs): await asyncio.sleep(20) # longer than timeout return MagicMock() mock_client.chat.completions.create = slow_call self.judge._client = mock_client self.judge._timeout = 0.1 # very short timeout result = asyncio.run(self.judge.judge_batch( [("Source", "Translation")], "fr", "French" )) assert result.verdict == "skip" assert "timeout" in result.error # ---------- make_judge_from_env ---------- class TestMakeJudgeFromEnv: def test_returns_none_when_no_api_key(self, monkeypatch): monkeypatch.delenv("L1_JUDGE_API_KEY", raising=False) assert make_judge_from_env() is None def test_returns_judge_when_key_set(self, monkeypatch): monkeypatch.setenv("L1_JUDGE_API_KEY", "test-key") monkeypatch.setenv("L1_JUDGE_MODEL", "gpt-4o-mini") monkeypatch.setenv("L1_JUDGE_BASE_URL", "https://api.openai.com/v1") judge = make_judge_from_env() assert judge is not None assert judge._model == "gpt-4o-mini" assert judge._base_url == "https://api.openai.com/v1" def test_default_model(self, monkeypatch): monkeypatch.setenv("L1_JUDGE_API_KEY", "k") monkeypatch.delenv("L1_JUDGE_MODEL", raising=False) judge = make_judge_from_env() assert judge._model == "deepseek-chat"