From 39051e799b38d5072569e9e70aa7f3fba97c5793 Mon Sep 17 00:00:00 2001 From: Kevin Sylvestre Date: Mon, 4 Nov 2024 09:35:48 -0800 Subject: [PATCH] Build support for arrays / objects on tools This allows much more complex calls with tools --- Gemfile.lock | 2 +- README.md | 30 +++++++++-- examples/tools | 30 +++++++++-- lib/omniai/tool/array.rb | 74 +++++++++++++++++++++++++++ lib/omniai/tool/object.rb | 62 +++++++++++++++++++++++ lib/omniai/tool/parameters.rb | 54 ++++++-------------- lib/omniai/tool/property.rb | 84 ++++++++++++++++++++++++------- lib/omniai/version.rb | 2 +- spec/factories/tool/array.rb | 11 ++++ spec/factories/tool/object.rb | 17 +++++++ spec/factories/tool/property.rb | 23 +++++++++ spec/omniai/tool/array_spec.rb | 47 +++++++++++++++++ spec/omniai/tool/object_spec.rb | 44 ++++++++++++++++ spec/omniai/tool/property_spec.rb | 82 ++++++++++++++++++++++++------ 14 files changed, 481 insertions(+), 81 deletions(-) create mode 100644 lib/omniai/tool/array.rb create mode 100644 lib/omniai/tool/object.rb create mode 100644 spec/factories/tool/array.rb create mode 100644 spec/factories/tool/object.rb create mode 100644 spec/factories/tool/property.rb create mode 100644 spec/omniai/tool/array_spec.rb create mode 100644 spec/omniai/tool/object_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index d663929..d135756 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - omniai (1.9.0) + omniai (1.9.1) event_stream_parser http zeitwerk diff --git a/README.md b/README.md index e15333f..1d2ad04 100644 --- a/README.md +++ b/README.md @@ -72,16 +72,38 @@ require 'omniai/google' CLIENT = OmniAI::Google::Client.new +LOCATION = OmniAI::Tool::Property.object( + properties: { + city: OmniAI::Tool::Property.string(description: 'e.g. "Toronto"'), + country: OmniAI::Tool::Property.string(description: 'e.g. "Canada"'), + }, + required: %i[city country] +) + +LOCATIONS = OmniAI::Tool::Property.array( + min_items: 1, + max_items: 5, + items: LOCATION +) + +UNIT = OmniAI::Tool::Property.string(enum: %w[celcius fahrenheit]) + +WEATHER = proc do |locations:, unit: 'celsius'| + locations.map do |location| + "#{rand(20..50)}° #{unit} in #{location[:city]}, #{location[:country]}" + end.join("\n") +end + TOOL = OmniAI::Tool.new( - proc { |location:, unit: 'celsius'| "#{rand(20..50)}° #{unit} in #{location}" }, + WEATHER, name: 'Weather', description: 'Lookup the weather in a location', parameters: OmniAI::Tool::Parameters.new( properties: { - location: OmniAI::Tool::Property.string(description: 'e.g. Toronto'), - unit: OmniAI::Tool::Property.string(enum: %w[celcius fahrenheit]), + locations: LOCATIONS, + unit: UNIT, }, - required: %i[location] + required: %i[locations] ) ) diff --git a/examples/tools b/examples/tools index ae31d3d..11c4613 100755 --- a/examples/tools +++ b/examples/tools @@ -5,16 +5,38 @@ require 'omniai/google' CLIENT = OmniAI::Google::Client.new +LOCATION = OmniAI::Tool::Property.object( + properties: { + city: OmniAI::Tool::Property.string(description: 'e.g. "Toronto"'), + country: OmniAI::Tool::Property.string(description: 'e.g. "Canada"'), + }, + required: %i[city country] +) + +LOCATIONS = OmniAI::Tool::Property.array( + min_items: 1, + max_items: 5, + items: LOCATION +) + +UNIT = OmniAI::Tool::Property.string(enum: %w[celcius fahrenheit]) + +WEATHER = proc do |locations:, unit: 'celsius'| + locations.map do |location| + "#{rand(20..50)}° #{unit} in #{location[:city]}, #{location[:country]}" + end.join("\n") +end + TOOL = OmniAI::Tool.new( - proc { |location:, unit: 'celsius'| "#{rand(20..50)}° #{unit} in #{location}" }, + WEATHER, name: 'Weather', description: 'Lookup the weather in a location', parameters: OmniAI::Tool::Parameters.new( properties: { - location: OmniAI::Tool::Property.string(description: 'e.g. Toronto'), - unit: OmniAI::Tool::Property.string(enum: %w[celcius fahrenheit]), + locations: LOCATIONS, + unit: UNIT, }, - required: %i[location] + required: %i[locations] ) ) diff --git a/lib/omniai/tool/array.rb b/lib/omniai/tool/array.rb new file mode 100644 index 0000000..21238fc --- /dev/null +++ b/lib/omniai/tool/array.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module OmniAI + class Tool + # Represents a schema object. + # + # @example + # array = OmniAI::Tool::Array.new( + # description: 'A list of people.', + # items: OmniAI::Tool::Object.new( + # properties: { + # name: OmniAI::Tool::Property.string(description: 'The name of the person.'), + # age: OmniAI::Tool::Property.integer(description: 'The age of the person.'), + # }, + # required: %i[name] + # ), + # min_items: 1, + # max_items: 5, + # }) + class Array + TYPE = 'array' + + # @!attribute [rw] items + # @return [OmniAI::Tool::Object, OmniAI::Tool::Array, OmniAI::Tool::Property] + attr_accessor :items + + # @!attribute [rw] max_items + # @return [Integer, nil] + attr_accessor :max_items + + # @!attribute [rw] min_items + # @return [Integer, nil] + attr_accessor :min_items + + # @!attribute [rw] description + # @return [String, nil] + attr_accessor :description + + # @param items [OmniAI::Tool::Object, OmniAI::Tool::Array, OmniAI::Tool::Property] required + # @param max_items [Integer] optional + # @param min_items [Integer] optional + # @param description [String] optional + def initialize(items:, max_items: nil, min_items: nil, description: nil) + @items = items + @description = description + @max_items = max_items + @min_items = min_items + end + + # @example + # array.serialize # => { type: 'array', items: { type: 'string' } } + # + # @return [Hash] + def serialize + { + type: TYPE, + description: @description, + items: @items.serialize, + maxItems: @max_items, + minItems: @min_items, + }.compact + end + + # @example + # array.parse(['1', '2', '3']) # => [1, 2, 3] + # @param args [Array] + # + # @return [Array] + def parse(args) + args.map { |arg| @items.parse(arg) } + end + end + end +end diff --git a/lib/omniai/tool/object.rb b/lib/omniai/tool/object.rb new file mode 100644 index 0000000..011ae4a --- /dev/null +++ b/lib/omniai/tool/object.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module OmniAI + class Tool + # Represents a schema object. + # + # @example + # object = OmniAI::Tool::Object.new( + # properties: { + # name: OmniAI::Tool::Property.string(description: 'The name of the person.'), + # age: OmniAI::Tool::Property.integer(description: 'The age of the person.'), + # }, + # required: %i[name] + # }) + class Object + TYPE = 'object' + + # @!attribute [rw] properties + # @return [Hash] + attr_accessor :properties + + # @!attribute [rw] required + # @return [Array] + attr_accessor :required + + # @!attribute [rw] description + # @return [String, nil] + attr_accessor :description + + # @param properties [Hash] + # @param required [Array] + # @return [OmniAI::Tool::Parameters] + def initialize(properties: {}, required: [], description: nil) + @properties = properties + @required = required + @description = description + end + + # @return [Hash] + def serialize + { + type: TYPE, + description: @description, + properties: @properties.transform_values(&:serialize), + required: @required, + }.compact + end + + # @param args [Hash] + # + # @return [Hash] + def parse(args) + result = {} + @properties.each do |name, property| + value = args[String(name)] + result[name.intern] = property.parse(value) unless value.nil? + end + result + end + end + end +end diff --git a/lib/omniai/tool/parameters.rb b/lib/omniai/tool/parameters.rb index 65bbd28..8a51279 100644 --- a/lib/omniai/tool/parameters.rb +++ b/lib/omniai/tool/parameters.rb @@ -2,46 +2,22 @@ module OmniAI class Tool - # Usage: + # Parameters are used to define the arguments for a tool. # - # parameters = OmniAI::Tool::Parameters.new(properties: { - # n: OmniAI::Tool::Parameters.integer(description: 'The nth number to calculate.') - # required: %i[n] - # }) - class Parameters - module Type - OBJECT = 'object' - end - - # @param type [String] - # @param properties [Hash] - # @param required [Array] - # @return [OmniAI::Tool::Parameters] - def initialize(type: Type::OBJECT, properties: {}, required: []) - @type = type - @properties = properties - @required = required - end - - # @return [Hash] - def serialize - { - type: @type, - properties: @properties.transform_values(&:serialize), - required: @required, - }.compact - end - - # @param args [Hash] - # @return [Hash] - def parse(args) - result = {} - @properties.each do |name, property| - value = args[String(name)] - result[name.intern] = property.parse(value) if value - end - result - end + # @example + # parameters = OmniAI::Tool::Parameters.new(properties: { + # people: OmniAI::Tool::Parameters.array( + # items: OmniAI::Tool::Parameters.object( + # properties: { + # name: OmniAI::Tool::Parameters.string(description: 'The name of the person.'), + # age: OmniAI::Tool::Parameters.integer(description: 'The age of the person.'), + # employeed: OmniAI::Tool::Parameters.boolean(description: 'Is the person employeed?'), + # } + # n: OmniAI::Tool::Parameters.integer(description: 'The nth number to calculate.') + # required: %i[n] + # }) + # tool = OmniAI::Tool.new(fibonacci, parameters: parameters) + class Parameters < Object end end end diff --git a/lib/omniai/tool/property.rb b/lib/omniai/tool/property.rb index 70a5555..4a966d5 100644 --- a/lib/omniai/tool/property.rb +++ b/lib/omniai/tool/property.rb @@ -2,9 +2,15 @@ module OmniAI class Tool - # Usage: + # A property used for a tool parameter. # - # property = OmniAI::Tool::Property.new(type: 'string', description: 'The nth number to calculate.') + # @example + # OmniAI::Tool::Property.array(description: '...', items: ...) + # OmniAI::Tool::Property.object(description: '...', properties: { ... }, required: %i[...]) + # OmniAI::Tool::Property.string(description: '...') + # OmniAI::Tool::Property.integer(description: '...') + # OmniAI::Tool::Property.number(description: '...') + # OmniAI::Tool::Property.boolean(description: '...') class Property module Type BOOLEAN = 'boolean' @@ -22,36 +28,80 @@ module Type # @return [Array, nil] attr_reader :enum - # @param description [String] - # @param enum [Array] + # @example + # property = OmniAI::Tool::Property.array( + # items: OmniAI::Tool::Property.string(description: 'The name of the person.'), + # description: 'A list of names.' + # min_items: 1, + # max_items: 5, + # ) + # + # @param items [OmniAI::Tool::Property] required - the items in the array + # @param min_items [Integer] optional - the minimum number of items + # @param max_items [Integer] optional - the maximum number of items + # @param description [String] optional - a description of the array + # + # @return [OmniAI::Tool::Array] + def self.array(items:, min_items: nil, max_items: nil, description: nil) + OmniAI::Tool::Array.new(items:, description:, min_items:, max_items:) + end + + # @example + # property = OmniAI::Tool::Property.object( + # properties: { + # name: OmniAI::Tool::Property.string(description: 'The name of the person.'), + # age: OmniAI::Tool::Property.integer(description: 'The age of the person.'), + # employeed: OmniAI::Tool::Property.boolean(description: 'Is the person employeed?'), + # }, + # description: 'A person.' + # required: %i[name] + # ) + # + # @param properties [Hash] required - the properties of the object + # @param requird [Array] optional - the required properties + # @param description [String] optional - a description of the object + # + # @return [OmniAI::Tool::Array] + def self.object(properties: {}, required: [], description: nil) + OmniAI::Tool::Object.new(properties:, required:, description:) + end + + # @param description [String] optional - a description of the property + # @param enum [Array] optional - the possible values of the property + # # @return [OmniAI::Tool::Property] def self.boolean(description: nil, enum: nil) new(type: Type::BOOLEAN, description:, enum:) end - # @param description [String] - # @param enum [Array] + # @param description [String] optional - a description of the property + # @param enum [Array] optinoal - the possible values of the property + # # @return [OmniAI::Tool::Property] def self.integer(description: nil, enum: nil) new(type: Type::INTEGER, description:, enum:) end - # @param description [String] - # @param enum [Array] + # @param description [String] optional - a description of the property + # @param enum [Array] optional - the possible values of the property + # # @return [OmniAI::Tool::Property] def self.string(description: nil, enum: nil) new(type: Type::STRING, description:, enum:) end - # @param description [String] - # @param enum [Array] + # @param description [String] optional - a description of the property + # @param enum [Array] optional - the possible values of the property + # # @return [OmniAI::Tool::Property] def self.number(description: nil, enum: nil) new(type: Type::NUMBER, description:, enum:) end - # @param description [String] - # @param enum [Array] + # @param type [String] required - the type of the property + # @param description [String] optional - a description of the property + # @param enum [Array] optional - the possible values of the property + # # @return [OmniAI::Tool::Property] def initialize(type:, description: nil, enum: nil) @type = type @@ -60,12 +110,7 @@ def initialize(type:, description: nil, enum: nil) end # @example - # property.serialize - # # { - # # type: 'string', - # # description: 'The unit (e.g. "fahrenheit" or "celsius").' - # # enum: %w[fahrenheit celsius] - # # } + # property.serialize #=> { type: 'string' } # # @return [Hash] def serialize @@ -76,6 +121,9 @@ def serialize }.compact end + # @example + # property.parse('123') #=> 123 + # # @return [String, Integer, Float, Boolean, Object] def parse(value) case @type diff --git a/lib/omniai/version.rb b/lib/omniai/version.rb index b34854f..4f42fc1 100644 --- a/lib/omniai/version.rb +++ b/lib/omniai/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module OmniAI - VERSION = '1.9.0' + VERSION = '1.9.1' end diff --git a/spec/factories/tool/array.rb b/spec/factories/tool/array.rb new file mode 100644 index 0000000..d39b498 --- /dev/null +++ b/spec/factories/tool/array.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :tool_array, class: 'OmniAI::Tool::Array' do + initialize_with { new(**attributes) } + + association(:items, factory: :tool_object, strategy: :build) + min_items { 2 } + max_items { 3 } + end +end diff --git a/spec/factories/tool/object.rb b/spec/factories/tool/object.rb new file mode 100644 index 0000000..808bf6a --- /dev/null +++ b/spec/factories/tool/object.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :tool_object, class: 'OmniAI::Tool::Object' do + initialize_with { new(**attributes) } + + description { 'A person.' } + properties do + { + name: build(:tool_property, :string, description: 'The name of the person.'), + age: build(:tool_property, :integer, description: 'The age of the person.'), + employeed: build(:tool_property, :boolean, description: 'Is the person employeed?'), + } + end + required { %i[name] } + end +end diff --git a/spec/factories/tool/property.rb b/spec/factories/tool/property.rb new file mode 100644 index 0000000..04805de --- /dev/null +++ b/spec/factories/tool/property.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :tool_property, class: 'OmniAI::Tool::Property' do + initialize_with { new(**attributes) } + + trait :integer do + type { OmniAI::Tool::Property::Type::INTEGER } + end + + trait :string do + type { OmniAI::Tool::Property::Type::STRING } + end + + trait :boolean do + type { OmniAI::Tool::Property::Type::BOOLEAN } + end + + trait :number do + type { OmniAI::Tool::Property::Type::NUMBER } + end + end +end diff --git a/spec/omniai/tool/array_spec.rb b/spec/omniai/tool/array_spec.rb new file mode 100644 index 0000000..e52d4e6 --- /dev/null +++ b/spec/omniai/tool/array_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +RSpec.describe OmniAI::Tool::Array do + subject(:array) { build(:tool_array) } + + it { expect(array.items).to be_a(OmniAI::Tool::Object) } + it { expect(array.min_items).to be(2) } + it { expect(array.max_items).to be(3) } + + describe '#serialize' do + subject(:serialize) { array.serialize } + + it 'returns a hash' do + expect(serialize).to eql({ + type: 'array', + items: { + type: 'object', + description: 'A person.', + properties: { + name: { type: 'string', description: 'The name of the person.' }, + age: { type: 'integer', description: 'The age of the person.' }, + employeed: { type: 'boolean', description: 'Is the person employeed?' }, + }, + required: %i[name], + }, + minItems: 2, + maxItems: 3, + }) + end + end + + describe '#parse' do + subject(:parse) do + array.parse([ + { 'name' => 'Ringo', 'age' => '50', 'employeed' => true }, + { 'name' => 'George', 'age' => '25', 'employeed' => false }, + ]) + end + + it 'parses a hash' do + expect(parse).to eql([ + { name: 'Ringo', age: 50, employeed: true }, + { name: 'George', age: 25, employeed: false }, + ]) + end + end +end diff --git a/spec/omniai/tool/object_spec.rb b/spec/omniai/tool/object_spec.rb new file mode 100644 index 0000000..70f82dc --- /dev/null +++ b/spec/omniai/tool/object_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.describe OmniAI::Tool::Object do + subject(:object) { build(:tool_object) } + + it { expect(object.properties).to be_a(Hash) } + it { expect(object.required).to be_a(Array) } + it { expect(object.description).to be_a(String) } + + describe '#serialize' do + subject(:serialize) { object.serialize } + + it 'returns a hash' do + expect(serialize).to eql({ + type: 'object', + description: 'A person.', + properties: { + name: { type: 'string', description: 'The name of the person.' }, + age: { type: 'integer', description: 'The age of the person.' }, + employeed: { type: 'boolean', description: 'Is the person employeed?' }, + }, + required: %i[name], + }) + end + end + + describe '#parse' do + subject(:parse) do + object.parse({ + 'name' => 'Ringo', + 'age' => '50', + 'employeed' => true, + }) + end + + it 'parses an object' do + expect(parse).to eql({ + name: 'Ringo', + age: 50, + employeed: true, + }) + end + end +end diff --git a/spec/omniai/tool/property_spec.rb b/spec/omniai/tool/property_spec.rb index 617f171..07f7c01 100644 --- a/spec/omniai/tool/property_spec.rb +++ b/spec/omniai/tool/property_spec.rb @@ -1,11 +1,39 @@ # frozen_string_literal: true RSpec.describe OmniAI::Tool::Property do - subject(:property) { described_class.new(type:, description:, enum:) } + subject(:property) { build(:tool_property) } - let(:type) { described_class::Type::STRING } - let(:description) { 'The unit (e.g. "fahrenheit" or "celsius")' } - let(:enum) { %w[fahrenheit celsius] } + describe '.array' do + subject(:array) do + described_class.array(items:, min_items:, max_items:) + end + + let(:min_items) { 2 } + let(:max_items) { 3 } + let(:items) { build(:tool_property, :string, description: 'A string.') } + + it { expect(array).to be_a(OmniAI::Tool::Array) } + it { expect(array.items).to eql(items) } + it { expect(array.min_items).to eql(min_items) } + it { expect(array.max_items).to eql(max_items) } + end + + describe '.object' do + subject(:object) { described_class.object(properties:, required:) } + + let(:properties) do + { + name: build(:tool_property, :string, description: 'The name of the person.'), + age: build(:tool_property, :integer, description: 'The age of the person.'), + } + end + + let(:required) { %i[name] } + + it { expect(object).to be_a(OmniAI::Tool::Object) } + it { expect(object.properties).to eql(properties) } + it { expect(object.required).to eql(required) } + end describe '.boolean' do subject(:property) { described_class.boolean } @@ -32,18 +60,44 @@ end describe '#serialize' do - it 'converts the property to a hash' do - expect(property.serialize).to eq({ - type: 'string', - description: 'The unit (e.g. "fahrenheit" or "celsius")', - enum: %w[fahrenheit celsius], - }) + subject(:serialize) { property.serialize } + + context 'with a string' do + let(:property) { build(:tool_property, :string, description: 'The unit (e.g. "F" or "C")', enum: %w[F C]) } + + it 'converts the property to a hash' do + expect(serialize).to eq({ type: 'string', description: 'The unit (e.g. "F" or "C")', enum: %w[F C] }) + end + end + + context 'with an integer' do + let(:property) { build(:tool_property, :integer) } + + it 'converts the property to a hash' do + expect(serialize).to eq({ type: 'integer' }) + end + end + + context 'with a boolean' do + let(:property) { build(:tool_property, :boolean) } + + it 'converts the property to a hash' do + expect(serialize).to eq({ type: 'boolean' }) + end + end + + context 'with a number' do + let(:property) { build(:tool_property, :number) } + + it 'converts the property to a hash' do + expect(serialize).to eq({ type: 'number' }) + end end end describe '#parse' do context 'when the type is boolean' do - let(:type) { described_class::Type::BOOLEAN } + subject(:property) { build(:tool_property, :boolean) } it 'parses the value as a boolean' do expect(property.parse(true)).to be_truthy @@ -52,7 +106,7 @@ end context 'when the type is integer' do - let(:type) { described_class::Type::INTEGER } + subject(:property) { build(:tool_property, :integer) } it 'parses the value as an integer' do expect(property.parse(0)).to eq(0) @@ -61,7 +115,7 @@ end context 'when the type is string' do - let(:type) { described_class::Type::STRING } + subject(:property) { build(:tool_property, :string) } it 'parses the value as a string' do expect(property.parse('fahrenheit')).to eq('fahrenheit') @@ -69,7 +123,7 @@ end context 'when the type is number' do - let(:type) { described_class::Type::NUMBER } + subject(:property) { build(:tool_property, :number) } it 'parses the value as a number' do expect(property.parse(0.0)).to eq(0.0)