はじめに

近年LLMを活用した機能を組み込んだアプリ開発が一般的になってきました。

FlutterでもGoogleのAIフレームワークGenkitが公開され、LLMを扱う機能が実装しやすくなっています。

一方でLLMをアプリに組み込むとき、従来のFlutter開発ではあまり意識してこなかった要件が生じます。それはLLMに構造化された情報を出力させるために、JSON Schemaが必要になるという事です。

ClaudeやGemini等のモデルは出力をどういう形で返すべきかをJSON Schemaで受け取り決定します。つまりこれらのモデルを使用する場合Flutterアプリで扱うDartの型をJSON Schemaに変換できる必要があります。

ところがFlutterで一般的に使用されるfreezedやjson_serializableでは「Dart オブジェクト ↔ JSON 」相互変換コードを作る事はできても、「その型がどういう構造を持つか」という情報を取得する事はできません。またFlutterの開発ではリフレクション機能も使用できない為、実行時に型情報を取り出すこともできません

この問題を解決するのが本記事で紹介するschemanticです。

この記事ではschemanticの概要・基本的な使い方・応用的な使い方・LLMとの連携方法を見ていきます。

前提

  • Dart version 3.10.0以上
  • 下記3つのパッケージ必須
    • schemantic version 0.2.2
    • schemantic_builder 0.1.2
    • build_runner 2.15.1

概要

schemanticとは型安全なデータクラスと実行時JSON Schemaを同時に生成する事ができるDartのライブラリです。

基本的な使い方

  • インストール

下記3つのパッケージを追加します。

dart pub add schemantic
dart pub add dev:schemantic_builder
dart pub add dev:build_runner
  • スキーマの定義
import 'package:schemantic/schemantic.dart';

part 'user.g.dart';

@Schema()
abstract class $User {
  String get name;
  int? get age;
  bool get isAdmin;
}
  • コード生成
dart run build_runner build
  • 実装例
void main() async {
  // コンストラクタ
  final user = User(name: 'Alice', age: 30, isAdmin: true);
  print(user.toJson());
  // {name: Alice, age: 30, isAdmin: true}

  // JSONからのパース
  final parsed = User.fromJson({'name': 'Bob', 'isAdmin': false});
  print(parsed.name); // Bob

  // 実行時にJSON Schemaを取得
  print(User.$schema.jsonSchema());
  // {type: object,
  //  properties: {name: {type: string}, age: {type: integer}, isAdmin: {type: boolean}},
  //  required: [name, isAdmin]}

  // バリデーション
  final errors = await User.$schema.validate({'name': 'Charlie'});
  for (final e in errors) {
    print(e.toErrorString());
    // Required property "isAdmin" is missing at path #root
  }
}

重要なのはUser.$schemaです。これは型と構造を保持するオブジェクトで、
parse/serialize/validate/jsonSchemaの 4 つの操作を提供します。型からJSON Schemaを取り出すにはこのjsonSchemaを使用します。

応用的な使い方

フィールド制約及びキー名マッピング

@Field及び派生(@StringField/@IntegerField/@DoubleField)を使用すると、JSON Schemaに制約を付与する事ができます。

@Schema()
abstract class $User {
  @StringField(minLength: 1, maxLength: 150, pattern: r'^[a-zA-Z\s]+$')
  String get name;

  @IntegerField(
    name: 'years_old', // JSON側のキー名
    description: 'Age of the user',
    minimum: 0,
    maximum: 200,
  )
  int? get age; // Dart側はageのまま

  @Field(description: 'Is this user an admin?')
  bool get isAdmin;
}

生成されるJSON Schemaは次のようになります。

{
    "type": "object",
    "properties": {
        "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 150,
            "pattern": "^[a-zA-Z\\s]+$"
        },
        "years_old": {
            "type": "integer",
            "description": "Age of the user",
            "minimum": 0,
            "maximum": 200
        },
        "isAdmin": {
            "type": "boolean",
            "description": "Is this user an admin?"
        }
    },
    "required": [
        "name",
        "isAdmin"
    ]
}

これらの制約をもとにLLMは出力を制限します。

Union型(AnyOf)

これは1つのフィールドに複数型を許容できる機能です。
型安全性を損なわないように、自動でヘルパーファクトリを生成します。

@Schema()
abstract class $SearchResult {
  String get title;

  // id フィールドは int, Stringのいずれかを許容する
  @AnyOf([int, String])
  Object? get id;
}

@AnyOfを付与すると下記のようにSearchResultというヘルパークラスが生成され、書き込み側は型安全になります。

void main() async {
  // 自動生成された SearchResultId ヘルパーを使って型安全に値をセットする

  // パターン1: IDが整数の場合
  final result1 = SearchResult(title: '記事A', id: SearchResultId.int(101));

  // パターン2: IDが文字列(UUIDなど)の場合
  final result2 = SearchResult(title: '記事B', id: SearchResultId.string('uuid-v4-abc-123'));
}

既存の方法を持ち込む

json_serializable 6.13.0以降であれば、createJsonSchema: trueでJSON Schemaを生成することも可能です。schemanticへの橋渡し(SchemanticType.from)を定義して使用すれば、既存クラスを書き換えることなくschemanticを使用してGenkitのシステムに載せる事が可能です。

@JsonSerializable(createJsonSchema: true)
class Person {
  final String firstName;
  final String lastName;

  Person({required this.firstName, required this.lastName});

  factory Person.fromJson(Map json) => _$PersonFromJson(json);
  Map toJson() => _$PersonToJson(this);

  static const jsonSchema = _$PersonJsonSchema;

  /// schemanticへの橋渡し
  static final schema = SchemanticType.from(
    jsonSchema: Person.jsonSchema,
    parse: (json) => Person.fromJson(json as Map),
    serialize: (person) => person.toJson(),
  );
}

スキーマ生成に対応していないライブラリを使用している場合でも、下記のようにJSON Schemaを直接渡す事が可能です。

final manualSchema = SchemanticType.from(
  jsonSchema: {
    'type': 'object',
    'properties': {
      'foo': {'type': 'string'},
    },
    'required': ['foo'],
  },
  parse: (json) => MyType.fromJson(json as Map),
);

デフォルト実装が基本型・List・Mapを自動的に処理し、それ以外のオブジェクトには toJson() を呼び出します。独自のエンコーディングが必要な場合のみ serialize: を指定してください。

LLMとの連携

Structured Outputsとは

前提となるStructured Outputsの仕組みを整理します。

LLMの出力は基本的にただの文字列です。仮に「特定のデータ構造で出力して」とプロンプトで指示してもそのデータ構造での出力が保証されるわけではありません。

この出力の形式を仕様として保証するのが Structured Outputsであり、LLMに対してAPI経由で渡すものがJSON Schemaです。

これによりアプリ側から次のような処理が不要になります。

  • 正規表現によるJSON の抽出及びコードフェンスの除去
  • 「キーが存在しないかもしれない」という防御的な分岐

Genkit Dartを使ったJSON Schemaの指定方法

これまで学んできたschemanticがどのようにしてLLMの出力に影響を与えるのか、Genkit Dartを活用して簡単に確認します。

スキーマの定義

@Schema(description: '映画のレビュー情報')
abstract class $MovieReview {
  @StringField(description: '映画のタイトル')
  String get title;

  @IntegerField(description: '評価スコア(1〜5点)', minimum: 1, maximum: 5)
  int get rating;

  @Field(description: '見どころや良かった点のリスト')
  List get highlights;

  @StringField(description: '一言レビュー(50文字以内)')
  String get shortReview;
}

このdescriptionは生成されたJSON Schemaに含まれ、そのJSON Schemaがプロンプトに埋め込まれてモデルへ届きます。つまり@Field(description:)はコード上のコメントではなくモデルへの指示になります。もし出力が期待通りにならないときはプロンプト本文より先に見直すべき箇所になります。

同様にminimum/maximumは単なるバリデーションではなく出力空間を制限するガードレールとして機能します。プロンプト本文で制限を指示するよりこちらで宣言する方が確実です。

Genkit Dartでのスキーマ指定

void main() async {
  Future generate() async {
    final ai = Genkit(plugins: [googleAI(apiKey: "sample")]);
    // outputSchemaで定義したスキーマを指定します。
    final response = await ai.generate(model: googleAI.gemini('gemini-3.5-flash'), prompt: '映画「トトロ」のレビューを作成してください。', outputSchema: MovieReview.$schema);
    return response.output;
  }

  final MovieReview? review = await generate();

  if (review != null) {
    print('\n--- 生成結果(MovieReview オブジェクト) ---');
    print('タイトル  : ${review.title}');
    print('評価      : ${'★' * review.rating} (${review.rating}/5)');
    print('見どころ  : ${review.highlights.join(' / ')}');
    print('一言感想  : ${review.shortReview}');
  } else {
    print('生成に失敗しました。');
  }
}

出力結果

--- 生成結果(MovieReview オブジェクト) ---
flutter: タイトル  : となりのトトロ
flutter: 評価      : ★★★★★ (5/5)
flutter: 見どころ  : トトロやまっくろくろすけなどの魅力的な不思議な生き物たち / 昭和30年代の美しい日本の自然と懐かしい田舎の風景 / サツキとメイの姉妹の絆と、家族の温かいストーリー
flutter: 一言感想  : 豊かな自然と不思議な生き物たちが織りなす、何度観ても心温まる不朽の名作ファンタジー。

まとめ

冒頭で挙げた問題は、「Dart の型を実行時に JSON Schema として取り出せない」というものでした。

schemantic は、ひとつの型定義から型安全なデータクラスと実行時 JSON Schema の両方を生成することができます。そして、その JSON Schema がそのまま Structured Outputsの入力になるため、LLMとの境界を型で繋げられるようになります。

※ 注意点

schemanticはあくまでLLMとスキーマでやり取りを行い、適切にデータを変換する為のツールです。実態はミュータブルで==/hashCode/copyWithも含まれません。その為UIの状態として持ち回すオブジェクトとしてはfreezedの方が適切です。

参考