本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。 なお本記事の数値は、特記のない限り 2026年8月20日時点で、筆者の手元の実ファイルを計測した値です。

AI に文書をレビューさせる方法は多く書かれています。ただしそのほとんどは、本文をチャットに貼って直してもらう形です。

実務で回っている文書は Word です。査読、申請書、契約書、共著論文。貼り付け方式をこれらに使うと、3つの問題が出ます。

  • 位置が特定できない。 「3ページ目の中ほど、"従来手法" のあたり」を毎回説明することになります。この説明は指摘そのものより長くなります
  • 指摘と対応が対応づかない。 20件指摘して、何件が本文に反映され、何件が却下されたのかを、後から数える手段がありません
  • AI が本文を再生成すると、コメントが消える。 原稿から docx を生成し直す運用だと、人が付けたコメントは毎回失われます

Word の校閲機能(コメントと編集履歴)は、この3つをそのまま解決する仕組みです。位置は本文中のアンカーが持ち、指摘と返信はスレッドになり、誰が書いたかは著者名で分かれます。問題は、これを機械から読み書きする方法が、あまり書かれていないことです。

申請書3本を Claude Code とレビューしたときに作った 200 行ほどのツールと、そこで実際に踏んだ失敗の記録です。


何を往復させたか

対象は3本の Word 文書です。中身は申請書ですが、この記事では手法の話だけをします。

最終版の docx を実測した件数です。

文書人が付けたコメントAI が書いた返信人の直接修正(改訂を含む段落数)
概要版2頁3333
本体4頁8(うち1件は返信)86
別種目の申請書8頁16(うち3件は返信)923

「人の直接修正」は、コメントを書く代わりに本文を直接書き換えたものです。Word の変更履歴として w:ins / w:del で残ります。8頁の文書では、コメント13件に対して直接修正が23段落。指摘の主要な経路はコメントではなく本文の書き換えでした。

この往復を成立させるために触ったのが、.docx の中の4つの XML パートです。


.docx を開ける

.docx は zip です。校閲に関係するパートだけを見ます。

unzip -l 校閲_本体_v2.docx | grep -E 'comment|document.xml'
    72531  word/document.xml
    10179  word/comments.xml
     3711  word/commentsExtended.xml
     3803  word/commentsIds.xml

コメント1件を成立させるのに、4つのパートを整合させる必要があります。

Word のコメント1件と返信1件を書くために触る4つのパートの関係図。word/document.xml が commentRangeStart / commentRangeEnd / commentReference で本文中の位置を持ち、w:id で word/comments.xml のコメント本体(著者と文面、w14:paraId)に対応する。word/commentsExtended.xml は w15:paraId と w15:paraIdParent でスレッドの親子関係を持ち、返信の paraId を親コメントの w14:paraId に向ける。word/commentsIds.xml は paraId に durableId を対応させる。paraIdParent を書かないと返信は独立したコメントとして並ぶ

word/comments.xml — 本文と著者

コメントの実体です。実ファイルから1件そのまま引きます(名前空間宣言は長いので省略しています)。

<w:comment w:id="18" w:author="作成者" w:initials="A">
  <w:p w14:paraId="66942C55" w14:textId="77777777">
    <w:r><w:rPr><w:rStyle w:val="ab"/></w:rPr><w:annotationRef/></w:r>
    <w:r><w:rPr><w:rFonts w:hint="eastAsia"/></w:rPr>
      <w:t>日本語として、おかしな表現。修正して。</w:t></w:r>
  </w:p>
</w:comment>

コメントの中身は段落(w:p)です。つまり本文と同じ構造で、w:r(run)に w:t(テキスト)が入ります。1つ目の run にある <w:annotationRef/> は吹き出しに番号を出すための参照で、Word が自前で入れるものです。

w:author が「作成者」になっているのは匿名化の結果です。 Word の「ドキュメント検査」でドキュメントのプロパティと個人情報を削除すると、著者名がこの表記に置き換わります。人名でフィルタするコードを書くと、匿名化された時点で動かなくなります。 筆者はこれで一度取り違えました。

word/document.xml — 本文中の位置

本文側には3つの要素が入ります。

<w:commentRangeStart w:id="18"/>
  …コメントの対象になっている語…
<w:commentRangeEnd w:id="18"/>
<w:r><w:rPr><w:rStyle w:val="ab"/></w:rPr>
  <w:commentReference w:id="18"/></w:r>

commentRangeStartcommentRangeEnd が範囲を、commentReference が吹き出しの位置を示します。この3つはいずれも w:idcomments.xmlw:comment に対応します。

範囲の中にあるテキストが「そのコメントが何を指しているか」です。 これを取り出せると、AI 側の作業がかなり楽になります。抽出は正規表現で足ります。

以降のコードで使う小道具を先に置きます。

import sys, re, json, copy, zipfile, shutil, difflib
from xml.etree import ElementTree as ET

W   = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
GT  = lambda s: ''.join(re.findall(r'<w:t[^>]*>([^<]*)</w:t>', s))
ESC = lambda s: s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')

範囲の中のテキストは、これだけで取り出せます。

def anchors(doc):
    """コメントid → 本文中で選択されていた語"""
    return {m.group(1): GT(m.group(2)) for m in
            re.finditer(r'<w:commentRangeStart w:id="(\d+)"/>(.*?)<w:commentRangeEnd w:id="\1"/>',
                        doc, re.S)}

後方参照 \1 で開始と終了の w:id を一致させています。コメントの範囲は入れ子になることがあり、w:id を見ずに最初の commentRangeEnd で閉じると別のコメントの範囲を拾います。

word/commentsExtended.xml — スレッドの親子関係

ここが最大のハマりどころです。

返信を「返信」にしているのは comments.xml ではありません。commentsExtended.xmlw15:paraIdParent です。

実ファイルから、コメント8件と返信8件ぶんの w15:commentEx をそのまま引きます。

<w15:commentEx w15:paraId="66942C55" w15:done="0"/>
<w15:commentEx w15:paraId="6D382511" w15:done="0"/>
<w15:commentEx w15:paraId="2668A9E3" w15:done="0"/>
<w15:commentEx w15:paraId="001B11B7" w15:done="0"/>
<w15:commentEx w15:paraId="6B79D7B7" w15:done="0"/>
<w15:commentEx w15:paraId="68AAA7C7" w15:done="0"/>
<w15:commentEx w15:paraId="4DC5F827" w15:paraIdParent="68AAA7C7" w15:done="0"/>
<w15:commentEx w15:paraId="6561AB40" w15:done="0"/>
<w15:commentEx w15:paraId="40000025" w15:paraIdParent="66942C55" w15:done="0"/>
<w15:commentEx w15:paraId="40000026" w15:paraIdParent="6D382511" w15:done="0"/>
<w15:commentEx w15:paraId="40000027" w15:paraIdParent="2668A9E3" w15:done="0"/>
<w15:commentEx w15:paraId="40000028" w15:paraIdParent="001B11B7" w15:done="0"/>
<w15:commentEx w15:paraId="40000029" w15:paraIdParent="6B79D7B7" w15:done="0"/>
<w15:commentEx w15:paraId="4000002A" w15:paraIdParent="68AAA7C7" w15:done="0"/>
<w15:commentEx w15:paraId="4000002B" w15:paraIdParent="4DC5F827" w15:done="0"/>
<w15:commentEx w15:paraId="4000002C" w15:paraIdParent="6561AB40" w15:done="0"/>

読み方は次のとおりです。

  • w15:paraIdw:comment の中の w:p が持つ w14:paraId です。w:commentw:id"18" のような連番)ではありません。別の識別子を2つ使い分けているのがこのパートの厄介なところです
  • w15:paraIdParent の無いものが親コメント(この文書では7件。人が付けたコメント8件のうち1件は、既存コメントへの返信として書かれています)
  • 4000002x で始まるものは、筆者のツールが生成した返信です。0x40000000 + コメントid で振っているので一目で区別できます
  • 4DC5F82768AAA7C7 への返信であり、さらに 4000002B4DC5F827 への返信になっています。スレッドは2段以上に伸びます(人が AI の返信に再返信した箇所です)
  • w15:done は「解決済み」フラグです。今回は全件 0 のまま運用しました

paraIdParent を書かずにコメントを足すと、Word 上では返信ではなく独立したコメントとして並びます。 対応関係が見えないので、往復の意味が失われます。

word/commentsIds.xml — 版をまたいだ同一性

<w16cid:commentId w16cid:paraId="66942C55" w16cid:durableId="4D826173"/>
<w16cid:commentId w16cid:paraId="40000025" w16cid:durableId="50000025"/>

w16cid:durableId は、コピーや版の受け渡しをまたいでコメントを同定するための ID です。筆者のツールは 0x50000000 + コメントid で機械的に振っています。既存の値と衝突しない範囲であれば、この程度で実用上の問題は出ませんでした。ただし paraId に対応する行が無いコメントがあると Word が読み込みに失敗する可能性があるため、comments.xml に足したら必ずここにも足します。


読み出す

ここまでが分かれば、読み出しは短く書けます。返信は除いて、親コメントだけを対象語つきで出します。

def cmd_read(path):
    z = zipfile.ZipFile(path)
    if 'word/comments.xml' not in z.namelist():
        print('コメントなし'); return
    an = anchors(z.read('word/document.xml').decode())
    ex = z.read('word/commentsExtended.xml').decode() \
         if 'word/commentsExtended.xml' in z.namelist() else ''
    child = {m.group(1) for m in
             re.finditer(r'w15:paraId="([0-9A-F]+)" w15:paraIdParent', ex)}
    out = []
    for c in ET.fromstring(z.read('word/comments.xml')):
        cid = c.get(W+'id')
        pid = c.find(W+'p').get(W+'paraId') if c.find(W+'p') is not None else None
        if pid in child: continue          # 返信は飛ばす
        out.append({'id': cid, 'author': c.get(W+'author'),
                    'anchor': an.get(cid, '').strip(),
                    'text': ''.join(t.text or '' for t in c.iter(W+'t'))})
    print(json.dumps(out, ensure_ascii=False, indent=2))

出力はこうなります。構造は実ファイルどおりですが、コメントの対象語と文面は、申請内容に当たらない題材に置き換えています。

[
  {
    "id": "18",
    "author": "作成者",
    "anchor": "集計と推計の両立しなさ",
    "text": "日本語として、おかしな表現。修正して。"
  },
  {
    "id": "34",
    "author": "作成者",
    "anchor": "多い",
    "text": "件数の多さが挙げられる、といった表現のほうがよいのでは?"
  }
]

anchor が入っているのが要点です。 「多い」という1語に対して「表現のほうがよいのでは」と言われている、という対応がそのまま JSON に出てきます。AI 側は本文全体を読み直さずに、どこを直せばよいかが分かります。


返信を書き戻す

返信1件につき、3つのパートに1要素ずつと、本文に1つの run を足します。中核部分です。

def cmd_reply(path, jsonfile):
    """{"23":"対応しました。…"} の形式で返信を書き戻す"""
    rep = json.load(open(jsonfile, encoding='utf-8'))
    zin = zipfile.ZipFile(path); parts = {n: zin.read(n) for n in zin.namelist()}; zin.close()
    com = parts['word/comments.xml'].decode(); ext = parts['word/commentsExtended.xml'].decode()
    ids = parts['word/commentsIds.xml'].decode(); doc = parts['word/document.xml'].decode()
    # 親コメントの w:id → その中の w:p の w14:paraId
    ppara = {m.group(1): m.group(2) for m in
             re.finditer(r'<w:comment w:id="(\d+)".*?w14:paraId="([0-9A-F]+)"', com, re.S)}
    mx = max(int(x) for x in re.findall(r'<w:comment w:id="(\d+)"', com))
    nc = []; ne = []; ni = []
    for k, (pid, txt) in enumerate(sorted(rep.items(), key=lambda kv: int(kv[0]))):
        if pid not in ppara:
            print('親コメントなし:', pid, file=sys.stderr); continue
        nid = mx + 1 + k
        para = '%08X' % (0x40000000 + nid)
        dur  = '%08X' % (0x50000000 + nid)
        nc.append(f'<w:comment w:id="{nid}" w:author="Claude" w:initials="C">'
                  f'<w:p w14:paraId="{para}" w14:textId="77777777">'
                  f'<w:r><w:rPr><w:rStyle w:val="ab"/></w:rPr><w:annotationRef/></w:r>'
                  f'<w:r><w:rPr><w:rFonts w:hint="eastAsia"/></w:rPr>'
                  f'<w:t xml:space="preserve">{ESC(txt)}</w:t></w:r></w:p></w:comment>')
        ne.append(f'<w15:commentEx w15:paraId="{para}" '
                  f'w15:paraIdParent="{ppara[pid]}" w15:done="0"/>')
        ni.append(f'<w16cid:commentId w16cid:paraId="{para}" w16cid:durableId="{dur}"/>')
        # 本文側: 親の commentReference の直後に、幅ゼロのアンカーを足す
        j = doc.find(f'<w:commentReference w:id="{pid}"')
        if j < 0: continue
        e = doc.find('</w:r>', j) + 6
        doc = doc[:e] + (f'<w:r><w:rPr><w:rStyle w:val="ab"/></w:rPr>'
                         f'<w:commentRangeStart w:id="{nid}"/>'
                         f'<w:commentRangeEnd w:id="{nid}"/>'
                         f'<w:commentReference w:id="{nid}"/></w:r>') + doc[e:]
    parts['word/comments.xml'] = com.replace('</w:comments>', ''.join(nc) + '</w:comments>').encode()
    parts['word/commentsExtended.xml'] = ext.replace('</w15:commentsEx>', ''.join(ne) + '</w15:commentsEx>').encode()
    parts['word/commentsIds.xml'] = ids.replace('</w16cid:commentsIds>', ''.join(ni) + '</w16cid:commentsIds>').encode()
    parts['word/document.xml'] = doc.encode()
    tmp = path + '.tmp'
    with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zo:
        for n, d in parts.items(): zo.writestr(n, d)
    shutil.move(tmp, path)

実装上の判断が4つあります。

1. XML パーサではなく文字列操作で書いている。 xml.etree.ElementTree で読んで書き戻すと、パートが別物になります。実ファイルで試した結果です。

from xml.etree import ElementTree as ET
import zipfile
src = zipfile.ZipFile('校閲_本体_v2.docx').read('word/comments.xml')
out = ET.tostring(ET.fromstring(src), encoding='utf-8')
print(len(src), '->', len(out))
print(out[:180].decode())
10179 -> 8698
<ns0:comments xmlns:ns0="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
 xmlns:ns1="http://schemas.openxmlformats.org/markup-compatibility/2006"
 … ns1:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl w16du wp14"

w:ns0: に置き換わり、使われていない名前空間宣言が落ちて 1,481 バイト縮みます。mc:Ignorable の値は w14 w15 … という接頭辞の列なので、宣言が落ちた時点で存在しない接頭辞を指すことになります。 閉じタグの直前に文字列を挿入する方式なら、この問題が起きず、git diff 相当の確認も効きます。読み出しだけは ElementTree を使っています。

2. 返信のアンカーは幅ゼロにしている。 commentRangeStartcommentRangeEnd を隣接させ、親の commentReference の直後に置きます。返信は本文の特定の語を指すものではないので、範囲を持たせる必要がありません。

3. 新しい w:id は既存の最大値の次から振る。 空き番号を探して再利用すると、commentsExtended.xmlcommentsIds.xml に残っている古い行と衝突しうるので、単調増加にしています。

4. 元のファイルは一度メモリに全部読んでから、.tmp に書いて shutil.move で置き換える。 zip は追記更新ができないので、いずれにせよ全パートを書き直すことになります。書き込み中に落ちたときに元のファイルを壊さないよう、別名に書いてから差し替えます。

呼び出しは JSON 1枚です。

cat > replies.json <<'JSON'
{
  "18": "対応しました。名詞化した造語をやめ、何が両立しないかを直接書きました。",
  "34": "対応しました。「多い」を「件数の多さ」に改めました。"
}
JSON
python3 word_review.py reply 校閲_本体_v2.docx replies.json
返信 8 件を挿入

編集履歴を機械で作る

コメントより効くのが編集履歴です。AI の修正を「変更履歴つき」で返すと、人は Word 上で1箇所ずつ承認・却下できます。 全文を読み直す必要がありません。

構造は素直です。挿入は w:ins で run を包み、削除は w:del で包んで w:tw:delText に替えます。

<w:del w:id="0" w:author="作成者">
  <w:r><w:rPr><w:rFonts w:ascii="Century" w:eastAsia="MS 明朝"/><w:b/><w:sz w:val="22"/></w:rPr>
    <w:delText>判別できな</w:delText></w:r>
</w:del>
<w:ins w:id="1" w:author="作成者">
  <w:r><w:rPr><w:rFonts w:ascii="Century" w:eastAsia="MS 明朝"/><w:b/><w:sz w:val="22"/></w:rPr>
    <w:t>は判別が難し</w:t></w:r>
</w:ins>
  • w:del の中の run は、テキストを w:t ではなく w:delText で持ちます
  • w:author で著者が分かれます。AI の提案と人の修正を、同じファイルの中で色分けできます
  • w:date は省略できます(上の例は匿名化で日付が落ちた実ファイルです)
  • w:ins / w:del は run の外側に付きます。 段落(w:p)の直下の子として並びます

生成側のコードです。既存 run の書式を引き継ぐところが要点です。

def _mkrun(text, kind, proto, author, qn, OxmlElement):
    r = OxmlElement('w:r')
    if proto is not None:
        pr = proto.find(qn('w:rPr'))
        if pr is not None:
            r.append(copy.deepcopy(pr))            # 書式を引き継ぐ
    e = OxmlElement('w:delText' if kind == 'del' else 'w:t')
    e.set(qn('xml:space'), 'preserve'); e.text = text; r.append(e)
    if kind == 'eq':
        return r
    w = OxmlElement('w:del' if kind == 'del' else 'w:ins')
    w.set(qn('w:id'), str(abs(hash(text + kind + author)) % 90000 + 1000))
    w.set(qn('w:author'), author); w.set(qn('w:date'), '2026-01-01T00:00:00Z')
    w.append(r); return w

段落の run を全部作り直すので、w:rPr を引き継がないとフォント指定が消えます。申請書の様式は本文が明朝の 11pt に固定されているので、これを落とすと様式違反になります。 段落の先頭 run を proto として deepcopy し、生成する全 run に付けています。

xml:space="preserve" は必須です。付けないと、前後の空白を含む差分で空白が落ちます。


実装で効いた4つの工夫

1. 文字単位の差分は断片化して読めない

最初は difflib.SequenceMatcher の文字単位の opcodes をそのまま w:ins / w:del に変換しました。結果、Word 上の表示が「貸ることができるがが、り異なる」のような文字列になりました。

再現できます。次はそのまま動きます。

import difflib

before = "本調査では、対象を貸出冊数として集計できるが、その値は調査期間により異なる。"
after   = "本調査では、対象の貸出冊数を推計するが、得られる値は調査期間によって大きく異なる。"

ops = difflib.SequenceMatcher(None, before, after).get_opcodes()
print('削除された文字だけを連結:',
      ''.join(before[i1:i2] for t, i1, i2, j1, j2 in ops if t in ('delete', 'replace')))
print('挿入された文字だけを連結:',
      ''.join(after[j1:j2] for t, i1, i2, j1, j2 in ops if t in ('insert', 'replace')))
削除された文字だけを連結: をとして集できそのり
挿入された文字だけを連結: のを推す得られるって大きく

日本語は文字単位の一致が偶然たくさん起きるので、変更が細切れになります。 これを w:del / w:ins に落とすと、Word の校閲ウィンドウには意味のない断片が並びます。

次に文単位(句点で分割)に切り替えました。今度は粗すぎました。1文字直しただけで文が丸ごと削除・挿入として表示され、どこが変わったのかが分かりません。

採ったのは近接した変更をまとめる方式です。差分の間に挟まる一致部分が smooth 文字(採用値は 6)未満なら、その一致も変更の一部として扱います。

def diff(b, a, smooth):
    ops = difflib.SequenceMatcher(None, b, a).get_opcodes()
    if smooth:
        # 短い「一致」を replace に格下げする
        ops = [('replace',) + o[1:] if (i and o[0] == 'equal' and o[2] - o[1] < smooth) else o
               for i, o in enumerate(ops)]
        # 隣り合った非一致どうしを1つに畳む
        res = []
        for o in ops:
            if res and o[0] != 'equal' and res[-1][0] != 'equal':
                pv = res.pop(); res.append(('replace', pv[1], o[2], pv[3], o[4]))
            else:
                res.append(o)
        ops = res
    out = []
    for tag, i1, i2, j1, j2 in ops:
        if tag == 'equal':    out.append(b[i1:i2])
        elif tag == 'delete': out.append('[-%s-]' % b[i1:i2])
        elif tag == 'insert': out.append('{+%s+}' % a[j1:j2])
        else:
            if b[i1:i2]: out.append('[-%s-]' % b[i1:i2])
            if a[j1:j2]: out.append('{+%s+}' % a[j1:j2])
    return ''.join(out)

print('smooth=0 :', diff(before, after, 0))
print('smooth=6 :', diff(before, after, 6))
smooth=0 : 本調査では、対象[-を-]{+の+}貸出冊数[-として集-]{+を推+}計[-でき-]{+す+}るが、[-その-]{+得られる+}値は調査期間によ[-り-]{+って大きく+}異なる。
smooth=6 : 本調査では、対象[-を貸出冊数として集計できるが、その-]{+の貸出冊数を推計するが、得られる+}値は調査期間によ[-り異なる。-]{+って大きく異なる。+}

smooth=6 では、書き換えた節がひとまとまりで出ます。一方で末尾の「により異なる」→「によって大きく異なる」は前後の一致が長いので分かれたままです。細かい直しは細かいまま、書き換えた節は節として出るという挙動になります。

6という値は文書を見ながら決めたもので、根拠のある定数ではありません。日本語の申請書ではこの値で読める出力になりました。

2. 様式を壊さない

生成する側で最も危険なのが、ページ構造を持つ段落に手を入れることです。

def has_sect(p):
    pPr = p._p.find(qn('w:pPr'))
    return pPr is not None and pPr.find(qn('w:sectPr')) is not None

for p in d.paragraphs:
    if has_sect(p): continue
    ...

w:pPr の中に w:sectPr を持つ段落は、セクションの区切りです。用紙サイズ、余白、そしてページごとに違うヘッダーの割り当てがここに入っています。

対象にした文書のひとつは word/header1.xml から header7.xml まで7つのヘッダーを持ち、w:sectPr が4箇所ありました。この段落の run を作り直すと、ページ構造とページ別ヘッダーが壊れます。 筆者は一度これで様式を壊しています。

段落を空にする処理を書くときも同じです。「空段落を削除する」ような一括処理は、sectPr を持つ段落を必ず除外します。

3. 書式を引き継ぐ

前節の _mkrunw:rPrdeepcopy している理由です。差分から run を作り直すと書式が既定値に戻ります。ゴシックの見出しが明朝になり、11pt が 10.5pt になります。

deepcopy が必要なのは、同じ w:rPr 要素オブジェクトを複数の run に append すると、lxml では最後の1つにしか付かない(要素は木の中で1箇所にしか存在できない)ためです。

4. 両者が触った段落は、原案からの差分を一度だけ出す

これが一番厄介でした。

往復の2周目では、同じ段落を人と AI の両方が触っていることがあります。素直に実装すると、「人の版」と「AI の版」を続けて出力してしまい、Word 上で本文が二重に見えます。 承認すると同じ文が2回並びます。

採ったのは、原案(往復の起点になったテキスト)から最終版への差分を一度だけ出し、著者名を連名にする方法です。実ファイルの著者の内訳です。

改訂を含む段落数(著者別): {'Claude': 11, '中村 覚': 1, '中村 覚+Claude': 5}
<w:del w:id="47610" w:author="中村 覚+Claude" w:date="2026-08-20T22:45:00Z">
  <w:r><w:rPr></w:rPr><w:delText xml:space="preserve">は検知しえな</w:delText></w:r>
</w:del>

w:author は任意の文字列なので、「中村 覚+Claude」という著者を作れます。Word は著者名を文字列として扱うため、これは独立した1人の著者になります。「この段落は2人が触った」という情報が、承認画面の著者名として出ます。

二重出力は目視では気づきにくいので、検証コードで機械的に検出しています(後述)。


新しいパートを足すときの登録

comments.xml などが最初から入っていない docx にコメントを足す場合、パートを置くだけでは Word が読みません。2箇所への登録が要ります。

word/_rels/document.xml.rels

<Relationship Id="rId9"
  Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
  Target="comments.xml"/>
<Relationship Id="rId10"
  Type="http://schemas.microsoft.com/office/2011/relationships/commentsExtended"
  Target="commentsExtended.xml"/>
<Relationship Id="rId11"
  Type="http://schemas.microsoft.com/office/2016/09/relationships/commentsIds"
  Target="commentsIds.xml"/>

comments だけが openxmlformats.org、残る2つは schemas.microsoft.com の下にあり、しかも年(2011 / 2016/09)が URI に入っています。 コメントが ECMA-376 の一部で、スレッド化と durableId が後から Microsoft 拡張として足されたことがそのまま出ています。

[Content_Types].xml

<Override PartName="/word/comments.xml"
  ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>
<Override PartName="/word/commentsExtended.xml"
  ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"/>
<Override PartName="/word/commentsIds.xml"
  ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml"/>

こちらは3つとも wordprocessingml.<パート名>+xml の形で揃っています。

登録は冪等に書きます。

for tag, tgt, typ in NS:
    if f'Target="{tgt}"' not in rels:
        rels = rels.replace('</Relationships>',
                            f'<Relationship Id="rIdW{tag}" Type="{typ}" Target="{tgt}"/></Relationships>')

Id は文書内で一意であればよいので、rIdWc のような衝突しない接頭辞を使っています。


運用手順と、その順序の理由

ここまでは仕組みの話です。実際に事故が起きたのは、仕組みではなく手順のほうでした。

校閲ファイルを版で積み上げる往復の流れ図。提出用 docx から track / reply で校閲_vN.docx を作りユーザーに渡す。ユーザーが Word でコメントと直接修正を入れて返す。受領したファイルはまず cp で校閲履歴/vN_受領 に退避し、以後は読むだけにする。そこから read でコメントを読み出し、原稿 final/*.txt に修正を集約し、再ビルドして track、reply、検証の順で校閲_v(N+1).docx を作る。事故1は再生成でユーザーの版を上書きしコメントを失いかけたこと、事故2は「返信だけ足した版」と「本文を最新にした版」を取り違えたこと

事故は2件です。

事故1: ユーザーがコメントを付けたファイルを、再生成で上書きした。

この運用では、docx は原稿テキストから毎回生成しています。生成スクリプトを走らせれば同じ名前のファイルが作り直され、コメントも編集履歴も消えます。 バックアップから復元しました。

事故2: 「返信を足したファイル」と「本文を最新にしたファイル」を取り違えた。

reply は既存ファイルにコメントを足すだけで、本文は触りません。track は本文を作り直します。この2つを別ファイルに対して走らせてしまい、返信は最新だが本文が1世代前のファイルをユーザーに渡しました。

この2件から出た手順です。

0. 受領したファイルを即座に退避(以後、このファイルは読むだけ)
     cp 校閲_対象_vN.docx 校閲履歴/校閲_対象_vN_受領.docx

1. コメントと直接修正を読み出す
     python3 word_review.py read 校閲_対象_vN.docx

2. 直接修正(編集履歴)を原稿 final/*.txt に反映する

3. コメントへの対応を原稿に反映する

4. ビルドして頁数を実測する(枠を超えたら削ってから次へ)

5. 本文を最新にした校閲ファイルを生成する(編集履歴つき・様式維持)
     python3 word_review.py track <最新の提出用docx> <原案テキスト> 校閲_対象_v(N+1).docx --rev 校閲履歴/校閲_対象_vN_受領.docx

6. その上に返信を足す
     python3 word_review.py reply 校閲_対象_v(N+1).docx replies.json

7. 検証してから開く

順序の根拠は3つです。

  • 0 が最初にあるのは事故1のためです。受領ファイルを不可侵にしてしまえば、後段でどれだけ生成を繰り返しても失うものがありません
  • 修正は必ず原稿 final/*.txt に集約するのは、docx が生成物だからです。docx を直接直すと、次の再ビルドで消えます
  • 5(track)→ 6(reply)の順は入れ替えられません。 track は本文を作り直すので、先に返信を足しても上書きされます。事故2はこの順序を守らなかったものです

ファイル名の規則も決めました。

研究計画調書_<種目>.docx              提出用。生成し直してよい
校閲_<対象>_v<N>.docx                 いま作業中の校閲版
校閲履歴/校閲_<対象>_v<N>_受領.docx   受領した時点で固定(不可侵)

検証コード

Word で開く前に必ず走らせます。 開いて目視で確認する運用では、事故2は見つかりませんでした。

そのままコピーして動きます。ファイル名だけ書き換えてください。

import zipfile, re
from collections import Counter

f = '校閲_対象_v2.docx'
KEY = '再現手順'        # 今回の修正で入れたはずの語(本文が最新かの確認用)
SECT_EXPECTED = 3       # 提出用 docx の w:sectPr の数

z = zipfile.ZipFile(f)
doc = z.read('word/document.xml').decode()
GT = lambda s: ''.join(re.findall(r'<w:t[^>]*>([^<]*)</w:t>', s))

# 1. 著者の内訳(意図しない著者名が混ざっていないか)
print('改訂の著者:', dict(Counter(re.findall(r'<w:(?:ins|del) [^>]*w:author="([^"]+)"', doc))))
print('コメントの著者:', dict(Counter(
    re.findall(r'<w:comment [^>]*w:author="([^"]+)"', z.read('word/comments.xml').decode()))))

# 2. コメント数と返信数
com = z.read('word/comments.xml').decode()
ext = z.read('word/commentsExtended.xml').decode()
child = {m.group(1) for m in re.finditer(r'w15:paraId="([0-9A-F]+)" w15:paraIdParent', ext)}
top = rep = 0
for m in re.finditer(r'<w:comment w:id="\d+"[^>]*>.*?w14:paraId="([0-9A-F]+)"', com, re.S):
    if m.group(1) in child: rep += 1
    else: top += 1
print(f'親コメント: {top} / 返信: {rep}')
print('commentsIds の行数:', len(re.findall(r'<w16cid:commentId ', z.read('word/commentsIds.xml').decode())),
      '← 親+返信と一致すること')

# 3. sectPr(様式のページ構造)
print('sectPr:', len(re.findall(r'<w:sectPr', doc)), f'← {SECT_EXPECTED} と一致すること')

# 4. 本文の二重出力(人版と AI 版を続けて出していないか)
dup = sum(1 for p in re.findall(r'<w:p\b.*?</w:p>', doc, re.S)
          for t in [GT(re.sub(r'<w:del .*?</w:del>', '', p, flags=re.S)).strip()]
          if len(t) > 60 and t[:40] in t[40:])
print('本文の二重出力:', dup, '← 0 でなければバグ')

# 5. 本文が最新か(今回入れたはずの語が w:ins の中にあるか)
ins = ''.join(GT(m.group(0)) for m in re.finditer(r'<w:ins .*?</w:ins>', doc, re.S))
print(f'"{KEY}" が挿入に含まれる:', KEY in ins, '← False なら古い本文')

5番目が事故2を捕まえます。 「返信だけ足した古い本文」は、コメント数も著者も sectPr も正常です。今回の修正で入れたはずの語が w:ins の中に無いという形でしか検出できません。

4番目の二重出力検出は、削除部分(w:del)を除いた段落テキストの先頭40文字が、41文字目以降にもう一度現れるかを見ています。「承認した状態の本文」で同じ文が2回出ていれば、それが二重出力です。


機械検査を通っても、文章のリズムは直らない

往復が一巡したあとに残ったのは、事実の誤りでも様式違反でもなく、文章の読みにくさでした。依頼者からの指摘です。

全体的に、文と文がぶつ切りの印象を持ちました。もう少し文を繋げる、または接続詞を入れる、などして、自然な文章にしてほしい。

付かない、ない、されない、とぶつ切りですよね?

原因は執筆側にありました。「一文70字以内・読点3個未満」という機械的な基準を自分に課していたためです。字数と頁数の制約が厳しい文書では、この基準は頁に収めるうえで有効に働きます。その一方で、因果でつながっている文まで切ってしまい、全体がぶつ切りになりました。

機械で取れるのは語尾の一致だけ

「同じ語尾が2文以上続く箇所」は規則で書けます。

import re

def endings(text):
    """同じ語尾が連続している箇所を拾う"""
    out = []
    for para in [p for p in text.split('\n') if p.strip()]:
        ss = [s.strip() for s in re.split(r'(?<=。)', para.replace('**', '')) if s.strip()]
        run = []
        for s in ss:
            m = re.search(r'(ない|ある|する|なる|いる|れる|できる)。$', s)
            key = m.group(1) if m else None
            if run and key and key == run[-1][0]:
                run.append((key, s))
            else:
                if len(run) >= 2: out.append(run)
                run = [(key, s)] if key else []
        if len(run) >= 2: out.append(run)
    return out
t = "記録は残らない。確認する手段もない。利用者が確かめる方法も示されない。"
for run in endings(t):
    print(len(run), '文が「%s。」で終わる:' % run[0][0])
    for k, s in run: print('   ', s)
3 文が「ない。」で終わる:
    記録は残らない。
    確認する手段もない。
    利用者が確かめる方法も示されない。

取れるのはここまでです。 実際に指摘されたもののうち、次は規則で書けません。

  • 短い文が続いて息継ぎが多い
  • 接続詞を入れれば流れる箇所
  • 主語の反復
  • 同じ語の近接反復
  • 語順が不自然
  • つなぎすぎて長い

読みやすさだけを見るエージェントを別に立てる

意味が取れるかを見る読み手検査とは別のエージェントを立て、読みやすさだけを見させます。 内容の当否を見させると、そちらに引っ張られて文章を見なくなります。

指示に入れたのは5点です。

  • 内容の当否・事実関係・論理の穴は見なくて結構です。文章の読みやすさだけを見てください」と明示する
  • 抽出項目を上の6つに分けて列挙する。「読みにくいところを挙げて」では返ってくる粒度が揃わない
  • 改善案まで書かせる。 意味の検査では「書き直すな、指摘だけしろ」としますが、リズムは案があるほうが速い
  • 紙幅の制約が厳しいので、書き換え案は元より長くしないでください」と制約を与える
  • 最後に「7点満点で何点か、最も直すべき癖を1つ」を答えさせる

最後の1問が効きました。返ってきた総括です。

主張を短い断定文で置き、その根拠・逆接・言い換えを接続助詞で畳まずに次の文へ切り出す癖。 「~が」「~ため」「~ものの」「~たところ」で一文に畳むだけで、字数を増やさずに息継ぎが半分になる。

結果

  • 35件を抽出し、7ファイル中6ファイル・24段落に反映した
  • 字数は81字減った。 つなぐと減ります。接続助詞1つで、切った文の主語の繰り返しが消えるためです
  • 評価は 4.5/7
  • 文法の破綻が1件見つかった

書き換えの形です(文面は記事用に作ったもので、実際の申請書の文面ではありません)。

beforeafter
断定を切る貸出冊数は、調査を行った時期と、対象とした館の数から求める。しかし記録に残るのは合計値だけである。(49字)貸出冊数は調査を行った時期と対象とした館の数から求めるが、記録に残るのは合計値だけである。(45字)
主語の反復本手法は集計の手順を定める。本手法は既存の道具に組み込める。(30字)本手法は集計の手順を定め、既存の道具に組み込める。(25字)
語尾の一致記録は残らない。利用者が確かめる方法も示されない。(25字)記録が残らず、利用者が確かめる方法も示されない。(24字)

3件とも、つないだほうが短くなっています。

文法の破綻は次の形でした。

本調査の対象施設は、県の情報公開条例(公立図書館もこの条例のいう実施機関にあたる)。

括弧の外に述語がないまま句点で終わっています。 括弧を外すと「本調査の対象施設は、県の情報公開条例。」になります。直前の編集で AI 自身が壊したもので、記事前半の機械検査はすべて通っていました。 表記も頁数も語尾も、この破綻を見ません。

分けた理由

この記事の前半は、w:sectPr の数や本文の二重出力を機械で検査する話でした。その検査が守るものと、守らないものは、はっきり分かれます。

機械検査読み手エージェント
見えるもの表記、頁数、太字の量、語尾の一致、様式の構造リズム、係り受け、語順、主語の反復
見えないもの文と文のつながり、述語の欠落事実の当否、様式違反

通っても読みやすいとは限らない、が結論です。したがって AI に文書をレビューさせるときは、「意味が取れるか」と「読みやすいか」を別の検査に分けます。 同じエージェントに両方を頼むと、意味のほうに寄って文章を見なくなります。


分かったことと、確認できていないこと

筆者が実ファイルで確認した範囲では、次が言えます。

  • コメントの読み書きは、comments.xml / commentsExtended.xml / commentsIds.xml / document.xml の4パートを整合させれば足りる。標準ライブラリ(zipfile / re / xml.etree)だけで書ける
  • 返信をスレッドにするのは w15:paraIdParent。値は親コメントの w:id ではなく、親コメント内の w:pw14:paraId
  • スレッドは2段以上に伸びる(人が AI の返信に再返信した箇所を確認)
  • 編集履歴の著者名は任意の文字列を取れる。「人+AI」という連名を1人の著者として書ける
  • w:sectPr を持つ段落を除外すれば、ページ別ヘッダー(実ファイルでは7つ)を保ったまま本文だけを差分に置き換えられる

次は確認できていません。

  • track --rev でコメントを載せ直すと、元のアンカー範囲が失われます。 同じ文書の受領版と、その次の版を実測しました。受領版はコメント範囲7件のうち7件が語を含んでいたのに対し、載せ直した次の版は11件のうち0件です。コメントは段落末尾に幅ゼロで置き直されます。どの語に対する指摘かは読み出した JSON に残るものの、Word の画面上では段落単位の粒度に落ちます。 ここは改善余地として残っています
  • w15:done="1"(解決済み)の書き込みは試していません。今回は全件 0 で運用しました
  • 検証したのは macOS 版の Word だけです。Windows 版・Web 版・LibreOffice での挙動は確認していません
  • commentsExtended.xml / commentsIds.xml の仕様は Microsoft の拡張です。[MS-DOCX] に記述がありますが、筆者が読んだのは実ファイルの構造であって仕様書ではありません
  • 図表・脚注・表の中のコメントは、今回の3文書には無かったため扱っていません

使ってみての所感

数字で見ると、この往復は「コメント57件・返信50件」よりも「編集履歴29段落分」のほうが効いていました。指摘を文章で書くより、直した本文を変更履歴で渡すほうが、書く側も読む側も短くて済みます。 コメントは「なぜそう直したか」を1行添えるための場所として機能しました。

そしてこの形式の効用は、対応漏れが数えられることでした。親コメント数と返信数を突き合わせれば、何件中何件に返したかが機械的に出ます。チャットに貼る方式では、この数字が出ません。

ツールは 200 行ほどです。仕組みの理解に時間がかかったのは w15:paraIdParent の1点だけで、それ以外は zip と正規表現で足りました。


検証の方法について

本記事のうち、次は筆者が実ファイル(3本の校閲済み docx とその履歴版)を直接展開して確認したものです。

  • 4つのパートの存在と、w:comment / w15:commentEx / w16cid:commentId / w:commentRangeStart の各要素の実際の属性値(記事中の XML は、名前空間宣言の省略と、申請内容に当たる文面の置き換えを除き、実ファイルの引用です)
  • スレッドの親子関係が w15:paraIdParent にあること、および2段以上に伸びている実例
  • w:ins / w:del / w:delText の構造と、w:author に「中村 覚+Claude」が入っていること
  • コメント数・返信数・改訂を含む段落数(記事冒頭の表)
  • word/_rels/document.xml.rels[Content_Types].xml の3行ずつ
  • w:sectPr の個数(4 / 3 / 1)と header*.xml の個数(7 / 5 / 2)
  • track --rev の後にアンカー範囲が失われること(同一文書で、範囲に語を含むものが 7/7 → 0/11)

差分の断片化と smooth の効果は、記事に載せたコードをそのまま実行した出力です。例文は記事用に作ったもので、実際の申請書の文面ではありません。

「貸ることができるがが、り異なる」は往復の最中に記録した実際の出力ですが、これを生んだ入力そのものは残っていません。上のコードは同じ現象を再現するために書いたものです。

「機械検査を通っても、文章のリズムは直らない」の節の数値(35件、7ファイル中6ファイル・24段落、81字減、4.5/7)は往復時の記録によるものです。before / after の例文と文法破綻の再現例は記事用に作ったもので、実際の申請書の文面ではありません。 endings() の出力は、記事に載せたコードをそのまま実行した結果です。

参照