Traduire les libellés d’une interface
Faire exister une application dans une autre langue, en gardant les variables et la place disponible à l’écran.
De l’IA, mais un petit modèle chez vous
Peu coûteuxRapideVos données restent chez vous
Le verdict
N2 est recommandé parce que c’est le premier niveau qui traduit. N0 garde sa place devant lui, et l’essentiel d’un fichier d’interface s’y arrête, mais il n’a rien à dire d’une chaîne nouvelle : son test le montre en renvoyant une absence de correspondance plutôt qu’une approximation présentable. Ce que N3 ajoute est le contexte d’interface, et il le fait payer le texte de votre application chez un tiers, une réponse non déterministe et une dépendance de fournisseur ; sur les variables, les deux niveaux réclament le même contrôle en sortie, et N2 le double d’un masquage, puisque son modèle ne voit jamais la variable, là où celui de N3 la lit dans l’invite. Le modèle auto-hébergé garde les chaînes chez vous, et ne change que lorsque vous changez ses poids.
Modèle de traduction neuronale auto-hébergé, une paire de langues à la fois
De l’IA, mais un petit modèle chez vous
Petit modèle spécialisé auto-hébergéN2Peu coûteuxRapide
Python
"""
Traduire avec un modèle neuronal auto-hébergé, une paire de langues à la
fois.
Niveau N2. C'est le niveau qui traduit : contrairement à la mémoire de N0, il
rend une réponse pour une chaîne qui n'est dans aucune mémoire. Le prix, c'est
un modèle par paire de langues : celui-ci lit l'anglais et écrit le français.
L'essentiel du code ci-dessous ne concerne pas la traduction. Il concerne les
variables d'interpolation. Un modèle de traduction lit `{count}` comme du
texte : lancé sur opus-mt-en-fr, `{count} items selected` revient en
`{compte} éléments sélectionnés`, et l'interface affiche une accolade là où
un nombre devait être.
Chaque variable est donc remplacée par un marqueur numéroté avant que le
modèle ne voie la chaîne, remise ensuite, et comptée. Déplacer un marqueur
est permis : l'ordre des mots est l'affaire du modèle. En perdre un ou en
inventer un est signalé, et l'appelant reçoit un brouillon marqué plutôt
qu'une interface cassée. Un message ICU de pluriel ou de sélection part
toujours en relecture : ses branches mêlent du texte à traduire et des
mots-clés à garder, et un marqueur ne sait pas séparer les deux.
"""
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
/**
* Traduire avec un modèle neuronal auto-hébergé, une paire de langues à la
* fois.
*
* Niveau N2. C'est le niveau qui traduit : contrairement à la mémoire de N0, il
* rend une réponse pour une chaîne qui n'est dans aucune mémoire. Le prix, c'est
* un modèle par paire de langues : celui-ci lit l'anglais et écrit le français.
*
* L'essentiel du code ci-dessous ne concerne pas la traduction. Il concerne les
* variables d'interpolation. Un modèle de traduction lit `{count}` comme du
* texte : lancé sur opus-mt-en-fr, `{count} items selected` revient en
* `{compte} éléments sélectionnés`, et l'interface affiche une accolade là où
* un nombre devait être.
*
* Chaque variable est donc remplacée par un marqueur numéroté avant que le
* modèle ne voie la chaîne, remise ensuite, et comptée. Déplacer un marqueur
* est permis : l'ordre des mots est l'affaire du modèle. En perdre un ou en
* inventer un est signalé, et l'appelant reçoit un brouillon marqué plutôt
* qu'une interface cassée. Un message ICU de pluriel ou de sélection part
* toujours en relecture : ses branches mêlent du texte à traduire et des
* mots-clés à garder, et un marqueur ne sait pas séparer les deux.
*/
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));
}Essayer
Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.
La traduction rendue par le modèle est simulée par le double local qui sert aux tests. Ce qui est calculé ici, c’est ce que le code lui envoie, ce qu’il réessaie, ce qu’il remet en place et ce qu’il refuse de livrer.
Un libellé de bouton, sans variable
Donné
Save changes
Ce qu’il renvoie
Enregistrer les modifications
Ce qu’il en conclut
Traduction acceptée
Les variables de la source sont toutes revenues, celles-là et pas d’autres.
Le modèle a reçu « Save changes » — 1 appel.
Une chaîne avec un nombre dedans
Donné
{count} items selected
Ce qu’il renvoie
{count} éléments sélectionnés
Ce qu’il en conclut
Traduction acceptée
Les variables de la source sont toutes revenues, celles-là et pas d’autres.
Le modèle a reçu « [0] items selected » — 1 appel.
Deux variables, et le français les remet dans l’autre ordre
Donné
Delete {count} of {total}
Ce qu’il renvoie
Sur {total}, supprimer {count}
Ce qu’il en conclut
Traduction acceptée
Les variables de la source sont toutes revenues, celles-là et pas d’autres.
Le modèle a reçu « Delete [0] of [1] » — 1 appel.
Le processus du modèle meurt au premier appel
Donné
le même libellé, mais le premier appel au modèle échoue
Ce qu’il renvoie
Enregistrer
Ce qu’il en conclut
Traduction acceptée
Les variables de la source sont toutes revenues, celles-là et pas d’autres.
Le modèle a reçu « Save » — 2 appels.
Le modèle ne rend que des espaces
Donné
le même libellé ; le modèle répond deux fois par du blanc
Ce qu’il renvoie
Traduction refusée
Le modèle n’a rien rendu d’exploitable. Le code préfère lever une erreur plutôt que de livrer un libellé vide.
Le modèle a reçu « Save » — 2 appels.
Le modèle perd la variable en route
Donné
la même chaîne ; le modèle répond « Des éléments sélectionnés »
Ce qu’il renvoie
Des éléments sélectionnés
Ce qu’il en conclut
Renvoyée en relecture
L’extrait signale l’écart : variables differ from the source: expected {count}, got none.
Le modèle a reçu « [0] items selected » — 1 appel.
Là, ça casseLe marqueur « [0] » n’est pas revenu : la phrase française est correcte, et il n’y a plus de nombre dedans. L’extrait ne peut pas empêcher le modèle de le perdre — il peut seulement refuser d’appeler cela une traduction finie, et c’est ce qu’il fait ici. Cette réponse est écrite par le double local, pas obtenue du vrai modèle.Voir le niveau au-dessus
Ce que ça implique
- Vos données restent chez vous
- Résultat toujours identique
- Se mesure sur un échantillon, pas cas par cas
- Dépend d’une bibliothèque
- Empreinte modérée
Ce que cela ajoute à votre périmètre réglementaire
- Traitement sur votre infrastructure : les chaînes ne sont transmises à personne
- Licence du modèle à respecter : Apache 2.0 pour opus-mt-en-fr
Point de rupture
Un message ICU casse pour de bon : ses branches mêlent du texte à traduire et des mots-clés à garder. Lancé hors des tests sur le pluriel ICU de « {count} items », le vrai modèle traduit en « un » et « autres » les deux mots-clés qui doivent rester en anglais, et l’extrait, qui ne sait pas les protéger, renvoie tout message ICU en relecture. La variable ordinaire, elle, est tenue : elle part en marqueur numéroté — le modèle reçoit « [0] items selected », jamais « {count} » — et l’extrait vérifie qu’il revient, sur des réponses qu’un double local écrit.
Quand passer au niveau suivant
Vos corrections ne portent plus sur la langue mais sur l’emploi : « Save » traduit comme le verbe alors que c’est le libellé d’un bouton, un registre d’administrateur là où l’interface s’adresse à un client, une phrase là où il y a la place d’un mot.
Les quatre options
- N0Mémoire de traduction : correspondance exacte, puis approchée signaléeGratuit · Instantané
- N1Niveau sans objet ici
- RecommandéN2Modèle de traduction neuronale auto-hébergé, une paire de langues à la foisPeu coûteux · Rapide
- N3Appel à un modèle généraliste, avec le contexte d’interfaceélevé · Une seconde
Les autres options
N0Mémoire de traduction : correspondance exacte, puis approchée signaléeGratuit · Instantané
Mémoire de traduction : correspondance exacte, puis approchée signalée
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Réemployer les traductions que vous avez déjà payées : une mémoire de
traduction.
Niveau N0. Aucun modèle, aucun service, aucune clé. Une chaîne déjà dans la
mémoire retrouve sa traduction, et la traduction la moins chère est celle
qu'on ne commande pas deux fois.
Trois réponses, et la différence entre elles compte plus que le code. Une
correspondance exacte part en production : la même chaîne, à l'espacement,
aux caractères de format invisibles et à la composition Unicode près. Une
chaîne assez proche est une correspondance approchée — un mot ajouté, une
majuscule ou un accent changé — et c'est un brouillon : elle revient avec son
score et un drapeau de relecture, jamais comme une traduction finie, parce
que « Polish » et « polish » ne sont pas le même mot. Tout le reste est du
texte nouveau, dont ce niveau n'a rien à dire — voyez le point de rupture
dans le test.
La correspondance exacte est tranchée d'abord, par une recherche dans un
dictionnaire, et seule une chaîne absente de la mémoire est notée contre
toutes ses entrées. Noter une chaîne qui y était déjà, c'est la façon chère de
n'apprendre rien : « index » construit ce dictionnaire, une fois pour tout un
fichier.
Les variables d'interpolation sont vérifiées à part du score. Une traduction
dont les variables ne correspondent pas à la source est une interface cassée,
quel que soit son score : elle est donc signalée même sur une correspondance
exacte. Un message ICU de pluriel ou de sélection est vérifié sur le nom et le
type de son argument, pas sur les branches qu'il contient.
Une chaîne plus longue que MAX_FUZZY_CHARACTERS n'a droit qu'aux
correspondances exactes : comparer deux textes longs coûte de l'ordre du
produit de leurs longueurs, pour chaque entrée de la mémoire.
"""
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
/**
* Réemployer les traductions que vous avez déjà payées : une mémoire de
* traduction.
*
* Niveau N0. Aucun modèle, aucun service, aucune clé. Une chaîne déjà dans la
* mémoire retrouve sa traduction, et la traduction la moins chère est celle
* qu'on ne commande pas deux fois.
*
* Trois réponses, et la différence entre elles compte plus que le code. Une
* correspondance exacte part en production : la même chaîne, à l'espacement,
* aux caractères de format invisibles et à la composition Unicode près. Une
* chaîne assez proche est une correspondance approchée — un mot ajouté, une
* majuscule ou un accent changé — et c'est un brouillon : elle revient avec son
* score et un drapeau de relecture, jamais comme une traduction finie, parce
* que « Polish » et « polish » ne sont pas le même mot. Tout le reste est du
* texte nouveau, dont ce niveau n'a rien à dire — voyez le point de rupture
* dans le test.
*
* La correspondance exacte est tranchée d'abord, par une recherche dans un
* dictionnaire, et seule une chaîne absente de la mémoire est notée contre
* toutes ses entrées. Noter une chaîne qui y était déjà, c'est la façon chère de
* n'apprendre rien : « index » construit ce dictionnaire, une fois pour tout un
* fichier.
*
* Les variables d'interpolation sont vérifiées à part du score. Une traduction
* dont les variables ne correspondent pas à la source est une interface cassée,
* quel que soit son score : elle est donc signalée même sur une correspondance
* exacte. Un message ICU de pluriel ou de sélection est vérifié sur le nom et le
* type de son argument, pas sur les branches qu'il contient.
*
* Une chaîne plus longue que MAX_FUZZY_CHARACTERS n'a droit qu'aux
* correspondances exactes : comparer deux textes longs coûte de l'ordre du
* produit de leurs longueurs, pour chaque entrée de la mémoire.
*/
// 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);
}Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Aucun fournisseur dans la boucle
- Empreinte négligeable
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : les chaînes et leur mémoire restent chez vous
Point de rupture
Une mémoire réemploie, elle ne traduit pas. La classe de temps ci-dessus est celle du cas ordinaire, une chaîne inchangée retrouvée dans l’index exact ; une chaîne absente, elle, est notée contre toutes les entrées de la mémoire, et cette recherche-là se compte en dizaines de millisecondes sur mille chaînes, davantage si vos chaînes sont longues. Le test lui soumet « Two-factor authentication is required for administrators » : rien dans la mémoire ne s’en approche, et la fonction renvoie une absence de correspondance au lieu de la phrase française qu’elle a sous la main.
Quand passer au niveau suivant
La file des chaînes sans correspondance grossit plus vite que vos traducteurs ne la vident.
N1Niveau sans objet ici
Niveau sans objet ici
Modèle classique légerN1
Un système de traduction statistique s’entraîne sur un corpus de votre paire de langues aligné phrase à phrase, qu’il faut réunir avant la première traduction. Le modèle du niveau suivant arrive déjà entraîné, sur les corpus alignés du projet OPUS.
N3Appel à un modèle généraliste, avec le contexte d’interfaceélevé · Une seconde
Appel à un modèle généraliste, avec le contexte d’interface
API de LLM généralisteN3élevéUne seconde
Python
"""
Traduire en interrogeant un modèle généraliste, avec le contexte d'interface.
Niveau N3. Ce que ce niveau apporte face à un modèle de traduction, c'est le
contexte : un modèle traducteur reçoit une chaîne et rien d'autre, tandis qu'à
un modèle généraliste on peut dire que `Save` est le libellé d'un bouton et
non le verbe d'une phrase, que l'interface s'adresse à un client plutôt qu'à
un administrateur, et qu'il n'y a pas la place d'une phrase entière.
Ce qu'il coûte, c'est tout ce qui entoure l'appel : une clé, un fournisseur
qui répond de la prose quand on a demandé du JSON, des reprises, un plafond
sur l'entrée, et le même contrôle des variables qu'au niveau du dessous,
parce que se faire demander de garder `{count}` tel quel n'est pas la même
chose que le faire.
"""
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
/**
* Traduire en interrogeant un modèle généraliste, avec le contexte d'interface.
*
* Niveau N3. Ce que ce niveau apporte face à un modèle de traduction, c'est le
* contexte : un modèle traducteur reçoit une chaîne et rien d'autre, tandis qu'à
* un modèle généraliste on peut dire que `Save` est le libellé d'un bouton et
* non le verbe d'une phrase, que l'interface s'adresse à un client plutôt qu'à
* un administrateur, et qu'il n'y a pas la place d'une phrase entière.
*
* Ce qu'il coûte, c'est tout ce qui entoure l'appel : une clé, un fournisseur
* qui répond de la prose quand on a demandé du JSON, des reprises, un plafond
* sur l'entrée, et le même contrôle des variables qu'au niveau du dessous,
* parce que se faire demander de garder `{count}` tel quel n'est pas la même
* chose que le faire.
*/
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));
}Ce que ça implique
- Vos données partent chez un tiers
- Résultat variable d’un appel à l’autre
- Difficile à tester
- Dépend d’un fournisseur externe
- Empreinte élevée
Ce que cela ajoute à votre périmètre réglementaire
- Transfert à un sous-traitant du texte de votre interface, libellés de fonctionnalités non encore publiées compris
- Localisation du traitement à vérifier auprès du fournisseur
- Réutilisation éventuelle des requêtes par le fournisseur, selon des conditions contractuelles qui sont les siennes
Point de rupture
Demander n’est pas obtenir, et deux tests le montrent — en écrivant la réponse eux-mêmes dans un double local, faute de pouvoir commander une mauvaise réponse à un vrai fournisseur. La consigne réclame du JSON : la réponse rendue est “Sure! In French, Save is « Enregistrer ».”, une phrase de politesse qu’on ne pose pas sur un bouton, et l’extrait lève une erreur plutôt que de la rendre. La consigne réclame ensuite de garder {count} tel quel, et le modèle le voit en clair, lui, dans l’invite : la réponse rendue le traduit en {compte}, et seul le contrôle de sortie l’attrape. Ce qui est prouvé est ce contrôle, pas la docilité du modèle.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus.
Pour aller plus loin
- 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é
Source des chiffres
- 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
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.