0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

紙クーポンをスマホで撮るだけ。Next.js App Router × マルチモーダルLLMで「実用に耐えるAI-OCR」を作った全記録

0
Posted at

ITコンサルタントのかたわら個人開発をしているしんいちと申します。

紙のクーポンをスマホで撮影すると、店名・特典内容・有効期限をAIが読み取って、期限が近づくと通知してくれるPWA「クポ活レーダー」を運営しています。

この記事では、その中核機能である 「クーポン画像 → 構造化JSON」のAI-OCRパイプライン を Next.js App Router で実装した話を書きます。具体的には:

  • 従来型OCR(Tesseract / Vision API)ではなく マルチモーダルLLM を選んだ理由
  • OpenRouter経由で 4モデルを横並び比較したPoC の結果(精度・速度・コスト実測)
  • Route Handler を upload / process の2段構成 にした設計判断
  • LLMの出力を プロダクションで信用するための防御層(zod・リトライ・信頼度スコア)

コードは実際に本番稼働しているものを簡略化して引用します。

なぜ従来型OCRではなくLLMなのか

最初に検討したのは当然、Tesseract や Google Cloud Vision API といった従来型OCRです。しかし要件を整理すると、欲しいのは「文字起こし」ではないことに気づきます。

クーポン画像から欲しいのは、こういう 構造化データ です。

{
  "store_name": "〇〇スーパー △△店",
  "benefit": "お会計から200円引き",
  "condition": "1,000円以上お買い上げの場合",
  "expires_at": "2026-07-31"
}

従来型OCRの出力は「画像内の全テキスト+座標」なので、そこから 「どれが店名で、どれが特典で、どの日付が有効期限か」を判別する後処理 が別途必要になります。クーポンのレイアウトは店ごとにバラバラで、「有効期限」「〇〇まで」「期限:」と表記も揺れる。この後処理こそが本丸で、ルールベースで書くと地獄です。

マルチモーダルLLMなら、画像理解と意味解釈と構造化を1リクエストで やってくれます。「7/31まで」を 2026-07-31 に正規化するのも、「税込1,000円以上」が利用条件だと判別するのも、プロンプトで指示するだけ。OCRという問題を「画像を入力とする情報抽出タスク」として解き直した、というのがこの構成の要点です。

モデル選定:OpenRouter経由で4モデルをPoC比較

モデルは決め打ちせず、OpenRouter(OpenAI互換の統一APIで各社モデルを呼べるゲートウェイ)経由で4モデルを 同一プロンプト・同一画像セット で比較しました。実物のクーポン画像20枚に対し、抽出結果が業務的に使えるか(=確認フォームのpre-fillとして成立するか)を人力採点しています。

モデル セマンティック実用率 平均レイテンシ JSONパース成功率 1画像コスト
Gemini 3 Flash Preview 20/20 (100%) 4.1秒 100% 約0.25円
Kimi K2.5 実用率で劣後 20秒超
Qwen 3.5 Plus 実用率で劣後 20秒超
Xiaomi MiMo v2.5 実用率で劣後 30秒超

Gemini 3 Flash Preview が精度・速度・コストの3軸すべてで圧勝でした。レイテンシが他の1/5〜1/8 というのが体験上決定的で、「撮影→4秒で結果」と「撮影→30秒待たされる」ではプロダクトとして別物になります。

OpenRouterを挟んだ理由は2つです。

  1. モデル切り替えがコード変更ゼロ:OpenAI互換なので、環境変数のモデルIDを差し替えるだけで全モデルを試せます。PoCのコストが劇的に下がる
  2. deprecation耐性:実はこのPoC自体、当初採用していた Gemini 2.5 Flash のdeprecation告知を受けた再選定でした。LLMのモデルは平気で廃止されるので、特定プロバイダのSDKに依存しない層を1枚挟む のは個人開発でも元が取れます
// lib/ocr/client.ts(抜粋)— モデルは環境変数で差し替え可能
const modelId = process.env.OCR_MODEL_ID ?? 'google/gemini-3-flash-preview';

const requestBody = {
  model: modelId,
  temperature: 0, // 抽出タスクなので創造性は不要。再現性優先
  messages: [
    {
      role: 'user',
      content: [
        { type: 'image_url', image_url: { url: imageDataUrl } }, // data:image/jpeg;base64,...
        { type: 'text', text: prompt },
      ],
    },
  ],
};

コストは1画像あたり約0.25円。月間アクティブユーザー300人が使い倒しても月1,500円程度の試算で、個人開発の収支でも成立します。

API設計:upload と process を分けた2段構成

Route Handler は次の2本に分けました。

POST /api/v1/ocr/upload   … 画像をSupabase Storageの一時領域(ocr-temp)へ。upload_idを返す
POST /api/v1/ocr/process  … upload_idの画像をLLMでOCRし、構造化JSONを返す

1本のエンドポイントで「受信→OCR→返却」をやらない理由は3つあります。

  1. 責務とエラーの分離:「アップロード失敗(回線・サイズ超過)」と「OCR失敗(プロバイダ障害・読み取り不能)」はユーザーへの案内が全く違います。前者は「再アップロードしてください」、後者は「手動入力に切り替えますか?」。エンドポイントを分けると、この分岐がステータスコードとエラーコードに素直に落ちます
  2. リトライの単位:OCRが失敗しても画像は一時領域に残っているので、画像を再送させずに process だけ再実行できます。モバイル回線で5MB近い画像を撮り直しさせるのは体験が悪すぎる
  3. タイムアウト予算の確保:Vercelの関数実行時間は有限です。アップロード時間をOCRの実行時間予算から切り離せます

process 側は Route セグメント設定でタイムアウトを明示しています。

// app/api/v1/ocr/process/route.ts
export const maxDuration = 30; // Vercel Functions の実行上限を30秒に

処理フローの全体像はこうです。

クライアント(スマホカメラ)
  │ ① multipart/form-data で画像POST
  ▼
POST /ocr/upload ─── Supabase Storage (ocr-temp/{user_id}/{timestamp}.jpg)
  │ ② upload_id を返却
  ▼
POST /ocr/process
  ├ ③ Storageから画像取得 → Base64化
  ├ ④ OpenRouter経由でLLM呼び出し(リトライ付き)
  ├ ⑤ 応答JSONをzodで検証(失敗なら再OCR 1回)
  ├ ⑥ 一時画像を削除(ユーザーが画像保存OFFの場合)
  ▼
確認フォームにpre-fill → ユーザーが修正・確定 → クーポン登録

最後が「自動登録」ではなく 「確認フォームへのpre-fill」 なのは意図的な設計で、後述する信頼度スコアと組み合わせてAIの読み取りミスをユーザーが自然に訂正できる動線にしています。

プロンプト設計:JSONスキーマと信頼度の自己申告

プロンプトは全文でもこの程度の短さです。

// lib/ocr/prompt.ts(抜粋)
return `あなたはクーポン券・割引券の画像から情報を抽出する専門家です。
以下のJSON形式で情報を抽出してください。抽出できない項目はnullにしてください。

必ず以下のJSONのみを返してください(Markdownのコードブロックは不要です):
{
  "benefit": "特典内容(例: 200円引き、10%OFF)",
  "condition": "利用条件(例: 500円以上購入時)またはnull",
  "expires_at": "有効期限(YYYY-MM-DD形式)またはnull",
  "store_name": "店舗名またはnull",
  ...
  "confidence_scores": {
    "benefit": 0.0〜1.0の信頼度スコア,
    ...
  }
}

抽出ルール:
- benefit(特典内容)は必須です
- expires_at は必ずYYYY-MM-DD形式にしてください(例: 2026-06-30)
- confidence_scores は各フィールドの読み取り確信度を0.0〜1.0で表してください
- 0.8未満のスコアは「読み取りに不確かな部分がある」ことを示します`;

工夫は3点です。

① 「抽出できなければnull」を明示する。 これを書かないと、LLMは空欄を埋めたがります。ぼやけた画像から有効期限を「推測」されるのが最悪のケースで、「読めないならnull」と逃げ道を与えることでハルシネーションを大きく減らせます。

② 信頼度スコアを自己申告させる。 各フィールドに0.0〜1.0の確信度を出させ、0.8未満のフィールドはUI側でハイライト して「ここはAIが自信なさげです、確認してください」とユーザーに示します。LLMの自己申告スコアは厳密な確率ではありませんが、「かすれた印字」「変則レイアウト」で実際に低い値を返してくる程度には実用的で、確認フォームの注意誘導としては十分機能しています。

// lib/ocr/parser.ts — 閾値0.8未満のフィールドを抽出してUIに渡す
const LOW_CONFIDENCE_THRESHOLD = 0.8;

export function calculateLowConfidenceFields(
  scores: OcrResult['confidence_scores'],
): string[] {
  return Object.entries(scores)
    .filter(([, score]) => score < LOW_CONFIDENCE_THRESHOLD)
    .map(([field]) => field);
}

③ 「コードブロック不要」と書いても、コードブロックで返ってくる前提で書く。 後述します。

LLMの出力をプロダクションで信用するための防御層

LLM APIの出力は「たいてい正しいJSON」ですが、プロダクションでは「たいてい」では困ります。防御は3層にしました。

第1層:Markdownコードブロックの除去

プロンプトで「コードブロック不要」と指示していても、モデルは一定確率で ```json ... ``` で包んで返します。指示で抑止しつつ、来る前提でパース側も対応する のが正解です。

// lib/ocr/parser.ts(抜粋)
const cleaned = rawText
  .replace(/^```(?:json)?\s*/i, '')
  .replace(/\s*```\s*$/, '')
  .trim();

第2層:zodによるスキーマ検証

JSON.parse が通っても、フィールド欠損・型違い・enum外の値はあり得ます。パース結果は必ずzodスキーマに通します。

// lib/validations/ocr.ts(抜粋)
export const ocrResultSchema = z.object({
  benefit: z.string().min(1), // 特典内容だけは必須
  expires_at: z.string().nullable().optional(),
  barcode_type: z.enum(['JAN13', 'JAN8', 'QR', 'CODE128', 'OTHER']).nullable().optional(),
  confidence_scores: z.object({
    benefit: z.number().min(0).max(1),
    // ...
  }),
});

これで「LLMの出力」という不定形な世界と「アプリ内部」の型安全な世界の境界が1箇所に固定されます。境界より内側では OcrResult 型を無条件に信用できます。

第3層:失敗の種類ごとに違うリトライ戦略

リトライは「どこで何が失敗したか」で戦略を変えています。

失敗の種類 戦略 理由
HTTP 5xx / ネットワーク障害 指数バックオフで最大3回(0ms→500ms→1000ms) 一過性の可能性が高い
HTTP 4xx リトライしない(即エラー) 認証・設定ミスは何度呼んでも失敗する
JSONパース/スキーマ検証失敗 OCR自体を1回だけ再実行 同じ応答を再パースしても無駄。temperature=0でも出力は揺れるので、再生成には意味がある

3つ目がLLM特有のポイントです。パース失敗時に必要なのは「再パース」ではなく 「再生成」。同一入力でも出力が揺れるというLLMの性質を、ここでは逆に利用しています。それでもダメなら潔く諦めて、「うまく読み取れませんでした。内容を確認して、修正してください」と手動入力に誘導します。最終フォールバックが常に手動入力として存在する ことが、AI機能を安心して本番に置ける最大の理由です。

プライバシー:画像の扱いは「消す」が基本

ユーザーが撮影するのはクーポンですが、財布の上で撮ればレシートや会員証が写り込むかもしれません。画像の扱いは保守的に倒しました。

  • 一時領域は使い捨て:アップロード先は ocr-temp バケットで、OCR完了後に削除。ユーザーが「画像を保存する」設定を明示的にONにしている場合のみ、正規バケットへ移動します
  • LLMプロバイダ側にも残さない:OpenRouterへのリクエストに X-Data-Policy: no-store ヘッダを付与し、プロンプト(=画像)のログ保存を無効化しています
// lib/ocr/client.ts(抜粋)
headers: {
  'Content-Type': 'application/json',
  'Authorization': `Bearer ${apiKey}`,
  'X-Data-Policy': 'no-store', // OpenRouter側にプロンプト・画像を保存させない
},

外部AIに画像を送る機能では、「送った先でどう扱われるか」まで含めて設計し、利用規約とプライバシーポリシーに明記する必要があります。ここを曖昧にすると後で必ず苦しくなります。

ハマりどころまとめ

実装・運用で踏んだポイントを列挙します。

  • 「一時画像は無条件削除」で画像保存機能が壊れた:OCR完了時に一時画像を必ず消す実装にしていたところ、「画像保存ON」ユーザーの正規保存処理が コピー元消失 で全滅しました。一時領域のライフサイクルは後続処理まで含めて設計する必要があります(現在は保存OFFのユーザーのみ即削除)
  • HEIC対応:iPhoneのカメラ既定フォーマット。マルチモーダルLLM側は読めるので、変換サーバーを立てずにそのまま渡しています
  • モデルのdeprecationは本当に来る:採用3週間後に主力モデルの廃止告知が来ました。環境変数1つでモデルを差し替えられる設計にしていたので、PoC再実行→切り替えが半日で完了しています
  • temperature=0でも出力は完全には安定しない:だからこそパース失敗時の再生成リトライと、最終的な手動入力フォールバックが要ります

まとめ

  • 「OCR」を 画像入力の情報抽出タスク としてマルチモーダルLLMで解くと、後処理の地獄が消える
  • モデルは決め打ちせず OpenAI互換ゲートウェイ経由でPoC比較。差し替え可能にしておくとdeprecationにも半日で対応できる
  • Route Handlerは upload / process 分離 でエラー案内・リトライ・タイムアウト予算が素直になる
  • LLM出力は コードブロック除去 → zod検証 → 種類別リトライ の3層で防御し、最終フォールバックは常に手動入力
  • 信頼度スコアの自己申告+閾値0.8のハイライトで、AIの不確かさをUIに透過 させる

この構成で本番稼働してから約1か月、OCR起因の致命的な障害はゼロで運用できています。

作ったものはこちらです。紙クーポンを撮るだけで期限前に通知が届く無料PWAなので、財布にクーポンを眠らせがちな方はぜひ → クポ活レーダー

なお、この開発は Claude Code のサブエージェント28体で構成した"AI開発チーム"で進めており、その組織設計の話はZennに書きました → https://zenn.dev/xim2jp_tokyo/articles/ce4e5faba0e41a

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?