En bref
DSPy avec Gemini Turbo permet une traduction fiable au moment de la construction. Le fournisseur de contexte React, le middleware de routage Next et un wrapper de traduction, offrent un pipeline i18n bon marché, rapide et majoritairement fiable avec une DevEx simple. Automatisez-le avec GitHub Actions ; voilà.
L'espace problématique
Si vous souhaitez qu'un produit web atteigne des utilisateurs à l'échelle mondiale, vous avez besoin d'un moyen d'exprimer la même interface dans plusieurs langues sans multiplier l'effort de création par chaque locale prise en charge.
Si vous avez déjà intégré une solution d'internationalisation (i18n) dans une application web, vous connaissez probablement les difficultés des solutions déjà présentes sur le marché.
J'ai utilisé crowdin, which is extremely reliable, but it's expensive, slow (you can expect a 48 hour turnaround), and frankly their API is cumbersome to work with. i18nextd'autre part, est une plateforme open-source populaire pour la traduction automatique, mais je l'ai trouvée fragile pour ce qui revient à une simple exigence architecturale : mapper des clés à des chaînes de caractères.
Au-delà de la nature trop élaborée de ces plateformes, il y a quelque chose à dire sur le fait de s'approprier sa chaîne d'outils, et beaucoup à apprendre sur le fonctionnement de la traduction automatique au cours du processus.
En m'affairant à préparer ce site, j'ai réalisé que j'avais besoin d'une solution plus simple permettant un retour rapide et un moteur de traduction fiable.
J'ai opté pour la traduction automatique au moment de la compilation avec DSPy, Gemini et le routage Next, orchestré par les actions GitHub. Ces contraintes étant exposées, entrons dans les détails.
La solution en 8 heures
Au lieu d'adopter un grand framework i18n d'exécution, j'ai construit un petit pipeline autour d'une idée centrale : l'anglais est la source de vérité, et chaque autre locale est un artefact généré.
À un niveau élevé, le système se compose de quatre parties :
- Un fichier de locale anglaise canonique qui définit le texte de l'application.
- Une étape de génération qui dérive les types et identifie les clés non traduites.
- Un script de traduction par lots qui produit des fichiers JSON de locale au moment de la construction.
- Une couche d'exécution légère — middleware pour le routage de locale et un contexte React pour la recherche dans le dictionnaire.
Le nœud de la décision de conception ici est que la traduction ne nonse produisent au moment de la requête. Au moment où l'application est déployée, les dictionnaires traduits existent déjà. Cela maintient la latence d'exécution constante, évite les appels de modèle en production et rend le système beaucoup plus facile à comprendre.
Pourquoi le temps de compilation a prévalu
Ce n'est pas une application de chat en direct, nous avons donc pu nous débarrasser des préoccupations liées au temps réel. Étant donné que la traduction au moment de la compilation m'a apporté trois choses importantes :
- Prévisibilité :les déploiements produisent des artefacts de locale déterministes.
- Performances :pas d'appels par requête à un modèle ou à une API de traduction.
- Simplicité :l'exécution ne nécessite que la recherche dans le dictionnaire, l'interpolation et la sélection de la locale.
À un niveau élevé, le flux de travail ressemble à ceci :
Rendering diagram...
Nous avons du contenu statique (la page d'accueil du blog) qui est lui-même compilé, il a donc fallu des astuces complexes avec les actions GitHub pour intégrer également le rendu de ce contenu statique, et les blogs Markdown sont une autre histoire.
Mais le résultat net est un système très fiable qui fonctionne essentiellement tout seul. Voyons comment le côté client agit en tant que fournisseur et consommateur du moteur i18n.
Fais comme les Romains (et utilise un modèle de fondation GD)
Le piston central de l'implémentation est un générateur de traduction. Les entrées et les sorties sont de pures chaînes de caractères ; English -> Target Language. Plutôt simple.
Pour générer les clés, nous avons mis en place un analyseur AST avec ts-morphqui fouille notre TypeScript pour notre wrapper i18n, le t()méthode (plus d'informations ci-dessous).
Pour chaque wrapper trouvé, nous extrayons la chaîne enveloppée et la déposons, ainsi que la valeur anglaise canonisée, dans /lib/en.json. Ce fichier sert de
Pour le harnais du modèle DSPybibliothèque servait de coup de grâce, enabling structured derivation with a tight contract for the expected inputs and outputs from the model.
Traducteur
* Notez que le ramasse-miettes, la journalisation et l'analyse du contexte sont omis par souci de concision.
import osimport jsonimport dspyfrom pydantic import BaseModel, Fieldclass TranslationOutput(BaseModel):translations: dict[str, str] = Field(description="A dictionary mapping the english keys to translated strings")class GenerateTranslations(dspy.Signature):"""Generate professional, localized translations for a web application UI."""source_language: str = dspy.InputField(desc="The source language of the given keys.")keys: list[str] = dspy.InputField(desc="A list of english interface text keys to translate.")target_language: str = dspy.InputField(desc="The target language code (e.g., 'es', 'fr', 'de').")translations: TranslationOutput = dspy.OutputField(desc="A strictly structured JSON output of translations.")class TranslationGenerator(dspy.Module):def __init__(self):super().__init__()PredictComponent = getattr(dspy, "TypedPredictor", dspy.Predict)self.generator = PredictComponent(GenerateTranslations)def forward(self, keys: list[str], target_language: str) -> dict:"""Generates translations from English to the target language for all keys in en.json"""if not os.environ.get("GEMINI_API_KEY"):print(f"Skipping LLM call for {target_language} (no API key). Using mock translations.")return {key: f"[{target_language}] {key}" for key in keys}result = self.generator(source_language="English", keys=keys, target_language=target_language)# Defensively parse out the response from Geminitry:translations = getattr(result, "translations", None)if hasattr(translations, "model_dump"):return translations.model_dump().get("translations", {})elif hasattr(translations, "dict"):return translations.dict().get("translations", {})elif hasattr(translations, "translations"):return translations.translationselif isinstance(translations, dict):return translations.get("translations", translations)else:return {}except Exception as e:print(f"Failed to extract translations for {target_language}. Error: {e}")return {key: f"[{target_language}] {key}" for key in keys}TARGET_LANGUAGES = {"es": "Spanish","fr": "French","de": "German"}def main():generator = TranslationGenerator()current_dir = os.path.dirname(os.path.abspath(__file__))locales_dir = os.path.join(os.path.dirname(os.path.dirname(current_dir)), "packages", "i18n", "locales")os.makedirs(locales_dir, exist_ok=True)en_json_path = os.path.join(locales_dir, "en.json")with open(en_json_path, 'r', encoding='utf-8') as f:en_locale = json.load(f)english_keys = list(en_locale.keys())print(f"Loaded {len(english_keys)} keys from en.json")for lang_code, lang_name in TARGET_LANGUAGES.items():lang_json_path = os.path.join(locales_dir, f"{lang_code}.json")existing_translations = {}if os.path.exists(lang_json_path):with open(lang_json_path, 'r', encoding='utf-8') as f:existing_translations = json.load(f)# Find missing keysmissing_keys = [k for k in english_keys if k not in existing_translations]# Batch LLM processingbatches = batch_missing_keys(missing_keys, 50)for i, batch in enumerate(batches):# calls the `forward` methodnew_translations = generator(keys=batch, target_language=lang_code)# Merge translationsfor k in batch:if k in new_translations:existing_translations[k] = new_translations[k]else:existing_translations[k] = k # fallback to ascii englishwith open(lang_json_path, 'w', encoding='utf-8') as f:json.dump(existing_translations, f, indent=2, ensure_ascii=False)
La logique est simple : parcourez les clés anglaises et, pour chaque langue cible, demandez au modèle de générer sa meilleure estimation de traduction, en la stockant dans le fichier JSON associé. Plutôt astucieux.
Cependant, il y a un hic à confier les responsabilités de la TA à un LLM. Malgré le typage strict de notre harnais DSPy et la contractualisation Pydantic, il n'y a aucune garantie que le modèle nous donnera ce que nous voulons. Pour tenir compte du caractère probabiliste
Le traducteur étant écarté, tournons notre attention vers le côté client pour mieux comprendre comment nous servons notre prose polyglotte.
Le Contexte est Roi
Avec le traducteur en place, nous avions besoin d'un harnais sémantique capable de capturer le contenu et d'injecter dynamiquement la traduction pertinente.
Nous avons construit un I18nProviderqui enveloppe la charge utile de notre application. Un contexte React léger fournit la locale, tandis que les chaînes de traduction réelles sont récupérées côté serveur lorsque cela est possible ou chargées initialement.
Le nœud du problème est un wrapper i18n, t( key, fallback, values ).
Il définit une clé, qui par défaut est le contenu passé, une valeur de repli, si aucune traduction ne peut être trouvée, et le valuesnous donnent la capacité d'interpoler du contenu dynamique dans cette chaîne, si nous le souhaitons.
Examinons l'implémentation de plus près.
Traduction côté serveur
// /i18n/provider.ts'use client';import type React from 'react';import { createContext, useContext } from 'react';import type { ReactNode } from 'react';import { interpolate } from './interpolate';import type {I18nContextType,LocaleCode,Translations,} from './types';const I18nContext = createContext<I18nContextType | null>(null);export interface I18nProviderProps {children: ReactNode;defaultLocale?: LocaleCode;dictionary?: Translations;}export const I18nProvider: React.FC<I18nProviderProps> = ({children,defaultLocale = 'en',dictionary,}) => {const t = (key: string,fallbackOrValues?: string | Record<string, string | number>,values?: Record<string, string | number>,): string => {let fallback = key;let interpolationValues = values;if (typeof fallbackOrValues === 'string') {fallback = fallbackOrValues;} else if (fallbackOrValues !== undefined) {interpolationValues = fallbackOrValues;}return interpolate(dictionary?.[key] || fallback,interpolationValues,);};const value: I18nContextType = {locale: defaultLocale,t,};return (<I18nContext.Provider value={value}>{children}</I18nContext.Provider>);};export const usei18n = (): I18nContextType => {const context = useContext(I18nContext);if (!context) {throw new Error('usei18n must be used within an I18nProvider');}return context;};
// layout.tsxconst RootLayout = async ({children,params,}: { children: React.ReactNode; params: Promise<any> }) => {const { locale } = (await params) as { locale: LocaleCode };const dictionary = await getDictionary(locale);return (<I18nProvider defaultLocale={locale} dictionary={dictionary}><body className='flex min-h-screen flex-col bg-background text-foreground transition-colors overflow-x-hidden antialiased'><AppMenu />{children}</body></I18nProvider>);};
Ce contexte effectue le travail lourd côté serveur, en lisant la locale actuelle à partir de l'URL de la requête et en injectant le dictionnaire résolu dans le fournisseur de contexte React pour consommation.
Défis et Extensions
La solution que j'ai adoptée n'est pas sans limites. En chemin, j'ai découvert que la TA de base représentait une approche naïve. Mais carpe tauri cornua; examinons certaines des limitations de notre approche et comment elles peuvent être résolues.
Markdown
D'un point de vue implémentation, un problème était d'établir un moyen d'analyser le contenu MDX (comme cet article de blog) pour en extraire le texte sans déformer le markdown ou les composants React. L'extraction de base recherche le contenu textuel enveloppé dans un t()appel, mais en Markdown, la structure sémantique devient beaucoup plus lâche. Le texte est entrelacé avec du balisage, des composants et de la prose, ce qui rend l'extraction naïve fragile. Pour résoudre ce problème, nous avons dû implémenter un plugin personnalisé en utilisant la Remarquebibliothèque, qui expose une API pratique pour traiter les AST arbitraires.
Texte isolé
Les chaînes les plus difficiles à traduire sont souvent les plus courtes. Les boutons, les étiquettes, les éléments de menu et les fragments d'interface utilisateur sont sémantiquement pauvres en isolation.
Un modèle ne montrant que Open, Apply, or Chargedoit deviner le sens du mot que vous voulez dire. Les humains résolvent cette ambiguïté à partir du contexte ; un pipeline de traduction par lots doit la fournir explicitement.
Une solution standard de l'industrie à ce problème est d'ajouter commentaires de codeavec un contexte pertinent sur la signification d'une phrase donnée qui peut guider le traducteur. Ces commentaires peuvent ensuite être couplés à leurs clés au moment de la génération pour étendre l'invite d'extraction avec un indice au modèle sur la signification intentionnelle.
(C'était une si bonne idée que je l'ai intégrée en écrivant le blog. Ce n'est plus une implémentation de huit heures.)
Langues à faibles ressources
Bien que cela dépasse le cadre de l'implémentation i18n en tant que telle, il convient de souligner que la traduction automatique n'est pas un problème résolu, en particulier pour les langues à faibles ressources. De nombreuses recherches, comme Aucune langue n'est laissée pour compteétude de 2022, a démontré que les langues moins populaires souffrent d'un manque de données d'entraînement de haute qualité, ce qui peut entraîner une mauvaise qualité de traduction. Autant dire que les résultats peuvent varier, et si vous écrivez en zoulou et ciblez un public kurmanji, vous devrez embaucher un traducteur humain.
C'est tout
Dans l'ensemble, cela s'est avéré une expérience très instructive, et fonctionne en grande partie comme on l'espérait. Ce projet m'a convaincu que les modèles de fondation peuvent être une aide formidable pour diffuser mon message à un public plus large, et le prototype d'une session de bidouillage rapide pour construire un pipeline i18n sur mesure a porté ses fruits.
Vous pouvez consulter le résultat sur cette page ; utilisez le sélecteur de langue dans la barre de menu pour basculer entre l'anglais, l'allemand, le français et l'espagnol – dis-moi, qu'en penses-tu ?