見積書PDFをClaude APIで品目表に構造化 — 複数社を同じ列で比較する
書式の違う見積書PDFから品目・数量・単価・条件をJSONで抜き、合計の再計算で抜けを検知して、複数社を同じ列に並べるPythonの手順です。
3社から見積書PDFが届いたのに、品名の書き方も、税込か税抜かも、納期の置き場所もバラバラ。比較表を作る前に、まず写経が始まります。この写経を、Claude APIのPDF対応とstructured outputsで「品目の表」に置き換えるのがこの記事の話です。
流れは4段です。見積書をPDFのまま渡して品目と条件をJSONで返させ、数量×単価と合計をコードで再計算して抜けを拾い、依頼した品目名に寄せて、最後に複数社を同じ列のCSVに並べます。チャットに貼り付けて比較する手作業はClaudeでベンダー見積もりを比較・正規化する方法で扱っています。ここでは、件数が増えても同じ手順で回せるようにスクリプトにします。
全体の流れと、Claudeに任せる範囲
見積書PDFが比較表になるまで
- 1
PDFをそのまま渡す
1社1リクエストで、見積書PDFと依頼品目の一覧を渡します。
- 2
スキーマに沿ったJSONで受け取る
品目の行、小計・税・総額、納期・支払条件・有効期限を返させます。
- 3
コードで再計算する
数量×単価と各行の合計が合うか、行の合計が小計に一致するかを調べます。
- 4
同じ列に並べて出力する
依頼品目を行、会社を列にしたCSVを作ります。
Claudeが担うのは1番目と2番目、つまり「PDFを読んで決まった形に起こす」ところだけです。3番目の計算は、あえてコード側に置きます。読み取りで1行落ちたり桁を取り違えたりしたとき、AIの自己申告に頼らず機械的に気づける形にしておくためです。
PDFの渡し方と、先に確かめておく条件
PDFは3通りで渡せます。URLの参照、base64で埋め込むdocumentブロック、Files APIのfile_idです。ローカルにある見積書を数社分処理するだけなら、base64で十分です。
渡す前に押さえる条件は次のとおりです。
PDFを渡すときの上限
リクエストサイズ
32MB
プラットフォームにより異なる
1リクエストのページ数
600
コンテキストが1M未満なら100
形式
標準PDF
パスワード・暗号化は不可
両方の上限はPDF以外の入力も含めたリクエスト全体にかかります。見積書は数ページのものが大半なので、上限にぶつかるのは「見積書に仕様書や図面がまとめて綴じられている」ケースです。表が細かいページや小さい文字のページが多いPDFは、ページ数の上限に届く前にコンテキストウィンドウを使い切ることがあります。その場合は、見積書の本体だけを切り出すか、ページを分けて送ります。上限そのものの扱いはClaudeとChatGPTのPDF処理上限を比較するに詳しく、リクエストが通らないときの400エラーは「Could not process PDF」の原因と対処法で切り分けられます。
使える面にも差があります。Amazon BedrockとGoogle Cloudでは、現状base64で渡す方式だけが使えます。Bedrockを使うなら、ConverseのAPIで画像や表の見た目まで読ませるには引用(citations)を有効にする必要があり、無効のままだと文字の抽出だけになります。表の罫線や結合セルに意味がある見積書では、この差が結果に出ます。
PDFは1ページずつ画像に変換され、抽出したテキストが画像と一緒に渡されます。トークンは1ページあたり1,500〜3,000が目安で、PDF専用の追加料金はありません。見積書1通が3〜5ページなら、1,500〜3,000トークンの掛け算で4,500〜15,000トークン前後です。実際の消費は、token countingで自分のPDFを測ると確実です。
スキーマを決める — 先に列を決めておく
返させる形は、Pydanticのモデルで定義します。client.messages.parse()にモデルをoutput_formatとして渡すと、SDKがスキーマに変換して送り、返ってきたJSONを検証してparsed_outputに入れてくれます。JSON出力の固定そのものはClaude JSONモードの使い方にまとめています。
見積書の場合、決めどころは次の3点です。
- 品目の行。品名・仕様・数量・単位・単価・行の金額に加え、PDFの何ページ目にあったかも持たせます。あとで人が確かめるときの手がかりになります
- 金額の階層。小計、消費税、総額を別々の項目にして、PDFに書かれていなければ空にします。税込か税抜かは、書かれていないことも多いので「税込」「税抜」「記載なし」の三択で受けます
- 依頼品目との対応。こちらが出した依頼品目のどれに当たる行かを、各行に書かせます。会社ごとに違う品名を、比較の行に寄せるための列です
from pydantic import BaseModel
class LineItem(BaseModel):
requested_item: str # 依頼品目のどれか。該当なしなら「該当なし」
item_name: str # PDFに書かれた品名そのまま
spec: str | None # 型番・仕様
quantity: float | None
unit: str # 台・式・m など。書かれていなければ空文字
unit_price: float | None
line_total: float | None # 行の金額。値引き行はマイナス
source_page: int # PDFビューアー上のページ番号
class Quote(BaseModel):
vendor: str
quote_no: str | None
issued_on: str | None # YYYY-MM-DD
tax_basis: str # 税込 / 税抜 / 記載なし
items: list[LineItem]
subtotal: float | None
tax: float | None
grand_total: float | None
delivery: str | None # 納期の原文
payment_terms: str | None # 支払条件の原文
valid_until: str | None # 見積有効期限の原文スキーマには制限があります。minimumやmaxLengthのような数値・文字列の制約は使えず、Pydanticで付けてもAPIには届きません(SDKが説明文に書き写して、返ってきた値の検証側で効かせます)。もう一点、str | Noneのようなnull許容の項目は、スキーマ上はanyOfの型になり、公式が定める「union型を持つパラメータは合計16個まで」の対象です。上のモデルでnull許容の項目は12個なので、範囲に収まっています。項目を足すときは、この数を数えながら足すのが安全です。
もう一つ、必須項目と任意項目の並び順にも癖があります。出力ではrequiredな項目が先に、任意の項目があとに並びます。上のモデルはすべて必須の形で書いているので、定義した順に出ます。
PDFを渡して品目JSONを受け取る
PDFはテキストより前に置きます。これは公式の指針に沿った並びです。ほかに、標準的なフォントを使う、ページを正しい向きにそろえる、文字がはっきり読めるようにする、といった指針があります。FAXを撮ったような傾いた見積書は、向きを直してから送ったほうが抜けが減ります。
import base64
from pathlib import Path
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5-5"
REQUESTED = ["ノートPC 14型", "ディスプレイ 24型", "ドッキングステーション"]
SYSTEM = """あなたは見積書PDFから品目表を起こす担当です。
PDFに書かれていることだけを根拠にし、書かれていない項目はnullにします。
金額は税表記を変えず、PDFに印字された数字をそのまま使います。
値引き行はline_totalをマイナスで返します。
ページ番号はPDFビューアー上のページ番号で答えます。"""
def extract_quote(pdf_path: Path) -> Quote:
data = base64.standard_b64encode(pdf_path.read_bytes()).decode("utf-8")
prompt = (
"この見積書の全品目をitemsに入れてください。"
"各行のrequested_itemには、次の依頼品目のうち最も近いものを"
"一字一句そのまま書き、どれにも当たらない行は『該当なし』とします。\n"
+ "\n".join(f"- {name}" for name in REQUESTED)
)
response = client.messages.parse(
model=MODEL,
max_tokens=8000,
system=SYSTEM,
messages=[{
"role": "user",
"content": [
{"type": "document",
"source": {"type": "base64",
"media_type": "application/pdf",
"data": data}},
{"type": "text", "text": prompt},
],
}],
output_format=Quote,
)
if response.stop_reason != "end_turn":
raise RuntimeError(f"{pdf_path.name}: {response.stop_reason}")
return response.parsed_output最後のstop_reasonの確認は省かないでください。出力がmax_tokensで途切れると、JSONがスキーマに合わない形で終わることがあります。品目が数十行ある見積書は、max_tokensを多めに取ります。安全上の理由で応答を断られたときも、stop_reasonがrefusalになりステータスは200のまま返るため、ステータスだけ見ていると気づけません。
「書かれていないものはnull」の指示は、見積書では特に効きます。税の欄がない見積書で「消費税10%」を補われると、合計の検算が合ってしまい、誰も補完に気づけないからです。
合計の再計算で、抜けと読み違いを拾う
ここからはコードの仕事です。見積書に印字された金額は、次の3つの等式で互いに縛られています。
| 検算 | 崩れたときに疑うこと |
|---|---|
| 数量×単価=行の金額 | 崩れたときに疑うこと桁の読み違い、単位(式・台)の取り違え |
| 行の金額の合計=小計 | 崩れたときに疑うこと品目の行の抜け、値引き行の符号 |
| 小計+税=総額 | 崩れたときに疑うこと税の欄の読み違い、税込・税抜の取り違え |
def verify(q: Quote, tol: float = 0.5) -> list[str]:
problems = []
total = 0.0
for i, it in enumerate(q.items, start=1):
if it.line_total is None:
problems.append(f"{i}行目 {it.item_name}: 金額が空")
continue
total += it.line_total
if it.quantity is not None and it.unit_price is not None:
if abs(it.quantity * it.unit_price - it.line_total) > tol:
problems.append(
f"{i}行目 {it.item_name}: 数量×単価が金額と合わない"
f"(p.{it.source_page})")
if it.requested_item not in REQUESTED + ["該当なし"]:
problems.append(f"{i}行目: 依頼品目名が不一致 {it.requested_item}")
if q.subtotal is not None and abs(total - q.subtotal) > tol:
problems.append(f"行の合計 {total:,.0f} が小計 {q.subtotal:,.0f} と不一致")
if None not in (q.subtotal, q.tax, q.grand_total):
if abs(q.subtotal + q.tax - q.grand_total) > tol:
problems.append("小計+税が総額と不一致")
return problemstolは端数処理の幅です。行ごとに切り捨てる会社と、合計で切り捨てる会社があるので、1円単位でぴったり一致を求めると誤検知が増えます。まず0.5円ほどで回し、通らない会社だけ個別に幅を広げます。
依頼品目名のチェックも、ここに入れてあります。structured outputsのenumは大文字小文字までは保証されないと公式が注意しているので、返ってきた文字列を依頼品目の一覧と照合する形にしています。日本語の品名では大文字小文字の問題は起きにくいものの、型番に英字が入る場合に効きます。
問題が出た行は、source_pageで示したページをPDFビューアーで開いて見ます。再計算が合わなかったから誤りと決めつけず、「人が見るべき行の絞り込み」として使うのが現実的です。見積書そのものの計算が間違っていることも、実際にはあります。
複数社を同じ列に並べる
3社分をそれぞれextract_quoteにかけ、verifyを通したら、依頼品目を行、会社を列にして並べます。
import csv
quotes = {p.stem: extract_quote(p) for p in sorted(Path("quotes").glob("*.pdf"))}
for name, q in quotes.items():
for msg in verify(q):
print(f"[要確認] {name}: {msg}")
vendors = list(quotes)
with open("comparison.csv", "w", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
w.writerow(["依頼品目"] + [f"{v}(単価/金額)" for v in vendors])
for req in REQUESTED:
row = [req]
for v in vendors:
hits = [it for it in quotes[v].items if it.requested_item == req]
row.append(" + ".join(
f"{it.unit_price}/{it.line_total}" for it in hits) or "なし")
w.writerow(row)
w.writerow(["税表記"] + [quotes[v].tax_basis for v in vendors])
w.writerow(["総額"] + [quotes[v].grand_total for v in vendors])
w.writerow(["納期"] + [quotes[v].delivery for v in vendors])
w.writerow(["支払条件"] + [quotes[v].payment_terms for v in vendors])
w.writerow(["有効期限"] + [quotes[v].valid_until for v in vendors])utf-8-sigで書いているのは、ExcelでCSVを開いたときに文字化けしないようにするためです。同じ依頼品目に複数の行が当たる場合(本体と保守が別行になっているなど)は、+でつないで1つのセルに入れています。
出力は次のような形になります。会社名と数字は説明用の仮のものです。
| 依頼品目 | A社 | B社 | C社 |
|---|---|---|---|
| ノートPC 14型 | A社128,000/1,280,000 | B社119,800/1,198,000 | C社131,000/1,310,000 |
| ディスプレイ24型 | A社21,500/215,000 | B社24,000/240,000 | C社なし |
| 税表記 | A社税抜 | B社税込 | C社税抜 |
表の最終行に税表記を並べているのは、横に並べた単価が同じ基準かどうかを、比較する人の目に入れるためです。上の仮の例ではB社だけが税込なので、そのまま単価を比べるとB社が不当に高く見えます。税込と税抜が混ざっているときは、税率が明記されている会社に限って換算列を足します。換算の前提が書かれていない見積書は、換算せずに「記載なし」のまま残します。
「なし」の列も大切です。C社のように依頼品目のうち一部しか見積もっていない会社は、再計算では問題が出ません。比較表の段階で初めて「見積もりが来ていない品目」として見えてきます。
つまずきやすい点
- スキャン画像のPDFで金額が欠ける: 各ページは画像にも変換されるため、文字レイヤーのないスキャンでも読めますが、画質が低いと桁を読み違えます。再計算が合わないのは、たいていこのケースです。解像度の高いスキャンを取り直すと改善します
- ページ番号がずれる: 表紙や目次を数えるかどうかで、印字のページ番号とPDFビューアー上の番号が違います。プロンプトでは、ビューアー上の番号で答えさせる決めにしておくと、人が開くときに迷いません
- 品目が多くて出力が途中で切れる:
stop_reasonがmax_tokensになったら、max_tokensを増やして再実行します。見積書の末尾にある小計や総額が欠けている場合も、これが原因のことがあります - 同じスキーマでも初回だけ遅い: スキーマを初めて使うときは、文法の準備のために追加の遅延があり、準備した結果は24時間キャッシュされます。スキーマを書き換えるとキャッシュが切り替わるので、項目を頻繁にいじりながら試すと、そのたびに初回の遅さが出ます
件数が増えたとき
見積書が数社分なら、上のスクリプトで足ります。何百通もの請求書や契約書をまとめて処理するなら、Message Batches APIでPDFを1件1リクエストとして投げる方法があり、公式のPDF対応のページにも例があります。実際の手順は契約台帳をPDFから一括抽出する手順にあり、この記事のスキーマを差し替えるだけで流用できます。同じ依頼品目リストを何度も渡す運用なら、プロンプトキャッシュも使えます。
大きなPDFを繰り返し使うなら、Files APIで一度アップロードしてfile_idで参照する方法もあります。base64の変換が要らなくなり、リクエストが小さくなります。ただしMicrosoft FoundryでAzure上にホストしたデプロイでは、Files APIは使えません。
本番で使う前の線引き
この手順が出すのは「検算つきの下書き」です。見積書の金額は発注の判断に直結するため、再計算で引っかかった行とnullの項目は、必ず人がPDFで確かめてから比較表に載せます。再計算が全部通っても、品名の対応づけ(どの行がどの依頼品目か)は検算では守れません。最初の数社は、依頼品目との対応を目で確かめる運用にすると安全です。