spectre_ai 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7ce184de05db051e8406fcd782ab084b745899cf914ac606a3a57b7201835325
4
+ data.tar.gz: b2adc0ef0d18ca87a9af0d5a1e3ca4273d2fad2d234a8f1f2cb9c3ca370723ae
5
+ SHA512:
6
+ metadata.gz: cbfaa059e8432580a7fbda955ff66945a7e5b97fd76205feb1ae9f1934bf02bfe610741e56f43ecf8dc842fb600e3e1a913e5992044dc96b72a3c7feece87a3c
7
+ data.tar.gz: ea9ef3108a3f6550646cd698e13e9e32d97c0de7dcba1b4045d5b880095f078e7ef7520bea5c1515265853290610cf6859ce34497eee1dfbcee2a7f2852068bb
data/CHANGELOG.md ADDED
File without changes
data/README.md ADDED
@@ -0,0 +1,307 @@
1
+ # Spectre
2
+
3
+ **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.
4
+
5
+ ## Compatibility
6
+
7
+ | Feature | Compatibility |
8
+ |-------------------------|---------------|
9
+ | Foundation Models (LLM) | OpenAI |
10
+ | Embeddings | OpenAI |
11
+ | Vector Searching | MongoDB Atlas |
12
+ | Prompt Templates | OpenAI |
13
+
14
+ **💡 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).
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem 'spectre'
22
+ ```
23
+
24
+ And then execute:
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ Or install it yourself as:
31
+
32
+ ```bash
33
+ gem install spectre
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ### 1. Setup
39
+
40
+ First, you’ll need to generate the initializer to configure your OpenAI API key. Run the following command to create the initializer:
41
+
42
+ ```bash
43
+ rails generate spectre:install
44
+ ```
45
+
46
+ This will create a file at `config/initializers/spectre.rb`, where you can set your OpenAI API key:
47
+
48
+ ```ruby
49
+ Spectre.setup do |config|
50
+ config.api_key = 'your_openai_api_key'
51
+ config.llm_provider = :openai
52
+ end
53
+ ```
54
+
55
+ ### 2. Enable Your Rails Model(s)
56
+
57
+ #### For Embedding
58
+
59
+ To use Spectre for generating embeddings in your Rails model, follow these steps:
60
+
61
+ 1. Include the Spectre module.
62
+ 2. Declare the model as embeddable.
63
+ 3. Define the embeddable fields.
64
+
65
+ Here is an example of how to set this up in a model:
66
+
67
+ ```ruby
68
+ class Model
69
+ include Mongoid::Document
70
+ include Spectre
71
+
72
+ spectre :embeddable
73
+ embeddable_field :message, :response, :category
74
+ end
75
+ ```
76
+
77
+ #### For Vector Searching (MongoDB Only)
78
+
79
+ **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.
80
+
81
+ To enable vector-based search in your Rails model:
82
+
83
+ 1. Include the Spectre module.
84
+ 2. Declare the model as searchable.
85
+ 3. Configure search parameters.
86
+
87
+ Use the following methods to configure the search path, index, and result fields:
88
+
89
+ - **configure_spectre_search_path:** The path where the embeddings are stored.
90
+ - **configure_spectre_search_index:** The index used for the vector search.
91
+ - **configure_spectre_result_fields:** The fields to include in the search results.
92
+
93
+ Here is an example of how to set this up in a model:
94
+
95
+ ```ruby
96
+ class Model
97
+ include Mongoid::Document
98
+ include Spectre
99
+
100
+ spectre :searchable
101
+ configure_spectre_search_path 'embedding'
102
+ configure_spectre_search_index 'vector_index'
103
+ configure_spectre_result_fields({ "message" => 1, "response" => 1 })
104
+ end
105
+ ```
106
+
107
+ ### 3. Create Embeddings
108
+
109
+ **Create Embedding for a Single Record**
110
+
111
+ To create an embedding for a single record, you can call the `embed!` method on the instance record:
112
+
113
+ ```ruby
114
+ record = Model.find(some_id)
115
+ record.embed!
116
+ ```
117
+
118
+ This will create the embedding and store it in the specified embedding field, along with the timestamp in the `embedded_at` field.
119
+
120
+ **Create Embeddings for Multiple Records**
121
+
122
+ To create embeddings for multiple records at once, use the `embed_all!` method:
123
+
124
+ ```ruby
125
+ Model.embed_all!(
126
+ scope: -> { where(:response.exists => true, :response.ne => nil) },
127
+ validation: ->(record) { !record.response.blank? }
128
+ )
129
+ ```
130
+
131
+ 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.
132
+
133
+ **Directly Create Embeddings Using `Spectre.provider_module::Embeddings.create`**
134
+
135
+ 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:
136
+
137
+ ```ruby
138
+ Spectre.provider_module::Embeddings.create("Your text here")
139
+ ```
140
+
141
+ 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:
142
+
143
+ ```ruby
144
+ Spectre.provider_module::Embeddings.create("Your text here", model: "text-embedding-ada-002")
145
+ ```
146
+
147
+ ### 4. Performing Vector-Based Searches
148
+
149
+ Once your model is configured as searchable, you can perform vector-based searches on the stored embeddings:
150
+
151
+ ```ruby
152
+ Model.vector_search('Your search query', custom_result_fields: { "response" => 1 }, additional_scopes: [{ "$match" => { "category" => "science" } }])
153
+ ```
154
+
155
+ This method will:
156
+
157
+ - **Embed the Search Query:** Uses the configured LLM provider to embed the search query.
158
+ **Note:** If your text is already embedded, you can pass the embedding (as an array), and it will perform just the search.
159
+
160
+ - **Perform Vector-Based Search:** Searches the embeddings stored in the specified `search_path`.
161
+
162
+ - **Return Matching Records:** Provides the matching records with the specified `result_fields` and their `vectorSearchScore`.
163
+
164
+ **Keyword Arguments:**
165
+
166
+ - **custom_result_fields:** Limit the fields returned in the search results.
167
+ - **additional_scopes:** Apply additional MongoDB filters to the search results.
168
+
169
+ ### 5. Creating Completions
170
+
171
+ 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.
172
+
173
+ **Basic Completion Example**
174
+
175
+ 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:
176
+
177
+ ```ruby
178
+ Spectre.provider_module::Completions.create(
179
+ user_prompt: "Tell me a joke.",
180
+ system_prompt: "You are a funny assistant."
181
+ )
182
+ ```
183
+
184
+ This sends the request to the LLM provider’s API and returns the chat completion.
185
+
186
+ **Customizing the Completion**
187
+
188
+ 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:
189
+
190
+ ```ruby
191
+ Spectre.provider_module::Completions.create(
192
+ user_prompt: "Tell me a joke.",
193
+ system_prompt: "You are a funny assistant.",
194
+ assistant_prompt: "Sure, here's a joke!",
195
+ model: "gpt-4"
196
+ )
197
+ ```
198
+
199
+ **Using a JSON Schema for Structured Output**
200
+
201
+ 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:
202
+
203
+ ```ruby
204
+ json_schema = {
205
+ name: "completion_response",
206
+ schema: {
207
+ type: "object",
208
+ properties: {
209
+ response: { type: "string" },
210
+ final_answer: { type: "string" }
211
+ },
212
+ required: ["response", "final_answer"],
213
+ additionalProperties: false
214
+ }
215
+ }
216
+
217
+ Spectre.provider_module::Completions.create(
218
+ user_prompt: "What is the capital of France?",
219
+ system_prompt: "You are a knowledgeable assistant.",
220
+ json_schema: json_schema
221
+ )
222
+ ```
223
+
224
+ This structured format guarantees that the response adheres to the schema you’ve provided, ensuring more predictable and controlled results.
225
+
226
+ ### 6. Creating Dynamic Prompts
227
+
228
+ 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).
229
+
230
+ **Example Directory Structure for Prompts**
231
+
232
+ Create a folder structure in your app to hold the prompt templates:
233
+
234
+ ```
235
+ app/spectre/prompts/
236
+ └── rag/
237
+ ├── system.yml.erb
238
+ └── user.yml.erb
239
+ ```
240
+
241
+ Each `.yml.erb` file can contain dynamic content and be customized with embedded Ruby (ERB).
242
+
243
+ **Example Prompt Templates**
244
+
245
+ - **`system.yml.erb`:**
246
+
247
+ ```yaml
248
+ system: |
249
+ You are a helpful assistant designed to provide answers based on specific documents and context provided to you.
250
+ Follow these guidelines:
251
+ 1. Only provide answers based on the context provided.
252
+ 2. Be polite and concise.
253
+ ```
254
+
255
+ - **`user.yml.erb`:**
256
+
257
+ ```yaml
258
+ user: |
259
+ User's query: <%= @query %>
260
+ Context: <%= @objects.join(", ") %>
261
+ ```
262
+
263
+ **Rendering Prompts**
264
+
265
+ You can render prompts in your Rails application using the `Spectre::Prompt.render` method, which loads and renders the specified prompt template:
266
+
267
+ ```ruby
268
+ # Render a system prompt
269
+ Spectre::Prompt.render(template: 'rag/system')
270
+
271
+ # Render a user prompt with local variables
272
+ Spectre::Prompt.render(
273
+ template: 'rag/user',
274
+ locals: {
275
+ query: query,
276
+ objects: objects
277
+ }
278
+ )
279
+ ```
280
+
281
+ - **`template`:** The path to the prompt template file (e.g., `rag/system`).
282
+ - **`locals`:** A hash of variables to be used inside the ERB template.
283
+
284
+ **Combining Completions with Prompts**
285
+
286
+ You can also combine completions and prompts like so:
287
+
288
+ ```ruby
289
+ Spectre.provider_module::Completions.create(
290
+ user_prompt: Spectre::Prompt.render(template: 'rag/user', locals: { query: @query, user: @user }),
291
+ system_prompt: Spectre::Prompt.render(template: 'rag/system')
292
+ )
293
+ ```
294
+
295
+ ## Contributing
296
+
297
+ 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!
298
+
299
+ 1. **Fork** the repository.
300
+ 2. **Create** a new feature branch (`git checkout -b my-new-feature`).
301
+ 3. **Commit** your changes (`git commit -am 'Add some feature'`).
302
+ 4. **Push** the branch (`git push origin my-new-feature`).
303
+ 5. **Create** a pull request.
304
+
305
+ ## License
306
+
307
+ This gem is available as open source under the terms of the MIT License.
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectre
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path('templates', __dir__)
7
+
8
+ def create_initializer_file
9
+ template "spectre_initializer.rb", "config/initializers/spectre.rb"
10
+ end
11
+
12
+ desc "This generator creates system_prompt.yml.erb and user_prompt.yml.erb examples in your app/spectre/prompts folder."
13
+ def create_prompt_files
14
+ directory 'rag', 'app/spectre/prompts/rag'
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ system: |
2
+ You are a helpful assistant designed to provide answers based on specific documents and context provided to you.
3
+ Follow these guidelines:
4
+ 1. Only provide answers based on the context provided to you.
5
+ 2. Never mention the context directly in your responses.
@@ -0,0 +1,3 @@
1
+ user: |
2
+ User's query: <%= @query %>
3
+ Context: <%= @objects.join(", ") %>
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ Spectre.setup do |config|
4
+ # Chose your LLM (openai, cohere, ollama)
5
+ config.llm_provider = :openai
6
+ # Set the API key for your chosen LLM
7
+ config.api_key = ENV.fetch('CHATGPT_API_TOKEN')
8
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logging'
4
+ require_relative 'openai'
5
+
6
+ module Spectre
7
+ module Embeddable
8
+ include Spectre::Logging
9
+
10
+ class NoEmbeddableFieldsError < StandardError; end
11
+ class EmbeddingValidationError < StandardError; end
12
+
13
+ def self.included(base)
14
+ base.extend ClassMethods
15
+ end
16
+
17
+ # Converts the specified fields into a JSON representation suitable for embedding.
18
+ #
19
+ # @return [String] A JSON string representing the vectorized content of the specified fields.
20
+ #
21
+ # @raise [NoEmbeddableFieldsError] if no embeddable fields are defined in the model.
22
+ #
23
+ def as_vector
24
+ raise NoEmbeddableFieldsError, "Embeddable fields are not defined" if self.class.embeddable_fields.empty?
25
+
26
+ vector_data = self.class.embeddable_fields.map { |field| [field, send(field)] }.to_h
27
+ vector_data.to_json
28
+ end
29
+
30
+ # Embeds the vectorized content and saves it to the specified fields.
31
+ #
32
+ # @param validation [Proc, nil] A validation block that returns true if the embedding should proceed.
33
+ # @param embedding_field [Symbol] The field in which to store the generated embedding (default: :embedding).
34
+ # @param timestamp_field [Symbol] The field in which to store the embedding timestamp (default: :embedded_at).
35
+ #
36
+ # @example
37
+ # embed!(validation: ->(record) { !record.response.nil? }, embedding_field: :custom_embedding, timestamp_field: :custom_embedded_at)
38
+ #
39
+ # @raise [EmbeddingValidationError] if the validation block fails.
40
+ #
41
+ def embed!(validation: nil, embedding_field: :embedding, timestamp_field: :embedded_at)
42
+ if validation && !validation.call(self)
43
+ raise EmbeddingValidationError, "Validation failed for embedding"
44
+ end
45
+
46
+ embedding_value = Spectre.provider_module::Embeddings.create(as_vector)
47
+ send("#{embedding_field}=", embedding_value)
48
+ send("#{timestamp_field}=", Time.now)
49
+ save!
50
+ end
51
+
52
+ module ClassMethods
53
+ include Spectre::Logging
54
+
55
+ def embeddable_field(*fields)
56
+ @embeddable_fields = fields
57
+ end
58
+
59
+ def embeddable_fields
60
+ @embeddable_fields ||= []
61
+ end
62
+
63
+ # Embeds the vectorized content for all records that match the optional scope
64
+ # and pass the validation check. Saves the embedding and timestamp to the specified fields.
65
+ # Also counts the number of successful and failed embeddings.
66
+ #
67
+ # @param scope [Proc, nil] A scope or query to filter records (default: all records).
68
+ # @param validation [Proc, nil] A validation block that returns true if the embedding should proceed for a record.
69
+ # @param embedding_field [Symbol] The field in which to store the generated embedding (default: :embedding).
70
+ # @param timestamp_field [Symbol] The field in which to store the embedding timestamp (default: :embedded_at).
71
+ #
72
+ # @example
73
+ # embed_all!(
74
+ # scope: -> { where(:response.exists => true, :response.ne => nil) },
75
+ # validation: ->(record) { !record.response.nil? },
76
+ # embedding_field: :custom_embedding,
77
+ # timestamp_field: :custom_embedded_at
78
+ # )
79
+ #
80
+ def embed_all!(scope: nil, validation: nil, embedding_field: :embedding, timestamp_field: :embedded_at)
81
+ records = scope ? instance_exec(&scope) : all
82
+
83
+ success_count = 0
84
+ failure_count = 0
85
+
86
+ records.each do |record|
87
+ begin
88
+ record.embed!(
89
+ validation: validation,
90
+ embedding_field: embedding_field,
91
+ timestamp_field: timestamp_field
92
+ )
93
+ success_count += 1
94
+ rescue EmbeddingValidationError => e
95
+ log_error("Failed to embed record #{record.id}: #{e.message}")
96
+ failure_count += 1
97
+ rescue => e
98
+ log_error("Unexpected error embedding record #{record.id}: #{e.message}")
99
+ failure_count += 1
100
+ end
101
+ end
102
+
103
+ puts "Successfully embedded #{success_count} records."
104
+ puts "Failed to embed #{failure_count} records."
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'time'
5
+
6
+ module Spectre
7
+ module Logging
8
+ def logger
9
+ @logger ||= create_logger
10
+ end
11
+
12
+ def log_error(message)
13
+ logger.error(message)
14
+ end
15
+
16
+ def log_info(message)
17
+ logger.info(message)
18
+ end
19
+
20
+ def log_debug(message)
21
+ logger.debug(message)
22
+ end
23
+
24
+ private
25
+
26
+ def create_logger
27
+ Logger.new(STDOUT).tap do |log|
28
+ log.progname = 'Spectre'
29
+ log.level = Logger::DEBUG # Set the default log level (can be changed to INFO, WARN, etc.)
30
+ log.formatter = proc do |severity, datetime, progname, msg|
31
+ "#{datetime.utc.iso8601} #{severity} #{progname}: #{msg}\n"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Spectre
8
+ module Openai
9
+ class Completions
10
+ API_URL = 'https://api.openai.com/v1/chat/completions'
11
+ DEFAULT_MODEL = 'gpt-4o-mini'
12
+
13
+ # Class method to generate a completion based on a user prompt
14
+ #
15
+ # @param user_prompt [String] the user's input to generate a completion for
16
+ # @param system_prompt [String] an optional system prompt to guide the AI's behavior
17
+ # @param assistant_prompt [String] an optional assistant prompt to provide context for the assistant's behavior
18
+ # @param model [String] the model to be used for generating completions, defaults to DEFAULT_MODEL
19
+ # @param json_schema [Hash, nil] an optional JSON schema to enforce structured output
20
+ # @param max_tokens [Integer] the maximum number of tokens for the completion (default: 50)
21
+ # @return [String] the generated completion text
22
+ # @raise [APIKeyNotConfiguredError] if the API key is not set
23
+ # @raise [RuntimeError] for general API errors or unexpected issues
24
+ def self.create(user_prompt:, system_prompt: "You are a helpful assistant.", assistant_prompt: nil, model: DEFAULT_MODEL, json_schema: nil, max_tokens: nil)
25
+ api_key = Spectre.api_key
26
+ raise APIKeyNotConfiguredError, "API key is not configured" unless api_key
27
+
28
+ uri = URI(API_URL)
29
+ http = Net::HTTP.new(uri.host, uri.port)
30
+ http.use_ssl = true
31
+ http.read_timeout = 10 # seconds
32
+ http.open_timeout = 10 # seconds
33
+
34
+ request = Net::HTTP::Post.new(uri.path, {
35
+ 'Content-Type' => 'application/json',
36
+ 'Authorization' => "Bearer #{api_key}"
37
+ })
38
+
39
+ request.body = generate_body(user_prompt, system_prompt, assistant_prompt, model, json_schema, max_tokens).to_json
40
+ response = http.request(request)
41
+
42
+ unless response.is_a?(Net::HTTPSuccess)
43
+ raise "OpenAI API Error: #{response.code} - #{response.message}: #{response.body}"
44
+ end
45
+
46
+ parsed_response = JSON.parse(response.body)
47
+
48
+ # Check if the response contains a refusal
49
+ if parsed_response.dig('choices', 0, 'message', 'refusal')
50
+ raise "Refusal: #{parsed_response.dig('choices', 0, 'message', 'refusal')}"
51
+ end
52
+
53
+ # Check if the finish reason is "length", indicating incomplete response
54
+ if parsed_response.dig('choices', 0, 'finish_reason') == "length"
55
+ raise "Incomplete response: The completion was cut off due to token limit."
56
+ end
57
+
58
+ # Return the structured output if it's included
59
+ parsed_response.dig('choices', 0, 'message', 'content')
60
+ rescue JSON::ParserError => e
61
+ raise "JSON Parse Error: #{e.message}"
62
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
63
+ raise "Request Timeout: #{e.message}"
64
+ end
65
+
66
+ private
67
+
68
+ # Helper method to generate the request body
69
+ #
70
+ # @param user_prompt [String] the user's input to generate a completion for
71
+ # @param system_prompt [String] an optional system prompt to guide the AI's behavior
72
+ # @param assistant_prompt [String] an optional assistant prompt to provide context for the assistant's behavior
73
+ # @param model [String] the model to be used for generating completions
74
+ # @param json_schema [Hash, nil] an optional JSON schema to enforce structured output
75
+ # @param max_tokens [Integer, nil] the maximum number of tokens for the completion
76
+ # @return [Hash] the body for the API request
77
+ def self.generate_body(user_prompt, system_prompt, assistant_prompt, model, json_schema, max_tokens)
78
+ messages = [
79
+ { role: 'system', content: system_prompt },
80
+ { role: 'user', content: user_prompt }
81
+ ]
82
+
83
+ # Add the assistant prompt if provided
84
+ messages << { role: 'assistant', content: assistant_prompt } if assistant_prompt
85
+
86
+ body = {
87
+ model: model,
88
+ messages: messages,
89
+ }
90
+ body['max_tokens'] = max_tokens if max_tokens
91
+
92
+ # Add the JSON schema as part of response_format if provided
93
+ if json_schema
94
+ body[:response_format] = {
95
+ type: 'json_schema',
96
+ json_schema: json_schema
97
+ }
98
+ end
99
+
100
+ body
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Spectre
8
+ module Openai
9
+ class Embeddings
10
+ API_URL = 'https://api.openai.com/v1/embeddings'
11
+ DEFAULT_MODEL = 'text-embedding-3-small'
12
+
13
+ # Class method to generate embeddings for a given text
14
+ #
15
+ # @param text [String] the text input for which embeddings are to be generated
16
+ # @param model [String] the model to be used for generating embeddings, defaults to DEFAULT_MODEL
17
+ # @return [Array<Float>] the generated embedding vector
18
+ # @raise [APIKeyNotConfiguredError] if the API key is not set
19
+ # @raise [RuntimeError] for general API errors or unexpected issues
20
+ def self.create(text, model: DEFAULT_MODEL)
21
+ api_key = Spectre.api_key
22
+ raise APIKeyNotConfiguredError, "API key is not configured" unless api_key
23
+
24
+ uri = URI(API_URL)
25
+ http = Net::HTTP.new(uri.host, uri.port)
26
+ http.use_ssl = true
27
+ http.read_timeout = 10 # seconds
28
+ http.open_timeout = 10 # seconds
29
+
30
+ request = Net::HTTP::Post.new(uri.path, {
31
+ 'Content-Type' => 'application/json',
32
+ 'Authorization' => "Bearer #{api_key}"
33
+ })
34
+
35
+ request.body = { model: model, input: text }.to_json
36
+ response = http.request(request)
37
+
38
+ unless response.is_a?(Net::HTTPSuccess)
39
+ raise "OpenAI API Error: #{response.code} - #{response.message}: #{response.body}"
40
+ end
41
+
42
+ JSON.parse(response.body).dig('data', 0, 'embedding')
43
+ rescue JSON::ParserError => e
44
+ raise "JSON Parse Error: #{e.message}"
45
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
46
+ raise "Request Timeout: #{e.message}"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectre
4
+ module Openai
5
+ # Require each specific client file here
6
+ require_relative 'openai/embeddings'
7
+ require_relative 'openai/completions'
8
+ end
9
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'yaml'
5
+
6
+ module Spectre
7
+ class Prompt
8
+ PROMPTS_PATH = File.join(Dir.pwd, 'app', 'spectre', 'prompts')
9
+
10
+ # Render a prompt by reading and rendering the YAML template
11
+ #
12
+ # @param template [String] The path to the template file, formatted as 'type/prompt' (e.g., 'rag/system')
13
+ # @param locals [Hash] Variables to be passed to the template for rendering
14
+ #
15
+ # @return [String] Rendered prompt
16
+ def self.render(template:, locals: {})
17
+ type, prompt = split_template(template)
18
+ file_path = prompt_file_path(type, prompt)
19
+
20
+ raise "Prompt file not found: #{file_path}" unless File.exist?(file_path)
21
+
22
+ template_content = File.read(file_path)
23
+ erb_template = ERB.new(template_content)
24
+
25
+ context = Context.new(locals)
26
+ rendered_prompt = erb_template.result(context.get_binding)
27
+
28
+ YAML.safe_load(rendered_prompt)[prompt]
29
+ end
30
+
31
+ private
32
+
33
+ # Split the template parameter into type and prompt
34
+ #
35
+ # @param template [String] Template path in the format 'type/prompt' (e.g., 'rag/system')
36
+ # @return [Array<String, String>] An array containing the type and prompt
37
+ def self.split_template(template)
38
+ template.split('/')
39
+ end
40
+
41
+ # Build the path to the desired prompt file
42
+ #
43
+ # @param type [String] Name of the prompt folder
44
+ # @param prompt [String] Type of prompt (e.g., 'system', 'user')
45
+ #
46
+ # @return [String] Full path to the template file
47
+ def self.prompt_file_path(type, prompt)
48
+ "#{PROMPTS_PATH}/#{type}/#{prompt}.yml.erb"
49
+ end
50
+
51
+ # Helper class to handle the binding for ERB rendering
52
+ class Context
53
+ def initialize(locals)
54
+ locals.each do |key, value|
55
+ instance_variable_set("@#{key}", value)
56
+ end
57
+ end
58
+
59
+ # Returns binding for ERB template rendering
60
+ def get_binding
61
+ binding
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectre
4
+ module Searchable
5
+ def self.included(base)
6
+ unless base.ancestors.map(&:to_s).include?('Mongoid::Document')
7
+ raise "Spectre::Searchable can only be included in Mongoid models. The class #{base.name} does not appear to be a Mongoid model."
8
+ end
9
+
10
+ base.extend ClassMethods
11
+ end
12
+
13
+ module ClassMethods
14
+ # Configure the path to the embedding field for the vector search.
15
+ #
16
+ # @param path [String] The path to the embedding field.
17
+ def configure_spectre_search_path(path)
18
+ @search_path = path
19
+ end
20
+
21
+ # Configure the index to be used for the vector search.
22
+ #
23
+ # @param index [String] The name of the vector index.
24
+ def configure_spectre_search_index(index)
25
+ @search_index = index
26
+ end
27
+
28
+ # Configure the default fields to include in the search results.
29
+ #
30
+ # @param fields [Hash] The fields to include in the results, with their MongoDB projection configuration.
31
+ def configure_spectre_result_fields(fields)
32
+ @result_fields = fields
33
+ end
34
+
35
+ # Provide access to the configured search path.
36
+ #
37
+ # @return [String] The configured search path.
38
+ def search_path
39
+ @search_path || 'embedding' # Default to 'embedding' if not configured
40
+ end
41
+
42
+ # Provide access to the configured search index.
43
+ #
44
+ # @return [String] The configured search index.
45
+ def search_index
46
+ @search_index || 'vector_index' # Default to 'vector_index' if not configured
47
+ end
48
+
49
+ # Provide access to the configured result fields.
50
+ #
51
+ # @return [Hash, nil] The configured result fields, or nil if not configured.
52
+ def result_fields
53
+ @result_fields
54
+ end
55
+
56
+ # Searches based on a query string by first embedding the query.
57
+ #
58
+ # @param query [String] The text query to embed and search for.
59
+ # @param limit [Integer] The maximum number of results to return (default: 5).
60
+ # @param additional_scopes [Array<Hash>] Additional MongoDB aggregation stages to filter or modify results.
61
+ # @param custom_result_fields [Hash, nil] Custom fields to include in the search results, overriding the default.
62
+ #
63
+ # @return [Array<Hash>] The search results, including the configured fields and score.
64
+ #
65
+ # @example Basic search with configured result fields
66
+ # results = Model.vector_search("What is AI?")
67
+ #
68
+ # @example Search with custom result fields
69
+ # results = Model.vector_search(
70
+ # "What is AI?",
71
+ # limit: 10,
72
+ # custom_result_fields: { "some_additional_field": 1, "another_field": 1 }
73
+ # )
74
+ #
75
+ # @example Search with additional filtering using scopes
76
+ # results = Model.vector_search(
77
+ # "What is AI?",
78
+ # limit: 10,
79
+ # additional_scopes: [{ "$match": { "some_field": "some_value" } }]
80
+ # )
81
+ #
82
+ # @example Combining custom result fields and additional scopes
83
+ # results = Model.vector_search(
84
+ # "What is AI?",
85
+ # limit: 10,
86
+ # additional_scopes: [{ "$match": { "some_field": "some_value" } }],
87
+ # custom_result_fields: { "some_additional_field": 1, "another_field": 1 }
88
+ # )
89
+ #
90
+ def vector_search(query, limit: 5, additional_scopes: [], custom_result_fields: nil)
91
+ # Check if the query is a string (needs embedding) or an array (already embedded)
92
+ embedded_query = if query.is_a?(String)
93
+ Spectre.provider_module::Embeddings.create(query)
94
+ elsif query.is_a?(Array) && query.all? { |e| e.is_a?(Float) }
95
+ query
96
+ else
97
+ raise ArgumentError, "Query must be a String or an Array of Floats"
98
+ end
99
+
100
+ # Build the MongoDB aggregation pipeline
101
+ pipeline = [
102
+ {
103
+ "$vectorSearch": {
104
+ "queryVector": embedded_query,
105
+ "path": search_path,
106
+ "numCandidates": 100,
107
+ "limit": limit,
108
+ "index": search_index
109
+ }
110
+ }
111
+ ]
112
+
113
+ # Add any additional scopes provided
114
+ pipeline.concat(additional_scopes) if additional_scopes.any?
115
+
116
+ # Determine the fields to include in the results
117
+ fields_to_project = custom_result_fields || result_fields || {}
118
+ fields_to_project["score"] = { "$meta": "vectorSearchScore" }
119
+
120
+ # Add the project stage with the fields to project
121
+ pipeline << { "$project": fields_to_project }
122
+
123
+ self.collection.aggregate(pipeline).to_a
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectre # :nodoc:all
4
+ VERSION = "1.0.0"
5
+ end
data/lib/spectre.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spectre/version"
4
+ require "spectre/embeddable"
5
+ require 'spectre/searchable'
6
+ require "spectre/openai"
7
+ require "spectre/logging"
8
+ require 'spectre/prompt'
9
+
10
+ module Spectre
11
+ class APIKeyNotConfiguredError < StandardError; end
12
+
13
+ VALID_LLM_PROVIDERS = {
14
+ openai: Spectre::Openai,
15
+ # cohere: Spectre::Cohere,
16
+ # ollama: Spectre::Ollama
17
+ }.freeze
18
+
19
+ def self.included(base)
20
+ base.extend ClassMethods
21
+ end
22
+
23
+ module ClassMethods
24
+ def spectre(*modules)
25
+ modules.each do |mod|
26
+ case mod
27
+ when :embeddable
28
+ include Spectre::Embeddable
29
+ when :searchable
30
+ include Spectre::Searchable
31
+ else
32
+ raise ArgumentError, "Unknown spectre module: #{mod}"
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ class << self
39
+ attr_accessor :api_key, :llm_provider
40
+
41
+ def setup
42
+ yield self
43
+ validate_llm_provider!
44
+ end
45
+
46
+ def provider_module
47
+ VALID_LLM_PROVIDERS[llm_provider] || raise("LLM provider #{llm_provider} not supported")
48
+ end
49
+
50
+ private
51
+
52
+ def validate_llm_provider!
53
+ unless VALID_LLM_PROVIDERS.keys.include?(llm_provider)
54
+ raise ArgumentError, "Invalid llm_provider: #{llm_provider}. Must be one of: #{VALID_LLM_PROVIDERS.keys.join(', ')}"
55
+ end
56
+ end
57
+
58
+ end
59
+ end
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spectre_ai
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Ilya Klapatok
8
+ - Matthew Black
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2024-09-16 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rspec-rails
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :development
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: pry
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ description: Spectre is a Ruby gem that makes it easy to AI-enable your Ruby on Rails
43
+ application.
44
+ email: ilya@hiremav.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - README.md
51
+ - lib/generators/spectre/install_generator.rb
52
+ - lib/generators/spectre/templates/rag/system.yml.erb
53
+ - lib/generators/spectre/templates/rag/user.yml.erb
54
+ - lib/generators/spectre/templates/spectre_initializer.rb
55
+ - lib/spectre.rb
56
+ - lib/spectre/embeddable.rb
57
+ - lib/spectre/logging.rb
58
+ - lib/spectre/openai.rb
59
+ - lib/spectre/openai/completions.rb
60
+ - lib/spectre/openai/embeddings.rb
61
+ - lib/spectre/prompt.rb
62
+ - lib/spectre/searchable.rb
63
+ - lib/spectre/version.rb
64
+ homepage: https://github.com/hiremav/spectre
65
+ licenses:
66
+ - MIT
67
+ metadata: {}
68
+ post_install_message:
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '3'
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.5.11
84
+ signing_key:
85
+ specification_version: 4
86
+ summary: Spectre
87
+ test_files: []