i18n AI

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

Autor: Mateusz Muszarski Zaktualizowano 5 min czytania In English

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 msgstr do LLM, pokazuje podgląd przebiegu, zostawia kopie .bak i 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.

Strona główna na trzech telefonach po angielsku, polsku i hiszpańsku: ten sam układ z przyciskami Hire me, Zatrudnij mnie i Contrátame
Jedna strona, trzy katalogi. Każdy przycisk i etykieta to komunikat z katalogu; hiszpańskie przetłumaczyła AI.

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
  1. 01 Szuka luk Puste msgstr we wszystkich językach, a w trybie naprawy wszystkie wpisy.
  2. 02 Podgląd Model, języki, liczba tekstów i zapytań, potem pytanie tak czy nie.
  3. 03 Szkic Paczki tekstów idą do modelu przez OpenRouter, w ramach Twoich limitów.
  4. 04 Kontrola tokenów Zmienne i tagi muszą przetrwać, a liczba mnoga zostać poprawnym ICU.
  5. 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: false wysył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, maxRequestsPerRun i maxTranslationsPerRun ograniczają 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:

  1. w polskim albo hiszpańskim katalogu brakuje komunikatu z angielskiego źródła;
  2. któryś msgstr jest pusty;
  3. {zmienne} albo numerowane tagi różnią się od źródła;
  4. 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ń?

  1. Po zmianach w kodzie uruchom lingui extract, żeby nowe komunikaty pojawiły się jako puste wpisy.
  2. Uruchom translate --only-empty --dry-run i przeczytaj podgląd: języki, liczbę tekstów i zapytań.
  3. Puść przebieg naprawdę. Pliki .bak to Twój przycisk „cofnij”.
  4. 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.
  5. Przeczytaj diff dla każdego języka, który znasz, a resztę oznacz jako nieprzejrzaną.
  6. 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.

Stopka hiszpańskiej podstrony: trzy przyciski z flagami języków i zdanie, że wersję hiszpańską przetłumaczyła AI i nikt jej jeszcze nie sprawdził
Stopka każdej hiszpańskiej podstrony: przełącznik języka, a pod nim informacja, że native speaker jeszcze tej wersji nie przejrzał.

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 .bak i 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

  1. lingui-translate-ai on npm (otwiera się w nowej karcie) (dostęp: )
  2. lingui-translate-ai README (GitHub) (otwiera się w nowej karcie) (dostęp: )
  3. lingui-translate-ai v1.1.0 release notes (GitHub) (otwiera się w nowej karcie) (dostęp: )
  4. lingui-translate-ai source: src/commands/translate.ts (GitHub) (otwiera się w nowej karcie) (dostęp: )
  5. Unicode CLDR: Language Plural Rules (otwiera się w nowej karcie) (dostęp: )
  6. MDN: Intl.PluralRules.prototype.resolvedOptions() (otwiera się w nowej karcie) (dostęp: )
  7. Lingui: Pluralization (otwiera się w nowej karcie) (dostęp: )
  8. Lingui: Macros reference (otwiera się w nowej karcie) (dostęp: )
  9. Lingui: ICU MessageFormat (otwiera się w nowej karcie) (dostęp: )
  10. Lingui: Vite plugin (otwiera się w nowej karcie) (dostęp: )
  11. ICU User Guide: Formatting Messages (otwiera się w nowej karcie) (dostęp: )

Wszystkie wpisy