技術ブログ

GeminiのAI-OCRでレシートを読み取り、仕訳CSVまで自動でデータ化してみる

・ 読了 約15分

GeminiのAI-OCRでレシートを読み取り、仕訳CSVまで自動でデータ化してみる

レシートや請求書など、帳票データ化の悩みは多くの企業で発生している課題かと思います。

今回は、Gemini 3.6 FlashをAI-OCRとして利用し、レシートの画像や動画から仕訳データのCSVを作成する簡単なアプリを作ってみました。

今回作成したアプリでは、レシートの画像または動画に加えて、出力したい形式のCSVサンプルをアップロードします。

アプリ側でCSVからヘッダーとサンプル行を取得し、Geminiがその列構成や表記を参考に、読み取ったレシートを同じ形式へ変換します。今回はMoney Forwardから出力した仕訳CSVをテンプレートとして利用しました。

一般的なOCRでも文字を読み取ること自体はできます。しかし実務では、読み取った文字を日付、金額、勘定科目、税区分などに分類し、利用するシステムの形式へ並べ直す必要があります。

今回は、文字認識だけでなく、内容の解釈から既存のCSV形式への変換までをGeminiでどこまで自動化できるのか試してみます。

AI-OCRは「文字を読んだ後」まで任せられる

従来のOCRと、GeminiのようなマルチモーダルAIをOCRとして利用する場合の大きな違いは、文字を認識するだけでなく、その意味まで解釈できることです。

レシートには店名、商品名、日付、金額、税率などさまざまな情報が含まれています。会計処理では、それぞれを読み取るだけでなく、「この数字は税込合計」「この番号はインボイス登録番号」と判断し、さらに購入内容から勘定科目や税区分を決める必要があります。

今回のアプリでは、この処理を抽出とCSVへのマッピングの2段階に分けました。

最初にGeminiがレシートを読み取り、仕訳に必要な情報を共通のデータ構造へ変換します。その結果と、CSVテンプレートから取得したヘッダーやサンプル行をGeminiへ渡し、、最終的なCSV形式へ変換します。

今回使用した3枚のレシートはこちらです。

※ちょうど良いレシートが手元になかったため、今回は子ども用品など私的な支出のレシートを使用しています

Gemini Developer APIで仕訳データを抽出する

今回はGoogleのGemini Developer APIからGemini 3.6 Flashを利用し、モデルの呼び出しにはInteractions APIを使っています。

現在のInteractions APIでは、画像や動画などのマルチモーダル入力とStructured Outputsを同じインターフェースで扱えます。

まずレシートの内容を抽出する

画像の場合はBase64へ変換し、プロンプトと一緒にInteractions APIへ渡しています。

parts = [
    {
        "type": "image",
        "data": base64.b64encode(item.data).decode("ascii"),
        "mime_type": item.mime_type,
    }
    for item in media
]

return self._request(
    [
        {"type": "text", "text": EXTRACTION_PROMPT},
        *parts,
    ],
    ReceiptExtraction,
)

ここで使用しているEXTRACTION_PROMPTは以下のような内容です。

EXTRACTION_PROMPT = """\
入力された画像または動画から、互いに異なるレシートを出現順にすべて検出してください。

1枚のレシートを1件として source_index を1から連番にします。 

日付、店舗、登録番号、税込合計、税率別の内税、支払方法、購入内容を読み取り、 単一仕訳として妥当な借方・貸方勘定科目、税区分、摘要を推定してください。 

根拠が見えない値は推測で埋めず null にし、 具体的な曖昧さだけ uncertainties に記録します。

金額はすべて整数の日本円で返してください。
"""

プロンプトでは、単に「レシートを読み取ってください」とは指示していません。

何を抽出するか、どこまでAIに判断させるか、判断できない場合にどうするかまで指定しています。

特に今回は、読み取れない値を無理に推測させずnullにすること、曖昧な部分をuncertaintiesとして残すことを明示しています。

生成AIを業務で利用する場合、すべての項目を何らかの値で埋めてもらうより、AIが判断できなかったこと自体をデータとして取得できる方が扱いやすいケースがあります。

Structured Outputsでデータ構造を固定する

GeminiのレスポンスにはStructured Outputsを利用しています。

interaction = self._client.interactions.create(
    model=MODEL_ID,
    input=input_data,
    response_format={
        "type": "text",
        "mime_type": "application/json",
        "schema": schema.model_json_schema(),
    },
)

return schema.model_validate_json(interaction.output_text)

Pydanticで定義したモデルからmodel_json_schema()でJSON Schemaを生成し、response_formatへ渡しています。

GeminiのStructured Outputsでは、application/jsonとJSON Schemaを指定することで、そのSchemaに沿ったJSONを返すよう制約できます。

今回のようなOCRでは、AIから自然文を受け取って後から解析するのではなく、最初からアプリケーションで扱えるオブジェクトとして取得できるのが便利です。

動画はFiles APIへアップロードする

画像とは処理方法を変え、動画の場合はGeminiのFiles APIを利用しています。

with TemporaryDirectory() as directory:
    path = Path(directory) / "receipt-video.mp4"
    path.write_bytes(video_bytes)

    uploaded = client.files.upload(file=str(path))

    try:
        deadline = time.monotonic() + 120

        while state_name(uploaded) != "ACTIVE":
            if state_name(uploaded) == "FAILED":
                raise ValueError("動画を処理できませんでした")

            if time.monotonic() >= deadline:
                raise TimeoutError("動画処理がタイムアウトしました")

            time.sleep(2)
            uploaded = client.files.get(name=uploaded.name)

        video_part = {
            "type": "video",
            "uri": uploaded.uri,
            "mime_type": uploaded.mime_type,
        }

        result = request_gemini([
            {"type": "text", "text": EXTRACTION_PROMPT},
            video_part,
        ])
    finally:
        client.files.delete(name=uploaded.name)

Files APIへアップロードした動画は、その場ですぐ推論に利用できるとは限りません。

ファイルにはPROCESSINGACTIVEFAILEDといった状態があり、ACTIVEになると推論に利用できます。今回のコードでもfiles.get()を定期的に呼び出し、ACTIVEになるまで待っています。

処理後は今回の用途ではファイルを再利用しないため、finallyで削除しています。

今回は3枚のレシートを順番に映した動画をアップロードしています。写真を1枚ずつ撮影しなくても、一つの動画から複数のレシートを認識し、それぞれ別の取引として抽出できます。

アップロードしたCSVの形式へ変換する

ここが今回のアプリで少し工夫した部分です。

Geminiから直接「Money ForwardのCSVを作って」と依頼するのではなく、ユーザーがアップロードしたCSVをテンプレートとして利用しています。

今回はMoney Forwardから出力した既存の仕訳CSVをアップロードしました。

CSVからヘッダーと数行のサンプルデータを取得し、先ほどGeminiが抽出したレシート情報と一緒に、もう一度Geminiへ渡します。

このとき使っているのがMAPPING_PROMPTです。

MAPPING_PROMPT = """\
あなたは抽出済みレシートを任意の仕訳CSVテンプレートへ転記する変換器です。

headers の順序を絶対に変更せず、各レシートにつき values を1行返してください。
values の要素数は headers と完全に一致させます。

sample_rows は列の意味、日付、勘定科目、税区分などの表記例としてだけ利用します。
分からない列は空文字にし、テンプレートにない列を追加しません。

source_index は入力レシートの番号を維持してください。
入力データ:
"""

実際には、テンプレートのヘッダー、サンプル行、抽出済みのレシート情報をJSONとして追加しています。

payload = json.dumps(
    {
        "headers": template.headers,
        "sample_rows": template.sample_rows,
        "receipts": extraction.model_dump(mode="json")["receipts"],
    },
    ensure_ascii=False,
)

return self._request(MAPPING_PROMPT + payload, MappedCsv)

ここでサンプル行も渡しているのがポイントです。

同じ意味の項目でも、システムによって日付形式や税区分、勘定科目の表記は異なります。ヘッダーだけでなく既存のデータも見せることで、AIがそのCSVで使われている表記に合わせて出力できます。

そのため、アプリケーション側にMoney Forward専用の列名をすべてハードコードしなくても、テンプレートとなるCSVを変更すれば別の形式へ対応できる余地があります。

AIが返した結果をそのままCSVにするのではなく、出力前に通常のプログラムでも整合性を検証しています。実際には以下のように、レシート数、CSVの列数、source_indexの重複や欠落などをチェックしています。

def _ordered_rows(
    headers: list[str],
    extraction: ReceiptExtraction,
    mapped: MappedCsv,
) -> list[list[str]]:

    # 検出したレシート数と、Geminiが生成したCSVの行数が一致するか確認
    if len(mapped.rows) != len(extraction.receipts):
        raise PipelineError(
            "検出したレシート数と生成されたCSV行数が一致しません。"
        )

    mapped_by_index = {}

    for row in mapped.rows:
        # 同じレシート番号が複数行に重複していないか確認
        if row.source_index in mapped_by_index:
            raise PipelineError(
                "生成されたCSVに重複したレシート番号があります。"
            )

        # Geminiが生成した各行の列数が、
        # アップロードされたCSVテンプレートの列数と一致するか確認
        if len(row.values) != len(headers):
            raise PipelineError(
                "生成されたCSVの列数がテンプレートと一致しません。"
            )

        mapped_by_index[row.source_index] = row.values

    expected = [
        receipt.source_index
        for receipt in extraction.receipts
    ]

    # 抽出されたレシート番号と、
    # CSVへマッピングされたレシート番号が一致するか確認
    # 欠落したレシートや、存在しない番号の追加を検出する
    if set(mapped_by_index) != set(expected):
        raise PipelineError(
            "生成されたCSVのレシート番号が抽出結果と一致しません。"
        )

    # Geminiの返却順に依存せず、
    # 元のレシートの出現順(source_index順)でCSV行を並べる
    return [
        mapped_by_index[index]
        for index in expected
    ]

3枚のレシートから、Money Forwardの仕訳CSVと同じ27列のデータを生成できました。取引日や金額だけでなく、勘定科目、税区分、インボイス登録番号、摘要なども入力されています。

※画像サイズの関係で後ろ数カラムを省略しています

普通のGeminiでもCSVは作れる。それでもAPIを使う理由

今回のような処理は、レシートを数枚読み取ってCSVを一度作るだけなら、通常のGeminiから依頼することもできます。

専用のアプリケーションを作る意味が出てくるのは、同じ処理を業務として何度も繰り返す場合です。

今回のアプリでは、抽出ルールをプロンプトとして固定し、Structured Outputsでデータ構造を決め、アップロードしたCSVに合わせて出力形式を変換しています。

さらに、列数や行数の検証、動画ファイルの処理、CSVファイルの生成までを一連の処理として実行できます。

今後はここに対話的な操作を加えるのも面白そうです。

Interactions APIはprevious_interaction_idを利用して、それまでの会話状態を引き継いだ処理にも対応しています。

最初に生成された仕訳に対して「この取引は消耗品費ではなく旅費交通費に変更して」「この店舗では今後この勘定科目を使って」と指示し、結果を修正するUIも考えられます。

一度のAI実行ですべてを正解させるのではなく、AIが最初のデータを作り、必要なところだけ人が対話して修正する形にすると、単純なOCRよりもう少し業務エージェントに近い使い方になりそうです。

まとめ

今回はGemini 3.6 FlashをAI-OCRとして利用し、レシートの画像や動画から仕訳データを抽出し、CSVへ変換するアプリを作ってみました。

画像はInteractions APIへ直接入力し、動画はFiles APIへアップロードして処理しています。また、Structured Outputsを利用することで、Geminiの出力をあらかじめ定義したJSON Schemaに沿った形式で取得し、そのままCSV生成などの後続処理につなげられます。

単発でレシートを読み取ってCSVを作るだけであれば、通常のGeminiからでも可能です。一方、業務として繰り返し利用する場合には、プロンプトやSchemaを固定したり、マスタとの照合や検証処理を加えたり、読み取り結果を対話しながら修正したりと、アプリケーションとして実装する意味が出てきます。

今回のようなレシートの仕訳データ化だけでなく、請求書や注文書から必要な項目を抽出する、複数の見積書を比較表にまとめる、約款や契約書などのPDFを読み取って条件の違いを整理する、といった業務にも同じ考え方を応用できます。

AI-OCRで重要なのは、文字を読み取ることだけではなく、読み取った内容を解釈し、業務で利用できるデータへ変換するところまで含めて設計することだと感じています。

AI導入・業務自動化のご相談

弊社では、AI導入や業務自動化の支援を行っています。

また、今回のような帳票の読み取りから、データの構造化・加工・CSV出力までを一連の業務として自動化するサービス「Subel」を開発しています。

Excel・CSV・PDFなどを使った定型業務や、AI-OCRを実際の業務フローに組み込みたい企業さまは、お気軽にご相談ください。


Resonalエンジニアリング部

Resonalエンジニアリング部

Google Cloudや生成AIの導入、プロダクト開発における役立つコンテンツをお届けします。

お問い合わせ

サービスに関するご質問や、取材・パートナーシップのご相談など、こちらからお気軽にご連絡ください。

お問い合わせはこちら