Jev Recipe / 経理オペレーション

Jev で経費分類:銀行明細の自動仕訳と信頼度ゲート

カード明細や銀行明細の各行を型付きの財務判断へ変換します:category Choice、控除 Score、needs_review Noul——明細 1 件分を 1 回の evaluate 呼び出しで処理し、0.85 の信頼度ゲートが自動計上と人手レビューを振り分けます。

経費分類パイプライン構築 6 ステップ

01コードを書く前にカテゴリ体系(勘定科目の軸)を決める。経理がすでに仕訳に使っている勘定科目から出発する——software_saas、travel、meals、office_supplies、professional_services、そして無理にラベルを付けずエスカレーションする other。軸は小さく保つ。カテゴリを 1 つ増やすたびに確率質量は薄まり、各カテゴリはちょうど 1 つの勘定科目に対応させる。
02契約は 1 回の evaluate 呼び出しに 3 つの質問として設計する。選択肢ごとに criteria を持つ category Choice、税務処理を判定する控除 Score(1 = 個人支出、4 = 全額控除の経費)、そして「この行は曖昧すぎて計上できないのではないか」を問う needs_review Noul。複数質問を 1 呼び出しにまとめる形は、リードスコアリング Recipe の営業優先度と同じ型付き契約——モデルは事実を述べ、ポリシーはコード側に置く。
03ゲーティングのポリシーはアプリケーションコードに置く。category の信頼度 0.85 以上かつ needs_review = false なら自動計上、0.60〜0.85 はレビューキューへ、0.60 未満とすべての「other」ラベルは経理担当者の確認へ。カットオフはラベル付き明細から導出するものであって、当て推量ではない。0.85 が精度の契約として機能するのは、Jev の信頼度が RLCD 較正されているからだ。
04コミットする前にバッチ計算をする。state は入力トークンのみの課金($0.042/M)で、出力は無料。30 行の明細は 1 行約 50 トークンとして約 1,500 入力トークン ≈ $0.00007。月 1 万件の明細でも 1 ドル未満——内訳の詳細は Jev 料金ページに。明細全体が 1 回の evaluate 呼び出しに乗る。
05消し込みで修正を資産に変える。レビュアーが行を再分類したら、修正をラベル付きサンプルセットに書き戻し、現在のスキーマでシャドーモードに通す。修正はカテゴリ体系の穴を見つける手段だ。同じ店舗が繰り返し「other」に落ちるのは、カテゴリが足りないか criteria が狭いというシグナルであり、消すべきノイズではない。
06精度だけでなく分布を監視する。自動計上の割合、「other」レート、0.60〜0.85 の境界帯に落ちるトラフィックを追跡する。新しい銀行エクスポート形式、カードのリブランド、新しい支出カテゴリは分布シフト——信頼度ゲート付きフォールバックチェーンが再較正するのと同じドリフトだ。しきい値は四半期ごとに再検証する。
schema / 経費分類判定コントラクト
{
  "category": {
    "type": "choice",
    "instructions": "各行の経費をちょうど 1 つのカテゴリに分類する",
    "criteria": {
      "software_saas": "SaaS 契約、クラウドホスティング、デジタルツールのライセンス",
      "travel": "航空券、ホテル、地上交通、出張手当",
      "meals": "レストラン、コーヒー、顧客接待とチームの会食",
      "office_supplies": "事務用品、備品、家具、倉庫用品の購入",
      "professional_services": "法務、会計、コンサルティング、外注、代理店",
      "other": "上記のいずれも確信を持って当てはまらない——無理にラベルを付けずエスカレーション"
    }
  },
  "deductibility": {
    "type": "score",
    "instructions": "各行を経費としてどの程度計上できるかを評価、1(個人支出・控除対象外)から 4(全額控除の経費)"
  },
  "needs_review": {
    "type": "noul",
    "instructions": "各行について、確実に分類するには曖昧すぎて、計上前に経理担当者の確認が必要ですか?"
  }
}
実際の明細行を State として送ると、category・控除 Score・needs_review の較正済み信頼度を確認できます。
インタラクティブデモ / 銀行明細の経費分類

銀行明細の一括経費分類シミュレーター

明細を選んでパイプラインを実行。category Choice でカテゴリ、控除 Score で税務処理、needs_review Noul で曖昧な行を判定し、0.85 ゲートが「自動計上 / レビュー待ち / 経理確認」に振り分けます(フロントエンドのシミュレーションで、実 API は呼びません):

category + deductibility + needs_review
7 行
0103-01

AWS EMEA SARL

$184.20
0203-02

UBER *TRIP 88112

$23.75
0303-03

OFFICE DEPOT #221

$96.40
0403-04

SQ *BLUE BOTTLE COFFEE

$18.50
0503-05

GRANITE LEGAL PLLC

$2,400.00
0603-06

MERCH PAYMENT 8842 LLC

$312.88
0703-07

ZOOM.US

$15.99
source: 法人カード / 3 月
Jev 判定出力
~86ms / ≈$0.0000147

「一括分類を実行」を押すと、行ごとのカテゴリ・控除 Score・信頼度レーンを表示します。

コード側のポリシー: 信頼度 ≥ 0.85 かつ needs_review = false → 自動計上 · 0.60〜0.85 → レビュー待ち · 0.60 未満または「other」・needs_review = true → 経理確認 · 金額と税の計算はコード側に · デモデータ(シミュレーション取引)

経費分類の比較:Jev 型付き契約 vs キーワードルールと生成系 LLM

METRIC
Jev
キーワードルール + 生成系 LLM
カテゴリ体系の変更
コードでバージョン管理された criteria を編集——次の呼び出しから新しい体系が使われる。再学習も正規表現の書き直しも不要
キーワードルールは店舗名の表記ゆれとともに陳腐化する。生成系 LLM はカテゴリ体系をプロンプトに載せるため、実行ごとにドリフトする
信頼度のセマンティクス
RLCD 較正済みの確率——0.85 は約 85% の精度契約として振る舞い、しきい値が自動計上を直接ゲートする
ルール: なし——キーワードは一致するかしないかだけ。生成系: 自己申告で未較正、logprob の追加実装が必要
未知の店舗
設計された「other」と needs_review Noul——解決できない行は強制的な推測ではなくレビューキューになる
ルール: 一致しない文字列は黙ってデフォルトのバケットへ落ちる。生成系: それらしいカテゴリを勝手に作り、推測した痕跡を何も残さない
明細 1 件あたりのレイテンシ
バッチ全体で約 70〜100ms——30 行が 1 回の evaluate 呼び出し、単一フォワードパスで完了
ルール: 即時だが自由テキストの記述が読めない。生成系: トークン単位の生成で 1 行 1.5〜3 秒——30 行の明細で 45〜90 秒
バッチコスト
入力のみ課金 $0.042/M、出力無料——30 行の明細 ≈ $0.00007。月 1 万件でも 1 ドル未満
ルール: 実行は無料だが修正が高価——誤仕訳のたびに人手の分単位コスト。生成系: 判定ごとに出力トークンを課金、リトライで再請求
判定の一貫性
決定論的——同じ行は常に同じカテゴリ・同じ信頼度になるため、帳簿の突き合わせが実行間で崩れない
ルール: 決定論的だが不透明——誤仕訳の説明には正規表現の追跡が必要。生成系: サンプリング揺らぎが境界行を実行間で入れ替える

実装コード:TypeScript / Python の経費分類パイプライン

python / ベースライン: キーワードルール + 生成系フォールバック
import json
import os
import time

import requests

# ベースライン: まずキーワードルール、一致しないものを生成系 LLM へ。
# 2 つのシステム、2 つの失敗モード。
KEYWORD_RULES = {
    "software_saas": ["AWS", "NOTION", "ZOOM.US", "ADOBE"],
    "travel": ["DELTA AIR", "UBER", "LYFT", "MARRIOTT"],
    "meals": ["RESTAURANT", "COFFEE", "SUSHI", "CAFE"],
    "office_supplies": ["OFFICE DEPOT", "STAPLES", "AMZN MKTP"],
}
CATEGORIES = set(KEYWORD_RULES) | {"professional_services", "other"}

def rule_category(line: str) -> str | None:
    upper = line.upper()
    for category, keywords in KEYWORD_RULES.items():
        if any(keyword in upper for keyword in keywords):
            return category
    return None  # 「SQ *BLUE BOTTLE」「MERCH PAYMENT 8842 LLC」はどのルールにも一致しない

def generative_category(line: str) -> dict:
    # 1 行 1.5〜3 秒、試行ごとに出力トークンを課金。json.loads は
    # 幻覚カテゴリも本物と同じく通してしまう。
    for attempt in range(3):
        resp = requests.post(
            "https://api.openai.com/v1/chat/completions",
            headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
            json={
                "model": "gpt-4o-mini",
                "response_format": {"type": "json_object"},
                "messages": [{
                    "role": "user",
                    "content": f'この経費行を {", ".join(sorted(CATEGORIES))} の 1 つに分類し、'
                               f'JSON で返答: {{"category": "..."}}\n明細行: {line}',
                }],
            },
            timeout=30,
        )
        try:
            category = json.loads(
                resp.json()["choices"][0]["message"]["content"]
            )["category"]
            if category in CATEGORIES:  # json_mode が提供しない値チェック
                return {"category": category, "confidence": None}
        except (KeyError, json.JSONDecodeError):
            time.sleep(2 ** attempt)  # リトライのたびに生成全体を再請求
    return {"category": "other", "confidence": None}  # サイレントな失敗

def categorize_line(line: str) -> dict:
    category = rule_category(line)
    if category is not None:
        return {"category": category, "confidence": None}  # ルールに信頼度はない
    return generative_category(line)
python / jev 明細バッチ分類
import requests

JEV_ENDPOINT = "https://api.typesafe.ai/v1/jev/evaluate"
AUTO_POST_CONFIDENCE = 0.85   # 0.85 以上 → 自動計上
REVIEW_MIN_CONFIDENCE = 0.60  # 0.60〜0.85 → レビューキュー、未満 → 人手

QUESTIONS = {
    "category": {
        "type": "choice",
        "instructions": "各行の経費をちょうど 1 つのカテゴリに分類する",
        "criteria": {
            "software_saas": "SaaS 契約、クラウドホスティング、デジタルツール",
            "travel": "航空券、ホテル、地上交通",
            "meals": "レストラン、コーヒー、顧客接待",
            "office_supplies": "事務用品、備品、倉庫用品の購入",
            "professional_services": "法務、会計、コンサルティング、外注",
            "other": "上記のいずれも確信を持って当てはまらない",
        },
    },
    "deductibility": {
        "type": "score",
        "instructions": "各行を経費としてどの程度計上できるかを評価、1(個人支出)から 4(全額控除)",
    },
    "needs_review": {
        "type": "noul",
        "instructions": "各行について、経理担当者の確認なしに計上するには曖昧すぎますか?",
    },
}

def categorize_statement(lines: list[dict]) -> list[dict]:
    # 明細全体が 1 つの state として乗る——3 つの型付き質問が
    # 単一の約 70-100ms フォワードパスで行ごとに答えを返す。
    resp = requests.post(
        JEV_ENDPOINT,
        json={"state": {"lines": lines}, "questions": QUESTIONS},
        timeout=5,
    )
    resp.raise_for_status()
    data = resp.json()

    results = []
    for i, line in enumerate(lines):
        category = data["category"][i]
        flagged = data["needs_review"][i]["answer"]
        if flagged or category["answer"] == "other":
            lane = "human_review"
        elif category["confidence"] >= AUTO_POST_CONFIDENCE:
            lane = "auto_post"
        elif category["confidence"] >= REVIEW_MIN_CONFIDENCE:
            lane = "review"
        else:
            lane = "human_review"
        results.append({
            "line_index": line["line_index"],
            "category": category["answer"],
            "confidence": category["confidence"],
            "deductibility": data["deductibility"][i]["answer"],
            "lane": lane,
        })
    return results

def sweep_statements(statements: list[list[dict]]) -> dict:
    # 入力のみ課金 $0.042/M: 30 行の明細は約 1,500 トークン ≈ $0.00007。
    # 月 1 万件でも 1 ドル未満です。
    lanes: dict[str, int] = {"auto_post": 0, "review": 0, "human_review": 0}
    for lines in statements:
        for row in categorize_statement(lines):
            lanes[row["lane"]] += 1
    return lanes
typescript / ベースライン: ルール + structured outputs リトライ
import { z } from "zod";
import OpenAI from "openai";

// ベースライン: まずキーワードルール、残りを structured outputs LLM へ。
const KEYWORD_RULES: Record<string, string[]> = {
  software_saas: ["AWS", "NOTION", "ZOOM.US", "ADOBE"],
  travel: ["DELTA AIR", "UBER", "LYFT", "MARRIOTT"],
  meals: ["RESTAURANT", "COFFEE", "SUSHI", "CAFE"],
  office_supplies: ["OFFICE DEPOT", "STAPLES", "AMZN MKTP"],
};

const CATEGORIES = [
  "software_saas",
  "travel",
  "meals",
  "office_supplies",
  "professional_services",
  "other",
] as const;

const Decision = z.object({ category: z.enum(CATEGORIES) });
const client = new OpenAI();

function ruleCategory(line: string): string | null {
  const upper = line.toUpperCase();
  for (const [category, keywords] of Object.entries(KEYWORD_RULES)) {
    if (keywords.some((k) => upper.includes(k))) return category;
  }
  return null; // 「MERCH PAYMENT 8842 LLC」はどのルールにも一致しない
}

export async function categorizeLine(line: string) {
  const category = ruleCategory(line);
  if (category) return { category, confidence: null }; // ルールに信頼度はない

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const resp = await client.chat.completions.create({
        model: "gpt-4o-mini",
        response_format: { type: "json_object" },
        messages: [
          {
            role: "user",
            content: `この経費行を ${CATEGORIES.join(", ")} の 1 つに分類し、JSON で返答: {"category": "..."}\n明細行: ${line}`,
          },
        ],
      });
      // 形状は保証される。値が正しいかは保証されない——
      // しかも自動化をゲートする信頼度の数値が存在しない。
      return {
        category: Decision.parse(
          JSON.parse(resp.choices[0].message.content!),
        ).category,
        confidence: null,
      };
    } catch {
      await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
    }
  }
  return { category: "other" as const, confidence: null };
}
typescript / jev 型付き明細パイプライン
const JEV_ENDPOINT = "https://api.typesafe.ai/v1/jev/evaluate";

// ポリシーはアプリケーションコードに置く。モデルは事実を報告するだけ。
const AUTO_POST_CONFIDENCE = 0.85; // 0.85 以上 → 自動計上
const REVIEW_MIN_CONFIDENCE = 0.6; // 0.60〜0.85 → レビューキュー

type StatementLine = {
  line_index: number;
  merchant: string;
  amount: number;
  date: string;
  memo?: string;
};

const QUESTIONS = {
  category: {
    type: "choice",
    instructions: "各行の経費をちょうど 1 つのカテゴリに分類する",
    criteria: {
      software_saas: "SaaS 契約、クラウドホスティング、デジタルツール",
      travel: "航空券、ホテル、地上交通",
      meals: "レストラン、コーヒー、顧客接待",
      office_supplies: "事務用品、備品、倉庫用品の購入",
      professional_services: "法務、会計、コンサルティング、外注",
      other: "上記のいずれも確信を持って当てはまらない",
    },
  },
  deductibility: {
    type: "score",
    instructions: "各行を経費としてどの程度計上できるかを評価、1(個人支出)から 4(全額控除)",
  },
  needs_review: {
    type: "noul",
    instructions: "各行について、経理担当者の確認なしに計上するには曖昧すぎますか?",
  },
} as const;

export type ExpenseLane = "auto_post" | "review" | "human_review";

export async function categorizeStatement(lines: StatementLine[]) {
  const response = await fetch(JEV_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ state: { lines }, questions: QUESTIONS }),
  });
  if (!response.ok) throw new Error("Jev evaluate failed");
  const data = await response.json();

  // 答えは行ごとに、state.lines の順序と揃って返ります。
  return lines.map((line, i) => {
    const category = data.category[i] as {
      answer: string;
      confidence: number;
    };
    const flagged = data.needs_review[i].answer as boolean;
    const lane: ExpenseLane =
      flagged || category.answer === "other"
        ? "human_review"
        : category.confidence >= AUTO_POST_CONFIDENCE
          ? "auto_post"
          : category.confidence >= REVIEW_MIN_CONFIDENCE
            ? "review"
            : "human_review";
    return {
      ...line,
      category: category.answer,
      confidence: category.confidence,
      deductibility: data.deductibility[i].answer as number,
      lane,
    };
  });
}

// 金額・税・カテゴリ別集計はコード側に置く。モデルは算術をしない。
// 30 行の明細 ≈ 1,500 入力トークン、処理時間は約 70-100ms、
// 出力トークン $0。

経費分類のよくある質問

経費分類(expense categorization)とは何ですか?

経費分類は、支出の各行——カードの請求、銀行明細の記録、レシート——を経理上のカテゴリ(ソフトウェア、出張、接待、事務用品、専門サービス)に割り当てる作業です。経理は何百年も手作業でやってきました。自動化が答えるべき問いは、ソフトウェアが自由テキストの店舗記述を読んで同じ判断を下せるか、です。このページは各行を 1 つの型付き財務判断として扱います: カテゴリ体系全体をカバーする Choice、控除 Score、needs_review フラグ——そして較正済み信頼度が、自動計上する行と人手で確認する行を振り分けます。

銀行明細はどう自動分類すればよいですか?

明細をエクスポートし、各行を(店舗名・金額・日付・メモの)オブジェクトにパースして、その行を state として 1 回の Jev evaluate 呼び出しに送ります——上のレシピは 30 件のデモ取引を 1 リクエストにまとめています。行ごとに 3 つの型付き答えが返ります: category Choice、控除 Score、needs_review Noul。あとはコード側が 0.85 のゲートを適用し、高信頼度の行は帳簿へ計上、残りはレビューキューへ。エクスポートのパースは呼び出し側の境界です。Jev が読むのは行のテキストであって PDF のレイアウトではありません。その上流の「分割してから分類」問題は、ドキュメント分類 Recipe のテーマです。

AI の経費分類はどのくらい正確ですか?

正直な答えは「行ごとに、ゲートを付けて」です。Jev の信頼度は RLCD 較正済みなので、0.85 のしきい値は 85% の精度契約として振る舞います。そのゲートを通った自動計上 100 行のうち約 85 行は正しく分類され、残りの 15 行が黙って計上されないようパイプラインが設計されています。0.60〜0.85 帯の行、すべての「other」行と needs_review 行は経理担当者へ渡り、その修正がラベル付きサンプルになります。精度が大事な場所で向上するのは、モデルが再学習したからではなく、レビューキューが誤仕訳を帳簿に届かせないからです。

汎用 LLM 分類とは何が違いますか?

契約の違いが 3 つあります。第一に有界な出力: 生成系分類器は「おそらく meals——ラーメンチェーンに見えますね」と散文で答えられますが、Jev はカテゴリ体系から 1 つの値と較正済み確率だけを返します。第二に、信頼度の数値に意味がある: RLCD 較正は 0.85 を精度の契約にしますが、トークン logprob はトークンが出やすいかどうかの尺度であって、判断の正確さではありません。第三にバッチ: 1 回の evaluate 呼び出しが明細全体の 3 つの質問に約 70〜100ms で答え、課金は入力のみ——判定ごとの出力トークンもリトライもありません。単一テキストの REST / CLI 一般形は、テキスト分類 API ガイドを参照してください。

カテゴリはどう選ぶべきですか?

モデルの考えるカテゴリではなく、経理がすでに仕訳に使っている勘定科目から出発します。軸は小さく——5〜8 カテゴリと設計された「other」——ラベルを 1 つ増やすたびに確率質量が薄まり、各カテゴリはちょうど 1 つの勘定科目に対応させるべきです。criteria はレビュアーの語彙(「顧客接待」「クラウドホスティング」)で書き、他のポリシーと同じくコードでバージョン管理します。同じ店舗が繰り返し「other」に落ちたら、カテゴリの追加か criteria の修正が必要というシグナルです。

未知の店舗はどう扱いますか?

「未知」は設計された結果であり、エラーではありません。「MERCH PAYMENT 8842 LLC」のようなパースできない記述は other ラベルか needs_review フラグを取り、0.85 のゲートを下回り、人手の確認キューに落ちます——1 行の奇妙な明細が誤った勘定科目に計上されるのを防ぐのが、この human-in-the-loop レーンです。レビュアーが 1 回解決すれば修正はラベル付きセットに入り、同じ未知のパターンが繰り返し現れるなら、カテゴリを足すか criteria を引き締めるシグナルです。

経理パイプラインを広げる