0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIにHTMLを生成させない。Reactのblock schemaで生成UIの境界を作る

0
Posted at

短いpromptからdashboardのHTMLを出すところまでは、もう難しくありません。困るのはそのあとです。

許可したtagはどれか。勝手なURLやevent handlerが混ざっていないか。layoutが崩れたとき、promptとCSSと生成結果のどこを直すのか。画面が一度出たことより、毎回reviewできることのほうが重要になります。

自分なら、modelにはHTMLもJSXも返させません。返させるのは、appが用意したblockを選ぶ小さなJSON documentだけです。

model output (unknown)
  -> BlockDocumentSchema.safeParse()
  -> Block[]
  -> renderer registry
  -> React elements

modelの仕事は、appが許可したUI vocabularyを選ぶところまでです。その先の境界をReact、TypeScript、Zodで作ります。

まずmodelを外す

生成UIの実装で先にmodel SDKへつなぐと、schemaの問題とmodel出力の揺れを同時にdebugすることになります。最初は固定fixtureで十分です。

React + TypeScriptのprojectへZodを追加します。test例ではVitestを使います。

pnpm add zod
pnpm add -D vitest

最初に許すblockは3種類だけにします。

  • heading: セクション見出し
  • paragraph: 本文
  • stat: ラベル付きの数値や短い値

link、image、button、入れ子layoutはまだ入れません。便利なblockほど、URL、権限、深さといった別のpolicyが必要になるからです。

const fixture: unknown = {
  schemaVersion: 1,
  blocks: [
    {
      id: "heading-1",
      type: "heading",
      level: 2,
      text: "今週の利用状況",
    },
    {
      id: "paragraph-1",
      type: "paragraph",
      text: "集計済みのイベントだけを表示しています。",
    },
    {
      id: "stat-1",
      type: "stat",
      label: "完了したtask",
      value: "18",
      tone: "positive",
    },
  ],
};

ここでは型を付けず、意図的にunknownで受けます。外部modelの返り値は、TypeScriptの型注釈を書いても実行時には信用できません。

Zodを型の正本にする

schemaは「このappが描画してよいか」を判定します。JSONとして読めるだけでは通しません。

import { z } from "zod";

const BlockIdSchema = z.string().trim().min(1).max(64);

const HeadingBlockSchema = z
  .object({
    id: BlockIdSchema,
    type: z.literal("heading"),
    level: z.union([z.literal(2), z.literal(3)]),
    text: z.string().trim().min(1).max(80),
  })
  .strict();

const ParagraphBlockSchema = z
  .object({
    id: BlockIdSchema,
    type: z.literal("paragraph"),
    text: z.string().trim().min(1).max(600),
  })
  .strict();

const StatBlockSchema = z
  .object({
    id: BlockIdSchema,
    type: z.literal("stat"),
    label: z.string().trim().min(1).max(40),
    value: z.string().trim().min(1).max(80),
    tone: z.enum(["neutral", "positive", "warning"]),
  })
  .strict();

export const BlockSchema = z.discriminatedUnion("type", [
  HeadingBlockSchema,
  ParagraphBlockSchema,
  StatBlockSchema,
]);

export const BlockDocumentSchema = z
  .object({
    schemaVersion: z.literal(1),
    blocks: z.array(BlockSchema).min(1).max(12),
  })
  .strict();

export type Block = z.infer<typeof BlockSchema>;
export type BlockDocument = z.infer<typeof BlockDocumentSchema>;

.strict()を付けたのは、modelが足した未知fieldを黙って捨てないためです。たとえばparagraphhtmlが混ざったらparseを失敗させます。受け入れたつもりのないfieldを裏側に残すより、schemaの変更としてreviewしたほうが追えます。

文字数やblock数の上限は記事用の例です。実際の値は画面とproduct要件に合わせます。ただし、上限そのものは早い段階で入れます。構造が正しくても、数千blockを一度に描画してよいとは限りません。

z.inferからBlockを作るので、同じunionをTypeScript側へ手書きしません。schemaと型が別々に育つ状態を避けられます。

renderer registryで描画先を固定する

検証後のblockは、登録済みのReact componentだけへ渡します。文字列からcomponent名を組み立てたり、動的importしたりはしません。

import { Fragment, type ReactNode } from "react";
import type { Block } from "./block-schema";

type BlockType = Block["type"];
type BlockOf<K extends BlockType> = Extract<Block, { type: K }>;

type RendererMap = {
  [K in BlockType]: (block: BlockOf<K>) => ReactNode;
};

const renderers = {
  heading: (block) =>
    block.level === 2 ? (
      <h2>{block.text}</h2>
    ) : (
      <h3>{block.text}</h3>
    ),

  paragraph: (block) => <p>{block.text}</p>,

  stat: (block) => (
    <dl data-tone={block.tone}>
      <dt>{block.label}</dt>
      <dd>{block.value}</dd>
    </dl>
  ),
} satisfies RendererMap;

function assertNever(value: never): never {
  throw new Error(`Unhandled block: ${JSON.stringify(value)}`);
}

function renderBlock(block: Block): ReactNode {
  switch (block.type) {
    case "heading":
      return renderers.heading(block);
    case "paragraph":
      return renderers.paragraph(block);
    case "stat":
      return renderers.stat(block);
    default:
      return assertNever(block);
  }
}

export function BlockList({ blocks }: { blocks: Block[] }) {
  return (
    <section>
      {blocks.map((block) => (
        <Fragment key={block.id}>{renderBlock(block)}</Fragment>
      ))}
    </section>
  );
}

RendererMapは、各typeに対応するrendererを要求します。schemaへimageを追加してregistryを更新し忘れると、satisfies RendererMapでcompile errorになります。switch側もassertNeverで追加漏れを検出できます。

少し重複して見えますが、ここは自動的に丸めないほうがよい場所です。block追加のdiffに「入力schema」と「描画component」が並ぶので、reviewで変更範囲を確認できます。

Reactはblock.textをtext nodeとして扱います。raw HTMLを通す経路がないため、dangerouslySetInnerHTMLも不要です。未登録のtypeを汎用HTML rendererへ逃がすと、この境界が消えます。

parse失敗をUIの状態として返す

safeParse()が失敗したときに空配列を返すと、ユーザーからは「生成結果が空だった」のか「検証に失敗した」のか分かりません。不正なdocumentはfallbackへ分けます。

import {
  BlockDocumentSchema,
  type BlockDocument,
} from "./block-schema";
import { BlockList } from "./block-list";

type ParseIssue = {
  path: string;
  code: string;
};

type ParsedDocument =
  | {
      ok: true;
      value: BlockDocument;
    }
  | {
      ok: false;
      issues: ParseIssue[];
    };

export function parseBlockDocument(raw: unknown): ParsedDocument {
  const result = BlockDocumentSchema.safeParse(raw);

  if (result.success) {
    return { ok: true, value: result.data };
  }

  return {
    ok: false,
    issues: result.error.issues.map((issue) => ({
      path: issue.path.join("."),
      code: issue.code,
    })),
  };
}

function InvalidGeneratedView({ onRetry }: { onRetry: () => void }) {
  return (
    <section role="alert">
      <p>表示を組み立てられませんでした。</p>
      <button type="button" onClick={onRetry}>
        もう一度生成する
      </button>
    </section>
  );
}

export function GeneratedView({
  raw,
  onRetry,
}: {
  raw: unknown;
  onRetry: () => void;
}) {
  const parsed = parseBlockDocument(raw);

  if (!parsed.ok) {
    return <InvalidGeneratedView onRetry={onRetry} />;
  }

  return <BlockList blocks={parsed.value.blocks} />;
}

上の例ではparseBlockDocument()をcomponentから呼んでいます。productionでは、model responseを受け取る境界で一度だけparseし、成功値か失敗値をstateへ保存するほうが扱いやすいです。renderのたびに同じerror logを送る事故も避けられます。

開発用のlogに残すなら、Zodのissueからpathcode、schema version、request IDあたりに絞ります。prompt全文や秘密値をそのまま保存する必要はありません。同じ不正出力に対する自動retryにも上限を持たせます。

なお、Zodが確認するのは構造です。認可、prompt injection対策、URLの安全性、外部resourceのsandboxまで面倒を見てくれるわけではありません。

modelを呼ばずに壊れ方をtestする

生成UIはhappy pathのscreenshotより、拒否したい入力をfixtureにしたほうが境界を確認しやすいです。

import { describe, expect, it } from "vitest";
import { parseBlockDocument } from "./generated-view";

const validDocument: unknown = {
  schemaVersion: 1,
  blocks: [
    { id: "h-1", type: "heading", level: 2, text: "利用状況" },
    { id: "p-1", type: "paragraph", text: "直近7日間の集計です。" },
    {
      id: "s-1",
      type: "stat",
      label: "完了したtask",
      value: "18",
      tone: "positive",
    },
  ],
};

describe("parseBlockDocument", () => {
  it("正常なblockの順序を保つ", () => {
    const result = parseBlockDocument(validDocument);

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.blocks.map((block) => block.id)).toEqual([
        "h-1",
        "p-1",
        "s-1",
      ]);
    }
  });

  it("未知のtypeを拒否する", () => {
    const result = parseBlockDocument({
      schemaVersion: 1,
      blocks: [{ id: "x-1", type: "script", code: "alert(1)" }],
    });

    expect(result.ok).toBe(false);
  });

  it("必須propの欠落を拒否する", () => {
    const result = parseBlockDocument({
      schemaVersion: 1,
      blocks: [
        {
          id: "s-1",
          type: "stat",
          label: "完了したtask",
          tone: "neutral",
        },
      ],
    });

    expect(result.ok).toBe(false);
  });

  it("長すぎるtextを拒否する", () => {
    const result = parseBlockDocument({
      schemaVersion: 1,
      blocks: [
        {
          id: "p-1",
          type: "paragraph",
          text: "a".repeat(601),
        },
      ],
    });

    expect(result.ok).toBe(false);
  });

  it("block数の上限を超えたdocumentを拒否する", () => {
    const blocks = Array.from({ length: 13 }, (_, index) => ({
      id: `p-${index}`,
      type: "paragraph" as const,
      text: `paragraph ${index}`,
    }));

    const result = parseBlockDocument({ schemaVersion: 1, blocks });

    expect(result.ok).toBe(false);
  });

  it("未知fieldを拒否する", () => {
    const result = parseBlockDocument({
      schemaVersion: 1,
      blocks: [
        {
          id: "p-1",
          type: "paragraph",
          text: "表示用の本文",
          html: "<img src=x onerror=alert(1)>",
        },
      ],
    });

    expect(result.ok).toBe(false);
  });
});

このtestはmodelの応答品質を測っていません。appが何を受け入れ、何をrendererへ渡さないかを固定しています。modelやproviderを交換しても残るtestです。

schemaとregistryの対応漏れはruntime testではなくTypeScriptに任せます。BlockSchemaへ新しいtypeを足した状態でtypecheckを走らせ、registryとswitchの両方が更新されるまでcompileを通さない運用にします。

link、image、actionは別々に許可する

3 blockで境界が動いてから、必要なものだけ追加します。自分なら一度に増やしません。

block 追加前に決めること
link https:限定か、許可originを持つか、外部遷移をどう表示するか
image 許可origin、width / height上限、alt、proxyの有無
action 表示と実行を分離し、権限、確認、二重実行防止をどう持つか
nested layout 最大depth、子要素数、循環を作れないschema

とくにactionは表示blockの延長で作らないほうが安全です。「削除ボタンを表示できる」と「削除を実行してよい」は別のcontractです。実行側には認可と確認を残します。

imageやlinkを追加しても、modelが返したURLをそのまま信用はしません。Zodで文字列の形を確認したうえで、schemeやoriginのpolicyを通します。remote HTMLを表示したくなったら、通常のblock rendererとは別のsandbox境界として設計します。

modelをつなぐのは最後でいい

fixtureで次の流れが通ったら、modelのstructured outputをBlockDocumentSchemaへ接続します。

unknownを受ける
  -> schemaで拒否できる
  -> 許可済みcomponentだけを描画する
  -> invalidならfallbackを返す
  -> failure fixtureで境界を固定する

この順番なら、model SDKやagent runtimeを変えても、schema、registry、fallback、testはapp側に残ります。生成結果の自由度をproviderへ預けていないからです。

生成UIでは、巧妙なpromptより先に「生成してよいblockの一覧」を書きます。自分ならheadingparagraphstatから始めます。それで足りなくなったときに、policyとtestを添えて1種類ずつ増やします。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?