1,000枚のレシートと、衝動買いしたスキャナ
新年の抱負は、たいてい一月の第二週で息絶える。わたしの場合は「今年こそレシートを家計につける」だった。気づけば引き出しには、コーヒー一杯から新幹線まで、一年分の生活が感熱紙の上で薄れかけたまま、1,000枚近く堆積していた。これをExcelのマス目へ一枚ずつ写経する——その量を前に、正直、心が折れた。
本当にやりたかったのは、家計を「予算ドリブン」に切り替えること。なんとなく使ってなんとなく後悔する暮らしから、リアルな出費をダッシュボードに映し、「今月はあといくら使えるのか」を数字で握って生きる側へ。そのためには、まず1,000枚という名の負債を、人間の手を介さずデータへ変える仕掛けがいる。
で、ついにScanSnapをポチった。これが全ての始まり。紙はPCのフォルダに吸い込まれ、Googleドライブのデスクトップ同期が雲の上まで運んでくれる。そこまでは順調だった。問題はその先で、ドライブに溜まった画像は、いつまでもただの画像のまま居座る。誰かが手で数字に翻訳してやらないと、家計簿にも予算にもならない。スキャナを買ったところで、写経の宛先がExcelからフォルダに引っ越しただけ——そのままでは、ね。
だから、その「誰か」をコードに肩代わりさせることにした。スキャンしたら黙ってデータになり、妻と二人で予算の残りを眺められる。そういう静かな仕組みがほしかっただけ。
先に結論を言っておく。この記事の主張はひとつだけ。LLM-OCRは信じるな。信じる代わりに、ただの足し算で見張れ。 派手なAI活用術の話はしない。むしろAIに裏切られ続けた人間が、どうやって安全柵を立てたかという、地味な防御の記録になる。
完成したもの、その地図
全体はこうなった。紙からダッシュボードまで、人間が触る場所はスキャナのボタンだけ。
スマホで開く家族ダッシュボード。「今月あと使えるのは」を一番大きく、その下にカテゴリ別の残り。積立に取り分けたぶんも「使用済み」として効いている。
サーバは一台も借りていない。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 が一枚あって、そこに scriptId と rootDir(今回は 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,900 を 1,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が、西暦の十の位を平然と読み違えた。2024 を 2014 に。十年の時を勝手に遡る。しかも、ご丁寧にまた自信ありげ。さすがに本気で困り果てた。
まず気づいたのは、こいつは「今日が何日か」を知らないという当たり前。カレンダーを持たない相手に「最近のレシートだ」と察しろというのが無理筋だった。だから毎回のリクエストに今日の日付を握らせ、未来や一年以上前に飛ばないよう、釘を刺すことにした。
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
