From 060c0290c321bc4c2e328290def2b701cb74cd06 Mon Sep 17 00:00:00 2001 From: kladafox Date: Wed, 28 Aug 2024 16:56:25 +0200 Subject: [PATCH 01/37] Added embeddable --- .github/workflows/ci.yml | 12 +- Gemfile | 6 +- Gemfile.lock | 106 +++++++++++++++++- .../initializer/initializer_generator.rb | 17 +++ .../spectre/templates /spectre_initializer.rb | 9 ++ lib/spectre.rb | 34 ++++++ lib/spectre/embeddable.rb | 98 ++++++++++++++++ lib/spectre/logging.rb | 36 ++++++ lib/spectre/openai.rb | 9 ++ lib/spectre/openai/embeddings.rb | 39 +++++++ spec/examples.txt | 13 +++ spec/spec_helper.rb | 23 ++++ spec/spectre/embeddable_spec.rb | 99 ++++++++++++++++ spec/spectre_spec.rb | 39 +++++++ spec/support/test_model.rb | 40 +++++++ spectre.gemspec | 2 + 16 files changed, 574 insertions(+), 8 deletions(-) create mode 100644 lib/generators/spectre/initializer/initializer_generator.rb create mode 100644 lib/generators/spectre/templates /spectre_initializer.rb create mode 100644 lib/spectre.rb create mode 100644 lib/spectre/embeddable.rb create mode 100644 lib/spectre/logging.rb create mode 100644 lib/spectre/openai.rb create mode 100644 lib/spectre/openai/embeddings.rb create mode 100644 spec/examples.txt create mode 100644 spec/spec_helper.rb create mode 100644 spec/spectre/embeddable_spec.rb create mode 100644 spec/spectre_spec.rb create mode 100644 spec/support/test_model.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1291a48..049ece9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,10 @@ jobs: ruby-version: 3.3.4 bundler-cache: true - # Uncomment and use the following steps for running tests - # - name: Build and test with rspec - # env: - # RUBYOPT: -W:no-deprecated - # run: bundle exec rspec spec + - name: Install dependencies + run: bundle install + + - name: Build and test with rspec + env: + RUBYOPT: -W:no-deprecated + run: bundle exec rspec spec \ No newline at end of file diff --git a/Gemfile b/Gemfile index be173b2..f32691c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,9 @@ # frozen_string_literal: true -source "https://rubygems.org" +source 'https://rubygems.org' gemspec + +group :development, :test do + gem 'rspec-rails' +end diff --git a/Gemfile.lock b/Gemfile.lock index 5f10d75..c24a856 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,12 +6,114 @@ PATH GEM remote: https://rubygems.org/ specs: + actionpack (7.1.4) + actionview (= 7.1.4) + activesupport (= 7.1.4) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actionview (7.1.4) + activesupport (= 7.1.4) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activesupport (7.1.4) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) + base64 (0.2.0) + bigdecimal (3.1.8) + builder (3.3.0) + concurrent-ruby (1.3.4) + connection_pool (2.4.1) + crass (1.0.6) + diff-lcs (1.5.1) + drb (2.2.1) + erubi (1.13.0) + i18n (1.14.5) + concurrent-ruby (~> 1.0) + io-console (0.7.2) + irb (1.14.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + loofah (2.22.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + minitest (5.25.1) + mutex_m (0.2.0) + nokogiri (1.16.7-arm64-darwin) + racc (~> 1.4) + psych (5.1.2) + stringio + racc (1.8.1) + rack (3.1.7) + rack-session (2.0.0) + rack (>= 3.0.0) + rack-test (2.1.0) + rack (>= 1.3) + rackup (2.1.0) + rack (>= 3) + webrick (~> 1.8) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + railties (7.1.4) + actionpack (= 7.1.4) + activesupport (= 7.1.4) + irb + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + rake (13.2.1) + rdoc (6.7.0) + psych (>= 4.0.0) + reline (0.5.9) + io-console (~> 0.5) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.4) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.1) + stringio (3.1.1) + thor (1.3.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + webrick (1.8.1) + zeitwerk (2.6.17) PLATFORMS - arm64-darwin-23 - ruby + arm64-darwin DEPENDENCIES + rspec-rails spectre! BUNDLED WITH diff --git a/lib/generators/spectre/initializer/initializer_generator.rb b/lib/generators/spectre/initializer/initializer_generator.rb new file mode 100644 index 0000000..db20413 --- /dev/null +++ b/lib/generators/spectre/initializer/initializer_generator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails/generators' + +module Spectre + module Generators + class InitializerGenerator < Rails::Generators::Base + source_root File.expand_path('templates', __dir__) + + desc "Creates a Spectre initializer file for configuring the gem" + + def create_initializer_file + template "spectre_initializer.rb", "config/initializers/spectre.rb" + end + end + end +end \ No newline at end of file diff --git a/lib/generators/spectre/templates /spectre_initializer.rb b/lib/generators/spectre/templates /spectre_initializer.rb new file mode 100644 index 0000000..e472970 --- /dev/null +++ b/lib/generators/spectre/templates /spectre_initializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +Spectre.setup do |config| + # Set the API key for OpenAI + config.api_key = ENV['CHATGPT_API_TOKEN'] + + # Optionally set the log level (e.g., Logger::DEBUG, Logger::INFO) + Spectre::Logging.logger.level = Logger::INFO +end diff --git a/lib/spectre.rb b/lib/spectre.rb new file mode 100644 index 0000000..3caf103 --- /dev/null +++ b/lib/spectre.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "spectre/version" +require "spectre/embeddable" +require "spectre/openai" +require "spectre/logging" +require "generators/spectre/initializer/initializer_generator" + +module Spectre + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + def spectre(*modules) + modules.each do |mod| + case mod + when :embeddable + include Spectre::Embeddable + else + raise ArgumentError, "Unknown spectre module: #{mod}" + end + end + end + end + + class << self + attr_accessor :api_key + + def setup + yield self + end + end +end \ No newline at end of file diff --git a/lib/spectre/embeddable.rb b/lib/spectre/embeddable.rb new file mode 100644 index 0000000..dc0c32b --- /dev/null +++ b/lib/spectre/embeddable.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require_relative 'logging' +require_relative 'openai' + +module Spectre + module Embeddable + include Spectre::Logging + + class NoEmbeddableFieldsError < StandardError; end + class EmbeddingValidationError < StandardError; end + + def self.included(base) + base.extend ClassMethods + end + + # Converts the specified fields into a JSON representation suitable for embedding. + # + # @return [String] A JSON string representing the vectorized content of the specified fields. + # + # @raise [NoEmbeddableFieldsError] if no embeddable fields are defined in the model. + # + def as_vector + raise NoEmbeddableFieldsError, "Embeddable fields are not defined" if self.class.embeddable_fields.empty? + + vector_data = self.class.embeddable_fields.map { |field| [field, send(field)] }.to_h + vector_data.to_json + end + + # Embeds the vectorized content and saves it to the specified fields. + # + # @param validation [Proc, nil] A validation block that returns true if the embedding should proceed. + # @param embedding_field [Symbol] The field in which to store the generated embedding (default: :embedding). + # @param timestamp_field [Symbol] The field in which to store the embedding timestamp (default: :embedded_at). + # + # @example + # embed!(validation: -> { !self.response.nil? }, embedding_field: :custom_embedding, timestamp_field: :custom_embedded_at) + # + # @raise [EmbeddingValidationError] if the validation block fails. + # + def embed!(validation: nil, embedding_field: :embedding, timestamp_field: :embedded_at) + if validation && !instance_exec(&validation) + raise EmbeddingValidationError, "Validation failed for embedding" + end + + embedding_value = Spectre::Openai::Embeddings.generate(as_vector) + send("#{embedding_field}=", embedding_value) + send("#{timestamp_field}=", Time.now) + save! + end + + module ClassMethods + include Spectre::Logging + + def embeddable_field(*fields) + @embeddable_fields = fields + end + + def embeddable_fields + @embeddable_fields ||= [] + end + + # Embeds the vectorized content for all records that match the optional scope + # and pass the validation check. Saves the embedding and timestamp to the specified fields. + # + # @param scope [Proc, nil] A scope or query to filter records (default: all records). + # @param validation [Proc, nil] A validation block that returns true if the embedding should proceed for a record. + # @param embedding_field [Symbol] The field in which to store the generated embedding (default: :embedding). + # @param timestamp_field [Symbol] The field in which to store the embedding timestamp (default: :embedded_at). + # + # @example + # embed_all!( + # scope: -> { where(:response.exists => true) }, + # validation: ->(record) { !record.response.nil? }, + # embedding_field: :custom_embedding, + # timestamp_field: :custom_embedded_at + # ) + # + def embed_all!(scope: nil, validation: nil, embedding_field: :embedding, timestamp_field: :embedded_at) + records = scope ? instance_exec(&scope) : all + + records.each do |record| + begin + record.embed!( + validation: validation, + embedding_field: embedding_field, + timestamp_field: timestamp_field + ) + rescue EmbeddingValidationError => e + log_error("Failed to embed record #{record.id}: #{e.message}") + rescue => e + log_error("Unexpected error embedding record #{record.id}: #{e.message}") + end + end + end + end + end +end diff --git a/lib/spectre/logging.rb b/lib/spectre/logging.rb new file mode 100644 index 0000000..a14fdc8 --- /dev/null +++ b/lib/spectre/logging.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'logger' +require 'time' + +module Spectre + module Logging + def logger + @logger ||= create_logger + end + + def log_error(message) + logger.error(message) + end + + def log_info(message) + logger.info(message) + end + + def log_debug(message) + logger.debug(message) + end + + private + + def create_logger + Logger.new(STDOUT).tap do |log| + log.progname = 'Spectre' + log.level = Logger::DEBUG # Set the default log level (can be changed to INFO, WARN, etc.) + log.formatter = proc do |severity, datetime, progname, msg| + "#{datetime.utc.iso8601} #{severity} #{progname}: #{msg}\n" + end + end + end + end +end diff --git a/lib/spectre/openai.rb b/lib/spectre/openai.rb new file mode 100644 index 0000000..64df87c --- /dev/null +++ b/lib/spectre/openai.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Spectre + module Openai + # Require each specific client file here + require_relative 'openai/embeddings' + # require_relative 'openai/completions' + end +end diff --git a/lib/spectre/openai/embeddings.rb b/lib/spectre/openai/embeddings.rb new file mode 100644 index 0000000..53122e0 --- /dev/null +++ b/lib/spectre/openai/embeddings.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' +require 'uri' + +module Spectre + module Openai + class Embeddings + API_URL = 'https://api.openai.com/v1/embeddings' + MODEL = 'text-embedding-3-small' + + def self.generate(text) + api_key = Spectre.api_key + raise "API key is not configured" unless api_key + + uri = URI(API_URL) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + + request = Net::HTTP::Post.new(uri.path, { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{api_key}" + }) + + request.body = { model: MODEL, input: text }.to_json + response = http.request(request) + + unless response.is_a?(Net::HTTPSuccess) + raise "OpenAI API Error: #{response.body}" + end + + JSON.parse(response.body).dig('data', 0, 'embedding') + rescue JSON::ParserError => e + raise "JSON Parse Error: #{e.message}" + end + end + end +end diff --git a/spec/examples.txt b/spec/examples.txt new file mode 100644 index 0000000..580c4c3 --- /dev/null +++ b/spec/examples.txt @@ -0,0 +1,13 @@ +example_id | status | run_time | +------------------------------------------ | ------ | --------------- | +./spec/spectre/embeddable_spec.rb[1:1:1:1] | passed | 0.00017 seconds | +./spec/spectre/embeddable_spec.rb[1:1:2:1] | passed | 0.0001 seconds | +./spec/spectre/embeddable_spec.rb[1:2:1:1] | passed | 0.00301 seconds | +./spec/spectre/embeddable_spec.rb[1:2:2:1] | passed | 0.00044 seconds | +./spec/spectre/embeddable_spec.rb[1:3:1] | passed | 0.00003 seconds | +./spec/spectre/embeddable_spec.rb[1:4:1:1] | passed | 0.00012 seconds | +./spec/spectre/embeddable_spec.rb[1:4:2:1] | passed | 0.00024 seconds | +./spec/spectre_spec.rb[1:1:1] | passed | 0.00023 seconds | +./spec/spectre_spec.rb[1:1:2] | passed | 0.00043 seconds | +./spec/spectre_spec.rb[1:2:1] | passed | 0.00003 seconds | +./spec/spectre_spec.rb[1:3:1] | passed | 0.00027 seconds | diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..28b0a03 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spectre' + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = "spec/examples.txt" + config.disable_monkey_patching! + config.warnings = true + config.default_formatter = "doc" if config.files_to_run.one? + config.order = :random + Kernel.srand config.seed +end diff --git a/spec/spectre/embeddable_spec.rb b/spec/spectre/embeddable_spec.rb new file mode 100644 index 0000000..38fee51 --- /dev/null +++ b/spec/spectre/embeddable_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'support/test_model' + +RSpec.describe Spectre::Embeddable do + before do + TestModel.clear_all + TestModel.embeddable_field :field1, :field2 + end + + describe '#as_vector' do + context 'when embeddable fields are defined' do + it 'returns a JSON representation of the embeddable fields' do + model = TestModel.new(field1: 'value1', field2: 'value2') + expected_json = { 'field1' => 'value1', 'field2' => 'value2' }.to_json + + expect(model.as_vector).to eq(expected_json) + end + end + + context 'when no embeddable fields are defined' do + it 'raises a NoEmbeddableFieldsError' do + # Reset embeddable_fields to simulate a model without defined embeddable fields + allow(TestModel).to receive(:embeddable_fields).and_return([]) + + model = TestModel.new(field1: 'value1', field2: 'value2') + + expect { model.as_vector }.to raise_error(Spectre::Embeddable::NoEmbeddableFieldsError, 'Embeddable fields are not defined') + end + end + end + + describe '#embed!' do + before do + allow(Spectre::Openai::Embeddings).to receive(:generate).and_return('embedded_value') + end + + context 'when validation passes' do + it 'embeds and saves the vectorized content' do + model = TestModel.new(field1: 'value1', field2: 'value2') + + expect { model.embed! }.to change { model.embedding }.from(nil).to('embedded_value') + expect(model.embedded_at).not_to be_nil + end + end + + context 'when validation fails' do + it 'raises an EmbeddingValidationError' do + model = TestModel.new(field1: 'value1', field2: 'value2') + + expect { + model.embed!(validation: -> { false }) + }.to raise_error(Spectre::Embeddable::EmbeddingValidationError, 'Validation failed for embedding') + end + end + end + + describe '.embeddable_field' do + it 'sets the embeddable fields for the class' do + expect(TestModel.embeddable_fields).to eq([:field1, :field2]) + end + end + + describe '.embed_all!' do + before do + allow(Spectre::Openai::Embeddings).to receive(:generate).and_return('embedded_value') + end + + context 'for all records' do + it 'embeds and saves the vectorized content' do + TestModel.create!(field1: 'value1', field2: 'value2') + TestModel.create!(field1: 'value3', field2: 'value4') + + expect(TestModel.all.size).to eq(2) + + TestModel.embed_all!(embedding_field: :embedding, timestamp_field: :embedded_at) + + TestModel.all.each do |record| + expect(record.embedding).to eq('embedded_value') + expect(record.embedded_at).not_to be_nil + end + end + end + + context 'when errors occur during embedding' do + it 'handles errors gracefully' do + allow_any_instance_of(TestModel).to receive(:embed!).and_raise(Spectre::Embeddable::EmbeddingValidationError) + + TestModel.create!(field1: 'value1', field2: 'value2') + + expect { + TestModel.embed_all!(embedding_field: :embedding, timestamp_field: :embedded_at) + }.to_not raise_error + end + end + end +end \ No newline at end of file diff --git a/spec/spectre_spec.rb b/spec/spectre_spec.rb new file mode 100644 index 0000000..767b58b --- /dev/null +++ b/spec/spectre_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'spectre' + +RSpec.describe Spectre do + describe '.setup' do + it 'yields the configuration to the block' do + yielded = false + Spectre.setup do |config| + yielded = true + end + expect(yielded).to be true + end + + it 'allows setting the api_key' do + api_key = 'test_api_key' + Spectre.setup do |config| + config.api_key = api_key + end + expect(Spectre.api_key).to eq(api_key) + end + end + + describe '.configuration' do + it 'returns the current configuration' do + Spectre.setup do |config| + config.api_key = 'test_key' + end + expect(Spectre.api_key).to eq('test_key') + end + end + + describe '.version' do + it 'returns the correct version' do + expect(Spectre::VERSION).to match(/\A\d+\.\d+\.\d+\z/) + end + end +end diff --git a/spec/support/test_model.rb b/spec/support/test_model.rb new file mode 100644 index 0000000..fc39f64 --- /dev/null +++ b/spec/support/test_model.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spectre' + +class TestModel + include Spectre + + spectre :embeddable + + attr_accessor :id, :field1, :field2, :embedding, :embedded_at + + @@id_counter = 0 + + def initialize(field1:, field2:) + @id = (@@id_counter += 1) # Simple incremental ID + @field1 = field1 + @field2 = field2 + @embedding = nil + @embedded_at = nil + end + + def save! + # Simulate saving to a database + true + end + + def self.all + @all ||= [] + end + + def self.create!(attributes) + new_instance = new(**attributes) + @all << new_instance + new_instance + end + + def self.clear_all + @all = [] + end +end \ No newline at end of file diff --git a/spectre.gemspec b/spectre.gemspec index 78064e1..45a338e 100644 --- a/spectre.gemspec +++ b/spectre.gemspec @@ -14,5 +14,7 @@ Gem::Specification.new do |s| s.files = Dir.glob("lib/**/*") + %w[README.md CHANGELOG.md] s.require_paths = ["lib"] + # Development dependencies + s.add_development_dependency 'rspec-rails' s.required_ruby_version = ">= 3" end From ddf33c33a9cbd6f3ea893941c2ba3db6806c541b Mon Sep 17 00:00:00 2001 From: kladafox Date: Wed, 28 Aug 2024 16:57:50 +0200 Subject: [PATCH 02/37] Added bundle lock platform x86_64-linux --- Gemfile.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index c24a856..1d9ff92 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -54,6 +54,8 @@ GEM mutex_m (0.2.0) nokogiri (1.16.7-arm64-darwin) racc (~> 1.4) + nokogiri (1.16.7-x86_64-linux) + racc (~> 1.4) psych (5.1.2) stringio racc (1.8.1) @@ -111,6 +113,7 @@ GEM PLATFORMS arm64-darwin + x86_64-linux DEPENDENCIES rspec-rails From 6e7b8b09ffe487fd31e2ba3774a6df073376da13 Mon Sep 17 00:00:00 2001 From: kladafox Date: Wed, 28 Aug 2024 17:05:40 +0200 Subject: [PATCH 03/37] Adjust rspec setup --- spec/examples.txt | 13 ------------- spec/spec_helper.rb | 3 +-- 2 files changed, 1 insertion(+), 15 deletions(-) delete mode 100644 spec/examples.txt diff --git a/spec/examples.txt b/spec/examples.txt deleted file mode 100644 index 580c4c3..0000000 --- a/spec/examples.txt +++ /dev/null @@ -1,13 +0,0 @@ -example_id | status | run_time | ------------------------------------------- | ------ | --------------- | -./spec/spectre/embeddable_spec.rb[1:1:1:1] | passed | 0.00017 seconds | -./spec/spectre/embeddable_spec.rb[1:1:2:1] | passed | 0.0001 seconds | -./spec/spectre/embeddable_spec.rb[1:2:1:1] | passed | 0.00301 seconds | -./spec/spectre/embeddable_spec.rb[1:2:2:1] | passed | 0.00044 seconds | -./spec/spectre/embeddable_spec.rb[1:3:1] | passed | 0.00003 seconds | -./spec/spectre/embeddable_spec.rb[1:4:1:1] | passed | 0.00012 seconds | -./spec/spectre/embeddable_spec.rb[1:4:2:1] | passed | 0.00024 seconds | -./spec/spectre_spec.rb[1:1:1] | passed | 0.00023 seconds | -./spec/spectre_spec.rb[1:1:2] | passed | 0.00043 seconds | -./spec/spectre_spec.rb[1:2:1] | passed | 0.00003 seconds | -./spec/spectre_spec.rb[1:3:1] | passed | 0.00027 seconds | diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 28b0a03..a6e7f56 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -14,10 +14,9 @@ config.shared_context_metadata_behavior = :apply_to_host_groups config.filter_run_when_matching :focus - config.example_status_persistence_file_path = "spec/examples.txt" config.disable_monkey_patching! config.warnings = true - config.default_formatter = "doc" if config.files_to_run.one? + config.default_formatter = "progress" if config.files_to_run.one? config.order = :random Kernel.srand config.seed end From 006cbcd613a1a771257939d287e4628b05131f66 Mon Sep 17 00:00:00 2001 From: kladafox Date: Wed, 28 Aug 2024 17:46:13 +0200 Subject: [PATCH 04/37] Added embeddings class --- Gemfile | 1 + Gemfile.lock | 15 +++++ lib/spectre/openai/embeddings.rb | 25 ++++++-- spec/spec_helper.rb | 2 + spec/spectre/openai/embeddings_spec.rb | 79 ++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 spec/spectre/openai/embeddings_spec.rb diff --git a/Gemfile b/Gemfile index f32691c..2adccdb 100644 --- a/Gemfile +++ b/Gemfile @@ -6,4 +6,5 @@ gemspec group :development, :test do gem 'rspec-rails' + gem 'webmock' end diff --git a/Gemfile.lock b/Gemfile.lock index 1d9ff92..b888a17 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -32,15 +32,21 @@ GEM minitest (>= 5.1) mutex_m tzinfo (~> 2.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) base64 (0.2.0) bigdecimal (3.1.8) builder (3.3.0) concurrent-ruby (1.3.4) connection_pool (2.4.1) + crack (1.0.0) + bigdecimal + rexml crass (1.0.6) diff-lcs (1.5.1) drb (2.2.1) erubi (1.13.0) + hashdiff (1.1.1) i18n (1.14.5) concurrent-ruby (~> 1.0) io-console (0.7.2) @@ -58,6 +64,7 @@ GEM racc (~> 1.4) psych (5.1.2) stringio + public_suffix (6.0.1) racc (1.8.1) rack (3.1.7) rack-session (2.0.0) @@ -87,6 +94,8 @@ GEM psych (>= 4.0.0) reline (0.5.9) io-console (~> 0.5) + rexml (3.3.6) + strscan rspec-core (3.13.0) rspec-support (~> 3.13.0) rspec-expectations (3.13.2) @@ -105,9 +114,14 @@ GEM rspec-support (~> 3.13) rspec-support (3.13.1) stringio (3.1.1) + strscan (3.1.0) thor (1.3.1) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + webmock (3.23.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) webrick (1.8.1) zeitwerk (2.6.17) @@ -118,6 +132,7 @@ PLATFORMS DEPENDENCIES rspec-rails spectre! + webmock BUNDLED WITH 2.5.11 diff --git a/lib/spectre/openai/embeddings.rb b/lib/spectre/openai/embeddings.rb index 53122e0..11b3d46 100644 --- a/lib/spectre/openai/embeddings.rb +++ b/lib/spectre/openai/embeddings.rb @@ -6,33 +6,46 @@ module Spectre module Openai + class APIKeyNotConfiguredError < StandardError; end + class Embeddings API_URL = 'https://api.openai.com/v1/embeddings' - MODEL = 'text-embedding-3-small' - - def self.generate(text) + DEFAULT_MODEL = 'text-embedding-3-small' + + # Class method to generate embeddings for a given text + # + # @param text [String] the text input for which embeddings are to be generated + # @param model [String] the model to be used for generating embeddings, defaults to DEFAULT_MODEL + # @return [Array] the generated embedding vector + # @raise [APIKeyNotConfiguredError] if the API key is not set + # @raise [RuntimeError] for general API errors or unexpected issues + def self.generate(text, model: DEFAULT_MODEL) api_key = Spectre.api_key - raise "API key is not configured" unless api_key + raise APIKeyNotConfiguredError, "API key is not configured" unless api_key uri = URI(API_URL) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true + http.read_timeout = 10 # seconds + http.open_timeout = 10 # seconds request = Net::HTTP::Post.new(uri.path, { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{api_key}" }) - request.body = { model: MODEL, input: text }.to_json + request.body = { model: model, input: text }.to_json response = http.request(request) unless response.is_a?(Net::HTTPSuccess) - raise "OpenAI API Error: #{response.body}" + raise "OpenAI API Error: #{response.code} - #{response.message}: #{response.body}" end JSON.parse(response.body).dig('data', 0, 'embedding') rescue JSON::ParserError => e raise "JSON Parse Error: #{e.message}" + rescue Net::OpenTimeout, Net::ReadTimeout => e + raise "Request Timeout: #{e.message}" end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a6e7f56..c118730 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true require 'spectre' +require 'webmock/rspec' RSpec.configure do |config| + WebMock.disable_net_connect!(allow_localhost: true) config.expect_with :rspec do |expectations| expectations.include_chain_clauses_in_custom_matcher_descriptions = true end diff --git a/spec/spectre/openai/embeddings_spec.rb b/spec/spectre/openai/embeddings_spec.rb new file mode 100644 index 0000000..d2ae0c3 --- /dev/null +++ b/spec/spectre/openai/embeddings_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Spectre::Openai::Embeddings do + let(:api_key) { 'test_api_key' } + let(:text) { 'example text' } + let(:embedding) { [0.1, 0.2, 0.3] } + let(:response_body) { { data: [{ embedding: embedding }] }.to_json } + + before do + allow(Spectre).to receive(:api_key).and_return(api_key) + end + + describe '.generate' do + context 'when the API key is not configured' do + before do + allow(Spectre).to receive(:api_key).and_return(nil) + end + + it 'raises an APIKeyNotConfiguredError' do + expect { + described_class.generate(text) + }.to raise_error(Spectre::Openai::APIKeyNotConfiguredError, 'API key is not configured') + end + end + + context 'when the request is successful' do + before do + stub_request(:post, Spectre::Openai::Embeddings::API_URL) + .to_return(status: 200, body: response_body, headers: { 'Content-Type' => 'application/json' }) + end + + it 'returns the embedding' do + result = described_class.generate(text) + expect(result).to eq(embedding) + end + end + + context 'when the API returns an error' do + before do + stub_request(:post, Spectre::Openai::Embeddings::API_URL) + .to_return(status: 500, body: 'Internal Server Error') + end + + it 'raises an error with the API response' do + expect { + described_class.generate(text) + }.to raise_error(RuntimeError, /OpenAI API Error/) + end + end + + context 'when the response is not valid JSON' do + before do + stub_request(:post, Spectre::Openai::Embeddings::API_URL) + .to_return(status: 200, body: 'Invalid JSON') + end + + it 'raises a JSON Parse Error' do + expect { + described_class.generate(text) + }.to raise_error(RuntimeError, /JSON Parse Error/) + end + end + + context 'when the request times out' do + before do + stub_request(:post, Spectre::Openai::Embeddings::API_URL) + .to_timeout + end + + it 'raises a Request Timeout error' do + expect { + described_class.generate(text) + }.to raise_error(RuntimeError, /Request Timeout/) + end + end + end +end \ No newline at end of file From bceb3854ff4c71ea67ea6651e98ffa7e6324198c Mon Sep 17 00:00:00 2001 From: kladafox Date: Wed, 28 Aug 2024 19:44:20 +0200 Subject: [PATCH 05/37] Some corrections --- ...ializer_generator.rb => install_generator.rb} | 6 +----- .../spectre/templates /spectre_initializer.rb | 9 --------- .../spectre/templates/spectre_initializer.rb | 6 ++++++ lib/spectre.rb | 1 - lib/spectre/embeddable.rb | 16 +++++++++++++--- spec/spectre/embeddable_spec.rb | 2 +- 6 files changed, 21 insertions(+), 19 deletions(-) rename lib/generators/spectre/{initializer/initializer_generator.rb => install_generator.rb} (62%) delete mode 100644 lib/generators/spectre/templates /spectre_initializer.rb create mode 100644 lib/generators/spectre/templates/spectre_initializer.rb diff --git a/lib/generators/spectre/initializer/initializer_generator.rb b/lib/generators/spectre/install_generator.rb similarity index 62% rename from lib/generators/spectre/initializer/initializer_generator.rb rename to lib/generators/spectre/install_generator.rb index db20413..d3edd13 100644 --- a/lib/generators/spectre/initializer/initializer_generator.rb +++ b/lib/generators/spectre/install_generator.rb @@ -1,14 +1,10 @@ # frozen_string_literal: true -require 'rails/generators' - module Spectre module Generators - class InitializerGenerator < Rails::Generators::Base + class InstallGenerator < Rails::Generators::Base source_root File.expand_path('templates', __dir__) - desc "Creates a Spectre initializer file for configuring the gem" - def create_initializer_file template "spectre_initializer.rb", "config/initializers/spectre.rb" end diff --git a/lib/generators/spectre/templates /spectre_initializer.rb b/lib/generators/spectre/templates /spectre_initializer.rb deleted file mode 100644 index e472970..0000000 --- a/lib/generators/spectre/templates /spectre_initializer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -Spectre.setup do |config| - # Set the API key for OpenAI - config.api_key = ENV['CHATGPT_API_TOKEN'] - - # Optionally set the log level (e.g., Logger::DEBUG, Logger::INFO) - Spectre::Logging.logger.level = Logger::INFO -end diff --git a/lib/generators/spectre/templates/spectre_initializer.rb b/lib/generators/spectre/templates/spectre_initializer.rb new file mode 100644 index 0000000..0049b1c --- /dev/null +++ b/lib/generators/spectre/templates/spectre_initializer.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +Spectre.setup do |config| + # Set the API key for OpenAI + config.api_key = ENV.fetch('CHATGPT_API_TOKEN') +end diff --git a/lib/spectre.rb b/lib/spectre.rb index 3caf103..ac65362 100644 --- a/lib/spectre.rb +++ b/lib/spectre.rb @@ -4,7 +4,6 @@ require "spectre/embeddable" require "spectre/openai" require "spectre/logging" -require "generators/spectre/initializer/initializer_generator" module Spectre def self.included(base) diff --git a/lib/spectre/embeddable.rb b/lib/spectre/embeddable.rb index dc0c32b..4b7c4de 100644 --- a/lib/spectre/embeddable.rb +++ b/lib/spectre/embeddable.rb @@ -34,12 +34,12 @@ def as_vector # @param timestamp_field [Symbol] The field in which to store the embedding timestamp (default: :embedded_at). # # @example - # embed!(validation: -> { !self.response.nil? }, embedding_field: :custom_embedding, timestamp_field: :custom_embedded_at) + # embed!(validation: ->(record) { !record.response.nil? }, embedding_field: :custom_embedding, timestamp_field: :custom_embedded_at) # # @raise [EmbeddingValidationError] if the validation block fails. # def embed!(validation: nil, embedding_field: :embedding, timestamp_field: :embedded_at) - if validation && !instance_exec(&validation) + if validation && !validation.call(self) raise EmbeddingValidationError, "Validation failed for embedding" end @@ -62,6 +62,7 @@ def embeddable_fields # Embeds the vectorized content for all records that match the optional scope # and pass the validation check. Saves the embedding and timestamp to the specified fields. + # Also counts the number of successful and failed embeddings. # # @param scope [Proc, nil] A scope or query to filter records (default: all records). # @param validation [Proc, nil] A validation block that returns true if the embedding should proceed for a record. @@ -70,7 +71,7 @@ def embeddable_fields # # @example # embed_all!( - # scope: -> { where(:response.exists => true) }, + # scope: -> { where(:response.exists => true, :response.ne => nil) }, # validation: ->(record) { !record.response.nil? }, # embedding_field: :custom_embedding, # timestamp_field: :custom_embedded_at @@ -79,6 +80,9 @@ def embeddable_fields def embed_all!(scope: nil, validation: nil, embedding_field: :embedding, timestamp_field: :embedded_at) records = scope ? instance_exec(&scope) : all + success_count = 0 + failure_count = 0 + records.each do |record| begin record.embed!( @@ -86,12 +90,18 @@ def embed_all!(scope: nil, validation: nil, embedding_field: :embedding, timesta embedding_field: embedding_field, timestamp_field: timestamp_field ) + success_count += 1 rescue EmbeddingValidationError => e log_error("Failed to embed record #{record.id}: #{e.message}") + failure_count += 1 rescue => e log_error("Unexpected error embedding record #{record.id}: #{e.message}") + failure_count += 1 end end + + puts "Successfully embedded #{success_count} records." + puts "Failed to embed #{failure_count} records." end end end diff --git a/spec/spectre/embeddable_spec.rb b/spec/spectre/embeddable_spec.rb index 38fee51..921d77b 100644 --- a/spec/spectre/embeddable_spec.rb +++ b/spec/spectre/embeddable_spec.rb @@ -51,7 +51,7 @@ model = TestModel.new(field1: 'value1', field2: 'value2') expect { - model.embed!(validation: -> { false }) + model.embed!(validation: ->(model) { false }) }.to raise_error(Spectre::Embeddable::EmbeddingValidationError, 'Validation failed for embedding') end end From 22dfd6d1ce1d16994b27079e31c33beeb765cca4 Mon Sep 17 00:00:00 2001 From: kladafox Date: Wed, 28 Aug 2024 19:57:17 +0200 Subject: [PATCH 06/37] Added Readme --- README.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 25ddcc4..483ca83 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,79 @@ -# VantaBlack Spectre +# Spectre +**Spectre** is a Ruby gem designed to provide an abstraction layer for generating embeddings using OpenAI's API. This gem simplifies the process of embedding data fields within your Rails models. ## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'spectre' +``` +And then execute: +```bash +bundle install +``` +Or install it yourself as: +```bash +gem install spectre +``` +## Usage + +### 1. Setup + +First, you’ll need to generate the initializer to configure your OpenAI API key. Run the following command to create the initializer: +```bash +rails generate spectre:install +``` +This will create a file at config/initializers/spectre.rb, where you can set your OpenAI API key: +```ruby +Spectre.setup do |config| + config.api_key = 'your_openai_api_key' +end +``` +### 2. Integrate Spectre with Your Model + +To use Spectre for generating embeddings in your Rails model, follow these steps: + +1. Include the Spectre module: +Include Spectre in your model to enable the spectre method. +2. Declare the Model as Embeddable: +Use the spectre :embeddable declaration to make the model embeddable. +3. Define the Embeddable Fields: +Use the embeddable_field method to specify which fields should be used to generate the embeddings. + +Here is an example of how to set this up in a model: +```ruby +class Model + include Mongoid::Document + include Spectre + + spectre :embeddable + embeddable_field :message, :response, :category +end +``` +### 3. Generating Embeddings + +**Generate Embedding for a Single Record** + +To generate an embedding for a single record, you can call the embed! method on the instance: +```ruby +record = Model.find(some_id) +record.embed! +``` +This will generate the embedding and store it in the specified embedding field, along with the timestamp in the embedded_at field. + +**Generate Embeddings for Multiple Records** + +To generate embeddings for multiple records at once, use the embed_all! method: +```ruby +Model.embed_all!( + scope: -> { where(:response.exists => true, :response.ne => nil) }, + validation: ->(record) { !record.response.blank? } +) +``` +This method will generate embeddings for all records that match the given scope and validation criteria. The method will also print the number of successful and failed embeddings to the console. + +## Contributing +Bug reports and pull requests are welcome on GitHub at https://github.com/hiremav/spectre. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct. + From 404528fc9f6a920e5d9b04f0908691722ca681c3 Mon Sep 17 00:00:00 2001 From: kladafox Date: Wed, 28 Aug 2024 20:01:25 +0200 Subject: [PATCH 07/37] Update readme --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 483ca83..0feeec7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Spectre -**Spectre** is a Ruby gem designed to provide an abstraction layer for generating embeddings using OpenAI's API. This gem simplifies the process of embedding data fields within your Rails models. +**Spectre** is a Ruby gem designed to provide an abstraction layer for generating embeddings using OpenAI's API... +This gem simplifies the process of embedding data fields within your Rails models. ## Installation @@ -74,6 +75,20 @@ Model.embed_all!( ``` This method will generate embeddings for all records that match the given scope and validation criteria. The method will also print the number of successful and failed embeddings to the console. +**Directly Generate Embeddings Using Spectre::Openai::Embeddings.generate** + +If you need to generate an embedding directly without using the model integration, you can use the Spectre::Openai::Embeddings.generate method. This can be useful if you want to generate embeddings for custom text outside of your models: + +```ruby +Spectre::Openai::Embeddings.generate("Your text here") +``` + +This method sends the text to OpenAI’s API and returns the embedding vector. You can optionally specify a different model by passing it as an argument: + +```ruby +Spectre::Openai::Embeddings.generate("Your text here", model: "text-embedding-3-large") +``` + ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/hiremav/spectre. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct. From 68179c925cd651c85b08ac78aea62633db050269 Mon Sep 17 00:00:00 2001 From: kladafox Date: Thu, 29 Aug 2024 15:32:51 +0200 Subject: [PATCH 08/37] Added configuration option llm_provider --- .../spectre/templates/spectre_initializer.rb | 4 +- lib/spectre.rb | 22 +++++++- lib/spectre/embeddable.rb | 2 +- spec/spectre/embeddable_spec.rb | 1 + spec/spectre_spec.rb | 51 ++++++++++++++++++- 5 files changed, 76 insertions(+), 4 deletions(-) diff --git a/lib/generators/spectre/templates/spectre_initializer.rb b/lib/generators/spectre/templates/spectre_initializer.rb index 0049b1c..bbde34d 100644 --- a/lib/generators/spectre/templates/spectre_initializer.rb +++ b/lib/generators/spectre/templates/spectre_initializer.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true Spectre.setup do |config| - # Set the API key for OpenAI + # Chose your LLM (openai, cohere, ollama) + config.llm_provider = :openai + # Set the API key for your chosen LLM config.api_key = ENV.fetch('CHATGPT_API_TOKEN') end diff --git a/lib/spectre.rb b/lib/spectre.rb index ac65362..4c0669d 100644 --- a/lib/spectre.rb +++ b/lib/spectre.rb @@ -6,6 +6,12 @@ require "spectre/logging" module Spectre + VALID_LLM_PROVIDERS = { + openai: Spectre::Openai, + # cohere: Spectre::Cohere, + # ollama: Spectre::Ollama + }.freeze + def self.included(base) base.extend ClassMethods end @@ -24,10 +30,24 @@ def spectre(*modules) end class << self - attr_accessor :api_key + attr_accessor :api_key, :llm_provider def setup yield self + validate_llm_provider! + end + + def provider_module + VALID_LLM_PROVIDERS[llm_provider] || raise("LLM provider #{llm_provider} not supported") + end + + private + + def validate_llm_provider! + unless VALID_LLM_PROVIDERS.keys.include?(llm_provider) + raise ArgumentError, "Invalid llm_provider: #{llm_provider}. Must be one of: #{VALID_LLM_PROVIDERS.keys.join(', ')}" + end end + end end \ No newline at end of file diff --git a/lib/spectre/embeddable.rb b/lib/spectre/embeddable.rb index 4b7c4de..98edfed 100644 --- a/lib/spectre/embeddable.rb +++ b/lib/spectre/embeddable.rb @@ -43,7 +43,7 @@ def embed!(validation: nil, embedding_field: :embedding, timestamp_field: :embed raise EmbeddingValidationError, "Validation failed for embedding" end - embedding_value = Spectre::Openai::Embeddings.generate(as_vector) + embedding_value = Spectre.provider_module::Embeddings.generate(as_vector) send("#{embedding_field}=", embedding_value) send("#{timestamp_field}=", Time.now) save! diff --git a/spec/spectre/embeddable_spec.rb b/spec/spectre/embeddable_spec.rb index 921d77b..a1a9251 100644 --- a/spec/spectre/embeddable_spec.rb +++ b/spec/spectre/embeddable_spec.rb @@ -8,6 +8,7 @@ before do TestModel.clear_all TestModel.embeddable_field :field1, :field2 + allow(Spectre).to receive(:llm_provider).and_return(:openai) end describe '#as_vector' do diff --git a/spec/spectre_spec.rb b/spec/spectre_spec.rb index 767b58b..1c22fef 100644 --- a/spec/spectre_spec.rb +++ b/spec/spectre_spec.rb @@ -9,16 +9,63 @@ yielded = false Spectre.setup do |config| yielded = true + config.llm_provider = :openai end expect(yielded).to be true end - it 'allows setting the api_key' do + it 'allows setting the api_key and llm_provider' do api_key = 'test_api_key' + llm_provider = :openai Spectre.setup do |config| config.api_key = api_key + config.llm_provider = llm_provider end expect(Spectre.api_key).to eq(api_key) + expect(Spectre.llm_provider).to eq(llm_provider) + end + + it 'raises an error for an invalid llm_provider' do + expect { + Spectre.setup do |config| + config.llm_provider = :invalid_provider + end + }.to raise_error(ArgumentError, "Invalid llm_provider: invalid_provider. Must be one of: openai") + end + end + + describe '.provider_module' do + it 'returns the correct module for the :openai provider' do + Spectre.setup do |config| + config.llm_provider = :openai + end + expect(Spectre.provider_module).to eq(Spectre::Openai) + end + + # it 'returns the correct module for the :cohere provider' do + # Spectre.setup do |config| + # config.llm_provider = :cohere + # end + # expect(Spectre.provider_module).to eq(Spectre::Cohere) + # end + # + # it 'returns the correct module for the :ollama provider' do + # Spectre.setup do |config| + # config.llm_provider = :ollama + # end + # expect(Spectre.provider_module).to eq(Spectre::Ollama) + # end + + it 'raises an error for an unsupported provider' do + Spectre.setup do |config| + config.llm_provider = :openai + end + + allow(Spectre).to receive(:llm_provider).and_return(:unsupported_provider) + + expect { + Spectre.provider_module + }.to raise_error(RuntimeError, "LLM provider unsupported_provider not supported") end end @@ -26,8 +73,10 @@ it 'returns the current configuration' do Spectre.setup do |config| config.api_key = 'test_key' + config.llm_provider = :openai end expect(Spectre.api_key).to eq('test_key') + expect(Spectre.llm_provider).to eq(:openai) end end From 22307585706abcbfa98bb241ac582e0590e3d24d Mon Sep 17 00:00:00 2001 From: kladafox Date: Thu, 29 Aug 2024 15:33:41 +0200 Subject: [PATCH 09/37] Added empty line to the EOF --- lib/spectre.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spectre.rb b/lib/spectre.rb index 4c0669d..244f3b0 100644 --- a/lib/spectre.rb +++ b/lib/spectre.rb @@ -50,4 +50,4 @@ def validate_llm_provider! end end -end \ No newline at end of file +end From 7657d68e58e1d15c872717eed8b2116ed9f0d112 Mon Sep 17 00:00:00 2001 From: kladafox Date: Thu, 29 Aug 2024 15:34:21 +0200 Subject: [PATCH 10/37] Correct spec --- spec/spectre/embeddable_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/spectre/embeddable_spec.rb b/spec/spectre/embeddable_spec.rb index a1a9251..ac0e845 100644 --- a/spec/spectre/embeddable_spec.rb +++ b/spec/spectre/embeddable_spec.rb @@ -8,7 +8,9 @@ before do TestModel.clear_all TestModel.embeddable_field :field1, :field2 - allow(Spectre).to receive(:llm_provider).and_return(:openai) + Spectre.setup do |config| + config.llm_provider = :openai + end end describe '#as_vector' do From 8e01805f9477382f4d073776c9159bfc8495ac11 Mon Sep 17 00:00:00 2001 From: kladafox Date: Thu, 29 Aug 2024 17:22:56 +0200 Subject: [PATCH 11/37] Added searchable module --- lib/spectre.rb | 3 + lib/spectre/searchable.rb | 117 ++++++++++++++++++++++++++++++++ spec/spectre/searchable_spec.rb | 59 ++++++++++++++++ spec/support/test_model.rb | 18 ++++- 4 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 lib/spectre/searchable.rb create mode 100644 spec/spectre/searchable_spec.rb diff --git a/lib/spectre.rb b/lib/spectre.rb index 244f3b0..67336c4 100644 --- a/lib/spectre.rb +++ b/lib/spectre.rb @@ -2,6 +2,7 @@ require "spectre/version" require "spectre/embeddable" +require 'spectre/searchable' require "spectre/openai" require "spectre/logging" @@ -22,6 +23,8 @@ def spectre(*modules) case mod when :embeddable include Spectre::Embeddable + when :searchable + include Spectre::Searchable else raise ArgumentError, "Unknown spectre module: #{mod}" end diff --git a/lib/spectre/searchable.rb b/lib/spectre/searchable.rb new file mode 100644 index 0000000..0ae5d4d --- /dev/null +++ b/lib/spectre/searchable.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module Spectre + module Searchable + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + # Configure the path to the embedding field for the vector search. + # + # @param path [String] The path to the embedding field. + def configure_spectre_search_path(path) + @search_path = path + end + + # Configure the index to be used for the vector search. + # + # @param index [String] The name of the vector index. + def configure_spectre_search_index(index) + @search_index = index + end + + # Configure the default fields to include in the search results. + # + # @param fields [Hash] The fields to include in the results, with their MongoDB projection configuration. + def configure_spectre_result_fields(fields) + @result_fields = fields + end + + # Provide access to the configured search path. + # + # @return [String] The configured search path. + def search_path + @search_path || 'embedding' # Default to 'embedding' if not configured + end + + # Provide access to the configured search index. + # + # @return [String] The configured search index. + def search_index + @search_index || 'vector_index' # Default to 'vector_index' if not configured + end + + # Provide access to the configured result fields. + # + # @return [Hash, nil] The configured result fields, or nil if not configured. + def result_fields + @result_fields + end + + # Searches based on a query string by first embedding the query. + # + # @param query [String] The text query to embed and search for. + # @param limit [Integer] The maximum number of results to return (default: 5). + # @param additional_scopes [Array] Additional MongoDB aggregation stages to filter or modify results. + # @param custom_result_fields [Hash, nil] Custom fields to include in the search results, overriding the default. + # + # @return [Array] The search results, including the configured fields and score. + # + # @example Basic search with configured result fields + # results = CognitiveResponse.search("What is AI?") + # + # @example Search with custom result fields + # results = CognitiveResponse.search( + # "What is AI?", + # limit: 10, + # custom_result_fields: { "some_additional_field": 1, "another_field": 1 } + # ) + # + # @example Search with additional filtering using scopes + # results = CognitiveResponse.search( + # "What is AI?", + # limit: 10, + # additional_scopes: [{ "$match": { "some_field": "some_value" } }] + # ) + # + # @example Combining custom result fields and additional scopes + # results = CognitiveResponse.search( + # "What is AI?", + # limit: 10, + # additional_scopes: [{ "$match": { "some_field": "some_value" } }], + # custom_result_fields: { "some_additional_field": 1, "another_field": 1 } + # ) + # + def search(query, limit: 5, additional_scopes: [], custom_result_fields: nil) + # Generate the embedding for the query string + embedded_query = Spectre.provider_module::Embeddings.generate(query) + + # Build the MongoDB aggregation pipeline + pipeline = [ + { + "$vectorSearch": { + "queryVector": embedded_query, + "path": search_path, + "numCandidates": 100, + "limit": limit, + "index": search_index + } + } + ] + + # Add any additional scopes provided + pipeline.concat(additional_scopes) if additional_scopes.any? + + # Determine the fields to include in the results + fields_to_project = custom_result_fields || result_fields || {} + fields_to_project["score"] = { "$meta": "vectorSearchScore" } + + # Add the project stage with the fields to project + pipeline << { "$project": fields_to_project } + + self.collection.aggregate(pipeline).to_a + end + end + end +end diff --git a/spec/spectre/searchable_spec.rb b/spec/spectre/searchable_spec.rb new file mode 100644 index 0000000..9835b17 --- /dev/null +++ b/spec/spectre/searchable_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'support/test_model' + +RSpec.describe Spectre::Searchable do + before do + TestModel.clear_all + TestModel.create!(field1: 'What is AI?', field2: 'Artificial Intelligence') + TestModel.create!(field1: 'Machine Learning', field2: 'AI in ML') + Spectre.setup do |config| + config.llm_provider = :openai + end + allow(Spectre::Openai::Embeddings).to receive(:generate).and_return([0.1, 0.2, 0.3]) + + # Mock the aggregate method on the collection to simulate MongoDB's aggregation pipeline + allow(TestModel.collection).to receive(:aggregate).and_return([ + { 'field1' => 'What is AI?', 'field2' => 'Artificial Intelligence', 'score' => 0.99 }, + { 'field1' => 'Machine Learning', 'field2' => 'AI in ML', 'score' => 0.95 } + ]) + end + + describe '.search' do + context 'with default configuration' do + it 'returns matching records with vectorSearchScore' do + results = TestModel.search('AI') + expect(results).to be_an(Array) + expect(results.size).to be > 0 + expect(results.first.keys).to include('field1', 'field2', 'score') + end + end + + context 'with custom result fields' do + it 'returns only specified fields and vectorSearchScore' do + custom_fields = { 'field2' => 1 } + allow(TestModel.collection).to receive(:aggregate).and_return([ + { 'field2' => 'Artificial Intelligence', 'score' => 0.99 } + ]) + + results = TestModel.search('AI', custom_result_fields: custom_fields) + expect(results.first.keys).to include('field2', 'score') + expect(results.first.keys).not_to include('field1') + end + end + + context 'with additional scopes' do + it 'applies additional filtering to the search results' do + additional_scopes = [{ "$match": { "field1": "Machine Learning" } }] + allow(TestModel.collection).to receive(:aggregate).and_return([ + { 'field1' => 'Machine Learning', 'field2' => 'AI in ML', 'score' => 0.95 } + ]) + + results = TestModel.search('AI', additional_scopes: additional_scopes) + expect(results.size).to eq(1) + expect(results.first['field1']).to eq('Machine Learning') + end + end + end +end diff --git a/spec/support/test_model.rb b/spec/support/test_model.rb index fc39f64..b379032 100644 --- a/spec/support/test_model.rb +++ b/spec/support/test_model.rb @@ -5,7 +5,7 @@ class TestModel include Spectre - spectre :embeddable + spectre :embeddable, :searchable attr_accessor :id, :field1, :field2, :embedding, :embedded_at @@ -37,4 +37,18 @@ def self.create!(attributes) def self.clear_all @all = [] end -end \ No newline at end of file + + # Implement the `collection` method to return self (for mocking) + def self.collection + self + end + + def self.aggregate(pipeline) + # This will be mocked in the spec file + end + + # Configure search path, index, and result fields + configure_spectre_search_path 'embedding' + configure_spectre_search_index 'vector_index' + configure_spectre_result_fields({ "field1": 1, "field2": 1 }) +end From b7ce8c54b3b4042763ea742ac360b924a568787d Mon Sep 17 00:00:00 2001 From: kladafox Date: Thu, 29 Aug 2024 17:33:26 +0200 Subject: [PATCH 12/37] Updated readme --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0feeec7..a0f691d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # Spectre -**Spectre** is a Ruby gem designed to provide an abstraction layer for generating embeddings using OpenAI's API... -This gem simplifies the process of embedding data fields within your Rails models. +**Spectre** is a Ruby gem designed to provide an abstraction layer for generating embeddings using OpenAI's API and for performing vector-based searches. This gem simplifies the process of embedding data fields and searching within your Rails models. ## Installation @@ -30,10 +29,13 @@ This will create a file at config/initializers/spectre.rb, where you can set you ```ruby Spectre.setup do |config| config.api_key = 'your_openai_api_key' + config.llm_provider = :openai # Options: :openai end ``` ### 2. Integrate Spectre with Your Model +**2.1. Embeddable Module** + To use Spectre for generating embeddings in your Rails model, follow these steps: 1. Include the Spectre module: @@ -53,6 +55,35 @@ class Model embeddable_field :message, :response, :category end ``` + +**2.2. Searchable Module** + +To enable vector-based search in your Rails model: + +1. Include the Spectre module: +Include Spectre in your model to enable the spectre method. +2. Declare the Model as Searchable: +Use the spectre :searchable declaration to make the model searchable. +3. Configure Search Parameters: +Use the following methods to configure the search path, index, and result fields: +• configure_spectre_search_path: Set the path where the embeddings are stored. +• configure_spectre_search_index: Set the index used for the vector search. +• configure_spectre_result_fields: Set the fields to include in the search results. + +Here is an example of how to set this up in a model: + +```ruby +class Model + include Mongoid::Document + include Spectre + + spectre :searchable + configure_spectre_search_path 'embedding' + configure_spectre_search_index 'vector_index' + configure_spectre_result_fields({ "message": 1, "response": 1 }) +end +``` + ### 3. Generating Embeddings **Generate Embedding for a Single Record** @@ -75,20 +106,44 @@ Model.embed_all!( ``` This method will generate embeddings for all records that match the given scope and validation criteria. The method will also print the number of successful and failed embeddings to the console. -**Directly Generate Embeddings Using Spectre::Openai::Embeddings.generate** +**Directly Generate Embeddings Using Spectre.provider_module::Embeddings.generate** If you need to generate an embedding directly without using the model integration, you can use the Spectre::Openai::Embeddings.generate method. This can be useful if you want to generate embeddings for custom text outside of your models: ```ruby -Spectre::Openai::Embeddings.generate("Your text here") +Spectre.provider_module::Embeddings.generate("Your text here") ``` This method sends the text to OpenAI’s API and returns the embedding vector. You can optionally specify a different model by passing it as an argument: ```ruby -Spectre::Openai::Embeddings.generate("Your text here", model: "text-embedding-3-large") +Spectre.provider_module::Embeddings.generate("Your text here", model: "text-embedding-3-large") +``` + +### 4. Performing Vector-Based Searches + +Once your model is configured as searchable, you can perform vector-based searches on the stored embeddings: + +```ruby +Model.search('Your search query', custom_result_fields: { "response" => 1 }, additional_scopes: [{ "$match": { "category": "science" } }]) ``` +This method will: + +• Embed the search query using the configured LLM provider. + +• Perform a vector-based search on the embeddings stored in the specified search_path. + +• Return the matching records with the specified result_fields and their vectorSearchScore. + +**Examples:** + +• **Custom Result Fields**: Limit the fields returned in the search results. + +• **Additional Scopes**: Apply additional MongoDB filters to the search results. + + + ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/hiremav/spectre. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct. From ee83bd95bdfdaecfb7613e022032a04786f8aa6e Mon Sep 17 00:00:00 2001 From: kladafox Date: Fri, 30 Aug 2024 15:33:36 +0200 Subject: [PATCH 13/37] Added completions class --- README.md | 23 +++++++ lib/spectre.rb | 2 + lib/spectre/openai.rb | 2 +- lib/spectre/openai/completions.rb | 69 +++++++++++++++++++++ lib/spectre/openai/embeddings.rb | 2 - spec/spectre/openai/completions_spec.rb | 80 +++++++++++++++++++++++++ spec/spectre/openai/embeddings_spec.rb | 2 +- 7 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 lib/spectre/openai/completions.rb create mode 100644 spec/spectre/openai/completions_spec.rb diff --git a/README.md b/README.md index a0f691d..1d562c6 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,30 @@ This method will: • **Additional Scopes**: Apply additional MongoDB filters to the search results. +### 5. Generating Completions +Spectre also provides an interface to generate text completions using the LLM provider. This can be useful for generating responses, messages, or other forms of text. + +**Generate a Completion** + +To generate a text completion, use the Spectre.provider_module::Completions.generate method: + +```ruby +Spectre.provider_module::Completions.generate( + user_prompt: "Tell me a joke.", + system_prompt: "You are a funny assistant." +) +``` + +This method sends the prompts to the LLM provider’s API and returns the generated completion. You can optionally specify a different model by passing it as an argument: + +```ruby +Spectre.provider_module::Completions.generate( + user_prompt: "Tell me a joke.", + system_prompt: "You are a funny assistant.", + model: "gpt-4-turbo" +) +``` ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/hiremav/spectre. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct. diff --git a/lib/spectre.rb b/lib/spectre.rb index 67336c4..7348417 100644 --- a/lib/spectre.rb +++ b/lib/spectre.rb @@ -7,6 +7,8 @@ require "spectre/logging" module Spectre + class APIKeyNotConfiguredError < StandardError; end + VALID_LLM_PROVIDERS = { openai: Spectre::Openai, # cohere: Spectre::Cohere, diff --git a/lib/spectre/openai.rb b/lib/spectre/openai.rb index 64df87c..701b2e4 100644 --- a/lib/spectre/openai.rb +++ b/lib/spectre/openai.rb @@ -4,6 +4,6 @@ module Spectre module Openai # Require each specific client file here require_relative 'openai/embeddings' - # require_relative 'openai/completions' + require_relative 'openai/completions' end end diff --git a/lib/spectre/openai/completions.rb b/lib/spectre/openai/completions.rb new file mode 100644 index 0000000..f2ae943 --- /dev/null +++ b/lib/spectre/openai/completions.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' +require 'uri' + +module Spectre + module Openai + class Completions + API_URL = 'https://api.openai.com/v1/completions' + DEFAULT_MODEL = 'gpt-4o-mini' + + # Class method to generate a completion based on a user prompt + # + # @param user_prompt [String] the user's input to generate a completion for + # @param system_prompt [String] an optional system prompt to guide the AI's behavior + # @param model [String] the model to be used for generating completions, defaults to DEFAULT_MODEL + # @return [String] the generated completion text + # @raise [APIKeyNotConfiguredError] if the API key is not set + # @raise [RuntimeError] for general API errors or unexpected issues + def self.generate(user_prompt, system_prompt: "You are a helpful assistant.", model: DEFAULT_MODEL) + api_key = Spectre.api_key + raise APIKeyNotConfiguredError, "API key is not configured" unless api_key + + uri = URI(API_URL) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.read_timeout = 10 # seconds + http.open_timeout = 10 # seconds + + request = Net::HTTP::Post.new(uri.path, { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{api_key}" + }) + + request.body = generate_body(user_prompt, system_prompt, model).to_json + response = http.request(request) + + unless response.is_a?(Net::HTTPSuccess) + raise "OpenAI API Error: #{response.code} - #{response.message}: #{response.body}" + end + + JSON.parse(response.body).dig('choices', 0, 'message', 'content') + rescue JSON::ParserError => e + raise "JSON Parse Error: #{e.message}" + rescue Net::OpenTimeout, Net::ReadTimeout => e + raise "Request Timeout: #{e.message}" + end + + private + + # Helper method to generate the request body + # + # @param user_prompt [String] the user's input to generate a completion for + # @param system_prompt [String] an optional system prompt to guide the AI's behavior + # @param model [String] the model to be used for generating completions + # @return [Hash] the body for the API request + def self.generate_body(user_prompt, system_prompt, model) + { + model: model, + messages: [ + { role: 'system', content: system_prompt }, + { role: 'user', content: user_prompt } + ] + } + end + end + end +end diff --git a/lib/spectre/openai/embeddings.rb b/lib/spectre/openai/embeddings.rb index 11b3d46..4ffbcc6 100644 --- a/lib/spectre/openai/embeddings.rb +++ b/lib/spectre/openai/embeddings.rb @@ -6,8 +6,6 @@ module Spectre module Openai - class APIKeyNotConfiguredError < StandardError; end - class Embeddings API_URL = 'https://api.openai.com/v1/embeddings' DEFAULT_MODEL = 'text-embedding-3-small' diff --git a/spec/spectre/openai/completions_spec.rb b/spec/spectre/openai/completions_spec.rb new file mode 100644 index 0000000..27512b6 --- /dev/null +++ b/spec/spectre/openai/completions_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Spectre::Openai::Completions do + let(:api_key) { 'test_api_key' } + let(:user_prompt) { 'Tell me a joke.' } + let(:system_prompt) { 'You are a funny assistant.' } + let(:completion) { 'Why did the chicken cross the road? To get to the other side!' } + let(:response_body) { { choices: [{ message: { content: completion } }] }.to_json } + + before do + allow(Spectre).to receive(:api_key).and_return(api_key) + end + + describe '.generate' do + context 'when the API key is not configured' do + before do + allow(Spectre).to receive(:api_key).and_return(nil) + end + + it 'raises an APIKeyNotConfiguredError' do + expect { + described_class.generate(user_prompt, system_prompt: system_prompt) + }.to raise_error(Spectre::APIKeyNotConfiguredError, 'API key is not configured') + end + end + + context 'when the request is successful' do + before do + stub_request(:post, Spectre::Openai::Completions::API_URL) + .to_return(status: 200, body: response_body, headers: { 'Content-Type' => 'application/json' }) + end + + it 'returns the completion text' do + result = described_class.generate(user_prompt, system_prompt: system_prompt) + expect(result).to eq(completion) + end + end + + context 'when the API returns an error' do + before do + stub_request(:post, Spectre::Openai::Completions::API_URL) + .to_return(status: 500, body: 'Internal Server Error') + end + + it 'raises an error with the API response' do + expect { + described_class.generate(user_prompt, system_prompt: system_prompt) + }.to raise_error(RuntimeError, /OpenAI API Error/) + end + end + + context 'when the response is not valid JSON' do + before do + stub_request(:post, Spectre::Openai::Completions::API_URL) + .to_return(status: 200, body: 'Invalid JSON') + end + + it 'raises a JSON Parse Error' do + expect { + described_class.generate(user_prompt, system_prompt: system_prompt) + }.to raise_error(RuntimeError, /JSON Parse Error/) + end + end + + context 'when the request times out' do + before do + stub_request(:post, Spectre::Openai::Completions::API_URL) + .to_timeout + end + + it 'raises a Request Timeout error' do + expect { + described_class.generate(user_prompt, system_prompt: system_prompt) + }.to raise_error(RuntimeError, /Request Timeout/) + end + end + end +end diff --git a/spec/spectre/openai/embeddings_spec.rb b/spec/spectre/openai/embeddings_spec.rb index d2ae0c3..dfd456d 100644 --- a/spec/spectre/openai/embeddings_spec.rb +++ b/spec/spectre/openai/embeddings_spec.rb @@ -21,7 +21,7 @@ it 'raises an APIKeyNotConfiguredError' do expect { described_class.generate(text) - }.to raise_error(Spectre::Openai::APIKeyNotConfiguredError, 'API key is not configured') + }.to raise_error(Spectre::APIKeyNotConfiguredError, 'API key is not configured') end end From da2578e8308e6b43c80a7ab496249b05f345ea70 Mon Sep 17 00:00:00 2001 From: kladafox Date: Fri, 30 Aug 2024 15:34:09 +0200 Subject: [PATCH 14/37] Added empty lines to the EOFs --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 049ece9..daf0d0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,4 +20,4 @@ jobs: - name: Build and test with rspec env: RUBYOPT: -W:no-deprecated - run: bundle exec rspec spec \ No newline at end of file + run: bundle exec rspec spec diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ef2bb5..78fcceb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,4 +27,4 @@ jobs: chmod 0600 $HOME/.gem/credentials printf -- "---\n:github: Bearer ${GITHUB_TOKEN}\n" > $HOME/.gem/credentials gem build *.gemspec - gem push *.gem --key github --host https://rubygems.pkg.github.com/${REPO_OWNER} \ No newline at end of file + gem push *.gem --key github --host https://rubygems.pkg.github.com/${REPO_OWNER} From cb068e52a55bb2c452fb2b650e503b7df542f8b6 Mon Sep 17 00:00:00 2001 From: kladafox Date: Fri, 30 Aug 2024 15:35:32 +0200 Subject: [PATCH 15/37] Added empty lines to the EOFs --- lib/generators/spectre/install_generator.rb | 2 +- spec/spectre/embeddable_spec.rb | 2 +- spec/spectre/openai/embeddings_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/generators/spectre/install_generator.rb b/lib/generators/spectre/install_generator.rb index d3edd13..7c18b96 100644 --- a/lib/generators/spectre/install_generator.rb +++ b/lib/generators/spectre/install_generator.rb @@ -10,4 +10,4 @@ def create_initializer_file end end end -end \ No newline at end of file +end diff --git a/spec/spectre/embeddable_spec.rb b/spec/spectre/embeddable_spec.rb index ac0e845..640cfea 100644 --- a/spec/spectre/embeddable_spec.rb +++ b/spec/spectre/embeddable_spec.rb @@ -99,4 +99,4 @@ end end end -end \ No newline at end of file +end diff --git a/spec/spectre/openai/embeddings_spec.rb b/spec/spectre/openai/embeddings_spec.rb index dfd456d..f87efe9 100644 --- a/spec/spectre/openai/embeddings_spec.rb +++ b/spec/spectre/openai/embeddings_spec.rb @@ -76,4 +76,4 @@ end end end -end \ No newline at end of file +end From fd040f4b870db1f22b88b323c840b33e7930dd38 Mon Sep 17 00:00:00 2001 From: kladafox Date: Fri, 30 Aug 2024 15:49:18 +0200 Subject: [PATCH 16/37] Change searchable search to be vector_search --- lib/spectre/searchable.rb | 10 +++++----- spec/spectre/searchable_spec.rb | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/spectre/searchable.rb b/lib/spectre/searchable.rb index 0ae5d4d..4d0910c 100644 --- a/lib/spectre/searchable.rb +++ b/lib/spectre/searchable.rb @@ -59,31 +59,31 @@ def result_fields # @return [Array] The search results, including the configured fields and score. # # @example Basic search with configured result fields - # results = CognitiveResponse.search("What is AI?") + # results = Model.vector_search("What is AI?") # # @example Search with custom result fields - # results = CognitiveResponse.search( + # results = Model.vector_search( # "What is AI?", # limit: 10, # custom_result_fields: { "some_additional_field": 1, "another_field": 1 } # ) # # @example Search with additional filtering using scopes - # results = CognitiveResponse.search( + # results = Model.vector_search( # "What is AI?", # limit: 10, # additional_scopes: [{ "$match": { "some_field": "some_value" } }] # ) # # @example Combining custom result fields and additional scopes - # results = CognitiveResponse.search( + # results = Model.vector_search( # "What is AI?", # limit: 10, # additional_scopes: [{ "$match": { "some_field": "some_value" } }], # custom_result_fields: { "some_additional_field": 1, "another_field": 1 } # ) # - def search(query, limit: 5, additional_scopes: [], custom_result_fields: nil) + def vector_search(query, limit: 5, additional_scopes: [], custom_result_fields: nil) # Generate the embedding for the query string embedded_query = Spectre.provider_module::Embeddings.generate(query) diff --git a/spec/spectre/searchable_spec.rb b/spec/spectre/searchable_spec.rb index 9835b17..793b209 100644 --- a/spec/spectre/searchable_spec.rb +++ b/spec/spectre/searchable_spec.rb @@ -23,7 +23,7 @@ describe '.search' do context 'with default configuration' do it 'returns matching records with vectorSearchScore' do - results = TestModel.search('AI') + results = TestModel.vector_search('AI') expect(results).to be_an(Array) expect(results.size).to be > 0 expect(results.first.keys).to include('field1', 'field2', 'score') @@ -37,7 +37,7 @@ { 'field2' => 'Artificial Intelligence', 'score' => 0.99 } ]) - results = TestModel.search('AI', custom_result_fields: custom_fields) + results = TestModel.vector_search('AI', custom_result_fields: custom_fields) expect(results.first.keys).to include('field2', 'score') expect(results.first.keys).not_to include('field1') end @@ -50,7 +50,7 @@ { 'field1' => 'Machine Learning', 'field2' => 'AI in ML', 'score' => 0.95 } ]) - results = TestModel.search('AI', additional_scopes: additional_scopes) + results = TestModel.vector_search('AI', additional_scopes: additional_scopes) expect(results.size).to eq(1) expect(results.first['field1']).to eq('Machine Learning') end From f24ae9de856e1bad8ff297b8869049bf337a6a44 Mon Sep 17 00:00:00 2001 From: kladafox Date: Fri, 30 Aug 2024 15:51:48 +0200 Subject: [PATCH 17/37] Updated readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d562c6..96b2aaf 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Spectre.provider_module::Embeddings.generate("Your text here", model: "text-embe Once your model is configured as searchable, you can perform vector-based searches on the stored embeddings: ```ruby -Model.search('Your search query', custom_result_fields: { "response" => 1 }, additional_scopes: [{ "$match": { "category": "science" } }]) +Model.vector_search('Your search query', custom_result_fields: { "response" => 1 }, additional_scopes: [{ "$match": { "category": "science" } }]) ``` This method will: From e37fda72fff1bbbb044a6b3339c3f3538fd588b7 Mon Sep 17 00:00:00 2001 From: kladafox Date: Fri, 30 Aug 2024 16:27:13 +0200 Subject: [PATCH 18/37] Correct completions url --- lib/spectre/openai/completions.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spectre/openai/completions.rb b/lib/spectre/openai/completions.rb index f2ae943..f92763a 100644 --- a/lib/spectre/openai/completions.rb +++ b/lib/spectre/openai/completions.rb @@ -7,7 +7,7 @@ module Spectre module Openai class Completions - API_URL = 'https://api.openai.com/v1/completions' + API_URL = 'https://api.openai.com/v1/chat/completions' DEFAULT_MODEL = 'gpt-4o-mini' # Class method to generate a completion based on a user prompt From e2b4ffcf6b2691490317e11e1b53ad4ba533fbcd Mon Sep 17 00:00:00 2001 From: kladafox Date: Fri, 30 Aug 2024 16:36:55 +0200 Subject: [PATCH 19/37] Added assistant_prompt to completions --- lib/spectre/openai/completions.rb | 21 ++++++++++++++------- spec/spectre/openai/completions_spec.rb | 11 ++++++----- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/lib/spectre/openai/completions.rb b/lib/spectre/openai/completions.rb index f92763a..d00e87c 100644 --- a/lib/spectre/openai/completions.rb +++ b/lib/spectre/openai/completions.rb @@ -14,11 +14,12 @@ class Completions # # @param user_prompt [String] the user's input to generate a completion for # @param system_prompt [String] an optional system prompt to guide the AI's behavior + # @param assistant_prompt [String] an optional assistant prompt to provide context for the assistant's behavior # @param model [String] the model to be used for generating completions, defaults to DEFAULT_MODEL # @return [String] the generated completion text # @raise [APIKeyNotConfiguredError] if the API key is not set # @raise [RuntimeError] for general API errors or unexpected issues - def self.generate(user_prompt, system_prompt: "You are a helpful assistant.", model: DEFAULT_MODEL) + def self.generate(user_prompt:, system_prompt: "You are a helpful assistant.", assistant_prompt: nil, model: DEFAULT_MODEL) api_key = Spectre.api_key raise APIKeyNotConfiguredError, "API key is not configured" unless api_key @@ -33,7 +34,7 @@ def self.generate(user_prompt, system_prompt: "You are a helpful assistant.", mo 'Authorization' => "Bearer #{api_key}" }) - request.body = generate_body(user_prompt, system_prompt, model).to_json + request.body = generate_body(user_prompt, system_prompt, assistant_prompt, model).to_json response = http.request(request) unless response.is_a?(Net::HTTPSuccess) @@ -53,15 +54,21 @@ def self.generate(user_prompt, system_prompt: "You are a helpful assistant.", mo # # @param user_prompt [String] the user's input to generate a completion for # @param system_prompt [String] an optional system prompt to guide the AI's behavior + # @param assistant_prompt [String] an optional assistant prompt to provide context for the assistant's behavior # @param model [String] the model to be used for generating completions # @return [Hash] the body for the API request - def self.generate_body(user_prompt, system_prompt, model) + def self.generate_body(user_prompt, system_prompt, assistant_prompt, model) + messages = [ + { role: 'system', content: system_prompt }, + { role: 'user', content: user_prompt } + ] + + # Add the assistant prompt if provided + messages << { role: 'assistant', content: assistant_prompt } if assistant_prompt + { model: model, - messages: [ - { role: 'system', content: system_prompt }, - { role: 'user', content: user_prompt } - ] + messages: messages } end end diff --git a/spec/spectre/openai/completions_spec.rb b/spec/spectre/openai/completions_spec.rb index 27512b6..b2204a8 100644 --- a/spec/spectre/openai/completions_spec.rb +++ b/spec/spectre/openai/completions_spec.rb @@ -6,6 +6,7 @@ let(:api_key) { 'test_api_key' } let(:user_prompt) { 'Tell me a joke.' } let(:system_prompt) { 'You are a funny assistant.' } + let(:assistant_prompt) { 'Sure, here\'s a joke!' } let(:completion) { 'Why did the chicken cross the road? To get to the other side!' } let(:response_body) { { choices: [{ message: { content: completion } }] }.to_json } @@ -21,7 +22,7 @@ it 'raises an APIKeyNotConfiguredError' do expect { - described_class.generate(user_prompt, system_prompt: system_prompt) + described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt) }.to raise_error(Spectre::APIKeyNotConfiguredError, 'API key is not configured') end end @@ -33,7 +34,7 @@ end it 'returns the completion text' do - result = described_class.generate(user_prompt, system_prompt: system_prompt) + result = described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt, assistant_prompt: assistant_prompt) expect(result).to eq(completion) end end @@ -46,7 +47,7 @@ it 'raises an error with the API response' do expect { - described_class.generate(user_prompt, system_prompt: system_prompt) + described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt) }.to raise_error(RuntimeError, /OpenAI API Error/) end end @@ -59,7 +60,7 @@ it 'raises a JSON Parse Error' do expect { - described_class.generate(user_prompt, system_prompt: system_prompt) + described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt) }.to raise_error(RuntimeError, /JSON Parse Error/) end end @@ -72,7 +73,7 @@ it 'raises a Request Timeout error' do expect { - described_class.generate(user_prompt, system_prompt: system_prompt) + described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt) }.to raise_error(RuntimeError, /Request Timeout/) end end From 566af603308dbdaf8a705bdbbafbbf0a27e7c504 Mon Sep 17 00:00:00 2001 From: kladafox Date: Fri, 30 Aug 2024 16:51:59 +0200 Subject: [PATCH 20/37] Updated readme --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 96b2aaf..ffe5f2d 100644 --- a/README.md +++ b/README.md @@ -157,13 +157,14 @@ Spectre.provider_module::Completions.generate( ) ``` -This method sends the prompts to the LLM provider’s API and returns the generated completion. You can optionally specify a different model by passing it as an argument: +This method sends the prompts to the LLM provider’s API and returns the generated completion. You can optionally specify a different model by passing it as an argument and set assistant_prompt: ```ruby Spectre.provider_module::Completions.generate( user_prompt: "Tell me a joke.", system_prompt: "You are a funny assistant.", - model: "gpt-4-turbo" + model: "gpt-4-turbo", + assistant_prompt: "Assistant prompt here." ) ``` From b04286c011e5a395841e49a4401ccb12c0ba83bc Mon Sep 17 00:00:00 2001 From: kladafox Date: Fri, 30 Aug 2024 18:29:39 +0200 Subject: [PATCH 21/37] Refactor searchable --- lib/spectre/searchable.rb | 10 ++++++++-- spec/spectre/searchable_spec.rb | 25 ++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/spectre/searchable.rb b/lib/spectre/searchable.rb index 4d0910c..962e2d3 100644 --- a/lib/spectre/searchable.rb +++ b/lib/spectre/searchable.rb @@ -84,8 +84,14 @@ def result_fields # ) # def vector_search(query, limit: 5, additional_scopes: [], custom_result_fields: nil) - # Generate the embedding for the query string - embedded_query = Spectre.provider_module::Embeddings.generate(query) + # Check if the query is a string (needs embedding) or an array (already embedded) + embedded_query = if query.is_a?(String) + Spectre.provider_module::Embeddings.generate(query) + elsif query.is_a?(Array) && query.all? { |e| e.is_a?(Float) } + query + else + raise ArgumentError, "Query must be a String or an Array of Floats" + end # Build the MongoDB aggregation pipeline pipeline = [ diff --git a/spec/spectre/searchable_spec.rb b/spec/spectre/searchable_spec.rb index 793b209..73aaa7d 100644 --- a/spec/spectre/searchable_spec.rb +++ b/spec/spectre/searchable_spec.rb @@ -8,9 +8,11 @@ TestModel.clear_all TestModel.create!(field1: 'What is AI?', field2: 'Artificial Intelligence') TestModel.create!(field1: 'Machine Learning', field2: 'AI in ML') + Spectre.setup do |config| config.llm_provider = :openai end + allow(Spectre::Openai::Embeddings).to receive(:generate).and_return([0.1, 0.2, 0.3]) # Mock the aggregate method on the collection to simulate MongoDB's aggregation pipeline @@ -20,7 +22,7 @@ ]) end - describe '.search' do + describe '.vector_search' do context 'with default configuration' do it 'returns matching records with vectorSearchScore' do results = TestModel.vector_search('AI') @@ -55,5 +57,26 @@ expect(results.first['field1']).to eq('Machine Learning') end end + + context 'when query is already an embedding array' do + it 'uses the provided embedding directly' do + embedded_query = [0.1, 0.2, 0.3] + + # Ensure that the generate method is not called + expect(Spectre::Openai::Embeddings).not_to receive(:generate) + + results = TestModel.vector_search(embedded_query) + expect(results).to be_an(Array) + expect(results.first.keys).to include('field1', 'field2', 'score') + end + end + + context 'when query is neither string nor array of floats' do + it 'raises an ArgumentError' do + expect { + TestModel.vector_search(12345) + }.to raise_error(ArgumentError, 'Query must be a String or an Array of Floats') + end + end end end From 253f43f7a7078164e2d645baf1e94bd13a18c657 Mon Sep 17 00:00:00 2001 From: kladaFOX <43650501+kladaFOX@users.noreply.github.com> Date: Fri, 30 Aug 2024 19:04:05 +0200 Subject: [PATCH 22/37] Update README.md Co-authored-by: Matthew Black --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ffe5f2d..d2e87c7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Spectre -**Spectre** is a Ruby gem designed to provide an abstraction layer for generating embeddings using OpenAI's API and for performing vector-based searches. This gem simplifies the process of embedding data fields and searching within your Rails models. +**Spectre** is a Ruby gem designed to make it easy to perform vector-based searches, generate embeddings and execute dynamic LLM prompts (Like RAG). ## Installation From 785cb6c7f5f1f50fa16539da7ce8d73f879a0724 Mon Sep 17 00:00:00 2001 From: kladafox Date: Tue, 3 Sep 2024 15:42:36 +0200 Subject: [PATCH 23/37] Error out searchable for non mongoid models --- README.md | 4 +++- lib/spectre/searchable.rb | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d2e87c7..9b230c5 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ class Model end ``` -**2.2. Searchable Module** +**2.2. Searchable Module (MongoDB Only)** + +**Note:** The `Searchable` module is designed to work exclusively with Mongoid models. If you attempt to include it in a non-Mongoid model, an error will be raised. This ensures that vector-based searches, which rely on MongoDB's specific features, are only used in appropriate contexts. To enable vector-based search in your Rails model: diff --git a/lib/spectre/searchable.rb b/lib/spectre/searchable.rb index 962e2d3..ebdddad 100644 --- a/lib/spectre/searchable.rb +++ b/lib/spectre/searchable.rb @@ -3,6 +3,10 @@ module Spectre module Searchable def self.included(base) + unless base.ancestors.include?(Mongoid::Document) + raise "Spectre::Searchable can only be included in Mongoid models. The class #{base.name} does not appear to be a Mongoid model." + end + base.extend ClassMethods end From 31f09bd68380d103b03a9b2ace6c727f8aa91654 Mon Sep 17 00:00:00 2001 From: kladafox Date: Tue, 3 Sep 2024 16:04:19 +0200 Subject: [PATCH 24/37] Fixed specs --- lib/spectre/searchable.rb | 2 +- spec/support/mongoid/document.rb | 5 +++++ spec/support/test_model.rb | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 spec/support/mongoid/document.rb diff --git a/lib/spectre/searchable.rb b/lib/spectre/searchable.rb index ebdddad..b1b21a6 100644 --- a/lib/spectre/searchable.rb +++ b/lib/spectre/searchable.rb @@ -3,7 +3,7 @@ module Spectre module Searchable def self.included(base) - unless base.ancestors.include?(Mongoid::Document) + unless base.ancestors.map(&:to_s).include?('Mongoid::Document') raise "Spectre::Searchable can only be included in Mongoid models. The class #{base.name} does not appear to be a Mongoid model." end diff --git a/spec/support/mongoid/document.rb b/spec/support/mongoid/document.rb new file mode 100644 index 0000000..b71cdd7 --- /dev/null +++ b/spec/support/mongoid/document.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Mongoid + module Document; end +end diff --git a/spec/support/test_model.rb b/spec/support/test_model.rb index b379032..77a2cd1 100644 --- a/spec/support/test_model.rb +++ b/spec/support/test_model.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true require 'spectre' +require_relative 'mongoid/document' class TestModel include Spectre + include Mongoid::Document spectre :embeddable, :searchable From 0a27d38627f9e947cd4b6044550742dd2276dd1b Mon Sep 17 00:00:00 2001 From: kladafox Date: Wed, 4 Sep 2024 17:14:15 +0200 Subject: [PATCH 25/37] Added prompts class --- Gemfile | 1 + Gemfile.lock | 6 ++ README.md | 67 ++++++++++++++++++ lib/generators/spectre/install_generator.rb | 5 ++ .../templates/rag/system_prompt.yml.erb | 5 ++ .../spectre/templates/rag/user_prompt.yml.erb | 3 + lib/spectre.rb | 1 + lib/spectre/prompt.rb | 57 ++++++++++++++++ spec/spec_helper.rb | 5 ++ spec/spectre/prompt_spec.rb | 68 +++++++++++++++++++ spectre.gemspec | 1 + 11 files changed, 219 insertions(+) create mode 100644 lib/generators/spectre/templates/rag/system_prompt.yml.erb create mode 100644 lib/generators/spectre/templates/rag/user_prompt.yml.erb create mode 100644 lib/spectre/prompt.rb create mode 100644 spec/spectre/prompt_spec.rb diff --git a/Gemfile b/Gemfile index 2adccdb..45ea790 100644 --- a/Gemfile +++ b/Gemfile @@ -7,4 +7,5 @@ gemspec group :development, :test do gem 'rspec-rails' gem 'webmock' + gem 'pry' end diff --git a/Gemfile.lock b/Gemfile.lock index b888a17..fc683b6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -37,6 +37,7 @@ GEM base64 (0.2.0) bigdecimal (3.1.8) builder (3.3.0) + coderay (1.1.3) concurrent-ruby (1.3.4) connection_pool (2.4.1) crack (1.0.0) @@ -56,12 +57,16 @@ GEM loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) + method_source (1.1.0) minitest (5.25.1) mutex_m (0.2.0) nokogiri (1.16.7-arm64-darwin) racc (~> 1.4) nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) psych (5.1.2) stringio public_suffix (6.0.1) @@ -130,6 +135,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + pry rspec-rails spectre! webmock diff --git a/README.md b/README.md index 9b230c5..cd1c512 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,73 @@ Spectre.provider_module::Completions.generate( ) ``` +### 6. Generating Dynamic Prompts + +Spectre provides a system for generating dynamic prompts based on templates. You can define reusable prompt templates and generate them with different inputs in your Rails app. + +**Example Directory Structure for Prompts** + +Create a folder structure in your app to hold the prompt templates: + +``` +app/spectre/prompts/ +└── rag/ + ├── system_prompt.yml.erb + └── user_prompt.yml.erb +``` + +Each .yml.erb file can contain dynamic content and be customized with embedded Ruby (ERB). + +**Example Prompt Templates** + +• system_prompt.yml.erb: +```yaml +system: | + You are a helpful assistant designed to provide answers based on specific documents and context provided to you. + Follow these guidelines: + 1. Only provide answers based on the context provided. + 2. Be polite and concise. +``` + +• user_prompt.yml.erb: +```yaml +user: | + User's query: <%= @query %> + Context: <%= @objects.join(", ") %> +``` + +**Generating Prompts in Your Code** + +You can generate prompts in your Rails application using the Spectre::Prompt.generate method, which loads and renders the specified prompt template: + +```ruby +# Generate a system prompt +Spectre::Prompt.generate(name: 'rag', prompt: :system) + +# Generate a user prompt with local variables +Spectre::Prompt.generate( + name: 'rag', + prompt: :user, + locals: { + query: query, + objects: objects + } +) +``` + +• name: The name of the folder where the prompt files are stored (e.g., rag). +• prompt: The name of the specific prompt file (e.g., system or user). +• locals: A hash of variables to be used inside the ERB template. + +**Generating Example Prompt Files** + +You can use a Rails generator to create example prompt files in your project. Run the following command: + +```bash +rails generate spectre:install +``` + + ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/hiremav/spectre. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct. diff --git a/lib/generators/spectre/install_generator.rb b/lib/generators/spectre/install_generator.rb index 7c18b96..88863b3 100644 --- a/lib/generators/spectre/install_generator.rb +++ b/lib/generators/spectre/install_generator.rb @@ -8,6 +8,11 @@ class InstallGenerator < Rails::Generators::Base def create_initializer_file template "spectre_initializer.rb", "config/initializers/spectre.rb" end + + desc "This generator creates system_prompt.yml.erb and user_prompt.yml.erb examples in your app/spectre/prompts folder." + def create_prompt_files + directory 'rag', 'app/spectre/prompts/rag' + end end end end diff --git a/lib/generators/spectre/templates/rag/system_prompt.yml.erb b/lib/generators/spectre/templates/rag/system_prompt.yml.erb new file mode 100644 index 0000000..a77fb2d --- /dev/null +++ b/lib/generators/spectre/templates/rag/system_prompt.yml.erb @@ -0,0 +1,5 @@ +system: | + You are a helpful assistant designed to provide answers based on specific documents and context provided to you. + Follow these guidelines: + 1. Only provide answers based on the context provided to you. + 2. Never mention the context directly in your responses. diff --git a/lib/generators/spectre/templates/rag/user_prompt.yml.erb b/lib/generators/spectre/templates/rag/user_prompt.yml.erb new file mode 100644 index 0000000..6ebd97d --- /dev/null +++ b/lib/generators/spectre/templates/rag/user_prompt.yml.erb @@ -0,0 +1,3 @@ +user: | + User's query: <%= @query %> + Context: <%= @objects.join(", ") %> diff --git a/lib/spectre.rb b/lib/spectre.rb index 7348417..60533e7 100644 --- a/lib/spectre.rb +++ b/lib/spectre.rb @@ -5,6 +5,7 @@ require 'spectre/searchable' require "spectre/openai" require "spectre/logging" +require 'spectre/prompt' module Spectre class APIKeyNotConfiguredError < StandardError; end diff --git a/lib/spectre/prompt.rb b/lib/spectre/prompt.rb new file mode 100644 index 0000000..305e679 --- /dev/null +++ b/lib/spectre/prompt.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'erb' +require 'yaml' + +module Spectre + class Prompt + PROMPTS_PATH = File.join(Dir.pwd, 'app', 'spectre', 'prompts') + + # Generate a prompt by reading and rendering the YAML template + # + # @param name [String] Name of the folder containing the prompts (e.g., 'rag') + # @param prompt [Symbol] The type of prompt (e.g., :system or :user) + # @param locals [Hash] Variables to be passed to the template for rendering + # + # @return [String] Rendered prompt + def self.generate(name:, prompt:, locals: {}) + file_path = prompt_file_path(name, prompt) + + raise "Prompt file not found: #{file_path}" unless File.exist?(file_path) + + template = File.read(file_path) + erb_template = ERB.new(template) + + context = Context.new(locals) + rendered_prompt = erb_template.result(context.get_binding) + + YAML.safe_load(rendered_prompt)[prompt.to_s] + end + + private + + # Build the path to the desired prompt file + # + # @param name [String] Name of the prompt folder + # @param prompt [Symbol] Type of prompt (e.g., :system, :user) + # + # @return [String] Full path to the template file + def self.prompt_file_path(name, prompt) + "#{PROMPTS_PATH}/#{name}/#{prompt}_prompt.yml.erb" + end + + # Helper class to handle the binding for ERB rendering + class Context + def initialize(locals) + locals.each do |key, value| + instance_variable_set("@#{key}", value) + end + end + + # Returns binding for ERB template rendering + def get_binding + binding + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c118730..0d71de7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,6 +2,7 @@ require 'spectre' require 'webmock/rspec' +require 'pry' RSpec.configure do |config| WebMock.disable_net_connect!(allow_localhost: true) @@ -21,4 +22,8 @@ config.default_formatter = "progress" if config.files_to_run.one? config.order = :random Kernel.srand config.seed + + config.before(:suite) do + require 'pry' + end end diff --git a/spec/spectre/prompt_spec.rb b/spec/spectre/prompt_spec.rb new file mode 100644 index 0000000..fce9449 --- /dev/null +++ b/spec/spectre/prompt_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'spectre/prompt' +require 'tmpdir' + +RSpec.describe Spectre::Prompt do + let(:system_prompt_content) do + <<~ERB + system: | + You are a helpful assistant. + ERB + end + + let(:user_prompt_content) do + <<~ERB + user: | + User's query: <%= @query %> + Context: <%= @cognitive_responses_in_context.join(", ") %> + ERB + end + + before do + # Create a temporary directory to hold the prompts + @tmpdir = Dir.mktmpdir + + # Create the necessary folders and files for the test + prompts_folder = File.join(@tmpdir, 'rag') + FileUtils.mkdir_p(prompts_folder) + + # Write the mock system_prompt.yml.erb and user_prompt.yml.erb files + File.write(File.join(prompts_folder, 'system_prompt.yml.erb'), system_prompt_content) + File.write(File.join(prompts_folder, 'user_prompt.yml.erb'), user_prompt_content) + + # Temporarily set the PROMPTS_PATH to the tmp directory + stub_const('Spectre::Prompt::PROMPTS_PATH', @tmpdir) + end + + after do + # Clean up the temporary directory after the test + FileUtils.remove_entry @tmpdir + end + + describe '.generate' do + context 'when generating the system prompt' do + it 'returns the rendered system prompt' do + result = described_class.generate(name: 'rag', prompt: :system) + + expect(result).to eq("You are a helpful assistant.\n") + end + end + + context 'when generating the user prompt with locals' do + let(:query) { 'What is AI?' } + let(:cognitive_responses_in_context) { ['AI is cool', 'AI is the future'] } + + it 'returns the rendered user prompt with local variables' do + result = described_class.generate( + name: 'rag', + prompt: :user, + locals: { query: query, cognitive_responses_in_context: cognitive_responses_in_context } + ) + expected_result = "User's query: What is AI?\nContext: AI is cool, AI is the future\n" + expect(result).to eq(expected_result) + end + end + end +end diff --git a/spectre.gemspec b/spectre.gemspec index 45a338e..c8109c5 100644 --- a/spectre.gemspec +++ b/spectre.gemspec @@ -16,5 +16,6 @@ Gem::Specification.new do |s| s.require_paths = ["lib"] # Development dependencies s.add_development_dependency 'rspec-rails' + s.add_development_dependency 'pry' s.required_ruby_version = ">= 3" end From 0511ee1945c24d00831a30441fdfa1f583c83acf Mon Sep 17 00:00:00 2001 From: kladafox Date: Thu, 5 Sep 2024 15:07:14 +0200 Subject: [PATCH 26/37] Changed the name of prompts param --- README.md | 4 ++-- lib/spectre/prompt.rb | 12 ++++++------ spec/spectre/prompt_spec.rb | 10 +++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index cd1c512..b4ad3eb 100644 --- a/README.md +++ b/README.md @@ -211,11 +211,11 @@ You can generate prompts in your Rails application using the Spectre::Prompt.gen ```ruby # Generate a system prompt -Spectre::Prompt.generate(name: 'rag', prompt: :system) +Spectre::Prompt.generate(type: 'rag', prompt: :system) # Generate a user prompt with local variables Spectre::Prompt.generate( - name: 'rag', + type: 'rag', prompt: :user, locals: { query: query, diff --git a/lib/spectre/prompt.rb b/lib/spectre/prompt.rb index 305e679..a897259 100644 --- a/lib/spectre/prompt.rb +++ b/lib/spectre/prompt.rb @@ -9,13 +9,13 @@ class Prompt # Generate a prompt by reading and rendering the YAML template # - # @param name [String] Name of the folder containing the prompts (e.g., 'rag') + # @param type [String] Name of the folder containing the prompts (e.g., 'rag') # @param prompt [Symbol] The type of prompt (e.g., :system or :user) # @param locals [Hash] Variables to be passed to the template for rendering # # @return [String] Rendered prompt - def self.generate(name:, prompt:, locals: {}) - file_path = prompt_file_path(name, prompt) + def self.generate(type:, prompt:, locals: {}) + file_path = prompt_file_path(type, prompt) raise "Prompt file not found: #{file_path}" unless File.exist?(file_path) @@ -32,12 +32,12 @@ def self.generate(name:, prompt:, locals: {}) # Build the path to the desired prompt file # - # @param name [String] Name of the prompt folder + # @param type [String] Name of the prompt folder # @param prompt [Symbol] Type of prompt (e.g., :system, :user) # # @return [String] Full path to the template file - def self.prompt_file_path(name, prompt) - "#{PROMPTS_PATH}/#{name}/#{prompt}_prompt.yml.erb" + def self.prompt_file_path(type, prompt) + "#{PROMPTS_PATH}/#{type}/#{prompt}_prompt.yml.erb" end # Helper class to handle the binding for ERB rendering diff --git a/spec/spectre/prompt_spec.rb b/spec/spectre/prompt_spec.rb index fce9449..a73bb8b 100644 --- a/spec/spectre/prompt_spec.rb +++ b/spec/spectre/prompt_spec.rb @@ -16,7 +16,7 @@ <<~ERB user: | User's query: <%= @query %> - Context: <%= @cognitive_responses_in_context.join(", ") %> + Context: <%= @objects.join(", ") %> ERB end @@ -44,7 +44,7 @@ describe '.generate' do context 'when generating the system prompt' do it 'returns the rendered system prompt' do - result = described_class.generate(name: 'rag', prompt: :system) + result = described_class.generate(type: 'rag', prompt: :system) expect(result).to eq("You are a helpful assistant.\n") end @@ -52,13 +52,13 @@ context 'when generating the user prompt with locals' do let(:query) { 'What is AI?' } - let(:cognitive_responses_in_context) { ['AI is cool', 'AI is the future'] } + let(:objects) { ['AI is cool', 'AI is the future'] } it 'returns the rendered user prompt with local variables' do result = described_class.generate( - name: 'rag', + type: 'rag', prompt: :user, - locals: { query: query, cognitive_responses_in_context: cognitive_responses_in_context } + locals: { query: query, objects: objects } ) expected_result = "User's query: What is AI?\nContext: AI is cool, AI is the future\n" expect(result).to eq(expected_result) From e98dc63473954d44e8aa836e1375759b1ce87b7e Mon Sep 17 00:00:00 2001 From: kladafox Date: Mon, 9 Sep 2024 17:32:45 +0200 Subject: [PATCH 27/37] Added json_format to completions class --- lib/spectre/openai/completions.rb | 40 ++++++++++++--- spec/spectre/openai/completions_spec.rb | 66 ++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/lib/spectre/openai/completions.rb b/lib/spectre/openai/completions.rb index d00e87c..34a6d1d 100644 --- a/lib/spectre/openai/completions.rb +++ b/lib/spectre/openai/completions.rb @@ -16,10 +16,12 @@ class Completions # @param system_prompt [String] an optional system prompt to guide the AI's behavior # @param assistant_prompt [String] an optional assistant prompt to provide context for the assistant's behavior # @param model [String] the model to be used for generating completions, defaults to DEFAULT_MODEL + # @param json_schema [Hash, nil] an optional JSON schema to enforce structured output + # @param max_tokens [Integer] the maximum number of tokens for the completion (default: 50) # @return [String] the generated completion text # @raise [APIKeyNotConfiguredError] if the API key is not set # @raise [RuntimeError] for general API errors or unexpected issues - def self.generate(user_prompt:, system_prompt: "You are a helpful assistant.", assistant_prompt: nil, model: DEFAULT_MODEL) + def self.generate(user_prompt:, system_prompt: "You are a helpful assistant.", assistant_prompt: nil, model: DEFAULT_MODEL, json_schema: nil, max_tokens: nil) api_key = Spectre.api_key raise APIKeyNotConfiguredError, "API key is not configured" unless api_key @@ -34,14 +36,27 @@ def self.generate(user_prompt:, system_prompt: "You are a helpful assistant.", a 'Authorization' => "Bearer #{api_key}" }) - request.body = generate_body(user_prompt, system_prompt, assistant_prompt, model).to_json + request.body = generate_body(user_prompt, system_prompt, assistant_prompt, model, json_schema, max_tokens).to_json response = http.request(request) unless response.is_a?(Net::HTTPSuccess) raise "OpenAI API Error: #{response.code} - #{response.message}: #{response.body}" end - JSON.parse(response.body).dig('choices', 0, 'message', 'content') + parsed_response = JSON.parse(response.body) + + # Check if the response contains a refusal + if parsed_response.dig('choices', 0, 'message', 'refusal') + raise "Refusal: #{parsed_response.dig('choices', 0, 'message', 'refusal')}" + end + + # Check if the finish reason is "length", indicating incomplete response + if parsed_response.dig('choices', 0, 'finish_reason') == "length" + raise "Incomplete response: The completion was cut off due to token limit." + end + + # Return the structured output if it's included + parsed_response.dig('choices', 0, 'message', 'content') rescue JSON::ParserError => e raise "JSON Parse Error: #{e.message}" rescue Net::OpenTimeout, Net::ReadTimeout => e @@ -56,8 +71,10 @@ def self.generate(user_prompt:, system_prompt: "You are a helpful assistant.", a # @param system_prompt [String] an optional system prompt to guide the AI's behavior # @param assistant_prompt [String] an optional assistant prompt to provide context for the assistant's behavior # @param model [String] the model to be used for generating completions + # @param json_schema [Hash, nil] an optional JSON schema to enforce structured output + # @param max_tokens [Integer, nil] the maximum number of tokens for the completion # @return [Hash] the body for the API request - def self.generate_body(user_prompt, system_prompt, assistant_prompt, model) + def self.generate_body(user_prompt, system_prompt, assistant_prompt, model, json_schema, max_tokens) messages = [ { role: 'system', content: system_prompt }, { role: 'user', content: user_prompt } @@ -66,10 +83,21 @@ def self.generate_body(user_prompt, system_prompt, assistant_prompt, model) # Add the assistant prompt if provided messages << { role: 'assistant', content: assistant_prompt } if assistant_prompt - { + body = { model: model, - messages: messages + messages: messages, } + body['max_tokens'] = max_tokens if max_tokens + + # Add the JSON schema as part of response_format if provided + if json_schema + body[:response_format] = { + type: 'json_schema', + json_schema: json_schema + } + end + + body end end end diff --git a/spec/spectre/openai/completions_spec.rb b/spec/spectre/openai/completions_spec.rb index b2204a8..db07013 100644 --- a/spec/spectre/openai/completions_spec.rb +++ b/spec/spectre/openai/completions_spec.rb @@ -8,7 +8,7 @@ let(:system_prompt) { 'You are a funny assistant.' } let(:assistant_prompt) { 'Sure, here\'s a joke!' } let(:completion) { 'Why did the chicken cross the road? To get to the other side!' } - let(:response_body) { { choices: [{ message: { content: completion } }] }.to_json } + let(:response_body) { { choices: [{ message: { content: completion }, finish_reason: 'stop' }] }.to_json } before do allow(Spectre).to receive(:api_key).and_return(api_key) @@ -77,5 +77,69 @@ }.to raise_error(RuntimeError, /Request Timeout/) end end + + context 'when the response finish_reason is length' do + let(:incomplete_response_body) { { choices: [{ message: { content: completion }, finish_reason: 'length' }] }.to_json } + + before do + stub_request(:post, Spectre::Openai::Completions::API_URL) + .to_return(status: 200, body: incomplete_response_body, headers: { 'Content-Type' => 'application/json' }) + end + + it 'raises an incomplete response error' do + expect { + described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt) + }.to raise_error(RuntimeError, /Incomplete response: The completion was cut off due to token limit./) + end + end + + context 'with a max_tokens parameter' do + let(:max_tokens) { 30 } + + before do + stub_request(:post, Spectre::Openai::Completions::API_URL) + .to_return(status: 200, body: response_body, headers: { 'Content-Type' => 'application/json' }) + end + + it 'sends the max_tokens parameter in the request' do + described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt, max_tokens: max_tokens) + + expect(a_request(:post, Spectre::Openai::Completions::API_URL) + .with(body: hash_including(max_tokens: max_tokens))).to have_been_made + end + end + + context 'with a json_schema parameter' do + let(:json_schema) { { name: 'completion_response', schema: { type: 'object', properties: { response: { type: 'string' } } } } } + + before do + stub_request(:post, Spectre::Openai::Completions::API_URL) + .to_return(status: 200, body: response_body, headers: { 'Content-Type' => 'application/json' }) + end + + it 'sends the json_schema in the request' do + described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt, json_schema: json_schema) + + expect(a_request(:post, Spectre::Openai::Completions::API_URL) + .with { |req| JSON.parse(req.body)['response_format']['json_schema'] == JSON.parse(json_schema.to_json) }).to have_been_made.once + end + end + + context 'when the response contains a refusal' do + let(:refusal_response_body) do + { choices: [{ message: { refusal: "I'm sorry, I cannot assist with that request." } }] }.to_json + end + + before do + stub_request(:post, Spectre::Openai::Completions::API_URL) + .to_return(status: 200, body: refusal_response_body, headers: { 'Content-Type' => 'application/json' }) + end + + it 'raises a refusal error' do + expect { + described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt) + }.to raise_error(RuntimeError, /Refusal: I'm sorry, I cannot assist with that request./) + end + end end end From de483350b05fc8ca94c63705964610a6a2182d7c Mon Sep 17 00:00:00 2001 From: kladafox Date: Mon, 9 Sep 2024 17:36:47 +0200 Subject: [PATCH 28/37] Updated readme --- README.md | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b4ad3eb..38e7af6 100644 --- a/README.md +++ b/README.md @@ -146,11 +146,11 @@ This method will: ### 5. Generating Completions -Spectre also provides an interface to generate text completions using the LLM provider. This can be useful for generating responses, messages, or other forms of text. +Spectre provides an interface to generate text completions using your configured LLM provider, allowing you to generate dynamic responses, messages, or other forms of text. -**Generate a Completion** +**Basic Completion Example** -To generate a text completion, use the Spectre.provider_module::Completions.generate method: +To generate a simple text completion, use the Spectre.provider_module::Completions.generate method. You can provide a user prompt and an optional system prompt to guide the response: ```ruby Spectre.provider_module::Completions.generate( @@ -159,16 +159,46 @@ Spectre.provider_module::Completions.generate( ) ``` -This method sends the prompts to the LLM provider’s API and returns the generated completion. You can optionally specify a different model by passing it as an argument and set assistant_prompt: +This sends the request to the LLM provider’s API and returns the generated completion. + +**Customizing the Completion** + +You can customize the behavior by specifying additional parameters such as the model or an assistant_prompt to provide further context for the AI’s responses: ```ruby Spectre.provider_module::Completions.generate( user_prompt: "Tell me a joke.", system_prompt: "You are a funny assistant.", - model: "gpt-4-turbo", - assistant_prompt: "Assistant prompt here." + assistant_prompt: "Sure, here's a joke!", + model: "gpt-4-turbo" +) +``` + +**Using a JSON Schema for Structured Output** + +For cases where you need structured output (e.g., for returning specific fields or formatted responses), you can pass a json_schema parameter. The schema ensures that the completion conforms to a predefined structure: + +```ruby +json_schema = { + name: "completion_response", + schema: { + type: "object", + properties: { + response: { type: "string" }, + final_answer: { type: "string" } + }, + required: ["response", "final_answer"], + additionalProperties: false + } +} + +Spectre.provider_module::Completions.generate( + user_prompt: "What is the capital of France?", + system_prompt: "You are a knowledgeable assistant.", + json_schema: json_schema ) ``` +This structured format guarantees that the response adheres to the schema you’ve provided, ensuring more predictable and controlled results. ### 6. Generating Dynamic Prompts From e4a9fee53e3bc340e1b0d0c1d9c1ee60225fd59b Mon Sep 17 00:00:00 2001 From: Matthew Black Date: Mon, 9 Sep 2024 14:01:12 -0700 Subject: [PATCH 29/37] WIP Readme --- README.md | 62 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 38e7af6..1f209e8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,18 @@ # Spectre -**Spectre** is a Ruby gem designed to make it easy to perform vector-based searches, generate embeddings and execute dynamic LLM prompts (Like RAG). +**Spectre** is a Ruby gem that makes it easy to AI-enable your Ruby on Rails application. Currently, Spectre focuses on helping developers perform vector-based searches, generate embeddings, and manage multiple dynamic prompts — ideal for applications that require RAG (Retrieval-Augmented Generation) and dynamic prompts. + +## Compatibility + +| Feature | Compatibility | +|------------------------|---------------| +| Foundation Model (LLM) | OpenAI | +| Embeddings | OpenAI | +| Vector Searching | MongoDB Atlas | +| Prompt Files | OpenAI | + + +**💡 Note:** We'll first be prioritizing additional foundation models (Claude, Cohere, LLaMA, etc), then looking to add additional support for more vector database (Pgvector, Pinecone, etc). If you're looking for something a bit more extendable we highly recommend checking out [langchainrb](https://github.com/patterns-ai-core/langchainrb). ## Installation @@ -13,7 +25,9 @@ And then execute: ```bash bundle install ``` + Or install it yourself as: + ```bash gem install spectre ``` @@ -29,21 +43,18 @@ This will create a file at config/initializers/spectre.rb, where you can set you ```ruby Spectre.setup do |config| config.api_key = 'your_openai_api_key' - config.llm_provider = :openai # Options: :openai + config.llm_provider = :openai end ``` -### 2. Integrate Spectre with Your Model +### 2. Integrate Spectre with Your Rails Model **2.1. Embeddable Module** To use Spectre for generating embeddings in your Rails model, follow these steps: -1. Include the Spectre module: -Include Spectre in your model to enable the spectre method. -2. Declare the Model as Embeddable: -Use the spectre :embeddable declaration to make the model embeddable. -3. Define the Embeddable Fields: -Use the embeddable_field method to specify which fields should be used to generate the embeddings. +1. Include the Spectre module. +2. Declare the Model as embeddable. +3. Define the embeddable fields. Here is an example of how to set this up in a model: ```ruby @@ -62,15 +73,14 @@ end To enable vector-based search in your Rails model: -1. Include the Spectre module: -Include Spectre in your model to enable the spectre method. -2. Declare the Model as Searchable: -Use the spectre :searchable declaration to make the model searchable. -3. Configure Search Parameters: +1. Include the Spectre module. +2. Declare the Model as Searchable. +3. Configure search paramaters. + Use the following methods to configure the search path, index, and result fields: -• configure_spectre_search_path: Set the path where the embeddings are stored. -• configure_spectre_search_index: Set the index used for the vector search. -• configure_spectre_result_fields: Set the fields to include in the search results. +- **configure_spectre_search_path:** The path where the embeddings are stored. +- **configure_spectre_search_index:** The index used for the vector search. +- **configure_spectre_result_fields:** The fields to include in the search results. Here is an example of how to set this up in a model: @@ -90,7 +100,7 @@ end **Generate Embedding for a Single Record** -To generate an embedding for a single record, you can call the embed! method on the instance: +To generate an embedding for a single record, you can call the embed! method on the instance record: ```ruby record = Model.find(some_id) record.embed! @@ -110,10 +120,10 @@ This method will generate embeddings for all records that match the given scope **Directly Generate Embeddings Using Spectre.provider_module::Embeddings.generate** -If you need to generate an embedding directly without using the model integration, you can use the Spectre::Openai::Embeddings.generate method. This can be useful if you want to generate embeddings for custom text outside of your models: +If you need to generate an embedding directly without using the model integration, you can use the `Spectre.provider_module::Embeddings.generate` method. This can be useful if you want to generate embeddings for custom text outside of your models. For example, with OpenAI: ```ruby -Spectre.provider_module::Embeddings.generate("Your text here") +Spectre::provider_module::Embeddings.generate("Your text here") ``` This method sends the text to OpenAI’s API and returns the embedding vector. You can optionally specify a different model by passing it as an argument: @@ -268,5 +278,15 @@ rails generate spectre:install ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/hiremav/spectre. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct. +Bug reports and pull requests are welcome on GitHub at https://github.com/hiremav/spectre. This project is intended to be a safe, welcoming space for collaboration, and your contributions are greatly appreciated! + + 1. Fork the repository. + 2. Create a new feature branch (git checkout -b my-new-feature). + 3. Commit your changes (git commit -am 'Add some feature'). + 4. Push the branch (git push origin my-new-feature). + 5. Create a pull request. + +## License + +This gem is available as open source under the terms of the MIT License. From 20c995b09b8b0982cf5a46a0fd3ab86f983be852 Mon Sep 17 00:00:00 2001 From: kladafox Date: Tue, 10 Sep 2024 15:00:02 +0200 Subject: [PATCH 30/37] Changed the name of prompt class method from generate to render --- README.md | 6 +++--- lib/spectre/prompt.rb | 4 ++-- spec/spectre/prompt_spec.rb | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 38e7af6..4412515 100644 --- a/README.md +++ b/README.md @@ -240,11 +240,11 @@ user: | You can generate prompts in your Rails application using the Spectre::Prompt.generate method, which loads and renders the specified prompt template: ```ruby -# Generate a system prompt -Spectre::Prompt.generate(type: 'rag', prompt: :system) +# Render a system prompt +Spectre::Prompt.render(type: 'rag', prompt: :system) # Generate a user prompt with local variables -Spectre::Prompt.generate( +Spectre::Prompt.render( type: 'rag', prompt: :user, locals: { diff --git a/lib/spectre/prompt.rb b/lib/spectre/prompt.rb index a897259..af4b363 100644 --- a/lib/spectre/prompt.rb +++ b/lib/spectre/prompt.rb @@ -7,14 +7,14 @@ module Spectre class Prompt PROMPTS_PATH = File.join(Dir.pwd, 'app', 'spectre', 'prompts') - # Generate a prompt by reading and rendering the YAML template + # Render a prompt by reading and rendering the YAML template # # @param type [String] Name of the folder containing the prompts (e.g., 'rag') # @param prompt [Symbol] The type of prompt (e.g., :system or :user) # @param locals [Hash] Variables to be passed to the template for rendering # # @return [String] Rendered prompt - def self.generate(type:, prompt:, locals: {}) + def self.render(type:, prompt:, locals: {}) file_path = prompt_file_path(type, prompt) raise "Prompt file not found: #{file_path}" unless File.exist?(file_path) diff --git a/spec/spectre/prompt_spec.rb b/spec/spectre/prompt_spec.rb index a73bb8b..e6df4a3 100644 --- a/spec/spectre/prompt_spec.rb +++ b/spec/spectre/prompt_spec.rb @@ -44,7 +44,7 @@ describe '.generate' do context 'when generating the system prompt' do it 'returns the rendered system prompt' do - result = described_class.generate(type: 'rag', prompt: :system) + result = described_class.render(type: 'rag', prompt: :system) expect(result).to eq("You are a helpful assistant.\n") end @@ -55,7 +55,7 @@ let(:objects) { ['AI is cool', 'AI is the future'] } it 'returns the rendered user prompt with local variables' do - result = described_class.generate( + result = described_class.render( type: 'rag', prompt: :user, locals: { query: query, objects: objects } From 76a072b6b51ed8aa108e268f7a700475c25e7eaf Mon Sep 17 00:00:00 2001 From: Matthew Black Date: Thu, 12 Sep 2024 15:39:59 -0700 Subject: [PATCH 31/37] Update Naming --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7f50a08..c1c38ad 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ ## Compatibility -| Feature | Compatibility | -|------------------------|---------------| -| Foundation Model (LLM) | OpenAI | -| Embeddings | OpenAI | -| Vector Searching | MongoDB Atlas | -| Prompt Files | OpenAI | +| Feature | Compatibility | +|-------------------------|---------------| +| Foundation Models (LLM) | OpenAI | +| Embeddings | OpenAI | +| Vector Searching | MongoDB Atlas | +| Prompt Templates | OpenAI | **💡 Note:** We'll first be prioritizing additional foundation models (Claude, Cohere, LLaMA, etc), then looking to add additional support for more vector database (Pgvector, Pinecone, etc). If you're looking for something a bit more extendable we highly recommend checking out [langchainrb](https://github.com/patterns-ai-core/langchainrb). @@ -212,7 +212,7 @@ This structured format guarantees that the response adheres to the schema you’ ### 6. Generating Dynamic Prompts -Spectre provides a system for generating dynamic prompts based on templates. You can define reusable prompt templates and generate them with different inputs in your Rails app. +Spectre provides a system for generating dynamic prompts based on templates. You can define reusable prompt templates and generate them with different paramaters in your Rails app, _(like view partials)_. **Example Directory Structure for Prompts** From 5d02751617b1bc4b23b057f10b6dd065cd04ec4d Mon Sep 17 00:00:00 2001 From: kladafox Date: Fri, 13 Sep 2024 16:01:01 +0200 Subject: [PATCH 32/37] Changed the name of completions class method from generate to create --- lib/spectre/openai/completions.rb | 2 +- spec/spectre/openai/completions_spec.rb | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/spectre/openai/completions.rb b/lib/spectre/openai/completions.rb index 34a6d1d..7ff3f6c 100644 --- a/lib/spectre/openai/completions.rb +++ b/lib/spectre/openai/completions.rb @@ -21,7 +21,7 @@ class Completions # @return [String] the generated completion text # @raise [APIKeyNotConfiguredError] if the API key is not set # @raise [RuntimeError] for general API errors or unexpected issues - def self.generate(user_prompt:, system_prompt: "You are a helpful assistant.", assistant_prompt: nil, model: DEFAULT_MODEL, json_schema: nil, max_tokens: nil) + def self.create(user_prompt:, system_prompt: "You are a helpful assistant.", assistant_prompt: nil, model: DEFAULT_MODEL, json_schema: nil, max_tokens: nil) api_key = Spectre.api_key raise APIKeyNotConfiguredError, "API key is not configured" unless api_key diff --git a/spec/spectre/openai/completions_spec.rb b/spec/spectre/openai/completions_spec.rb index db07013..c7693f7 100644 --- a/spec/spectre/openai/completions_spec.rb +++ b/spec/spectre/openai/completions_spec.rb @@ -22,7 +22,7 @@ it 'raises an APIKeyNotConfiguredError' do expect { - described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt) + described_class.create(user_prompt: user_prompt, system_prompt: system_prompt) }.to raise_error(Spectre::APIKeyNotConfiguredError, 'API key is not configured') end end @@ -34,7 +34,7 @@ end it 'returns the completion text' do - result = described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt, assistant_prompt: assistant_prompt) + result = described_class.create(user_prompt: user_prompt, system_prompt: system_prompt, assistant_prompt: assistant_prompt) expect(result).to eq(completion) end end @@ -47,7 +47,7 @@ it 'raises an error with the API response' do expect { - described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt) + described_class.create(user_prompt: user_prompt, system_prompt: system_prompt) }.to raise_error(RuntimeError, /OpenAI API Error/) end end @@ -60,7 +60,7 @@ it 'raises a JSON Parse Error' do expect { - described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt) + described_class.create(user_prompt: user_prompt, system_prompt: system_prompt) }.to raise_error(RuntimeError, /JSON Parse Error/) end end @@ -73,7 +73,7 @@ it 'raises a Request Timeout error' do expect { - described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt) + described_class.create(user_prompt: user_prompt, system_prompt: system_prompt) }.to raise_error(RuntimeError, /Request Timeout/) end end @@ -88,7 +88,7 @@ it 'raises an incomplete response error' do expect { - described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt) + described_class.create(user_prompt: user_prompt, system_prompt: system_prompt) }.to raise_error(RuntimeError, /Incomplete response: The completion was cut off due to token limit./) end end @@ -102,7 +102,7 @@ end it 'sends the max_tokens parameter in the request' do - described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt, max_tokens: max_tokens) + described_class.create(user_prompt: user_prompt, system_prompt: system_prompt, max_tokens: max_tokens) expect(a_request(:post, Spectre::Openai::Completions::API_URL) .with(body: hash_including(max_tokens: max_tokens))).to have_been_made @@ -118,7 +118,7 @@ end it 'sends the json_schema in the request' do - described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt, json_schema: json_schema) + described_class.create(user_prompt: user_prompt, system_prompt: system_prompt, json_schema: json_schema) expect(a_request(:post, Spectre::Openai::Completions::API_URL) .with { |req| JSON.parse(req.body)['response_format']['json_schema'] == JSON.parse(json_schema.to_json) }).to have_been_made.once @@ -137,7 +137,7 @@ it 'raises a refusal error' do expect { - described_class.generate(user_prompt: user_prompt, system_prompt: system_prompt) + described_class.create(user_prompt: user_prompt, system_prompt: system_prompt) }.to raise_error(RuntimeError, /Refusal: I'm sorry, I cannot assist with that request./) end end From 98bb3dbba039d878ea0a1d98403551940bcdd9c6 Mon Sep 17 00:00:00 2001 From: kladafox Date: Fri, 13 Sep 2024 16:17:44 +0200 Subject: [PATCH 33/37] Refactor Prompt class --- README.md | 19 +++++++-------- .../{system_prompt.yml.erb => system.yml.erb} | 0 .../rag/{user_prompt.yml.erb => user.yml.erb} | 0 lib/spectre/prompt.rb | 24 ++++++++++++------- spec/spectre/prompt_spec.rb | 11 ++++----- 5 files changed, 29 insertions(+), 25 deletions(-) rename lib/generators/spectre/templates/rag/{system_prompt.yml.erb => system.yml.erb} (100%) rename lib/generators/spectre/templates/rag/{user_prompt.yml.erb => user.yml.erb} (100%) diff --git a/README.md b/README.md index c1c38ad..713353e 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ This structured format guarantees that the response adheres to the schema you’ ### 6. Generating Dynamic Prompts -Spectre provides a system for generating dynamic prompts based on templates. You can define reusable prompt templates and generate them with different paramaters in your Rails app, _(like view partials)_. +Spectre provides a system for generating dynamic prompts based on templates. You can define reusable prompt templates and generate them with different parameters in your Rails app, _(like view partials)_. **Example Directory Structure for Prompts** @@ -221,15 +221,15 @@ Create a folder structure in your app to hold the prompt templates: ``` app/spectre/prompts/ └── rag/ - ├── system_prompt.yml.erb - └── user_prompt.yml.erb + ├── system.yml.erb + └── user.yml.erb ``` Each .yml.erb file can contain dynamic content and be customized with embedded Ruby (ERB). **Example Prompt Templates** -• system_prompt.yml.erb: +• system.yml.erb: ```yaml system: | You are a helpful assistant designed to provide answers based on specific documents and context provided to you. @@ -238,7 +238,7 @@ system: | 2. Be polite and concise. ``` -• user_prompt.yml.erb: +• user.yml.erb: ```yaml user: | User's query: <%= @query %> @@ -251,12 +251,11 @@ You can generate prompts in your Rails application using the Spectre::Prompt.gen ```ruby # Render a system prompt -Spectre::Prompt.render(type: 'rag', prompt: :system) +Spectre::Prompt.render(template: 'rag/system') # Generate a user prompt with local variables Spectre::Prompt.render( - type: 'rag', - prompt: :user, + template: 'rag/user', locals: { query: query, objects: objects @@ -264,8 +263,7 @@ Spectre::Prompt.render( ) ``` -• name: The name of the folder where the prompt files are stored (e.g., rag). -• prompt: The name of the specific prompt file (e.g., system or user). +• template: The path to the prompt template file (e.g., rag/system). • locals: A hash of variables to be used inside the ERB template. **Generating Example Prompt Files** @@ -276,7 +274,6 @@ You can use a Rails generator to create example prompt files in your project. Ru rails generate spectre:install ``` - ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/hiremav/spectre. This project is intended to be a safe, welcoming space for collaboration, and your contributions are greatly appreciated! diff --git a/lib/generators/spectre/templates/rag/system_prompt.yml.erb b/lib/generators/spectre/templates/rag/system.yml.erb similarity index 100% rename from lib/generators/spectre/templates/rag/system_prompt.yml.erb rename to lib/generators/spectre/templates/rag/system.yml.erb diff --git a/lib/generators/spectre/templates/rag/user_prompt.yml.erb b/lib/generators/spectre/templates/rag/user.yml.erb similarity index 100% rename from lib/generators/spectre/templates/rag/user_prompt.yml.erb rename to lib/generators/spectre/templates/rag/user.yml.erb diff --git a/lib/spectre/prompt.rb b/lib/spectre/prompt.rb index af4b363..b73ad26 100644 --- a/lib/spectre/prompt.rb +++ b/lib/spectre/prompt.rb @@ -9,35 +9,43 @@ class Prompt # Render a prompt by reading and rendering the YAML template # - # @param type [String] Name of the folder containing the prompts (e.g., 'rag') - # @param prompt [Symbol] The type of prompt (e.g., :system or :user) + # @param template [String] The path to the template file, formatted as 'type/prompt' (e.g., 'rag/system') # @param locals [Hash] Variables to be passed to the template for rendering # # @return [String] Rendered prompt - def self.render(type:, prompt:, locals: {}) + def self.render(template:, locals: {}) + type, prompt = split_template(template) file_path = prompt_file_path(type, prompt) raise "Prompt file not found: #{file_path}" unless File.exist?(file_path) - template = File.read(file_path) - erb_template = ERB.new(template) + template_content = File.read(file_path) + erb_template = ERB.new(template_content) context = Context.new(locals) rendered_prompt = erb_template.result(context.get_binding) - YAML.safe_load(rendered_prompt)[prompt.to_s] + YAML.safe_load(rendered_prompt)[prompt] end private + # Split the template parameter into type and prompt + # + # @param template [String] Template path in the format 'type/prompt' (e.g., 'rag/system') + # @return [Array] An array containing the type and prompt + def self.split_template(template) + template.split('/') + end + # Build the path to the desired prompt file # # @param type [String] Name of the prompt folder - # @param prompt [Symbol] Type of prompt (e.g., :system, :user) + # @param prompt [String] Type of prompt (e.g., 'system', 'user') # # @return [String] Full path to the template file def self.prompt_file_path(type, prompt) - "#{PROMPTS_PATH}/#{type}/#{prompt}_prompt.yml.erb" + "#{PROMPTS_PATH}/#{type}/#{prompt}.yml.erb" end # Helper class to handle the binding for ERB rendering diff --git a/spec/spectre/prompt_spec.rb b/spec/spectre/prompt_spec.rb index e6df4a3..1058bc9 100644 --- a/spec/spectre/prompt_spec.rb +++ b/spec/spectre/prompt_spec.rb @@ -29,8 +29,8 @@ FileUtils.mkdir_p(prompts_folder) # Write the mock system_prompt.yml.erb and user_prompt.yml.erb files - File.write(File.join(prompts_folder, 'system_prompt.yml.erb'), system_prompt_content) - File.write(File.join(prompts_folder, 'user_prompt.yml.erb'), user_prompt_content) + File.write(File.join(prompts_folder, 'system.yml.erb'), system_prompt_content) + File.write(File.join(prompts_folder, 'user.yml.erb'), user_prompt_content) # Temporarily set the PROMPTS_PATH to the tmp directory stub_const('Spectre::Prompt::PROMPTS_PATH', @tmpdir) @@ -41,10 +41,10 @@ FileUtils.remove_entry @tmpdir end - describe '.generate' do + describe '.render' do context 'when generating the system prompt' do it 'returns the rendered system prompt' do - result = described_class.render(type: 'rag', prompt: :system) + result = described_class.render(template: 'rag/system') expect(result).to eq("You are a helpful assistant.\n") end @@ -56,8 +56,7 @@ it 'returns the rendered user prompt with local variables' do result = described_class.render( - type: 'rag', - prompt: :user, + template: 'rag/user', locals: { query: query, objects: objects } ) expected_result = "User's query: What is AI?\nContext: AI is cool, AI is the future\n" From 6493760d7b8306dcb4ecc5568219d692e9dd464d Mon Sep 17 00:00:00 2001 From: Matthew Black Date: Fri, 13 Sep 2024 15:31:28 -0700 Subject: [PATCH 34/37] Clean up Readme --- README.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 713353e..1ca0994 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Spectre -**Spectre** is a Ruby gem that makes it easy to AI-enable your Ruby on Rails application. Currently, Spectre focuses on helping developers perform vector-based searches, generate embeddings, and manage multiple dynamic prompts — ideal for applications that require RAG (Retrieval-Augmented Generation) and dynamic prompts. +**Spectre** is a Ruby gem that makes it easy to AI-enable your Ruby on Rails application. Currently, Spectre focuses on helping developers generate embeddings, perform vector-based searches, and manage multiple dynamic prompts — ideal for applications that require RAG (Retrieval-Augmented Generation) and other dynamic prompts. ## Compatibility @@ -11,8 +11,7 @@ | Vector Searching | MongoDB Atlas | | Prompt Templates | OpenAI | - -**💡 Note:** We'll first be prioritizing additional foundation models (Claude, Cohere, LLaMA, etc), then looking to add additional support for more vector database (Pgvector, Pinecone, etc). If you're looking for something a bit more extendable we highly recommend checking out [langchainrb](https://github.com/patterns-ai-core/langchainrb). +**💡 Note:** We'll first be prioritizing additional foundation models (Claude, Cohere, LLaMA, etc), then looking to add additional support for more vector databases (Pgvector, Pinecone, etc). If you're looking for something a bit more extendable we highly recommend checking out [langchainrb](https://github.com/patterns-ai-core/langchainrb). ## Installation @@ -46,9 +45,7 @@ Spectre.setup do |config| config.llm_provider = :openai end ``` -### 2. Integrate Spectre with Your Rails Model - -**2.1. Embeddable Module** +### 2. Enable Your Rails Model(s) for Embedding To use Spectre for generating embeddings in your Rails model, follow these steps: @@ -67,9 +64,9 @@ class Model end ``` -**2.2. Searchable Module (MongoDB Only)** +### 2.2. Enable Your Rails Model(s) for Vector Searching (MongoDB Only)** -**Note:** The `Searchable` module is designed to work exclusively with Mongoid models. If you attempt to include it in a non-Mongoid model, an error will be raised. This ensures that vector-based searches, which rely on MongoDB's specific features, are only used in appropriate contexts. +**Note:** Currently, the `Searchable` module is designed to work exclusively with Mongoid models. If you attempt to include it in a non-Mongoid model, an error will be raised. This ensures that vector-based searches, which rely on MongoDB's specific features, are only used in appropriate contexts. To enable vector-based search in your Rails model: @@ -142,41 +139,41 @@ Model.vector_search('Your search query', custom_result_fields: { "response" => 1 This method will: -• Embed the search query using the configured LLM provider. +• Embed the search query using the configured LLM provider. **Note:** If your text is already embeded, you can pass the embed _(as a Array)_ and it will perform just the search. • Perform a vector-based search on the embeddings stored in the specified search_path. • Return the matching records with the specified result_fields and their vectorSearchScore. -**Examples:** +**Keyword Arguments:** • **Custom Result Fields**: Limit the fields returned in the search results. • **Additional Scopes**: Apply additional MongoDB filters to the search results. -### 5. Generating Completions +### 5. Creating Completions -Spectre provides an interface to generate text completions using your configured LLM provider, allowing you to generate dynamic responses, messages, or other forms of text. +Spectre provides an interface to generate chat completions using your configured LLM provider, allowing you to generate dynamic responses, messages, or other forms of text. **Basic Completion Example** -To generate a simple text completion, use the Spectre.provider_module::Completions.generate method. You can provide a user prompt and an optional system prompt to guide the response: +To generate a simple chat completion, use the Spectre.provider_module::Completions.generate method. You can provide a user prompt and an optional system prompt to guide the response: ```ruby -Spectre.provider_module::Completions.generate( +Spectre.provider_module::Completions.create( user_prompt: "Tell me a joke.", system_prompt: "You are a funny assistant." ) ``` -This sends the request to the LLM provider’s API and returns the generated completion. +This sends the request to the LLM provider’s API and returns the generated chat completion. **Customizing the Completion** You can customize the behavior by specifying additional parameters such as the model or an assistant_prompt to provide further context for the AI’s responses: ```ruby -Spectre.provider_module::Completions.generate( +Spectre.provider_module::Completions.create( user_prompt: "Tell me a joke.", system_prompt: "You are a funny assistant.", assistant_prompt: "Sure, here's a joke!", @@ -202,7 +199,7 @@ json_schema = { } } -Spectre.provider_module::Completions.generate( +Spectre.provider_module::Completions.create( user_prompt: "What is the capital of France?", system_prompt: "You are a knowledgeable assistant.", json_schema: json_schema @@ -266,12 +263,15 @@ Spectre::Prompt.render( • template: The path to the prompt template file (e.g., rag/system). • locals: A hash of variables to be used inside the ERB template. -**Generating Example Prompt Files** +**Combining Completions with Prompts** -You can use a Rails generator to create example prompt files in your project. Run the following command: +You can also combine a Compleitions and Prompts like so: -```bash -rails generate spectre:install +```ruby +Spectre.provider_module::Completions.create( + user_prompt: Spectre::Prompt.render(template: 'rag/user', locals: {query: @query, user: @user}), + system_prompt: Spectre::Prompt.render(template: 'rag/system') +) ``` ## Contributing From 8eedeaa47aa6c42fc80c4f49631160a1fc5af85b Mon Sep 17 00:00:00 2001 From: Matthew Black Date: Fri, 13 Sep 2024 15:44:49 -0700 Subject: [PATCH 35/37] README Update --- README.md | 154 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 86 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 1ca0994..f928373 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Spectre -**Spectre** is a Ruby gem that makes it easy to AI-enable your Ruby on Rails application. Currently, Spectre focuses on helping developers generate embeddings, perform vector-based searches, and manage multiple dynamic prompts — ideal for applications that require RAG (Retrieval-Augmented Generation) and other dynamic prompts. +**Spectre** is a Ruby gem that makes it easy to AI-enable your Ruby on Rails application. Currently, Spectre focuses on helping developers create embeddings, perform vector-based searches, create chat completions, and manage dynamic prompts — ideal for applications that are featuring RAG (Retrieval-Augmented Generation), chatbots and dynamic prompts. ## Compatibility @@ -11,7 +11,7 @@ | Vector Searching | MongoDB Atlas | | Prompt Templates | OpenAI | -**💡 Note:** We'll first be prioritizing additional foundation models (Claude, Cohere, LLaMA, etc), then looking to add additional support for more vector databases (Pgvector, Pinecone, etc). If you're looking for something a bit more extendable we highly recommend checking out [langchainrb](https://github.com/patterns-ai-core/langchainrb). +**💡 Note:** We will first prioritize adding support for additional foundation models (Claude, Cohere, LLaMA, etc.), then look to add support for more vector databases (Pgvector, Pinecone, etc.). If you're looking for something a bit more extensible, we highly recommend checking out [langchainrb](https://github.com/patterns-ai-core/langchainrb). ## Installation @@ -20,7 +20,9 @@ Add this line to your application's Gemfile: ```ruby gem 'spectre' ``` + And then execute: + ```bash bundle install ``` @@ -30,30 +32,38 @@ Or install it yourself as: ```bash gem install spectre ``` + ## Usage ### 1. Setup First, you’ll need to generate the initializer to configure your OpenAI API key. Run the following command to create the initializer: + ```bash rails generate spectre:install ``` -This will create a file at config/initializers/spectre.rb, where you can set your OpenAI API key: + +This will create a file at `config/initializers/spectre.rb`, where you can set your OpenAI API key: + ```ruby Spectre.setup do |config| config.api_key = 'your_openai_api_key' config.llm_provider = :openai end ``` -### 2. Enable Your Rails Model(s) for Embedding + +### 2. Enable Your Rails Model(s) + +#### For Embedding To use Spectre for generating embeddings in your Rails model, follow these steps: -1. Include the Spectre module. -2. Declare the Model as embeddable. -3. Define the embeddable fields. +1. Include the Spectre module. +2. Declare the model as embeddable. +3. Define the embeddable fields. Here is an example of how to set this up in a model: + ```ruby class Model include Mongoid::Document @@ -64,17 +74,18 @@ class Model end ``` -### 2.2. Enable Your Rails Model(s) for Vector Searching (MongoDB Only)** +#### For Vector Searching (MongoDB Only) **Note:** Currently, the `Searchable` module is designed to work exclusively with Mongoid models. If you attempt to include it in a non-Mongoid model, an error will be raised. This ensures that vector-based searches, which rely on MongoDB's specific features, are only used in appropriate contexts. To enable vector-based search in your Rails model: -1. Include the Spectre module. -2. Declare the Model as Searchable. -3. Configure search paramaters. +1. Include the Spectre module. +2. Declare the model as searchable. +3. Configure search parameters. Use the following methods to configure the search path, index, and result fields: + - **configure_spectre_search_path:** The path where the embeddings are stored. - **configure_spectre_search_index:** The index used for the vector search. - **configure_spectre_result_fields:** The fields to include in the search results. @@ -89,44 +100,48 @@ class Model spectre :searchable configure_spectre_search_path 'embedding' configure_spectre_search_index 'vector_index' - configure_spectre_result_fields({ "message": 1, "response": 1 }) + configure_spectre_result_fields({ "message" => 1, "response" => 1 }) end ``` -### 3. Generating Embeddings +### 3. Create Embeddings + +**Create Embedding for a Single Record** -**Generate Embedding for a Single Record** +To create an embedding for a single record, you can call the `embed!` method on the instance record: -To generate an embedding for a single record, you can call the embed! method on the instance record: ```ruby record = Model.find(some_id) record.embed! ``` -This will generate the embedding and store it in the specified embedding field, along with the timestamp in the embedded_at field. -**Generate Embeddings for Multiple Records** +This will create the embedding and store it in the specified embedding field, along with the timestamp in the `embedded_at` field. + +**Create Embeddings for Multiple Records** + +To create embeddings for multiple records at once, use the `embed_all!` method: -To generate embeddings for multiple records at once, use the embed_all! method: ```ruby Model.embed_all!( scope: -> { where(:response.exists => true, :response.ne => nil) }, validation: ->(record) { !record.response.blank? } ) ``` -This method will generate embeddings for all records that match the given scope and validation criteria. The method will also print the number of successful and failed embeddings to the console. -**Directly Generate Embeddings Using Spectre.provider_module::Embeddings.generate** +This method will create embeddings for all records that match the given scope and validation criteria. The method will also print the number of successful and failed embeddings to the console. + +**Directly Create Embeddings Using `Spectre.provider_module::Embeddings.create`** -If you need to generate an embedding directly without using the model integration, you can use the `Spectre.provider_module::Embeddings.generate` method. This can be useful if you want to generate embeddings for custom text outside of your models. For example, with OpenAI: +If you need to create an embedding directly without using the model integration, you can use the `Spectre.provider_module::Embeddings.create` method. This can be useful if you want to create embeddings for custom text outside of your models. For example, with OpenAI: ```ruby -Spectre::provider_module::Embeddings.generate("Your text here") +Spectre.provider_module::Embeddings.create("Your text here") ``` This method sends the text to OpenAI’s API and returns the embedding vector. You can optionally specify a different model by passing it as an argument: ```ruby -Spectre.provider_module::Embeddings.generate("Your text here", model: "text-embedding-3-large") +Spectre.provider_module::Embeddings.create("Your text here", model: "text-embedding-ada-002") ``` ### 4. Performing Vector-Based Searches @@ -134,31 +149,31 @@ Spectre.provider_module::Embeddings.generate("Your text here", model: "text-embe Once your model is configured as searchable, you can perform vector-based searches on the stored embeddings: ```ruby -Model.vector_search('Your search query', custom_result_fields: { "response" => 1 }, additional_scopes: [{ "$match": { "category": "science" } }]) +Model.vector_search('Your search query', custom_result_fields: { "response" => 1 }, additional_scopes: [{ "$match" => { "category" => "science" } }]) ``` This method will: -• Embed the search query using the configured LLM provider. **Note:** If your text is already embeded, you can pass the embed _(as a Array)_ and it will perform just the search. +- **Embed the Search Query:** Uses the configured LLM provider to embed the search query. + **Note:** If your text is already embedded, you can pass the embedding (as an array), and it will perform just the search. -• Perform a vector-based search on the embeddings stored in the specified search_path. +- **Perform Vector-Based Search:** Searches the embeddings stored in the specified `search_path`. -• Return the matching records with the specified result_fields and their vectorSearchScore. +- **Return Matching Records:** Provides the matching records with the specified `result_fields` and their `vectorSearchScore`. **Keyword Arguments:** -• **Custom Result Fields**: Limit the fields returned in the search results. - -• **Additional Scopes**: Apply additional MongoDB filters to the search results. +- **custom_result_fields:** Limit the fields returned in the search results. +- **additional_scopes:** Apply additional MongoDB filters to the search results. ### 5. Creating Completions -Spectre provides an interface to generate chat completions using your configured LLM provider, allowing you to generate dynamic responses, messages, or other forms of text. +Spectre provides an interface to create chat completions using your configured LLM provider, allowing you to create dynamic responses, messages, or other forms of text. **Basic Completion Example** -To generate a simple chat completion, use the Spectre.provider_module::Completions.generate method. You can provide a user prompt and an optional system prompt to guide the response: - +To create a simple chat completion, use the `Spectre.provider_module::Completions.create` method. You can provide a user prompt and an optional system prompt to guide the response: + ```ruby Spectre.provider_module::Completions.create( user_prompt: "Tell me a joke.", @@ -166,24 +181,24 @@ Spectre.provider_module::Completions.create( ) ``` -This sends the request to the LLM provider’s API and returns the generated chat completion. +This sends the request to the LLM provider’s API and returns the chat completion. **Customizing the Completion** -You can customize the behavior by specifying additional parameters such as the model or an assistant_prompt to provide further context for the AI’s responses: +You can customize the behavior by specifying additional parameters such as the model or an `assistant_prompt` to provide further context for the AI’s responses: ```ruby Spectre.provider_module::Completions.create( user_prompt: "Tell me a joke.", system_prompt: "You are a funny assistant.", assistant_prompt: "Sure, here's a joke!", - model: "gpt-4-turbo" + model: "gpt-4" ) ``` **Using a JSON Schema for Structured Output** -For cases where you need structured output (e.g., for returning specific fields or formatted responses), you can pass a json_schema parameter. The schema ensures that the completion conforms to a predefined structure: +For cases where you need structured output (e.g., for returning specific fields or formatted responses), you can pass a `json_schema` parameter. The schema ensures that the completion conforms to a predefined structure: ```ruby json_schema = { @@ -205,11 +220,12 @@ Spectre.provider_module::Completions.create( json_schema: json_schema ) ``` + This structured format guarantees that the response adheres to the schema you’ve provided, ensuring more predictable and controlled results. -### 6. Generating Dynamic Prompts +### 6. Creating Dynamic Prompts -Spectre provides a system for generating dynamic prompts based on templates. You can define reusable prompt templates and generate them with different parameters in your Rails app, _(like view partials)_. +Spectre provides a system for creating dynamic prompts based on templates. You can define reusable prompt templates and render them with different parameters in your Rails app (think Ruby on Rails view partials). **Example Directory Structure for Prompts** @@ -222,35 +238,37 @@ app/spectre/prompts/ └── user.yml.erb ``` -Each .yml.erb file can contain dynamic content and be customized with embedded Ruby (ERB). +Each `.yml.erb` file can contain dynamic content and be customized with embedded Ruby (ERB). **Example Prompt Templates** -• system.yml.erb: -```yaml -system: | - You are a helpful assistant designed to provide answers based on specific documents and context provided to you. - Follow these guidelines: - 1. Only provide answers based on the context provided. - 2. Be polite and concise. -``` +- **`system.yml.erb`:** -• user.yml.erb: -```yaml -user: | - User's query: <%= @query %> - Context: <%= @objects.join(", ") %> -``` + ```yaml + system: | + You are a helpful assistant designed to provide answers based on specific documents and context provided to you. + Follow these guidelines: + 1. Only provide answers based on the context provided. + 2. Be polite and concise. + ``` + +- **`user.yml.erb`:** + + ```yaml + user: | + User's query: <%= @query %> + Context: <%= @objects.join(", ") %> + ``` -**Generating Prompts in Your Code** +**Rendering Prompts** -You can generate prompts in your Rails application using the Spectre::Prompt.generate method, which loads and renders the specified prompt template: +You can render prompts in your Rails application using the `Spectre::Prompt.render` method, which loads and renders the specified prompt template: ```ruby # Render a system prompt Spectre::Prompt.render(template: 'rag/system') -# Generate a user prompt with local variables +# Render a user prompt with local variables Spectre::Prompt.render( template: 'rag/user', locals: { @@ -260,30 +278,30 @@ Spectre::Prompt.render( ) ``` -• template: The path to the prompt template file (e.g., rag/system). -• locals: A hash of variables to be used inside the ERB template. +- **`template`:** The path to the prompt template file (e.g., `rag/system`). +- **`locals`:** A hash of variables to be used inside the ERB template. **Combining Completions with Prompts** -You can also combine a Compleitions and Prompts like so: +You can also combine completions and prompts like so: ```ruby Spectre.provider_module::Completions.create( - user_prompt: Spectre::Prompt.render(template: 'rag/user', locals: {query: @query, user: @user}), + user_prompt: Spectre::Prompt.render(template: 'rag/user', locals: { query: @query, user: @user }), system_prompt: Spectre::Prompt.render(template: 'rag/system') ) ``` ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/hiremav/spectre. This project is intended to be a safe, welcoming space for collaboration, and your contributions are greatly appreciated! +Bug reports and pull requests are welcome on GitHub at [https://github.com/hiremav/spectre](https://github.com/hiremav/spectre). This project is intended to be a safe, welcoming space for collaboration, and your contributions are greatly appreciated! - 1. Fork the repository. - 2. Create a new feature branch (git checkout -b my-new-feature). - 3. Commit your changes (git commit -am 'Add some feature'). - 4. Push the branch (git push origin my-new-feature). - 5. Create a pull request. +1. **Fork** the repository. +2. **Create** a new feature branch (`git checkout -b my-new-feature`). +3. **Commit** your changes (`git commit -am 'Add some feature'`). +4. **Push** the branch (`git push origin my-new-feature`). +5. **Create** a pull request. ## License -This gem is available as open source under the terms of the MIT License. +This gem is available as open source under the terms of the MIT License. \ No newline at end of file From ddc103093f0112bca9b0f8464e7a2656c9884e4d Mon Sep 17 00:00:00 2001 From: kladafox Date: Mon, 16 Sep 2024 16:13:23 +0200 Subject: [PATCH 36/37] Change the name of Embeddings class method from generate to create --- lib/spectre/embeddable.rb | 2 +- lib/spectre/openai/embeddings.rb | 2 +- lib/spectre/searchable.rb | 2 +- spec/spectre/embeddable_spec.rb | 4 ++-- spec/spectre/openai/embeddings_spec.rb | 10 +++++----- spec/spectre/searchable_spec.rb | 6 +++--- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/spectre/embeddable.rb b/lib/spectre/embeddable.rb index 98edfed..692a990 100644 --- a/lib/spectre/embeddable.rb +++ b/lib/spectre/embeddable.rb @@ -43,7 +43,7 @@ def embed!(validation: nil, embedding_field: :embedding, timestamp_field: :embed raise EmbeddingValidationError, "Validation failed for embedding" end - embedding_value = Spectre.provider_module::Embeddings.generate(as_vector) + embedding_value = Spectre.provider_module::Embeddings.create(as_vector) send("#{embedding_field}=", embedding_value) send("#{timestamp_field}=", Time.now) save! diff --git a/lib/spectre/openai/embeddings.rb b/lib/spectre/openai/embeddings.rb index 4ffbcc6..f140dce 100644 --- a/lib/spectre/openai/embeddings.rb +++ b/lib/spectre/openai/embeddings.rb @@ -17,7 +17,7 @@ class Embeddings # @return [Array] the generated embedding vector # @raise [APIKeyNotConfiguredError] if the API key is not set # @raise [RuntimeError] for general API errors or unexpected issues - def self.generate(text, model: DEFAULT_MODEL) + def self.create(text, model: DEFAULT_MODEL) api_key = Spectre.api_key raise APIKeyNotConfiguredError, "API key is not configured" unless api_key diff --git a/lib/spectre/searchable.rb b/lib/spectre/searchable.rb index b1b21a6..46d11c9 100644 --- a/lib/spectre/searchable.rb +++ b/lib/spectre/searchable.rb @@ -90,7 +90,7 @@ def result_fields def vector_search(query, limit: 5, additional_scopes: [], custom_result_fields: nil) # Check if the query is a string (needs embedding) or an array (already embedded) embedded_query = if query.is_a?(String) - Spectre.provider_module::Embeddings.generate(query) + Spectre.provider_module::Embeddings.create(query) elsif query.is_a?(Array) && query.all? { |e| e.is_a?(Float) } query else diff --git a/spec/spectre/embeddable_spec.rb b/spec/spectre/embeddable_spec.rb index 640cfea..02630fa 100644 --- a/spec/spectre/embeddable_spec.rb +++ b/spec/spectre/embeddable_spec.rb @@ -37,7 +37,7 @@ describe '#embed!' do before do - allow(Spectre::Openai::Embeddings).to receive(:generate).and_return('embedded_value') + allow(Spectre::Openai::Embeddings).to receive(:create).and_return('embedded_value') end context 'when validation passes' do @@ -68,7 +68,7 @@ describe '.embed_all!' do before do - allow(Spectre::Openai::Embeddings).to receive(:generate).and_return('embedded_value') + allow(Spectre::Openai::Embeddings).to receive(:create).and_return('embedded_value') end context 'for all records' do diff --git a/spec/spectre/openai/embeddings_spec.rb b/spec/spectre/openai/embeddings_spec.rb index f87efe9..8273808 100644 --- a/spec/spectre/openai/embeddings_spec.rb +++ b/spec/spectre/openai/embeddings_spec.rb @@ -20,7 +20,7 @@ it 'raises an APIKeyNotConfiguredError' do expect { - described_class.generate(text) + described_class.create(text) }.to raise_error(Spectre::APIKeyNotConfiguredError, 'API key is not configured') end end @@ -32,7 +32,7 @@ end it 'returns the embedding' do - result = described_class.generate(text) + result = described_class.create(text) expect(result).to eq(embedding) end end @@ -45,7 +45,7 @@ it 'raises an error with the API response' do expect { - described_class.generate(text) + described_class.create(text) }.to raise_error(RuntimeError, /OpenAI API Error/) end end @@ -58,7 +58,7 @@ it 'raises a JSON Parse Error' do expect { - described_class.generate(text) + described_class.create(text) }.to raise_error(RuntimeError, /JSON Parse Error/) end end @@ -71,7 +71,7 @@ it 'raises a Request Timeout error' do expect { - described_class.generate(text) + described_class.create(text) }.to raise_error(RuntimeError, /Request Timeout/) end end diff --git a/spec/spectre/searchable_spec.rb b/spec/spectre/searchable_spec.rb index 73aaa7d..8ebf31e 100644 --- a/spec/spectre/searchable_spec.rb +++ b/spec/spectre/searchable_spec.rb @@ -13,7 +13,7 @@ config.llm_provider = :openai end - allow(Spectre::Openai::Embeddings).to receive(:generate).and_return([0.1, 0.2, 0.3]) + allow(Spectre::Openai::Embeddings).to receive(:create).and_return([0.1, 0.2, 0.3]) # Mock the aggregate method on the collection to simulate MongoDB's aggregation pipeline allow(TestModel.collection).to receive(:aggregate).and_return([ @@ -62,8 +62,8 @@ it 'uses the provided embedding directly' do embedded_query = [0.1, 0.2, 0.3] - # Ensure that the generate method is not called - expect(Spectre::Openai::Embeddings).not_to receive(:generate) + # Ensure that the create method is not called + expect(Spectre::Openai::Embeddings).not_to receive(:create) results = TestModel.vector_search(embedded_query) expect(results).to be_an(Array) From fd8a3fa83cbde06ab540ae1b17dec8375972d522 Mon Sep 17 00:00:00 2001 From: kladafox Date: Mon, 16 Sep 2024 17:14:25 +0200 Subject: [PATCH 37/37] Bump version --- Gemfile.lock | 2 +- lib/spectre/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index fc683b6..a87f3a3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - spectre (0.0.1) + spectre (1.0.0) GEM remote: https://rubygems.org/ diff --git a/lib/spectre/version.rb b/lib/spectre/version.rb index 6d198ca..18e20ba 100644 --- a/lib/spectre/version.rb +++ b/lib/spectre/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Spectre # :nodoc:all - VERSION = "0.0.1" + VERSION = "1.0.0" end