Yerel bir model bir çevirmenin yerini tutmaz. Ancak kısa arayüz metinleri için size ücretsiz olarak makul taslaklar sunar ve metinleriniz makinenizden hiç çıkmaz. İşin büyük kısmı modelin etrafında döner: ona bağlam vermek, placeholder'ları korumak, dönen sonucu gözden geçirmek.
Model kurulumu
Ollama ile tek bir komut modeli indirir, ardından yerel bir sunucu http://localhost:11434 adresinde dinler:
ollama pull gemma3:12b
Varsayılan quantize edilmiş sürümüyle 12 milyar parametreli bir model 16 GB belleğe sığar. Daha küçük bir makinede gemma3:4b basit metinlerin üstesinden gelir, ama uzun cümlelerde daha fazla hata yapar.
LM Studio ile modeli uygulamada seçer ve yerel sunucuyu başlatırsınız: varsayılan olarak http://localhost:1234/v1 adresinde OpenAI uyumlu bir API sunar.
Modelin bilmesi gerekenler
"Share list" metnini tek başına göndermek, modeli bunun bir düğme mi, başlık mı yoksa fiil mi olduğunu tahmin etmeye zorlar. Üç şey belirgin bir fark yaratır:
- Anahtarın açıklaması, ARB dosyalarınızda
@key.descriptionalanını dolduruyorsanız. Bu alanı doldurmanın en iyi gerekçesi de budur. - Uygulamanın bağlamı: "aile alışveriş listesi uygulaması, sade bir ton, Türkçede 'sen' hitabı".
- Biçim kuralları: her
{placeholder}'ı ve ICU sözdizimini olduğu gibi koru, yalnızca çeviriyle yanıt ver.
Sıcaklığı (temperature) da 0'a ayarlayın: çeviride çeşitlilik yerine en olası yanıtı istersiniz.
Başlangıç için bir script
Bu Dart script'i, app_fr.arb'de eksik (ya da boş) olan anahtarları Ollama ile tek tek çevirir. ARB dosyalarınıza dokunmaz: taslaklar ayrı bir dosyaya gider ve placeholder'ları değişmiş her yanıt bir kenara ayrılır.
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();
}
Prompt'a uygulamanızın bağlamını ekleyin; hedef diliniz Fransızca değilse (örneğin Türkçe için app_tr.arb ve "to Turkish") dosya adlarını ve dili değiştirin.
Modelin yanıldığı yerler
ICU çoğulları. Zayıf nokta burası. Bir model other anahtar sözcüğünü çevirebilir, bir dalı atlayabilir ya da {count}'u tek bir dalda yeniden adlandırabilir. Script'teki placeholder karşılaştırması bunun bir kısmını yakalar; geri kalanı için her çoğulu gözden geçirin. Fransızcada sıfır için de geçerli olan =1 dallarına da dikkat edin (ayrıntılar ICU çoğulları rehberinde).
Özel adlar. Uygulamanızın adı, ücretli paketiniz, bir özellik adı: modeller bunları çevirmeye meraklıdır. Bunları prompt'ta korunacak terimler olarak listeleyin ve sonrasında kontrol edin.
Tutarlılık. Her metin tek başına çevrilir, bu yüzden "item" bir yerde "öğe", başka bir yerde "ürün" olabilir. Prompt'a eklenen kısa bir sözlük (glossary) bunun çoğunu çözer.
Uzunluk. Türkçe, Almanca ve Fransızca çoğu zaman İngilizceden daha uzundur. İngilizcede sığan bir düğme taşabilir; bunu ancak ekranda görürsünüz.
LM Studio ile
Yalnızca istek değişir. API OpenAI biçimini izler: /v1/chat/completions adresine bir mesaj listesi gönderin ve yanıttan choices[0].message.content'i okuyun. Script'in geri kalanı aynı kalır.
Önce gözden geçirin, sonra birleştirin
Taslak dosyasını app_en.arb'nin yanında açın ve anahtar anahtar gözden geçirin. İyi olanları app_fr.arb'ye kopyalayın, flutter gen-l10n'i yeniden çalıştırın ve ilgili ekranlara bakın. LocalePilot'un otomatikleştirdiği döngü budur: Ollama veya LM Studio'dan gelen öneriler çalışma alanına "gözden geçirilecek yapay zekâ taslağı" olarak düşer, QA placeholder'ları karşılaştırır ve dışa aktarma adımına kadar dosyalarınıza hiçbir şey yazılmaz.