feat(translation): Interpreter client — batch translate + health (#143 step 2)
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -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"),
|
||||
}
|
||||
Reference in New Issue
Block a user