2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIは満面の笑みで嘘をつく|レシートOCR自動化の沼

2
Last updated at Posted at 2026-06-12

1,000枚のレシートと、衝動買いしたスキャナ

新年の抱負は、たいてい一月の第二週で息絶える。わたしの場合は「今年こそレシートを家計につける」だった。気づけば引き出しには、コーヒー一杯から新幹線まで、一年分の生活が感熱紙の上で薄れかけたまま、1,000枚近く堆積していた。これをExcelのマス目へ一枚ずつ写経する——その量を前に、正直、心が折れた。

本当にやりたかったのは、家計を「予算ドリブン」に切り替えること。なんとなく使ってなんとなく後悔する暮らしから、リアルな出費をダッシュボードに映し、「今月はあといくら使えるのか」を数字で握って生きる側へ。そのためには、まず1,000枚という名の負債を、人間の手を介さずデータへ変える仕掛けがいる。

で、ついにScanSnapをポチった。これが全ての始まり。紙はPCのフォルダに吸い込まれ、Googleドライブのデスクトップ同期が雲の上まで運んでくれる。そこまでは順調だった。問題はその先で、ドライブに溜まった画像は、いつまでもただの画像のまま居座る。誰かが手で数字に翻訳してやらないと、家計簿にも予算にもならない。スキャナを買ったところで、写経の宛先がExcelからフォルダに引っ越しただけ——そのままでは、ね。

だから、その「誰か」をコードに肩代わりさせることにした。スキャンしたら黙ってデータになり、妻と二人で予算の残りを眺められる。そういう静かな仕組みがほしかっただけ。

先に結論を言っておく。この記事の主張はひとつだけ。LLM-OCRは信じるな。信じる代わりに、ただの足し算で見張れ。 派手なAI活用術の話はしない。むしろAIに裏切られ続けた人間が、どうやって安全柵を立てたかという、地味な防御の記録になる。

完成したもの、その地図

全体はこうなった。紙からダッシュボードまで、人間が触る場所はスキャナのボタンだけ。

家族で見られる予算ダッシュボード(GASウェブアプリ完成イメージ)

スマホで開く家族ダッシュボード。「今月あと使えるのは」を一番大きく、その下にカテゴリ別の残り。積立に取り分けたぶんも「使用済み」として効いている。

サーバは一台も借りていない。Google Apps Script(GAS)が5分ごとに監視フォルダを覗き、新顔の画像を見つけてGeminiに渡し、返ってきたJSONをスプレッドシートに積む。それだけ。読み取りの頭脳はGemini 2.5 Flash。後で散々こいつに泣かされるが、仕事はする。

書いておくと、開発そのものも趣味が悪いほどGoogleに寄せた。エディタは Google Antigravity。自然言語が第一の入力で、エージェントが設計から実装、ブラウザでの動作確認まで勝手に回す、例の「人間はプロダクトオーナーに降格」系の開発環境だ。GASへの橋渡しは clasp。ローカルのコードを clasp push でクラウドのApps Scriptへ流し込む。

コラム:clasp で、GAS を“ローカルの住人”にする

GASの標準はブラウザのスクリプトエディタ。悪くはない。が、gitは使えず、補完は貧弱、複数ファイルを束ねるのも一苦労——立派なのに窓が一つしかない書斎、みたいな閉塞感がある。そこから連れ出してくれるのが clasp(Command Line Apps Script Projects)。Googleが配っている公式CLIで、これを噛ませると、手元のエディタ(今回はAntigravity)で書いたコードを、コマンド一発でクラウドのGASへ流し込める。git管理も、慣れたショートカットも、まるごと持ち込めるわけだ。

導入はあっけない。Nodeさえあれば二行で済む。

npm install -g @google/clasp
clasp login   # ブラウザが開いてGoogle認証

ひとつ、最初に必ず引っかかる関所がある。claspは裏でApps Script APIを叩くので、script.google.com のユーザー設定で「Google Apps Script API」をオンにしておくこと。ここを忘れると、ログインは通るのに push だけ門前払い、という間抜けな朝を迎える羽目になる。

プロジェクトは、まっさらから生むか、既存を手元に引き取るか。

# 新規で作る
clasp create --type standalone --title "家計OCR" --rootDir src
# すでにブラウザ側にあるなら、スクリプトIDで引き取る
clasp clone <スクリプトID>

あとは、この三語とひたすら暮らす。

clasp push    # 手元 → クラウド(書いて反映)
clasp pull    # クラウド → 手元(向こうの変更を取り込む)
clasp open    # ブラウザのエディタを開く

腰を据えて書く日は clasp push --watch が効く。保存するたび勝手に押し上げてくれるので、ブラウザへ切り替える往復がまるごと蒸発する。手元には .clasp.json が一枚あって、そこに scriptIdrootDir(今回は src)が書かれている。これがローカルとクラウドを縛る、たった一本の紐。

最後に、初日にわたしが盛大に転んだ石を置いておく。appsscript.json(マニフェスト)を触ると、clasp push は「上書きしていい?(y/N)」と一度だけ確認を挟む。これを見落として Enter を空振りすると、送ったつもりのコードが一行も旅立たない。コードは完璧、論理も完璧、なのに現実だけが過去のまま——いちばん恥ずかしいバグの正体が、この沈黙のプロンプトだった。確認で待たせたくなければ clasp push -f で黙らせればいい。教訓はひとつ、反映されたつもりを疑え。

読み取りの安定は、Geminiの構造化出力(responseSchema)に救われた。欲しい形をスキーマで宣言しておくと、向こうがその器に合わせて返してくる。パースで消耗しないだけで人生は少し長くなる。

const RECEIPT_SCHEMA = {
  type: 'object',
  properties: {
    date: { type: 'string', description: '購入日。YYYY-MM-DD に正規化' },
    store_name: { type: 'string' },
    total: { type: 'number', description: '税込合計' },
    subtotal: { type: 'number', description: '税抜小計' },
    tax_total: { type: 'number' },
    tax_breakdown: {
      type: 'array',
      items: { type: 'object', properties: {
        rate: { type: 'number' }, taxable: { type: 'number' }, tax: { type: 'number' }
      } }
    },
    line_items: {
      type: 'array',
      items: { type: 'object', properties: {
        name: { type: 'string' }, unit_price: { type: 'number' },
        quantity: { type: 'number' }, amount: { type: 'number' }
      } }
    },
    confidence: { type: 'number', description: '読み取りの自信度 0.0〜1.0' }
  }
};

最後の confidence。当時のわたしは、これを置けば怪しい行を弾けると本気で信じていた。その純真さが、すぐに踏みにじられる。

沼その一:confidenceという名の、感じのいい詐欺師

ある日のミスドのレシート(金額はダミーに変えてある)。Geminiは品目を一字一句きれいに並べ、税率の8%と10%まで仕分け、最後に堂々と confidence: 1.0 を返してきた。満点。完璧な仕事のはずだった。

ところが税抜小計だけ、1,9001,990 と読み違えていた。たった一桁。けれど確定申告では、その一桁が後で牙を剥く。腹立たしいのは、本人にまったく悪気がないこと。自信度1.0で、にっこり笑って嘘をつく。 これがLLMだと、肚の底から理解した瞬間だった。

学んだのは単純な事実。自己申告の自信なんて、面接で「コミュニケーション能力が強みです」と言う候補者くらいには当てにならない。フィルタの軸を confidence に置いた設計は、根っこから腐っている。

そこで発想を裏返した。レシートという紙には、もともと冗長な証拠が刻まれている。税抜と税を足せば合計になる。品目を全部足しても合計になる。だったらAIの気分ではなく、この恒等式が成り立つかを、こちらが電卓で殴って確かめればいい。

意気揚々と「品目合計=合計」「税率内訳の対象額=税抜小計」で検算を書いた。結果、7件中5件が「要確認」で真っ赤に染まった。安全柵のつもりが、ただのオオカミ少年。

沼その二:日本のレシートは、税抜と税込の二枚舌

なぜ誤検知が雪崩れたのか。犯人を追って気づいたのは、自分が日本のレシート文化を舐めていたという、わりと初歩的な罪だった。

店によって、明細は税抜で書かれ、合計だけ税込のレシートがある。かと思えば、税率の内訳が税込で刷られている領収書もある。同じ「金額」という顔をして、ある列は税を着込み、ある列は脱いでいる。「品目合計は税込の合計と一致するはず」という思い込みこそが、誤検知の母だった。

直し方は、決めつけをやめること。表示がどっちに転んでも必ず成り立つ条件だけを、検算の芯に据えた。揺るがないのは「税抜+税=合計」という一本。これは厳格に、±1円の端数だけ見逃して殴る。一方で、品目合計や税率内訳の合計は、税抜と税込のどちらかに寄り添えば無罪とする。要するに、容疑者に逃げ道を一本だけ残してやる。

function validateReceipt_(data) {
  const total = num(data.total);
  const subtotal = num(data.subtotal);
  const tax = num(data.tax_total);
  const itemsSum = sumBy(data.line_items, 'amount');
  const taxableSum = sumBy(data.tax_breakdown, 'taxable');

  const tol = Math.max(2, Math.round(total * 0.01)); // 端数・丸めの逃げ道
  const near = (a, b, t = 1) => Math.abs(a - b) <= t;
  // 税抜・税込のどちらかに一致すれば無罪(表示方式に依存しない)
  const netOrGross = (v) => near(v, total, tol) || (subtotal && near(v, subtotal, tol));

  const reasons = [];

  // 主検出器:税抜+税=合計。ここだけは厳格。桁の読み違いはここで首根っこを掴む
  if (subtotal && !near(subtotal + tax, total)) {
    reasons.push(`税抜${subtotal}+税${tax}≠合計${total}`);
  }
  if (itemsSum && !netOrGross(itemsSum)) {
    reasons.push(`品目合計${itemsSum}が税抜/税込どちらとも不一致`);
  }
  if (taxableSum && !netOrGross(taxableSum)) {
    reasons.push(`税率内訳計${taxableSum}が税抜/税込どちらとも不一致`);
  }

  return reasons.length ? '要確認: ' + reasons.join(' / ') : 'OK';
}

これでスプレッドシートに「整合チェック」という名の見張り番が一列できた。足し算が合わない行にだけ「要確認」の赤旗が立つ。例の 1,900→1,990 も、税抜1990+税157≠合計2057 という一行で即座に縛り上げられた。誇らしい瞬間だったと思う。

ここで腹落ちしたのは、二段構えの真理。LLMは確率で間違える生き物だから、出口に決定論の関所を置く。そしてその関所のルールは、対象の世界の「常識」——ここでは日本の税表示の二枚舌——を知らないと、ただの嫌がらせにしかならない。ドメインを知らない検算は、現場を知らないマネジメントと同じで、正論で人を疲れさせるだけ。

沼その三:2024年を、2014年と言い張る

確定申告で唯一、絶対に譲れないもの。それは「いつの金か」。月や店はまだ許せる。年がズレたら、申告そのものが別の物語になる。

そのGeminiが、西暦の十の位を平然と読み違えた。20242014 に。十年の時を勝手に遡る。しかも、ご丁寧にまた自信ありげ。さすがに本気で困り果てた。

まず気づいたのは、こいつは「今日が何日か」を知らないという当たり前。カレンダーを持たない相手に「最近のレシートだ」と察しろというのが無理筋だった。だから毎回のリクエストに今日の日付を握らせ、未来や一年以上前に飛ばないよう、釘を刺すことにした。

const today = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy-MM-dd');
const prompt = `本日は ${today} です。\n`
  + '- 日付は YYYY-MM-DD に正規化。領収書は通常「本日より過去1年以内」。'
  + '本日より未来や1年以上前にならないよう、月日から最も近い妥当な年を選ぶ。'
  + '西暦の十の位の読み違い(例: 2014と2024)に特に注意。';

これだけで誤読の頻度はぐっと落ちた。とはいえ、ゼロにはならない。確率の世界に絶対はないのだから。

ここで一番の判断をした。Geminiに自動補正はさせない。 一見、賢いAIに「たぶん2024でしょ」と直させたくなる。でも税の数字を黙って書き換えるのは、確定申告では最大の禁じ手。だから怪しい年は直さず、ただ「要確認」の札を首から下げさせて、最終判断は人間と元画像の対面に委ねた。

function checkDatePlausibility_(dateStr) {
  const d = parseDate(dateStr);
  if (!d) return '日付不明';
  const ageDays = Math.round((Date.now() - d.getTime()) / 86400000);
  if (ageDays > 450) return `日付が約${Math.round(ageDays / 30)}ヶ月前(${dateStr})→年の読み違いの可能性`;
  if (ageDays < -3) return `未来日付(${dateStr})`;
  return '';
}

しきい値を450日(およそ15ヶ月)にしたのには理由がある。一月の領収書を翌年三月に処理する、あの確定申告特有の時間差を誤爆しないため。2014 みたいな露骨な遡行だけは、確実に網にかかる。直さず、目立たせて、人に返す。AIに最終決定権を渡さない——この一線が、数字が命の領域ではいちばん効いたと思う。

沼その四:スプレッドシートは、文字列を勝手に日付に「育てる」

これは毛色の違う沼。けれど一番タチが悪かった。一ファイルに複数枚のレシートが入っていた場合に備えて、「1/4」「2/4」といったファイル内通番をセルに書き込んだ。ところがGoogleスプレッドシートは、親切心からそれを「1月4日」に育て上げてしまう。気づけば通番の列が、見覚えのない日付で埋まっていた。

書式を文字列に固定して一度は黙らせた。が、列を増やす移行処理が clear() でシートを掃除した拍子に、その書式まで掃き出され、亡霊のように日付化が蘇る。同じ幽霊が二度出るなら、家の建て方が悪い。

だから書式に頼るのをやめ、そもそも日付に見えない表記にした。半角スラッシュを全角の「/」に替えるだけ。たったこれだけで、スプレッドシートは「1/4」を数式や日付と見なさなくなる。小細工に見えて、これがいちばん壊れない。

// "1/4" は日付に化ける。"1/4"(全角スラッシュ)は化けない
const seq = (idx + 1) + '' + count;

予算サイクルの対象期を「2026-06」と書いたら、これまた律儀に2026年6月1日へ昇格していた、なんて余罪もあった。教訓はひとつ。スプレッドシートのセルは、こちらが黙っていると気を利かせて勝手に解釈する。 良かれと思って動く新人ほど、仕様を握らせないと事故る。読むときに正規化するか、最初から日付に見えない値を渡すか。性善説でセルを信じてはいけない。

ついでに白状すると、Geminiは混雑するとき 503 で気絶する。これは病気ではなく気分なので、指数バックオフで二度三度、肩を叩いて起こしてやれば仕事に戻る。同じレシートを二回スキャンしてしまう人間側のうっかりは、「日付+店名+金額+時刻」の指紋で同一視して、二件目に「重複の疑い」と耳打ちさせた。二重計上は、確定申告では地味に効く嘘になる。

カレンダーという幻、ダッシュボードという妥協

出張に紐づく経費を自動で色分けしたくて、Googleカレンダーの予定とレシートの日付を突き合わせる作戦に出た。設計は美しかった。動かしてみたら、予定がひとつも見つからない。

種明かしはしょうもなかった。このGASは私の個人Gmailの上で回っていて、出張の招待が刺さっているのは会社のアカウント。隣の家の冷蔵庫を開けようとしていたわけだ。アカウントの壁は、コードでは越えられない。結局カレンダー連携は諦め、「出張期間」を手で書く素朴なマスターシートに方針転換した。賢い自動化より、確実に動く泥臭さ。アジャイルでいう「動くソフトウェア」が、立派な設計図に勝った瞬間。

ダッシュボードも一筋縄ではいかなかった。最初はFirebaseで作り込む気満々。でも「妻のGoogleアカウントに共有したいだけ」という要件に照らすと、Authだのルールだのは大砲で蚊を撃つ話。Looker Studioも検討したが、最終的にGASのウェブアプリに落ち着いた。スマホでひらく一枚画面に「今月あと◯円」を大きく出し、カテゴリごとの残りと、旅行のための積立残高を並べる。妻のITリテラシーに最適化した、やさしい貯金箱。

ひとつだけ、消せない傷が残った。個人アカウントで公開したGASウェブアプリの上部には、「このアプリはGoogleではなくユーザーが作成しました/不正行為を報告」という、あのグレーの帯が必ず憑く。Googleの正規の安全装置なので、コードでは祓えない。妻が毎回ぎょっとするので、これは「私が作ったやつだから大丈夫」という、いちばん原始的なAPIで対処している。

財布の痛み、という名のコスト

気になるのは金。OCRはGemini 2.5 Flashで、レシート一枚あたりおよそ0.6円。月に数百円の世界。無料枠(一日1,500リクエスト)に収まれば実質タダだが、無料枠は入力が学習に使われる規約なので、家計という極めて私的な紙束を流すなら、課金をオンにして口を閉じてもらうのが筋だと思う。

新しい gemini-3.5-flash も味見した。速いし賢い。けれどレシートを読むだけなら2.5 Flashで十分で、しかも費用はおよそ四分の一。新しい=正解、という思考停止こそ、いちばん高くつく。

おわりに:信用できない相棒と、どう組むか

作り終えて手元に残ったのは、派手なAI活用術ではなく、間違いとの距離の取り方だった。

LLMは確率で間違える。confidenceは笑顔で嘘をつく。だから出口に決定論の関所を置き、その関所には対象ドメインの常識を仕込む。クリティカルな数字は直さず、目立たせて人に返す。そしてスプレッドシートは黙っていると勝手に気を利かせるから、性善説でセルを信じない。書いてみると、どれもエンジニアリングというより、信用できない相手とどう仕事を回すかという、スクラムマスターの日常そのものだった気がする。

「AIで自動化しました」と言うとき、面白さの九割は、自動化した部分ではなく、AIが間違えた後始末をどう設計したかに宿っている。同じようにレシートや帳票をLLMに読ませようとしている、少し前のわたしのような誰かへ。便利な道具ほど、裏切られる前提で柵を立てておくといい。

残タスクは、店名キーワードでのカテゴリ自動振り分けと、過去の予算と実績を並べる履歴ビュー。完璧主義は敵なので、動くものから先に出す。

開発:Google Antigravity + clasp / 読み取り:Gemini 2.5 Flash

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?