Flutter アプリを Ollama や LM Studio で翻訳する

更新 · LocalePilot

自分のマシンで動くモデルなら、サブスクリプションなしで、テキストをオンラインサービスに送ることなく、ARB ファイルの翻訳の下書きを作れます。セットアップ、出発点となる Dart スクリプト、そしてモデルが間違えやすいところを紹介します。

ローカルモデルが翻訳者の代わりになるわけではありません。それでも短い UI 文字列なら、無料でまずまずの下書きが得られ、テキストはマシンの外に出ません。手間がかかるのはモデルの周りの作業で、コンテキストを与え、プレースホルダーを守り、返ってきた訳をレビューします。

モデルをインストールする

Ollama の場合、コマンド 1 つでモデルがダウンロードされ、ローカルサーバーが http://localhost:11434 で待ち受けます。

ollama pull gemma3:12b

120 億パラメータのモデルは、デフォルトの量子化ビルドで 16 GB のメモリに収まります。小さめのマシンでは gemma3:4b で単純な文字列は扱えますが、長い文ではミスが増えます。

LM Studio の場合、アプリでモデルを選んでローカルサーバーを起動します。OpenAI 互換の API が、デフォルトでは http://localhost:1234/v1 で公開されます。

モデルに伝えるべきこと

「Share list」だけを送ると、それがボタンなのか、タイトルなのか、動詞なのか、モデルは推測するしかありません。はっきり効果があるのは次の 3 つです。

  • キーの説明:ARB ファイルの @key.description を書いていれば使えます。説明を書くいちばんの理由です。
  • アプリのコンテキスト:「家族向けの買い物リストアプリ、平易なトーン、フランス語ではくだけた tu」など。
  • 書式のルール:すべての {placeholder} と ICU 構文をそのまま保ち、訳文だけを返すこと。

翻訳では最も確からしい答えが欲しいので、temperature も 0 にします。

出発点となるスクリプト

この Dart スクリプトは、app_fr.arb で不足している(または空の)キーを、Ollama で 1 つずつ翻訳します。ARB ファイルには触れません。下書きは別のファイルに書き出され、プレースホルダーが変わった答えは除外されます。

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();
}

プロンプトにアプリのコンテキストを追加し、ターゲット言語がフランス語でなければ書き換えてください(日本語なら app_ja.arb と「to Japanese」)。

うまくいかないところ

ICU の複数形。モデルがいちばん間違えやすいところです。 other というキーワードを翻訳したり、分岐を落としたり、1 つの分岐だけ {count} の名前を変えたりします。スクリプトのプレースホルダー比較で一部は検出できますが、残りはすべての複数形をレビューして確認してください。フランス語ではゼロにも適用される =1 の分岐にも注意が必要です(詳しくは ICU 複数形のガイドを参照)。

固有名詞。アプリ名、有料プラン名、機能名など、モデルはこれらを翻訳したがります。プロンプトで保持すべき用語として列挙し、後で確認してください。

一貫性。文字列は 1 つずつ独立して翻訳されるため、「item」がある所では「article」、別の所では「élément」になることがあります。プロンプトに短い用語集を入れれば、ほとんど解決します。

長さ。フランス語やドイツ語は英語より長くなりがちです。英語では収まっていたボタンがはみ出すかもしれません。それは画面で確認するしかありません。

LM Studio の場合

変わるのはリクエストだけです。API は OpenAI の形式に従います。メッセージのリストを /v1/chat/completions に送り、レスポンスの choices[0].message.content を読みます。スクリプトの残りはそのままです。

レビューしてからマージ

下書きファイルを app_en.arb の横に開き、キーを 1 つずつ確認します。良いものを app_fr.arb にコピーし、flutter gen-l10n を再実行し、該当する画面を確認します。このループを自動化するのが LocalePilot です。Ollama や LM Studio からの提案は「要レビューの AI 下書き」としてワークスペースに届き、QA がプレースホルダーを比較し、書き出しまでファイルには何も書き込まれません。

よくある質問

アプリの文字列にはどのモデルを使うべきですか?

gemma3:12b のような最近の汎用 12B モデルなら、短い文字列で良い下書きが得られます。小さめのマシンでは 4B 版も使えますが、ミスは増えます。

テキストはマシンの外に出ますか?

いいえ。Ollama と LM Studio はモデルをローカルで実行し、リクエストは localhost に送られます。

ARB ファイルに直接書き込んでもいいですか?

下書きファイルに書き出し、レビューしてからマージするほうが安全です。モデルは ICU の複数形を壊したり、ブランド名を翻訳したりすることがあります。