Translate interface strings
Make an application exist in another language, keeping the placeholders and the room available on screen.
AI, but a small model on your own machines
InexpensiveFastYour data stays with you
The verdict
N2 is the recommendation because it is the first level that translates at all. N0 keeps its place in front of it, and most of an interface file stops there, but it has nothing to say about a new string: its test shows exactly that, returning no match rather than a presentable approximation. What N3 adds is the interface context, and it charges your application’s text to a third party, a non-deterministic answer and a provider dependency for it; on the variables the two levels call for precisely the same check on the output, and N2 backs that check with masking, since its model never sees the variable where N3's reads it in the prompt. The self-hosted model keeps the strings on your side, and changes only when you change its weights.
Self-hosted neural translation model, one language pair at a time
AI, but a small model on your own machines
Small self-hosted specialised modelN2InexpensiveFast
Python
"""
Translate with a self-hosted neural model, one pair of languages at a time.
Rung N2. This is the rung that translates: unlike the memory of N0, it returns
an answer for a string that is in no memory. The price is one model per
language pair: this one reads English and writes French.
Most of the code below is not about translating. It is about the interpolation
variables. A translation model reads `{count}` as text: run against
opus-mt-en-fr, `{count} items selected` comes back as
`{compte} éléments sélectionnés`, and the interface prints a brace where a
number should be.
So each variable is swapped for a numbered marker before the model sees the
string, put back afterwards, and counted. Moving a marker is allowed — word
order is the model's job. Losing or inventing one is reported, and the caller
gets a flagged draft instead of a broken interface. An ICU plural or select
message always goes to review: its branches hold text to translate next to
keywords to keep, and one marker cannot separate the two.
"""
from __future__ import annotations
import re
from types import SimpleNamespace
MODEL_NAME = "Helsinki-NLP/opus-mt-en-fr"
# The variable forms an interface uses: {{count}} (i18next), {count}, {}, the
# head of an ICU argument such as {count, plural, ...}, %s, %d, %(count)s, the
# numbered %1$s of Android, and the %@, %1$@ and %ld of iOS.
PLACEHOLDER = re.compile(
r"\{\{\s*[A-Za-z0-9_.]+\s*\}\}|\{[A-Za-z0-9_]*\}|\{\s*[A-Za-z0-9_]+\s*,\s*[A-Za-z]+"
r"|%(?:\([A-Za-z0-9_]+\)|\d+\$)?l{0,2}[sd@]"
)
# The stand-in the model sees instead of a variable. Its pieces are in the
# model's vocabulary, so the model can write it back; a marker made of
# characters the vocabulary lacks is dropped. Check again if you change model.
MARK = "[{}]"
class TranslationUnavailable(Exception):
"""The model failed every attempt, or returned nothing usable."""
def load_translator(name: str = MODEL_NAME):
"""The real model: weights on disk, loaded once, run locally."""
# transformers 5 removed the "translation" pipeline: call the model itself.
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer # pragma: no cover
tokenizer = AutoTokenizer.from_pretrained(name)
model = AutoModelForSeq2SeqLM.from_pretrained(name)
def generate(text: str) -> str:
output = model.generate(**tokenizer([text], return_tensors="pt"))
return tokenizer.decode(output[0], skip_special_tokens=True)
return SimpleNamespace(generate=generate)
def placeholders(text: str) -> list[str]:
"""The interpolation variables, in the order they appear."""
return PLACEHOLDER.findall(text)
def translate(source: str, model=None, *, attempts: int = 2) -> dict:
"""
Translate one interface string, and check what came back.
`model` is injected so this can be tested without loading the weights.
Left alone, it is the real one above.
"""
if not source.strip():
return {"target": source, "review": False, "warnings": []}
model = load_translator() if model is None else model
variables = placeholders(source)
masked = source
for index, variable in enumerate(variables):
masked = masked.replace(variable, MARK.format(index), 1)
target = _generate(model, masked, attempts).strip()
for index, variable in enumerate(variables):
target = target.replace(MARK.format(index), variable)
warnings = []
found = placeholders(target)
if sorted(found) != sorted(variables):
warnings.append(
"variables differ from the source: expected "
+ (" ".join(variables) or "none") + ", got " + (" ".join(found) or "none")
)
if any("," in variable for variable in variables):
warnings.append("ICU message: check its branches by hand")
return {"target": target, "review": bool(warnings), "warnings": warnings}
def _generate(model, text: str, attempts: int) -> str:
"""A local model still fails: out of memory, a worker that died, a batch."""
last_error: Exception | None = None
for _ in range(attempts):
try:
output = model.generate(text)
except Exception as error: # noqa: BLE001 - any model failure is retried
last_error = error
continue
if isinstance(output, str) and output.strip():
return output
last_error = ValueError("the model returned no translation text")
raise TranslationUnavailable(str(last_error))JavaScript
/**
* Translate with a self-hosted neural model, one pair of languages at a time.
*
* Rung N2. This is the rung that translates: unlike the memory of N0, it
* returns an answer for a string that is in no memory. The price is one model
* per language pair: this one reads English and writes French.
*
* Most of the code below is not about translating. It is about the
* interpolation variables. A translation model reads `{count}` as text: run
* against opus-mt-en-fr, `{count} items selected` comes back as
* `{compte} éléments sélectionnés`, and the interface prints a brace where a
* number should be.
*
* So each variable is swapped for a numbered marker before the model sees the
* string, put back afterwards, and counted. Moving a marker is allowed — word
* order is the model's job. Losing or inventing one is reported, and the
* caller gets a flagged draft instead of a broken interface. An ICU plural or
* select message always goes to review: its branches hold text to translate
* next to keywords to keep, and one marker cannot separate the two.
*/
export const MODEL_NAME = 'Xenova/opus-mt-en-fr';
// The variable forms an interface uses: {{count}} (i18next), {count}, {}, the
// head of an ICU argument such as {count, plural, ...}, %s, %d, %(count)s, the
// numbered %1$s of Android, and the %@, %1$@ and %ld of iOS.
const PLACEHOLDER =
/\{\{\s*[A-Za-z0-9_.]+\s*\}\}|\{[A-Za-z0-9_]*\}|\{\s*[A-Za-z0-9_]+\s*,\s*[A-Za-z]+|%(?:\([A-Za-z0-9_]+\)|\d+\$)?l{0,2}[sd@]/g;
// The stand-in the model sees instead of a variable. Its pieces are in the
// model's vocabulary, so the model can write it back; a marker made of
// characters the vocabulary lacks is dropped. Check again if you change model.
const mark = (index) => `[${index}]`;
export class TranslationUnavailable extends Error {}
/** The real model: weights on disk, loaded once, run locally. */
export async function loadTranslator(name = MODEL_NAME) {
const { pipeline } = await import('@huggingface/transformers');
const pipe = await pipeline('translation', name);
return { generate: async (text) => (await pipe(text))[0].translation_text };
}
/** The interpolation variables, in the order they appear. */
export function placeholders(text) {
return [...text.matchAll(PLACEHOLDER)].map((m) => m[0]);
}
/**
* Translate one interface string, and check what came back.
*
* @param {string} source
* @param {object} options
* @param {{generate: Function}} [options.model] injected so this can be
* tested without loading the weights; defaults to the real one above
* @param {number} [options.attempts]
*/
export async function translate(source, { model, attempts = 2 } = {}) {
if (source.trim() === '') return { target: source, review: false, warnings: [] };
const translator = model ?? (await loadTranslator());
const variables = placeholders(source);
let masked = source;
variables.forEach((variable, index) => {
masked = masked.replace(variable, mark(index));
});
let target = (await generate(translator, masked, attempts)).trim();
variables.forEach((variable, index) => {
target = target.split(mark(index)).join(variable);
});
const warnings = [];
const found = placeholders(target);
if (String([...found].sort()) !== String([...variables].sort())) {
warnings.push(
`variables differ from the source: expected ${variables.join(' ') || 'none'}` +
`, got ${found.join(' ') || 'none'}`,
);
}
if (variables.some((variable) => variable.includes(','))) {
warnings.push('ICU message: check its branches by hand');
}
return { target, review: warnings.length > 0, warnings };
}
/** A local model still fails: out of memory, a worker that died, a batch. */
async function generate(model, text, attempts) {
let lastError;
for (let i = 0; i < attempts; i += 1) {
let output;
try {
output = await model.generate(text);
} catch (error) {
lastError = error;
continue;
}
if (typeof output === 'string' && output.trim()) return output;
lastError = new Error('the model returned no translation text');
}
throw new TranslationUnavailable(String(lastError));
}Try it
This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.
The translation the model returns is simulated by the local double the tests use. What is computed here is what the code sends it, what it retries, what it puts back and what it refuses to ship.
A button label, no variable
Given
Save changes
What it returns
Enregistrer les modifications
What it concludes
Translation accepted
Every variable of the source came back, those and no others.
The model received “Save changes” — 1 call.
A string with a number in it
Given
{count} items selected
What it returns
{count} éléments sélectionnés
What it concludes
Translation accepted
Every variable of the source came back, those and no others.
The model received “[0] items selected” — 1 call.
Two variables, and French puts them the other way round
Given
Delete {count} of {total}
What it returns
Sur {total}, supprimer {count}
What it concludes
Translation accepted
Every variable of the source came back, those and no others.
The model received “Delete [0] of [1]” — 1 call.
The model worker dies on the first call
Given
the same label, but the first call to the model fails
What it returns
Enregistrer
What it concludes
Translation accepted
Every variable of the source came back, those and no others.
The model received “Save” — 2 calls.
The model returns nothing but spaces
Given
the same label; the model answers whitespace twice
What it returns
Translation refused
The model returned nothing usable. The code raises rather than shipping a blank label.
The model received “Save” — 2 calls.
The model loses the variable on the way
Given
the same string; the model answers “Des éléments sélectionnés”
What it returns
Des éléments sélectionnés
What it concludes
Sent back for review
The snippet reports the mismatch: variables differ from the source: expected {count}, got none.
The model received “[0] items selected” — 1 call.
Here it breaksThe marker “[0]” did not come back: the French sentence is correct, and there is no longer a number in it. The snippet cannot stop the model losing it — it can only refuse to call the result a finished translation, which is what it does here. This answer is written by the local double, not obtained from the real model.See the level above
What this means for you
- Your data stays on your own machines
- Always the same result
- Measured on a sample, not case by case
- Depends on a library
- Moderate footprint
What this adds to your regulatory scope
- Processing on your own infrastructure: the strings are handed to nobody
- Model licence to comply with: Apache 2.0 for opus-mt-en-fr
Breaking point
An ICU message breaks for good: its branches mix text to translate with keywords to keep. Run outside the tests on the ICU plural of “{count} items”, the real model turns the two keywords that must stay in English into “un” and “autres”, and the snippet, which cannot protect them, sends every ICU message back for review. The ordinary variable does hold: it travels as a numbered marker — the model is handed “[0] items selected”, never “{count}” — and the snippet checks that it comes back, on answers a local double writes.
When to move up a level
Your corrections stop being about the language and start being about the use: Save translated as the verb when it is a button label, an administrator’s register where the interface speaks to a customer, a sentence where there is room for one word.
The four options
- N0Translation memory: exact match, then a flagged approximate oneFree · Instant
- N1Not worth it here
- RecommendedN2Self-hosted neural translation model, one language pair at a timeInexpensive · Fast
- N3A general-purpose model call, carrying the interface contextélevé · About a second
The other options
N0Translation memory: exact match, then a flagged approximate oneFree · Instant
Translation memory: exact match, then a flagged approximate one
Rule and classic algorithmN0FreeInstant
Python
"""
Reuse the translations you already paid for: a translation memory.
Rung N0. No model, no service, no key. A string already in the memory gets its
translation back, and the cheapest translation is the one you do not order
twice.
Three answers, and the difference between them matters more than the code.
An exact match ships: the same string, give or take its spacing, invisible
format characters and Unicode composition. A close enough string is an
approximate match — a word added, a capital or an accent changed — and it is
a draft: it comes back with its score and a review flag, never as a finished
translation, because "Polish" and "polish" are not the same word. Anything
else is new text, which this rung has nothing to say about — see the breaking
point in the test.
Interpolation variables are checked apart from the score. A translation whose
variables do not match the source is a broken interface, however high it
scores, so it is flagged even on an exact hit. An ICU plural or select message
is checked on its argument name and type, not on the branches inside it.
A string longer than MAX_FUZZY_CHARACTERS gets exact matches only: scoring two
long texts costs of the order of the product of their lengths, for every entry
of the memory.
"""
from __future__ import annotations
import re
import unicodedata
from difflib import SequenceMatcher
# The variable forms an interface uses: {{count}} (i18next), {count}, {}, the
# head of an ICU argument such as {count, plural, ...}, %s, %d, %(count)s, the
# numbered %1$s of Android, and the %@, %1$@ and %ld of iOS.
PLACEHOLDER = re.compile(
r"\{\{\s*[A-Za-z0-9_.]+\s*\}\}|\{[A-Za-z0-9_]*\}|\{\s*[A-Za-z0-9_]+\s*,\s*[A-Za-z]+"
r"|%(?:\([A-Za-z0-9_]+\)|\d+\$)?l{0,2}[sd@]"
)
MAX_FUZZY_CHARACTERS = 500
def placeholders(text: str) -> list[str]:
"""The interpolation variables of a string, sorted so order does not count."""
return sorted(PLACEHOLDER.findall(text))
def exact_key(text: str) -> str:
"""Fold only what cannot change the words: composition, format characters, spacing."""
composed = unicodedata.normalize("NFC", text)
visible = "".join(c for c in composed if unicodedata.category(c) != "Cf")
return " ".join(visible.split())
def normalise(text: str) -> str:
"""Fold case, accents and spacing for the score. A match on this alone is reviewed."""
stripped = unicodedata.normalize("NFKD", exact_key(text))
without_accents = "".join(c for c in stripped if not unicodedata.category(c).startswith("M"))
return " ".join(without_accents.lower().split())
def index(memory: dict[str, str]) -> dict[str, tuple[str, str]]:
"""
The memory keyed by what an exact match compares.
Build it once for a whole file and pass it to every `lookup`: most of an
interface does not change from one version to the next, so most strings
are found here, and scoring is what costs.
"""
return {exact_key(known): (known, target) for known, target in memory.items()}
def lookup(source: str, memory: dict[str, str], threshold: float = 0.75, exact=None) -> dict:
"""
Look a string up in a memory of `source: target` pairs already translated.
Returns the status (`exact`, `fuzzy` or `none`), the target when there is
one, the score, and whether a human has to look at it.
The exact match is settled first, by lookup in a dictionary, and only a
string that is not in the memory is scored against every entry of it.
Scoring a string that was already there is the expensive way to learn
nothing: `exact` is that dictionary, built here when the caller does not
hand one in.
"""
key, folded = exact_key(source), normalise(source)
found = (index(memory) if exact is None else exact).get(key)
if found is not None:
return _decide("exact", source, found[0], found[1], 1.0)
best_source, best_target, best_score = None, None, 0.0
for known, target in memory.items():
candidate = normalise(known)
if max(len(folded), len(candidate)) > MAX_FUZZY_CHARACTERS:
continue
# autojunk=False so a long string scores exactly like the JavaScript
# version of this snippet, which has no such heuristic.
score = SequenceMatcher(None, folded, candidate, autojunk=False).ratio()
if score > best_score:
best_source, best_target, best_score = known, target, score
if best_score >= threshold:
return _decide("fuzzy", source, best_source, best_target, best_score)
return {"status": "none", "target": None, "score": best_score,
"matched": None, "review": False, "warnings": []}
def _decide(status: str, source: str, matched: str, target: str, score: float) -> dict:
"""Assemble the answer, and never let a fuzzy hit pass as a finished one."""
warnings = []
if placeholders(target) != placeholders(source):
warnings.append("interpolation variables differ from the source string")
return {"status": status, "target": target, "score": score, "matched": matched,
"review": status == "fuzzy" or bool(warnings), "warnings": warnings}JavaScript
/**
* Reuse the translations you already paid for: a translation memory.
*
* Rung N0. No model, no service, no key. A string already in the memory gets
* its translation back, and the cheapest translation is the one you do not
* order twice.
*
* Three answers, and the difference between them matters more than the code.
* An exact match ships: the same string, give or take its spacing, invisible
* format characters and Unicode composition. A close enough string is an
* approximate match — a word added, a capital or an accent changed — and it is
* a draft: it comes back with its score and a review flag, never as a finished
* translation, because "Polish" and "polish" are not the same word. Anything
* else is new text, which this rung has nothing to say about — see the
* breaking point in the test.
*
* Interpolation variables are checked apart from the score. A translation
* whose variables do not match the source is a broken interface, however high
* it scores, so it is flagged even on an exact hit. An ICU plural or select
* message is checked on its argument name and type, not on the branches inside
* it.
*
* A string longer than MAX_FUZZY_CHARACTERS gets exact matches only: scoring
* two long texts costs of the order of the product of their lengths, for every
* entry of the memory.
*/
// The variable forms an interface uses: {{count}} (i18next), {count}, {}, the
// head of an ICU argument such as {count, plural, ...}, %s, %d, %(count)s, the
// numbered %1$s of Android, and the %@, %1$@ and %ld of iOS.
const PLACEHOLDER =
/\{\{\s*[A-Za-z0-9_.]+\s*\}\}|\{[A-Za-z0-9_]*\}|\{\s*[A-Za-z0-9_]+\s*,\s*[A-Za-z]+|%(?:\([A-Za-z0-9_]+\)|\d+\$)?l{0,2}[sd@]/g;
// Python's str.split() also splits on these; JavaScript's \s does not.
const SPACES = /[\s\x1c-\x1f\x85]+/;
export const MAX_FUZZY_CHARACTERS = 500;
/** The interpolation variables of a string, sorted so order does not count. */
export function placeholders(text) {
return [...text.matchAll(PLACEHOLDER)].map((m) => m[0]).sort();
}
/** Fold only what cannot change the words: composition, format characters, spacing. */
export function exactKey(text) {
return text.normalize('NFC').replace(/\p{Cf}/gu, '').split(SPACES).filter(Boolean).join(' ');
}
/** Fold case, accents and spacing for the score. A match on this alone is reviewed. */
export function normalise(text) {
return exactKey(text).normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().split(SPACES).filter(Boolean).join(' ');
}
/**
* The memory keyed by what an exact match compares.
*
* Build it once for a whole file and pass it to every `lookup`: most of an
* interface does not change from one version to the next, so most strings are
* found here, and scoring is what costs.
*/
export function index(memory) {
return new Map(Object.entries(memory).map(([known, target]) => [exactKey(known), [known, target]]));
}
/**
* Look a string up in a memory of `source: target` pairs already translated.
*
* Returns the status (`exact`, `fuzzy` or `none`), the target when there is
* one, the score, and whether a human has to look at it.
*
* The exact match is settled first, by lookup in a map, and only a string that
* is not in the memory is scored against every entry of it. Scoring a string
* that was already there is the expensive way to learn nothing: `exact` is that
* map, built here when the caller does not hand one in.
*/
export function lookup(source, memory, threshold = 0.75, exact = null) {
const key = exactKey(source);
const folded = normalise(source);
const found = (exact ?? index(memory)).get(key);
if (found) return decide('exact', source, found[0], found[1], 1);
let best = { source: null, target: null, score: 0 };
for (const [known, target] of Object.entries(memory)) {
const candidate = normalise(known);
// Lengths in code points, as in Python.
if (Math.max([...folded].length, [...candidate].length) > MAX_FUZZY_CHARACTERS) continue;
const score = ratio(folded, candidate);
if (score > best.score) best = { source: known, target, score };
}
if (best.score >= threshold) return decide('fuzzy', source, best.source, best.target, best.score);
return { status: 'none', target: null, score: best.score, matched: null, review: false, warnings: [] };
}
/** Assemble the answer, and never let a fuzzy hit pass as a finished one. */
function decide(status, source, matched, target, score) {
const warnings = [];
if (String(placeholders(target)) !== String(placeholders(source))) {
warnings.push('interpolation variables differ from the source string');
}
return {
status, target, score, matched,
review: status === 'fuzzy' || warnings.length > 0,
warnings,
};
}
/**
* Ratcliff-Obershelp similarity, the algorithm behind Python's
* difflib.SequenceMatcher.ratio: twice the number of matching characters over
* the total length. Ported here so both versions of this snippet return the
* very same score on the very same pair.
*/
export function ratio(a, b) {
const left = [...a];
const right = [...b];
const total = left.length + right.length;
if (total === 0) return 1;
return (2 * matchedCount(left, right, 0, left.length, 0, right.length)) / total;
}
/** Longest common block, then the same search left and right of it. */
function matchedCount(a, b, alo, ahi, blo, bhi) {
let bestI = alo, bestJ = blo, bestSize = 0;
let lengths = new Map();
for (let i = alo; i < ahi; i += 1) {
const next = new Map();
for (let j = blo; j < bhi; j += 1) {
if (a[i] !== b[j]) continue;
const size = (lengths.get(j - 1) ?? 0) + 1;
next.set(j, size);
// Strictly greater: the earliest block wins a tie, as in difflib.
if (size > bestSize) [bestI, bestJ, bestSize] = [i - size + 1, j - size + 1, size];
}
lengths = next;
}
if (bestSize === 0) return 0;
return bestSize
+ matchedCount(a, b, alo, bestI, blo, bestJ)
+ matchedCount(a, b, bestI + bestSize, ahi, bestJ + bestSize, bhi);
}What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- No provider in the loop
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the strings and their memory stay with you
Breaking point
A memory reuses, it does not translate. The latency class above is the ordinary case, an unchanged string found in the exact index; a string that is absent is scored against every entry of the memory instead, and that search runs into tens of milliseconds over a thousand strings, more when your strings are long. The test hands it “Two-factor authentication is required for administrators”: nothing in the memory comes close, and the function returns no match rather than the French sentence it happens to be holding.
When to move up a level
The queue of unmatched strings grows faster than your translators empty it.
N1Not worth it here
Not worth it here
Lightweight classic modelN1
A statistical translation system is trained on a corpus in your language pair, aligned sentence by sentence, which has to be gathered before the first translation. The model on the level above comes already trained, on the aligned corpora of the OPUS project.
N3A general-purpose model call, carrying the interface contextélevé · About a second
A general-purpose model call, carrying the interface context
General-purpose LLM APIN3élevéAbout a second
Python
"""
Translate by asking a general-purpose model, with the interface context.
Rung N3. What this rung buys over a translation model is the context: a
translator model gets a string and nothing else, while a general-purpose model
can be told that `Save` is the label of a button and not the verb in a
sentence, that the interface is addressed to a customer rather than an
administrator, and that there is no room for a full sentence.
What it costs is everything around the call: a key, a provider that answers
prose when JSON was asked for, retries, a cap on the input, and the same
variable check as the rung below, because being asked to keep `{count}`
verbatim is not the same as doing it.
"""
from __future__ import annotations
import json
import re
PROMPT = (
"Translate the user interface string below into {language}.\n"
"Where it appears in the interface: {context}\n"
"Keep these interpolation variables exactly as written: {variables}\n"
"Keep the length of an interface label, not of a sentence.\n"
'Answer with JSON only: {{"translation": "..."}}\n\n'
"String:\n{source}"
)
# An interface string that no longer fits on one screen is not an interface
# string. Refusing it here, and a context as long, is a cost control.
MAX_CHARACTERS = 2000
# The variable forms an interface uses: {{count}} (i18next), {count}, {}, the
# head of an ICU argument such as {count, plural, ...}, %s, %d, %(count)s, the
# numbered %1$s of Android, and the %@, %1$@ and %ld of iOS.
PLACEHOLDER = re.compile(
r"\{\{\s*[A-Za-z0-9_.]+\s*\}\}|\{[A-Za-z0-9_]*\}|\{\s*[A-Za-z0-9_]+\s*,\s*[A-Za-z]+"
r"|%(?:\([A-Za-z0-9_]+\)|\d+\$)?l{0,2}[sd@]"
)
# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., temperature=...)` method.
MODEL = "gpt-4.1-mini" # an example id: check the parameters your model accepts
class ProviderClient:
"""The one call this snippet makes, on top of the provider's SDK."""
def __init__(self, sdk=None, model: str = MODEL):
if sdk is None: # pragma: no cover - needs a key and a network
from openai import OpenAI
sdk = OpenAI()
self.sdk, self.model = sdk, model
def complete(self, *, prompt: str, temperature: float) -> str:
response = self.sdk.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
)
return response.choices[0].message.content
class TranslationUnavailable(Exception):
"""The provider could not be reached, or answered something unusable."""
def placeholders(text: str) -> list[str]:
"""The interpolation variables, sorted so a moved one still matches."""
return sorted(PLACEHOLDER.findall(text))
def translate(source: str, language: str, *, context: str = "",
client=None, attempts: int = 3) -> dict:
"""
Translate one interface string, with what the model needs to know about it.
`client` is injected so this function can be tested without a network
call. In production it defaults to a real provider client.
"""
if len(source) > MAX_CHARACTERS or len(context) > MAX_CHARACTERS:
raise ValueError(f"string or context longer than {MAX_CHARACTERS} characters")
if not source.strip(): # nothing to translate is not worth a call
return {"target": source, "review": False, "warnings": []}
client = client or ProviderClient()
variables = placeholders(source)
prompt = PROMPT.format(
language=language,
context=context or "not given",
variables=" ".join(variables) or "none",
source=source,
)
target = _ask(client, prompt, attempts)
warnings = []
if placeholders(target) != variables:
warnings.append("the model did not keep the interpolation variables")
if any("," in variable for variable in variables):
warnings.append("ICU message: check its branches by hand")
return {"target": target, "review": bool(warnings), "warnings": warnings}
def _ask(client, prompt: str, attempts: int) -> str:
last_error: Exception | None = None
for _ in range(attempts):
try:
# The lowest temperature: the SDK documents lower values as more
# focused and deterministic.
answer = client.complete(prompt=prompt, temperature=0)
if not isinstance(answer, str): # a refusal comes back as no content
last_error = ValueError("the model answered no text")
continue
text = answer.strip()
# A JSON answer wrapped whole in one code fence is read; nothing else is.
if text.startswith("```") and text.endswith("```") and text.count("```") == 2:
text = text[3:-3].removeprefix("json")
parsed = json.loads(text)
target = parsed.get("translation") if isinstance(parsed, dict) else None
if isinstance(target, str) and target.strip():
return target.strip()
last_error = ValueError("the model answered without a translation")
except Exception as error: # noqa: BLE001 - any provider failure is retried
last_error = error
raise TranslationUnavailable(str(last_error))JavaScript
/**
* Translate by asking a general-purpose model, with the interface context.
*
* Rung N3. What this rung buys over a translation model is the context: a
* translator model gets a string and nothing else, while a general-purpose
* model can be told that `Save` is the label of a button and not the verb in
* a sentence, that the interface is addressed to a customer rather than an
* administrator, and that there is no room for a full sentence.
*
* What it costs is everything around the call: a key, a provider that answers
* prose when JSON was asked for, retries, a cap on the input, and the same
* variable check as the rung below, because being asked to keep `{count}`
* verbatim is not the same as doing it.
*/
const PROMPT = [
'Translate the user interface string below into {language}.',
'Where it appears in the interface: {context}',
'Keep these interpolation variables exactly as written: {variables}',
'Keep the length of an interface label, not of a sentence.',
'Answer with JSON only: {"translation": "..."}',
'',
'String:',
'{source}',
].join('\n');
// An interface string that no longer fits on one screen is not an interface
// string. Refusing it here, and a context as long, is a cost control.
export const MAX_CHARACTERS = 2000;
// The variable forms an interface uses: {{count}} (i18next), {count}, {}, the
// head of an ICU argument such as {count, plural, ...}, %s, %d, %(count)s, the
// numbered %1$s of Android, and the %@, %1$@ and %ld of iOS.
const PLACEHOLDER =
/\{\{\s*[A-Za-z0-9_.]+\s*\}\}|\{[A-Za-z0-9_]*\}|\{\s*[A-Za-z0-9_]+\s*,\s*[A-Za-z]+|%(?:\([A-Za-z0-9_]+\)|\d+\$)?l{0,2}[sd@]/g;
// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts
export async function providerClient(sdk, model = MODEL) {
if (!sdk) {
const { OpenAI } = await import('openai');
sdk = new OpenAI();
}
return {
async complete({ prompt, temperature }) {
const response = await sdk.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
temperature,
});
return response.choices[0].message.content;
},
};
}
export class TranslationUnavailable extends Error {}
/** The interpolation variables, sorted so a moved one still matches. */
export function placeholders(text) {
return [...text.matchAll(PLACEHOLDER)].map((m) => m[0]).sort();
}
/**
* Translate one interface string, with what the model needs to know about it.
*
* @param {string} source
* @param {string} language
* @param {object} options
* @param {string} [options.context] where the string appears in the interface
* @param {{complete: Function}} [options.client] injected so this can be
* tested without a network call; defaults to a real provider client
* @param {number} [options.attempts]
*/
export async function translate(source, language, { context = '', client, attempts = 3 } = {}) {
// Characters are counted as code points, as in Python: an emoji is one.
if ([...source].length > MAX_CHARACTERS || [...context].length > MAX_CHARACTERS) {
throw new RangeError(`string or context longer than ${MAX_CHARACTERS} characters`);
}
// Nothing to translate is not worth a call.
if (source.trim() === '') return { target: source, review: false, warnings: [] };
client ??= await providerClient();
const variables = placeholders(source);
const prompt = fill(PROMPT, {
language,
context: context || 'not given',
variables: variables.join(' ') || 'none',
source,
});
const target = await ask(client, prompt, attempts);
const warnings = [];
if (String(placeholders(target)) !== String(variables)) {
warnings.push('the model did not keep the interpolation variables');
}
if (variables.some((variable) => variable.includes(','))) {
warnings.push('ICU message: check its branches by hand');
}
return { target, review: warnings.length > 0, warnings };
}
/**
* Fill the prompt fields. The replacer is a function on purpose: a string
* replacement would give a `$` inside an interface string, such as the one a
* numbered variable carries, a meaning it does not have.
*/
function fill(template, fields) {
return Object.entries(fields).reduce(
(text, [key, value]) => text.replace(`{${key}}`, () => value),
template,
);
}
async function ask(client, prompt, attempts) {
let lastError;
for (let i = 0; i < attempts; i += 1) {
try {
const answer = await client.complete({
prompt,
// The lowest temperature: the SDK documents lower values as more
// focused and deterministic.
temperature: 0,
});
if (typeof answer !== 'string') {
// A refusal comes back as no content.
lastError = new Error('the model answered no text');
continue;
}
let text = answer.trim();
// A JSON answer wrapped whole in one code fence is read; nothing else is.
if (text.startsWith('```') && text.endsWith('```') && text.split('```').length === 3) {
text = text.slice(3, -3).replace(/^json/, '');
}
const parsed = JSON.parse(text);
const target = parsed && typeof parsed === 'object' ? parsed.translation : null;
if (typeof target === 'string' && target.trim()) return target.trim();
lastError = new Error('the model answered without a translation');
} catch (error) {
lastError = error;
}
}
throw new TranslationUnavailable(String(lastError));
}What this means for you
- Your data goes to a third party
- The result varies from one call to the next
- Hard to test
- Depends on an outside provider
- High footprint
What this adds to your regulatory scope
- Transfer of your interface text to a processor, including labels of features you have not shipped yet
- Processing location to be confirmed with the provider
- Possible reuse of the requests by the provider, under contractual terms that are the provider’s own
Breaking point
Asking is not getting, and two tests show it — by writing the answer themselves, into a local double, since no test can order a bad answer from a real provider. The instruction asks for JSON: the answer handed back is “Sure! In French, Save is « Enregistrer ».”, a courtesy sentence you do not put on a button, and the snippet raises rather than return it. The instruction then asks for {count} to be kept exactly as written, and this model does see it plainly, in the prompt: the answer handed back translates it as {compte}, and only the check on the output catches it. What is proved is that check, not the model’s obedience.
When to move up a level
There is no level above this one.
Further reading
- Helsinki-NLP/opus-mt-en-fr — la fiche du modèle employé au niveau N2
- Moses — le système de référence de la traduction statistique, et le corpus aligné qu’il demande
- Python — difflib.SequenceMatcher, l’appariement approché du niveau N0
- XLIFF 2.1 — le format d’échange qui porte une mémoire de traduction et son état de relecture
- ICU — MessageFormat, la forme des variables d’interpolation dans un libellé
Where the figures come from
- Carte du modèle Helsinki-NLP/opus-mt-en-fr : licence Apache 2.0, paire anglais-français
- Xenova/opus-mt-en-fr — la conversion ONNX des mêmes poids, chargée par l’extrait JavaScript
- Modèle exécuté hors des tests pour écrire le point de rupture de N2 : `transformers` 5.17.0 avec `torch` 2.14.0+cpu et `sentencepiece` 0.2.2 côté Python, `@huggingface/transformers` 4.2.0 côté JavaScript. « {count} items selected » revient « {compte} éléments sélectionnés » des deux côtés ; le pluriel ICU « {count, plural, one {# item} other {# items}} » revient avec ses mots-clés traduits en « un » et « autres » ; le marqueur `[N]` est conservé sur vingt libellés sur vingt
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.