Claude Media
契約台帳をPDFから一括抽出 — Claude APIとBatch APIの手順

契約台帳をPDFから一括抽出 — Claude APIとBatch APIの手順

契約書PDFの当事者・期間・自動更新・解約通知期限・準拠法をBatch APIで一括抽出し、CSVの契約台帳にするPythonの手順です。件数が多いときの分割と再投入も扱います。

契約書PDFが数百件あり、台帳がまだ無い。この状態から台帳を作るなら、Claude APIのMessage Batches APIにPDFを1件1リクエストで投げ、当事者・期間・自動更新・解約通知期限・準拠法をJSONで返させてCSVにまとめる流れが現実的です。バッチ料金は標準の50%で、ほとんどのバッチは1時間以内に終わります。

ここでは、項目の決め方、リクエストの作り方、結果の回収、CSVへの変換、人が見る列の作り方までをPythonで通します。台帳が既にあり、更新日を追いかけたい場合はCoworkで契約更新日をリマインドする手順が受け皿になります。ここで作るCSVは、その一覧の元データにもなります。

台帳に入れる項目を先に決める

最初に決めるのは、台帳の列です。列が曖昧なままPDFを投げると、Claudeは「重要そうな項目」を自分で選んで返します。契約書ごとに返る項目が揺れると、CSVに並べた時点で使えません。

この記事では次の8項目で進めます。日付はISO形式(YYYY-MM-DD)に揃えさせ、書かれていない項目は推測せず null にさせます。

台帳の列JSONのキー抽出時の注意
契約名JSONのキーtitle抽出時の注意表紙や題名の文字をそのまま
当事者JSONのキーparties抽出時の注意配列。社名と、甲・乙などの呼称を対で
開始日JSONのキーeffective_date抽出時の注意締結日と効力発生日が別なら効力発生日
現在の期間の満了日JSONのキーend_date抽出時の注意更新後の期間は含めない
自動更新JSONのキーauto_renewal抽出時の注意真偽値。条項が無ければ null
解約通知期限(原文)JSONのキーnotice_text抽出時の注意「満了の90日前まで」のような文言のまま
解約通知の日数JSONのキーnotice_days抽出時の注意日数に直せるときだけ整数
準拠法JSONのキーgoverning_law抽出時の注意「日本法」など条項の記載どおり

解約通知期限を「原文」と「日数」の2列に割るのがこの設計の要点です。期限日そのものをClaudeに計算させず、原文と日数だけを抜かせ、日付の計算は後段のコードで行います。計算を分けておけば、期限日がずれたときに原文を見て原因を追えます。

加えて、各項目の根拠ページ(pages)と、読み取りに迷った点のメモ(memo)も返させます。人が確認するとき、PDFのどこを開けばよいかが分かるからです。

全体の流れ

作業は6段階です。Claudeが担うのは3番目だけで、前後はふつうのスクリプトです。

手順

PDFから台帳ができるまで

  1. 1

    PDFを1件ずつ登録する

    ファイル名とは別に、短い連番の custom_id を振って対応表を残します。

  2. 2

    1件1リクエストでバッチを組む

    PDFを document ブロックで渡し、抽出指示を添えます。

  3. 3

    バッチを送り、完了を待つ

    processing_status が ended になるまでポーリングします。

  4. 4

    結果を回収してJSONに戻す

    custom_id で元のPDFと突き合わせます。

  5. 5

    CSVに書き出す

    解約通知の期限日はここでコードが計算します。

  6. 6

    人が確認してから台帳に載せる

    null や memo のある行を先に見ます。

PDFを1件1リクエストにして送る

Message Batches APIは、Messagesリクエストの束を非同期で処理します。1つのバッチに入れられるのは10万リクエストか256MBのどちらか先に届くほうまでです。契約書PDFは1件が数MBになることがあるので、実際に先に詰まるのはサイズのほうです。base64にすると元のファイルより大きくなる点も合わせて、バッチを分ける前提で組みます。

PDF側にも制限があります。リクエスト全体で32MB、ページ数は1リクエストあたり最大600ページです。ただしコンテキストウィンドウが1Mトークン未満のモデルでは100ページになります。契約書1件がこの範囲に収まるなら、1件1リクエストで問題ありません。数百ページの基本契約と付属書をまとめたPDFは、分割してから投入します。

custom_id には1〜64文字の英数字・ハイフン・アンダースコアしか使えません。日本語のファイル名はそのまま入れられないので、連番を振って対応表をJSONに残します。

import base64
import json
from pathlib import Path
 
import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
 
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5-5"
BATCH_BYTES_LIMIT = 150 * 1024 * 1024  # 256MBの上限に余裕を持たせた目安
 
SYSTEM = """あなたは契約書から台帳用の項目を抜き出す担当です。
PDFに書かれていることだけを根拠にし、書かれていない項目は null にします。
推測や一般的な慣行で補いません。日付はYYYY-MM-DD形式にします。
出力はJSONオブジェクト1つだけで、前後に説明文を付けません。"""
 
PROMPT = """次のキーを持つJSONを返してください。
title, parties(配列。社名と呼称), effective_date, end_date(現在の期間の満了日),
auto_renewal(真偽値), notice_text(解約通知の原文), notice_days(整数。日数に直せるときのみ),
governing_law, pages(各キーの根拠ページ番号のオブジェクト), memo(読み取りに迷った点)"""
 
 
def build_request(custom_id: str, pdf_path: Path) -> Request:
    data = base64.standard_b64encode(pdf_path.read_bytes()).decode("utf-8")
    return Request(
        custom_id=custom_id,
        params=MessageCreateParamsNonStreaming(
            model=MODEL,
            max_tokens=1500,
            system=SYSTEM,
            messages=[{
                "role": "user",
                "content": [
                    {"type": "document",
                     "source": {"type": "base64",
                                "media_type": "application/pdf",
                                "data": data}},
                    {"type": "text", "text": PROMPT},
                ],
            }],
        ),
    )
 
 
pdfs = sorted(Path("contracts").glob("*.pdf"))
manifest = {f"c{i:04d}": p.name for i, p in enumerate(pdfs, start=1)}
Path("manifest.json").write_text(
    json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
 
# サイズの合計がBATCH_BYTES_LIMITを超えない範囲でバッチを分ける
groups, current, size = [], [], 0
for cid, name in manifest.items():
    file_size = Path("contracts", name).stat().st_size
    if current and size + file_size > BATCH_BYTES_LIMIT:
        groups.append(current)
        current, size = [], 0
    current.append(cid)
    size += file_size
if current:
    groups.append(current)
 
batch_ids = []
for group in groups:
    requests = [build_request(cid, Path("contracts", manifest[cid]))
                for cid in group]
    batch = client.messages.batches.create(requests=requests)
    batch_ids.append(batch.id)
 
Path("batch_ids.json").write_text(json.dumps(batch_ids), encoding="utf-8")
print(batch_ids)

PDFをテキストより前に置くのは、公式のPDF処理の指針に沿った並びです。ほかにも、標準的なフォントを使う、ページを正しい向きに揃える、といった指針があります。スキャンが傾いた契約書は抽出結果が崩れやすいので、投入前に向きだけ直しておくと後の確認が減ります。

PDFは各ページが画像に変換され、抽出されたテキストが画像と並べて渡されます。1ページあたり1,500〜3,000トークンが目安です。署名欄の手書き部分や押印が読めるかは、PDFの画質に左右されます。

抽出プロンプトで効くのは「書かれていなければnull」

上のプロンプトは短く見えますが、効かせているのは2点です。

1つ目は、書かれていない項目を null にさせる指示です。これが無いと、準拠法の条項がない契約書で「日本法」と補った値が返ることがあります。台帳に入ってしまえば、誰も補われた値だと気づけません。

2つ目は、期間の取り方の指定です。自動更新の契約書には「当初期間」と「更新後の期間」が出てきます。台帳に載せたいのは今まさに進行している期間の満了日なので、end_date は現在の期間のものとプロンプトに明記しています。更新を何度も重ねた契約では、PDFの本文に現在の期間の満了日が書かれていないことがあります。その場合は、当初の満了日と更新条項から満了日を導けるかをClaudeに迷わせず、null と memo で人に回させるほうが安全です。

出力形式をさらに厳密に固定したい場合は、structured outputsを使う手もあります。使い方はClaude JSONモードの使い方にまとめてあります。この記事では、プロンプト指定と後段の検証の組み合わせで進めます。

バッチの完了を待って結果を回収する

送信後の processing_status は in_progress から始まり、全リクエストが終わると ended になります。数百件規模なら、ほとんどは1時間以内に終わります。24時間以内に処理が終わらなかったリクエストは expired になるので、翌日まで放置せず状況を見ます。

import json
import time
from pathlib import Path
 
import anthropic
 
client = anthropic.Anthropic()
batch_ids = json.loads(Path("batch_ids.json").read_text(encoding="utf-8"))
 
pending = set(batch_ids)
while pending:
    for bid in list(pending):
        b = client.messages.batches.retrieve(bid)
        if b.processing_status == "ended":
            pending.discard(bid)
            print(bid, b.request_counts)
    if pending:
        time.sleep(60)

終わったら結果を取り出します。結果は入力と同じ順序で返るとは限りません。公式も、結果の対応づけには必ず custom_id を使うよう求めています。先ほど連番を振って対応表を残したのは、このためです。

結果の種類は4つです。succeeded は成功、errored は失敗、canceled と expired は処理に至らなかったものです。errored、canceled、expired は課金されません。

import json
from pathlib import Path
 
import anthropic
 
client = anthropic.Anthropic()
batch_ids = json.loads(Path("batch_ids.json").read_text(encoding="utf-8"))
 
raw, failed = {}, {}
for bid in batch_ids:
    for item in client.messages.batches.results(bid):
        outcome = item.result
        if outcome.type == "succeeded":
            raw[item.custom_id] = outcome.message.content[0].text
        elif outcome.type == "errored":
            failed[item.custom_id] = outcome.error.error.type
        else:
            failed[item.custom_id] = outcome.type
 
Path("raw.json").write_text(
    json.dumps(raw, ensure_ascii=False), encoding="utf-8")
Path("failed.json").write_text(json.dumps(failed), encoding="utf-8")
print(len(raw), "件成功 /", len(failed), "件失敗または未処理")

結果は29日間保存されます。それを過ぎると、バッチ自体は見られても結果はダウンロードできなくなります。回収したJSONは、29日を待たずにローカルへ保存しておきます。

JSONを検証してCSVにする

succeeded でも、中身が使える形とは限りません。JSONとして読めない、必要なキーが欠けている、といった行はここで弾き、確認用の列に回します。解約通知の期限日は、抜き出した満了日と日数からコードで求めます。

import csv
import json
import re
from datetime import date, timedelta
from pathlib import Path
 
manifest = json.loads(Path("manifest.json").read_text(encoding="utf-8"))
raw = json.loads(Path("raw.json").read_text(encoding="utf-8"))
 
KEYS = ["title", "parties", "effective_date", "end_date", "auto_renewal",
        "notice_text", "notice_days", "governing_law", "pages", "memo"]
 
 
def parse(text: str):
    text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip())
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return None
 
 
def to_date(value):
    try:
        return date.fromisoformat(value) if value else None
    except ValueError:
        return None
 
 
rows = []
for cid, name in manifest.items():
    data = parse(raw.get(cid, ""))
    row = {"custom_id": cid, "file": name, "review": ""}
    if not isinstance(data, dict):
        row["review"] = "JSONとして読めない、または結果なし"
        rows.append(row)
        continue
    for k in KEYS:
        v = data.get(k)
        row[k] = (json.dumps(v, ensure_ascii=False)
                  if isinstance(v, (list, dict)) else v)
    end, days = to_date(data.get("end_date")), data.get("notice_days")
    if end and isinstance(days, int):
        row["notice_deadline"] = (end - timedelta(days=days)).isoformat()
    reasons = [k for k in ("end_date", "auto_renewal", "governing_law")
               if data.get(k) is None]
    if data.get("memo"):
        reasons.append("memoあり")
    row["review"] = " / ".join(reasons)
    rows.append(row)
 
fields = ["custom_id", "file"] + KEYS + ["notice_deadline", "review"]
with open("ledger.csv", "w", newline="", encoding="utf-8-sig") as f:
    writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
    writer.writeheader()
    writer.writerows(rows)

CSVを utf-8-sig で書いているのは、Excelで開いたときに日本語が文字化けしにくいからです。review 列は、何が欠けているかを書き出すだけの単純なものです。この列が空でない行だけを先に見れば、全件を目で追う必要はなくなります。

notice_deadline は「現在の期間の満了日から通知日数をさかのぼった日」です。「更新日の前営業日まで」のように営業日で数える契約や、「到達日基準」か「発信日基準」かで数え方が変わる契約は、この単純な引き算に合いません。notice_text の原文を並べておけば、そうした行は人がすぐ見つけられます。

台帳に載せる前に人が見る場所

抽出結果は下書きです。契約書の解釈そのものは弁護士の領域で、Claudeが返した値を正解として台帳に入れる運用はお勧めしません。AIの契約書レビューと弁護士法72条の関係は弁護士法72条とAIの契約書レビューで整理しています。ここで作る台帳は、法的判断ではなく、契約書の記載を表に写し取る作業にとどめるのが線引きです。

見る順番の目安を挙げます。

  • review 列に何か入っている行は、すべて目視します。
  • pages に書かれた根拠ページをPDFで開き、end_date と notice_text だけは全件を突き合わせます。この2つは更新の見逃しに直結する列です。
  • 当事者の社名は、登記上の表記と一致しているかを別途確かめます。

目視の対象を絞る別の手もあります。同じ契約書を2回抽出して結果が食い違った行だけを見る方法です。バッチ料金は標準の半額なので、2回流しても片道の標準料金と同じ水準で済みます。

失敗した分だけ再投入する

failed.json に残った行は、原因で扱いが分かれます。

結果意味次の手
errored(invalid_request_error)意味リクエスト自体に問題がある次の手PDFやパラメータを直してから再送
errored(それ以外)意味サーバー側の失敗次の手そのまま再送できる
expired意味24時間以内に処理されなかった次の手そのまま再送できる
canceled意味バッチを中止した次の手必要なら再送

PDFが原因で400エラーになった行は、「Could not process PDF」400エラーの原因と対処法に原因の切り分けがあります。パスワード付きや暗号化されたPDFは、標準的なPDFではないので受け付けられません。投入前に解除しておく必要があります。

もうひとつ見落としやすいのが、成功扱いで返ってくる拒否です。拒否されたリクエストも succeeded として返るため、outcome.type を見ているだけでは気づけません。stop_reason まで確認する書き方はBatch APIの拒否は成功扱いになる落とし穴にあります。台帳に空の行が混じる原因になるので、検証コードに入れておくと見落としを防げます。

再送分は、元の custom_id をそのまま使って新しいバッチを作ります。custom_id は1つのバッチの中で重複しなければよいので、manifest.json の連番を再利用でき、CSVの行との対応が崩れません。バッチの基本的な流れはClaude Batch APIの使い方にあります。

費用と機密性の見方

費用は、ページ数と選んだモデルで大づかみに見積もれます。PDFは1ページあたり1,500〜3,000トークンが目安で、PDF専用の追加料金はありません。たとえば1件30ページの契約書を300件処理すると、入力は約1,350万〜2,700万トークン(13.5〜27MTok)です。バッチ料金でClaude Sonnet 5.5を使うと、入力は100万トークンあたり1ドルなので、入力側は13.5〜27ドルの計算になります。出力はJSONが数百トークンなので、300件でも1ドル前後です。

実際のトークン数はPDFごとに違うため、投入前にトークンカウントで試算できます。モデルは精度と単価の兼ね合いです。まず代表的な契約書10件ほどで試し、項目ごとの正答率を見てから決めます。

機密性の面では、バッチの扱いを把握しておく必要があります。バッチの入出力は作成から最大29日間、サーバーに保存されます。処理後であれば DELETE /v1/messages/batches/{batch_id} でいつでも削除でき、処理中のバッチは先にキャンセルする必要があります。契約書には取引先の情報が含まれるので、結果を回収し終えたら削除してしまう運用が合います。バッチは作成したWorkspace内に隔離されます。同じWorkspaceのAPIキーを持つ人は、そのバッチと結果を見られます。ゼロデータリテンション(ZDR)の対象になるかどうかは、公式の「API and data retention」ページに機能ごとの一覧があります。

つまずきやすい点

  • 日本語のファイル名を custom_id に入れて400になる: 英数字・ハイフン・アンダースコアのみ、1〜64文字です。連番と対応表で避けます。
  • 1つのバッチが256MBを超える: 413の request_too_large になります。上のコードのようにサイズで分割します。
  • サイズは足りているのにページ数で落ちる: ページ数の上限は1リクエストあたり100〜600ページです。付属書つきの長大なPDFは先に分割します。
  • 結果をCSVの行順に並べてしまう: 返る順序は不定です。必ず custom_id で突き合わせます。
  • 29日を過ぎて結果が取れない: 回収したJSONは、取得した日のうちにローカルへ保存しておきます。
  • スキャン画像の契約書で項目が空になる: PDFは画像とテキストの両方を渡す仕組みですが、画質が低いと読み違いが増えます。向きと解像度を整えてから投入します。

件数が少ないとき、条項の中身まで見たいとき

台帳づくりはバッチ向きの仕事ですが、数十件ならCoworkのフォルダ処理でも用が足ります。手元のフォルダにある契約書をPlaybookで一括レビューする手順はCoworkで契約書をバッチレビューするにあります。台帳を作るのがAPIで、条項の良し悪しを見るのがCoworkという分担です。

条項ごとの要約や、根拠となる条文の引用まで残したい場合は、抽出項目の設計とメタ要約を扱うClaudeで法務文書を要約するAPI実装が次の読み物になります。PDFをファイルとして保管して使い回す方法はClaude Files APIでPDFを処理する方法で説明しています。

まとめ

契約台帳の下書きづくりは、台帳の列を先に決め、PDFを1件1リクエストでBatch APIに流し、custom_id で結果を回収してCSVにする流れで組めます。Claudeに任せるのは「書かれた内容の抜き出し」までです。期限日の計算と、更新の見逃しに効く満了日・通知条項の突き合わせは、コードと人が受け持ちます。最初の10件で列の定義と正答率を固めてから全件を流す順番にすると、やり直しのコストを抑えられます。

この記事を共有:XはてブLinkedIn