Jev Recipe / 経理オペレーション
Jev で経費分類:銀行明細の自動仕訳と信頼度ゲート
カード明細や銀行明細の各行を型付きの財務判断へ変換します:category Choice、控除 Score、needs_review Noul——明細 1 件分を 1 回の evaluate 呼び出しで処理し、0.85 の信頼度ゲートが自動計上と人手レビューを振り分けます。
経費分類パイプライン構築 6 ステップ
{
"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": "各行について、確実に分類するには曖昧すぎて、計上前に経理担当者の確認が必要ですか?"
}
}銀行明細の一括経費分類シミュレーター
明細を選んでパイプラインを実行。category Choice でカテゴリ、控除 Score で税務処理、needs_review Noul で曖昧な行を判定し、0.85 ゲートが「自動計上 / レビュー待ち / 経理確認」に振り分けます(フロントエンドのシミュレーションで、実 API は呼びません):
AWS EMEA SARL
$184.20UBER *TRIP 88112
$23.75OFFICE DEPOT #221
$96.40SQ *BLUE BOTTLE COFFEE
$18.50GRANITE LEGAL PLLC
$2,400.00MERCH PAYMENT 8842 LLC
$312.88ZOOM.US
$15.99「一括分類を実行」を押すと、行ごとのカテゴリ・控除 Score・信頼度レーンを表示します。
経費分類の比較:Jev 型付き契約 vs キーワードルールと生成系 LLM
実装コード:TypeScript / 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)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 lanesimport { 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 };
}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 を引き締めるシグナルです。