Files
lukasandClaude Opus 5.5 21cfaa07ed Translator: languages that fail to download are tried again
The first start tried the default languages once; a DNS failure at that
moment left them missing for good. Languages asked for are now kept as
pending in state.json and retried in the background (30 s, doubling to
15 min) until they are in, across restarts; an install from the admin
panel joins the same queue. A state from before this, with nothing
installed, counts as an unfinished first start. The admin panel shows
what is waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:21:38 +02:00

472 lines
16 KiB
Python

"""Ultimo's translation service: Mozilla's Firefox Translations models (the
Bergamot engine) and fastText language identification, behind a small HTTP
API that only the webmail API calls (backend/app/translation.py), on the
internal network. A message's text never leaves the server.
- Firefox's models translate to and from English; any other pair goes
through English (Polish → English → German). So a language is installed as
its two models, to and from English, and English is always there.
- What is installed is kept in ``$DATA_DIR/state.json``, with the languages
still to install. The first start asks for ``TRANSLATOR_LANGUAGES``; after
that the admin panel adds and removes languages (``POST``/``DELETE
/languages/{code}``). A language that could not be downloaded (no network
yet) is tried again in the background, at growing intervals, until it is
in — across restarts too.
- Models come from Mozilla's Remote Settings (where Firefox gets them), each
file checked against its SHA-256; only release models (no test builds).
- Language identification: fastText's lid.176 (176 languages), baked into
the image.
API (JSON):
GET /health
GET /languages installed / installing / available, errors
POST /languages/{code} install (in the background)
DELETE /languages/{code} remove
POST /detect {"q": str | [str]} → language(s)
POST /translate {"q": str | [str], "source", "target"} → translation(s)
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import shutil
import threading
import time
import urllib.request
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import fasttext
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
def _load_engine() -> Any:
"""The Bergamot engine: only its compiled module. Importing the bergamot
package itself fetches model catalogues from translatelocally.com and
OPUS on every start (and fails without them); we load our own models."""
import importlib.machinery
import importlib.util
spec = importlib.util.find_spec("bergamot")
if spec is None or not spec.submodule_search_locations:
raise ImportError("the bergamot package is not installed")
folder = Path(next(iter(spec.submodule_search_locations)))
path = next(folder.glob("_bergamot*.so"))
loader = importlib.machinery.ExtensionFileLoader("_bergamot", str(path))
module_spec = importlib.util.spec_from_loader("_bergamot", loader)
assert module_spec is not None
module = importlib.util.module_from_spec(module_spec)
loader.exec_module(module)
return module
bergamot = _load_engine()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("translator")
DATA = Path(os.environ.get("DATA_DIR", "/data"))
MODELS = DATA / "models"
STATE = DATA / "state.json"
LID_MODEL = os.environ.get("LID_MODEL", "/opt/lid.176.ftz")
DEFAULT_LANGUAGES = [
c.strip() for c in os.environ.get("TRANSLATOR_LANGUAGES", "pl,de,fr,es,it").split(",")
]
THREADS = int(os.environ.get("TRANSLATOR_THREADS", "2"))
RECORDS_URL = os.environ.get(
"MODELS_RECORDS_URL",
"https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/"
"translations-models/records",
)
ATTACHMENTS_URL = os.environ.get(
"MODELS_ATTACHMENTS_URL", "https://firefox-settings-attachments.cdn.mozilla.net/"
)
PIVOT = "en"
_RECORDS_TTL = 3600
_CODE = re.compile(r"^[a-z]{2,3}(-[A-Za-z]{2,4})?$")
_service = bergamot.Service(bergamot.ServiceConfig(numWorkers=THREADS, logLevel="off"))
_lid = fasttext.load_model(LID_MODEL)
_lock = threading.Lock() # state, loaded models, installs
_loaded: dict[str, Any] = {} # "en-pl" → bergamot model
_installing: dict[str, dict[str, int]] = {} # "pl" → {"done": bytes, "total": bytes}
_errors: dict[str, str] = {} # "pl" → why the last install failed
_records: tuple[float, list[dict[str, Any]]] = (0.0, [])
# --- what is installed --------------------------------------------------------------
def _read_state() -> dict[str, Any] | None:
try:
return json.loads(STATE.read_text())
except (OSError, ValueError):
return None
def _installed() -> list[str]:
state = _read_state() or {}
return [PIVOT] + [c for c in state.get("languages", []) if c != PIVOT]
def _pending() -> list[str]:
"""Languages asked for and not installed yet (tried again until they are)."""
return list((_read_state() or {}).get("pending", []))
def _write_state(installed: list[str], pending: list[str]) -> None:
DATA.mkdir(parents=True, exist_ok=True)
tmp = STATE.with_suffix(".tmp")
done = set(installed) - {PIVOT}
tmp.write_text(
json.dumps(
{
"languages": sorted(done),
"pending": [c for c in dict.fromkeys(pending) if c not in done and c != PIVOT],
}
)
)
tmp.replace(STATE)
def _write_installed(codes: list[str]) -> None:
_write_state(codes, _pending())
# --- Mozilla's model catalogue -----------------------------------------------------------
def _fetch(url: str, timeout: float = 60) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": "ultimo-translator"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
def _catalogue() -> list[dict[str, Any]]:
"""Mozilla's model records (cached for an hour)."""
global _records
stamp, cached = _records
if cached and time.monotonic() - stamp < _RECORDS_TTL:
return cached
data = json.loads(_fetch(RECORDS_URL))["data"]
_records = (time.monotonic(), data)
return data
def _version(text: str) -> tuple[int, ...] | None:
""" "2.1" → (2, 1); test builds ("2.1a1") → None."""
return tuple(int(x) for x in text.split(".")) if re.fullmatch(r"\d+(\.\d+)*", text) else None
def _release_files(source: str, target: str) -> list[dict[str, Any]]:
"""The newest release model of one direction: its files' records."""
by_version: dict[tuple[int, ...], list[dict[str, Any]]] = {}
for r in _catalogue():
if r.get("fromLang") != source or r.get("toLang") != target:
continue
if r.get("filter_expression"):
continue # a test build (Nightly only)
v = _version(str(r.get("version", "")))
if v is not None:
by_version.setdefault(v, []).append(r)
if not by_version:
raise LookupError(f"no model for {source} → {target}")
return by_version[max(by_version)]
def _available() -> list[str]:
"""Languages Mozilla has release models for, both ways with English."""
pairs: set[tuple[str, str]] = set()
for r in _catalogue():
if not r.get("filter_expression") and _version(str(r.get("version", ""))):
pairs.add((r["fromLang"], r["toLang"]))
return sorted({a for a, b in pairs if b == PIVOT and (PIVOT, a) in pairs} | {PIVOT})
# --- installing ---------------------------------------------------------------------------
def _model_dir(source: str, target: str) -> Path:
return MODELS / f"{source}-{target}"
def _install_direction(code: str, source: str, target: str) -> None:
files = _release_files(source, target)
final = _model_dir(source, target)
tmp = final.with_name(final.name + ".partial")
shutil.rmtree(tmp, ignore_errors=True)
tmp.mkdir(parents=True)
for r in files:
att = r["attachment"]
blob = _fetch(ATTACHMENTS_URL + att["location"], timeout=600)
if hashlib.sha256(blob).hexdigest() != att["hash"]:
raise ValueError(f"{att['filename']}: checksum mismatch")
(tmp / att["filename"]).write_bytes(blob)
with _lock:
_installing[code]["done"] += len(blob)
(tmp / "files.json").write_text(
json.dumps(
{r["fileType"]: r["attachment"]["filename"] for r in files}
| {"version": files[0]["version"]}
)
)
shutil.rmtree(final, ignore_errors=True)
tmp.replace(final)
def _install(code: str) -> None:
"""Both directions of ``code`` with English, then mark it installed."""
try:
directions = [(code, PIVOT), (PIVOT, code)]
total = sum(r["attachment"]["size"] for s, t in directions for r in _release_files(s, t))
with _lock:
_installing[code]["total"] = total
for source, target in directions:
_install_direction(code, source, target)
with _lock:
_write_state(_installed() + [code], [c for c in _pending() if c != code])
for pair in (f"{code}-{PIVOT}", f"{PIVOT}-{code}"):
_loaded.pop(pair, None) # a reinstall: load the new files
_errors.pop(code, None)
log.info("installed %s", code)
except Exception as exc: # noqa: BLE001 — reported in /languages, tried again later
log.warning("installing %s failed: %s", code, exc)
with _lock:
_errors[code] = str(exc)[:300]
finally:
with _lock:
_installing.pop(code, None)
_wake = threading.Event() # a new language to install: try now, not after the pause
_RETRY_FIRST, _RETRY_MAX = 30.0, 900.0
def _installer() -> None:
"""Install the pending languages, one after another; while some fail
(no network, Mozilla out of reach), try again after a pause that grows
from half a minute to a quarter of an hour."""
pause = _RETRY_FIRST
while True:
for code in _pending():
with _lock:
if code in _installing or code in _installed():
continue
_installing[code] = {"done": 0, "total": 0}
_install(code)
if not _pending():
pause = _RETRY_FIRST
_wake.wait() # nothing to do until a language is asked for
else:
_wake.wait(pause)
pause = min(pause * 2, _RETRY_MAX)
_wake.clear()
def _ask_for(code: str) -> None:
with _lock:
_write_state(_installed(), _pending() + [code])
_errors.pop(code, None)
_wake.set()
def _start() -> None:
"""The first start asks for the default languages; every start resumes
what is still pending."""
state = _read_state()
# No state yet — or one from before pending languages were kept, with
# nothing installed (a first start that could not download): ask for the
# defaults.
if state is None or ("pending" not in state and not state.get("languages")):
_write_state([], [c for c in DEFAULT_LANGUAGES if c])
threading.Thread(target=_installer, daemon=True).start()
@asynccontextmanager
async def _lifespan(_: FastAPI) -> AsyncIterator[None]:
_start()
yield
app = FastAPI(title="Ultimo translator", docs_url=None, redoc_url=None, lifespan=_lifespan)
# --- translating -------------------------------------------------------------------------
def _config(directory: Path) -> str:
files = json.loads((directory / "files.json").read_text())
model = files["model"]
vocabs = (
[files["vocab"], files["vocab"]]
if "vocab" in files
else [files["srcvocab"], files["trgvocab"]]
)
# The models are 8-bit; their file name says which kind (as Firefox decides).
precision = "int8shiftAlphaAll" if model.endswith("alphas.bin") else "int8shiftAll"
lines = [
"relative-paths: false",
"models:",
f" - {directory / model}",
"vocabs:",
*[f" - {directory / v}" for v in vocabs],
"beam-size: 1",
"normalize: 1.0",
"word-penalty: 0",
"mini-batch: 64",
"maxi-batch: 1000",
"maxi-batch-sort: src",
"workspace: 2000",
"max-length-factor: 2.5",
"max-length-break: 128",
"mini-batch-words: 1024",
"skip-cost: true",
"quiet: true",
f"gemm-precision: {precision}",
]
if "lex" in files:
lines += ["shortlist:", f" - {directory / files['lex']}", " - false"]
return "\n".join(lines) + "\n"
def _model(source: str, target: str) -> Any:
pair = f"{source}-{target}"
with _lock:
if pair not in _loaded:
directory = _model_dir(source, target)
if not (directory / "files.json").exists():
raise HTTPException(400, f"{source} → {target} is not installed")
_loaded[pair] = _service.modelFromConfig(_config(directory))
return _loaded[pair]
_OPTIONS = bergamot.ResponseOptions(alignment=False, qualityScores=False, HTML=False)
def _translate(texts: list[str], source: str, target: str) -> list[str]:
todo = [i for i, t in enumerate(texts) if t.strip()]
out = list(texts)
if not todo or source == target:
return out
batch = [texts[i] for i in todo]
steps = [(source, target)] if PIVOT in (source, target) else [(source, PIVOT), (PIVOT, target)]
for s, t in steps:
model = _model(s, t)
batch = [
r.target.text for r in _service.translate(model, bergamot.VectorString(batch), _OPTIONS)
]
for i, text in zip(todo, batch, strict=True):
out[i] = text
return out
# --- identifying languages -----------------------------------------------------------------
# fastText's labels where Firefox's codes differ.
_LID_CODES = {"zh": "zh-Hans", "no": "nb", "iw": "he", "fil": "tl"}
# Signs only traditional Chinese uses (a few common ones): tell zh-Hant apart.
_TRADITIONAL = set("們這個來說會對時國過還後點學東車長門開關問電話員")
def _detect(text: str) -> dict[str, Any]:
sample = " ".join(text.split())[:2000]
if not sample:
return {"language": "", "confidence": 0.0}
labels, scores = _lid.predict(sample, k=1)
code = labels[0].replace("__label__", "") if labels else ""
code = _LID_CODES.get(code, code)
if code == "zh-Hans" and sum(c in _TRADITIONAL for c in sample) >= 2:
code = "zh-Hant"
return {"language": code, "confidence": round(float(scores[0]) * 100, 1) if scores else 0.0}
# --- the API ---------------------------------------------------------------------------------
class DetectIn(BaseModel):
q: str | list[str]
class TranslateIn(BaseModel):
q: str | list[str]
source: str
target: str
@app.get("/health")
def health() -> dict[str, bool]:
return {"ok": True}
@app.get("/languages")
def languages() -> dict[str, Any]:
try:
available = _available()
except Exception as exc: # noqa: BLE001 — offline: what is installed still works
log.info("model catalogue unavailable: %s", exc)
available = []
with _lock:
return {
"installed": _installed(),
"installing": {k: dict(v) for k, v in _installing.items()},
"pending": [c for c in _pending() if c not in _installing],
"available": available,
"errors": dict(_errors),
}
@app.post("/languages/{code}", status_code=202)
def install(code: str) -> dict[str, Any]:
if not _CODE.fullmatch(code) or code == PIVOT:
raise HTTPException(400, "not a language code")
try:
_release_files(code, PIVOT)
_release_files(PIVOT, code)
except LookupError as exc:
raise HTTPException(404, str(exc)) from exc
except Exception as exc: # noqa: BLE001 — Mozilla out of reach: queue it anyway
log.info("model catalogue unavailable, %s queued: %s", code, exc)
_ask_for(code)
return {"started": True}
@app.delete("/languages/{code}")
def remove(code: str) -> dict[str, bool]:
if code == PIVOT:
raise HTTPException(400, "English is always installed")
with _lock:
_write_state([c for c in _installed() if c != code], [c for c in _pending() if c != code])
_errors.pop(code, None)
for pair in (f"{code}-{PIVOT}", f"{PIVOT}-{code}"):
_loaded.pop(pair, None)
shutil.rmtree(MODELS / pair, ignore_errors=True)
return {"removed": True}
@app.post("/detect")
def detect(body: DetectIn) -> Any:
if isinstance(body.q, str):
return [_detect(body.q)]
return [_detect(q) for q in body.q]
@app.post("/translate")
def translate(body: TranslateIn) -> dict[str, Any]:
texts = [body.q] if isinstance(body.q, str) else body.q
source = body.source
if source == "auto":
source = _detect(" ".join(texts))["language"]
installed = _installed()
for code in (source, body.target):
if code not in installed:
raise HTTPException(400, f"{code or 'this language'} is not installed")
out = _translate(texts, source, body.target)
return {"translatedText": out[0] if isinstance(body.q, str) else out, "source": source}