4
5

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の請求書抽出をPydanticで型と業務ルールまで固定する

4
Posted at

はじめに

書類の読み取りを自動化するとき、OCRの次に困るのが「LLMから返ってきた値を、そのまま業務データとして扱ってよいのか」という問題です。

たとえば請求書なら、合計金額が "110,000円"、日付が "2026年9月30日"、登録番号が "T1234567890123" のように書かれています。人が見れば意味は分かりますが、後続のデータベースや会計処理へ渡すなら、文字列のままでは扱いにくくなります。

AIを使った業務の自動化に取り組む中で、私は「AIに正解を保証させる」のではなく、AIには候補を構造化してもらい、プログラム側でも検証する形にしています。

この記事では、OCR済みの請求書テキストを入力として、金額・請求日・インボイス番号をLLMで構造化し、Pydanticで検証するところまでをPythonで組みます。

結論

LLMの出力形式はPydanticモデルで固定します。
ただし、JSONスキーマに適合したことと、元の請求書を正しく読めたことは同じではありません。
型、文字列形式、項目間の関係をアプリケーション側でも検証し、通らないデータは人の確認へ回します。

環境

この記事では、2026年9月26日時点で次の構成を前提にします。

  • macOS / Linux
  • Python 3.12
  • OpenAI Python SDK 3.19.2
  • Pydantic 2.13.5
  • OpenAI Responses API

OpenAI Python SDK 3.19.2は2026年9月24日公開、Pydantic 2.13.5は2026年8月28日公開の安定版です。バージョンはPyPIの公開情報で確認できます。

インストールします。

python -m venv .venv
source .venv/bin/activate

pip install "openai==3.19.2" "pydantic==2.13.5"

APIキーは環境変数から読み込む前提にします。コードへ直接書き込みません。

処理全体は次のように分けます。

ここで重要なのは、LLMを最終判定者にしないことです。

OCRもLLMも間違える可能性があります。そこで、それぞれが得意な処理だけを担当させ、最後に決定的なルールを通常のプログラムで確認します。

🔧 実装

まず「保存したい形」から決める

先にプロンプトを書くのではなく、後続システムが欲しいデータ型から決めます。

今回は次のJSONを最終形にします。

{
  "invoice_number": "INV-2026-001",
  "invoice_date": "2026-09-30",
  "due_date": "2026-10-31",
  "subtotal": 100000,
  "tax": 10000,
  "total": 110000,
  "currency": "JPY",
  "issuer_registration_number": "T1234567890123"
}

ポイントは、表示用の文字列と保存用の値を分けることです。

たとえば金額を "110,000円" のまま返させると、後でカンマや通貨記号を取り除く必要があります。最初から整数の 110000 として扱えるほうが後続処理は単純です。

日付も同じです。和暦や "9月30日" といった表現をそのまま保存せず、抽出結果ではISO 8601形式の日付へ正規化します。

Pydanticモデルを作る

まずは構造を定義します。

from datetime import date
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field


class InvoiceExtraction(BaseModel):
    model_config = ConfigDict(extra="forbid")

    invoice_number: str | None = Field(
        default=None,
        description="請求書番号。記載がなければnull",
    )
    invoice_date: date
    due_date: date | None = None

    subtotal: int | None = Field(default=None, ge=0)
    tax: int | None = Field(default=None, ge=0)
    total: int = Field(ge=0)

    currency: Literal["JPY"]

    issuer_registration_number: str | None = Field(
        default=None,
        description="適格請求書発行事業者の登録番号。記載がなければnull",
    )

extra="forbid" を入れているのは、定義していないキーを受け入れないためです。

LLMが親切心で、

{
  "total": 110000,
  "memo": "税込金額です"
}

のような項目を追加しても、保存側の仕様には混ぜません。

また、金額には ge=0 を付けています。今回のサンプルでは通常の請求書を対象とし、負の値は受け付けない設計にしています。

ただし、これは普遍的なルールではありません。値引き、返金、赤伝票などを同じモデルで扱うシステムなら、この制約は変える必要があります。

スキーマは「世の中の請求書とはこういうものだ」という定義ではなく、「この処理が受け入れるデータ」の定義です。

インボイス番号を検証する

適格請求書発行事業者の登録番号について、国税庁は法人番号を有する課税事業者の場合、「T+法人番号(13桁)」という形式を案内しています。

今回は抽出後の形式チェックとして、T と13桁の数字だけを受け付けます。

import re

from pydantic import field_validator


REGISTRATION_NUMBER_PATTERN = re.compile(r"^T[0-9]{13}$")


class InvoiceExtraction(BaseModel):
    model_config = ConfigDict(extra="forbid")

    invoice_number: str | None = None
    invoice_date: date
    due_date: date | None = None

    subtotal: int | None = Field(default=None, ge=0)
    tax: int | None = Field(default=None, ge=0)
    total: int = Field(ge=0)

    currency: Literal["JPY"]

    issuer_registration_number: str | None = None

    @field_validator("issuer_registration_number")
    @classmethod
    def validate_registration_number(
        cls,
        value: str | None,
    ) -> str | None:
        if value is None:
            return None

        if not REGISTRATION_NUMBER_PATTERN.fullmatch(value):
            raise ValueError(
                "登録番号はTに続く13桁の数字で指定してください"
            )

        return value

ここで確認しているのは、あくまで文字列形式です。

T1234567890123 という形式になっていても、その番号が実在する事業者の有効な登録番号であることまでは保証できません。

実在性や登録状態まで必要なら、形式検証とは別の工程として扱うべきです。

金額の整合性も見る

JSONとして正しくても、次のデータは困ります。

{
  "subtotal": 100000,
  "tax": 10000,
  "total": 150000
}

型は全部整数なので、単純なスキーマ検証だけなら通ります。

そこで、項目同士の関係をPydanticの model_validator で確認します。

from pydantic import model_validator


class InvoiceExtraction(BaseModel):
    model_config = ConfigDict(extra="forbid")

    invoice_number: str | None = None
    invoice_date: date
    due_date: date | None = None

    subtotal: int | None = Field(default=None, ge=0)
    tax: int | None = Field(default=None, ge=0)
    total: int = Field(ge=0)

    currency: Literal["JPY"]
    issuer_registration_number: str | None = None

    @field_validator("issuer_registration_number")
    @classmethod
    def validate_registration_number(
        cls,
        value: str | None,
    ) -> str | None:
        if value is None:
            return None

        if not REGISTRATION_NUMBER_PATTERN.fullmatch(value):
            raise ValueError(
                "登録番号はTに続く13桁の数字で指定してください"
            )

        return value

    @model_validator(mode="after")
    def validate_amounts(self):
        if self.subtotal is not None and self.tax is not None:
            expected_total = self.subtotal + self.tax

            if self.total != expected_total:
                raise ValueError(
                    "subtotal + tax と total が一致しません"
                )

        return self

このチェックも、対象帳票の仕様に合わせて決めます。

実際の請求書では、複数税率、非課税項目、端数処理、値引きなどがあります。すべての帳票に対して subtotal + tax == total が成立するとは限りません。

そのため私は、バリデーションを増やすときに「数学的に正しそうだから」ではなく、その業務で確実に成立する条件かを先に確認するようにしています。

支払期日の前後関係を見る

日付にも項目間の関係があります。

今回の処理では、支払期日が記載されている場合、請求日より前の日付を異常として扱うことにします。

@model_validator(mode="after")
def validate_business_rules(self):
    if self.due_date is not None:
        if self.due_date < self.invoice_date:
            raise ValueError(
                "支払期日が請求日より前になっています"
            )

    if self.subtotal is not None and self.tax is not None:
        if self.subtotal + self.tax != self.total:
            raise ValueError(
                "subtotal + tax と total が一致しません"
            )

    return self

これも対象業務で成立すると確認できた場合だけ使います。

大切なのは、LLMへの指示文に「間違えないでください」と書くことではありません。間違った値が来たときに、プログラムが止められる条件を明文化することです。

LLMへ構造化を依頼する

次に、OCR済みテキストをLLMへ渡します。

OpenAIのStructured Outputsでは、定義した構造に沿った出力を取得できます。Python SDKではPydanticモデルを使った構造化も可能です。

以下では、モデル名を環境変数で指定できるようにします。モデルの提供状況や利用可能な機能は変わる可能性があるため、固定値をコードへ埋め込まず、利用時点の公式ドキュメントで確認する形にしています。

import os

from openai import OpenAI


client = OpenAI()

MODEL_NAME = os.environ["OPENAI_MODEL"]

抽出関数を作ります。

def extract_invoice(ocr_text: str) -> InvoiceExtraction:
    response = client.responses.parse(
        model=MODEL_NAME,
        input=[
            {
                "role": "system",
                "content": (
                    "あなたは請求書からデータを抽出する処理です。"
                    "入力に書かれている情報だけを使用してください。"
                    "推測で値を補わないでください。"
                    "記載がない任意項目はnullにしてください。"
                    "金額は通貨記号と桁区切りを除いた整数にしてください。"
                    "日付は入力から読み取れる日付だけを使用してください。"
                ),
            },
            {
                "role": "user",
                "content": ocr_text,
            },
        ],
        text_format=InvoiceExtraction,
    )

    if response.output_parsed is None:
        raise ValueError("構造化された抽出結果を取得できませんでした")

    return response.output_parsed

呼び出し側はかなり単純になります。

ocr_text = """
請求書

請求書番号: INV-2026-001
請求日: 2026年9月30日
支払期限: 2026年10月31日

小計 100,000円
消費税 10,000円
合計 110,000円

登録番号 T1234567890123
"""

invoice = extract_invoice(ocr_text)

print(invoice.model_dump_json(indent=2))

想定する結果は次の形です。

{
  "invoice_number": "INV-2026-001",
  "invoice_date": "2026-09-30",
  "due_date": "2026-10-31",
  "subtotal": 100000,
  "tax": 10000,
  "total": 110000,
  "currency": "JPY",
  "issuer_registration_number": "T1234567890123"
}

ここまでで、「自然文をJSONっぽく返してください」とお願いする方式から、アプリケーションが受け付ける構造をコードで定義する方式へ変わりました。

Structured Outputsの後にも検証を置く

Structured Outputsを使えば、JSONの構造についてはかなり扱いやすくなります。

それでも、次の二つは分けて考えています。

構造が正しい
≠
抽出した内容が正しい

たとえばOCRが、

合計 110,000円

を、

合計 170,000円

と読み間違えたとします。

LLMがOCRテキストに忠実に 170000 を返せば、JSONスキーマとしては正しい値です。

つまりJSONスキーマが保証するのは、主としてデータの「形」です。元画像と一致しているかという「意味」まで自動的に保証するものではありません。

そのため実運用では、私は検証を層に分けて考えます。

LLMの出力がスキーマを通ったから、そのまま書き込みまで許可する、という設計にはしません。

読み取り専用から始めるという考え方とも相性がよく、最初は抽出結果を確認画面に出すだけでも十分です。

完成版を1ファイルにまとめる

ここまでを最小構成にまとめると、次のようになります。

import os
import re
from datetime import date
from typing import Literal

from openai import OpenAI
from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    field_validator,
    model_validator,
)


REGISTRATION_NUMBER_PATTERN = re.compile(r"^T[0-9]{13}$")


class InvoiceExtraction(BaseModel):
    model_config = ConfigDict(extra="forbid")

    invoice_number: str | None = Field(
        default=None,
        description="請求書番号。記載がなければnull",
    )
    invoice_date: date
    due_date: date | None = None

    subtotal: int | None = Field(default=None, ge=0)
    tax: int | None = Field(default=None, ge=0)
    total: int = Field(ge=0)

    currency: Literal["JPY"]

    issuer_registration_number: str | None = Field(
        default=None,
        description="登録番号。記載がなければnull",
    )

    @field_validator("issuer_registration_number")
    @classmethod
    def validate_registration_number(
        cls,
        value: str | None,
    ) -> str | None:
        if value is None:
            return None

        if not REGISTRATION_NUMBER_PATTERN.fullmatch(value):
            raise ValueError(
                "登録番号はTに続く13桁の数字で指定してください"
            )

        return value

    @model_validator(mode="after")
    def validate_business_rules(self):
        if self.due_date is not None:
            if self.due_date < self.invoice_date:
                raise ValueError(
                    "支払期日が請求日より前になっています"
                )

        if self.subtotal is not None and self.tax is not None:
            if self.subtotal + self.tax != self.total:
                raise ValueError(
                    "subtotal + tax と total が一致しません"
                )

        return self


client = OpenAI()
MODEL_NAME = os.environ["OPENAI_MODEL"]


def extract_invoice(ocr_text: str) -> InvoiceExtraction:
    response = client.responses.parse(
        model=MODEL_NAME,
        input=[
            {
                "role": "system",
                "content": (
                    "あなたは請求書からデータを抽出する処理です。"
                    "入力に書かれている情報だけを使用してください。"
                    "推測で値を補わないでください。"
                    "記載がない任意項目はnullにしてください。"
                    "金額は通貨記号と桁区切りを除いた整数にしてください。"
                    "日付は入力から読み取れる日付だけを使用してください。"
                ),
            },
            {
                "role": "user",
                "content": ocr_text,
            },
        ],
        text_format=InvoiceExtraction,
    )

    if response.output_parsed is None:
        raise ValueError(
            "構造化された抽出結果を取得できませんでした"
        )

    return response.output_parsed


if __name__ == "__main__":
    sample = """
請求書

請求書番号: INV-2026-001
請求日: 2026年9月30日
支払期限: 2026年10月31日

小計 100,000円
消費税 10,000円
合計 110,000円

登録番号 T1234567890123
"""

    invoice = extract_invoice(sample)
    print(invoice.model_dump_json(indent=2))

このコードではOCRそのものは扱っていません。

画像やPDFをOCRし、その結果のテキストを extract_invoice() に渡すところから始めています。OCRエンジンを差し替えても、抽出以降の契約を変えずに済むようにするためです。

⚠️ ハマりどころ

nullと「推測して埋める」を区別する

請求書番号が見つからないとき、LLMがそれらしい文字列を請求書番号として採用してしまう可能性があります。

そこで任意項目は str | None として、「分からない」という状態をデータモデルに含めます。

これは地味ですが重要です。

invoice_number: str | None = None

必須にしてしまうと、モデルには何かを入れる圧力がかかります。元資料に存在しない可能性がある項目なら、アプリケーション側でも欠損を正規の状態として表現できるようにしておきます。

一方、合計金額のように後続処理で必須の項目は None を許可しません。

「帳票にないかもしれない」と「業務上なくては困る」を分ける必要があります。

JSONスキーマへ全部の業務ルールを押し込まない

型や最小値のようなルールは Field で表現できます。

total: int = Field(ge=0)

しかし、

subtotal + tax == total

のような項目横断のルールは、通常のバリデーションコードとして書いたほうが読みやすくなります。

さらに複雑な業務なら、Pydanticモデルからも分離します。

def validate_for_auto_registration(
    invoice: InvoiceExtraction,
) -> list[str]:
    errors: list[str] = []

    if invoice.issuer_registration_number is None:
        errors.append("登録番号を確認してください")

    if invoice.total == 0:
        errors.append("合計金額が0円です")

    return errors

これなら「データとして妥当か」と「自動登録してよいか」を別々に扱えます。

私はこの境界をかなり大事にしています。

Pydanticモデルを巨大な業務ルール集にすると、別の処理で同じデータ型を使いたくなったときに扱いづらくなるからです。

金額をfloatにしない

円単位の整数だけを扱うなら、float にする理由はほとんどありません。

total: int

としておけば、110000 のように扱えます。

一方、外貨や小数単位を扱うなら話が変わります。その場合は浮動小数点数へ安易に寄せず、Decimal や最小通貨単位での整数保持など、後続処理を含めて設計を決めます。

今回の記事ではJPYだけに対象を絞っているため、

currency: Literal["JPY"]

としました。

対象を絞ることで検証ルールを単純にできるなら、そのほうが保守しやすいと考えています。

日付を文字列のまま持たない

日付を、

invoice_date: str

にすると、

2026/09/30
2026-09-30
2026年9月30日
9/30

のすべてが文字列として通ってしまいます。

今回は、

invoice_date: date

としています。

抽出後にPythonの date として扱えるので、支払期日との比較も単純です。

if invoice.due_date < invoice.invoice_date:
    ...

LLMに整形させるだけで終わらず、プログラムが理解できる型へ落とすところまでを抽出処理の責任範囲にします。

インボイス番号の形式チェックは存在確認ではない

正規表現で、

T + 13桁

を確認しても、それだけで有効な登録番号だとは判断できません。

ここを一緒にしてしまうと、

形式OK = 登録確認済み

という誤った状態を作ってしまいます。

必要ならデータモデル上でも意味を分離します。

class RegistrationCheck(BaseModel):
    format_valid: bool
    registry_checked: bool
    registry_match: bool | None

文字列形式の検証、外部情報との照合、人による最終確認は、それぞれ別の責務です。

LLMへの再試行だけで解決しようとしない

バリデーションエラーが出たとき、

間違っています。もう一度出してください

とLLMへ何度も投げ直せばよい、という設計にはしていません。

たとえば、

subtotal = 100000
tax = 10000
total = 150000

だった場合、原因は複数考えられます。

OCRが間違えたのかもしれません。複数の合計欄をLLMが取り違えたのかもしれません。そもそも単純な足し算では表現できない請求書なのかもしれません。

再試行で値が変わったとしても、どちらが原本に合っているかは分かりません。

だから、検証エラーは「LLMに直させる材料」だけではなく、人へ戻すためのシグナルとして扱います。

📝 まとめ

LLMで帳票を構造化するとき、プロンプトだけで出力を安定させようとすると、後続処理が不安定になります。

今回の実装では、役割を次のように分けました。

  • OCRは画像から文字を取り出す
  • LLMは文字から項目の候補を構造化する
  • Pydanticは型と形式を検証する
  • model_validator は項目間の関係を検証する
  • 業務上の自動処理可否は必要に応じて別関数で判定する
  • 判断できないものは人の確認へ戻す

特に大切なのは、「スキーマを通った」と「正しい」を同じ意味にしないことです。

業務自動化では、すべてを自動で決めるより、機械的に止められる条件を増やすほうが扱いやすい場面があります。LLMには曖昧な文章から候補を整理してもらい、決定できるルールはコードへ戻す。この分担なら、後から項目や帳票が増えても、どこを変更すればよいか追いやすくなります。

参考文献

4
5
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
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?