訳文そのものは自然でした。意味も通っていました。それなのに、保存完了のメッセージから枚数の数字だけが消えていました。
私が個人で運用している壁紙アプリは、通知文やダイアログの文字列を複数の言語で持っています。その多言語化に Gemini を使うようになってから、翻訳の品質そのものより先に手を焼いたのが、この「書式指定子が静かにいなくなる」現象でした。
人間が訳文を読み返すとき、目は意味のほうへ行きます。%1$s や %@ は意味を持たない記号ですから、読み飛ばされます。そして実機で初めて、数字の入るはずの場所が空白になっていることに気づきます。
機械的に弾けるものは、機械に弾かせたほうが確実でした。ここでは、その検査を短いコードで用意するところまでを書きます。
訳文レビューでプレースホルダだけがすり抜ける理由
書式指定子は、翻訳という作業の中で特殊な立ち位置にあります。文の一部でありながら、訳してはいけない部分です。
モデルから見ると、%1$s は前後の文脈から切り離された短い記号列でしかありません。訳文を自然な語順に組み替える過程で、この記号が巻き込まれます。文としての完成度を上げようとするほど、記号は邪魔者になります。
そして人間のレビューでは、この欠落は見つかりません。訳文を読んで「意味が通るか」を確かめる作業と、「記号が同じ数だけ残っているか」を確かめる作業は、使う注意の種類が違います。前者に集中しているとき、後者は働いていません。
私の場合、レビューを丁寧にやろうとするほど検出率が下がりました。文章として読み込むほど、記号が背景に沈むためです。
壊れ方は4種類に収まりました
しばらく記録を取ったところ、壊れ方の種類はそれほど多くありませんでした。
| 種類 | 起きること | 実行時の見え方 |
|---|---|---|
| 欠落 | 訳文から指定子が消える | 値が入らない。言語によっては例外で停止する |
| 全角化 | %@ が %@ になる | 置換されず、記号がそのまま画面に出る |
| 増殖 | 同じ指定子が2つ以上に増える | 引数の数と合わず、未定義の値を読みにいく |
| 並び替え | 語順に合わせて指定子の順番が入れ替わる | 位置指定なしの場合、値が別の場所に入る |
厄介なのは4番目です。並び替え自体は間違いではありません。%1$s のように位置を明示した指定子であれば、訳文の中で順番が変わっても正しく解決されます。
問題は、原文が %s と %s のように位置指定なしで書かれている場合です。このとき順番が入れ替わると、値が入れ替わったまま、例外も出さずに表示されます。数字と名前が入れ替わった文が、そのまま利用者に届きます。
つまり、原文側を位置指定つきに揃えておくことが、翻訳を頼む前の下ごしらえになります。ここを済ませておかないと、後段の検査でも「並び替えを許してよいのか」を判断できません。
生成の時点で、記号を訳させない
検査の前に、そもそも壊れにくい頼み方があります。指定子を翻訳対象から外してしまう方法です。
一つは、送る前に指定子を目印へ置き換え、戻ってきてから復元するやり方です。%1$s を [[P1]] のような目立つ文字列にしておくと、モデルはそれを訳語ではなく記号として扱いやすくなります。
もう一つは、構造化出力で受け取ることです。原文と訳文を対にした JSON で返させると、どの原文に対する訳文なのかが確定します。対応関係が曖昧なまま長い文字列を受け取るより、後段の検査がずっと書きやすくなります。構造化出力そのものでつまずいた場合は、Gemini APIのJSON出力・構造化出力が正しく返らない原因と対処法 に症状別の切り分けをまとめてあります。
なお、出力の揺れを抑える目的で temperature を指定していた方は、そろそろ別の方法を考える時期です。サンプリング系のパラメータは非推奨になりました。揺れを潰す役目は、モデルの設定ではなく、こちらの検査側へ移していくことになります。この記事の検査を用意しておくことは、その移行の準備にもなります。
受け取った直後に弾く、短い検査
実際に使っている検査は、原文と訳文からそれぞれ指定子を拾い出し、個数を比べるだけのものです。
import re
import unicodedata
from collections import Counter
PLACEHOLDER = re.compile(r"%(\d+\$)?[@sdfxu]|%%")
def placeholders(text: str) -> Counter:
# 全角の記号を半角へ寄せてから数える
normalized = unicodedata.normalize("NFKC", text)
return Counter(m.group(0) for m in PLACEHOLDER.finditer(normalized))
def has_fullwidth(text: str) -> bool:
# 正規化した場合としない場合で結果が変わるなら、全角が混ざっている
raw = Counter(m.group(0) for m in PLACEHOLDER.finditer(text))
return placeholders(text) != raw
def verify(source: str, target: str) -> list[str]:
src, dst = placeholders(source), placeholders(target)
problems = []
if src - dst:
problems.append(f"欠落: {dict(src - dst)}")
if dst - src:
problems.append(f"増殖: {dict(dst - src)}")
if has_fullwidth(target):
problems.append("全角の書式指定子が含まれています")
if src and not all("$" in p for p in src):
# 位置指定なしの指定子が原文にある場合、並び替えを検出できない
problems.append("原文に位置指定なしの指定子があります(並び替えを検証できません)")
return problems手元で確かめたところ、この検査はいくつかの点で素直ではありませんでした。
まず、unicodedata.normalize("NFKC", text) を通すと %@ は %@ になります。つまり、正規化してから個数を比べる限り、全角化は差分として現れません。全角の検出には、正規化前後の結果を比べるという別の一手が必要でした。個数の比較だけを信じていると、この壊れ方は素通りします。
次に、並び替えの扱いです。Counter は多重集合ですから、順番は見ていません。位置指定つきの指定子であればそれで正しく、位置指定なしであれば検証できません。この検査に「原文の指定子が位置指定つきかどうか」を確かめる行が入っているのは、そのためです。検査で守れる範囲を、検査自身に申告させています。
%% は二重にした百分率の記号で、置換の対象ではありません。正規表現の最後にこれを入れておかないと、% を拾い損ねて誤検出につながります。
どこに置くか、そして落ちたときにどうするか
私はこの検査を、翻訳を受け取った直後に置いています。文字列リソースのファイルへ書き出す前です。
書き出してからビルド時に検証する構成も考えましたが、やめました。壊れた訳文がいったんファイルに入ってしまうと、どの言語のどの行が生成物なのかが曖昧になります。境界は、外から来たものが自分のリポジトリへ入る手前に置くのが分かりやすいと感じています。
検査に引っかかった行は、その言語だけ保留にします。全体をやり直すのではなく、該当する原文だけを再度投げます。多くの場合、二度目は通ります。三度続けて通らない場合は、原文の側に無理があると考えて、文そのものを短く書き直します。
再生成を自動で繰り返す仕組みは、あえて入れていません。同じ原文が何度も引っかかるという事実自体が、原文を直す合図になるためです。自動で押し流してしまうと、その合図が見えなくなります。
まとめ
まず、自分のアプリの原文を開いて、位置指定なしの %s や %@ が残っていないかを確かめてみてください。そこを位置指定つきに揃えるところからで、翻訳の並び替えは安全になります。
翻訳の品質そのものをどう見るか、スコアで測る仕組みまで踏み込みたい場合は、Gemini API でアプリのローカリゼーションを自動QAする で構造化出力を使った評価パイプラインを扱っています。この記事の検査が「壊れていないこと」を守るものだとすれば、あちらは「良い訳であること」を測るものです。
ロケールごとの実際の使い分けについては、Gemini 2.5 Flash で App Store のキーワード欄を多言語最適化した1ヶ月の所感 にも運用の記録を残しています。
小さな検査ですが、実機で気づく前に止められるようになったことの安心感は、思っていたよりも大きなものでした。お読みいただきありがとうございました。