Traduzir um app Flutter com Ollama ou LM Studio

Atualizado em · LocalePilot

Um modelo rodando na sua própria máquina pode gerar rascunhos de tradução para seus arquivos ARB, sem assinatura e sem enviar seus textos a um serviço online. A configuração, um script Dart para começar e os pontos em que o modelo erra.

Um modelo local não substitui um tradutor. Para strings curtas de interface, porém, ele entrega rascunhos razoáveis de graça, e seus textos nunca saem da sua máquina. A maior parte do trabalho está em volta do modelo: dar contexto, proteger os placeholders, revisar o que volta.

Instalar um modelo

Com o Ollama, um comando baixa o modelo, e um servidor local passa a escutar em http://localhost:11434:

ollama pull gemma3:12b

Um modelo de 12 bilhões de parâmetros na versão quantizada padrão cabe em 16 GB de memória. Em uma máquina menor, o gemma3:4b dá conta de strings simples, com mais erros em frases longas.

Com o LM Studio, você escolhe o modelo no app e inicia o servidor local: ele expõe uma API compatível com a da OpenAI, em http://localhost:1234/v1 por padrão.

O que o modelo precisa saber

Mandar "Share list" sozinho deixa o modelo adivinhando se é um botão, um título ou um verbo. Três coisas fazem uma diferença clara:

  • A descrição da chave, se você preencher @key.description nos seus arquivos ARB. É o melhor motivo para fazer isso.
  • O contexto do app: "app de lista de compras para a família, tom simples, tratamento por 'você'".
  • Regras de formato: manter todos os {placeholder} e a sintaxe ICU intactos, responder só com a tradução.

Defina também a temperatura como 0: em tradução você quer a resposta mais provável, não variedade.

Um script para começar

Este script Dart traduz as chaves ausentes (ou vazias) em app_fr.arb, uma de cada vez, com o Ollama. Ele não mexe nos seus arquivos ARB: os rascunhos vão para um arquivo separado, e toda resposta cujos placeholders mudaram é deixada 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();
}

Adicione ao prompt o contexto do seu app e o seu idioma de destino, se não for o francês (por exemplo, app_pt.arb e "Brazilian Portuguese").

Onde dá errado

Plurais ICU. É o ponto fraco. Um modelo pode traduzir a palavra-chave other, perder um branch ou renomear {count} em um único branch. A comparação de placeholders do script pega parte disso; revise cada plural para o resto. Atenção também aos branches =1, que se aplicam ao zero em francês e em português do Brasil (detalhes no guia de plurais ICU).

Nomes próprios. O nome do seu app, do seu plano pago, de uma funcionalidade: os modelos adoram traduzi-los. Liste-os no prompt como termos a manter e confira depois.

Consistência. Cada string é traduzida isoladamente, então "item" pode virar "artigo" aqui e "item" ali. Um glossário curto no prompt resolve a maior parte.

Comprimento. Francês, alemão e português costumam ser mais longos que o inglês. Um botão que cabia em inglês pode estourar; só a tela vai dizer.

Com o LM Studio

Só a requisição muda. A API segue o formato da OpenAI: envie uma lista de mensagens para /v1/chat/completions e leia choices[0].message.content na resposta. O resto do script continua igual.

Revisar, depois mesclar

Abra o arquivo de rascunhos ao lado do app_en.arb e passe por ele chave por chave. Copie o que estiver bom para o app_fr.arb, rode o flutter gen-l10n de novo e confira as telas envolvidas. É esse ciclo que o LocalePilot automatiza: as sugestões do Ollama ou do LM Studio chegam ao espaço de trabalho como "Rascunho da IA para revisar", a QA compara os placeholders, e nada é gravado nos seus arquivos antes da exportação.

Perguntas frequentes

Qual modelo usar para as strings de um app?

Um modelo generalista recente de 12B, como o gemma3:12b, gera bons rascunhos para strings curtas. Em uma máquina mais modesta, uma versão 4B funciona, com mais erros.

Meus textos saem da minha máquina?

Não. O Ollama e o LM Studio rodam o modelo localmente; as requisições vão para localhost.

Posso gravar direto nos meus arquivos ARB?

É melhor gravar em um arquivo de rascunhos e revisar antes de mesclar: um modelo pode quebrar um plural ICU ou traduzir um nome de marca.