diff --git a/lib/ruby_llm/schema.rb b/lib/ruby_llm/schema.rb index 2dc571a..d2ac2b4 100644 --- a/lib/ruby_llm/schema.rb +++ b/lib/ruby_llm/schema.rb @@ -93,7 +93,7 @@ def method_missing(method_name, ...) end def respond_to_missing?(method_name, include_private = false) - %i[string number integer boolean array object any_of one_of all_of none_of null].include?(method_name) || super + %i[string number integer boolean array tuple object any_of one_of all_of none_of null].include?(method_name) || super end end end diff --git a/lib/ruby_llm/schema/dsl/complex_types.rb b/lib/ruby_llm/schema/dsl/complex_types.rb index d5a0345..bc71a14 100644 --- a/lib/ruby_llm/schema/dsl/complex_types.rb +++ b/lib/ruby_llm/schema/dsl/complex_types.rb @@ -12,6 +12,10 @@ def array(name, description: nil, required: true, requires: nil, **options, &blo add_property(name, array_schema(description: description, **options, &block), required: required, requires: requires) end + def tuple(name, description: nil, required: true, requires: nil, **options, &block) + add_property(name, tuple_schema(description: description, **options, &block), required: required, requires: requires) + end + def any_of(name, description: nil, required: true, requires: nil, **options, &block) add_property(name, any_of_schema(description: description, **options, &block), required: required, requires: requires) end diff --git a/lib/ruby_llm/schema/dsl/schema_builders.rb b/lib/ruby_llm/schema/dsl/schema_builders.rb index e593a1b..3122b2d 100644 --- a/lib/ruby_llm/schema/dsl/schema_builders.rb +++ b/lib/ruby_llm/schema/dsl/schema_builders.rb @@ -77,17 +77,34 @@ def object_schema(description: nil, of: nil, reference: nil, min_properties: nil schema end - def array_schema(description: nil, of: nil, min_items: nil, max_items: nil, unevaluated_items: nil, &block) - items = determine_array_items(of, &block) + def array_schema(description: nil, of: nil, min_items: nil, max_items: nil, unevaluated_items: nil, unique: nil, &block) + schema_block = collect_schema_block(&block) if block_given? + items = determine_array_items(of, schema_block) - { + schema = { type: "array", description: description, items: items, minItems: min_items, maxItems: max_items, + uniqueItems: unique, unevaluatedItems: unevaluated_items }.compact + + schema.merge!(schema_block.keywords) if schema_block + schema + end + + def tuple_schema(description: nil, &block) + schemas = collect_schemas_from_block(&block) + + { + type: "array", + description: description, + prefixItems: schemas, + minItems: schemas.length, + maxItems: schemas.length + }.compact end def any_of_schema(description: nil, &block) @@ -140,8 +157,9 @@ def raise_unknown_options!(type, options) raise ArgumentError, "unknown #{type} schema option: #{options.keys.first.inspect}" end - def determine_array_items(of, &) - return collect_schemas_from_block(&).first if block_given? + def determine_array_items(of, schema_block = nil) + return schema_block.schemas.first if schema_block&.schemas&.any? + return nil if schema_block return send("#{of}_schema") if primitive_type?(of) return reference(of) if of.is_a?(Symbol) return schema_class_to_inline_schema(of) if schema_class?(of) @@ -214,6 +232,12 @@ def collect_schema_block(&block) schema_block.keywords[:unevaluatedItems] = value end + context.define_singleton_method(:contains) do |min: nil, max: nil, &blk| + schema_block.keywords[:contains] = schema_builder.send(:collect_schemas_from_block, &blk).first + schema_block.keywords[:minContains] = min unless min.nil? + schema_block.keywords[:maxContains] = max unless max.nil? + end + # Allow Schema classes to be accessed in the context context.define_singleton_method(:const_missing) do |name| const_get(name) if const_defined?(name) diff --git a/spec/ruby_llm/schema/properties/array_validation_keywords_spec.rb b/spec/ruby_llm/schema/properties/array_validation_keywords_spec.rb new file mode 100644 index 0000000..57150be --- /dev/null +++ b/spec/ruby_llm/schema/properties/array_validation_keywords_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "array validation keywords" do + let(:schema_class) { Class.new(described_class) } + + it "supports uniqueItems with homogeneous arrays" do + schema_class.array :tags, of: :string, unique: true + + expect(schema_class.properties[:tags]).to eq({ + type: "array", + items: {type: "string"}, + uniqueItems: true + }) + end + + it "supports tuple schemas with prefixItems" do + schema_class.tuple :coordinates do + number description: "Latitude" + number description: "Longitude" + end + + expect(schema_class.properties[:coordinates]).to eq({ + type: "array", + prefixItems: [ + {type: "number", description: "Latitude"}, + {type: "number", description: "Longitude"} + ], + minItems: 2, + maxItems: 2 + }) + end + + it "supports tuple schemas with object entries" do + schema_class.tuple :event do + string description: "Event name" + integer description: "Unix timestamp" + object description: "Payload" do + string :id + string :source + end + end + + event_schema = schema_class.properties[:event] + + expect(event_schema[:prefixItems].length).to eq(3) + expect(event_schema[:prefixItems][2][:properties][:id]).to eq({type: "string"}) + expect(event_schema[:minItems]).to eq(3) + expect(event_schema[:maxItems]).to eq(3) + end + + it "supports contains with minContains and maxContains" do + schema_class.array :scores do + contains min: 1, max: 3 do + integer minimum: 10 + end + end + + expect(schema_class.properties[:scores]).to eq({ + type: "array", + contains: {type: "integer", minimum: 10}, + minContains: 1, + maxContains: 3 + }) + end + + it "keeps existing homogeneous item behavior" do + schema_class.array :items, of: :integer + + expect(schema_class.properties[:items]).to eq({ + type: "array", + items: {type: "integer"} + }) + end +end