ローカルモデルが翻訳者の代わりになるわけではありません。それでも短い 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 がプレースホルダーを比較し、書き出しまでファイルには何も書き込まれません。