AIレビューの出力をJSONスキーマで固定すると何が防げるか

レビュー出力を集計や台帳に流すために「JSON で出して」と書き、返ってきた JSON を読んで形式が崩れていることに気付く。欄の型と列挙値をスキーマで固定すると、この崩れは止まる。10通りの誤った出力をスキーマに当てて数えたところ、形式の誤り5件は全部落ち、中身の誤り5件は全部通った。スキーマが固定できるのは欄の型であって、欄に書かれた内容が事実かどうかではない。

この記事の提案を使うのは、レビュー出力を人が読む前に機械で読む必要があるとき(集計、CI への取り込み、台帳への追記)である。

この記事で使う5欄

指摘1件につき5つの欄を書かせる。これはこの記事の測定で使う組み合わせで、どの差分にも当てる基準の形ではない。欄の意味はそれぞれ1文で足りる。file は指摘の対象のファイルのパス、line はその中の行番号、claim は何が問題かを一文で書いたもの、quote はその行の実物の写し、severity は high / medium / low の3値である。

題材は TypeScript のモジュールで、注文の小計・割引・在庫の引き当てを行う。変更前は11ファイル、差分は8ファイル・追加25行・削除16行である。README.md に約束を5条置き、判定基準の出所にした。欠陥は8件仕込んであり、型検査では1件も出ない(npx --yes -p typescript@5 tsc --noEmit --strict は変更前・変更後のどちらも終了コード0。tsc 5.9.3、2026-10-12)。以下の数値はこの題材に対するもので、モデルは走らせていない。数えたのは、作った出力例をスキーマと検査器に通した結果である。

10通りの誤った出力

欠陥の1件を題材にして、同じ指摘を10通りに崩した。1件目から5件目は欄の形式の崩れ、6件目から10件目は形式が整ったまま中身が間違っているものである。基準にした正しい出力は次の形である。

崩す前の1件
{
  "file": "src/seats.ts",
  "line": 21,
  "claim": "reserve が残数を確認せずに在庫を減らす(約束3)",
  "quote": "reserve(sku: string, qty: number): void {",
  "severity": "high"
}

22行のスキーマに当てた結果を並べる。スキーマの検査は、構造化出力が対応している範囲(type / required / enum / additionalProperties: false)だけを実装した。

出力の例 スキーマ 落ちた理由、または通ったあと
1 quote の欄が無い 落とす $.findings[0].quote: 必須の欄が無い
2 severity が critical 落とす 列挙値の違反
3 severity が HIGH 落とす 列挙値の違反
4 line が文字列の "21" 落とす 型が integer ではない
5 suggestion の欄が増えている 落とす スキーマに無い欄
6 file が存在しない src/pricing.ts 通す 受け取り側の検査で拾える
7 line が 9999 通す 受け取り側の検査で拾える
8 line が実在する別の行の 12 通す 受け取り側の検査で拾える
9 quote がそのファイルの別の行の写し 通す 受け取り側の検査で拾える
10 claim が「reserve は減らす前に残数を確認している」 通す 機械では拾えない

形式の5件は全部落ち、中身の5件は全部通った。通った5件のうち4件は後述の検査で拾えて、残る1件は拾えない。10件目は欄の型も列挙値も正しく、file と line が実在し、quote がその行の実物と一致していて、claim だけが事実の逆を書いている。

3件目に注意が要る。大文字の HIGH はこのスキーマの enum では落ちるが、公式ドキュメントは列挙値の大文字小文字を保証しないと書いている(Structured outputs、https://platform.claude.com/docs/en/build-with-claude/structured-outputs、取得日 2026-10-11)。

Structured outputs don’t guarantee the capitalization of string enum and const values. … Compare enum values case-insensitively.

落ちた3件目は「スキーマが守った」のではなく、保証されていない箇所に当たった結果である。受け取り側で小文字へ寄せてから比べる処理を入れる。

スキーマに書けないもの

行番号を「1以上の整数」と書いて止めることはできない。構造化出力が対応していない JSON Schema の指定に minimum と maximum が含まれるためである。同じ理由で minLength も使えないので、claim が空文字列であることもスキーマでは止まらない。

公式ドキュメントは、スキーマに合う出力が返らない場合も挙げている。安全上の拒否がスキーマに優先し(stop_reason: "refusal")、出力が上限に達した場合は次の状態になる。

The output may be incomplete and not match your schema

どちらも 200 が返って課金される。受け取り側は stop_reason を見てから本文を読む必要がある。

成果物

指摘1件のスキーマ

指示文の output_config.format に渡すスキーマ(22行)
{
  "type": "object",
  "properties": {
    "findings": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "file": { "type": "string" },
          "line": { "type": "integer" },
          "claim": { "type": "string" },
          "quote": { "type": "string" },
          "severity": { "type": "string", "enum": ["high", "medium", "low"] }
        },
        "required": ["file", "line", "claim", "quote", "severity"],
        "additionalProperties": false
      }
    }
  },
  "required": ["findings"],
  "additionalProperties": false
}

受け取り側の検査

スキーマが通した誤りのうち、実在性・範囲・引用の一致は機械で拾える。レビュー対象のリポジトリの実物と突き合わせる。

受け取り側の検査(スキーマを通った1件に当てる)
def receive_checks(finding, root):
    path = root / finding["file"]
    if not path.is_file():
        return [f"実在性: {finding['file']} が無い"]
    lines = path.read_text(encoding="utf-8").splitlines()
    n = finding["line"]
    if not (1 <= n <= len(lines)):
        return [f"範囲: {finding['file']} は {len(lines)} 行しかない(line={n})"]
    if finding["quote"].strip() not in lines[n - 1]:
        return [f"引用: {finding['file']}:{n} の実物と一致しない"]
    return []

この3項目で、表の6件目から9件目が落ちる。10件目は落ちない。quote が実物と一致していても、claim がその実物について逆のことを書いていれば検査は通る。欄の値が事実かどうかを機械で判定する経路は、この5欄の中には無い。 事実の判定には、その行が何をしているかの知識が要るので、人か、別のレビューのパスがそれを担う。

スキーマを渡せない環境向けの代替

見出しを固定したテキストで同じ5欄を要求する。スキーマの指定が使えない環境でも、受け取り側の検査はそのまま当てられる。

出力形式の指定(スキーマが使えないとき)
指摘1件ごとに、次の5行をこの順で書く。行の頭の文字列は変えない。
1件も無ければ「指摘なし」とだけ書く。

## 指摘 <通し番号>/<総件数>
- 場所: <ファイルのパス>:<行番号>
- 深刻度: high / medium / low のいずれか1語(小文字)
- 主張: <何が問題か。一文>
- 引用: <その行の実物を1行だけ写す>

severity を小文字の1語に限る指定を本文に書いておくと、列挙値の大文字小文字が保証されない問題を、スキーマの側ではなく指示文の側で受け止められる。

スキーマを足すと何を使うか

出力項目は増えない。増えるのは指示文の長さで、このスキーマは22行・587バイトである。欄を1つ足すと properties に1行増え、required の行が伸びて23行になる。1回のレビューの指示文がもともと40行なら、スキーマを足して約1.5倍になる。固定部として扱える部分なので、同じスキーマを使い回すかぎり差分ごとの追加は無い。

もう1つ増えるのは、スキーマと受け取り側の検査の維持である。欄を足したときに直す箇所が3つになる(スキーマ、検査器、出力形式の指定)。3つのうち1つを直し忘れると、形式が整ったまま検査が素通りする出力が返る。

スキーマを落としていい条件は、出力を人だけが読むときである。 1つの差分について1回レビューさせて、その出力を人が読んで終わりなら、形式の崩れはその場で読み替えられる。スキーマが要るのは、出力を機械が読む段が後ろに控えているときに限る。集計も台帳への追記もしないなら、見出しを固定したテキストで足りる。

指示文の点検

出力をスキーマで固定するときに確かめる項目
  • 欄の型と列挙値をスキーマに書いたか
  • additionalProperties: false を付けて、欄が増えないようにしたか
  • 列挙値を小文字に限る指定を、指示文の本文にも書いたか
  • 受け取り側で列挙値を大文字小文字を区別せずに比べているか
  • 行番号の範囲をスキーマではなく受け取り側で検査しているか
  • ファイルの実在性を受け取り側で検査しているか
  • 引用がその行の実物と一致するかを受け取り側で検査しているか
  • stop_reason を見てから本文を読む形になっているか
  • 欄を足したときに直す3箇所(スキーマ、検査器、出力形式の指定)を控えてあるか
  • スキーマを渡せない環境向けに、見出しを固定したテキスト形式を用意したか
  • 欄の値が事実かどうかを、誰が判定するかを決めたか

おわりに

5欄のレビュー出力を10通りに崩してスキーマに当てると、欄の欠落・列挙値の違反・型の違い・欄の増加の5件は落ち、存在しないファイル・範囲外の行番号・別の行の指定・一致しない引用・事実の逆の主張の5件は通った。スキーマが見ているのは欄の型と必須性と列挙値だけである。

通った5件のうち4件は、レビュー対象の実物と突き合わせる3項目の検査で拾える。残る1件は、欄の形も場所も引用も正しく、主張だけが事実と逆になっている。この1件を拾う経路はスキーマの外に置く。

次に取る行動を1つ挙げる。手元のレビュー出力を1件取り、file と line を開いて quote がその行の実物と一致するかを確かめる。一致しない出力が混ざっていたなら、スキーマの前に受け取り側の検査を書く理由がその場で立つ。