契約台帳を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
PDFを1件ずつ登録する
ファイル名とは別に、短い連番の
custom_idを振って対応表を残します。 - 2
1件1リクエストでバッチを組む
PDFを
documentブロックで渡し、抽出指示を添えます。 - 3
バッチを送り、完了を待つ
processing_statusがendedになるまでポーリングします。 - 4
結果を回収してJSONに戻す
custom_idで元のPDFと突き合わせます。 - 5
CSVに書き出す
解約通知の期限日はここでコードが計算します。
- 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件で列の定義と正答率を固めてから全件を流す順番にすると、やり直しのコストを抑えられます。