From 7f8073c4c8636aff592a61a5771a697159d312ce Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 7 Jul 2026 12:23:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(translation):=20Interpreter=20client=20?= =?UTF-8?q?=E2=80=94=20batch=20translate=20+=20health=20(#143=20step=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit services/interpreter_client.py: sync (requests) client for the LibreTranslate- compatible /v1/translate — no new dep, mirrors the platform clients. translate() maps translatedText[]↔texts (order + length), returns detected_lang + engine_version (aggregate = first item, fine for a per-post [title, description] batch); passthrough items come back unchanged in their slot. InterpreterUnavailable on 503 / connection error (retry later), InterpreterBadRequest on 400. health() checks /v1/health engines.llm. 10 unit tests with mocked HTTP. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- backend/app/services/interpreter_client.py | 88 +++++++++++++++++ tests/test_interpreter_client.py | 108 +++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 backend/app/services/interpreter_client.py create mode 100644 tests/test_interpreter_client.py diff --git a/backend/app/services/interpreter_client.py b/backend/app/services/interpreter_client.py new file mode 100644 index 0000000..0e02029 --- /dev/null +++ b/backend/app/services/interpreter_client.py @@ -0,0 +1,88 @@ +"""Interpreter translation client (milestone 143) — a thin SYNC wrapper over the +self-hosted Interpreter LAN service (LibreTranslate-compatible `/v1/translate`). + +Sync (requests) because the only caller is the sync celery translate sweep, and +it mirrors FC's other platform clients. The service knows nothing about Curator — +all Curator logic stays here. base_url is operator-configured (empty until set; +behind a reverse proxy). Full API contract: Scribe note #1347. +""" +from __future__ import annotations + +import requests + + +class InterpreterUnavailable(Exception): + """The translation engine is down / unreachable (HTTP 503 or a connection + error) — retry later, don't drop the item.""" + + +class InterpreterBadRequest(Exception): + """Bad request params (HTTP 400).""" + + +def _url(base_url: str, path: str) -> str: + return f"{base_url.rstrip('/')}{path}" + + +def health(base_url: str, *, timeout: float = 5.0) -> bool: + """True iff the Interpreter LLM engine is up. Any error (unset URL, network, + non-200, engine down) → False, so the sweep just no-ops rather than raising.""" + if not base_url: + return False + try: + r = requests.get(_url(base_url, "/v1/health"), timeout=timeout) + except requests.RequestException: + return False + if r.status_code != 200: + return False + engines = (r.json() or {}).get("engines") or {} + return bool(engines.get("llm")) + + +def translate( + texts: list[str], *, base_url: str, target: str = "en", + source: str = "auto", timeout: float = 120.0, +) -> dict: + """Translate a batch. Returns:: + + {"translations": [str, ...], # SAME order & length as `texts` + "detected_lang": str | None, # aggregate: describes the FIRST item + "engine": str | None, + "engine_version": str | None} + + Interpreter batch metadata is aggregate (first item only) — fine for one + post's ``[title, description]`` batch since they share a language. Passthrough + items (already target-language / emoji-only) come back UNCHANGED in their + slot. Raises InterpreterUnavailable on 503 / connection error (retry later), + InterpreterBadRequest on 400. (Scribe note #1347.) + """ + if not texts: + return {"translations": [], "detected_lang": None, + "engine": None, "engine_version": None} + try: + r = requests.post( + _url(base_url, "/v1/translate"), + json={"q": list(texts), "source": source, + "target": target, "engine": "auto"}, + timeout=timeout, + ) + except requests.RequestException as e: + raise InterpreterUnavailable(str(e)) from e + if r.status_code == 503: + raise InterpreterUnavailable("interpreter engine unavailable") + if r.status_code == 400: + raise InterpreterBadRequest((r.json() or {}).get("error", "bad request")) + r.raise_for_status() + body = r.json() or {} + out = body.get("translatedText") + # q was an array → translatedText is an array (same order & length). Guard a + # scalar reply just in case (shouldn't happen for an array q). + if not isinstance(out, list): + out = [out] + interp = body.get("interpreter") or {} + return { + "translations": out, + "detected_lang": (body.get("detectedLanguage") or {}).get("language"), + "engine": interp.get("engine"), + "engine_version": interp.get("engine_version"), + } diff --git a/tests/test_interpreter_client.py b/tests/test_interpreter_client.py new file mode 100644 index 0000000..39b2e07 --- /dev/null +++ b/tests/test_interpreter_client.py @@ -0,0 +1,108 @@ +"""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): + self.status_code = status_code + self._payload = payload 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.requests, "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["engine_version"] == "ollama:x:12b" + assert captured["json"]["q"] == ["ねこが可愛い", "金髪ギャル!"] + assert captured["json"]["target"] == "en" + + +def test_translate_passthrough_unchanged(monkeypatch): + # Already-English items come back unchanged in their slot (engine "none"). + monkeypatch.setattr(ic.requests, "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.requests, "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.requests, "post", boom) + with pytest.raises(ic.InterpreterUnavailable): + ic.translate(["x"], base_url="http://i.lan") + + +def test_translate_400_raises_bad_request(monkeypatch): + monkeypatch.setattr( + ic.requests, "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.requests, "post", boom) + assert ic.translate([], base_url="http://i.lan")["translations"] == [] + + +def test_health_true_when_llm_up(monkeypatch): + monkeypatch.setattr( + ic.requests, "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.requests, "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.requests, "get", boom) + assert ic.health("http://i.lan") is False + + +def test_health_false_when_url_empty(): + assert ic.health("") is False