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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,40 @@ boolean :is_active, description: "Whether the person is active"
null :placeholder, description: "A placeholder property"
```

### Metadata Annotations

Metadata annotations describe schemas for humans and tools. They do not usually affect validation.

Supported annotations are `title`, `description`, `default`, `examples`, `deprecated`, `read_only`, and `write_only`.

Short annotations can be passed as keyword arguments:

```ruby
string :email,
title: "Email address",
description: "Primary contact email",
default: "user@example.com",
examples: ["alice@example.com"],
deprecated: false,
read_only: false,
write_only: false
```

Longer annotations can live inside complex schema blocks:

```ruby
object :account do
title "Account"
description "Billing account metadata used for invoices."
examples [{ id: "acct_123", status: "active" }]

string :id
string :status
end
```

Block annotations configure the enclosing schema node. If the same annotation is provided both as a keyword and inside the block, the keyword value wins.

⚠️ Please consult the LLM provider documentation for any limitations or restrictions. For example, as of now, OpenAI requires all properties to be required. In that case, you can use the `any_of` method to make a property optional.

```ruby
Expand Down
42 changes: 41 additions & 1 deletion lib/ruby_llm/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,50 @@ def definitions
@definitions ||= {}
end

def schema_metadata
@schema_metadata ||= {}
end

def name(name = nil)
@schema_name = name if name
return @schema_name if defined?(@schema_name)

super()
end

def title(*args)
metadata_accessor(:title, *args)
end

def description(description = nil)
@description = description if description
if description
@description = description
schema_metadata[:description] = description
end

@description
end

def default(*args)
metadata_accessor(:default, *args)
end

def examples(*args)
metadata_accessor(:examples, *args)
end

def deprecated(*args)
metadata_accessor(:deprecated, *args)
end

def read_only(*args)
metadata_accessor(:readOnly, *args)
end

def write_only(*args)
metadata_accessor(:writeOnly, *args)
end

def additional_properties(value = nil)
return @additional_properties ||= false if value.nil?

Expand All @@ -69,6 +101,14 @@ def valid?
validator = Validator.new(self)
validator.valid?
end

private

def metadata_accessor(keyword, *args)
return schema_metadata[keyword] if args.empty?

schema_metadata[keyword] = args.first
end
end

def initialize(name = nil, description: nil)
Expand Down
152 changes: 120 additions & 32 deletions lib/ruby_llm/schema/dsl/schema_builders.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,143 +5,201 @@ class Schema
module DSL
module SchemaBuilders
NOT_GIVEN = Object.new.freeze
METADATA_OPTIONS = {
title: :title,
default: :default,
examples: :examples,
deprecated: :deprecated,
read_only: :readOnly,
write_only: :writeOnly
}.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)
metadata = extract_metadata!(options)
format = options.delete(:format)
const = options.delete(:const) { NOT_GIVEN }
raise_unknown_options!("string", options)

add_const({
add_const(metadata.merge({
type: "string",
enum: enum,
description: description,
minLength: min_length,
maxLength: max_length,
pattern: pattern,
format: format
}.compact, const)
}.compact), const)
end

def number_schema(description: nil, minimum: nil, maximum: nil, greater_than: nil, less_than: nil, **options)
metadata = extract_metadata!(options)
multiple_of = options.delete(:multiple_of)
const = options.delete(:const) { NOT_GIVEN }
raise_unknown_options!("number", options)

add_const({
add_const(metadata.merge({
type: "number",
description: description,
minimum: minimum,
maximum: maximum,
exclusiveMinimum: greater_than,
exclusiveMaximum: less_than,
multipleOf: multiple_of
}.compact, const)
}.compact), const)
end

def integer_schema(description: nil, minimum: nil, maximum: nil, greater_than: nil, less_than: nil, **options)
metadata = extract_metadata!(options)
multiple_of = options.delete(:multiple_of)
const = options.delete(:const) { NOT_GIVEN }
raise_unknown_options!("integer", options)

add_const({
add_const(metadata.merge({
type: "integer",
description: description,
minimum: minimum,
maximum: maximum,
exclusiveMinimum: greater_than,
exclusiveMaximum: less_than,
multipleOf: multiple_of
}.compact, const)
}.compact), const)
end

def boolean_schema(description: nil, const: NOT_GIVEN)
add_const({type: "boolean", description: description}.compact, const)
def boolean_schema(description: nil, **options)
metadata = extract_metadata!(options)
const = options.delete(:const) { NOT_GIVEN }
raise_unknown_options!("boolean", options)

add_const(metadata.merge({type: "boolean", description: description}.compact), const)
end

def null_schema(description: nil, const: NOT_GIVEN)
add_const({type: "null", description: description}.compact, const)
def null_schema(description: nil, **options)
metadata = extract_metadata!(options)
const = options.delete(:const) { NOT_GIVEN }
raise_unknown_options!("null", options)

add_const(metadata.merge({type: "null", description: description}.compact), const)
end

def object_schema(description: nil, of: nil, reference: nil, min_properties: nil, max_properties: nil, unevaluated_properties: nil, &block)
def object_schema(description: nil, of: nil, reference: nil, **options, &block)
metadata = extract_metadata!(options)
min_properties = options.delete(:min_properties)
max_properties = options.delete(:max_properties)
unevaluated_properties = options.delete(:unevaluated_properties)
raise_unknown_options!("object", options)

if reference
warn "[DEPRECATION] The `reference` option will be deprecated. Please use `of` instead."
of = reference
end

schema = of ? determine_object_reference(of, description) : build_object_schema(description, &block)

schema.merge!(metadata)
schema[:unevaluatedProperties] = unevaluated_properties unless unevaluated_properties.nil?
schema[:minProperties] = min_properties unless min_properties.nil?
schema[:maxProperties] = max_properties unless max_properties.nil?
schema
end

def array_schema(description: nil, of: nil, min_items: nil, max_items: nil, unevaluated_items: nil, unique: nil, &block)
def array_schema(description: nil, of: nil, **options, &block)
metadata = extract_metadata!(options)
min_items = options.delete(:min_items)
max_items = options.delete(:max_items)
unevaluated_items = options.delete(:unevaluated_items)
unique = options.delete(:unique)
raise_unknown_options!("array", options)

schema_block = collect_schema_block(&block) if block_given?
items = determine_array_items(of, schema_block)

schema = {
schema = metadata.merge({
type: "array",
description: description,
items: items,
minItems: min_items,
maxItems: max_items,
uniqueItems: unique,
unevaluatedItems: unevaluated_items
}.compact
}.compact)

schema.merge!(schema_block.keywords) if schema_block
schema
merge_schema_block_keywords(schema, schema_block)
end

def tuple_schema(description: nil, &block)
schemas = collect_schemas_from_block(&block)
def tuple_schema(description: nil, **options, &block)
metadata = extract_metadata!(options)
raise_unknown_options!("tuple", options)

{
schema_block = collect_schema_block(&block)
schemas = schema_block.schemas

schema = metadata.merge({
type: "array",
description: description,
prefixItems: schemas,
minItems: schemas.length,
maxItems: schemas.length
}.compact
}.compact)

merge_schema_block_keywords(schema, schema_block)
end

def any_of_schema(description: nil, &block)
def any_of_schema(description: nil, **options, &block)
metadata = extract_metadata!(options)
raise_unknown_options!("any_of", options)

schema_block = collect_schema_block(&block)

{
schema = metadata.merge({
description: description,
anyOf: schema_block.schemas
}.merge(schema_block.keywords).compact
}.compact)

merge_schema_block_keywords(schema, schema_block)
end

def one_of_schema(description: nil, &block)
def one_of_schema(description: nil, **options, &block)
metadata = extract_metadata!(options)
raise_unknown_options!("one_of", options)

schema_block = collect_schema_block(&block)

{
schema = metadata.merge({
description: description,
oneOf: schema_block.schemas
}.merge(schema_block.keywords).compact
}.compact)

merge_schema_block_keywords(schema, schema_block)
end

def all_of_schema(description: nil, &block)
def all_of_schema(description: nil, **options, &block)
metadata = extract_metadata!(options)
raise_unknown_options!("all_of", options)

schema_block = collect_schema_block(&block)

{
schema = metadata.merge({
description: description,
allOf: schema_block.schemas
}.merge(schema_block.keywords).compact
}.compact)

merge_schema_block_keywords(schema, schema_block)
end

def none_of_schema(description: nil, &block)
def none_of_schema(description: nil, **options, &block)
metadata = extract_metadata!(options)
raise_unknown_options!("none_of", options)

schema_block = collect_schema_block(&block)
schemas = schema_block.schemas

{
schema = metadata.merge({
description: description,
not: schemas.one? ? schemas.first : {anyOf: schemas}
}.merge(schema_block.keywords).compact
}.compact)

merge_schema_block_keywords(schema, schema_block)
end

private
Expand All @@ -151,6 +209,24 @@ def add_const(schema, const)
schema
end

def extract_metadata!(options)
METADATA_OPTIONS.each_with_object({}) do |(option, keyword), metadata|
metadata[keyword] = options.delete(option) if options.key?(option)
end
end

def merge_schema_block_keywords(schema, schema_block)
return schema unless schema_block

schema.replace(schema_block.keywords.merge(schema))
end

def merge_schema_metadata(schema, schema_class)
return schema unless schema_class.respond_to?(:schema_metadata)

schema.replace(schema_class.schema_metadata.merge(schema))
end

def raise_unknown_options!(type, options)
return if options.empty?

Expand Down Expand Up @@ -182,6 +258,7 @@ def build_object_schema(description, &block)
description: description
}.compact

merge_schema_metadata(schema, sub_schema)
merge_conditions(schema, sub_schema)
merge_object_keywords(schema, sub_schema)
end
Expand Down Expand Up @@ -238,6 +315,16 @@ def collect_schema_block(&block)
schema_block.keywords[:maxContains] = max unless max.nil?
end

context.define_singleton_method(:description) do |value|
schema_block.keywords[:description] = value
end

METADATA_OPTIONS.each do |method_name, keyword|
context.define_singleton_method(method_name) do |value|
schema_block.keywords[keyword] = value
end
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)
Expand Down Expand Up @@ -269,6 +356,7 @@ def schema_class_to_inline_schema(schema_class_or_instance)
schema_class_or_instance.instance_variable_get(:@description) || schema_class.description
end

merge_schema_metadata(schema, schema_class)
schema[:description] = description if description

merge_conditions(schema, schema_class)
Expand Down
Loading