"""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 from datetime import UTC, datetime from email.utils import parsedate_to_datetime import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry class InterpreterUnavailable(Exception): """The translation engine is down / unreachable / draining — a connection error, HTTP 429, or a 5xx (commonly 502/503/504 through a reverse proxy while the service restarts). Retry later, don't drop the item. ``retry_after`` holds the server's Retry-After hint in seconds when it sent one, so the caller can back off exactly as long as it's asked to.""" def __init__(self, message: str, *, retry_after: float | None = None): super().__init__(message) self.retry_after = retry_after class InterpreterBadRequest(Exception): """Bad request params (HTTP 400).""" def _url(base_url: str, path: str) -> str: return f"{base_url.rstrip('/')}{path}" def _parse_retry_after(resp) -> float | None: """Parse a Retry-After header (RFC 7231: delta-seconds or an HTTP-date) into non-negative seconds, or None if absent/unparseable — so a gracefully- draining Interpreter can tell Curator exactly how long to wait before it tries again.""" raw = (resp.headers.get("Retry-After") or "").strip() if not raw: return None try: return max(0.0, float(int(raw))) # delta-seconds form except ValueError: pass try: # HTTP-date form when = parsedate_to_datetime(raw) except (TypeError, ValueError): return None if when is None: return None if when.tzinfo is None: when = when.replace(tzinfo=UTC) return max(0.0, (when - datetime.now(UTC)).total_seconds()) # A shared session pools the keep-alive connection across the per-post sweep # calls, and retries CONNECT failures only (connect=2, short backoff) — smoothing # the instant a reverse proxy reloads. Status codes are deliberately NOT retried # (status=0, raise_on_status=False): translate() maps 429/5xx → InterpreterUnavailable # itself, and letting urllib3 retry a draining 503 would defeat the Retry-After # backoff we honour upstream. _retry = Retry( total=None, connect=2, read=0, redirect=0, status=0, backoff_factor=0.3, raise_on_status=False, ) session = requests.Session() _adapter = HTTPAdapter(max_retries=_retry) session.mount("http://", _adapter) session.mount("https://", _adapter) 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 = session.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 "detected_confidence": float | None, # detector's confidence, if given "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 a connection error / 429 / 5xx (retry later, honouring Retry-After), InterpreterBadRequest on 400. (Scribe note #1347.) """ if not texts: return {"translations": [], "detected_lang": None, "detected_confidence": None, "engine": None, "engine_version": None} try: r = session.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 == 400: raise InterpreterBadRequest((r.json() or {}).get("error", "bad request")) # A gracefully-draining service — often behind a reverse proxy — returns 429 # or a 5xx gateway error (502/503/504), not always a clean 503. Treat every # one as "unavailable, retry later" and honour any Retry-After it sends, so a # rolling Interpreter restart interrupts the sweep cleanly (instead of raising # an opaque HTTPError) and resumes exactly when the service says it's ready. if r.status_code == 429 or r.status_code >= 500: raise InterpreterUnavailable( f"interpreter unavailable (HTTP {r.status_code})", retry_after=_parse_retry_after(r), ) 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 {} detected = body.get("detectedLanguage") or {} return { "translations": out, "detected_lang": detected.get("language"), "detected_confidence": detected.get("confidence"), "engine": interp.get("engine"), "engine_version": interp.get("engine_version"), }