Para cadenas cortas de interfaz, un modelo local da borradores decentes y gratuitos sin que tu texto salga de tu máquina, aunque no sustituye a un traductor. La mayor parte del trabajo está alrededor del modelo: darle contexto, proteger los placeholders y revisar lo que devuelve.
Instalar un modelo
Con Ollama, un solo comando descarga el modelo, y luego un servidor local escucha en http://localhost:11434:
ollama pull gemma3:12b
Un modelo de 12 000 millones de parámetros, en su versión cuantizada por defecto, cabe en 16 GB de memoria. En una máquina más pequeña, gemma3:4b resuelve las cadenas simples, con más errores en las frases largas.
Con LM Studio, eliges el modelo en la app e inicias el servidor local: expone una API compatible con OpenAI, en http://localhost:1234/v1 por defecto.
Lo que el modelo necesita saber
Si envías «Share list» sin más, el modelo tiene que adivinar si es un botón, un título o un verbo. Tres cosas marcan una diferencia clara:
- La descripción de la clave, si rellenas
@key.descriptionen tus archivos ARB. Es el mejor motivo para hacerlo. - El contexto de la app: «app de lista de compras familiar, tono sencillo, tuteo».
- Las reglas de formato: conservar intactos todos los
{placeholder}y la sintaxis ICU, responder solo con la traducción.
Pon también la temperatura a 0: para traducir quieres la respuesta más probable, no variedad.
Un script para empezar
Este script en Dart traduce, una a una y con Ollama, las claves que faltan (o están vacías) en app_fr.arb. No toca tus archivos ARB: los borradores van a un archivo aparte, y cualquier respuesta cuyos placeholders hayan cambiado se deja de lado.
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();
}
Añade el contexto de tu app al prompt y tu idioma de destino si no es el francés (por ejemplo, app_es.arb y «to Spanish»).
Dónde falla
Plurales ICU. Es el punto débil. Un modelo puede traducir la palabra clave other, eliminar una rama o renombrar {count} en una sola rama. La comparación de placeholders del script detecta una parte; revisa cada plural para el resto. Vigila también las ramas =1, que en francés se aplican al cero (los detalles están en la guía de plurales ICU).
Nombres propios. El nombre de tu app, tu plan de pago, el nombre de una función: los modelos tienden a traducirlos. Inclúyelos en el prompt como términos que no se traducen y revísalos después.
Coherencia. Cada cadena se traduce de forma aislada, así que «item» puede convertirse en «artículo» aquí y en «elemento» allá. Un glosario corto en el prompt resuelve la mayor parte.
Longitud. El español, el francés y el alemán suelen ser más largos que el inglés. Un botón que cabía en inglés puede desbordarse, y solo lo verás en pantalla.
Con LM Studio
Solo cambia la petición. La API sigue el formato de OpenAI: envía una lista de mensajes a /v1/chat/completions y lee choices[0].message.content en la respuesta. El resto del script no cambia.
Revisar y luego fusionar
Abre el archivo de borradores junto a app_en.arb y revísalo clave por clave. Copia lo que esté bien en app_fr.arb, vuelve a ejecutar flutter gen-l10n y mira las pantallas afectadas. Ese ciclo es lo que LocalePilot automatiza: las sugerencias de Ollama o LM Studio llegan al espacio de trabajo como «Borrador de IA por revisar», la QA compara los placeholders y no se escribe nada en tus archivos antes de la exportación.