Tłumaczenie katalogów Lingui przez LLM
Jak tłumaczyć katalogi .po z Lingui modelem językowym, co się psuje (zmienne, liczba mnoga, ton) i jakie testy puścić, zanim katalog trafi na produkcję.
Model językowy naszkicuje Ci każdy brakujący tekst w katalogu .po z Lingui, ale to wciąż tylko szkic. Zanim trafi na produkcję, puść testy mechaniczne (każdy komunikat wypełniony, zmienne i tagi bez zmian, wszystkie formy liczby mnogiej, których wymaga język), a potem daj go do przeczytania komuś, kto zna ten język. Do szkicowania napisałem małe CLI, lingui-translate-ai; ten tekst opisuje całą pętlę.
TL;DR
- Szkic to łatwa część: lingui-translate-ai wysyła puste
msgstrdo LLM, pokazuje podgląd przebiegu, zostawia kopie.baki próbuje naprawić zgubione zmienne.- Błędy są ciche: zła zmienna albo brakująca polska forma liczby mnogiej wyświetla zły tekst i niczego nie wywraca.
- Najpierw testy, potem człowiek: testy tokenów i form liczby mnogiej, czytelnik dla każdego języka, który da się przejrzeć, i uczciwa etykieta na resztę.
Po co w ogóle tłumaczyć katalogi maszynowo?
Linkoglot, moja własna aplikacja do nauki języków, działa w 30 językach, więc każdy nowy ekran to nowe teksty w 30 katalogach. Szkice robił skrypt z LLM w repozytorium aplikacji, a ten skrypt urósł do lingui-translate-ai (otwiera się w nowej karcie), CLI na licencji MIT opublikowanego w npm (w chwili pisania wersja 1.1.0). O samej aplikacji piszę w tekście jak w pojedynkę wydałem Linkoglota.
Ta strona jest mniejsza: trzy katalogi, a źródłem jest angielski.
Naszkicować te teksty jest łatwo. Trudno wiedzieć, które szkice są zepsute.
Jak katalog wygląda z perspektywy modelu?
Katalog Lingui to plik PO z gettext. Każdy wpis ma tekst źródłowy w msgid, tłumaczenie w msgstr i odnośnik do miejsca w kodzie. Ten pochodzi z polskiego katalogu tej strony:
#: src/components/layout/SiteHeader.astro:52
msgid "{name}, home"
msgstr "{name}, strona główna"
Model widzi krótkie teksty, zwykle bez kontekstu, pełne tokenów, których nie wolno mu ruszać. I właśnie tam zaczynają się problemy.
Co się psuje?
Zmienne i tagi
{name} musi wrócić dokładnie jako {name}. To samo dotyczy numerowanych tagów, które Lingui generuje dla formatowania w Trans, takich jak <0> i </0>.
Zepsuta zmienna niczego nie wywraca. W @lingui/core 6.9.0 polski tekst z {imie} zamiast {name} wyrenderuje się jako „, strona główna”, a usunięta zmienna po prostu zgubi wartość. Użytkownik zobaczy dziurę, build nie zobaczy nic.
Liczba mnoga
W regułach Unicode CLDR angielski ma dwie kategorie liczby mnogiej: one i other. Polski ma cztery: one (1), few (2-4, 22-24), many (0, 5-19, 100) i other (ułamki, na przykład 1,5). Model, który kopiuje strukturę angielskiego komunikatu, zwróci dwie:
msgid "{count, plural, one {# file} other {# files}}"
msgstr "{count, plural, one {# plik} other {# plików}}"
Tu też nic się nie wywróci. Brakujące kategorie spadają do other, więc w @lingui/core 6.9.0 ten komunikat wyświetli „2 plików” i „1,5 plików”, a po polsku powinno być „2 pliki” i „1,5 pliku”. Przewodnik Lingui mówi jasno: formy języka docelowego to zadanie tłumacza. Gdy tłumaczem jest model, to zadanie trzeba sprawdzić.
Ton i kontekst
Teksty w interfejsie są krótkie i wieloznaczne: angielskie „Open” to czasownik albo przymiotnik, „Save” to przycisk albo rabat. Makra Lingui przyjmują pola comment i context, a oba trafiają do katalogu. Przekaż je modelowi, bo często to jedyny kontekst, jaki dostaje.
Dokumentacja ICU dodaje jeszcze jedną zasadę: w gałęziach plural i select pisz pełne zdania zamiast sklejać fragmenty, bo szyk i odmiana różnią się między językami.
Skopiowany angielski i rozjeżdżające się terminy
Modele czasem zwracają angielski tekst bez zmian albo tłumaczą jeden termin z produktu na trzy sposoby w jednym katalogu. W długim diffie łatwo to przeoczyć.
Jak lingui-translate-ai obsługuje szkic?
Narzędzie uruchamiasz w katalogu głównym projektu z Lingui. Szuka package.json, zależności od Lingui albo pliku lingui.config.* oraz folderów językowych z plikami PO, a jeśli folder nie wygląda na projekt Lingui, kończy działanie.
npx lingui-translate-ai init # tworzy lingui-translate-ai.json
npx lingui-translate-ai translate --only-empty --dry-run
npx lingui-translate-ai translate --only-empty
- 01 Szuka luk Puste msgstr we wszystkich językach, a w trybie naprawy wszystkie wpisy.
- 02 Podgląd Model, języki, liczba tekstów i zapytań, potem pytanie tak czy nie.
- 03 Szkic Paczki tekstów idą do modelu przez OpenRouter, w ramach Twoich limitów.
- 04 Kontrola tokenów Zmienne i tagi muszą przetrwać, a liczba mnoga zostać poprawnym ICU.
- 05 Łatka w pliku Najpierw kopia .bak, potem tylko zmienione bloki msgstr.
Czego nie widać na schemacie:
- Klucz:
OPENROUTER_API_KEY, z powłoki albo z pliku.env. - Tryb naprawy: z
onlyEmptyTranslations: falsewysyła też istniejące tłumaczenia, więc model może poprawić skopiowany angielski, niespójne terminy, błędy w zmiennych i zbyt dosłowne teksty interfejsu. - Limity:
maxTranslationsPerRequest,maxRequestsPerRunimaxTranslationsPerRunograniczają wielkość przebiegu. - Naprawy: jeśli model zgubi zmienną, CLI próbuje ją wstawić z powrotem; poprawkę, której nie da się bezpiecznie naprawić, pomija i zapisuje w logu.
Liczba mnoga dostała osobną obsługę w wersji 1.1.0. Prompt każe modelowi zwrócić każdą kategorię CLDR, której potrzebuje język docelowy. CLI odrzuca potem liczbę mnogą, która wróci zepsuta: z inną nazwą argumentu lub słowem kluczowym, niedomkniętymi klamrami, bez gałęzi other albo bez #. Nie sprawdza za to, czy few i many naprawdę są na miejscu. Ten test należy do Ciebie.
Jakie testy puścić, zanim katalog trafi na produkcję?
Na tej stronie mały plik Vitest, src/locales/catalogs.test.ts, czyta każdy plik .po i kończy się błędem, gdy:
- w polskim albo hiszpańskim katalogu brakuje komunikatu z angielskiego źródła;
- któryś
msgstrjest pusty; {zmienne}albo numerowane tagi różnią się od źródła;- tłumaczenie zawiera surową twardą spację albo myślnik (półpauzę lub pauzę), czego zabraniają zasady stylu tej strony.
Test zmiennych to jedno wyrażenie regularne:
const tokens = (text: string) =>
[...text.matchAll(/\{\w+\}|<\/?\d+>/g)].map((m) => m[0]).sort();
for (const entry of list) expect(tokens(entry.str), entry.id).toEqual(tokens(entry.id));
Drugą siatką jest build produkcyjny: failOnMissing: "catalog" we wtyczce Vite od Lingui wywraca go, gdy brakuje polskiego albo hiszpańskiego komunikatu. Tę konfigurację pokazuję w tekście o tym, jak Lingui działa w statycznym buildzie Astro.
Ta strona nie ma teraz żadnych komunikatów z liczbą mnogą, więc jej test nie sprawdza kategorii. Jeśli Twoje katalogi je mają, dodaj taki test. Intl.PluralRules wie, jakich kategorii potrzebuje każdy język:
const CATEGORY = /\b(zero|one|two|few|many|other)\s*\{/g;
export function missingPlurals(locale: string, msgstr: string) {
if (!/,\s*plural\s*,/.test(msgstr)) return [];
const needed = new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
const found = new Set([...msgstr.matchAll(CATEGORY)].map((m) => m[1]));
return needed.filter((category) => !found.has(category));
}
missingPlurals("pl", "{count, plural, one {# plik} other {# plików}}");
// ["few", "many"]
Celowo jest naiwny: czyta płaskie komunikaty, a nie liczbę mnogą zagnieżdżoną w select. Zgłosi też hiszpański, dla którego CLDR definiuje kategorię many dla liczb takich jak 1 000 000, więc dla każdego języka zdecyduj, czy spadek do other jest do przyjęcia. Jak liczbę mnogą traktują dwie największe biblioteki, opisuję w porównaniu Lingui czy i18next.
Jak wygląda jedna runda tłumaczeń?
- Po zmianach w kodzie uruchom
lingui extract, żeby nowe komunikaty pojawiły się jako puste wpisy. - Uruchom
translate --only-empty --dry-runi przeczytaj podgląd: języki, liczbę tekstów i zapytań. - Puść przebieg naprawdę. Pliki
.bakto Twój przycisk „cofnij”. - Uruchom testy katalogów i build produkcyjny. Brakujące komunikaty, zmienione zmienne i (z testem opisanym wyżej) niepełna liczba mnoga zatrzymują się tutaj.
- Przeczytaj diff dla każdego języka, który znasz, a resztę oznacz jako nieprzejrzaną.
- Zacommituj katalogi i usuń pliki
.bak.
Kroki 4 i 5 sprawiają, że wynikowi można ufać. Pomiń je, a zepsute {name} trafi do użytkowników.
Dlaczego człowiek i tak musi to przeczytać?
Testy mechaniczne łapią zepsute tokeny i brakujące formy. Nie złapią złego rejestru, zdania poprawnego, ale dziwnego, ani słowa, którego żaden native speaker by nie użył.
Na tej stronie hiszpańskiego nikt jeszcze nie przeczytał i strona mówi to dwa razy. Nagłówek katalogu ma X-Review-Status: unreviewed (AI translation), czego pilnuje test. Każda hiszpańska podstrona pisze o tym obok przełącznika języka.
Trzydziestu języków, jak w Linkoglocie, jedna osoba nie przeczyta. To prawdziwa granica tego podejścia i powód, żeby otwarcie mówić, które języki przeczytał człowiek.
Ograniczenia
- Wynik nie jest deterministyczny. Dwa przebiegi mogą się różnić, więc dry run, pliki
.baki przejrzany diff przed commitem to Twoje zabezpieczenia. - Koszt zależy od modelu i wielkości katalogu. Zanim puścisz pełny przebieg, sprawdź jeden język z
--max-requests 1. - Kontekstu z natury jest mało. Komentarze i konteksty w makrach dają modelowi, a także recenzentowi, więcej materiału.
lingui-translate-ai znajdziesz na stronie z moimi projektami, obok Linkoglota, z którego wyrosło.
Źródła
- lingui-translate-ai on npm (otwiera się w nowej karcie) (dostęp: )
- lingui-translate-ai README (GitHub) (otwiera się w nowej karcie) (dostęp: )
- lingui-translate-ai v1.1.0 release notes (GitHub) (otwiera się w nowej karcie) (dostęp: )
- lingui-translate-ai source: src/commands/translate.ts (GitHub) (otwiera się w nowej karcie) (dostęp: )
- Unicode CLDR: Language Plural Rules (otwiera się w nowej karcie) (dostęp: )
- MDN: Intl.PluralRules.prototype.resolvedOptions() (otwiera się w nowej karcie) (dostęp: )
- Lingui: Pluralization (otwiera się w nowej karcie) (dostęp: )
- Lingui: Macros reference (otwiera się w nowej karcie) (dostęp: )
- Lingui: ICU MessageFormat (otwiera się w nowej karcie) (dostęp: )
- Lingui: Vite plugin (otwiera się w nowej karcie) (dostęp: )
- ICU User Guide: Formatting Messages (otwiera się w nowej karcie) (dostęp: )