M6 翻訳API(ja→en、自動補完・差分・監査)

M6では「portal_fields の“多言語UI”部分だけ」を走査すればOKです。ただし、翻訳の蓄積先(portal_translations 仮)は将来の View/スマートボタンにもそのまま拡張できる汎用設計にしておきましょう(object_type/object_key/attr_path/source_hash 方式)。


M6で“走査する”範囲(portal_fieldsに合わせる)

対象カラム(=抽出→翻訳→翻訳テーブルに集約)

  • label_i18n(JSONB)
  • help_i18n(JSONB)
  • placeholder_i18n(JSONB)
  • unit_i18n(JSONB)
  • selection_items(JSONB 配列)
    • 各要素の label_i18n(例:selection_items[i].label_i18n
  • (任意)notes(TEXT)
    • 将来レビュー運用を見据えて、attr_path="notes" として別URLに英訳を出せるようにしておくと便利です

対象外(M6では翻訳しない)

  • widget, ui_control, groups_xml_ids(仕様/制御情報であり翻訳不要)
  • default_value形式に応じて
    • 文字列や i18n 可能なJSONの場合のみ対象
    • 数値/コード/IDは対象外
    • 値の翻訳は translate = true の行のみ 実施(UI文言は translate に依らず常に対象)

抽出キー(翻訳テーブルへの格納単位)

  • object_type: "field" 固定(M6)
  • object_key: "{model}::{field_name}"(例:res.partner::x_customer_rank
  • attr_path の例:
    • label_i18n
    • help_i18n
    • placeholder_i18n
    • unit_i18n
    • selection_items[key=bronze].label_i18n
    • notes
    • (必要に応じて) default_value

言語と重複制御

  • source_lang="ja_JP", target_lang="en_US"
  • source_hash(原文の正規化文字列に対するハッシュ)を採用
    (object_type, object_key, attr_path, target_lang, source_hash) をユニーク相当にして二重翻訳防止
    → 原文が変われば source_hash も変わり、新規レコードで追跡

別URL(配信用)の考え方(M6)

  • GET /i18n/fields(表示専用)
    • クエリ:target=en_US&model=res.partner など
    • 内部では翻訳テーブルの“最新有効版”を集約して返す(原文は portal_fields
    • レスポンス構造は「object_key ごとに、label/help/placeholder/unitselection_items を再構成」
    • notes を返す場合は notes_translated のように分かる形に

将来、/i18n/views/i18n/smart-buttons を追加しても、同じ翻訳テーブルを object_type で切替えるだけでOK。


いま“View/スマートボタン”も走査すべき? → NO(M6は見送り推奨)

  • 理由:
    1. 抽出元がまだ未定義(Viewはxml_idarch_db、スマボはir.ui.view/ir.model/カスタム定義など)
    2. まず**fieldsだけで翻訳パイプライン(抽出→差分判定→翻訳→集約→別URL配信)**を完成させるのが速い
    3. 翻訳テーブルを汎用設計にしておけば、後追いで object_type=view/smart_button を追加するだけで拡張できる

受け入れ条件(M6・改版)

  • portal_fields非破壊(英語は書き込まない)
  • 上記の 対象カラムからのみ抽出 し、未訳・原文差分ありのものだけを翻訳テーブルへ 追記
  • translate=true は「値の翻訳(selection/default)」の実施フラグとして解釈(UI文言は常に対象)
  • GET /i18n/fields でモデル/キー指定の別URL表示ができる
  • 監査情報(translation_source=openaicreated_at/updated_at/updated_by 等)を保持
  • 再翻訳トリガ:source_hash 変化 または portal_fields.updated_at の差分検知

まずは「外枠」を一気に用意して、Swaggerから1件テストできる最小構成を作ります。
(まだOpenAI呼び出しはダミー/既存値は上書きしない/差分のみ更新)


1) services/i18n.py(ダミー翻訳+JSON再帰変換)

# app/services/i18n.py
from __future__ import annotations
from typing import Any, Dict, Tuple

class I18nTranslator:
    """
    ダミー翻訳器。
    - 方針:ja_JPがあればそれをそのままtargetへコピー(本番はOpenAIに差し替え)
    - 既存target値は上書きしない(常にnon-destructive)
    """

    def __init__(self, target_lang: str = "en_US") -> None:
        self.target_lang = target_lang

    def translate_text(self, text: str, src_lang: str = "ja_JP") -> str:
        # ★ダミー:現状は機械翻訳せず、そのまま返す
        # TODO: OpenAI呼び出しに差し替え(text-translation)
        return text

    def _ensure_i18n_dict(self, v: Any) -> Dict[str, str] | None:
        # label_i18n / help_i18n / notes 等、{"ja_JP": "...", "en_US": "..."}の形だけ対象
        if isinstance(v, dict) and any(k.endswith("_US") or k.endswith("_JP") for k in v.keys()):
            return v  # そのまま扱う
        return None

    def _maybe_fill_target(self, d: Dict[str, str]) -> Tuple[Dict[str, str], bool]:
        """
        d は {"ja_JP": "...", ...} のような辞書を想定。
        targetキーが無ければ ja_JP を翻訳して追加。
        変更があった場合は True を返す。
        """
        if self.target_lang in d:
            return d, False  # 既存値は上書きしない
        # 優先:ja_JP → 他言語があればその最初
        source_text = d.get("ja_JP", None)
        if source_text is None:
            # ja_JPが無い場合は、適当な最初の言語をソースに
            for k, v in d.items():
                if k != self.target_lang and isinstance(v, str):
                    source_text = v
                    break
        if source_text is None:
            return d, False
        d = dict(d)  # defensive copy
        d[self.target_lang] = self.translate_text(source_text, src_lang="ja_JP")
        return d, True

    def fill_missing_recursively(self, obj: Any) -> Tuple[Any, bool]:
        """
        JSONを再帰走査。
        - *_i18n辞書(label_i18n / help_i18n / notes / 任意)を検知しtargetを補完
        - list内の要素やネスト(selection_items[].label_i18n など)にも対応
        何か1つでも変化があれば第二戻り値がTrue
        """
        changed = False

        # i18n辞書なら優先対応
        i18n_dict = self._ensure_i18n_dict(obj)
        if i18n_dict is not None:
            new_i18n, ch = self._maybe_fill_target(i18n_dict)
            return new_i18n, ch

        # list / dict 再帰
        if isinstance(obj, list):
            new_list = []
            for item in obj:
                new_item, ch = self.fill_missing_recursively(item)
                new_list.append(new_item)
                changed = changed or ch
            return new_list, changed
        if isinstance(obj, dict):
            new_dict = {}
            for k, v in obj.items():
                new_v, ch = self.fill_missing_recursively(v)
                new_dict[k] = new_v
                changed = changed or ch
            return new_dict, changed

        # プリミティブ値はそのまま
        return obj, False

2) routers/translate.py(POST /fields/translate

# app/routers/translate.py
from __future__ import annotations
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Query
from sqlalchemy import select, update, func, text
from .. import db
from ..services.i18n import I18nTranslator

router = APIRouter(prefix="/fields", tags=["fields"])

# ポータル側のJSONB候補。存在チェックしてから使う(無い列は自動スキップ)
TRANSLATABLE_COLUMNS = ["label_i18n", "help_i18n", "notes", "selection_items"]

def _column_exists(col: str) -> bool:
    return hasattr(db.fields_table.c, col)

@router.post("/translate")
def translate_fields(
    target: str = Query("en_US", description="補完する言語キー。既存は上書きしません"),
    only_missing: bool = Query(True, description="targetが無い行のみ対象にする簡易フィルタ"),
    limit: int = Query(100, ge=1, le=1000, description="最大処理件数"),
    dry_run: bool = Query(False, description="Trueなら更新せず差分プレビューを返す"),
    ids: Optional[List[int]] = Query(None, description="特定IDに限定して処理(カンマ区切りOK)"),
) -> Dict[str, Any]:
    t = db.fields_table
    cols = [c for c in TRANSLATABLE_COLUMNS if _column_exists(c)]
    if not cols:
        return {
            "processed": 0,
            "updated": 0,
            "dry_run": dry_run,
            "message": "Translatable JSONB columns not found in portal_fields.",
            "candidate_columns": TRANSLATABLE_COLUMNS,
        }

    # 対象行の絞り込み
    where_clauses = []
    if ids:
        where_clauses.append(t.c.id.in_(ids))
    elif only_missing:
        # いずれかのJSONBに target キーが欠けている行を拾う(存在する列のみ)
        missing_checks = []
        for col in cols:
            # COALESCE( (col->>'en_US'), '' ) = ''
            missing_checks.append(
                func.coalesce(text(f"{col}->>:target"), "").eq("")  # bindparamでtarget渡す
            )
        # OR条件
        from sqlalchemy import or_
        where_clauses.append(or_(*missing_checks))

    # SELECT
    stmt = select(t).limit(limit)
    if where_clauses:
        from sqlalchemy import and_
        stmt = stmt.where(and_(*where_clauses))

    # bind params(text()内の :target 用)
    bind_params = {"target": target}

    conn = db.engine.connect()
    try:
        rows = conn.execute(stmt, bind_params).mappings().all()
        translator = I18nTranslator(target_lang=target)

        processed = 0
        updated = 0
        previews = []  # 変更差分のサンプル(最大20件)
        to_update: List[Dict[str, Any]] = []

        for row in rows:
            processed += 1
            row_updates: Dict[str, Any] = {}
            row_preview: Dict[str, Any] = {"id": row["id"], "diff": {}}

            for col in cols:
                val = row[col]
                if val is None:
                    continue
                new_val, changed = translator.fill_missing_recursively(val)
                if changed:
                    row_updates[col] = new_val
                    # プレビュー用に before/after を軽く
                    if len(previews) < 20:
                        row_preview["diff"][col] = {"before": val, "after": new_val}

            if row_updates:
                updated += 1
                # updated_at があれば NOW() を入れる(無ければスキップ)
                if _column_exists("updated_at"):
                    row_updates["updated_at"] = func.now()

                if dry_run:
                    if len(previews) < 20 and row_preview["diff"]:
                        previews.append(row_preview)
                else:
                    to_update.append({"id": row["id"], "values": row_updates})

        # 実更新
        if not dry_run and to_update:
            with conn.begin():
                for item in to_update:
                    conn.execute(
                        update(t).where(t.c.id == item["id"]).values(**item["values"])
                    )

        return {
            "processed": processed,
            "updated": updated if not dry_run else len(previews),
            "dry_run": dry_run,
            "preview_samples": previews,
            "note": "Existing values are never overwritten. Only missing target keys are filled.",
        }
    finally:
        conn.close()

ポイント

  • only_missing=true時はSQL側で「targetキーが空の候補」をざっくり拾い、Python側で厳密に再帰補完。
  • selection_itemsのような配列ネストも再帰で補完。
  • dry_run=trueで差分プレビュー(最大20件)を返し、非破壊で検証できます。
  • updated_at列があれば自動でnow()に更新(列がなければスキップ)。

3) 既存ルータの登録(main.py)

# app/main.py
from fastapi import FastAPI
from .routers import translate  # 追加
from .routers import fields     # 既存のCRUDルータ等

app = FastAPI(title="Dev-Portal API")

app.include_router(fields.router)
app.include_router(translate.router)  # 追加

4) DBリレーション(既存のdbモジュール想定)

既に「Inspector + Table(…, autoload_with=engine, schema=’public’)」でdb.fields_tableが定義済み、という想定のまま動作します。追加作業は不要です。


5) (任意)updated_at列が無い場合の軽量DDL

ALTER TABLE public.portal_fields
  ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();

無くても動きますが、差分実行の監査やM7以降の同期に便利です。

A. イメージをビルドしてデプロイを更新(推奨)

# 0) Minikube 起動(プロファイル dev32)
minikube -p dev32 start

# 1) Minikube の Docker デーモンを使う
eval $(minikube -p dev32 docker-env)

# 2) Dev-Portal API のプロジェクトへ移動してビルド(タグは新しく)
cd /path/to/dev-portal-api
docker build -t dev-portal-api:v6 .

# 3) デプロイ名を確認(例: dev-portal-api)
kubectl -n dev get deploy

# 4) イメージを差し替え → ロールアウト
kubectl -n dev set image deployment/dev-portal-api dev-portal-api=dev-portal-api:v6

# 5) ロールアウト完了待ち
kubectl -n dev rollout status deployment/dev-portal-api

# 6) ログで起動確認
kubectl -n dev logs deployment/dev-portal-api -f --tail=100

補足:もしデプロイ名が異なる場合は、kubectl -n dev get deployで確認した名前に置き換えてください。


B. 同じタグで上書きした場合(強制再起動)

# 同じタグ(例: v5)で再ビルドしただけなら、Podを再起動して読み直させる
eval $(minikube -p dev32 docker-env)
cd /path/to/dev-portal-api
docker build -t dev-portal-api:v5 .

# ロールアウト再起動(イメージは同じでもPodを作り直す)
kubectl -n dev rollout restart deployment/dev-portal-api
kubectl -n dev rollout status deployment/dev-portal-api

C. ConfigMap を変えた場合(envや設定を更新しただけ)

# 1) ConfigMap を適用(パスはあなたのYAMLに合わせて)
kubectl -n dev apply -f k8s/dev-portal-configmap.yaml

# 2) デプロイを再起動(ConfigMapのローリング反映)
kubectl -n dev rollout restart deployment/dev-portal-api
kubectl -n dev rollout status deployment/dev-portal-api

D. Ingress のポートフォワード(8080)を張り直し

# 裏でPFする(PID管理)
kubectl -n ingress-nginx port-forward svc/ingress-nginx-controller 8080:80 \
  > /tmp/pf_ingress.log 2>&1 & echo $! > /tmp/pf_ingress.pid

# 停止する場合
# kill $(cat /tmp/pf_ingress.pid) 2>/dev/null || pkill -f 'port-forward.*ingress-nginx-controller.*8080:80'

E. スモークテスト

# Swagger が出ればOK
curl -I http://api.local:8080/docs

# 追加した翻訳API(前回の /fields/translate を採用している場合の例:ドライラン)
curl -s -X POST "http://api.local:8080/fields/translate?target=en_US&only_missing=true&limit=10&dry_run=true" | jq .

もし今回 /i18n/translate にリネームしているなら、そのURLで同様にテストしてください。


F.(任意)Postgres 側の到達チェック

# Postgres Pod を確認
kubectl -n infra get pods

# Pod 名を使ってpsqlで接続(例: postgres-... を適宜置換)
kubectl -n infra exec -it <postgres-pod-name> -- psql -U dev -d devportal -c "\dt public.portal_fields"

上から順にそのまま実行すれば、配置したファイルの変更が反映され、http://api.local:8080/docs から新ルータ(翻訳API)が確認できるはずです。

  • portal_fields 自体は一切更新しません。
  • APIを叩くと “翻訳候補” を抽出して、翻訳専用テーブル(例:portal_translations)に集約挿入します。
  • OpenAI未接続の今は “実翻訳” はしません。 運用モードにより挙動は2通りにできます。

いまの挙動(OpenAIなし)

  • 対象: label_i18n / help_i18n / placeholder_i18n / unit_i18n / selection_items[].label_i18n(+任意でnotes/default_value
  • 抽出キー: object_type="field", object_key="{model}::{field_name}", attr_path=…
  • 重複防止: 原文のハッシュ(source_hash)で、同じ原文は二重挿入しない
  • 書き込み先: portal_translations のみ(portal_fieldsは非破壊)

モード(おすすめはA)

モードtranslated_text用途備考
A. ミラー(推奨)ja_JPをそのまま入れるUI配信・別URLのE2E確認がすぐできるtranslation_source="dummy", status="draft"
B. スケルトンNULL(空)翻訳未実施を明確化UIはフォールバック(ja)を使う想定

※ どちらも portal_fields には書きません。OpenAI接続後に再実行すれば、translation_source="openai"の“本物の訳”を追加できます(source_hashで差分だけ)。

フィルタと実行のイメージ

  • only_missing=true: まだ対象言語(例:en_US)の訳が無い(または原文が更新された)ものだけ抽出
  • ids=… / model=… / limit=…: 範囲を絞って安全に実行
  • dry_run=true: 挿入せずに 何件候補が出るか&サンプルをプレビュー

まとめ

  • このAPI=「翻訳候補の収集と集約」(現状はダミー)。
  • 実翻訳はOpenAI接続後に同じAPIで行い、既存候補は上書きせず、新しい原文に対してだけ追加挿入(履歴・監査を保全)。

Comments

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です