PDFファイルからテキストを抽出し、OpenAI API Structured Outputを呼ぶ方法
概要
このブログは、PDFファイルからテキストを抽出しOpenAI APIにぶん投げて、JSONレスポンスを取得するPythonコードの説明です。
ユース・ケースは、タクシーなどの領収書(PDF形式)から、金額と日付をJSON形式で取り出したい。
などが考えられます。最終的には、前回のブログで紹介した、会計ソフトfreeeのAPIのメタデータとして利用する魂胆です。(これについては次回説明します)
全体として、以下の2つのステップで実施します。
- PDFからテキストデータを抽出する
- 抽出したテキストを
OpenAI API
のプロンプトにぶち込む
pypdf
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というのがそれで、文章(プロンプト)から構造化データを抽出できます。
実のところ、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コードを掲載します。
コード全体
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