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?

ドキュメントをLLMで自動同期させたら、担当者交代のコストが変わった話

0
Posted at

はじめに

前任者から引き継いだプロジェクトで、ドキュメントのAPI一覧を信じて作業したら、そのエンドポイントはもう消えていた。そういう経験がある方に向けた記事です。

API一覧とテーブル定義について、コードとのズレをプルリクエストのたびにCIで検出し、ズレがあったときだけLLMにドキュメントの修正案を書かせる構成を、動くサンプルで紹介します。最後に、導入するときのチェックリストを付けています。設定だけ知りたい方は「3.」と「導入チェックリスト」だけ拾ってもらえれば十分です。

先に結論を書くと、この仕組みが効くのは、コード側に機械が読める「正」がある部分だけです。READMEのセットアップ手順や設計の意図は対象外です。その線引きは次の節で書きます。

前提条件 この仕組みが効く範囲

次の3つがそろっていることを前提にしています。

  • API: OpenAPIをコードから生成できる(FastAPI、springdoc-openapi、NestJSのSwaggerModuleなど)。手書きのOpenAPIしかない場合は、それとコードのズレは別の問題として残ります
  • DB: マイグレーションを空のDBに当てれば、今のスキーマが再現できる
  • ドキュメント: API一覧とテーブル定義が、列の決まったMarkdownの表で書かれている

Excelのテーブル定義書で管理している場合は、まずMarkdownかCSVに書き出す仕組みが要ります。ここが一番重いので、導入前に確認してください。

READMEや設計の意図は、コードから「正」を取り出せないので対象にしていません。Zennの記事にも書きましたが、READMEの修正案をLLMに書かせると、変数が増えたことには気づいても、なぜ増えたのかまでは書けず、表面的な案にとどまりました。

環境

macOS、Python 3.12.13、FastAPI 0.142.2、anthropic(Python SDK)1.11.0、PostgreSQL 16(Docker)、actionlint 1.7.12。2026年10月7日に手元で動作を確認しました。GitHub Actions上のYAMLはactionlintで検査済みですが、記事の結果はローカルで同じ手順を実行したものです。

この記事をざっくり図解

サンプルの構成

app/main.py                       # FastAPIのアプリ(OpenAPIの元)
migrations/001_create_users.sql
migrations/002_add_display_name.sql
docs/api.md                       # API一覧
docs/db/users.md                  # テーブル定義(テーブルごとに1ファイル)
scripts/export_openapi.py
scripts/export_schema.sql
scripts/check_doc_drift.py
scripts/draft_doc_fix.py
.github/workflows/doc-drift.yml

わざとズレを3つ入れてあります。コードには GET /users/{user_id}/invoices があるのにAPI一覧にない。API一覧には DELETE /users/{user_id} があるのにコードにはない。マイグレーションで display_name 列を足したのに、テーブル定義に書かれていない。

ドキュメントは、たとえば次の形です。列の順番を決めておくことが、機械的に比べるための条件になります。

docs/db/users.md
# users テーブル定義

| カラム | 型 | NULL許可 | 説明 |
|---|---|---|---|
| id | integer | × | 主キー |
| email | text | × | ログインに使うメールアドレス |
| created_at | timestamp with time zone | × | 登録日時 |

1. ズレの判定は、LLMではなくスクリプトでやる

Zennの記事では、ズレの判定と修正案の両方をLLMに出させる形を書きました。このサンプルでは、API一覧とテーブル定義については判定をスクリプトに任せ、LLMには修正案だけを書かせています。

理由は2つです。一つは、CIの合否が実行のたびに変わると困ることです。同じコードで、通ったり落ちたりするチェックは、すぐに誰も見なくなります。もう一つは費用で、ズレがないプルリクエストでは、LLMを1回も呼ばずに済みます。API一覧とテーブル定義は構造が決まっているので、集合の差を取るだけで判定できます。

コード側の情報は、OpenAPIとPostgreSQLの information_schema から取り出します。

scripts/export_openapi.py
import json
import sys

sys.path.insert(0, ".")
from app.main import app  # noqa: E402

json.dump(app.openapi(), sys.stdout, ensure_ascii=False, indent=2)
scripts/export_schema.sql
SELECT json_agg(
  json_build_object(
    'table', table_name,
    'column', column_name,
    'type', data_type,
    'nullable', is_nullable = 'YES'
  ) ORDER BY table_name, ordinal_position
)
FROM information_schema.columns
WHERE table_schema = 'public';

突き合わせるスクリプトです。

scripts/check_doc_drift.py
"""コード側の定義(OpenAPI / DBスキーマ)と、Markdownのドキュメントのズレを検出する。

使い方:
  python scripts/check_doc_drift.py build/openapi.json build/schema.json --report build/drift.json
ズレがあれば終了コード1を返す。LLMは使わない(判定は毎回同じ結果になる)。
"""
import argparse
import json
import pathlib
import sys

API_DOC = pathlib.Path("docs/api.md")
DB_DOC_DIR = pathlib.Path("docs/db")
HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
IGNORE_TABLES = {"schema_migrations", "alembic_version"}
# ドキュメントで使ってよい型の別名。information_schema の data_type に寄せる
TYPE_ALIASES = {
    "int": "integer",
    "varchar": "character varying",
    "timestamptz": "timestamp with time zone",
    "bool": "boolean",
}
NULLABLE_MARKS = {"○", "yes", "y", "true"}


def read_md_table(path: pathlib.Path) -> list[list[str]]:
    """Markdownの最初の表を、見出し行と区切り行を除いた行のリストで返す。"""
    rows = []
    for line in path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line.startswith("|"):
            if rows:
                break  # 最初の表だけ読む
            continue
        cells = [c.strip() for c in line.strip("|").split("|")]
        rows.append(cells)
    return rows[2:]  # 見出し行と |---| 行を飛ばす


def check_api(openapi: dict) -> dict:
    in_code = {
        (method.upper(), path)
        for path, ops in openapi.get("paths", {}).items()
        for method in ops
        if method in HTTP_METHODS
    }
    in_doc = {(r[0].upper(), r[1]) for r in read_md_table(API_DOC)}
    return {
        "missing_in_doc": sorted(f"{m} {p}" for m, p in in_code - in_doc),
        "missing_in_code": sorted(f"{m} {p}" for m, p in in_doc - in_code),
    }


def check_db(columns: list[dict]) -> list[dict]:
    drifts = []
    tables: dict[str, dict[str, dict]] = {}
    for c in columns:
        if c["table"] not in IGNORE_TABLES:
            tables.setdefault(c["table"], {})[c["column"]] = c

    for table, code_cols in sorted(tables.items()):
        doc_path = DB_DOC_DIR / f"{table}.md"
        if not doc_path.exists():
            drifts.append({"table": table, "kind": "doc_file_missing", "doc": str(doc_path)})
            continue
        doc_cols = {}
        for r in read_md_table(doc_path):
            name, typ, nullable = r[0], r[1].lower(), r[2].lower()
            doc_cols[name] = {
                "type": TYPE_ALIASES.get(typ, typ),
                "nullable": nullable in NULLABLE_MARKS,
            }
        for name in code_cols.keys() - doc_cols.keys():
            drifts.append({"table": table, "kind": "missing_in_doc", "column": name,
                           "code": code_cols[name]})
        for name in doc_cols.keys() - code_cols.keys():
            drifts.append({"table": table, "kind": "missing_in_code", "column": name})
        for name in code_cols.keys() & doc_cols.keys():
            code, doc = code_cols[name], doc_cols[name]
            if code["type"] != doc["type"]:
                drifts.append({"table": table, "kind": "type_mismatch", "column": name,
                               "code": code["type"], "doc": doc["type"]})
            if code["nullable"] != doc["nullable"]:
                drifts.append({"table": table, "kind": "nullable_mismatch", "column": name,
                               "code": code["nullable"], "doc": doc["nullable"]})
    return drifts


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("openapi")
    parser.add_argument("schema")
    parser.add_argument("--report", required=True)
    args = parser.parse_args()

    openapi = json.loads(pathlib.Path(args.openapi).read_text(encoding="utf-8"))
    columns = json.loads(pathlib.Path(args.schema).read_text(encoding="utf-8")) or []
    report = {"api": check_api(openapi), "db": check_db(columns)}
    pathlib.Path(args.report).write_text(
        json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")

    has_drift = bool(report["api"]["missing_in_doc"] or report["api"]["missing_in_code"]
                     or report["db"])
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return 1 if has_drift else 0


if __name__ == "__main__":
    sys.exit(main())

型の表記はチームごとに揺れるので、TYPE_ALIASES で information_schema の表記に寄せています。NULL許可の欄は、日本のテーブル定義書でよく使う「○」と「×」を受け付けるようにしました。

2. ズレがあったときだけ、LLMに修正案を書かせる

scripts/draft_doc_fix.py
"""検出したズレをもとに、ドキュメントの修正案をLLMに書かせ、PRコメント用のMarkdownを出力する。

CIの合否はこのスクリプトでは決めない。APIキーがない、入力が大きすぎる、
応答が途中で止まった、といった場合は、ズレの一覧だけを出力して終わる。
"""
import json
import os
import pathlib
import sys

import anthropic

MODEL = os.environ.get("DOC_SYNC_MODEL", "claude-opus-5-5")
MAX_INPUT_CHARS = 30_000  # これを超えたらLLMに渡さない(黙って切り詰めない)

SYSTEM = """あなたは設計ドキュメントの更新案を書く担当です。
入力は、機械的に検出したズレの一覧(JSON)と、現在のドキュメントです。
ズレを解消するための、Markdownの表の行の追加・変更・削除案だけを出してください。
・ズレの一覧にない箇所は変更しないこと
・説明欄など、コードから判断できない内容は推測せず「(要記入)」と書くこと
・ファイルごとに、変更後の表をそのまま貼れる形で示すこと"""


def summarize(report: dict) -> str:
    lines = []
    for e in report["api"]["missing_in_doc"]:
        lines.append(f"- API: ドキュメントにない `{e}`")
    for e in report["api"]["missing_in_code"]:
        lines.append(f"- API: コードにない `{e}`(削除済み?)")
    for d in report["db"]:
        col = f".{d['column']}" if "column" in d else ""
        lines.append(f"- DB: `{d['table']}{col}` {d['kind']}")
    return "\n".join(lines)


def related_docs(report: dict) -> dict[str, str]:
    paths = set()
    if report["api"]["missing_in_doc"] or report["api"]["missing_in_code"]:
        paths.add("docs/api.md")
    for d in report["db"]:
        paths.add(f"docs/db/{d['table']}.md")
    return {p: pathlib.Path(p).read_text(encoding="utf-8")
            for p in sorted(paths) if pathlib.Path(p).exists()}


def draft(report: dict, docs: dict[str, str]) -> str | None:
    if not os.environ.get("ANTHROPIC_API_KEY"):
        return None  # フォークからのPRなど、シークレットが渡らない場合
    body = "## ズレの一覧\n" + json.dumps(report, ensure_ascii=False, indent=2)
    for path, text in docs.items():
        body += f"\n\n## 現在のドキュメント: {path}\n{text}"
    if len(body) > MAX_INPUT_CHARS:
        return None

    client = anthropic.Anthropic()
    resp = client.messages.create(
        model=MODEL,
        max_tokens=4000,
        output_config={"effort": "low"},
        system=SYSTEM,
        messages=[{"role": "user", "content": body}],
    )
    if resp.stop_reason != "end_turn":
        return None  # max_tokens や refusal で止まった案は出さない
    return "".join(b.text for b in resp.content if b.type == "text")


def main() -> None:
    report = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))
    out = ["### ドキュメントとコードのズレ(自動検出)", "", summarize(report), ""]
    try:
        suggestion = draft(report, related_docs(report))
    except anthropic.APIError as e:
        suggestion = None
        print(f"LLM呼び出しに失敗: {e}", file=sys.stderr)
    if suggestion:
        out += ["<details><summary>修正案(LLMが作成。採用前に必ず確認してください)</summary>",
                "", suggestion, "", "</details>"]
    else:
        out.append("修正案は作成していません。上の一覧をもとに、ドキュメントを更新してください。")
    print("\n".join(out))


if __name__ == "__main__":
    main()

書いていて気をつけたのは、次の点です。

説明欄を推測させない。 新しい列の「説明」は、コードからは分かりません。何も指示しないと、列名から説明文をそれらしく作ってしまいます。それがドキュメントに残ると、あとから来た人には推測か事実か区別できません。だから「(要記入)」と書かせて、人が埋める箇所として残します。

入力を黙って切り詰めない。 長すぎるときは、途中で切ってLLMに渡すのではなく、修正案を作らずにズレの一覧だけを出します。半分だけ読んだ修正案は、ないほうがましだと考えました。

途中で止まった応答は使わない。 stop_reason が end_turn 以外(出力上限に達した、など)のときは、修正案を捨てています。

モデルは環境変数で差し替える。 表の行を書き足すだけの作業なので、effort は low にしています。

3. GitHub Actionsに組み込む

.github/workflows/doc-drift.yml
name: doc-drift

on:
  pull_request:
    paths:
      - "app/**"
      - "migrations/**"
      - "docs/api.md"
      - "docs/db/**"

permissions:
  contents: read
  pull-requests: write

jobs:
  drift:
    runs-on: ubuntu-24.04
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    env:
      DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - run: pip install -r requirements.txt

      - name: OpenAPIを書き出す
        run: |
          mkdir -p build
          python scripts/export_openapi.py > build/openapi.json

      - name: マイグレーションを当てて、スキーマを書き出す
        run: |
          for f in migrations/*.sql; do
            psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -f "$f"
          done
          psql "$DATABASE_URL" -At -f scripts/export_schema.sql > build/schema.json

      - name: ズレを検出する
        id: drift
        continue-on-error: true
        run: python scripts/check_doc_drift.py build/openapi.json build/schema.json --report build/drift.json

      - name: 修正案を作ってPRにコメントする
        if: steps.drift.outcome == 'failure'
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GH_TOKEN: ${{ github.token }}
        run: |
          python scripts/draft_doc_fix.py build/drift.json > build/comment.md
          gh pr comment "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" --body-file build/comment.md

      - name: ズレがあればジョブを失敗にする
        if: steps.drift.outcome == 'failure'
        run: exit 1

continue-on-error: true を付けたステップは、失敗しても steps.drift.outcome が failure になるだけで、後続のステップは動きます。コメントを付けたあと、最後のステップで改めてジョブを失敗にしています。これで「ズレがある間はマージできない」と「何がずれているかはコメントで分かる」を両立させています。

paths には、コードとドキュメントの両方を入れています。ドキュメントだけを直したプルリクエストでも、直した結果が合っているかを確認するためです。

ubuntu-24.04 のランナーにはPostgreSQLのクライアント(psql)が入っているので、DBのドライバをPythonに入れずに済みます。

4. 実行結果

ズレを入れたサンプルで実行すると、check_doc_drift.py は終了コード1で終わり、次のレポートを書き出します。

build/drift.json
{
  "api": {
    "missing_in_doc": [
      "GET /users/{user_id}/invoices"
    ],
    "missing_in_code": [
      "DELETE /users/{user_id}"
    ]
  },
  "db": [
    {
      "table": "users",
      "kind": "missing_in_doc",
      "column": "display_name",
      "code": {
        "table": "users",
        "column": "display_name",
        "type": "character varying",
        "nullable": true
      }
    }
  ]
}

APIキーがない状態で draft_doc_fix.py を動かすと、コメントはズレの一覧だけになります。

build/comment.md
### ドキュメントとコードのズレ(自動検出)

- API: ドキュメントにない `GET /users/{user_id}/invoices`
- API: コードにない `DELETE /users/{user_id}`(削除済み?)
- DB: `users.display_name` missing_in_doc

修正案は作成していません。上の一覧をもとに、ドキュメントを更新してください。

APIキーがある場合は、この下に、折りたたみで修正案が付きます。

導入チェックリスト

  • OpenAPIはコードから生成されているか(手書きなら、まずそこを直す)
  • マイグレーションを空のDBに当てて、今のスキーマが再現できるか
  • API一覧とテーブル定義は、列の順番が決まったMarkdownの表になっているか(Excelなら書き出しの仕組みを先に作る)
  • テーブル定義のファイル名とテーブル名を一致させたか
  • 型の表記ゆれを TYPE_ALIASES に登録したか
  • コードやドキュメントの一部を外部のLLM APIに送ってよいか、契約やNDAで確認したか
  • APIキーはリポジトリのシークレットに置き、ログに出していないか
  • 最初の1〜2週間は、ジョブを失敗にせず、コメントだけにしたか(既存のズレが多いと、全部のプルリクエストが止まる)
  • 既存のズレを一度まとめて直す日を決めたか
  • 修正案をそのまま採用せず、「(要記入)」を人が埋める運用にしたか

最初からジョブを失敗にすると、既存のズレのせいで関係のないプルリクエストまで止まります。最後のステップをしばらく外しておき、ズレを一掃してから戻すのが無難です。

注意点と限界

フォークからのプルリクエストでは、シークレットが渡りません。 その場合は修正案なしのコメントになり、GITHUB_TOKEN も読み取り専用になるので、コメント自体が付かないことがあります。OSSで使う場合は、別の構成が要ります。

比べているのは、エンドポイントの有無、列の有無、型、NULL許可だけです。 リクエストやレスポンスの項目、varchar(100) の長さ、デフォルト値、インデックスは見ていません。実際、このサンプルでも display_name の長さ100は character varying としか出てきません。比べたい項目が増えたら、export_schema.sql で character_maximum_length などを取り出して足してください。

プッシュのたびにコメントが増えます。 gh pr comment は新しいコメントを付けるので、ズレを直すまでプッシュを繰り返すと、同じ内容が並びます。気になる場合は、前回のコメントを更新する形にします。

LLMの修正案は間違えることがあります。 ズレの検出は毎回同じ結果になりますが、修正案は実行のたびに表現が変わります。採用するかどうかは、必ずレビューで決めてください。

コードを外部に送ることになります。 お客様のプロジェクトで使う場合、外部のLLM APIにコードやドキュメントを送ってよいかは、契約次第です。私たちも、送る前に確認するようにしています。

引き継ぎで何が変わったか

Zennの記事で書いたとおり、この種の仕組みを3か月ほど運用したあと、別のプロジェクトで担当者交代がありました。以前は、ドキュメントのほぼ全ページについて、コードで裏を取っていました。そのときは、API一覧とテーブル定義は信じてよく、READMEなど文脈の要る部分だけを前任者に聞けば済みました。

引き継ぎにかかった日数を測ってはいないので、数字は出せません。変わったのは、全部を疑ってかかる状態から、疑う場所を決められる状態になったことです。

最後に

ドキュメントのうち、コードから「正」を取り出せる部分は、思っているより多くあります。まずはAPI一覧かテーブル定義のどちらか一つで、判定をスクリプト、修正案をLLMに分ける形から試してみてください。

参考文献

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?