Structured OutputsとJSONモード:請求書抽出で信頼性を分けるもの

請求書抽出を例に、JSONの構文、スキーマへの準拠、原本との一致、発注書・権限の照合を分けて考えます。不明な値、拒否、未完了の応答と、その後の処理も具体的に示します。

A schema gate accepting a complete structured record and rejecting fragments

請求書の抽出結果が正しいJSONでも、誤った金額を発注書に紐づけることはあります。問うべきは「JSONで返ったか」だけではありません。どの契約をアプリケーションが検証し、原本から確定できない値をどう扱うかです。以下は架空の請求書 Invoice INV-1042; PO PO-778; Total USD 19.00. を使った説明です。目的は既存の発注書に対する支払候補を作ることであり、モデルに支払いを承認させることではありません。

受け渡しの契約を選ぶ

「JSONで答えて」という通常の指示は書式の依頼であって、出力制約ではありません。JSONモードは構文上有効なJSONを得るための仕組みですが、{"invoice_id":"INV-1042","total":"19.00"} も有効なJSONです。必要な po_id と currency はありません。スキーマ制約付きの応答を使えない場合でも、呼び出し側で検証・拒否する前提ならJSONモードは選択肢になります。OpenAIのJSONモードでは明示的にJSONの出力を指示する必要があり、出力が途中で切れた場合は解析可能とは限りません。OpenAI:JSONモード

今回の抽出には、まずアプリケーションが点検するデータを返す構造化応答が合います。OpenAIのStructured Outputsは、対応するモデル・設定とサポート対象のJSON Schemaの範囲内で、指定スキーマへの準拠を制約できます。一方、モデルがアプリケーションの機能を呼び出す必要があるなら、引数をスキーマで制約したstrictな関数ツールを定義します。ツール呼び出しはコードへの依頼であり、実行許可ではありません。実行前に値と権限を確認します。非対応または不適合のstrictスキーマはリクエスト時に拒否され得るため、任意のJSON Schemaが使えると決めつけず、実際のモデル・API・スキーマの組み合わせを確認してください。OpenAI:Structured Outputs、OpenAI:Function Callingのstrictモード

不明な値と中断を、別々の状態にする

抽出契約を invoice_extract_v1 とし、version、status、invoice_id、po_id、total、currency、evidence の7キーを必須、余分なキーは不可とします。status は ready または needs_review。請求書番号、発注書番号、金額、通貨は文字列かnull、evidence は原文からの逐語的な抜粋かnullです。ready なら全値と根拠を揃え、記載がない、あるいは矛盾する項目は needs_review にして未確定の値をnullにします。ゼロや通貨を推測で埋めません。金額は二進浮動小数点ではなく十進数の文字列で受け渡します。これはアプリケーション側の契約であり、利用先で対応するスキーマに落とし込む必要があります。JSON Schemaの required はキーの存在を指定するだけで、値の正しさは保証せず、存在するnullには許容される型が必要です。JSON Schema:必須プロパティ

次はこの受け渡し用の最小限のスキーマです。キー、型、状態の選択肢を制約しますが、ready のときの充足条件や原本照合は後段のコードに残します。比較的単純なキーワードを使っていますが、採用するAPIとモデルでの対応を導入前に確認してください。

{"type":"object","properties":{
  "version":{"type":"string","enum":["invoice_extract_v1"]},
  "status":{"type":"string","enum":["ready","needs_review"]},
  "invoice_id":{"type":["string","null"]},
  "po_id":{"type":["string","null"]},
  "total":{"type":["string","null"]},
  "currency":{"type":["string","null"]},
  "evidence":{"type":["string","null"]}
},"required":["version","status","invoice_id","po_id","total","currency","evidence"],"additionalProperties":false}

プロバイダー側の拒否は needs_review ではありません。拒否シグナルを請求書データとして解析しないで、API側の分岐で扱います。出力上限などで未完了になった応答も完成した抽出結果ではありません。原因を確認し、解消可能な場合だけ回数を限って再試行します。通信障害とスキーマ指定のリクエストエラーも別の状態です。完了し、かつ拒否されていない応答だけを抽出結果の検証に渡します。strictな出力形式でも、この区別は残ります。OpenAI:拒否と未完了の応答

形が合っていても、金額は間違う

架空の請求書に対して、次のレコードは必要なキーと型を満たしているように見えます。

{"version":"invoice_extract_v1","status":"ready","invoice_id":"INV-1042","po_id":"PO-778","total":"119.00","currency":"USD","evidence":"Invoice INV-1042; PO PO-778; Total USD 19.00."}

しかし total の 119.00 は原文の 19.00 と異なります。スキーマは文字列かどうかを調べられても、請求書を正しく読んだかまでは証明できません。原文と一致しても、登録済み発注書の金額・通貨と食い違う可能性があります。アプリケーションは信頼する発注書データと照合し、その発注書に対する依頼者の権限も確認します。モデルが引用した evidence は確認の手掛かりであり、引用したというだけで証明にはなりません。

後段のゲートを動かして確かめる

次の標準ライブラリだけで動く説明用コードは、完了済みで拒否されていない抽出結果を受け取ります。OpenAI APIの模擬実装でも、汎用JSON Schemaバリデーターでもありません。この契約、架空の請求書専用の単純な原文パターン、別途渡された発注書の正しい合計、許可された発注書の集合を検査します。コードを invoice_gate.py に保存して python invoice_gate.py で実行できます。本番では適切なスキーマバリデーターに加え、信頼できる文書の取得・照合と発注システムが必要です。文字列一致だけを不正検知に使わないでください。

import json
import re
from decimal import Decimal, InvalidOperation

SOURCE = "Invoice INV-1042; PO PO-778; Total USD 19.00."
KEYS = {"version", "status", "invoice_id", "po_id", "total", "currency", "evidence"}
GOOD = {"version": "invoice_extract_v1", "status": "ready",
        "invoice_id": "INV-1042", "po_id": "PO-778", "total": "19.00",
        "currency": "USD", "evidence": SOURCE}


def gate(raw, source, order_totals, authorized_pos):
    def reject(reason):
        return "REJECT: " + reason

    try:
        record = json.loads(raw)
    except json.JSONDecodeError:
        return reject("invalid JSON")
    if not isinstance(record, dict) or set(record) != KEYS:
        return reject("contract keys")
    if record["version"] != "invoice_extract_v1":
        return reject("unknown version")
    if record["status"] not in ("ready", "needs_review"):
        return reject("status")
    fields = ("invoice_id", "po_id", "total", "currency", "evidence")
    if any(value is not None and not isinstance(value, str)
           for value in (record[key] for key in fields)):
        return reject("field type")
    if record["status"] == "needs_review":
        return "REVIEW: unresolved extraction; no action"
    if any(not record[key] for key in fields):
        return reject("ready requires all values")
    if record["evidence"] not in source:
        return reject("evidence not in source")
    # Demonstration grammar for this one sample, not general invoice OCR.
    match = re.fullmatch(
        r"Invoice (INV-\d+); PO (PO-\d+); Total (USD) (\d+\.\d{2})\.",
        record["evidence"])
    if not match or tuple(record[k] for k in
                          ("invoice_id", "po_id", "currency", "total")) != match.groups():
        return reject("values do not match source")
    try:
        amount = Decimal(record["total"])
    except InvalidOperation:
        return reject("amount")
    po = record["po_id"]
    if po not in order_totals or (record["currency"], amount) != order_totals[po]:
        return reject("order amount/currency mismatch")
    if po not in authorized_pos:
        return reject("user not authorized for order")
    return "PASS: proposal may enter approval; do not pay automatically"


def run(label, record, authorized={"PO-778"}):
    print(label, gate(json.dumps(record), SOURCE,
                      {"PO-778": ("USD", Decimal("19.00"))}, authorized))


if __name__ == "__main__":
    run("correct", GOOD)
    run("missing key", {k: v for k, v in GOOD.items() if k != "currency"})
    run("wrong total", {**GOOD, "total": "119.00"})
    run("unauthorized", GOOD, set())
    run("unknown", {**GOOD, "status": "needs_review", "total": None})

キー不足は構文上正しいJSONでも拒否され、形が合っていても根拠にない金額は拒否されます。権限のない依頼も止め、不明な金額はレビューへ回します。PASS は承認フローに候補を送れるという意味だけで、支払いの実行ではありません。

失敗を振り分け、契約の版とテストを残す

観測された状態呼び出し側の対応
一時的な通信障害重複実行を防ぎつつ、回数を限って再試行する。
完了した応答のJSON不正・キー不一致拒否する。修正して抽出し直すとしても一度程度に抑え、その後はレビューへ。strict出力を使っているなら設定も調べる。
拒否・未完了の応答APIの専用分岐で扱い、完成した請求書として復元しない。中断の原因を解消できる場合だけ再試行する。
原文にない値・相反する値より良い文書を求めるか人が確認する。推測を繰り返さない。
原文または発注書との不一致・権限不足処理を止めてエスカレーションする。モデルの別回答で権限は生まれない。
非対応のstrictスキーマ・リクエストエラースキーマを修正するか対応するモデル・設定を選び、同じ要求をそのまま再送しない。

抽出契約の版を、モデル識別子、プロンプトの版、検証コードの版と一緒に記録します。未知の版は推測して読み替えず拒否します。回帰テストには上の5ケースに加え、通貨の欠落、矛盾する合計、別の通貨、重複する請求書番号、API境界での切断・拒否を含めます(最後の二つはこのローカルゲートでは検証しません)。失敗を分類して数え、自動化を広げる前に実際の請求書の根拠を確認します。操作の許可という別の論点は、Meydo JournalのAIエージェントによる買い物と支払いの記事にも通じます。