開発記事公開日
LLMの読み取り結果を、検算を通ったときだけ信じる

構造化スタジオで、見積書の算術的な冗長性を利用し、決定論の検算を通過したデータだけを蓄積する設計を紹介します
LLMに見積書のPDFを読ませると、明細まで並んだそれらしいJSONがすぐに返ってきます。手転記が消える光景に見えますが、この出力をそのまま業務データベースに貯めるわけにはいきません。読み間違いは目立たない形で混ざり、いったん混ざると、どのレコードが正しいのか外からは見分けられないからです。誤りの混ざったデータベースは、誤りのない紙の束より役に立ちません。紙は1枚ずつ確かめながら使えますが、疑わしいデータベースは、集計に使う前に全件を疑い直すことになります。社内業務向けに開発している構造化ツールでは、この問題への答えを「検算を通ったときだけ信じる」の一点に置きました。LLMの読み取り結果を決定論の検算に掛け、全て通過したレコードだけをデータベースへ昇格させる設計です。この記事では、その検算を担うホワイトリスト式のAST評価器と、昇格までの責任の分け方を、実装のコードで記録します。
第一対象を見積書に絞った理由
どの文書から始めるかは、需要だけの問題ではありませんでした。社内でもっとも転記の量が多いのは相見積の処理ですが、仕様書の決定ログに残した選定理由はもう一つあり、設計を決めたのはそちらです。見積書は、読み取りの正しさを文書自身の数字で確かめられる、数少ない文書だからです。
見積書の各行では数量×単価=金額が成り立ち、文書全体では明細の金額を足し上げた値が小計に一致します。同じ情報が計算の形で重ねて書かれているわけで、この性質を算術的冗長性と呼んでいます。数字をどこかで写し間違えると、掛け算か足し算のどこかが合わなくなりやすいのです。決定ログはこの性質を、LLM抽出を決定論の検算で補強できる「唯一級」の文書と表現しています。
もちろん、検算が読み間違いを必ず捕まえるわけではありません。数量と金額をつじつまの合う形で同時に読み違えれば計算は通りますし、品目名や日付の誤読はそもそも計算に現れません。そこでエディタは、左に原本のページ画像、右に読み取り結果を並べた対比表示にしてあり、算術の外にある値は人が目で確かめる前提です。検算が引き受けるのは、数字の写し間違いという、量が多く、かつ機械で裏の取れる部分だけです。
読み取れない値をnullで返させる出力契約
検算の手前には、LLM側との約束事があります。現行版は構造化にAPIを使いません。アプリがフレームからプロンプトを生成し、利用者はそれを自分のCopilotに貼り付け、原本ファイルを添付して実行し、返ってきた応答をアプリに貼り戻します。このプロンプトの末尾に、応答の形式を縛る出力契約が必ず入ります。
OUTPUT_CONTRACT = """## 出力契約(厳守)
- 応答は ```json コードブロック1個のみとする。前置き・解説・末尾コメントは一切書かない。
- JSONは必ず {"header": {...}, "lines": [...]} の形とする。明細がない文書では "lines": [] とする。
- スキーマに定義された全フィールドを必ず出力する。文書から読み取れない値は null とする。推測で埋めない。
- 数値フィールドは数値型で出力する(カンマ・通貨記号・単位を付けない)。
- 日付は YYYY-MM-DD 形式の文字列とする。"""
検算との関係が深いのはnullの規約です。読み取れなかった値をnullで返させれば、後段の必須チェックが捕まえ、レコードは未確定のまま止まります。怖いのは空欄ではなく、推測でもっともらしく埋められた値のほうです。こちらは見た目から区別できないため、契約は推測を明文で禁じています。ただ、契約はあくまで紙の約束で、LLMが守り続ける保証はどこにもありません。守られなかったときに検算が最後の網になる、という順序です。
貼り戻し側の扱いも決めてあります。応答から最初のjsonコードブロックを取り、なければ全文をJSONとして解釈を試み、それでも失敗したら生の応答テキストを保存して、修正依頼のプロンプトを生成します。パースの失敗は事故ではなく、想定内の分岐です。
evalを使わないホワイトリスト式の評価器
検算ルールは、スキーマや抽出指示とともに構造化フレームという再利用単位に保存されます。ルールの型は6つで、必須、型、正規表現、許容値の4型が形式を、arithmeticとsumの2型が算術を検査します。算術の式は「計算式 == 対象フィールド」の形で書き、行の中の検算は lines[].数量 * lines[].単価 == lines[].金額、明細をまたぐ集計は sum(lines[].金額) == header.小計 のように表します。右辺の実値がactual、左辺の計算値がexpectedです。
この式は、つまりデータベースに入っている文字列です。Pythonのevalに渡せば、評価器は一行で書けます。書けはします。ただ、その一行は「データベース内の文字列をコードとして実行する」設計であり、フレームを部門間で配布する構想(将来版)まで考えると、式は信用できない入力として扱うほかありません。
そこで、evalもexecも使わず、構文木を許可リストで検査してから自前の再帰評価器で計算します。許可されるのは、四則演算と単項の符号、数値リテラル、フィールド参照、5つの関数(sum、round、floor、ceil、abs)、それに式の最上位へ1回だけ置ける==です。べき乗は許可リストに入れていません。四則だけなら値の桁数は入力の桁数に縛られますが、べき乗は短い式で桁数を爆発させられるからです。内包表記もlambdaも、アンダースコアで始まる名前による属性の掘り出しも、構文木の検査の段階で例外になります。
_ALLOWED_FUNCS = {"sum", "round", "floor", "ceil", "abs"}
# DoS対策: 式の複雑さと数値文字列長の上限
MAX_EXPR_NODES = 200
MAX_NUMERIC_STR_LEN = 50
def parse(expr: str) -> ast.Expression:
"""式を解析し、許可外の構文があれば ExprError を送出する。"""
tree = ast.parse(expr, mode="eval")
all_nodes = list(ast.walk(tree))
if len(all_nodes) > MAX_EXPR_NODES:
raise ExprError(f"式が複雑すぎます(要素数 {len(all_nodes)} > {MAX_EXPR_NODES})")
for node in all_nodes:
if not isinstance(node, _ALLOWED_NODES):
raise ExprError(f"式で許可されない構文です: {type(node).__name__}")
# 以下、リテラルは数値のみ、関数は許可5種のみ、属性参照は header/lines のみ、
# _ で始まる名前の拒否、== は最上位1回のみ、を検査して通過した木だけ返す(抜粋)
エラーは2種類に分けています。式そのものが不正なExprErrorは定義エラーで、フレーム保存時の検証が弾きます。式は妥当でも値が取れないEvalFailure(フィールドの欠落、nullや数値でない文字列、ゼロ除算)は検算failとして報告し、レコードを未確定に留めます。さらに、壊れた1ルールが検算バッチ全体を落とさないよう、実行側はRecursionErrorやMemoryErrorも1ルール分のfailに閉じ込めます。括弧を2000重に重ねたネスト爆弾の式も、5000桁の数値文字列も、クラッシュではなく検算failに落ちることをテストで固定しています。
数の扱いには業務側の事情が入っています。丸めにはPython組込のroundを使いません。組込は0.5を偶数側へ寄せる銀行丸めで、見積書の商慣習である四捨五入と食い違うため、Decimalで常に絶対値の大きい側へ丸める実装を別に持ちます。比較の許容差は既定で±1円。端数処理のタイミングの違いで1円ずれる帳票は珍しくないだろうという読みで、ルール単位で±0にも相対値にも変えられます。
検算を全て通過したレコードだけの昇格
読み取り結果は、まず構造化案として保存されます。状態は3つです。スキーマ違反か検算failが残っていればdraft、スキーマに適合して検算が全てpassedならvalid、確定データとして登録済みならpromoted。昇格の入口は、validでなければ例外で閉じます。
def promote(conn: sqlite3.Connection, document_id: int, project_id: int) -> None:
instance = conn.execute(
"SELECT * FROM instances WHERE document_id = ?", (document_id,)
).fetchone()
if instance is None or instance["status"] != "valid":
raise DomainError("NOT_VALID", "検算を全て通過したデータのみ昇格できます")
# 以下、案件の存在を確認し、文書への案件の紐付けと status = 'promoted' への
# 更新を一つのトランザクションで行う
昇格には案件への紐付けが必須で、昇格済みのレコードは編集がロックされます。直したくなったら昇格解除という明示操作で戻しますが、このとき状態は機械的にvalidへ戻すのではなく、検算を掛け直した結果で決め直します。「validは常に検算の直近の結果である」という状態の意味を、どの経路でも崩さないためです。
同じ検査は、フレームを作る入口にも置いてあります。フレームには入力と期待出力のペア(Golden Sample)を添付しますが、この期待出力がスキーマ検証と検算を通らない場合、フレーム版そのものを保存できません。動かない検算ルールを持ったフレームが生まれない仕掛けです。
テストが固定しているのは、検算に落ちるペイロードでの昇格が例外で拒否されること、__import__ や open や getattr を含む式が定義の段階で弾かれること、通過したペイロードだけがpromotedへ進むことです。決定ログはこの一線の理由を「貯めたデータが信用できることが後段活用の大前提」と書いています。実際、相見積2文書の突合比較が対象にするのは昇格済みの文書だけです。

読む係と検算する係と確かめる係の分担
冒頭の、間違いの混ざったデータベースに戻ります。レコードを信じるかどうかを毎回人が判断するのでは、転記の手間が検証の手間に化けるだけです。この設計では、判断を三者に割り振りました。LLMは読む係で、読み取れない値をnullにする契約だけを負います。決定論のコードは検算する係で、算術的冗長性が裏を取れる範囲について、通すか止めるかを機械的に裁きます。人は確かめる係で、品目名のように計算へ現れない値を原本対比で見て、昇格の操作を行います。データベースに入るのは、この三段を抜けたレコードだけです。
この仕組みを組み込んだ「構造化スタジオ」は、BenriWorksの社内業務のために開発しているWindowsデスクトップツールで、一般向けの提供は行っていません。検算はあくまで数字の裏取りで、つじつまの合った読み違いのように原理的にすり抜ける誤りは残ります。それが実運用でどの程度の頻度なのか、±1円という既定の許容差は適切なのかは、貯まったデータでしか確かめられません。運用の数字が揃ったら、その答え合わせを書きます。





