2d36b54b8d
Closes the documentation gaps from the freshly-audited diplomail implementation. FUNCTIONAL.md gains a §11 "Diplomatic mail" with the full user-facing story across all five stages, mirrored into FUNCTIONAL_ru.md as the project conventions require. A new backend/docs/diplomail-translator-setup.md captures the LibreTranslate operational recipe (Docker image, env wiring, manual smoke test, troubleshooting). The package README gains a "Multi-instance posture" note documenting the deliberate absence of FOR UPDATE in the worker pickup query — single-instance is safe today; multi-instance scaling will revisit the claim mechanism. Two small edge-case tests round things out: malformed LibreTranslate response bodies (single string, short array, empty array, missing field) must surface as errors so the worker falls back instead of crashing; and an empty translation queue must produce zero events on three consecutive Worker.Tick calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
6.4 KiB
Markdown
165 lines
6.4 KiB
Markdown
# LibreTranslate setup for diplomatic mail
|
|
|
|
This document describes how to run the LibreTranslate backend that the
|
|
diplomatic-mail subsystem uses for body translation. The instructions
|
|
target three audiences: developers spinning up LibreTranslate
|
|
alongside `tools/local-dev`, operators preparing a real deployment,
|
|
and reviewers verifying the end-to-end translation flow by hand.
|
|
|
|
## When you need LibreTranslate
|
|
|
|
The diplomatic-mail worker runs unconditionally — `make up` and `make
|
|
test` both work without any translator. With
|
|
`BACKEND_DIPLOMAIL_TRANSLATOR_URL` unset, the noop translator
|
|
short-circuits the pipeline: messages are delivered in the original
|
|
language, and the inbox handler returns the original body to every
|
|
reader.
|
|
|
|
You only need LibreTranslate when you want to exercise the cross-
|
|
language path: sender writes in language X, recipient's
|
|
`accounts.preferred_language` is Y, the worker is expected to fetch
|
|
a Y rendering. The pipeline is otherwise identical and unaware of
|
|
which engine is producing translations.
|
|
|
|
## Running a local instance
|
|
|
|
LibreTranslate ships a public Docker image at
|
|
`libretranslate/libretranslate`. The image is ~3 GB on first pull
|
|
because it bundles every supported language model; subsequent runs
|
|
reuse the layer cache.
|
|
|
|
The simplest setup is a one-shot container:
|
|
|
|
```bash
|
|
docker run --rm -d --name libretranslate \
|
|
-p 5000:5000 \
|
|
-e LT_LOAD_ONLY=en,ru \
|
|
libretranslate/libretranslate:latest
|
|
```
|
|
|
|
The `LT_LOAD_ONLY` whitelist trims the loaded model set so the
|
|
container fits in ~600 MB of RAM. Drop the variable to load every
|
|
language pair LibreTranslate ships.
|
|
|
|
LibreTranslate boots in ~30 seconds (cold) or ~5 seconds (warm
|
|
model cache). Wait until `curl -s http://localhost:5000/languages`
|
|
returns a JSON array before pointing backend at it.
|
|
|
|
## Wiring backend at it
|
|
|
|
Add three env vars to the backend process:
|
|
|
|
```
|
|
BACKEND_DIPLOMAIL_TRANSLATOR_URL=http://localhost:5000
|
|
BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT=10s
|
|
BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS=5
|
|
```
|
|
|
|
When backend lives inside the `tools/local-dev` Docker network and
|
|
LibreTranslate runs on the host, replace `localhost` with the host's
|
|
docker-bridge address (`http://host.docker.internal:5000` on
|
|
Docker Desktop; `http://172.17.0.1:5000` on a Linux bridge by
|
|
default).
|
|
|
|
For a stack-internal deployment, drop LibreTranslate into the same
|
|
Docker compose file alongside backend and reach it by its service
|
|
name:
|
|
|
|
```yaml
|
|
services:
|
|
libretranslate:
|
|
image: libretranslate/libretranslate:latest
|
|
environment:
|
|
LT_LOAD_ONLY: "en,ru"
|
|
healthcheck:
|
|
test: ["CMD", "wget", "-qO-", "http://localhost:5000/languages"]
|
|
interval: 5s
|
|
timeout: 2s
|
|
retries: 12
|
|
|
|
backend:
|
|
environment:
|
|
BACKEND_DIPLOMAIL_TRANSLATOR_URL: "http://libretranslate:5000"
|
|
depends_on:
|
|
libretranslate:
|
|
condition: service_healthy
|
|
```
|
|
|
|
## Manual smoke test
|
|
|
|
Once both services are up:
|
|
|
|
1. Register two accounts via the public auth flow. Set the second
|
|
account's `preferred_language` to a value that differs from the
|
|
sender's writing language (e.g. sender writes in English, second
|
|
account is `ru`).
|
|
2. Create a private game with the first account, invite the second,
|
|
land both as active members.
|
|
3. Send a personal message: `POST /api/v1/user/games/{id}/mail/messages`
|
|
with the body in English.
|
|
4. Watch backend logs for the diplomail worker. After ~2 seconds you
|
|
should see `translator attempt succeeded` (or equivalent INFO
|
|
line) and the recipient flipped to `available_at`.
|
|
5. As the second account, fetch
|
|
`GET /api/v1/user/games/{id}/mail/messages/{message_id}`. The
|
|
response should carry both `body` (English original) and
|
|
`translated_body` (Russian) along with the `translation_lang`
|
|
and `translator` fields.
|
|
|
|
## Operational notes
|
|
|
|
- **Resource budget.** With `LT_LOAD_ONLY=en,ru` the container peaks
|
|
around 800 MB resident; with all languages, ~3 GB. Plan accordingly.
|
|
- **CPU.** LibreTranslate is CPU-bound. One translation of a 200-
|
|
word body takes ~200 ms on a modern x86 core; the diplomail worker
|
|
is single-threaded by design, so steady-state throughput is
|
|
`1 / avg_latency` per backend instance.
|
|
- **Outage behaviour.** A LibreTranslate outage stalls delivery of
|
|
pending pairs by at most ~31 seconds per pair (the worker's
|
|
exponential backoff schedule), then falls back to the original
|
|
body. Inbox listings never depend on the translator's
|
|
availability.
|
|
- **API key.** Backend does not send an API key. Self-hosted
|
|
deployments without `LT_API_KEYS` configured accept anonymous
|
|
POSTs by default, which matches our deployment posture
|
|
(LibreTranslate sits on the internal docker network, not
|
|
reachable from outside).
|
|
- **Models.** Adding a new target language is an operator-side
|
|
task: install the corresponding Argos model into the
|
|
LibreTranslate container (`argospm install …`) and either restart
|
|
the container or send a SIGHUP. The diplomail pipeline notices
|
|
the new language pair automatically — there is no allow-list
|
|
inside backend.
|
|
|
|
## Troubleshooting
|
|
|
|
- **`translator: do request: dial tcp ...: connect: connection refused`.**
|
|
LibreTranslate is not listening on the configured address. Verify
|
|
with `curl http://${URL}/languages`. On Docker setups, double-
|
|
check the bridge address discussion above.
|
|
- **`translator: libretranslate http 400`** in worker logs but the
|
|
language pair clearly exists.
|
|
Make sure the request used the two-letter codes (`en`, not
|
|
`en-US`). Backend normalises before sending; if you see a region
|
|
subtag in the log, file an issue against `internal/diplomail` —
|
|
the normalisation should be unconditional.
|
|
- **`translator: libretranslate http 503`.**
|
|
Container is still loading models. Wait for `/languages` to
|
|
respond `200`. The worker retries with backoff, so steady-state
|
|
recovers automatically.
|
|
- **Worker logs only "noop translator returned, delivering
|
|
fallback".**
|
|
`BACKEND_DIPLOMAIL_TRANSLATOR_URL` is empty in the backend
|
|
process. Confirm with `docker compose exec backend env | grep
|
|
DIPLOMAIL`.
|
|
|
|
## Future work
|
|
|
|
- Adding an OpenTelemetry counter and histogram for translator
|
|
outcomes is tracked in the diplomail package README; the metrics
|
|
will surface in Grafana once LibreTranslate is deployed.
|
|
- Email-alerting on prolonged outage (e.g. ≥ N consecutive failures
|
|
in M minutes) is planned through a new
|
|
`diplomail.translator.unhealthy` notification kind. Not wired
|
|
yet — current monitoring lives in zap logs.
|