[ Content Generation ]

Block Schema と Zod — 生成 AI 出力をバリデーションする型安全パイプライン

AI が生成する JSON が形式崩れを起こしても後段を止めないために、Block Schema と Zod をパイプラインの境界に挟む設計を montage の実例で解説する。

Author: 森本拓見
#claude-code #ai-driven-dev #validation #zod #typescript

はじめに

生成 AI が出力する JSON は、ほとんどの場合「それらしい形」をしている。しかし「それらしい」は「正しい」ではない。items が空配列だったり、必須フィールドが null になっていたり、数値であるべきフィールドに文字列が入っていたりする。

問題は、そのまま後段のコンポーネントに渡るとサイレントに壊れることだ。エラーが出ればまだいい。レンダリングが無音で崩れるほうが発見が遅れる。

montage の制作パイプラインはこの問題を Block Schema + Zod で対処している。AI の出力を信頼しない前提で設計した型安全な境界を、実装の具体と合わせて書く。


課題: 型情報なしでパイプラインに流すと後段で壊れる

montage の Composer エージェントは動画の各シーンに「コンテンツブロック」を配置する。棒グラフ・折れ線グラフ・指標カード・テキストブロックなど 40 種以上のブロックタイプがある。Composer は Claude に対して「このシーンには bar-chart ブロックが適切だ」と判断させ、そのデータ構造を JSON で出力させる。

型検証なしに動かしたとき、実際に起きた問題がいくつかある。

  • items フィールドが空配列で渡り、グラフコンポーネントが Cannot read property 'length' of undefined で落ちる
  • value に数値ではなく "100万円" という文字列が入り、計算処理でサイレントに NaN になる
  • series フィールドが丸ごと欠落し、折れ線グラフが白紙になる

いずれも TypeScript のコンパイルは通る。AI の出力は unknown 型で受け取るしかないからだ。「型は書いた。でも実行時に壊れた」という状況が繰り返された。


アプローチ: Block Schema を定義 + Zod でバリデーション

解決策はシンプルだ。ブロックタイプごとに Zod スキーマを定義し、AI の出力をパイプラインに流す前に必ずバリデーションを通す

設計の骨格は次の 3 要素で成り立っている。

  1. Block Schema(仕様書) — 各ブロックタイプのデータ構造を Markdown で定義する(specs/system/block-schemas.md)。これは Claude へのプロンプト文書でもある
  2. Zod スキーマ(検証コード) — 仕様書と対応する形で TypeScript に実装する(src/types/block-data.ts
  3. BLOCK_DATA_SCHEMAS レジストリ — ブロックタイプ名をキーに Zod スキーマをマップするオブジェクト。validateBlockData() 関数がここを引く

仕様書とコードを別管理することに違和感を持つかもしれない。しかしこの構成には理由がある。仕様書は Claude が読む文書で、Zod コードは TypeScript が読むコードだ。両者の役割は異なる。仕様書が変わったらコードも変える、という規律をチームで守れば二重管理のコストより整合性の利益が上回る。


実例: 動画ブロックのスキーマ定義パターン

棒グラフ(bar-chart)を例に、仕様書とコードがどう対応するかを示す。

仕様書(block-schemas.md)側の定義:

### bar-chart

| field         | type      | required | 説明                     |
|---|---|---|---|
| `items`       | `BarItem[]` | ✓      | データ配列(1件以上必須) |
| `items[].label` | `string`  | ✓      | カテゴリラベル           |
| `items[].value` | `number`  | ✓      | 値                       |
| `variant`     | `"vertical" \| "horizontal"` |   | バー方向 |

Zod 実装(block-data.ts)側の定義:

export const BarChartDataSchema = z.object({
  title: z.string().optional(),
  items: z
    .array(
      z.object({
        label: z.string(),
        value: z.number(),
        previousValue: z.number().optional(),
        highlight: z.boolean().optional(),
        color: z.string().nullable().optional(),
        annotation: z.string().nullable().optional(),
      })
    )
    .min(1),              // 空配列を弾く
  variant: z.enum(["vertical", "horizontal"]).optional(),
  showGrid: z.boolean().optional(),
  sorted: z.enum(["none", "asc", "desc"]).optional(),
  referenceLines: z.array(ReferenceLineSchema).optional(),
});

.min(1) の 1 行が、空配列による後段クラッシュをコードレベルで防いでいる。

レジストリはシンプルなマップ:

export const BLOCK_DATA_SCHEMAS: Partial<Record<ContentBlockType, z.ZodType>> = {
  "bar-chart": BarChartDataSchema,
  "line-chart": LineChartDataSchema,
  // ... 40+ ブロックタイプ
};

バリデーション関数:

export function validateBlockData(
  type: ContentBlockType,
  data: unknown
): { success: boolean; error?: string } {
  const schema = BLOCK_DATA_SCHEMAS[type];
  if (!schema) return { success: true };   // 未定義タイプはスルー
  const result = schema.safeParse(data);
  if (result.success) return { success: true };
  return { success: false, error: result.error.message };
}

safeParse を使うことで例外を投げずに検証結果を返す。パイプラインの呼び出し側が success: false を受け取ったときにどう扱うかを制御できる。


バリデーション失敗時の Post-hook リトライへの連動

バリデーションに失敗したとき、montage は単純にエラーで止めるのではなく Post-hook リトライに連動させている。

Composer が出力したブロックデータをバリデーションした結果 success: false だった場合、エラーメッセージを Composer に渡して再生成を依頼する。Zod のエラーメッセージにはどのフィールドが不正だったかが含まれるため、AI は次の試行で修正できる。

バリデーション失敗: items[0].value に数値が必要ですが string が渡されました
→ Composer に error メッセージを添えて再プロンプト
→ 再生成 → 再バリデーション → 成功したら次ステージへ

リトライの詳細設計(試行上限・fallback ブロックへの置換・監視メトリクス)はパイプライン全体の Post-hook 設計と密接に絡むため、別記事で詳しく扱う予定だ。


運用してわかった効果と落とし穴

効果として大きかった点は 2 つある。

1 つ目は、エラーの早期発見だ。バリデーションを Composer の直後に置くことで、後段の Renderer や Reviewer まで不正データが流れなくなった。どのシーンで何のフィールドが壊れているかがログで即わかる。

2 つ目は、Claude へのプロンプト品質が上がったことだ。Block Schema の仕様書を Composer のシステムプロンプトに含めることで、AI が「このブロックには items が必要で空配列は不可」と把握して出力する。バリデーションが通ることを目標に生成するよう設計されている。

落とし穴として気づいたこともある。

Zod バリデーションが通っても「視覚的に成立しているか」は別問題だ。value: 999999999 は数値として正しいが、グラフに表示すると崩れる。スキーマの役割は型と構造の検証に限定されており、ビジネスルールや表示品質の検証はレイヤーを分ける必要がある。

また、仕様書(Markdown)とコード(Zod)の乖離が起きやすい。仕様書を更新してコードを更新し忘れると、Claude の出力は正しいがバリデーションで落ちるというねじれが生じる。この問題には現時点では人間のレビューで対処しているが、仕様書からコードを自動生成する仕組みを検討中だ。


まとめ + 関連記事

Block Schema + Zod の本質は「AI の出力を信頼しない」という前提の実装化だ。AI は指示通りに出力しようとするが確実ではない。その不確実性をパイプラインの境界でコードとして制御することで、後段の壊れ方を予防できる。

設計のポイントを整理すると:

  • 仕様書(プロンプト文書)と Zod スキーマを対応させて管理する
  • safeParse でエラーを例外でなく値として受け取る
  • 空配列・必須フィールド欠落は .min(1).required() でコードレベルで防ぐ
  • バリデーション失敗は Post-hook リトライに連動させ、サイレント廃棄しない

montage 全体のパイプライン設計についてはYakumo の AI 駆動開発のリアルで全体像を書いている。各ステージの役割分担(Researcher → Analyst → Scriptwriter → Composer → Reviewer)はこの記事の前提になっている。

また、同じ「AI の出力を信頼しない」という発想で実装した Prompt Injection 対策についてはプロンプトインジェクション対策の実装で扱っている。外部データを LLM に渡す際の設計原則と合わせて読んでもらえると理解が深まるはずだ。

ShareShare on X