diff --git a/README.md b/README.md index c34ce80..01cd40e 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,28 @@ class MySchema < RubyLLM::Schema end ``` +### Core Keywords + +Use core keyword methods when a schema or subschema needs an identifier, anchor, comment, dynamic reference, or vocabulary declaration: + +```ruby +id "https://example.com/schemas/person" +anchor "person" +comment "Internal note" +dynamic_anchor "node" +dynamic_ref "#node" +vocabulary "https://json-schema.org/draft/2020-12/vocab/core" => true + +define :address do + anchor "address" + comment "Reusable address schema" + + string :street +end +``` + +`dynamic_ref` and `dynamic_anchor` are emitted as JSON Schema core keywords. Their recursive resolution semantics are handled by JSON Schema validators; this gem does not expand or interpret dynamic references. + ### Nested Schemas You can embed existing schema classes directly within objects or arrays for reusable schema composition. diff --git a/lib/ruby_llm/schema.rb b/lib/ruby_llm/schema.rb index 8b741fc..6f49d01 100644 --- a/lib/ruby_llm/schema.rb +++ b/lib/ruby_llm/schema.rb @@ -14,6 +14,14 @@ class Schema include JsonOutput PRIMITIVE_TYPES = %i[string number integer boolean null].freeze + CORE_KEYWORDS = { + id: "$id", + anchor: "$anchor", + comment: "$comment", + dynamic_anchor: "$dynamicAnchor", + dynamic_ref: "$dynamicRef", + vocabulary: "$vocabulary" + }.freeze class << self def create(&block) @@ -38,6 +46,10 @@ def schema_metadata @schema_metadata ||= {} end + def schema_core_keywords + @schema_core_keywords ||= {} + end + def name(name = nil) @schema_name = name if name return @schema_name if defined?(@schema_name) @@ -49,6 +61,30 @@ def title(*args) metadata_accessor(:title, *args) end + def id(*args) + core_keyword_accessor("$id", *args) + end + + def anchor(*args) + core_keyword_accessor("$anchor", *args) + end + + def comment(*args) + core_keyword_accessor("$comment", *args) + end + + def dynamic_anchor(*args) + core_keyword_accessor("$dynamicAnchor", *args) + end + + def dynamic_ref(*args) + core_keyword_accessor("$dynamicRef", *args) + end + + def vocabulary(*args) + core_keyword_accessor("$vocabulary", *args) + end + def description(description = nil) if description @description = description @@ -109,6 +145,12 @@ def metadata_accessor(keyword, *args) schema_metadata[keyword] = args.first end + + def core_keyword_accessor(keyword, *args) + return schema_core_keywords[keyword] if args.empty? + + schema_core_keywords[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 787c5cc..8399692 100644 --- a/lib/ruby_llm/schema/dsl/schema_builders.rb +++ b/lib/ruby_llm/schema/dsl/schema_builders.rb @@ -234,6 +234,12 @@ def merge_schema_metadata(schema, schema_class) schema.replace(schema_class.schema_metadata.merge(schema)) end + def merge_core_keywords(schema, schema_class) + return schema unless schema_class.respond_to?(:schema_core_keywords) + + schema.merge!(schema_class.schema_core_keywords) + end + def raise_unknown_options!(type, options) return if options.empty? @@ -266,6 +272,7 @@ def build_object_schema(description, &block) }.compact merge_schema_metadata(schema, sub_schema) + merge_core_keywords(schema, sub_schema) merge_conditions(schema, sub_schema) merge_object_keywords(schema, sub_schema) end @@ -336,6 +343,12 @@ def collect_schema_block(&block) end end + RubyLLM::Schema::CORE_KEYWORDS.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) @@ -368,6 +381,7 @@ def schema_class_to_inline_schema(schema_class_or_instance) end merge_schema_metadata(schema, schema_class) + merge_core_keywords(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 6ae6bd9..3241cef 100644 --- a/lib/ruby_llm/schema/dsl/utilities.rb +++ b/lib/ruby_llm/schema/dsl/utilities.rb @@ -17,6 +17,7 @@ def define(name, &) } merge_schema_metadata(schema, sub_schema) + merge_core_keywords(schema, sub_schema) merge_object_keywords(schema, sub_schema) merge_conditions(schema, sub_schema) diff --git a/lib/ruby_llm/schema/json_output.rb b/lib/ruby_llm/schema/json_output.rb index 2878f9e..94c73f2 100644 --- a/lib/ruby_llm/schema/json_output.rb +++ b/lib/ruby_llm/schema/json_output.rb @@ -19,6 +19,7 @@ def to_json_schema schema_hash["$defs"] = self.class.definitions unless self.class.definitions.empty? self.class.send(:merge_conditions, schema_hash, self.class) + self.class.send(:merge_core_keywords, schema_hash, self.class) { name: @name, diff --git a/spec/ruby_llm/schema/properties/core_keywords_spec.rb b/spec/ruby_llm/schema/properties/core_keywords_spec.rb new file mode 100644 index 0000000..09ce027 --- /dev/null +++ b/spec/ruby_llm/schema/properties/core_keywords_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "core keywords" do + let(:schema_class) { Class.new(described_class) } + + it "supports root schema core keywords" do + schema_class.id "https://example.com/schemas/person" + schema_class.anchor "person" + schema_class.comment "Internal note" + schema_class.dynamic_anchor "node" + schema_class.dynamic_ref "#node" + schema_class.vocabulary "https://json-schema.org/draft/2020-12/vocab/core" => true + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema).to include( + "$id" => "https://example.com/schemas/person", + "$anchor" => "person", + "$comment" => "Internal note", + "$dynamicAnchor" => "node", + "$dynamicRef" => "#node", + "$vocabulary" => {"https://json-schema.org/draft/2020-12/vocab/core" => true} + ) + end + + it "supports core keywords inside definitions" do + schema_class.define :address do + anchor "address" + comment "Reusable address schema" + + string :street + end + + definition = schema_class.definitions[:address] + + expect(definition["$anchor"]).to eq("address") + expect(definition["$comment"]).to eq("Reusable address schema") + end + + it "supports core keywords inside nested object schemas" do + schema_class.object :node do + dynamic_anchor "node" + + string :value + end + + node_schema = schema_class.properties[:node] + + expect(node_schema["$dynamicAnchor"]).to eq("node") + expect(node_schema[:properties][:value]).to eq({type: "string"}) + end + + it "supports core keywords on composition schemas" do + schema_class.any_of :identifier do + comment "Accepted identifier formats" + + string + integer + end + + identifier_schema = schema_class.properties[:identifier] + + expect(identifier_schema["$comment"]).to eq("Accepted identifier formats") + expect(identifier_schema[:anyOf].map { |schema| schema[:type] }).to eq(%w[string integer]) + end +end