From 18a3896ee5eb2ddf9b4a1d3fc5eb67375be877b3 Mon Sep 17 00:00:00 2001 From: Carmine Paolino Date: Sat, 6 Jun 2026 12:37:36 +0200 Subject: [PATCH] Add unevaluated keywords --- lib/ruby_llm/schema/dsl/schema_builders.rb | 96 +++++++++++-------- .../schema/properties/unevaluated_spec.rb | 60 ++++++++++++ 2 files changed, 114 insertions(+), 42 deletions(-) create mode 100644 spec/ruby_llm/schema/properties/unevaluated_spec.rb diff --git a/lib/ruby_llm/schema/dsl/schema_builders.rb b/lib/ruby_llm/schema/dsl/schema_builders.rb index f955907..900736a 100644 --- a/lib/ruby_llm/schema/dsl/schema_builders.rb +++ b/lib/ruby_llm/schema/dsl/schema_builders.rb @@ -5,6 +5,7 @@ class Schema module DSL module SchemaBuilders NOT_GIVEN = Object.new.freeze + SchemaBlock = Struct.new(:schemas, :keywords, keyword_init: true) def string_schema(description: nil, enum: nil, min_length: nil, max_length: nil, pattern: nil, **options) format = options.delete(:format) @@ -62,40 +63,19 @@ def null_schema(description: nil, const: NOT_GIVEN) add_const({type: "null", description: description}.compact, const) end - def object_schema(description: nil, of: nil, reference: nil, &block) + def object_schema(description: nil, of: nil, reference: nil, unevaluated_properties: nil, &block) if reference warn "[DEPRECATION] The `reference` option will be deprecated. Please use `of` instead." of = reference end - if of - determine_object_reference(of, description) - else - sub_schema = Class.new(Schema) - result = sub_schema.class_eval(&block) - - # If the block returned a reference and no properties were added, use the reference - if result.is_a?(Hash) && result["$ref"] && sub_schema.properties.empty? - result.merge(description ? {description: description} : {}) - # If the block returned a Schema class or instance, convert it to inline schema - elsif schema_class?(result) && sub_schema.properties.empty? - schema_class_to_inline_schema(result).merge(description ? {description: description} : {}) - # Block didn't return reference or schema, so we build an inline object schema - else - schema = { - type: "object", - properties: sub_schema.properties, - required: sub_schema.required_properties, - additionalProperties: sub_schema.additional_properties, - description: description - }.compact - - merge_conditions(schema, sub_schema) - end - end + schema = of ? determine_object_reference(of, description) : build_object_schema(description, &block) + + schema[:unevaluatedProperties] = unevaluated_properties unless unevaluated_properties.nil? + schema end - def array_schema(description: nil, of: nil, min_items: nil, max_items: nil, &block) + def array_schema(description: nil, of: nil, min_items: nil, max_items: nil, unevaluated_items: nil, &block) items = determine_array_items(of, &block) { @@ -103,44 +83,46 @@ def array_schema(description: nil, of: nil, min_items: nil, max_items: nil, &blo description: description, items: items, minItems: min_items, - maxItems: max_items + maxItems: max_items, + unevaluatedItems: unevaluated_items }.compact end def any_of_schema(description: nil, &block) - schemas = collect_schemas_from_block(&block) + schema_block = collect_schema_block(&block) { description: description, - anyOf: schemas - }.compact + anyOf: schema_block.schemas + }.merge(schema_block.keywords).compact end def one_of_schema(description: nil, &block) - schemas = collect_schemas_from_block(&block) + schema_block = collect_schema_block(&block) { description: description, - oneOf: schemas - }.compact + oneOf: schema_block.schemas + }.merge(schema_block.keywords).compact end def all_of_schema(description: nil, &block) - schemas = collect_schemas_from_block(&block) + schema_block = collect_schema_block(&block) { description: description, - allOf: schemas - }.compact + allOf: schema_block.schemas + }.merge(schema_block.keywords).compact end def none_of_schema(description: nil, &block) - schemas = collect_schemas_from_block(&block) + schema_block = collect_schema_block(&block) + schemas = schema_block.schemas { description: description, not: schemas.one? ? schemas.first : {anyOf: schemas} - }.compact + }.merge(schema_block.keywords).compact end private @@ -165,6 +147,24 @@ def determine_array_items(of, &) raise InvalidArrayTypeError, "Invalid array type: #{of.inspect}. Must be a primitive type (:string, :number, etc.), a symbol reference, a Schema class, or a Schema instance." end + def build_object_schema(description, &block) + sub_schema = Class.new(Schema) + result = sub_schema.class_eval(&block) + + return result.merge(description ? {description: description} : {}) if result.is_a?(Hash) && result["$ref"] && sub_schema.properties.empty? + return schema_class_to_inline_schema(result).merge(description ? {description: description} : {}) if schema_class?(result) && sub_schema.properties.empty? + + schema = { + type: "object", + properties: sub_schema.properties, + required: sub_schema.required_properties, + additionalProperties: sub_schema.additional_properties, + description: description + }.compact + + merge_conditions(schema, sub_schema) + end + def determine_object_reference(of, description = nil) result = case of when Symbol @@ -185,7 +185,11 @@ def determine_object_reference(of, description = nil) end def collect_schemas_from_block(&block) - schemas = [] + collect_schema_block(&block).schemas + end + + def collect_schema_block(&block) + schema_block = SchemaBlock.new(schemas: [], keywords: {}) schema_builder = self context = Object.new @@ -195,17 +199,25 @@ def collect_schemas_from_block(&block) type_name = schema_method.to_s.sub(/_schema$/, "") context.define_singleton_method(type_name) do |_name = nil, **options, &blk| - schemas << schema_builder.send(schema_method, **options, &blk) + schema_block.schemas << schema_builder.send(schema_method, **options, &blk) end end + context.define_singleton_method(:unevaluated_properties) do |value| + schema_block.keywords[:unevaluatedProperties] = value + end + + context.define_singleton_method(:unevaluated_items) do |value| + schema_block.keywords[:unevaluatedItems] = value + 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) end context.instance_eval(&block) - schemas + schema_block end def schema_class_to_inline_schema(schema_class_or_instance) diff --git a/spec/ruby_llm/schema/properties/unevaluated_spec.rb b/spec/ruby_llm/schema/properties/unevaluated_spec.rb new file mode 100644 index 0000000..2f6a90f --- /dev/null +++ b/spec/ruby_llm/schema/properties/unevaluated_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "unevaluated keywords" do + let(:schema_class) { Class.new(described_class) } + + it "supports unevaluatedProperties on composition schemas" do + schema_class.all_of :person do + object do + string :name + end + + object do + integer :age + end + + unevaluated_properties false + end + + person_schema = schema_class.properties[:person] + + expect(person_schema[:allOf].length).to eq(2) + expect(person_schema[:unevaluatedProperties]).to be(false) + end + + it "supports unevaluatedProperties on referenced object schemas" do + schema_class.define :person do + string :name + end + + schema_class.object :person, of: :person, unevaluated_properties: false + + expect(schema_class.properties[:person]).to eq({ + "$ref" => "#/$defs/person", + unevaluatedProperties: false + }) + end + + it "supports unevaluatedProperties on inline object schemas" do + schema_class.object :metadata, unevaluated_properties: false do + string :name + end + + metadata_schema = schema_class.properties[:metadata] + + expect(metadata_schema[:properties][:name]).to eq({type: "string"}) + expect(metadata_schema[:unevaluatedProperties]).to be(false) + end + + it "supports unevaluatedItems on array schemas" do + schema_class.array :values, of: :integer, unevaluated_items: false + + expect(schema_class.properties[:values]).to eq({ + type: "array", + items: {type: "integer"}, + unevaluatedItems: false + }) + end +end