概要:「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 つから導かれます。
- fail-safe:IR に乗ったテキストだけ触る。未対応の構造は「未翻訳で残る」だけで壊れない。
- 境界保持:翻訳は配列で渡し、連結しない・戻り長=入力長を強制。バッチもセグメント境界を割らない。
- 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:9b は runs モードの <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-ir を import type でしか参照しないので、ビルド後の dist には実行時依存が残りません(型は消える)。おかげで Claude Desktop からは 3 つの MCP を繋ぐだけで完結——契約パッケージもパイプラインも実行時には不要です。polyrepo の独立性がここで効きます。
おまけ:丸一日溶けかけた macOS の罠
b-2(Claude Desktop × ローカルLLM)を繋いだら、translate_dtir failed: fetch failed。Terminal の 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 つの穴ふさぎでは一度も曲げませんでした。
- fail-safe:IR に乗ったテキストだけ触る。どの穴も「未翻訳で残る」だけで壊れない=穴は coverage であって corruption ではなかった。
- 境界保持:バッチは配列、連結しない、戻り長=入力長。チャンクもセグメント境界を割らない。
- opt-in・後方互換:新機能(glossary / サイズ上限 / runs)は既定オフ。契約は前方互換、既存挙動は不変。
そして runs モードの実機比較で、「書式保持を狙うなら DeepL、ローカルは安全に collapse」という住み分けを実データで言語化できました。
中間表現を 1 枚噛ませて「触っていいもの」と「触ってはいけないもの」を分離する——この発想は、docx に限らず PDF や他フォーマットの「構造を壊さない自動処理」全般に効く設計だと思います。
実装は TypeScript × ESM × MCP SDK の polyrepo です。質問・指摘は歓迎します🙌