0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

混在言語の.docxを「書式を壊さず」翻訳する中間表現(DTIR)を設計し、仕事レベルの4つの穴を塞いだ話

0
Last updated at Posted at 2026-06-12

概要:「1つの Word ファイルの中に、オランダ語・フランス語・ドイツ語・日本語が段落ごとに混ざっている。これを書式・目次・画像を崩さずにまとめて翻訳したい」——DeepL コミュニティで見かけたこの相談を出発点に、ドキュメント翻訳のための中間表現 DTIR (Document Translation Intermediate Representation) を設計し、reader / translate / writer の MCP サーバ群として実装しました。

素朴に作れば「動く」ものはすぐできます。が、契約書・技術文書といった"仕事レベル"の書類に通そうとすると、表・脚注・ハイパーリンク・追跡変更・用語の一貫性・長文・段内書式…と、ボロボロ穴が出ます。本記事は、その穴を torture フィクスチャで定量化しながら 4 つ塞ぐまでの設計と実装、そして最後に runs モード(段内書式保持)の DeepL とローカルLLMの実機比較までを一気通貫で書いた、長めの保存版です。

対象読者・前提知識

正直に言うと、この記事は次の 3 つを「なんとなく知っている」と読みやすいです(深い知識は不要、要所で補足します)。

  • MCP (Model Context Protocol):LLM から外部ツールを呼ぶ仕組み。本記事では reader / translate / writer を MCP サーバとして実装しますが、設計の話は MCP を知らなくても追えます。→ 公式 modelcontextprotocol.io
  • OOXML / WordprocessingML.docx の実体は zip + XML で、段落 w:p > ラン w:r > テキスト w:t という入れ子構造。本文で都度かみ砕きます
  • LLM / 翻訳APIのバッチ翻訳の特性:テキスト配列をまとめて投げる、戻り配列長を保つ(「境界」)、構造化出力(JSON)を強制する…といった感覚。DeepL の text[] や OpenAI 互換 chat completions を触ったことがあると楽。

逆に、これらに全く触れたことがないと骨かもしれません。その場合は細部は飛ばして、「中間表現を1枚噛ませて"触っていいもの/触ってはいけないもの"を分離する」という設計の発想だけ持ち帰ってもらえれば十分です。

何を作ったか(全体像)

.docx(WordprocessingML) を入力に、翻訳対象のテキストだけを抜き出したセグメント表 = DTIR に変換し、翻訳エンジン(DeepL / ローカルLLM)で訳を充填し、元ファイルを基板に id 単位でパッチして訳 docx を書き戻す。これだけです。ポイントは、書式・画像・sectPr・目次フィールドといった「触ると壊れるもの」を 一切 IR に乗せないこと。

リポジトリは polyrepo で 5 つに分けています。

パッケージ 役割
doc-translation-ir 共有契約(DTIR v0.1)。型・スキーマ・検証・フィクスチャのみ。サーバではない
dtir-ooxml-reader-mcp docx → DTIR セグメント表 (docx_to_dtir)
dtir-translate-mcp DTIR の translation を充填 (translate_dtir)
dtir-ooxml-writer-mcp 訳済み DTIR + 元 docx → 訳 docx (dtir_to_docx)
dtir-docx-pipeline E2E ハーネス / ヘッドレス CLI / xCOMET 品質ゲート

構成は TypeScript × ESM × @modelcontextprotocol/sdk。各 MCP は契約パッケージに「型としてのみ」依存し、実行時依存はありません(後述)。

なぜ素朴な方法ではダメなのか

最初に「やってはいけない素朴解」を潰しておきます。

  • 段落ごとに翻訳APIを叩く → 段落数ぶんリクエストが飛んでコスト爆発。混在言語だと source_lang も段落ごとに変わる。
  • テキストを全部引っこ抜いて翻訳し、順番に戻す → 書式ラン(太字・色・リンク)、目次フィールド、画像の位置、sectPr が壊れる。OOXML はテキストと書式が <w:r>(ラン)単位で絡み合っているので、雑に戻すと崩壊する。
  • 言語の自動判定に丸投げ → 段落単位で言語が変わる文書では、ドキュメント全体に1つの source_lang を当てると誤訳の温床。

だからこそ「中間表現」を挟む —— 関心の分離

根っこの問題は 1 つです。OOXML ではテキストと書式が <w:r>(ラン)単位で分かちがたく絡んでいる。だから docx を直接いじると壊れるし、テキストだけ抜いて戻すと書式が迷子になる。翻訳 API を docx に直接当てる、という発想そのものが筋が悪い。

解き方はシンプルで、「翻訳対象のテキストだけ」を中立な中間表現(IR)に抜き出し、書式・構造・画像といった"触ると壊れるもの"は不透明な anchor(ロケータ)の裏に隠す。こうすると効くことが 3 つあります。

  • 翻訳ステージは純粋なテキスト配列だけを見る。書式を知らないので、書式を壊しようがない。
  • OOXML を知っているのは reader(docx→IR) と writer(IR→docx) の 2 箇所だけ。フォーマット依存の知識をそこに封じ込められる。
  • 翻訳エンジンを DeepL ↔ ローカルLLM で差し替えても、将来 PDF など別フォーマットに広げても、中間表現は同じ。中間段(言語解決・翻訳・品質評価)は IR の形しか知らない。

これが「なぜ翻訳 API を直接 docx に当てず、わざわざ中間表現を 1 枚噛ませるのか」への答えです。一言でいえば 関心の分離——壊していいもの(テキスト)と壊してはいけないもの(構造・書式)を、型の上で物理的に分ける。この中間表現が DTIR です。

設計の核:DTIR という中間表現

DTIR の設計思想は 3 つの原則に集約できます。本記事に出てくる実装判断は、ほぼ全部この 3 つから導かれます。

  1. fail-safe:IR に乗ったテキストだけ触る。未対応の構造は「未翻訳で残る」だけで壊れない。
  2. 境界保持:翻訳は配列で渡し、連結しない・戻り長=入力長を強制。バッチもセグメント境界を割らない。
  3. opt-in・後方互換:新機能は既定オフ。契約は最小に保ち、既存挙動は変えない。

順に、まず最重要の fail-safe から。

fail-safe 原則 ——「IR に乗ったテキストだけ触る」

DTIR の最重要思想は fail-safe です。reader は「翻訳対象の <w:t> テキスト」だけをセグメント表に抽出し、書式 XML は anchor.ref(ロケータ)に隠して IR の表面に出しません。writer は元ファイルを基板に、その anchor で要素を特定して id 単位でテキストだけ差し替えます。

この設計の効能は明確で、未対応の構造があっても「未翻訳のまま原語で残る(安全)」だけで、「文書破壊・レイアウト崩壊」は原理的に起きない。法律文書のような「壊れるくらいなら訳されない方がマシ」な領域に向いた失敗モードです。これは後で 4 つの穴を塞ぐときに効いてきます——どの穴も "coverage(取りこぼし)の穴" であって "corruption(破壊)の穴" ではない

セグメント表の構造

reader が出す各セグメントは、おおむねこんな形です(抜粋)。

{
  "id": "seg_xxx", // anchor から決定的に導出(part + 構造パスのハッシュ)
  "anchor": {
    // writer が戻す場所。中間段は不透明に扱う
    "format": "docx",
    "ref": {
      "part": "word/document.xml",
      "path": "/w:body[1]/w:p[12]",
      "runIds": ["r0", "r1"],
    },
  },
  "role": "body", // heading / table-cell / footnote / header ...
  "text": {
    "source": "...",
    "hasInlineFormatting": true,
    "runs": [
      /* ランのオフセット */
    ],
  },
  "language": { "value": "de-DE", "source": "detect" },
  "translatable": true, // フィールド/数値/空は false = 不可触
  "group": "de-DE", // 翻訳バッチのキー(= source言語)
  "translation": null, // translate ステージが埋める
}

anchor.ref の中身は reader と writer の完全合意が必要ですが、その間の中間ステージ(言語解決・翻訳・品質評価)からは不透明に扱われます。だから翻訳エンジンを差し替えても、品質評価器を足しても、anchor の形は知らなくていい。

group(source言語)単位のバッチ翻訳

混在言語のコスト問題は、段落を text[] 配列にまとめて 1 リクエストで投げることで解きます。DeepL のテキスト API は配列入力を受け、source_lang を省略すると配列の各要素の言語を個別に自動判定します(応答に項目ごとの detected_source_language が返る)。「段落ごとに1リクエスト」をやめて配列にまとめれば、コール数は 段落数 ÷ 配列上限 に収まります。

このパイプラインでは、その配列を source 言語でグループ化して管理しています(下図)。ただしこれは用語集(glossary)を言語ペア単位で当てるため(DeepL の glossary_id は source→target ペアに紐づく)と source_lang を明示するためで、コール最小化が目的ではありません。

「1リクエストは配列、連結しない、戻り配列長=入力長を強制」という境界保持を契約にしておくことで、訳の取り違えを構造的に防ぎます。

【追記・訂正】 公開当初この節は「グループ化でコール数が言語数に収束=コストの肝」と書いていましたが、不正確でした。コール削減の本質は 配列でまとめることです。DeepL は source_lang 省略時に配列の各項目を個別判定するため、言語ごとのグループ化は必須ではありません(むしろ言語境界でバッチが割れるので、小さな文書ではかえってコールが増えうる。この7段落の例も、全部1配列なら1コールで済みます)。本パイプラインが source 言語で group 化しているのは、主に glossary を言語ペア単位で当てるための実装都合で、コール最小化のためではありません。

ここまでで「動く」。でも"仕事レベル"には穴が 4 つ

混在言語の基本パイプラインはこれで動きます。実際、蘭仏独日が混ざった意地悪 docx を DeepL で訳し、xCOMET(品質評価) 平均 0.99 まで確認しました。

しかし契約書・技術文書を通そうとすると、reader の挙動を追うだけで穴が見えてきます。重要なのは、主観で「たぶん大丈夫」と言わず、torture フィクスチャ(実務文書で頻出する構造を 1 ファイルに詰めた意地悪 docx)で機械的に定量化すること。

torture に仕込んだ構造:表(結合セル・混在言語)、ハイパーリンク、脚注、追跡変更(w:ins)、段内太字。これを reader にかけ、「どの probe テキストが抽出できたか」を数えます。初版の結果は 7 probe 中 6 つ取りこぼし+2 文が分断。穴は 4 つの根本原因に集約されました。

# 症状
reader が「コンテナ直下の w:p / 段落直下の w:r」しか走査しない 表・脚注・リンク・追跡変更を取りこぼす。文も分断
writer の collapse で段内書式が消える 太字・色・リンクが先頭ランに統一される
バッチが言語別のみでサイズ非考慮 単一言語の長文 → 巨大1バッチ → 上限超過の恐れ
glossary 未対応 用語の一貫性が保証されない

以降、これを ① → ④ → ③ → ② の順で塞いでいきます(安全で副作用が小さい順)。すべて fail-safe を保ったまま、既存契約は後方互換、既定挙動は不変(opt-in)で着地させるのが裏テーマです。

① 再帰走査 —— 表・脚注・リンク・追跡変更を拾う

何が起きていたか

初版 reader は、こう走査していました。

// 旧: コンテナ直下の w:p しか見ない
const paragraphs = childElements(body).filter((e) => e.tagName === 'w:p');
// 旧: 段落直下の w:r しか見ない
const runs = childElements(p).filter((e) => e.tagName === 'w:r');

OOXML では、表のセル内段落は w:tbl > w:tr > w:tc > w:p という入れ子の奥にあります。ハイパーリンクの表示テキストは w:hyperlink > w:r、追跡変更の挿入は w:ins > w:r の中。脚注は document.xml ですらなく footnotes.xml という別パート。「直下だけ」走査は、これらを全部スルーしていました。

さらに悪いことに、ハイパーリンク段落 See [the signed contract] for the full terms. は、リンク内の run を飛ばすため "See for the full terms."文が分断されて翻訳に渡る。意味が崩れます。

再帰走査へ

段落探索とラン探索を両方再帰化します。鍵は、writer と一致する構造パスを作ること。

// 段落を documentElement から再帰収集。同名兄弟内の 1 始まり連番でパスを作る
function collectParagraphs(
  el: El,
  prefix: string,
  inTableCell: boolean,
  hits: ParaHit[],
): void {
  const counts = new Map<string, number>();
  for (const c of childElements(el)) {
    const idx = (counts.get(c.tagName) ?? 0) + 1;
    counts.set(c.tagName, idx);
    const childPath = `${prefix}/${c.tagName}[${idx}]`;
    if (c.tagName === 'w:p') {
      hits.push({ p: c, path: childPath, inTableCell });
      continue;
    }
    if (
      (c.tagName === 'w:footnote' || c.tagName === 'w:endnote') &&
      c.getAttribute('w:type')
    )
      continue; // separator はスキップ
    collectParagraphs(c, childPath, inTableCell || c.tagName === 'w:tc', hits);
  }
}

// 段落内のテキストランを読み順で再帰収集。hyperlink/ins/smartTag 内も連結、w:del は除外
function collectRunEls(p: El): El[] {
  const out: El[] = [];
  const walk = (el: El) => {
    for (const c of childElements(el)) {
      if (c.tagName === 'w:del') continue; // 削除済みテキストは対象外
      if (c.tagName === 'w:r') {
        out.push(c);
        continue;
      }
      walk(c);
    }
  };
  walk(p);
  return out;
}

パスは /w:body[1]/w:tbl[1]/w:tr[2]/w:tc[1]/w:p[1](表セル) や /w:footnote[3]/w:p[1](脚注) のように、part ルートからの構造パスになります。footnotes.xml / endnotes.xml もパート列挙に追加し、role に table-cell / footnote / endnote を持たせました。

writer も協調させる

reader が richなパスを吐くようになったので、writer の anchor 解決も汎用ナビゲータに置き換えます。/w:body/w:p[i] 専用の正規表現をやめ、任意の入れ子を辿れるように。

function navigatePath(doc: Document, path: string): El | null {
  let cur: El | null = doc.documentElement;
  for (const seg of path.split('/').filter(Boolean)) {
    const m = /^([\w:]+)\[(\d+)\]$/.exec(seg);
    if (!m || !cur) return null;
    cur =
      childElements(cur).filter((e) => e.tagName === m![1])[
        Number(m![2]) - 1
      ] ?? null;
  }
  return cur;
}

reader の連番と writer の連番が**同じ規則(同名兄弟内 1 始まり)**なので、脚注の separator をスキップしても両者のインデックスが一致します。「<w:t> だけ触る」不変条件はそのまま維持。

結果

torture カバレッジは 6/7 漏れ → 0。文分断も解消(See the signed contract for the full terms. が 1 文で渡る)。anchor の形が変わったので groundtruth / 静的 reader DTIR を再ベースラインし、適合性スイートは全緑に戻しました。後にこの torture を常設のカバレッジ回帰テスト(npm run test:torture)に昇格させ、「漏れ 0」を機械保証しています。

④ glossary —— 用語の一貫性

法律・技術文書では用語の訳ブレが品質を直撃します。xCOMET のようなスコアは「全体の質」を測りますが、用語一貫性は保証しません。そこで inline な用語対を単一の真実源にし、両エンジンへ橋渡しします。

interface Glossary {
  target: string;
  bySource: Record<string, { source: string; target: string }[]>; // source言語ごと。'*'は共通
  deeplIds?: Record<string, string>; // DeepL用: source言語 → 事前作成済 glossary_id
}

混在言語に対応するため source 言語ごと(bySource)に引きます。解決は「完全一致 → 主サブタグ(de-DE→de) → '*'」の順で集約し、source 語で先勝ち重複排除。

  • LLM エンジン: 解決した用語対をプロンプトに強制注入(「Glossary (MANDATORY): "源" => "訳"」)。外部状態なし・決定論的。
  • DeepL エンジン: source 言語に対応する glossary_id を付与(DeepL 本来の機構)。inline 用語対から createDeeplGlossary で id を作るヘルパも用意。

実訳例:glossary 有り/無しの差(ローカルLLM tower-plus:9b, EN→JA)

英独混在のサンプル取扱説明書から、用語が反復する 3 文を抜き、用語集なしhouse-style を強制する用語集ありで訳し比べた実機結果です(同一モデル・同一文)。

用語集(house ルール):

{ "target": "ja", "bySource": { "en": [
  { "source": "API endpoint",      "target": "API接続先" },
  { "source": "Firmware",          "target": "本体ソフトウェア" },
  { "source": "management console","target": "管理画面" }
]}}
原文(用語) 用語集なし(モデル既定) 用語集あり(house-style 強制)
API endpoint APIエンドポイント API接続先
Firmware ファームウェア 本体ソフトウェア
management console 管理コンソール 管理画面

文単位で見ると:

[なし] ファームウェアの更新は公式管理コンソールのみを通じて行わなければなりません。
[あり] 本体ソフトウェアのアップデートは公式管理画面のみで行ってください。

[なし] ネットワーク設定でAPIエンドポイントと認証トークンを入力してください。
[あり] ネットワーク設定にAPI接続先と認証トークンを入力してください。

API endpoint は別々の段落(s1・s2)に登場しますが、用語集ありでは両方とも "API接続先" に揃います。これが用語集の本質的な価値——訳ブレを潰し、承認済みの用語に固定すること。xCOMET のようなスコアでは保証できない一貫性を、ここで担保します。

面白いのは、tower-plus:9bruns モードの <x> タグは落とすのに、glossary のプロンプト注入には素直に従ったこと。「XML タグの保持」より「この語はこう訳せ」という自然言語の指示の方が、翻訳特化モデルには通りやすいようです。エンジン特性は機能ごとに測るべし、という教訓。

用語集は「翻訳設定」であって「文書構造」ではないので、DTIR 契約には載せず translate パッケージ内に閉じ込めました。契約は最小に保つ、という線引きです。

③ サイズバッチ —— 長文の上限対策

「言語グループ = 1 バッチ」は、単一言語の長い契約書 1 本だと巨大バッチになり、DeepL のリクエスト上限や LLM のコンテキスト上限を超えかねません。そこで各グループをサイズ上限でチャンク分割します。

最重要の不変条件は セグメント境界を絶対に割らないこと。チャンクは常にセグメントの整数個で、単一段落が maxChars を超えてもそれだけで 1 チャンクにします(段落テキストを途中で割ると再結合・誤訳のリスクがある)。

function chunkBySegments(
  segs: IRSegment[],
  limits: BatchLimits,
): IRSegment[][] {
  const chunks: IRSegment[][] = [];
  let cur: IRSegment[] = [],
    curChars = 0;
  for (const s of segs) {
    const len = s.text.source.length;
    const exceeds =
      cur.length > 0 &&
      (cur.length >= limits.maxItems || curChars + len > limits.maxChars);
    if (exceeds) {
      chunks.push(cur);
      cur = [];
      curChars = 0;
    }
    cur.push(s);
    curChars += len;
  }
  if (cur.length) chunks.push(cur);
  return chunks;
}

チャンク分割は **orchestration 層(translateDtir)**に置き、Translator 抽象は「渡された配列を訳す」ままに保ちました。エンジン別の既定は DeepL=50件/120000字、LLM=20件/4000字(狭いコンテキスト向けに小さめ)。小さい文書では既定が事実上 no-op になるよう緩く設定し、既存の batchCalls 挙動を不変に保っています。stats.chunked で分割回数が見えます。

② 脱collapse —— 段内の太字・色・リンクを保持する

これが一番難しい穴です。writer の既定は collapse:訳文を段落の先頭テキストランに入れ、残りの <w:t> を空にする。結果、Payment is **mandatory** now. の太字が消えます。

なぜ難しいか。翻訳後のテキストのどの部分が太字か、writer には分からないから。原文のラン境界(オフセット)は分かっても、訳文は語順も語数も変わるので、原文ランと訳文を素朴に対応づけられません。長さ按分で割ると、太字が単語の途中に乗ったり、まるで違う語に乗ったりして品質が破綻します。

インラインマーカー方式

正攻法は、各ランをタグで包んで翻訳エンジンに渡し、エンジンにタグを訳語スパンへ移動させること。DeepL の tag_handling=xml がまさにこれ。LLM にはプロンプトで「タグを保持せよ」と指示します。

// 原文ランを <x id> で包む(オフセットで切る)
function wrapRunsMarkup(source: string, runs: SegmentRun[]): string {
  return runs
    .map((r, i) => `<x id="${i}">${esc(source.slice(r.start, r.end))}</x>`)
    .join('');
}
// 訳側マークアップを「ラン別訳文の配列」に復元。失敗時は null(→ collapse フォールバック)
function parseRunsMarkup(marked: string, runCount: number): string[] | null {
  const out = new Array(runCount).fill('');
  const seen = new Set<number>();
  for (const m of marked.matchAll(/<x id="(\d+)">([\s\S]*?)<\/x>/g)) {
    const id = Number(m[1]);
    if (id < 0 || id >= runCount) return null;
    out[id] += unesc(m[2]);
    seen.add(id);
  }
  if (seen.size !== runCount) return null; // タグ欠落
  if (/\S/.test(marked.replace(/<x id="\d+">[\s\S]*?<\/x>/g, ''))) return null; // タグ外漏れ
  return out;
}

復元できたら契約に追加した任意フィールド translation.runTexts(ラン別訳、連結=text)に入れ、writer が各ランへ 1:1 で分配します。復元に失敗したら自動で collapse にフォールバック——ここが fail-safe の肝です。

inlineFormatting: 'collapse' | 'runs'opt-in(既定は collapse)にしたので、既存テストは全部そのまま緑。新挙動は明示的にオンにしたときだけ働きます。

runs モードのエンジン特性 —— DeepL vs ローカルLLM の実機比較

ここが本記事の山場です。「タグを保持できるエンジン前提」と言いましたが、実際どこまで保持できるのか。モックでは「エンジンがタグを訳語スパンへ動かす」と仮定していた部分を、本物で検証しました。

検証には、torture に de→en で太字語が文中→文末へ大きく動くケースをわざと追加しました。これがタグ追従の最難ケースです。

  • 原文(3ラン): [通常]"Der Vertrag wurde " [太字]"gestern" [通常]" unterzeichnet."
  • 期待訳: The contract was signed yesterday.("gestern→yesterday" が文末へ移動

DeepL の結果

// engine=deepl, inlineFormatting=runs
"translation": {
  "text": "The contract was signed yesterday.",
  "runTexts": ["The contract was ", "signed", " yesterday."]  // ← 太字(run1) = "signed"
}

タグは保持された。が、太字が "signed" に乗った。 本来太字にしたい "yesterday" は文末へ動いたのに、DeepL はタグを意味ではなく出力の位置順で配るので、id1 が真ん中の "signed" を包みました。

ローカルLLM(tower-plus:9b)の結果

// engine=llm, inlineFormatting=runs (neko8 の Ollama / tower-plus:9b)
"translation": {
  "text": "The contract was yesterday signed."
  // runTexts なし → タグ除去で復元失敗 → collapse フォールバック
}

翻訳特化モデルは <x> タグを翻訳の邪魔物として落としparseRunsMarkup が復元失敗 → 設計通り collapse にフォールバック。太字は消えますが、文は正しく・構造は健全

比較表

エンジン タグ保持 runTexts 太字の結末
DeepL "The contract was signed yesterday." ✅ 位置順で保持 あり 太字は残るが "signed" に乗る(語ズレ)
tower-plus:9b "The contract was yesterday signed." ❌ タグ除去 なし collapse=太字消失(文は正しい)

どちらも fail-safe は保たれる(文は正しい・構造健全・破壊ゼロ)。その上で性質が綺麗に分かれました。

結論:runs モード(段内書式保持)は DeepL 向き。タグを確実に保持する(語順移動時に強調語がズレる弱点はあるが、強調自体は残る)。一方 翻訳特化のローカルモデルは runs だと collapse に落ちるのが既定の振る舞い——安全だが書式は付かない。ローカルで書式保持を狙うなら、タグ指示に従う汎用チャットモデルLLM_JSON_MODE=false を試す価値がある。

<x> タグで段内書式を保持」という発想自体は珍しくありませんが、語順が大きく動く対では強調語がズレること、翻訳特化モデルはタグを捨ててフォールバックすることを、実データで定量化できたのが収穫でした。torture に最難ケースを 1 つ足したからこそ言えた話です。

アーキテクチャ上の学び

Translator 抽象でエンジンを差し替える

翻訳エンジンは Translator インタフェース 1 枚で抽象化しています。

interface Translator {
  translateBatch(
    texts: string[],
    opts: TranslateBatchOptions,
  ): Promise<string[]>;
}

実装は DeeplHttpTranslator / LlmTranslator(OpenAI 互換・クラウド/ローカル両対応) / StaticMapTranslator(テスト用)。LLM_BASE_URL を差し替えるだけで OpenAI でも Ollama でも vLLM でも同じコードで動きます。glossary もサイズ上限もタグ保持(markup)も、この抽象の上に薄く乗せました。

「2 軸 × 2」の 4 パターン

ユーザI/F と翻訳エンジンは独立した軸です。これを混同すると設計を誤ります。

DeepL(クラウド) ローカルLLM(Ollama)
Claude(会話駆動) a-2 b-2
CLI(ヘッドレス) a-3 b-3

会話駆動(a-2/b-2)は、Claude が 3 つの MCP ツールをオーケストレーションする。CLI(a-3/b-3)は、reader/translate/writer をライブラリとして同一プロセス内で呼び、xCOMET 品質ゲートや再翻訳ループを決定論コードで固定 DAG として回す。ローカルLLMには「翻訳」だけさせ、どのツールをどの順で呼ぶかは推論させない——これが安定運用の勘所でした。4 パターンすべてを実機で疎通確認しています。

契約は「型としてのみ」依存させる

各 MCP は doc-translation-irimport type でしか参照しないので、ビルド後の dist には実行時依存が残りません(型は消える)。おかげで Claude Desktop からは 3 つの MCP を繋ぐだけで完結——契約パッケージもパイプラインも実行時には不要です。polyrepo の独立性がここで効きます。

おまけ:丸一日溶けかけた macOS の罠

b-2(Claude Desktop × ローカルLLM)を繋いだら、translate_dtir failed: fetch failedTerminal の curl は通るのに MCP の fetch だけ失敗.local を IP に変えても、Ollama が健全でも変わらない。

犯人は macOS の「ローカルネットワーク」プライバシー権限が node バイナリ単位だったこと。MCP サーバは Claude.app ではなく Claude が spawn する node プロセスが LAN へ出る。Claude.app が許可済みでも node が未許可だとブロックされる。しかも nvm で node が複数版あり、**Claude が実際に使う版(v24.16.0)**を許可する必要がありました。

確定テスト(Claude が使う node の絶対パスで実行):

/Users/you/.nvm/versions/node/v24.16.0/bin/node \
  -e 'fetch("http://192.168.x.x:11434/api/tags").then(_=>console.log("OK")).catch(e=>console.log("FAIL",e.cause||e))'

これが OK なら node 自体は到達可能=あとは権限。システム設定 → プライバシーとセキュリティ → ローカルネットワークで該当 node を ON にして Claude Desktop を完全再起動、で解決しました。

ハマったらこれを疑う:GUI アプリ(Claude Desktop 等)から LAN 上の LLM/Ollama に出て fetch failed、でも Terminal の curl は通る——なら macOS の「ローカルネットワーク」権限が、アプリが spawn する node バイナリ(nvm なら使用中の版) 単位で付いていない可能性が高いです。Claude.app 側を許可しても効きません。

まとめ

混在言語 docx の翻訳という一見ニッチな課題から、ドキュメント翻訳の中間表現 DTIR を設計し、仕事レベルの 4 つの穴(① 再帰走査・④ glossary・③ サイズバッチ・② 脱collapse)を torture フィクスチャで定量化しながら塞ぎました。

そして何より、冒頭で挙げた 3 原則を、4 つの穴ふさぎでは一度も曲げませんでした

  1. fail-safe:IR に乗ったテキストだけ触る。どの穴も「未翻訳で残る」だけで壊れない=穴は coverage であって corruption ではなかった。
  2. 境界保持:バッチは配列、連結しない、戻り長=入力長。チャンクもセグメント境界を割らない。
  3. opt-in・後方互換:新機能(glossary / サイズ上限 / runs)は既定オフ。契約は前方互換、既存挙動は不変。

そして runs モードの実機比較で、「書式保持を狙うなら DeepL、ローカルは安全に collapse」という住み分けを実データで言語化できました。

中間表現を 1 枚噛ませて「触っていいもの」と「触ってはいけないもの」を分離する——この発想は、docx に限らず PDF や他フォーマットの「構造を壊さない自動処理」全般に効く設計だと思います。


実装は TypeScript × ESM × MCP SDK の polyrepo です。質問・指摘は歓迎します🙌

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?