Un modello locale non sostituisce un traduttore. Per le stringhe brevi di un'interfaccia, però, fornisce bozze decenti gratis, e il testo non lascia mai il tuo computer. Gran parte del lavoro sta attorno al modello: dargli contesto, proteggere i placeholder, revisionare quello che restituisce.
Installare un modello
Con Ollama, un solo comando scarica il modello, e un server locale resta in ascolto su http://localhost:11434:
ollama pull gemma3:12b
Un modello da 12 miliardi di parametri nella sua versione quantizzata predefinita sta in 16 GB di memoria. Su un computer meno potente, gemma3:4b gestisce le stringhe semplici, con più errori sulle frasi lunghe.
Con LM Studio, scegli il modello nell'app e avvii il server locale: espone un'API compatibile con OpenAI, su http://localhost:1234/v1 per impostazione predefinita.
Cosa deve sapere il modello
Inviare "Share list" da solo lascia il modello a indovinare se si tratta di un pulsante, di un titolo o di un verbo. Tre cose fanno una differenza netta:
- La descrizione della chiave, se compili
@key.descriptionnei file ARB. È il motivo migliore per farlo. - Il contesto dell'app: "app di lista della spesa per la famiglia, tono semplice, si dà del tu".
- Le regole di formato: mantenere intatti ogni
{placeholder}e la sintassi ICU, rispondere solo con la traduzione.
Imposta anche la temperatura a 0: per una traduzione vuoi la risposta più probabile, non la varietà.
Uno script di partenza
Questo script Dart traduce le chiavi mancanti (o vuote) in app_fr.arb, una alla volta, con Ollama. Non tocca i file ARB: le bozze vanno in un file separato, e ogni risposta in cui i placeholder sono cambiati viene messa da parte.
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();
}
Aggiungi al prompt il contesto della tua app, e la lingua di destinazione se non è il francese (per esempio app_it.arb e "to Italian").
Dove sbaglia
Plurali ICU. È il punto debole. Un modello può tradurre la parola chiave other, perdere un ramo o rinominare {count} in un solo ramo. Il confronto dei placeholder dello script ne intercetta una parte; per il resto, revisiona ogni plurale. Attenzione anche ai rami =1, che in francese si applicano allo zero (dettagli nella guida sui plurali ICU).
Nomi propri. Il nome dell'app, il piano a pagamento, il nome di una funzionalità: i modelli tendono a tradurli. Elencali nel prompt come termini da mantenere, e controllali dopo.
Coerenza. Ogni stringa viene tradotta isolatamente, quindi "item" può diventare "articolo" qui ed "elemento" là. Un breve glossario nel prompt risolve gran parte del problema.
Lunghezza. Italiano, francese e tedesco sono spesso più lunghi dell'inglese. Un pulsante che ci stava in inglese può sforare; solo lo schermo te lo dirà.
Con LM Studio
Cambia solo la richiesta. L'API segue il formato OpenAI: invia una lista di messaggi a /v1/chat/completions e leggi choices[0].message.content dalla risposta. Il resto dello script resta invariato.
Revisionare, poi unire
Apri il file di bozze accanto ad app_en.arb e passalo chiave per chiave. Copia ciò che va bene in app_fr.arb, riesegui flutter gen-l10n e guarda le schermate interessate. È proprio questo ciclo che LocalePilot automatizza: i suggerimenti di Ollama o LM Studio arrivano nell'area di lavoro come "bozza IA da revisionare", la QA confronta i placeholder, e niente viene scritto nei file prima dell'esportazione.