Skip to content

Nested Structured Output (ActiveAgent::SchemaGenerator) #260

@skovy

Description

@skovy

There are use cases to have arrays of objects in a JSON schema for structured output, which are supported by OpenAI: https://platform.openai.com/docs/guides/structured-outputs#definitions-are-supported

It appears this works with ActiveRecord, but not with ActiveModel:

when :has_many, :has_and_belongs_to_many
schema[:properties][association.name.to_s] = {
type: "array",
items: { "$ref": "#/$defs/#{association.name.to_s.singularize}" }
}
if options[:nested_associations]
nested_schema = json_schema_from_model(
association.klass,
options.merge(include_associations: false)
)
schema[:$defs][association.name.to_s.singularize] = nested_schema
end

Could we add support for this to ActiveAgent::SchemaGenerator for ActiveModel?

I have a hacky prototype / monkeypatch, but would be great to have upstream support for this.

class Schemas::ApplicationSchema
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveAgent::SchemaGenerator

  # ActiveModel doesn't support associations like ActiveRecord.
  #
  # This adds a simple `has_many` declaration for nested schemas, enabling
  # JSON Schema generation with $defs/$ref for AI agent structured output.
  class << self
    def associations
      @associations ||= {}
    end

    def has_many(name, class_name:)
      klass = class_name.classify.constantize

      associations[name.to_s] = {
        type: :array,
        class: klass
      }

      attribute name, default: -> { [] }
    end

    def to_json_schema(options = {})
      result = super

      if associations.any?
        schema = options[:strict] ? result[:schema] : result
        schema[:$defs] ||= {}

        associations.each do |association_name, config|
          klass = config[:class]
          singular_name = association_name.to_s.singularize

          nested_schema = klass.to_json_schema(strict: options[:strict], name: singular_name)
          schema_to_store = options[:strict] ? nested_schema[:schema] : nested_schema

          schema[:$defs][singular_name] = schema_to_store
          schema[:properties][association_name] = {
            type: "array",
            items: {"$ref": "#/$defs/#{singular_name}"}
          }
        end
      end

      result
    end
  end
end
require "rails_helper"

class Schemas::ExampleSchema < Schemas::ApplicationSchema
  class Address < Schemas::ApplicationSchema
    attribute :street, :string
    attribute :city, :string
    attribute :state, :string
    attribute :zip, :string
  end

  attribute :name, :string
  attribute :age, :integer
  attribute :email, :string

  has_many :addresses, class_name: Address.name

  validates :name, presence: true
  validates :age, presence: true
  validates :email, presence: true
end

RSpec.describe Schemas::ApplicationSchema, type: :model do
  describe "#to_json_schema" do
    let(:strict) { true }

    subject { Schemas::ExampleSchema.to_json_schema(strict:, name: "example_schema") }

    context "when strict" do
      let(:strict) { true }

      it "returns a valid JSON schema" do
        expect(subject).to eq(
          {
            name: "example_schema",
            schema: {
              type: "object",
              properties: {
                "name" => {type: "string"},
                "age" => {type: "integer"},
                "email" => {type: "string"},
                "addresses" => {
                  type: "array",
                  items: {"$ref": "#/$defs/address"}
                }
              },
              required: ["addresses", "age", "email", "name"],
              additionalProperties: false,
              "$defs": {
                "address" => {
                  type: "object",
                  properties: {
                    "street" => {type: "string"},
                    "city" => {type: "string"},
                    "state" => {type: "string"},
                    "zip" => {type: "string"}
                  },
                  required: ["city", "state", "street", "zip"],
                  additionalProperties: false
                }
              }
            },
            strict: true
          }
        )
      end
    end

    context "when not strict" do
      let(:strict) { false }

      it "returns a valid JSON schema" do
        expect(subject).to eq({
          type: "object",
          properties: {
            "name" => {type: "string"},
            "age" => {type: "integer"},
            "email" => {type: "string"},
            "addresses" => {
              type: "array",
              items: {"$ref": "#/$defs/address"}
            }
          },
          required: ["name", "age", "email"],
          additionalProperties: false,
          "$defs": {
            "address" => {
              type: "object",
              properties: {
                "street" => {type: "string"},
                "city" => {type: "string"},
                "state" => {type: "string"},
                "zip" => {type: "string"}
              },
              required: [],
              additionalProperties: false
            }
          }
        })
      end
    end
  end
end

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions