メインコンテンツへスキップ
見出し画像

UI・投稿・翻訳を3層に分離した日英SaaSの運用記録

    MemberHubはUI文言・投稿コンテンツ・AI翻訳の3層を独立させたi18nで、日英両対応の会員管理SaaSを構築しています。この設計により、UI変更と顧客投稿の更新、翻訳APIの運用がそれぞれ独立して管理できます。

    同じリポジトリで混在しがちな3つの対象を切り分ける

    UI chromeはボタンやナビゲーション、エラーメッセージといったプロダクト自身が発する文字列を指します。authored contentは店舗オーナーが書く商品説明やページの見出しなど、顧客が生成するテキストです。AI-assisted translationはClaude APIで生成されるもう一方の言語訳だけを扱います。

    この3つを1つの機構にまとめると、更新頻度や権限、障害時の影響範囲が乖離するため、別々の保存形式とフォールバック規則を設けました。UIは静的JSON、コンテンツはモデル上のオーバーレイ、翻訳は単一の呼び出し関数という具合に分けています。

    UI chromeはLit SPA全体で共有する言語設定を即座に反映する必要があり、authored contentは店舗オーナーが任意のタイミングで編集します。AI翻訳は外部APIに依存するため、障害発生時の切り分けと復旧を容易にするためにも分離が有効です。各層が独立していることで、フロントエンド開発者と店舗オーナー、外部サービスの運用者がそれぞれの変更を安全に進められます。

    2,300文字列を2ファイルで保つUI chrome

    Lit SPAの翻訳は`static/js/locales/en.json`と`ja.json`の2ファイルに集約され、35の名前空間に分かれています。キーの一致はレビューで担保しており、CIは導入していません。フロントエンドはDjango gettextを避け、96行の`i18n.js`だけで言語選択・補完・イベント通知を担います。

    `initLocale()`は`localStorage['mh_lang']`、次に`navigator.language`、最後に`'en'`の順で言語を選び、カタログJSONを取得して`mh:locale-changed`イベントを発行します。`t('common.save')`はドット記法でキーを走査し、`{param}`形式の補間をサポートします。Litのベースクラス`mh-base.js`が`this.t(key, params)`を公開し、各コンポーネントが`mh:locale-changed`を購読することで、言語切り替えが再フェッチなしで瞬時に反映されます。

    キー未登録時はキー文字列をそのまま表示するフォールバックを採用しました。画面に意味のある文字列が出る点と、欠落キーが即座にわかる利点がある一方、テストで「文字列が描画されたか」を検証すると誤検知が起きやすいと認識しています。未認証ページでは別ファイルの`_t()`が同じフォールバックを再実装し、公開サイトは完全に静的なHTMLにしています。

    カタログは約90〜107KBを丸ごと取得する方式で、分割は行っていません。単一のキャッシュ可能フェッチがこの規模では複数リクエストより有利と判断されたためです。サポート言語はハードコードされており、3言語目が入ればコンテンツモデルの再設計が必要になります。

    既存カラムを崩さず、片側言語だけをJSONFieldで持つ

    店舗名や商品名はデフォルト言語のカラムに保存され、`translations`というJSONFieldに反対側の言語のみを格納します。StoreSettingsとProductが同じ形状で、既存行へのマイグレーションは不要でした。読み出しは`content(store, lang, key, baseValue)`関数で、閲覧言語がデフォルトと異なり、かつオーバーレイ値が存在する場合のみ置き換えます。

    オーナーは`default_language`を`en`または`ja`のいずれかに選び、ベースカラムには常にその言語のテキストが入ります。`translations`は反対側の言語のみを保持するため、2言語構成では最大1つのオーバーレイキーが存在するだけで済みます。`content()`は閲覧言語とストアのデフォルト言語が異なり、かつオーバーレイ値が空でない場合にのみ置換を行うため、半分しか翻訳されていないストアでもデフォルト言語に段階的にフォールバックできます。

    Pages builderでも同じオーバーレイ方式を再利用し、ブロックごとに`translations[lang][key]`を追加するだけです。バナー画像を言語ごとに変えたいときは`url`キーを追加しただけで、モデル変更やマイグレーションは発生しませんでした。`TRANSLATABLE_FIELDS`というフロントエンドのマップで、heroブロックの`headingHtml`や`subtextHtml`、imageブロックの`alt`や`caption`など、どのキーが翻訳可能かが定義されています。

    HTMLフィールドは書き込み時と読み出し時の両方でnh3によるサニタイズが行われ、オーバーレイがサニタイズの抜け道にならないようになっています。`url_ja`のようなサフィックス付きキーや`{lang: url}`マップへの再構成は既存のオーバーレイパターンを崩すため避けられました。

    1箇所に集約したClaude呼び出しと、その結果起きた障害

    翻訳ボタンはすべて`core/translate.py`の`translate_texts`関数を通します。JSONをそのまま往復させることでHTMLタグやURLを保持しつつ、4つのツールAPIは薄いラッパーとして実装されています。エラーはHTTPコードに直接対応づけ、テストはAPIをモックします。

    `translate_texts(texts, source_lang, target_lang)`はフィールドマップをJSON化して単一プロンプトを送ります。指示は「これらのJSON値を翻訳し、同じキーの有効なJSONのみを返し、HTMLタグ・URL・コード・フォーマットを保持し、ウェブサイトの読者向けに自然な表現に翻訳する」という内容です。JSON-in/JSON-outにより、単一フィールドからフォーム全体まで同一のプロンプト形状で扱え、キーの欠落やリッチテキストHTMLの破損を防げます。

    4つのツールは`store_tool/api.py`、`pages_tool/api.py`、`survey_tool/api.py`、`webform_tool/api.py`の薄いラッパーで、storeとpagesは3行程度のglueコード、surveyとwebformはdictペイロードの展開を追加しています。エラーハンドリングはAPIキーの欠如を503、未対応言語ペアを400、モデルからの不正JSONを500、上流APIエラーを502に直接対応付けています。マークダウンコードフェンスはパース前に除去され、モデルが指示に従わずJSONを囲む場合に対応しています。

    モデルが404を返し始め、AI翻訳が全機能で停止しました。呼び出し箇所が1箇所だったため、2ファイルの変更とSDKバージョンの更新だけで復旧できました。モデル応答から本文を取り出す方法もブロック種別で切り替える必要があり、1回の障害で同時に2つの問題が表面化した形です。

    2言語前提と手動翻訳という制約

    翻訳は保存時に自動実行せず、ボタン押下ごとに行うことでAPIコストと内容の制御を両立しています。カタログの整合性も運用上の規律に依存しています。カタログの同等性はCIではなくレビューで維持されており、テストではキーエコーフォールバックが「何らかのテキストが描画された」という条件を満たしてしまうため、回帰テストの盲点として記録されています。

    こうした制約の中で、96行のランタイム・1列のJSON・54行の翻訳ラッパーで日英2言語の全画面をカバーできている点は、実際に運用を始めてみて意外とバランスが取れていると感じています。

     
     
    AIを使ったフルスタックのシステムを開発しています。2025年の後半から押し寄せてきたバイブコーディングなる最恐のツールを使ってどれだけのことができるのか、開発過程を気ままに書いていきます。

    あなたへのおすすめ