Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 54 additions & 42 deletions lib/ruby_llm/schema/dsl/schema_builders.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -62,85 +63,66 @@ 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)

{
type: "array",
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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions spec/ruby_llm/schema/properties/unevaluated_spec.rb
Original file line number Diff line number Diff line change
@@ -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