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

snippets/translate-interface-strings/n2.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
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

snippets/translate-interface-strings/n2.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

  1. N0Translation memory: exact match, then a flagged approximate oneFree · Instant
  2. N1Not worth it here
  3. RecommendedN2Self-hosted neural translation model, one language pair at a timeInexpensive · Fast
  4. 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

snippets/translate-interface-strings/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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

snippets/translate-interface-strings/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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

snippets/translate-interface-strings/n3.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
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

snippets/translate-interface-strings/n3.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * 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

Where the figures come from

Metadata