spectre_ai 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +0 -0
- data/README.md +307 -0
- data/lib/generators/spectre/install_generator.rb +18 -0
- data/lib/generators/spectre/templates/rag/system.yml.erb +5 -0
- data/lib/generators/spectre/templates/rag/user.yml.erb +3 -0
- data/lib/generators/spectre/templates/spectre_initializer.rb +8 -0
- data/lib/spectre/embeddable.rb +108 -0
- data/lib/spectre/logging.rb +36 -0
- data/lib/spectre/openai/completions.rb +104 -0
- data/lib/spectre/openai/embeddings.rb +50 -0
- data/lib/spectre/openai.rb +9 -0
- data/lib/spectre/prompt.rb +65 -0
- data/lib/spectre/searchable.rb +127 -0
- data/lib/spectre/version.rb +5 -0
- data/lib/spectre.rb +59 -0
- metadata +87 -0
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,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,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
|
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: []
|