GPT-5.6の見積書OCRをPythonで検算する
見積書を画像から読ませる仕事では、文字の読み取り精度より「合計が合っているか」のほうを先に機械で止めた方がいいです。
今朝、GPT-5.6 Sol の画像認識を扱った記事を見て、あらためてそう感じました。画像を読むモデルはかなり使いやすくなっています。けれど、見積書をスプレッドシートへ移す場面で困るのは、品名の一文字よりも 23,100円 が 23,000円 のまま次へ流れることです。人の目は合計欄だけ見て、明細の掛け算まで戻らないことがある。ここ地味に効きます。
モデルには画像から決まったJSONを返してもらい、金額の判定だけはPythonに任せます。OCRの結果をそのまま確定データにしない、という分け方です。
返却JSONに入れる項目を絞る
まず、画像を渡す側では次の形で返すよう指定します。金額は 12000 のような文字列に寄せます。12,000円 や 約1.2万円 を混ぜると、後段のチェックが余計に複雑になります。
{
"document_type": "見積書",
"issuer": "サンプル商事株式会社",
"issue_date": "2026-08-18",
"items": [
{"name": "初期設定", "quantity": "1", "unit_price": "12000", "amount": "12000"}
],
"subtotal": "12000",
"tax": "1200",
"total": "13200"
}
明細が複数行あるときは、items の要素を増やします。税込10%を決め打ちで計算しないのがポイントです。軽減税率や端数処理があるので、ここでは書類に書かれた税額を取り込み、明細合計・小計・税額・合計のつながりだけを見ます。
この流れでは、画像を見たモデルが金額を決めるわけではありません。
Pythonで明細から合計まで照合する
次のスクリプトを verify_estimate.py として保存します。標準ライブラリだけで動きます。Decimal を使っているのは、金額にfloatの丸め誤差を持ち込みたくないからです。
import json
import sys
from datetime import date
from decimal import Decimal, InvalidOperation
def yen(value, path):
if not isinstance(value, str):
raise ValueError(f"{path}: 文字列で返っていません")
try:
amount = Decimal(value)
except InvalidOperation:
raise ValueError(f"{path}: 金額として読めません ({value!r})")
if amount < 0 or amount.as_tuple().exponent < 0:
raise ValueError(f"{path}: 0以上の整数で返してください ({value!r})")
return amount
def require(record, key, path):
value = record.get(key)
if value in (None, ""):
raise ValueError(f"{path}.{key}: 値がありません")
return value
def verify(data):
if data.get("document_type") != "見積書":
raise ValueError("document_type: 見積書ではありません")
require(data, "issuer", "root")
try:
date.fromisoformat(require(data, "issue_date", "root"))
except ValueError:
raise ValueError("root.issue_date: YYYY-MM-DD形式ではありません")
items = data.get("items")
if not isinstance(items, list) or not items:
raise ValueError("items: 明細がありません")
item_total = Decimal("0")
for index, item in enumerate(items, start=1):
path = f"items[{index}]"
require(item, "name", path)
quantity = yen(require(item, "quantity", path), path + ".quantity")
unit_price = yen(require(item, "unit_price", path), path + ".unit_price")
amount = yen(require(item, "amount", path), path + ".amount")
if quantity == 0:
raise ValueError(path + ".quantity: 0件です")
if quantity * unit_price != amount:
raise ValueError(
f"{path}: 数量×単価={quantity * unit_price}円、金額={amount}円です"
)
item_total += amount
subtotal = yen(require(data, "subtotal", "root"), "root.subtotal")
tax = yen(require(data, "tax", "root"), "root.tax")
total = yen(require(data, "total", "root"), "root.total")
if item_total != subtotal:
raise ValueError(f"明細合計={item_total}円、税抜小計={subtotal}円です")
if subtotal + tax != total:
raise ValueError(f"税抜小計+消費税={subtotal + tax}円、合計={total}円です")
return item_total, total
if __name__ == "__main__":
try:
with open(sys.argv[1], encoding="utf-8") as source:
item_total, total = verify(json.load(source))
print(f"OK: 明細 {item_total}円 / 合計 {total}円")
except (IndexError, OSError, json.JSONDecodeError, ValueError) as error:
print(f"NG: {error}")
sys.exit(1)
実行結果と、止まるケース
先ほどのJSONを estimate.json として保存して実行します。
python3 verify_estimate.py estimate.json
手元では、明細が 12,000円 と 9,000円、税抜小計が 21,000円、消費税が 2,100円 のデータで次の出力になりました。
OK: 明細 21000円 / 合計 23100円
同じ明細で total だけを 23000 に変えると、終了コード1で止まります。
NG: 税抜小計+消費税=23100円、合計=23000円です
検算で拾えるのは算数と形式のズレです。画像の「8」を「3」と読んだ結果でも、明細から合計まで一貫していれば通ります。金額が大きい案件は、発行元・宛名・銀行口座の突合も別に残した方が安心です。全部を一つの判定に詰め込むより、止めたい理由ごとにチェックを分ける方が、修正の依頼も早くなります。
このまま業務へ入れるなら
取り込みの順番は、画像を読む、JSONを保存する、このスクリプトを通す、OKだけをスプレッドシートへ書く、で十分です。NGのJSONは捨てずに、元画像と同じフォルダへ残します。後から「どこが違ったか」を確認できるからです。
画像認識モデルの更新は歓迎です。ただ、書類の転記で信頼を作るのはモデル名ではなく、間違った数字を次の工程へ渡さない仕組みです。まずは見積書1枚で動かしてみると、どの項目に揺れが出るかも見えてきます。