Go back

Jevでレシート仕訳を判定するサービスを作ってみた

Posted on:

「読み取ったOCRのテキストをJevに渡して、勘定科目を選ばせるだけです」と言葉にすると、配管を書くだけの作業に聞こえます。実際、Claude Codeとのセッションで何を作るかを決めるところまでは数分で終わりました。難所があるとすれば、それはJevの側だろうと踏んでいました。前回で読んだ「賢いif文」の中身を、自分の手でどう設計するか。そこで詰まるはずだと。

ところが、実際に手を止めさせたのはJevではありませんでした。

ただし先に断っておくと、実レシートで試したのはまだ1枚だけです。精度をきちんと検証したとは言えません。それでも、その1枚が教えてくれたことは書く価値がありました。

プロジェクト名はjev-workです。レシート画像をアップロードすると、Tesseract.jsでテキストを抽出し、TypeSafeのJevが借方勘定科目を確率付きで推定する、Next.js(App Router、TypeScript、Tailwind)のWebアプリを作りました。

決めた3つの前提

コードを書き始める前に、範囲を決めるところから始まりました。

一つめは形態です。API単体やCLIではなく、Webアプリにしました。UIまで一気通貫で作れる形にしておけば、実際に触って確かめられます。

二つめはOCRです。Google Cloud VisionやAzure AI Document Intelligenceも候補にはありましたが、Tesseract.jsを選びました。無料でローカル完結し、OCR用のAPIキーなしにすぐ試せることを優先した結果です。

三つめは判定スコープです。借方勘定科目の推定だけに絞りました。現金やクレジットカードといった貸方を含む複式仕訳の生成は見送っています。MVPとして最小構成に留めた判断で、これは後の節でもう一度出てきます。

この3点が決まったあとの構成は、素直なものになりました。src/lib/accounts.tsに勘定科目の説明を定義し、src/lib/typesafe.tsがTypeSafeクライアントの薄いラッパーを持ち、src/app/api/classify/route.tsがOCRとJevの呼び出しを担う。それをsrc/components/ReceiptClassifier.tsxが画像選択からプレビュー、結果表示まで一枚のクライアントコンポーネントとしてまとめています。

勘定科目の説明文をどう書くか

勘定科目のリストは最初、思いつくままに並べていました。一般的な勘定科目表を眺めながら、それらしいものを20科目ほど拾っていたのです。

そのまま渡してもよかったのですが、TypeSafeのドキュメントには気になる一文がありました。Choiceの判定基準(criteria)を雑に書くと、悪くなるのはモデルの性能ではなく境界の設計そのものだ、というものです。ここで言う境界とは、どの科目とどの科目を、どんな基準で分けるかということです。

そこで科目を経費精算でよく使う14個に絞り込み、それぞれに具体的な一文の説明を付けました。

export const EXPENSE_ACCOUNTS = {
  旅費交通費:
    "電車・バス・タクシー・飛行機代、駐車場代、ETC、出張時の宿泊費など、移動や出張に関わる支出",
  会議費:
    "打ち合わせ・会議で提供した飲食代(1人あたり少額)、会議室のレンタル料",
  接待交際費:
    "取引先の接待、贈答品、お中元・お歳暮など、社外関係者をもてなす目的の支出",
  // ...以下、消耗品費・通信費・水道光熱費など計14科目
} as const;

このEXPENSE_ACCOUNTSが、加工せずそのままchoice()のcriteria(コード中ではcriteriaという変数名で参照しています)になります。呼び出し全体は、Jevの単一のエントリーポイントであるsystemOne()に、状態(state)と判定したい問い(questions)をまとめて渡す形です。

const result = await client.systemOne({
  state: { receiptText: ocrText },
  questions: {
    account: choice(
      "このレシート・領収書のOCRテキスト `receiptText` は、経費精算における借方のどの勘定科目に最も該当するか。",
      criteria
    ),
  },
});

Choiceを選んだのは、決まった選択肢の中から一つが勝つ判定だからです。順序尺度で採点するScoreや、真偽を確率で返すNoulではなく、選ばれたラベルと全選択肢の確率分布、そして確信度(分布の集中度)をまとめて返してくれる形が、この用途にちょうど合っていました。

科目を14個に絞ったのにも理由があります。選択肢同士が実際に区別できることが、Choiceの精度に直結するからです。一般的な勘定科目を100個近く並べて説明文が似通ってしまうと、確率が分散して確信度が全体的に下がりやすくなります。TypeSafeが公開している実装例集(クックブック)に、分類のフォールバックとして「群からカテゴリを絞り、絞った群の中でさらに分類する」パターンが載っているのも、同じ発想でしょう。

もう一つ、TYPESAFE_API_KEYsrc/lib/typesafe.tsとRoute Handlerの中だけで参照し、クライアントには一切渡していません。

export function getTypeSafeClient(): TypeSafeClient {
  if (!process.env.TYPESAFE_API_KEY) {
    throw new Error(
      "TYPESAFE_API_KEY が設定されていません。.env.local に設定してください。"
    );
  }
  return new TypeSafeClient();
}

Webアプリでは認証情報をサーバー側に留めるべきだという、ドキュメントの指摘どおりの形です。

ビルドは通ったのに、動かなかった

手を止めさせたのは、ここでした。

npm run buildは問題なく通りました。ところがnpm run devを立ち上げ、実際に画像をPOSTすると、サーバーログにこう出て、APIが落ちたのです。

Error: Cannot find module '/ROOT/node_modules/tesseract.js/src/worker-script/node/index.js'

uncaughtExceptionでした。ビルドが通っていたのに、うまくいっているに違いないと思っていました。ところが実行時になって、そうではないとわかったのです。

原因はNext.js(Turbopack)とtesseract.jsの組み合わせにありました。Turbopackはサーバー側の依存関係をデフォルトでバンドルします。一方tesseract.jsは、自分のワーカースクリプトを自パッケージ相対のパスで、実行時にrequire()する実装になっていました。バンドルされると、そのパスが実際のファイル配置とずれてしまい、解決できなくなるという仕組みです。

修正は一行でした。指定したパッケージをサーバー側のバンドル対象から外し、Node標準のrequire解決に任せるNext.jsの設定serverExternalPackagesに、tesseract.jsだけを足します。

const nextConfig: NextConfig = {
  reactCompiler: true,
  // tesseract.js はワーカースクリプトを実行時に相対パスで require するため、
  // バンドルさせず Node の通常解決に任せる。
  serverExternalPackages: ["tesseract.js"],
};

これで1x1の空白PNGをAPIにPOSTしたところ、想定どおり「画像からテキストを読み取れませんでした。」という422のJSONが返ってきました。OCR自体は正常に走っていて、空白画像だからテキストが空だった、という結果です。ようやく配管が通った瞬間でした。

Jevの判定ロジックそのものより、その手前の一行で止まっていたわけです。

確認できたところ、できていないところ

npm run lintnpm run buildは通しました。それだけでなくnpm run devを実際に立ち上げ、Node のfetchスクリプトから/api/classifyへ画像をPOSTし、レスポンスとサーバーログの両方を確認しています。ホームページのHTMLに、想定した日本語の文言(タイトルやボタンのラベル)が含まれていることも見ました。

ブラウザで実際の画面を目で見ての確認は、Claude Codeの作業環境にスクリーンショットを撮る手段がなかったため、自分ではできていません。.env.localもClaude Code側の権限設定でファイルの作成・読み取りが禁止されており、ここだけは自分の手で作る必要がありました。

その後、手元のブラウザで開いてレシートを1枚読ませてみました。画面自体は狙いどおり動いていて、推定科目・確率・確信度・次点候補・OCR原文の折りたたみ、どれも設計したとおりに表示されました。問題は、その中身でした。

読ませてみた最初の1枚

手元にあった感熱紙のレシートを、机の上に置いて斜めから撮った写真1枚をアップロードしました。抽出されたOCRテキストは、日本語としてほとんど意味をなしていませんでした。

壮 0 導 に さる
全く 0 マフ
を コー 人

(実際の出力の一部です。レシートの店名・金額・品目のどれとも対応していません。)

それでもJevは何かしらの答えを返しました。

推定科目は「雑費」で、確率30%、確信度24%。次点候補は接待交際費21%、旅費交通費17%、研修費12%でした。

確率が30%前後に散らばり、確信度も低い。OCRテキストがほぼノイズだったことを踏まえれば、これはむしろ健全な反応です。手がかりがほとんどない入力に対して、Jevは何かに強く賭けるのではなく、確率を選択肢全体へ薄く広げました。

ただ、画面にはこの数字がそのまま並びます。「雑費30%」という結果だけを見た人は、確信度が低いという事実に自分で気づく必要があります。UI側は、低確信度を強調していません。

まだ手をつけていないこと

貸方科目を含む複式仕訳の生成は、最初のスコープ決めで見送ったまま手つかずです。

確信度に応じたフォールバックも実装していません。TypeSafeのクックブック「classification_using_confidence」には、確信度が高ければ具体的なラベルを、低ければ広いカテゴリへフォールバックする設計が載っていました。読みはしたものの、今回のMVPには組み込んでおらず、その必要性は1枚目のレシートで早くも裏付けられた形です。低確信度のときに人手レビューへ回す導線も、同じ理由でまだありません。

勘定科目のリストも、今は経費精算向けの14個で固定されています。会社ごとの勘定科目マスタに対応させる余地は残したままです。

そして何より、実レシートでの検証はまだ1枚しかしていません。わかったのは、OCRの精度が全体のボトルネックになりうるということだけです。Jevの判定そのものの精度については、まともなOCRテキストを渡せていない以上、まだ何も言えません。

読めるかどうかを先に聞く

「まともなOCRテキストを渡せていない以上、まだ何も言えません」で終わらせるのも据わりが悪く、この記事を書いたあとに、ひとつだけ手を付けました。

最初に足したのは、確信度の低い結果を目立たせるUIの警告です。ただしそれは、判定が終わったあとの見せ方を直しただけでした。ノイズだらけのテキストを渡しても、Jevは律儀に14科目のどれかを選び続けます。表示を直しても、その裏では相変わらず無根拠な選択が行われているわけです。

だったら、科目を選ばせる前に、そもそも読める内容かどうかをJevに聞けばいいのではないか。Choiceと同じリクエストに、Noul(真偽を確率で返す判定)をもう一問足しました。

const result = await client.systemOne({
  state: { receiptText: ocrText },
  questions: {
    readable: noul(
      "このOCRテキスト `receiptText` は、レシート・領収書の内容として日本語で意味が通るか。",
      {
        true: "店名・金額・品目など、レシートとして自然に読める内容が含まれている",
        false:
          "文字化けや無関係な文字列の羅列で、レシートの内容として意味を成さない",
      }
    ),
    account: choice(/* ... */),
  },
});

2つの質問は同じ状態(state)に対して並列に評価されます。readableの確率が50%を下回ったら、accountの答えはそもそも画面に出さず、「読み取れませんでした、撮り直してください」に切り替えるようにしました。

最初にJevを詰まらせた、あの1枚をもう一度読ませてみました。読み取れた確率は16%。科目の判定はスキップされ、狙いどおり撮り直しを促す表示に変わりました。

前回の問いに戻ると

前回の記事では、危険なツール実行の前に挟んでいる確認処理が、Jevの向く用途そのものではないかと書きました。今回作ったものは、その確認処理そのものではありません。もっと素朴な、確率付きの分類器です。

それでも、実際に手を動かしてわかったことがあります。Jevを使う上で難所になるのは、モデルの呼び出し方ではなく、criteriaという説明文の設計と、選択肢の粒度でした。そしてもう一つ、専用モデルを呼ぶコード自体はうまく書けていても、それを取り巻くふつうのNext.jsアプリの配線一本で、ものごとは簡単に止まるということです。

1枚のレシートを実際に読ませてみて、Jevの判定精度を検証する前に、OCRというもっと地味な工程がボトルネックになりうるとわかりました。読めるかどうかを先に聞くゲートは、その事実に無理やり蓋をせず、Jevに「わからない」と正直に言わせる仕組みです。OCRの精度そのものを上げたわけではありません。

感熱紙の薄れなのか、斜めからの撮影角度なのか、机の上の照明なのか。この1枚がなぜ読めなかったのかは、まだわかっていません。

この記事のX告知です。

https://x.com/redamoon/status/2102017510526898292