040 - Translation Gateway¶
User Story¶
As an operator of an Infinito.Nexus deployment, I want one translation endpoint that chooses the best engine itself, remembers which engine won earlier comparisons, and lets a human override any string, so that every application translates content through a single configured URL and the quality improves without me tuning each application separately.
Background¶
The runtime half of translation is unbuilt, but most of its parts are already in the repository.
Present today:
svc-ai-libretranslate-engineruns LibreTranslate as an engine without a browser surface, andweb-svc-libretranslatepublishes it atlibre.translate.{{ DOMAIN_PRIMARY }}behind SSO.svc-ai-ollamaserves local models.svc-ai-s1answers typed questions onPOST /v1/systemone, andweb-svc-s1publishes that contract.svc-ai-litellmalready implements, for model routing, every mechanism this requirement needs for engine routing:Sampler(router_hook.py.j2:405) decides when to explore,History(:441) keeps one row per offered route per decision in the proxy’s own database,wins()returns{alias: (won, seen)},record_of()(:496) turns that into a clause inside the criteria line,judge_answers()(:610) puts the replies themselves to the decider while hiding which route produced them, and_compare()(:783) runs the fan-out in aserveor ashadowmode.
Absent today:
No role fronts several translation engines;
libretranslateis the only translation service key any consumer declares, and onlyweb-app-mediawikideclares it.No LTEngine role.
No Weblate role, so the deployment has no translation memory, no glossary and no place for a reviewed human override.
Scope¶
In scope: a gateway role, an LTEngine backend, a Weblate role, the engine decision and its learning log, the cache, and the per-application consumer integration.
Out of scope, and deliberately left alone:
039 - Core i18n Consumers wires this project’s own surfaces onto those catalogs. 040 also serves the content users put into the applications, at request time. Neither requirement is used to fix a defect belonging to the other.
The build-time catalog run of 037 - Gettext Catalogs LibreTranslate was out of scope until the measurement under Re-opened Decisions overturned the reason for it. It now routes through the gateway too.
Findings That Constrain the Design¶
The decision mechanism is not new work. Lifting it means extracting it, not copying it: a second copy of
Historyandjudge_answersis a second thing to fix when the contract moves. The house precedent for a cross-role Python artefact is the MCP adapter, whose build context is owned by one role and staged per instance so every consumer shares one audited copy (thelibretranslatemcpentry in web-svc-libretranslate/meta/services.yml).Consumer wiring is also not new work. 026 - Unified Addon Syntax already defines how one role bridges a service another role provides:
roles/<role>/meta/addons/<id>.ymlwith abridges:key that MUST resolve to a service in the same role’smeta/services.yml, and anenabledderived from that service flag rather than fromgroup_names. Every application-side integration in this requirement is an addon under that contract.The
translate.{{ DOMAIN_PRIMARY }}label is unclaimed; the only translation domain in the repository is the engine frontend’s.Ollama and LTEngine both pull models and reserve memory. The variant resource budget caps the deduplicated reservations of one variant, so the heavy backends cannot share a variant with each other.
Confirmed Decisions¶
Re-opening any of these MUST be recorded in the implementing PR.
The gateway is a new role,
web-svc-translate, published atapi.translate.{{ DOMAIN_PRIMARY }}. It is not an extension ofweb-svc-libretranslate, because that role publishes one engine’s own API and SSO-protected frontend, and a router in front of several engines is a different responsibility with its own database and its own failure modes.The gateway speaks the LibreTranslate API.
POST /translate,POST /detectandGET /languageskeep their published request and response shapes. A consumer therefore points at the gateway by changing one URL, and anything that already speaks to LibreTranslate works unchanged. The gateway is a router, not a new dialect.Backends are declared, never hardcoded. Each backend is a consumer declaration in the gateway’s own
meta/services.yml, gated on whether the providing role is deployed, and every reader of that backend inside the role goes throughlookup('config', application_id, 'services.<backend>.enabled')rather than testing the deployment again. Adding a backend is a declaration plus a template entry, not a code change. See Re-opened Decisions.Three backends ship: LibreTranslate, LTEngine, and Ollama. LibreTranslate is the default and the only one enabled without further deployment. LTEngine arrives as a new
svc-ai-ltenginerole. Ollama reusessvc-ai-ollamaand reaches a local model.Which engine answers is a System One decision, taken exactly as the model router takes it. The surviving candidate engines become the options of a single
choicequestion, the request becomes the state, and each option’s criteria line states only measured or catalogued facts about that engine: whether it runs in this cluster, which language pairs it serves, its measured latency where one exists, and how it fared in earlier comparisons. Achoicenaming an engine the gateway did not offer is refused rather than routed to.The strategy derives from the deployment, not from a hand-set value.
services.translate.router.strategyfollowsservices.s1.enabled: with the decider deployed the gateway asks it, and without it the gateway falls to apreferencestrategy it can compute itself from a declared engine order. One path leads from the deployment to the behaviour.The learning log is a
History-shaped table in the gateway’s own database, one row per offered engine per decision, each row marking whether that engine won. The signature groups comparable requests: source language, target language, and a coarse content class derived from the payload’s length and whether it carries markup.wins(signature)returns{engine: (won, seen)}and feeds decision 5’s criteria line, so the next decision reads what earlier ones concluded. A log that cannot be read MUST degrade to deciding without it and MUST NOT fail the request.What “best” means is judged on the answers, blind to their source. While sampling, the gateway asks several engines the same request and puts the returned translations to the decider as the options of one typed
choice, labelledoption-0,option-1, … so the verdict rests on what came back rather than on which engine promised what. The winner is the row marked won.Sampling is the exception, answering is the rule. The gateway fans out only when the sampler is due, and once a signature has accumulated a clear leader over a configured number of comparisons it routes straight to that engine without asking the decider at all. That is what makes the log pay for itself. A configured re-sample rate keeps a signature from freezing on a verdict the engines have since outgrown.
A reviewed human translation outranks every engine. The gateway consults Weblate’s translation memory first; a string with a reviewed translation is returned verbatim, no engine is called, and machine output MUST NOT overwrite it. This is the only hard precedence rule in the gateway.
Weblate ships as
web-app-weblatewith translation memory, glossaries, and a review workflow, reachable by its own UI and by the gateway over Weblate’s HTTP API with a token from the credential store. Glossary terms are passed to engines that accept them, and an answer that mangles a protected term is rejected rather than cached.The cache is one store, and it is the database the gateway already needs. A translation is cached on the tuple of engine, source language, target language and the digest of the source text. No second cache technology is introduced for it; a Redis consumer declaration stays off unless a measurement shows the database cache is the bottleneck.
No backend reachable means a loud failure. The gateway answers an error, never the untranslated source text dressed as a translation. An engine that errors repeatedly drops out of the candidate set, and its failures are counted separately from lost comparisons, because failing is not the same as answering worse.
The shared mechanism is extracted, not duplicated.
Sampler,History,record_of,judge_answersanddecidemove into one Python package owned bysvc-ai-s1, which owns the decider contract, and staged into bothsvc-ai-litellmandweb-svc-translatethe way the MCP adapter’s build context is staged per instance. The model router’s behaviour MUST not change: its existing tests are the regression net for the extraction.Every application-side integration is a 026 addon. A consuming role declares a
translateconsumer entry gated on the gateway’s service flag and an addon that bridges it. The applications in scope are the ones the architecture names: WordPress, Moodle, Discourse, OpenProject, Nextcloud and Matrix; which of them have an upstream consumer of this API is settled under Re-opened Decisions.The heavy backends live in separate variants. LTEngine and Ollama are not enabled in the same variant, and the variant set is measured with
cli/meta/roles/applications/ressourcesagainst the budget before the matrix runs.A refusal re-asks the next engine instead of ending the request. Decision 11 rejects an answer that mangles a protected term. That verdict is about one engine’s answer for one language pair, not about the string being untranslatable, because a refusal class is a property of the engine and the pair. The gateway therefore offers the same request to the next candidate of the set decision 5 ranked, minus the engines that already refused it, and returns the first answer that survives the check. Only when every candidate has refused does the gateway report the refusal, and never a mangled translation.
Refusals are counted on their own axis. An engine whose answer is rejected is neither unreachable like decision 13’s failures nor worse like decision 8’s lost comparisons: it is unsuited to that pair. The three counts are stored separately so decision 5’s criteria line can say which of the three happened, and so a pair an engine keeps mangling steers the next decision away from it without that engine losing standing on the pairs it serves well.
Translation quality is judged against the source, not only against the catalogue. Echo, collapse and a damaged protected span are visible in the catalogue alone; a fluent translation that means something else is not. A
scorequestion to the decider, carrying the source text, the translation and the file the entry came from, places each sampled entry on an ordered scale from “says something different” to “carries the source faithfully”. Anything the decider places below the middle level is a finding, named with its catalogue, its entry and its file, because a silent wrong translation is the failure this whole requirement exists to prevent.
Re-opened Decisions¶
Decision 3, the backend gate. As first written it demanded lookup('config', '<providing role>', …) and forbade group_names. That gate cannot work: lookup('config', …) resolves through get_merged_applications (plugins/lookup/config.py:45), which merges every role in the repository rather than the ones this deploy carries, so services.ltengine.enabled reads true whether or not LTEngine is deployed and every backend would always be offered. The play-scoped set lives behind a separate lookup (plugins/lookup/applications_current_play.py:69), and the applications cache keys its renders on group_names (utils/cache/base.py:108) rather than on the whole inventory, so a deployment-wide presence test inside meta/services.yml would be cached across inventories that differ.
The gate is therefore the house idiom every other consumer declaration uses, '<providing role>' in group_names, and the part of the decision that carries the design survives unchanged: no code inside the gateway tests a deployment, every reader asks its own services.<backend>.enabled, and adding a backend stays a declaration.
The 037 boundary. The Scope section kept make i18n-translate on the engine, arguing that one batch of known strings against one engine needs neither a cache nor a router. The catalogs since measured say otherwise: across the 47 docs catalogs, each of 22577 entries, 34098 translations carry a refusal, from 35 in tl to 2832 in ar, and the largest family of them is a mangled protected span at 11150, just ahead of a stuttered repetition at 10717. The catalog client masks those spans as <x id="N"></x> tags and asks LibreTranslate in html mode; whether an engine leaves that tag alone is a property of the engine and the language pair, which is exactly the fact the gateway’s learning log is keyed on. One engine cannot be compared against itself, so the direct path had no way to improve. The run therefore goes through the gateway.
Two mechanical consequences follow and are implemented with it. format travels with a request instead of being pinned to text in the LibreTranslate backend, because a tag sent in text mode is translated rather than preserved, and it joins the cache key so a text-mode answer is never served to an html-mode request. A caller may name protected spans of its own alongside the Weblate glossary, because a masked placeholder is not a glossary term and a prompted backend has no html mode to protect it with.
Decision 15, the six consumers. The decision names WordPress, Moodle, Discourse, OpenProject, Nextcloud and Matrix. An upstream survey of all six found a consumer of a self-hosted LibreTranslate endpoint in two of them:
App |
Upstream path |
Verdict |
|---|---|---|
WordPress |
|
wired |
Discourse |
|
wired |
Nextcloud |
the only LibreTranslate app, |
open |
Moodle |
the translation plugins take Google ( |
open |
Matrix |
|
open |
OpenProject |
ships no machine-translation extension at all; its localisation is static catalogues |
open |
The two wired pairs ship as 026 addons. The four open ones stay open rather than being faked: wiring them would mean either writing the app plugin in this repository or giving the gateway a second dialect, and decision 2 pins it to the LibreTranslate API. An OpenAI-compatible route on the gateway would reach Nextcloud and Moodle in one move and is the obvious follow-up, recorded here rather than implemented.
Architecture¶
flowchart TD
weblate["web-app-weblate<br/>translation memory, glossaries,<br/>human overrides, reviews"]
subgraph apps["Applications (026 addons)"]
wp["web-app-wordpress<br/>freedomtranslate-wp"]
di["web-app-discourse<br/>discourse-translator"]
end
subgraph gw["web-svc-translate · translate.DOMAIN_PRIMARY"]
router["router<br/>LibreTranslate API"]
cache["cache<br/>engine+pair+digest"]
history["history<br/>wins per signature"]
end
s1["svc-ai-s1<br/>POST /v1/systemone"]
lt["svc-ai-libretranslate-engine"]
lte["svc-ai-ltengine"]
ol["svc-ai-ollama"]
llm["local model"]
weblate -->|reviewed strings| router
wp --> router
di --> router
router --- cache
router --- history
router -->|choice: which engine| s1
router -->|choice: which answer| s1
history -->|won N of M| s1
router --> lt
router --> lte
router --> ol
ol --> llm
Component Roles¶
Role |
Responsibility |
State |
|---|---|---|
|
The gateway: LibreTranslate-compatible API, router, cache, learning log |
new |
|
LTEngine backend |
new |
|
Translation memory, glossaries, human overrides, reviews |
new |
|
Default backend |
exists, gains a gateway consumer declaration |
|
Local-model backend |
exists, gains a gateway consumer declaration |
|
Answers both typed choices; owns the extracted decision package |
exists, gains the shared package |
|
Keeps routing models, now on the extracted package |
exists, refactored without behaviour change |
Where each guarantee is enforced¶
Guarantee |
Proven by |
|---|---|
The gateway answers the LibreTranslate API shapes |
a Playwright spec translating a known string through |
A reviewed Weblate string wins over every engine |
a spec seeding a reviewed override and asserting the engine is never called |
The decision reads earlier outcomes |
a unit test over |
The verdict is blind to the engine |
a unit test asserting no engine name appears in the judge’s options |
A saturated signature skips the decider |
a unit test over the sampler and the saturation rule |
An unreadable log does not fail a request |
a unit test with the store raising |
No backend reachable fails loudly |
a unit test asserting an error, not a passthrough |
A refused answer reaches the next engine |
a unit test where the first engine mangles a protected term and the second answers cleanly |
Refusals, failures and lost comparisons are three counts |
a unit test moving each one without moving the others |
A translation that drifts from its source is found |
an external test scoring sampled entries against their source through the decider |
Extraction changed no model-router behaviour |
the existing |
Each consumer integration is a valid addon |
the 026 addon lints, including the bridge and Playwright-spec checks |
The variant set fits the budget |
|
Acceptance Criteria¶
Gateway¶
[ ] A
web-svc-translaterole exists and is published atapi.translate.{{ DOMAIN_PRIMARY }}.[ ]
POST /translate,POST /detectandGET /languagesanswer in the LibreTranslate request and response shapes.[ ] The gateway’s backends are consumer declarations in its own
meta/services.yml, and every reader inside the role resolves a backend throughlookup('config', application_id, 'services.<backend>.enabled').[ ]
services.translate.router.strategyderives fromservices.s1.enabledand resolves tosystem_onewith the decider deployed and topreferencewithout it.[ ] With no backend reachable the gateway answers an error and never returns the untranslated source as a translation.
[ ] An engine that errors repeatedly leaves the candidate set, and its failure count is stored separately from its lost comparisons.
[ ] An answer rejected for a mangled protected term sends the same request to the next candidate engine, and the first answer that survives the check is returned.
[ ] A request every candidate refuses is reported as a refusal, and no mangled translation is returned or cached.
[ ] Refusals, failures and lost comparisons are three separate counts, and the criteria line of the next decision can tell them apart.
Backends¶
[ ] An
svc-ai-ltenginerole serves translations and is reachable by the gateway.[ ]
svc-ai-libretranslate-engineis the default backend and the only one enabled without a further deployment.[ ]
svc-ai-ollamaserves as a backend through a local model.[ ] LTEngine and Ollama are never enabled in the same variant, and
cli/meta/roles/applications/ressourcesreports every variant inside the budget.
Decision and learning¶
[ ] The gateway asks the decider one
choicequestion whose options are the candidate engines and whose criteria lines state only measured or catalogued facts.[ ] A
choicenaming an engine the gateway did not offer is refused and no request is routed to it.[ ] One row per offered engine per decision is stored, marking which engine won, keyed on a signature of source language, target language and content class.
[ ] Earlier outcomes reach the next decision as a clause in the criteria line.
[ ] While sampling, several engines answer the same request and the decider chooses between the returned translations, labelled so that no engine name is visible to it.
[ ] A signature with a clear leader over the configured number of comparisons routes directly to that engine without a decider call, and the configured re-sample rate still re-opens it.
[ ] A log that cannot be read is logged and the decision proceeds without it; no request fails because of it.
Weblate¶
[ ] A
web-app-weblaterole exists with translation memory, glossaries and a review workflow.[ ] The gateway returns a reviewed Weblate translation verbatim without calling any engine.
[ ] Machine output never overwrites a reviewed translation.
[ ] Glossary terms are passed to engines that accept them, and an answer that mangles a protected term is rejected and not cached.
Cache¶
[ ] A translation is cached on engine, source language, target language and the digest of the source text, in the gateway’s own database.
[ ] A repeated identical request is answered from the cache without reaching an engine.
Consumers¶
[ ] WordPress and Discourse each declare a
translateconsumer entry and an addon under 026 that bridges it; Nextcloud, Moodle, Matrix and OpenProject stay open for the reason stated under Re-opened Decisions.[ ] Every one of those addons passes the 026 addon lints, including the bridge resolution and the per-addon Playwright spec check.
Translation quality¶
[ ] An external test samples entries per language the way
tests/external/i18n/test_back_translation.pydoes, and asks the decider onescorequestion per sampled entry.[ ] That question carries the source text, the translation and the file the entry was extracted from, and places the answer on an ordered scale whose middle level is “carries the source but distorts a detail”.
[ ] Every sampled entry the decider places below the middle level fails the test, and the report names each one with its language, its catalogue and its source file.
[ ] The test skips rather than fails when the decider is not deployed, the way the other external tests treat an absent service.
Tests and documentation¶
[ ] A Playwright spec translates a known string through
api.translate.<domain>and asserts the response.[ ] A Playwright spec asserts a reviewed Weblate override wins over the engine.
[ ] Unit tests cover the saturation rule, the unreadable log, the blind verdict and the loud failure.
[ ] Each new role carries a
README.mddescribing what it does and how it is wired.[ ] This requirement’s boundary against 037 and 039 is stated in the gateway’s README.
Cross-linking¶
Implementing PR: to be linked