Jevで仕訳の起票候補を作る|証憑から科目・税区分・金額を決める
/ 22 min read
Table of Contents
はじめに
経費精算の起票は、1件ずつ見れば難しくありません。領収書を見て、科目を決めて、税区分を決めて、金額を転記する。それだけです。
問題は件数です。月末に数百件が積み上がり、同じ判断を繰り返すことになります。そして**転記ミスが一番痛い。**科目の付け間違いは後から振り替えられますが、桁を間違えた金額は、誰かが気づくまで残ります。
ここで生成AIに「この領収書から仕訳を作って」と頼むと、別の問題が起きます。金額を書き換えてしまうことがあるからです。132,000円が13,200円になっていても、出力されたJSONはそれらしい形をしています。検算を通しても貸借は一致します。
Jevはこの用途に向いています。理由は逆説的で、Jevは文章を生成できないからです。
この記事では、証憑テキストから仕訳の起票候補を組み立てます。Jev入門で確認した3つの質問型と、会計・監査での使い方で決めた線引きをそのまま使います。
Jevは仕訳を「生成」しない
先に前提を確認します。Jevが返せるのは Choice / Score / Noul の3つだけです。公式ドキュメントにも「文章生成用に訓練されていない」と明記されています。
つまり、こういう出力は構造的に返ってきません。
{ "借方科目": "交際費", "金額": 132000 }ところが、仕訳を分解してみるとほとんどが閉じた選択肢です。
| 項目 | 担当 | 方法 |
|---|---|---|
| 借方・貸方の勘定科目 | Jev | Choice(科目マスタが選択肢) |
| 補助科目・部門・取引先 | Jev | Choice |
| 税区分 | Jev | Choice |
| 金額 | 正規表現 + Jev | 候補を拾い、どれが総額かを選ばせる |
| 日付 | 正規表現 + Jev | 同上 |
| 消費税額・本体価額 | コード | Jevは計算しない |
| 貸借一致の検算 | コード | 同上 |
| 摘要文 | 定型か別モデル | Jevは生成しない |
仕訳を組み立てるのはコードで、Jevは各欄の値を選ぶだけという役割分担になります。
金額はモデルに書かせない
ここがこの記事の中心です。公式クックブックに「pre-parsed value extraction」というパターンがあります。手順は3段階です。
- 正規表現で金額らしき文字列を多めに拾う
- Jevの
Choiceに「どれが支払総額か」を聞く - コードが選ばれた文字列をそのままコピーして数値化する
ドキュメントの表現を借りると、「返ってくる値は正規表現が見つけたスパンのひとつが、変更されずにコピーされたもの」です。Jevは候補の中からしか選べないので、存在しない金額が出てくることがありません。
領収書のテキストを用意します。
RECEIPT = """領収書2026年3月31日株式会社大和田商事 御中ご飲食代として小計 120,000円消費税(10%) 12,000円合計 132,000円上記正に領収いたしました銀座あけぼの店 TEL 03-1234-5678登録番号 T1234567890123"""候補を拾います。
import re
AMOUNT_RE = re.compile(r"[¥¥]?\d[\d,]*\s*円|[¥¥]\s*\d[\d,]*")
def find_amounts(text: str) -> list[str]: """金額らしき文字列を多めに拾う。絞り込みはしない。""" return list(dict.fromkeys(m.group().strip() for m in AMOUNT_RE.finditer(text)))
def to_int(amount: str) -> int: return int(re.sub(r"[^\d]", "", amount))>>> find_amounts(RECEIPT)['120,000円', '12,000円', '132,000円']電話番号の 03-1234-5678、消費税(10%) の 10、登録番号の数字はすべて除外されています。円 か ¥ を必須にしているだけですが、これで大半のノイズは落ちます。
勘定科目は二段階で選ばせる
Choice の選択肢は255件までです。勘定科目マスタは、補助科目まで含めると普通に超えます。
しかもSDKは件数をチェックしません。300件渡しても、クライアント側ではそのままリクエストが組み立てられます。自分でガードを入れておきます。
MAX_CHOICE_OPTIONS = 255
def ensure_choice_size(criteria: dict) -> None: if len(criteria) > MAX_CHOICE_OPTIONS: raise ValueError(f"選択肢が{len(criteria)}件。Choiceは{MAX_CHOICE_OPTIONS}件まで。段階を分ける")超える場合は、公式も推奨している二段階選択にします。大分類を選ばせてから、その中の科目を選ばせます。
ACCOUNT_GROUPS = { "entertainment_group": "取引先との飲食・贈答・供応に関する費用", "travel_group": "出張・移動にかかる交通費と宿泊費", "supplies_group": "文具・備品などの消耗品の購入", "other_group": "上記のいずれにも当てはまらない",}
ACCOUNTS_BY_GROUP = { "entertainment_group": { "交際費": "取引先の接待・供応・贈答", "会議費": "社内または取引先との打合せにかかる飲食", "福利厚生費": "役員・従業員のみを対象とする慰安・懇親", }, "travel_group": { "旅費交通費": "出張・移動の実費", "通勤費": "従業員の通勤にかかる定期代", }, "supplies_group": { "消耗品費": "文具・少額の備品", "事務用品費": "事務用の消耗品", }, "other_group": {"雑費": "他の科目に当てはまらない少額の費用"},}大分類の説明文は、科目の区別がつく書き方にします。「交際費」と「会議費」と「福利厚生費」は、実務では誰が参加したかで分かれます。その基準を criteria の説明に書いておかないと、Jevは摘要の字面だけで選びます。
1段階目:金額・大分類・税区分をまとめて聞く
from typesafe_sdk import Choice, TypeSafeClient
TAX_CLASSES = { "課税仕入10": "標準税率10%の課税仕入れ", "課税仕入8": "軽減税率8%の課税仕入れ(飲食料品・新聞)", "非課税仕入": "非課税とされる仕入れ", "対象外": "消費税の課税対象外",}
def first_pass(client: TypeSafeClient, text: str, amounts: list[str]): return client.system_one( state={"証憑": text, "金額候補": amounts}, questions={ "total": Choice( instructions="候補のうち、当社が支払う税込の総額はどれか", criteria={a: None for a in amounts}, ), "group": Choice( instructions="この支出が属する勘定科目の大分類はどれか", criteria=ACCOUNT_GROUPS, ), "tax_class": Choice( instructions="この仕入れに適用される消費税の税区分はどれか", criteria=TAX_CLASSES, ), }, )criteria={a: None for a in amounts} としているのは、金額候補には説明が要らないからです。Choice の criteria は値に None を許容します。選択肢の名前そのものが情報なので、余計な説明を足さないほうが素直に選ばれます。
state に証憑の全文と候補リストの両方を入れているのもポイントです。候補だけ渡すと「合計」の行がどれか分かりません。文脈と候補をセットで渡します。
2段階目:大分類の中から科目を選ぶ
def second_pass(client: TypeSafeClient, text: str, group: str): return client.system_one( state={"証憑": text}, questions={ "account": Choice( instructions="この支出の勘定科目はどれか", criteria=ACCOUNTS_BY_GROUP[group], ) }, )税額はコードで計算する
Jevは計算しません。税込総額と税区分が決まったら、税額と本体価額はこちらで出します。
from decimal import ROUND_DOWN, Decimal
TAX_RATES = { "課税仕入10": Decimal("0.10"), "課税仕入8": Decimal("0.08"), "非課税仕入": Decimal("0"), "対象外": Decimal("0"),}
rate = TAX_RATES[tax_class]tax = int((Decimal(total) * rate / (1 + rate)).quantize(Decimal("1"), rounding=ROUND_DOWN))base = total - taxfloat ではなく Decimal を使います。132000 * 0.1 / 1.1 を float でやると誤差が出ます。端数処理も、切捨てか四捨五入かを明示的に指定します。会社の処理方法に合わせてください。
組み立てる
ここまでをまとめます。
def build_entry(client: TypeSafeClient, text: str) -> dict: amounts = find_amounts(text) if not amounts: return {"status": "金額候補なし", "confidence": 0.0}
r1 = first_pass(client, text, amounts) group = r1.answers["group"].choice r2 = second_pass(client, text, group)
total = to_int(r1.answers["total"].choice) tax_class = r1.answers["tax_class"].choice rate = TAX_RATES[tax_class]
# 税額はコードで計算する。Jevには計算させない tax = int((Decimal(total) * rate / (1 + rate)).quantize(Decimal("1"), rounding=ROUND_DOWN)) base = total - tax
confidences = { "total": r1.answers["total"].confidence, "group": r1.answers["group"].confidence, "tax_class": r1.answers["tax_class"].confidence, "account": r2.answers["account"].confidence, } return { "debit_account": r2.answers["account"].choice, "debit_amount": base, "tax_account": "仮払消費税等", "tax_amount": tax, "credit_account": "未払金", "credit_amount": total, "tax_class": tax_class, "confidence": min(confidences.values()), "confidences": confidences, "model": r1.model, }実行すると、こうなります。
=== 起票候補 === debit_account 交際費 debit_amount 120000 tax_account 仮払消費税等 tax_amount 12000 credit_account 未払金 credit_amount 132000 tax_class 課税仕入10
各項目のconfidence: {'total': 0.895, 'group': 0.907, 'tax_class': 0.907, 'account': 0.895}採用するconfidence(最小値): 0.895confidence は最小値を採る
confidence を項目ごとに持っておいて、採用するのは最小値にします。
def route(entry: dict) -> str: if entry["confidence"] < 0.7: return "人が起票" if entry["confidence"] < 0.95: return "起票候補(要承認)" return "自動起票"科目が0.98でも税区分が0.55なら、その仕訳は0.55の仕訳です。平均を取ると、弱い判断が強い判断に埋もれます。公式のfunction callingクックブックでも「confidenceは呼び出し全体で最も弱い判断を反映する」とされています。
confidences を辞書で残しておくと、どの項目で引っかかったのかが分かります。承認画面でその欄だけ強調すれば、確認する人の負担が減ります。
検算で捕まるもの、捕まらないもの
起票前に必ず検算を通します。
def check(entry: dict) -> list[str]: """コード側の検算。ここを通らない仕訳は起票しない。""" errors = [] debit = entry["debit_amount"] + entry["tax_amount"] if debit != entry["credit_amount"]: errors.append(f"貸借不一致: 借方 {debit} / 貸方 {entry['credit_amount']}") if entry["debit_amount"] <= 0: errors.append("本体価額が0以下") rate = TAX_RATES[entry["tax_class"]] expected = int((Decimal(entry["credit_amount"]) * rate / (1 + rate)).quantize(Decimal("1"), rounding=ROUND_DOWN)) if entry["tax_amount"] != expected: errors.append(f"税額不一致: {entry['tax_amount']} / 期待値 {expected}") return errors税区分の取り違えは、これで捕まります。10%の取引を8%と判定した仕訳を通すと、こうなります。
税区分を8%と誤認した仕訳の検算: ['税額不一致: 10909 / 期待値 8888']**問題は、捕まらないほうです。**Jevが「合計 132,000円」ではなく「小計 120,000円」を総額として選んだ場合、そこから計算した本体価額と税額は当然に整合します。
小計(120,000)を総額と誤認した仕訳の検算: OK ← 通ってしまう対策として、**証憑の側で金額の関係を検算します。**領収書の候補に「a + b = c」を満たす組があれば、その c が総額である可能性が高い、という当たりの付け方ができます。
def sum_consistent_total(amounts: list[str]) -> int | None: """候補の中に a + b = c を満たす組があれば、その c が総額の可能性が高い。""" vals = sorted({to_int(a) for a in amounts}) found = [c for i, a in enumerate(vals) for b in vals[i:] for c in vals if a + b == c] return max(found) if found else None候補: ['120,000円', '12,000円', '132,000円']小計+税額=合計 を満たす値: 132000Jevの選択 132,000円: OKJevの選択 120,000円: ★不一致 → 人が確認小計と税額が併記されている証憑なら、これだけで取り違えを拾えます。モデルの判断を、モデルとは別の根拠で裏取りするという形です。合計しか書かれていない証憑では使えないので、None のときは素通しになります。
起票までの流れ
まとめるとこうなります。
| 段階 | 担当 | 落ちたときの行き先 |
|---|---|---|
| 金額候補の抽出 | 正規表現 | 候補ゼロ → 人が起票 |
| 総額・大分類・税区分の選択 | Jev | — |
| 勘定科目の選択 | Jev | — |
| 税額・本体価額の計算 | コード | — |
| 貸借・税額の検算 | コード | エラー → 人が起票 |
| 総額の裏取り | コード | 不一致 → 人が確認 |
| confidenceによる振り分け | コード | 0.7未満 → 人が起票 |
| 承認 | 人 | — |
Jevが関与するのは真ん中の2段階だけです。前後はすべてコードと人が持ちます。
注意点
税区分は税務判断です。 軽減税率の適用、交際費の損金算入、インボイスの要件。いずれも証憑の字面だけで決まるものではありません。tax_class の判定は入力の補助であって、判断ではありません。自動起票のしきい値を上げるより、税区分だけは常に人が見る運用のほうが安全だと思います。
証憑の文言に引きずられます。 Jevは入力データを疑いません。摘要や但し書きに指示のような文が入っていれば、それに従う可能性があります。
監査証跡を残します。 起票候補を作った時点の情報を、仕訳と一緒に保存しておきます。
from datetime import datetime
entry["judged_at"] = datetime.now().isoformat()entry["approved_by"] = user_id # 承認した人# entry["model"] にはモデル名(jev-1.13.0)が入っている# entry["confidences"] には項目ごとの確信度が残っている誰が承認したかが一番重要です。Jevが提案した仕訳であっても、帳簿に入った時点でそれは承認した人の仕訳です。提案と承認を1つの画面で済ませると、この区別が曖昧になります。
OCRの精度がそのまま効きます。 ここまでのコードは、証憑がテキストになっている前提です。紙をスキャンして読ませる場合、金額の桁が欠けた時点で候補からも消えます。Jevは画像を扱えないので、OCRは別の手段が必要です。
おわりに
生成AIで仕訳を作る話は以前からありますが、会計データで引っかかるのは決まって金額が書き換わることでした。
Jevの方式は、そこを構造で解いています。**モデルに「書かせる」のではなく「選ばせる」。**選択肢はこちらが用意したものだけ。だから、存在しない科目も、存在しない金額も出てきません。
代わりに、こちらの仕事が増えます。正規表現で候補を漏れなく拾う。科目マスタを255件以内の階層に整理する。税額の端数処理を決める。検算を書く。地味な準備がそのまま精度になる構造です。
ただ、会計の仕組みとしては、そのほうが筋が通っていると思います。何が根拠で、どこで人が判断したのかが、コードを読めば分かるからです。
