"""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"), }