🐍

PDFファイルからテキストを抽出し、OpenAI API Structured Outputを呼ぶ方法

に公開

概要

このブログは、PDFファイルからテキストを抽出しOpenAI APIにぶん投げて、JSONレスポンスを取得するPythonコードの説明です。

ユース・ケースは、タクシーなどの領収書(PDF形式)から、金額と日付をJSON形式で取り出したい。

などが考えられます。最終的には、前回のブログで紹介した、会計ソフトfreeeのAPIのメタデータとして利用する魂胆です。(これについては次回説明します)

全体として、以下の2つのステップで実施します。

  1. PDFからテキストデータを抽出する
  2. 抽出したテキストをOpenAI APIのプロンプトにぶち込む

pypdf

PDFファイルはバイナリー形式でテキストを保存しています。そのため、そのままではパースできません。ここでは、pypdfというライブラリを使用してテキストの抽出をします。

https://github.com/py-pdf/pypdf

extract_textでテキストを抽出します。コンテンツ全体をメモリに読み込む必要があるようなので、ファイルサイズが大きいとメモリを消耗します。領収書のファイルは小さいため問題にはなりませんが。PDFはページ構造を持っているため、for-inで全ページをスキャンしています。

reader = PdfReader(PDFファイルパス)
pages = [page.extract_text() or "" for page in reader.pages]
text = "\n".join(pages)

今回テストに使用したPDFファイルは、以下のようなタクシーアプリGOの領収書になります。

OpenAI Structured Outputs

ブログやニュース記事のような漠然とした文章データは、非構造化データと呼ばれ、そのままではコンピュータでは扱いづらい形式です。そのため、JSON形式などの構造化データに変換するのがお約束です。(他にもベクトル化する世界線もあります。)

まさに、OpenAI APIのStructured Outputsというのがそれで、文章(プロンプト)から構造化データを抽出できます。

https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses

実のところ、ChatGPTにそのままPDFファイルをぶっ込みますと、このようにJSONに変換して返してくれます。

おったまげです。日付、金額、乗車場所などを正確にパースしています。一方で、CO2を読み飛ばしたのは興味深いですね。

さて、このブログでは、OpenAI APIをPythonから呼び出すことで利用したいと思います。

このAPIはpydanticの型を認識してくれるため、JSONレスポンスの形式をpydanticを利用しReceiptクラスとして定義します。残念ながら、日付型には対応していないので文字列として受け取ります。

from pydantic import BaseModel, field_validator

# レスポンスの型を定義
class Receipt(BaseModel):
    price: str # 金額
    issue_date: str # 発行日 Store as string in YYYY-MM-DD format
    description: str # 詳細

client.responses.parse()の引数text_format=ReceiptにJSONレスポンスの型を指定してあげます。

さて、OpenAI structured-output APIを呼び出す部分のコードになります。
client.responses.parseで呼び出します。引数のプロンプトに、先ほど抽出したテキスト・データをセットします。

環境変数OPENAI_API_KEYにAPIキーをセットします。

def get_receipt_info(raw_text: str) -> Receipt:
    """Call OpenAI to get structured Receipt info."""
    client = OpenAI()

    # Send the raw text to OpenAI structured-output API
    # set OPENAI_API_KEY
    response = client.responses.parse(
        model="gpt-4o-2024-08-06",
        input=[
            {
                "role": "system",
                "content": (
                    "Extract receipt information. "
                    "Return `date` as YYYY-MM-DD, `price` without currency symbols, "
                    "and a brief `description`."
                ),
            },
            {"role": "user", "content": raw_text},
        ],
        text_format=Receipt,
    )

英語プロンプトのススメ

プロンプトは英語で記述した方が、精度とコスト面で良いです。日本語は曖昧さがあるため、プロンプトなどの技術的な指示にはあまり向いていません。技術文章も英語の方がストレートで分かりやすいと思います。

"Extract receipt information. "
"Return `date` as YYYY-MM-DD, `price` without currency symbols, "
"and a brief `description`."

Pythonコード

最後に全体のPythonコードを掲載します。

コード全体
main.py
from datetime import datetime, date
import sys
import argparse
import logging

from pypdf import PdfReader
from openai import OpenAI
from pydantic import BaseModel, field_validator

# ── Logging setup ──────────────────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)


# ── PDF Text Extraction ────────────────────────────────────────────────────────
def extract_text(pdf_path: str) -> str:
    """Read all text from a PDF file."""
    logger.debug(f"Opening PDF file: {pdf_path}")
    reader = PdfReader(pdf_path)
    pages = [page.extract_text() or "" for page in reader.pages]
    text = "\n".join(pages)
    logger.info(f"Extracted {len(pages)} pages from {pdf_path}")
    return text


# ── OpenAI Structured Output for Receipt ───────────────────────────────────────
class Receipt(BaseModel):
    price: str
    issue_date: str  # Store as string in YYYY-MM-DD format
    description: str

    @field_validator("issue_date")
    def validate_date(cls, value: str) -> str:
        try:
            datetime.strptime(value, "%Y-%m-%d").date()  # Validate format
        except ValueError:
            raise ValueError("Date must be in YYYY-MM-DD format")
        return value

    @property
    def date_as_date(self) -> date:
        """Convert string date to Python date object when needed."""
        return datetime.strptime(self.issue_date, "%Y-%m-%d").date()


# ── OpenAI API Call ───────────────────────────────────────────────────────────
def get_receipt_info(raw_text: str) -> Receipt:
    """Call OpenAI to get structured Receipt info."""
    client = OpenAI()

    # Send the raw text to OpenAI structured-output API
    response = client.responses.parse(
        model="gpt-4o-2024-08-06",
        input=[
            {
                "role": "system",
                "content": (
                    "Extract receipt information. "
                    "Return `date` as YYYY-MM-DD, `price` without currency symbols, "
                    "and a brief `description`."
                ),
            },
            {"role": "user", "content": raw_text},
        ],
        text_format=Receipt,
    )

    return response.output_parsed


# ── Combined PDF → OpenAI ──────────────────────────────────────────────────────
def process_pdf(pdf_path: str) -> Receipt:
    text = extract_text(pdf_path)
    logger.info("Sending extracted PDF text to OpenAI API for receipt parsing")
    return get_receipt_info(text)


# ── CLI ────────────────────────────────────────────────────────────────────────
def main():
    parser = argparse.ArgumentParser(
        description="PDF Receipt extractor & OpenAI structured-output demo"
    )
    sub = parser.add_subparsers(dest="cmd", required=True)

    # extract-text subcommand
    ex = sub.add_parser("extract-text", help="Extract text from a PDF")
    ex.add_argument("pdf_path", help="Path to the PDF file")

    # get-receipt subcommand
    gr = sub.add_parser("get-receipt", help="Parse a raw receipt string")
    gr.add_argument("prompt", help="Raw receipt text")

    # process-pdf subcommand
    pp = sub.add_parser("process-pdf", help="Extract text from a PDF and parse it")
    pp.add_argument("pdf_path", help="Path to the PDF file")

    args = parser.parse_args()

    try:
        if args.cmd == "extract-text":
            print(extract_text(args.pdf_path))
        elif args.cmd == "get-receipt":
            receipt = get_receipt_info(args.prompt)
            print(receipt.model_dump_json(indent=2))
        elif args.cmd == "process-pdf":
            receipt = process_pdf(args.pdf_path)
            print(receipt.model_dump_json(indent=2))
    except Exception as e:
        logger.error(e)
        sys.exit(1)


if __name__ == "__main__":
    main()

実行結果です。

正常にJSONデータでレシートの情報が抽出できました。

次回は、前回のブログで説明した freee のAPIと連携したいと思います。

Discussion