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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions lib/ruby_llm/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions lib/ruby_llm/schema/dsl/schema_builders.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions lib/ruby_llm/schema/dsl/utilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions lib/ruby_llm/schema/json_output.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions spec/ruby_llm/schema/properties/core_keywords_spec.rb
Original file line number Diff line number Diff line change
@@ -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