Lokalny model nie zastąpi tłumacza. Przy krótkich tekstach interfejsu daje jednak przyzwoite szkice za darmo, a tekst nigdy nie opuszcza komputera. Większość pracy jest wokół modelu: trzeba podać mu kontekst, chronić placeholdery i przejrzeć to, co zwraca.
Instalacja modelu
Z Ollama jedno polecenie pobiera model, a potem lokalny serwer nasłuchuje na http://localhost:11434:
ollama pull gemma3:12b
Model z 12 miliardami parametrów w domyślnej skwantyzowanej wersji mieści się w 16 GB pamięci. Na słabszym komputerze gemma3:4b poradzi sobie z prostymi tekstami, z większą liczbą błędów w długich zdaniach.
Z LM Studio wybierasz model w aplikacji i uruchamiasz lokalny serwer: udostępnia API zgodne z OpenAI, domyślnie pod http://localhost:1234/v1.
Co model musi wiedzieć
Wysłanie samego „Share list” zostawia model w niepewności, czy to przycisk, tytuł czy czasownik. Wyraźnie pomagają trzy rzeczy:
- Opis klucza, jeśli wypełniasz
@key.descriptionw plikach ARB. To najlepszy powód, żeby to robić. - Kontekst aplikacji: „aplikacja z rodzinną listą zakupów, prosty ton, po polsku zwracamy się na ty”.
- Reguły formatu: zachowaj każdy
{placeholder}i składnię ICU bez zmian, odpowiedz samym tłumaczeniem.
Ustaw też temperaturę na 0: przy tłumaczeniu chcesz najbardziej prawdopodobnej odpowiedzi, nie różnorodności.
Skrypt na start
Ten skrypt w Dart tłumaczy klucze brakujące (lub puste) w app_fr.arb, jeden po drugim, przez Ollama. Nie dotyka plików ARB: szkice trafiają do osobnego pliku, a każda odpowiedź, w której zmieniły się placeholdery, jest odkładana na bok.
import 'dart:convert';
import 'dart:io';
const model = 'gemma3:12b';
final placeholder = RegExp(r'\{[A-Za-z_]\w*\}');
Future<void> main() async {
final source = _read('lib/l10n/app_en.arb');
final target = _read('lib/l10n/app_fr.arb');
final drafts = <String, String>{};
for (final key in source.keys.where((k) => !k.startsWith('@'))) {
if ((target[key] as String?)?.isNotEmpty ?? false) continue;
final text = source[key] as String;
final description = (source['@$key'] as Map?)?['description'] ?? '';
final draft = await _translate(text, description as String);
if (_placeholders(draft) != _placeholders(text)) {
stderr.writeln('$key: placeholders changed, redo by hand');
continue;
}
drafts[key] = draft;
}
File('app_fr.drafts.json').writeAsStringSync(const JsonEncoder.withIndent(' ').convert(drafts));
print('${drafts.length} drafts in app_fr.drafts.json');
}
Map<String, dynamic> _read(String path) => jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
String _placeholders(String text) => (placeholder.allMatches(text).map((m) => m[0]!).toList()..sort()).join(',');
Future<String> _translate(String text, String description) async {
final client = HttpClient();
final request = await client.postUrl(Uri.parse('http://localhost:11434/api/generate'));
request.headers.contentType = ContentType.json;
request.write(jsonEncode({
'model': model,
'stream': false,
'options': {'temperature': 0},
'prompt': 'Translate this mobile app string from English to French. '
'Keep every {placeholder} and the ICU plural/select syntax unchanged. '
'Context: $description\n'
'Reply with the translation only.\n\n$text',
}));
final response = await request.close();
final body = jsonDecode(await response.transform(utf8.decoder).join()) as Map<String, dynamic>;
client.close();
return (body['response'] as String).trim();
}
Dodaj do promptu kontekst swojej aplikacji i język docelowy, jeśli to nie francuski (dla polskiego: app_pl.arb i „to Polish”).
Gdzie to się psuje
Liczba mnoga ICU. To słaby punkt. Model może przetłumaczyć słowo kluczowe other, zgubić gałąź albo przemianować {count} w jednej gałęzi. Po polsku często pomija też few lub many. Porównanie placeholderów w skrypcie wyłapuje część błędów; resztę wyłapiesz, przeglądając każdą liczbę mnogą. Uważaj też na gałęzie =1, które po francusku dotyczą zera (szczegóły w poradniku o liczbie mnogiej ICU).
Nazwy własne. Nazwa aplikacji, płatny plan, nazwa funkcji: modele lubią je tłumaczyć. Wypisz je w prompcie jako terminy do zachowania i sprawdź potem.
Spójność. Każdy tekst jest tłumaczony osobno, więc „item” może raz być „produktem”, a raz „elementem”. Krótki słownik w prompcie rozwiązuje większość problemów.
Długość. Polski, francuski i niemiecki często są dłuższe niż angielski. Przycisk, który mieścił się po angielsku, może się nie zmieścić, a sprawdzisz to tylko na ekranie.
Z LM Studio
Zmienia się tylko zapytanie. API stosuje format OpenAI: wyślij listę wiadomości do /v1/chat/completions i odczytaj choices[0].message.content z odpowiedzi. Reszta skryptu pozostaje bez zmian.
Przejrzyj, potem scal
Otwórz plik ze szkicami obok app_en.arb i przejdź przez niego klucz po kluczu. Skopiuj to, co dobre, do app_fr.arb, uruchom ponownie flutter gen-l10n i obejrzyj odpowiednie ekrany. Tę pętlę automatyzuje LocalePilot: propozycje z Ollama lub LM Studio trafiają do obszaru roboczego jako „Szkic AI do przeglądu”, QA porównuje placeholdery, a nic nie jest zapisywane w plikach przed eksportem.