diff --git a/lib/ruby_llm/schema/dsl.rb b/lib/ruby_llm/schema/dsl.rb index 5f315c0..2a65697 100644 --- a/lib/ruby_llm/schema/dsl.rb +++ b/lib/ruby_llm/schema/dsl.rb @@ -1,198 +1,17 @@ # frozen_string_literal: true +require_relative "dsl/schema_builders" +require_relative "dsl/primitive_types" +require_relative "dsl/complex_types" +require_relative "dsl/utilities" + module RubyLLM class Schema module DSL - # Primitive type methods - def string(name = nil, enum: nil, description: nil, required: true, min_length: nil, max_length: nil, pattern: nil, format: nil) - options = { - enum: enum, - description: description, - minLength: min_length, - maxLength: max_length, - pattern: pattern, - format: format - }.compact - - add_property(name, build_property_schema(:string, **options), required: required) - end - - def number(name = nil, description: nil, required: true, minimum: nil, maximum: nil, multiple_of: nil) - options = { - description: description, - minimum: minimum, - maximum: maximum, - multipleOf: multiple_of - }.compact - - add_property(name, build_property_schema(:number, **options), required: required) - end - - def integer(name = nil, description: nil, required: true) - add_property(name, build_property_schema(:integer, description: description), required: required) - end - - def boolean(name = nil, description: nil, required: true) - add_property(name, build_property_schema(:boolean, description: description), required: required) - end - - def null(name = nil, description: nil, required: true) - add_property(name, build_property_schema(:null, description: description), required: required) - end - - # Complex type methods - def object(name = nil, reference: nil, description: nil, required: true, &block) - add_property(name, build_property_schema(:object, description: description, reference: reference, &block), required: required) - end - - def array(name, of: nil, description: nil, required: true, min_items: nil, max_items: nil, &block) - items = determine_array_items(of, &block) - - add_property(name, { - type: "array", - description: description, - items: items, - minItems: min_items, - maxItems: max_items - }.compact, required: required) - end - - def any_of(name = nil, required: true, description: nil, &block) - schemas = collect_property_schemas_from_block(&block) - - add_property(name, { - description: description, - anyOf: schemas - }.compact, required: required) - end - - def optional(name, description: nil, &block) - any_of(name, description: description) do - instance_eval(&block) - null - end - end - - # Schema definition and reference methods - def define(name, &) - sub_schema = Class.new(Schema) - sub_schema.class_eval(&) - - definitions[name] = { - type: "object", - properties: sub_schema.properties, - required: sub_schema.required_properties, - additionalProperties: sub_schema.additional_properties - } - end - - def reference(schema_name) - if schema_name == :root - {"$ref" => "#"} - else - {"$ref" => "#/$defs/#{schema_name}"} - end - end - - # Schema building methods - def build_property_schema(type, **options, &) - case type - when :string - { - type: "string", - enum: options[:enum], - description: options[:description], - minLength: options[:minLength], - maxLength: options[:maxLength], - pattern: options[:pattern], - format: options[:format] - }.compact - when :number - { - type: "number", - description: options[:description], - minimum: options[:minimum], - maximum: options[:maximum], - multipleOf: options[:multipleOf] - }.compact - when :integer - { - type: "integer", - description: options[:description], - minimum: options[:minimum], - maximum: options[:maximum], - multipleOf: options[:multipleOf] - }.compact - when :boolean - {type: "boolean", description: options[:description]}.compact - when :null - {type: "null", description: options[:description]}.compact - when :object - # If the reference option is provided, return the reference - return reference(options[:reference]) if options[:reference] - - sub_schema = Class.new(Schema) - - # Evaluate the block and capture the result - result = sub_schema.class_eval(&) - - # 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(options[:description] ? {description: options[:description]} : {}) - else - { - type: "object", - properties: sub_schema.properties, - required: sub_schema.required_properties, - additionalProperties: sub_schema.additional_properties, - description: options[:description] - }.compact - end - when :any_of - schemas = collect_property_schemas_from_block(&) - { - anyOf: schemas - }.compact - else - raise InvalidSchemaTypeError, type - end - end - - private - - def add_property(name, definition, required:) - properties[name.to_sym] = definition - required_properties << name.to_sym if required - end - - def determine_array_items(of, &) - return collect_property_schemas_from_block(&).first if block_given? - return build_property_schema(of) if primitive_type?(of) - return reference(of) if of.is_a?(Symbol) - - raise InvalidArrayTypeError, of - end - - def collect_property_schemas_from_block(&block) - schemas = [] - schema_builder = self # Capture the current context that has build_property_schema - - context = Object.new - context.define_singleton_method(:string) { |name = nil, **options| schemas << schema_builder.build_property_schema(:string, **options) } - context.define_singleton_method(:number) { |name = nil, **options| schemas << schema_builder.build_property_schema(:number, **options) } - context.define_singleton_method(:integer) { |name = nil, **options| schemas << schema_builder.build_property_schema(:integer, **options) } - context.define_singleton_method(:boolean) { |name = nil, **options| schemas << schema_builder.build_property_schema(:boolean, **options) } - context.define_singleton_method(:null) { |name = nil, **options| schemas << schema_builder.build_property_schema(:null, **options) } - context.define_singleton_method(:object) { |name = nil, **options, &blk| schemas << schema_builder.build_property_schema(:object, **options, &blk) } - context.define_singleton_method(:any_of) { |name = nil, **options, &blk| schemas << schema_builder.build_property_schema(:any_of, **options, &blk) } - - context.instance_eval(&block) - schemas - end - - def primitive_type?(type) - type.is_a?(Symbol) && PRIMITIVE_TYPES.include?(type) - end + include SchemaBuilders + include PrimitiveTypes + include ComplexTypes + include Utilities end end end diff --git a/lib/ruby_llm/schema/dsl/complex_types.rb b/lib/ruby_llm/schema/dsl/complex_types.rb new file mode 100644 index 0000000..d109cb7 --- /dev/null +++ b/lib/ruby_llm/schema/dsl/complex_types.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module RubyLLM + class Schema + module DSL + module ComplexTypes + def object(name, description: nil, required: true, **options, &block) + add_property(name, object_schema(description: description, **options, &block), required: required) + end + + def array(name, description: nil, required: true, **options, &block) + add_property(name, array_schema(description: description, **options, &block), required: required) + end + + def any_of(name, description: nil, required: true, **options, &block) + add_property(name, any_of_schema(description: description, **options, &block), required: required) + end + + def optional(name, description: nil, &block) + any_of(name, description: description) do + instance_eval(&block) + null + end + end + end + end + end +end diff --git a/lib/ruby_llm/schema/dsl/primitive_types.rb b/lib/ruby_llm/schema/dsl/primitive_types.rb new file mode 100644 index 0000000..a8fd08f --- /dev/null +++ b/lib/ruby_llm/schema/dsl/primitive_types.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module RubyLLM + class Schema + module DSL + module PrimitiveTypes + def string(name, description: nil, required: true, **options) + add_property(name, string_schema(description: description, **options), required: required) + end + + def number(name, description: nil, required: true, **options) + add_property(name, number_schema(description: description, **options), required: required) + end + + def integer(name, description: nil, required: true, **options) + add_property(name, integer_schema(description: description, **options), required: required) + end + + def boolean(name, description: nil, required: true, **options) + add_property(name, boolean_schema(description: description, **options), required: required) + end + + def null(name, description: nil, required: true, **options) + add_property(name, null_schema(description: description, **options), required: required) + end + end + end + end +end diff --git a/lib/ruby_llm/schema/dsl/schema_builders.rb b/lib/ruby_llm/schema/dsl/schema_builders.rb new file mode 100644 index 0000000..a7cd192 --- /dev/null +++ b/lib/ruby_llm/schema/dsl/schema_builders.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module RubyLLM + class Schema + module DSL + module SchemaBuilders + def string_schema(description: nil, enum: nil, min_length: nil, max_length: nil, pattern: nil, format: nil) + { + type: "string", + enum: enum, + description: description, + minLength: min_length, + maxLength: max_length, + pattern: pattern, + format: format + }.compact + end + + def number_schema(description: nil, minimum: nil, maximum: nil, multiple_of: nil) + { + type: "number", + description: description, + minimum: minimum, + maximum: maximum, + multipleOf: multiple_of + }.compact + end + + def integer_schema(description: nil, minimum: nil, maximum: nil, multiple_of: nil) + { + type: "integer", + description: description, + minimum: minimum, + maximum: maximum, + multipleOf: multiple_of + }.compact + end + + def boolean_schema(description: nil) + {type: "boolean", description: description}.compact + end + + def null_schema(description: nil) + {type: "null", description: description}.compact + end + + def object_schema(description: nil, reference: nil, &block) + if reference + reference(reference) + 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} : {}) + else + { + type: "object", + properties: sub_schema.properties, + required: sub_schema.required_properties, + additionalProperties: sub_schema.additional_properties, + description: description + }.compact + end + end + end + + def array_schema(description: nil, of: nil, min_items: nil, max_items: nil, &block) + items = determine_array_items(of, &block) + + { + type: "array", + description: description, + items: items, + minItems: min_items, + maxItems: max_items + }.compact + end + + def any_of_schema(description: nil, &block) + schemas = collect_schemas_from_block(&block) + + { + description: description, + anyOf: schemas + }.compact + end + end + end + end +end diff --git a/lib/ruby_llm/schema/dsl/utilities.rb b/lib/ruby_llm/schema/dsl/utilities.rb new file mode 100644 index 0000000..267cb84 --- /dev/null +++ b/lib/ruby_llm/schema/dsl/utilities.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module RubyLLM + class Schema + module DSL + module Utilities + # Schema definition and reference methods + def define(name, &) + sub_schema = Class.new(Schema) + sub_schema.class_eval(&) + + definitions[name] = { + type: "object", + properties: sub_schema.properties, + required: sub_schema.required_properties, + additionalProperties: sub_schema.additional_properties + } + end + + def reference(schema_name) + if schema_name == :root + {"$ref" => "#"} + else + {"$ref" => "#/$defs/#{schema_name}"} + end + end + + private + + def add_property(name, definition, required:) + properties[name.to_sym] = definition + required_properties << name.to_sym if required + end + + def determine_array_items(of, &) + return collect_schemas_from_block(&).first if block_given? + return send("#{of}_schema") if primitive_type?(of) + return reference(of) if of.is_a?(Symbol) + + raise InvalidArrayTypeError, of + end + + def collect_schemas_from_block(&block) + schemas = [] + schema_builder = self + + context = Object.new + + # Dynamically create methods for all schema builders + schema_builder.methods.grep(/_schema$/).each do |schema_method| + 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) + end + end + + context.instance_eval(&block) + schemas + end + + def primitive_type?(type) + type.is_a?(Symbol) && PRIMITIVE_TYPES.include?(type) + end + end + end + end +end diff --git a/spec/ruby_llm/schema_spec.rb b/spec/ruby_llm/schema_spec.rb index 734fafd..bc29f82 100644 --- a/spec/ruby_llm/schema_spec.rb +++ b/spec/ruby_llm/schema_spec.rb @@ -395,11 +395,6 @@ let(:schema_class) { Class.new(described_class) } it "raises appropriate errors for invalid configurations" do - # Unknown schema type - expect { - schema_class.build_property_schema(:unknown_type) - }.to raise_error(RubyLLM::Schema::InvalidSchemaTypeError, "Unknown schema type: unknown_type") - # Invalid array types expect { schema_class.array :items, of: 123