7ddf94220d
Interpreter now returns a real per-detection confidence (source stays "auto"), so curator can reject the mis-detections it was blindly storing — e.g. a short English title mis-labelled as German and rewritten into the archive. The gate consumes ONLY Interpreter's own reported detection — curator does no language detection of its own (Scribe rule 133): a field is stored when the engine actually translated it AND either the detected language is CJK (script-detected, reliably high — ja/ko/zh trusted outright, incl. pure-kanji Japanese that lands as zh ~0.75) or the reported confidence clears a latin-script floor (_MIN_LATIN_CONFIDENCE = 0.90). A latin detection below the floor keeps the original and marks the post handled; a missing confidence fails open. The client already sent source="auto" and parsed confidence, so this is purely the gate + tests. Tests: pinned interpreter-client test now asserts source stays "auto"; new pure-unit gate tests (CJK trusted / latin floor / case-insensitive / fail-open) in the fast lane; end-to-end reject-low-latin, accept-high-latin, accept-low-cjk sweeps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgZP9v2otxVJymiYsnVuMy
146 lines
5.3 KiB
Python
146 lines
5.3 KiB
Python
"""Interpreter translation client (#143) — unit tests with mocked HTTP (no DB,
|
|
no real network)."""
|
|
import pytest
|
|
|
|
from backend.app.services import interpreter_client as ic
|
|
|
|
|
|
class _Resp:
|
|
def __init__(self, status_code=200, payload=None, headers=None):
|
|
self.status_code = status_code
|
|
self._payload = payload or {}
|
|
self.headers = headers or {}
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
def raise_for_status(self):
|
|
if self.status_code >= 400:
|
|
raise ic.requests.HTTPError(f"status {self.status_code}")
|
|
|
|
|
|
def test_translate_maps_batch_in_order(monkeypatch):
|
|
captured = {}
|
|
|
|
def fake_post(url, json, timeout):
|
|
captured["json"] = json
|
|
return _Resp(200, {
|
|
"translatedText": ["The cat is cute", "Blonde gal!"],
|
|
"detectedLanguage": {"language": "ja", "confidence": 0.98},
|
|
"interpreter": {"engine": "llm", "engine_version": "ollama:x:12b"},
|
|
})
|
|
|
|
monkeypatch.setattr(ic.session, "post", fake_post)
|
|
out = ic.translate(["ねこが可愛い", "金髪ギャル!"], base_url="http://i.lan")
|
|
assert out["translations"] == ["The cat is cute", "Blonde gal!"]
|
|
assert out["detected_lang"] == "ja"
|
|
assert out["detected_confidence"] == 0.98 # surfaced for the detection guard
|
|
assert out["engine_version"] == "ollama:x:12b"
|
|
assert captured["json"]["q"] == ["ねこが可愛い", "金髪ギャル!"]
|
|
assert captured["json"]["target"] == "en"
|
|
# Source MUST stay "auto": an explicit source makes Interpreter skip detection
|
|
# and report confidence 1.0, which would defeat the acceptance gate.
|
|
assert captured["json"]["source"] == "auto"
|
|
|
|
|
|
def test_translate_passthrough_unchanged(monkeypatch):
|
|
# Already-English items come back unchanged in their slot (engine "none").
|
|
monkeypatch.setattr(ic.session, "post", lambda *a, **k: _Resp(200, {
|
|
"translatedText": ["already english"],
|
|
"detectedLanguage": {"language": "en"},
|
|
"interpreter": {"engine": "none", "engine_version": None},
|
|
}))
|
|
out = ic.translate(["already english"], base_url="http://i.lan")
|
|
assert out["translations"] == ["already english"]
|
|
assert out["detected_lang"] == "en"
|
|
assert out["engine"] == "none"
|
|
|
|
|
|
def test_translate_503_raises_unavailable(monkeypatch):
|
|
monkeypatch.setattr(ic.session, "post", lambda *a, **k: _Resp(503, {}))
|
|
with pytest.raises(ic.InterpreterUnavailable):
|
|
ic.translate(["x"], base_url="http://i.lan")
|
|
|
|
|
|
def test_translate_connection_error_raises_unavailable(monkeypatch):
|
|
def boom(*a, **k):
|
|
raise ic.requests.ConnectionError("refused")
|
|
|
|
monkeypatch.setattr(ic.session, "post", boom)
|
|
with pytest.raises(ic.InterpreterUnavailable):
|
|
ic.translate(["x"], base_url="http://i.lan")
|
|
|
|
|
|
@pytest.mark.parametrize("code", [429, 500, 502, 503, 504])
|
|
def test_translate_429_and_5xx_raise_unavailable(monkeypatch, code):
|
|
# A gracefully-draining service behind a reverse proxy returns 429/502/503/504
|
|
# — all mean "retry later", not an opaque error.
|
|
monkeypatch.setattr(ic.session, "post", lambda *a, **k: _Resp(code, {}))
|
|
with pytest.raises(ic.InterpreterUnavailable) as ei:
|
|
ic.translate(["x"], base_url="http://i.lan")
|
|
assert ei.value.retry_after is None
|
|
|
|
|
|
def test_translate_honours_retry_after_seconds(monkeypatch):
|
|
monkeypatch.setattr(
|
|
ic.session, "post",
|
|
lambda *a, **k: _Resp(503, {}, headers={"Retry-After": "42"}),
|
|
)
|
|
with pytest.raises(ic.InterpreterUnavailable) as ei:
|
|
ic.translate(["x"], base_url="http://i.lan")
|
|
assert ei.value.retry_after == 42
|
|
|
|
|
|
def test_translate_retry_after_past_http_date_clamps_to_zero(monkeypatch):
|
|
# HTTP-date form; a past date → non-negative clamp to 0 (deterministic).
|
|
monkeypatch.setattr(
|
|
ic.session, "post",
|
|
lambda *a, **k: _Resp(
|
|
503, {}, headers={"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"}),
|
|
)
|
|
with pytest.raises(ic.InterpreterUnavailable) as ei:
|
|
ic.translate(["x"], base_url="http://i.lan")
|
|
assert ei.value.retry_after == 0.0
|
|
|
|
|
|
def test_translate_400_raises_bad_request(monkeypatch):
|
|
monkeypatch.setattr(
|
|
ic.session, "post", lambda *a, **k: _Resp(400, {"error": "bad target"})
|
|
)
|
|
with pytest.raises(ic.InterpreterBadRequest):
|
|
ic.translate(["x"], base_url="http://i.lan")
|
|
|
|
|
|
def test_translate_empty_is_noop(monkeypatch):
|
|
def boom(*a, **k):
|
|
raise AssertionError("should not call the service for an empty batch")
|
|
|
|
monkeypatch.setattr(ic.session, "post", boom)
|
|
assert ic.translate([], base_url="http://i.lan")["translations"] == []
|
|
|
|
|
|
def test_health_true_when_llm_up(monkeypatch):
|
|
monkeypatch.setattr(
|
|
ic.session, "get", lambda *a, **k: _Resp(200, {"engines": {"llm": True}})
|
|
)
|
|
assert ic.health("http://i.lan") is True
|
|
|
|
|
|
def test_health_false_when_engine_down(monkeypatch):
|
|
monkeypatch.setattr(
|
|
ic.session, "get", lambda *a, **k: _Resp(200, {"engines": {"llm": False}})
|
|
)
|
|
assert ic.health("http://i.lan") is False
|
|
|
|
|
|
def test_health_false_on_error(monkeypatch):
|
|
def boom(*a, **k):
|
|
raise ic.requests.ConnectionError("refused")
|
|
|
|
monkeypatch.setattr(ic.session, "get", boom)
|
|
assert ic.health("http://i.lan") is False
|
|
|
|
|
|
def test_health_false_when_url_empty():
|
|
assert ic.health("") is False
|