diff --git a/README.md b/README.md index 8d4c7de..c34ce80 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/ruby_llm/schema.rb b/lib/ruby_llm/schema.rb index d2ac2b4..8b741fc 100644 --- a/lib/ruby_llm/schema.rb +++ b/lib/ruby_llm/schema.rb @@ -34,6 +34,10 @@ 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) @@ -41,11 +45,39 @@ def name(name = nil) 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? @@ -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) diff --git a/lib/ruby_llm/schema/dsl/schema_builders.rb b/lib/ruby_llm/schema/dsl/schema_builders.rb index 3122b2d..ba7a6a2 100644 --- a/lib/ruby_llm/schema/dsl/schema_builders.rb +++ b/lib/ruby_llm/schema/dsl/schema_builders.rb @@ -5,14 +5,23 @@ 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, @@ -20,15 +29,16 @@ def string_schema(description: nil, enum: nil, min_length: nil, max_length: nil, 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, @@ -36,15 +46,16 @@ def number_schema(description: nil, minimum: nil, maximum: nil, greater_than: ni 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, @@ -52,18 +63,32 @@ def integer_schema(description: nil, minimum: nil, maximum: nil, greater_than: n 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 @@ -71,17 +96,25 @@ def object_schema(description: nil, of: nil, reference: nil, min_properties: nil 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, @@ -89,59 +122,84 @@ def array_schema(description: nil, of: nil, min_items: nil, max_items: nil, unev 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 @@ -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? @@ -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 @@ -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) @@ -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) diff --git a/lib/ruby_llm/schema/dsl/utilities.rb b/lib/ruby_llm/schema/dsl/utilities.rb index 835ff8e..6ae6bd9 100644 --- a/lib/ruby_llm/schema/dsl/utilities.rb +++ b/lib/ruby_llm/schema/dsl/utilities.rb @@ -16,6 +16,7 @@ def define(name, &) additionalProperties: sub_schema.additional_properties } + merge_schema_metadata(schema, sub_schema) merge_object_keywords(schema, sub_schema) merge_conditions(schema, sub_schema) diff --git a/spec/ruby_llm/schema/properties/metadata_annotations_spec.rb b/spec/ruby_llm/schema/properties/metadata_annotations_spec.rb new file mode 100644 index 0000000..815f5ca --- /dev/null +++ b/spec/ruby_llm/schema/properties/metadata_annotations_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "metadata annotations" do + let(:schema_class) { Class.new(described_class) } + + it "supports metadata keyword arguments on primitive schemas" do + schema_class.string :email, + title: "Email address", + description: "Primary contact email", + default: "user@example.com", + examples: ["alice@example.com"], + deprecated: false, + read_only: false, + write_only: true + + expect(schema_class.properties[:email]).to eq({ + type: "string", + title: "Email address", + description: "Primary contact email", + default: "user@example.com", + examples: ["alice@example.com"], + deprecated: false, + readOnly: false, + writeOnly: true + }) + end + + it "supports block annotations on object schemas" do + schema_class.object :account do + title "Account" + description "Billing account metadata" + default({status: "active"}) + examples [{id: "acct_123", status: "active"}] + deprecated false + read_only false + write_only false + + string :id + string :status + end + + account_schema = schema_class.properties[:account] + + expect(account_schema).to include( + title: "Account", + description: "Billing account metadata", + default: {status: "active"}, + examples: [{id: "acct_123", status: "active"}], + deprecated: false, + readOnly: false, + writeOnly: false + ) + expect(account_schema[:properties][:id]).to eq({type: "string"}) + end + + it "keeps keyword annotations ahead of block annotations" do + schema_class.object :account, title: "Keyword title", description: "Keyword description" do + title "Block title" + description "Block description" + end + + account_schema = schema_class.properties[:account] + + expect(account_schema[:title]).to eq("Keyword title") + expect(account_schema[:description]).to eq("Keyword description") + end + + it "supports current-node annotations on arrays and array items" do + schema_class.array :events do + description "Chronological event list" + + object do + description "Single event payload" + + string :type + end + end + + events_schema = schema_class.properties[:events] + + expect(events_schema[:description]).to eq("Chronological event list") + expect(events_schema[:items][:description]).to eq("Single event payload") + expect(events_schema[:items][:properties][:type]).to eq({type: "string"}) + end + + it "supports current-node annotations on composition schemas" do + schema_class.any_of :identifier do + description "Accepted user identifier formats" + + string description: "Username" + integer description: "Numeric user ID" + end + + identifier_schema = schema_class.properties[:identifier] + + expect(identifier_schema[:description]).to eq("Accepted user identifier formats") + expect(identifier_schema[:anyOf]).to eq( + [ + {type: "string", description: "Username"}, + {type: "integer", description: "Numeric user ID"} + ] + ) + end + + it "supports annotations inside reusable definitions" do + schema_class.define :address do + title "Address" + description "Reusable postal address schema" + + string :street + string :city + end + + definition = schema_class.definitions[:address] + + expect(definition[:title]).to eq("Address") + expect(definition[:description]).to eq("Reusable postal address schema") + expect(definition[:properties][:street]).to eq({type: "string"}) + end +end