Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
255 lines
9.9 KiB
Python
255 lines
9.9 KiB
Python
"""
|
|
Regression tests for judge-response robustness (实验 7-6 TTS 质量评估).
|
|
|
|
Covers two failure classes on LLM/Gemini judge responses:
|
|
- judge_rubric: judge returns "score": null (or a bare null dimension) -> int(None) TypeError
|
|
- judge_gemini_audio: safety-blocked Gemini responses have no
|
|
candidates/content/parts -> KeyError/IndexError instead of a clear error
|
|
|
|
Network is stubbed: the OpenAI-compatible judge client is replaced with a fake,
|
|
and urllib.request.urlopen is monkeypatched for the Gemini REST call.
|
|
"""
|
|
import io
|
|
import json
|
|
|
|
import pytest
|
|
|
|
import pipeline
|
|
|
|
|
|
class _FakeMessage:
|
|
content = "{}"
|
|
|
|
|
|
class _FakeChoice:
|
|
message = _FakeMessage()
|
|
|
|
|
|
class _FakeResp:
|
|
choices = [_FakeChoice()]
|
|
|
|
|
|
class _FakeCompletions:
|
|
@staticmethod
|
|
def create(**kwargs):
|
|
return _FakeResp()
|
|
|
|
|
|
class _FakeChat:
|
|
completions = _FakeCompletions()
|
|
|
|
|
|
class _FakeClient:
|
|
chat = _FakeChat()
|
|
|
|
|
|
def _stub_judge(monkeypatch, payload: dict):
|
|
_FakeMessage.content = json.dumps(payload, ensure_ascii=False)
|
|
monkeypatch.setattr(
|
|
pipeline, "get_judge_client_and_model", lambda model=None: (_FakeClient(), "fake-judge"))
|
|
|
|
|
|
def test_judge_rubric_tolerates_null_score(monkeypatch):
|
|
"""'score': null in a dimension dict is scored 0, not int(None) TypeError."""
|
|
_stub_judge(monkeypatch, {
|
|
"准确性": {"score": None, "reason": "无法判断"},
|
|
"自然度": {"score": 4, "reason": "语速正常"},
|
|
"情感表达": {"score": 0},
|
|
"音色一致性": {"score": 0, "reason": "无法听到音频"},
|
|
})
|
|
rub = pipeline.judge_rubric("原文文本", "中性", "回译文本", 3.0, 0.05)
|
|
assert rub.scores["准确性"] == 0
|
|
assert rub.scores["自然度"] == 4
|
|
assert rub.scores["音色一致性"] == 0
|
|
|
|
|
|
def test_judge_rubric_tolerates_null_dimension(monkeypatch):
|
|
"""A bare null dimension (non-dict) is scored 0, not int(None) TypeError."""
|
|
_stub_judge(monkeypatch, {
|
|
"准确性": None,
|
|
"自然度": 4,
|
|
"情感表达": 0,
|
|
"音色一致性": 0,
|
|
})
|
|
rub = pipeline.judge_rubric("原文文本", "中性", "回译文本", 3.0, 0.05)
|
|
assert rub.scores["准确性"] == 0
|
|
assert rub.scores["自然度"] == 4
|
|
|
|
|
|
class _FakeHTTPResp(io.BytesIO):
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
return False
|
|
|
|
|
|
def _stub_gemini(monkeypatch, payload: dict):
|
|
monkeypatch.setenv("GEMINI_API_KEY", "fake-key-for-test")
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
|
monkeypatch.setattr(pipeline, "_resolve_gemini_model", lambda key: "gemini-fake")
|
|
monkeypatch.setattr("urllib.request.urlopen",
|
|
lambda req, timeout=None: _FakeHTTPResp(json.dumps(payload).encode()))
|
|
|
|
|
|
@pytest.mark.parametrize("payload", [
|
|
{"promptFeedback": {"blockReason": "SAFETY"}}, # prompt 被拦截:无 candidates
|
|
{"candidates": []}, # 生成被拦截:空 candidates
|
|
{"candidates": [{"finishReason": "SAFETY", "index": 0}]}, # candidate 无 content
|
|
])
|
|
def test_judge_gemini_audio_blocked_raises_clear_error(monkeypatch, tmp_path, payload):
|
|
"""Blocked/empty Gemini responses raise a clear RuntimeError, not KeyError/IndexError."""
|
|
_stub_gemini(monkeypatch, payload)
|
|
audio = tmp_path / "a.mp3"
|
|
audio.write_bytes(b"\xff\xfb" + b"\x00" * 256)
|
|
reference = tmp_path / "reference.mp3"
|
|
reference.write_bytes(b"\xff\xfb" + b"\x01" * 256)
|
|
with pytest.raises(RuntimeError, match="Gemini 未返回评审文本"):
|
|
pipeline.judge_gemini_audio("原文", "中性", str(audio), str(reference))
|
|
|
|
|
|
def test_judge_gemini_audio_parses_valid_response(monkeypatch, tmp_path):
|
|
"""A normal Gemini response still parses (defensive navigation keeps working)."""
|
|
inner = json.dumps({
|
|
"准确性": {"score": 4, "reason": "ok"},
|
|
"自然度": 4,
|
|
"情感表达": None,
|
|
"音色一致性": {"score": 5},
|
|
}, ensure_ascii=False)
|
|
_stub_gemini(monkeypatch, {
|
|
"candidates": [{"content": {"parts": [{"text": inner}]}}],
|
|
})
|
|
audio = tmp_path / "a.mp3"
|
|
audio.write_bytes(b"\xff\xfb" + b"\x00" * 256)
|
|
reference = tmp_path / "reference.mp3"
|
|
reference.write_bytes(b"\xff\xfb" + b"\x01" * 256)
|
|
rub = pipeline.judge_gemini_audio("原文", "中性", str(audio), str(reference))
|
|
assert rub.scores["准确性"] == 4
|
|
assert rub.scores["情感表达"] == 0 # null score -> 0
|
|
assert rub.scores["音色一致性"] == 5
|
|
|
|
|
|
def test_judge_gemini_audio_falls_back_to_openrouter(monkeypatch, tmp_path):
|
|
"""An unavailable direct key keeps both clips on a direct-audio fallback route."""
|
|
monkeypatch.setenv("GEMINI_API_KEY", "invalid-direct-key")
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "fake-openrouter-key")
|
|
monkeypatch.setattr(pipeline, "_resolve_gemini_model", lambda key: "gemini-fake")
|
|
|
|
def _http_error(req, timeout=None):
|
|
import urllib.error
|
|
raise urllib.error.HTTPError(req.full_url, 400, "bad key", {}, io.BytesIO(b"invalid"))
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", _http_error)
|
|
expected = pipeline.RubricResult(
|
|
scores={dim: 4 for dim in pipeline.RUBRIC_DIMENSIONS},
|
|
reasons={dim: "audible evidence" for dim in pipeline.RUBRIC_DIMENSIONS},
|
|
judge_model="openrouter/google/gemini-3.5-flash",
|
|
evidence_mode="direct-audio-with-reference",
|
|
provider_attempts=[],
|
|
)
|
|
monkeypatch.setattr(pipeline, "_judge_openrouter_audio", lambda *args, **kwargs: expected)
|
|
audio = tmp_path / "a.mp3"
|
|
audio.write_bytes(b"\xff\xfb" + b"\x00" * 256)
|
|
reference = tmp_path / "reference.mp3"
|
|
reference.write_bytes(b"\xff\xfb" + b"\x01" * 256)
|
|
|
|
rub = pipeline.judge_gemini_audio("原文", "中性", str(audio), str(reference))
|
|
assert rub is expected
|
|
assert rub.evidence_mode == "direct-audio-with-reference"
|
|
|
|
|
|
def test_openrouter_failure_preserves_both_route_attempts(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("GEMINI_API_KEY", "invalid-direct-key")
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "invalid-openrouter-key")
|
|
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
|
monkeypatch.setattr(pipeline, "_resolve_gemini_model", lambda key: "gemini-fake")
|
|
|
|
def _http_error(req, timeout=None):
|
|
import urllib.error
|
|
body = b"direct invalid" if "googleapis" in req.full_url else b"router invalid"
|
|
code = 400 if "googleapis" in req.full_url else 401
|
|
raise urllib.error.HTTPError(req.full_url, code, "unavailable", {}, io.BytesIO(body))
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", _http_error)
|
|
audio = tmp_path / "a.mp3"
|
|
audio.write_bytes(b"\xff\xfb" + b"\x00" * 256)
|
|
reference = tmp_path / "reference.mp3"
|
|
reference.write_bytes(b"\xff\xfb" + b"\x01" * 256)
|
|
|
|
with pytest.raises(pipeline.JudgeRouteError) as caught:
|
|
pipeline.judge_gemini_audio("原文", "中性", str(audio), str(reference))
|
|
assert [attempt["status"] for attempt in caught.value.provider_attempts] == [
|
|
"unavailable", "unavailable"
|
|
]
|
|
assert [attempt["provider"] for attempt in caught.value.provider_attempts] == [
|
|
"Google Gemini API", "OpenRouter audio route"
|
|
]
|
|
|
|
|
|
def test_openrouter_failure_falls_back_to_exact_mistral_two_audio_route(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("GEMINI_API_KEY", "invalid-direct-key")
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "invalid-openrouter-key")
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "fake-mistral-key")
|
|
monkeypatch.setattr(pipeline, "_resolve_gemini_model", lambda key: "gemini-fake")
|
|
monkeypatch.setattr(pipeline.time, "sleep", lambda seconds: None)
|
|
observed_mistral_body = {}
|
|
mistral_calls = 0
|
|
inner = json.dumps({
|
|
dim: {"score": 4, "reason": "audible evidence"}
|
|
for dim in pipeline.RUBRIC_DIMENSIONS
|
|
}, ensure_ascii=False)
|
|
|
|
def _route(req, timeout=None):
|
|
nonlocal mistral_calls
|
|
import urllib.error
|
|
if "googleapis" in req.full_url:
|
|
raise urllib.error.HTTPError(
|
|
req.full_url, 400, "bad key", {}, io.BytesIO(b"direct invalid")
|
|
)
|
|
if "openrouter" in req.full_url:
|
|
raise urllib.error.HTTPError(
|
|
req.full_url, 401, "bad key", {}, io.BytesIO(b"router invalid")
|
|
)
|
|
mistral_calls += 1
|
|
if mistral_calls == 1:
|
|
raise urllib.error.HTTPError(
|
|
req.full_url, 500, "transient", {}, io.BytesIO(b"service unavailable")
|
|
)
|
|
observed_mistral_body.update(json.loads(req.data))
|
|
return _FakeHTTPResp(json.dumps({
|
|
"choices": [{"message": {"content": inner}}]
|
|
}).encode())
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", _route)
|
|
audio_bytes = b"\xff\xfb" + b"\x00" * 256
|
|
reference_bytes = b"\xff\xfb" + b"\x01" * 256
|
|
audio = tmp_path / "a.mp3"
|
|
audio.write_bytes(audio_bytes)
|
|
reference = tmp_path / "reference.mp3"
|
|
reference.write_bytes(reference_bytes)
|
|
|
|
rub = pipeline.judge_gemini_audio("原文", "中性", str(audio), str(reference))
|
|
|
|
content = observed_mistral_body["messages"][0]["content"]
|
|
assert [item["type"] for item in content] == [
|
|
"text", "text", "input_audio", "text", "input_audio"
|
|
]
|
|
audio_chunks = [item for item in content if item["type"] == "input_audio"]
|
|
assert len(audio_chunks) == 2
|
|
assert audio_chunks[0]["input_audio"].startswith("data:audio/mpeg;base64,")
|
|
assert audio_chunks[1]["input_audio"].startswith("data:audio/mpeg;base64,")
|
|
assert rub.judge_model == "mistral/voxtral-small-latest"
|
|
assert rub.evidence_mode == "direct-audio-with-reference"
|
|
assert [attempt["provider"] for attempt in rub.provider_attempts] == [
|
|
"Google Gemini API", "OpenRouter audio route", "Mistral Voxtral API"
|
|
]
|
|
assert [attempt["status"] for attempt in rub.provider_attempts] == [
|
|
"unavailable", "unavailable", "ok"
|
|
]
|
|
assert rub.provider_attempts[-1]["attempts"] == 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|