omni_agent 0.1.10 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +100 -2
- data/Rakefile +4 -0
- data/app/jobs/omni_agent/embed_job.rb +10 -0
- data/app/jobs/omni_agent/ingest_job.rb +12 -0
- data/docs/agent/callbacks.md +60 -0
- data/docs/agent/delegation.md +56 -0
- data/docs/agent/provider.md +93 -0
- data/docs/agent/run-entrypoints.md +81 -0
- data/docs/agent/streaming.md +62 -0
- data/docs/agent/structured-output.md +80 -0
- data/docs/configuration.md +68 -0
- data/docs/evals/caching.md +21 -0
- data/docs/evals/generating-and-running.md +87 -0
- data/docs/evals/golden-sets.md +32 -0
- data/docs/evals/judge-provider.md +20 -0
- data/docs/generators.md +143 -0
- data/docs/index.md +98 -0
- data/docs/intro.md +93 -0
- data/docs/mcp/authentication.md +226 -0
- data/docs/mcp/defining-servers.md +196 -0
- data/docs/mcp/transports.md +166 -0
- data/docs/rag.md +372 -0
- data/docs/stock-agents/ocr.md +52 -0
- data/docs/stock-agents/overview.md +91 -0
- data/docs/supported-providers.md +47 -0
- data/docs/tools.md +171 -0
- data/exe/omni_agent +48 -10
- data/lib/generators/omni_agent/chunks/chunks_generator.rb +130 -0
- data/lib/generators/omni_agent/embeddings/embeddings_generator.rb +72 -0
- data/lib/generators/omni_agent/install/install_generator.rb +78 -1
- data/lib/generators/omni_agent/mcp_server/mcp_server_generator.rb +4 -0
- data/lib/generators/omni_agent/stock_agent/stock_agent_generator.rb +73 -0
- data/lib/generators/omni_agent/vector_store.rb +113 -0
- data/lib/omni_agent/agent.rb +168 -39
- data/lib/omni_agent/chunkable.rb +181 -0
- data/lib/omni_agent/chunkers/hierarchical.rb +20 -0
- data/lib/omni_agent/chunkers/markdown.rb +41 -0
- data/lib/omni_agent/chunkers/recursive.rb +53 -0
- data/lib/omni_agent/chunkers.rb +31 -0
- data/lib/omni_agent/configuration.rb +14 -1
- data/lib/omni_agent/docs.rb +32 -0
- data/lib/omni_agent/embeddable.rb +131 -0
- data/lib/omni_agent/engine.rb +11 -0
- data/lib/omni_agent/errors.rb +15 -0
- data/lib/omni_agent/loaders/html.rb +21 -0
- data/lib/omni_agent/loaders/markdown.rb +18 -0
- data/lib/omni_agent/loaders/pdf.rb +25 -0
- data/lib/omni_agent/loaders/text.rb +15 -0
- data/lib/omni_agent/loaders.rb +68 -0
- data/lib/omni_agent/mcp/authenticator.rb +5 -2
- data/lib/omni_agent/mcp/oauth/access_token.rb +60 -0
- data/lib/omni_agent/mcp/oauth/http_client.rb +52 -0
- data/lib/omni_agent/mcp/oauth/introspection_verifier.rb +43 -0
- data/lib/omni_agent/mcp/oauth/jwt_verifier.rb +122 -0
- data/lib/omni_agent/mcp/oauth/resource_server.rb +166 -0
- data/lib/omni_agent/mcp/oauth.rb +18 -0
- data/lib/omni_agent/mcp/rack_app.rb +31 -7
- data/lib/omni_agent/mcp/server.rb +22 -5
- data/lib/omni_agent/providers/base.rb +14 -0
- data/lib/omni_agent/providers/mock.rb +89 -4
- data/lib/omni_agent/providers/ollama.rb +4 -0
- data/lib/omni_agent/providers/openai.rb +33 -2
- data/lib/omni_agent/providers/response.rb +6 -1
- data/lib/omni_agent/providers.rb +7 -0
- data/lib/omni_agent/schema.rb +200 -0
- data/lib/omni_agent/semantic_search.rb +45 -0
- data/lib/omni_agent/stock_agent.rb +59 -0
- data/lib/omni_agent/stock_agents/ocr/prompt.md.erb +6 -0
- data/lib/omni_agent/stock_agents/ocr.rb +70 -0
- data/lib/omni_agent/stock_agents.rb +26 -0
- data/lib/omni_agent/tool.rb +7 -107
- data/lib/omni_agent/version.rb +1 -1
- data/lib/omni_agent.rb +15 -1
- data/lib/tasks/omni_agent_chunks_tasks.rake +16 -0
- data/lib/tasks/omni_agent_embeddings_tasks.rake +15 -0
- metadata +55 -8
- data/app/assets/stylesheets/omni_agent/application.css +0 -15
- data/app/controllers/omni_agent/application_controller.rb +0 -4
- data/app/helpers/omni_agent/application_helper.rb +0 -4
- data/app/jobs/omni_agent/application_job.rb +0 -4
- data/app/mailers/omni_agent/application_mailer.rb +0 -6
- data/app/models/omni_agent/application_record.rb +0 -5
- data/app/views/layouts/omni_agent/application.html.erb +0 -17
data/docs/rag.md
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
<!-- Generated from omniagent-docs/docs/rag.mdx by `bundle exec rake docs:sync`. Do not edit by hand. -->
|
|
2
|
+
<!-- Online version: https://omniagent.activetaskers.com/docs/rag -->
|
|
3
|
+
|
|
4
|
+
# Retrieval-Augmented Generation (RAG)
|
|
5
|
+
|
|
6
|
+
RAG lets an agent answer from your own data: it searches your records for the passages relevant to a question and gives them to the model. Omni Agent keeps the vectors in your own database (or Meilisearch) and turns any searchable model into an agent tool. Records are searched whole with [`has_embedding`](#has_embedding), and files or long text in chunks with [`has_chunks`](#documents).
|
|
7
|
+
|
|
8
|
+
## Quick Start
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
rails generate omni_agent:embeddings Document title content
|
|
12
|
+
bin/rails db:migrate
|
|
13
|
+
bin/rails "omni_agent:embeddings:backfill[Document]"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
class SupportAgent < OmniAgent::Agent
|
|
18
|
+
searches Document, scope: ->(context) { where(account_id: context[:account_id]) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
SupportAgent.with(account_id: current_account.id).run("How do refunds work?")
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The agent now has a `SearchDocuments` tool. The model calls it when it needs information, and gets back the closest documents with their ids.
|
|
25
|
+
|
|
26
|
+
## Supported Stores
|
|
27
|
+
|
|
28
|
+
| Store | How | Requirements |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| Postgres | `has_embedding`, via [neighbor](https://github.com/ankane/neighbor) + pgvector | `gem "neighbor"`, the pgvector extension on the server |
|
|
31
|
+
| SQLite | `has_embedding`, via neighbor + sqlite-vec | `gem "neighbor"`, `gem "sqlite-vec"` |
|
|
32
|
+
| MariaDB 11.8+ | `has_embedding`, via neighbor | `gem "neighbor"` |
|
|
33
|
+
| Meilisearch | `include Meilisearch::Rails`, used as-is | [meilisearch-rails](https://github.com/meilisearch/meilisearch-rails) and an embedder on the index |
|
|
34
|
+
| Anything else | Your own `semantic_search(query, limit:)` | — |
|
|
35
|
+
|
|
36
|
+
MySQL can only search vectors on HeatWave. Use one of the stores above, or see the [Ruby fallback](#ruby-fallback-small-tables-only).
|
|
37
|
+
|
|
38
|
+
## has_embedding
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
class Document < ApplicationRecord
|
|
42
|
+
has_embedding :title, :content
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
* The listed attributes are joined into `embedding_text`, embedded with [`OmniAgent.embed`](#embeddings) and stored in the `embedding` column.
|
|
47
|
+
* Saving re-embeds only when one of those attributes changed. Blank text stores `nil` without calling the provider.
|
|
48
|
+
* `Document.semantic_search("refund policy", limit: 5)` returns the closest records, by cosine distance. It respects the current scope: `Document.where(account_id: 1).semantic_search(...)`.
|
|
49
|
+
* `Document.embed_all` embeds records with no vector yet. `Document.embed_all(force: true)` re-embeds every record.
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
|
|
53
|
+
* **`column:`**: The vector column. Defaults to `:embedding`.
|
|
54
|
+
* **`dimensions:`**: Vector size. Required on SQLite, where the column is `binary`. Postgres and MariaDB read it from the column.
|
|
55
|
+
* **`later:`**: `true` embeds in a [background job](#background-embedding) after commit, instead of during `save`. A Hash also sets that model's job options (`later: { queue: :critical }`).
|
|
56
|
+
* **`using: :ruby`**: See [Ruby fallback](#ruby-fallback-small-tables-only).
|
|
57
|
+
|
|
58
|
+
> **Tip**
|
|
59
|
+
>
|
|
60
|
+
> In tests, set `config.embedding_provider = :mock` so saving a record never calls a real API. The mock returns deterministic 8-dimension vectors.
|
|
61
|
+
|
|
62
|
+
## Background Embedding
|
|
63
|
+
|
|
64
|
+
With `later:`, saving a record enqueues `OmniAgent::EmbedJob` after the transaction commits, so a worker never picks up a record that isn't in the database yet. The vector is `nil` until the job runs.
|
|
65
|
+
|
|
66
|
+
The job is a regular ActiveJob job, so it runs on your app's queue adapter (Sidekiq, Solid Queue, GoodJob, ...). Set the adapter app-wide:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# config/application.rb
|
|
70
|
+
config.active_job.queue_adapter = :sidekiq
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
> **Warning**
|
|
74
|
+
>
|
|
75
|
+
> If you use Sidekiq only through `Sidekiq::Job` and never set `queue_adapter`, ActiveJob falls back to `:async`: jobs run in threads inside your web process and are lost on restart.
|
|
76
|
+
|
|
77
|
+
Set the queue and other options for every embed job in the initializer, then override them per model. They also apply to the jobs that [ingest documents](#documents):
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# config/initializers/omni_agent.rb
|
|
81
|
+
OmniAgent.configure do |config|
|
|
82
|
+
config.embedding_job_options = { queue: :embeddings, priority: 10 }
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
class Document < ApplicationRecord
|
|
88
|
+
has_embedding :title, :content, later: true # queue: :embeddings, priority: 10
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class Ticket < ApplicationRecord
|
|
92
|
+
has_embedding :subject, :body, later: { queue: :critical } # queue: :critical, priority: 10
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Both take ActiveJob's `set` options: `queue`, `priority`, `wait` and `wait_until`. Unknown keys raise an `ArgumentError`. `priority` only matters on backends that support it (Solid Queue, GoodJob, Delayed Job). Sidekiq ignores it and orders work by queue, so use a separate queue there instead. Make sure your workers process the queue you pick (for Sidekiq, `sidekiq -q embeddings -q default`).
|
|
97
|
+
|
|
98
|
+
* **Failures**: errors from the embedding API reach your job backend, which retries them (Sidekiq retries by default). Rate limits and timeouts are first retried inside the provider (`max_retries`).
|
|
99
|
+
* **Deleted records**: a job whose record was deleted before it ran is discarded, not retried.
|
|
100
|
+
|
|
101
|
+
## The Generator
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
rails generate omni_agent:embeddings Document title content
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
* Detects the store from your database adapter (override with `--backend=pgvector|sqlite|mariadb|ruby`).
|
|
108
|
+
* Detects the vector size by embedding one string with your configured model (override with `--dimensions=N`, e.g. when offline).
|
|
109
|
+
* Writes the migration: a `vector` column with a cosine HNSW index on Postgres (pgvector can't index above 2,000 dimensions, so larger vectors are left unindexed), a `binary` column plus `config/initializers/neighbor.rb` on SQLite, a `vector` column on MariaDB.
|
|
110
|
+
* Adds `has_embedding :title, :content` to the model.
|
|
111
|
+
|
|
112
|
+
Other options: `--column=NAME` and `--later`.
|
|
113
|
+
|
|
114
|
+
## searches
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
class SupportAgent < OmniAgent::Agent
|
|
118
|
+
searches Document
|
|
119
|
+
searches Article, as: :search_help_center, limit: 3
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Each call adds a read-only tool named `Search<Models>`. Its result lists each match as `[Document#12] <text>`, so the model can cite ids. The text is the record's `embedding_text`; for other models it's their string attributes. Define `embedding_text` on a model to control what the model sees.
|
|
124
|
+
|
|
125
|
+
Options:
|
|
126
|
+
|
|
127
|
+
* **`scope:`**: A lambda run on the model with the run's [context](agent/run-entrypoints.md). Use it for tenant isolation. Without it, the tool searches every record.
|
|
128
|
+
* **`limit:`**: Results per search. Defaults to `5`.
|
|
129
|
+
* **`as:`** / **`description:`**: Rename the tool or replace its description.
|
|
130
|
+
* **`embedder:`**: The Meilisearch embedder to search with. Defaults to `"default"`.
|
|
131
|
+
|
|
132
|
+
> **Warning**
|
|
133
|
+
>
|
|
134
|
+
> Always pass `scope:` in a multi-tenant app. Context comes from your code (`Agent.with(account_id: ...)`), never from the model, so the model can't widen it.
|
|
135
|
+
|
|
136
|
+
Because these are normal tools, they also work over [MCP](index.md#mcp-servers) and with [delegation](agent/delegation.md). Combine them with [structured output](agent/structured-output.md) to get citations back as data:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
output do
|
|
140
|
+
string :answer
|
|
141
|
+
array :source_ids, items_type: "integer"
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Documents
|
|
146
|
+
|
|
147
|
+
To answer from files (PDFs, Markdown, HTML) or long text, split them into chunks and search the chunks:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
rails generate omni_agent:chunks Document file
|
|
151
|
+
bin/rails active_storage:install # if you haven't
|
|
152
|
+
bin/rails db:migrate
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
class Document < ApplicationRecord
|
|
157
|
+
has_one_attached :file
|
|
158
|
+
has_chunks :file
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
class SupportAgent < OmniAgent::Agent
|
|
162
|
+
searches Document, scope: ->(context) { where(account_id: context[:account_id]) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
Document.create!(account_id: 1, file: params[:file]) # read, split and embedded in a background job
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
When the source changes, `OmniAgent::IngestJob` reads it into text, splits it, embeds the chunks in batches and replaces the document's chunks. They're stored in a `DocumentChunk` model that `belongs_to :document` and uses `has_embedding :content`, so every [store](#supported-stores) `has_embedding` supports works.
|
|
169
|
+
|
|
170
|
+
The source can be:
|
|
171
|
+
|
|
172
|
+
* An ActiveStorage attachment (`has_one_attached`)
|
|
173
|
+
* A text column, such as Markdown kept in the database
|
|
174
|
+
* Rich text (`has_rich_text`), read as plain text
|
|
175
|
+
* Any method that returns a String or an IO
|
|
176
|
+
|
|
177
|
+
### Loaders
|
|
178
|
+
|
|
179
|
+
A loader turns a document into text. By default (`as: :auto`), the format is detected from the file's content type, then its extension, then its first bytes:
|
|
180
|
+
|
|
181
|
+
| Loader | Reads | Needs |
|
|
182
|
+
| --- | --- | --- |
|
|
183
|
+
| `:pdf` | `.pdf` files with a text layer | `gem "pdf-reader"` |
|
|
184
|
+
| `:html` | `.html`, `.htm`: the visible text, without scripts or styles | — |
|
|
185
|
+
| `:markdown` | `.md`, `.markdown`, `.mdx`, as they are | — |
|
|
186
|
+
| `:text` | `.txt`, `.csv`, any `text/*` type, and any file that doesn't look binary | — |
|
|
187
|
+
|
|
188
|
+
Files no loader can read (a zip, an image) are skipped with a warning in the log, and never fail the save. To accept only one format, enforce its loader. Other files are then rejected by validation when the record is saved:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
has_chunks :file, as: :pdf # "File must be a PDF file"
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
For other formats, pass any object that responds to `call(io, filename:, content_type:)` and returns a String:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
has_chunks :file, as: ->(io, **) { Docx::Document.open(io).text }
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
To have auto-detection use it too, register it in the initializer with a `handles?` method:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
class DocxLoader
|
|
204
|
+
def self.handles?(filename:, content_type:)
|
|
205
|
+
File.extname(filename.to_s) == ".docx"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def self.call(io, **)
|
|
209
|
+
Docx::Document.open(io).text
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
OmniAgent.configure do |config|
|
|
214
|
+
config.document_loaders = { docx: DocxLoader } # tried before the built-in loaders
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
> **Note**
|
|
219
|
+
>
|
|
220
|
+
> Scanned PDFs are images, with no text to read, so they produce no chunks. Run them through OCR in a custom loader, e.g. one that calls the [OCR stock agent](stock-agents/ocr.md).
|
|
221
|
+
|
|
222
|
+
### Chunkers
|
|
223
|
+
|
|
224
|
+
| Chunker | Splits |
|
|
225
|
+
| --- | --- |
|
|
226
|
+
| `:recursive` (default) | On the coarsest boundary that fits: headings, then paragraphs, lines, sentences, words. Each chunk starts with the end of the previous one (`overlap`), so a sentence cut at a boundary is still found. |
|
|
227
|
+
| `:markdown` | One chunk per Markdown section, tagged with its heading path (`Billing › Refunds`). Long sections are split further. |
|
|
228
|
+
| `:hierarchical` | Small chunks for precise matching, each carrying its whole section. The search matches the small chunk, and the agent reads the section around it. |
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
has_chunks :file, chunker: :hierarchical, size: 500, overlap: 100
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`size` and `overlap` are in characters (defaults: `1000` and `200`, about 250 and 50 tokens). For `:hierarchical`, `size` is the small chunk and sections go up to four times that.
|
|
235
|
+
|
|
236
|
+
A custom chunker is any object that responds to `call(text, size:, overlap:)` and returns Strings, or Hashes with `content:` and optional `context:` (what the agent reads instead of `content`) and `metadata:`:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
has_chunks :body, chunker: ->(text, **) { text.split("\n---\n") }
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Options
|
|
243
|
+
|
|
244
|
+
* **`as:`**: The [loader](#loaders). Defaults to `:auto`.
|
|
245
|
+
* **`chunker:`**: The [chunker](#chunkers). Defaults to `:recursive`.
|
|
246
|
+
* **`size:`** / **`overlap:`**: Chunk size and overlap, in characters.
|
|
247
|
+
* **`later:`**: Ingests in `OmniAgent::IngestJob` by default, with the [`embedding_job_options`](#background-embedding). A Hash overrides them for this model (`later: { queue: :documents }`), and `false` ingests right after commit, in the request.
|
|
248
|
+
* **`class_name:`**: The chunk model. Defaults to `"#{model}Chunk"`.
|
|
249
|
+
|
|
250
|
+
### Updating and Searching
|
|
251
|
+
|
|
252
|
+
* Changing the source replaces the chunks. Chunks whose text didn't change keep their vector, so editing a page of a long document only embeds that page.
|
|
253
|
+
* Deleting the document deletes its chunks.
|
|
254
|
+
* `bin/rails "omni_agent:chunks:ingest[Document]"` ingests documents that have no chunks yet, for example ones created before you added `has_chunks`. Add `,force` to re-ingest every document, for example after changing the chunker. Files no loader can read are listed and skipped.
|
|
255
|
+
* `Document.where(account_id: 1).semantic_search("refunds")` returns the closest `DocumentChunk` records of those documents.
|
|
256
|
+
* `searches Document` searches the chunks of the documents in `scope:`, and lists each match as `[Document#12 › Billing › Refunds] <text>`.
|
|
257
|
+
|
|
258
|
+
## Meilisearch
|
|
259
|
+
|
|
260
|
+
meilisearch-rails models work with `searches` as they are:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
class Article < ApplicationRecord
|
|
264
|
+
include Meilisearch::Rails
|
|
265
|
+
|
|
266
|
+
meilisearch do
|
|
267
|
+
attribute :title, :body, :account_id
|
|
268
|
+
filterable_attributes [ :account_id ]
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
class SupportAgent < OmniAgent::Agent
|
|
273
|
+
searches Article, scope: ->(context) { where(account_id: context[:account_id]) }
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
The tool runs a hybrid (keyword + semantic) search with the index's `default` embedder (pick another with `embedder:`). Meilisearch embeds documents and queries itself, so configure an embedder on the index. meilisearch-rails has no setting for it, so do it once, for example from a console or a migration:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
Article.index.update_embedders(
|
|
281
|
+
default: { source: "openAi", apiKey: ENV["OPENAI_ACCESS_TOKEN"], model: "text-embedding-3-small", documentTemplate: "{{doc.title}} {{doc.body}}" }
|
|
282
|
+
)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Equality conditions in `scope:` (`where(account_id: 1)`) are sent to Meilisearch as filters, so those attributes must be both indexed (listed in `attribute`) and `filterable_attributes`. If they're missing, the search matches nothing and the tool returns "No results.". Every hit is then checked against the full scope in your database, so a condition Meilisearch can't express is still enforced.
|
|
286
|
+
|
|
287
|
+
## Any Other Store
|
|
288
|
+
|
|
289
|
+
`searches` works with anything that responds to `semantic_search(query, limit:)` and returns records (or strings):
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
class HelpCenter
|
|
293
|
+
def self.semantic_search(query, limit:)
|
|
294
|
+
QDRANT.search( # your vector store clientvector: OmniAgent.embed(query), limit: limit).map { |hit| hit["payload"]["text"] }
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
searches HelpCenter
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Always Retrieve First
|
|
302
|
+
|
|
303
|
+
To retrieve before every generation instead of letting the model decide, use a [`before_generation` callback](agent/callbacks.md). Instance variables it sets are available in the prompt:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
class SupportAgent < OmniAgent::Agent
|
|
307
|
+
before_generation :retrieve
|
|
308
|
+
|
|
309
|
+
def retrieve(payload)
|
|
310
|
+
@passages = Document.semantic_search(payload[:input], limit: 5)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
```erb
|
|
316
|
+
<%# app/agents/support_agent/prompt.md.erb %>
|
|
317
|
+
Answer using only the sources below and cite their ids.
|
|
318
|
+
|
|
319
|
+
<% @passages.each do |passage| %>
|
|
320
|
+
[<%= passage.id %>] <%= passage.embedding_text %>
|
|
321
|
+
<% end %>
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
This saves a round trip but always pays for retrieval, even for questions that don't need it. `searches` is usually the better default.
|
|
325
|
+
|
|
326
|
+
## Embeddings
|
|
327
|
+
|
|
328
|
+
`OmniAgent.embed` turns text into vectors with the configured provider. `has_embedding` uses it, and you can call it directly:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
OmniAgent.embed("How do refunds work?") # => [0.0123, -0.0456, ...]
|
|
332
|
+
OmniAgent.embed([ "first chunk", "second chunk" ]) # => [[...], [...]]
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
| Provider | Default embedding model |
|
|
336
|
+
| --- | --- |
|
|
337
|
+
| `:openai` | `text-embedding-3-small` |
|
|
338
|
+
| `:ollama` | `nomic-embed-text` (or `OLLAMA_EMBEDDING_MODEL`) |
|
|
339
|
+
| `:mock` | Deterministic 8-dimension vectors, no network calls |
|
|
340
|
+
|
|
341
|
+
Embedding settings are separate from the chat model:
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
OmniAgent.configure do |config|
|
|
345
|
+
config.embedding_provider = :openai # defaults to default_provider
|
|
346
|
+
config.embedding_model = "text-embedding-3-large" # defaults to the provider's model
|
|
347
|
+
end
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Override per call with `OmniAgent.embed(text, provider: :ollama, model: "mxbai-embed-large")`.
|
|
351
|
+
|
|
352
|
+
> **Warning**
|
|
353
|
+
>
|
|
354
|
+
> Index and query with the same provider and model. Vectors from different models are not comparable, and search returns garbage without raising. After changing models, update the column's dimensions in a migration and run `bin/rails "omni_agent:embeddings:backfill[Document,force]"`.
|
|
355
|
+
|
|
356
|
+
## Ruby Fallback (Small Tables Only)
|
|
357
|
+
|
|
358
|
+
> **Danger**
|
|
359
|
+
>
|
|
360
|
+
> **Avoid this in production.** The Ruby fallback loads **every row of the table** and computes similarity in Ruby on **each search**. It gets slower and uses more memory with every record you add, and it has no index. Use it only for prototypes or tables under ~10k rows, when your database can't search vectors (plain MySQL). Move to Postgres, SQLite or MariaDB with neighbor as soon as you can.
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
rails generate omni_agent:embeddings Document title content --backend=ruby
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
class Document < ApplicationRecord
|
|
368
|
+
has_embedding :title, :content, using: :ruby # stored in a json column
|
|
369
|
+
end
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Everything else (`semantic_search`, `searches`, `embed_all`, the backfill task) works the same, so switching to neighbor later only means changing the column and removing `using: :ruby`.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<!-- Generated from omniagent-docs/docs/stock-agents/ocr.mdx by `bundle exec rake docs:sync`. Do not edit by hand. -->
|
|
2
|
+
<!-- Online version: https://omniagent.activetaskers.com/docs/stock-agents/ocr -->
|
|
3
|
+
|
|
4
|
+
# OCR
|
|
5
|
+
|
|
6
|
+
`OmniAgent::StockAgents::OCR` reads photos, scans and PDFs with a vision model (`gpt-4o` by default). Its default output is `{ text: "..." }`, the full text in reading order; declare your own `output` to extract fields instead.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
rails generate omni_agent:stock_agent InvoiceOcr --from=ocr --fields=invoice_number:string total:number paid:boolean
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
# app/agents/invoice_ocr.rb
|
|
14
|
+
class InvoiceOcr < OmniAgent::StockAgents::OCR
|
|
15
|
+
output do
|
|
16
|
+
string :invoice_number
|
|
17
|
+
number :total
|
|
18
|
+
boolean :paid
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
InvoiceOcr.new.run(Pathname("invoice.jpg")).structured_output
|
|
23
|
+
# => { invoice_number: "A-1042", total: 120.5, paid: true }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Input
|
|
27
|
+
|
|
28
|
+
`run` takes one source or an array of them. Several sources are treated as pages of the same document, in order:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
InvoiceOcr.new.run(Pathname("invoice.pdf"))
|
|
32
|
+
InvoiceOcr.new.run([ params[:front], params[:back] ]) # uploaded files
|
|
33
|
+
InvoiceOcr.new.run(record.scans.to_a) # ActiveStorage attachments
|
|
34
|
+
InvoiceOcr.new.run([ "Only the line items", Pathname("page1.png"), URI("https://example.com/page2.png") ])
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Source | Sent as |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `Pathname`, `File`, `StringIO`, uploaded files (anything with `read`) | Base64 data URL |
|
|
40
|
+
| ActiveStorage attachments and blobs (anything with `download`) | Base64 data URL |
|
|
41
|
+
| `URI("https://...")` | Image URL the provider fetches |
|
|
42
|
+
| `String` | Text instructions sent with the images |
|
|
43
|
+
|
|
44
|
+
Files must be PNG, JPEG, GIF, WEBP or PDF; the type is read from the file's bytes, not its name. A plain string is never treated as a file path, so text from your users can't make the agent read files from your server. Pass a `Pathname` to read a file.
|
|
45
|
+
|
|
46
|
+
## Providers
|
|
47
|
+
|
|
48
|
+
PDFs are sent as file parts, which OpenAI supports. Ollama only accepts base64 images, so with Ollama use a vision model (e.g. `llama3.2-vision`) and pass image files, not PDFs or URIs.
|
|
49
|
+
|
|
50
|
+
## Prompt
|
|
51
|
+
|
|
52
|
+
The stock prompt tells the model to transcribe only what is visible, keep numbers and dates exactly as written, and leave illegible fields empty instead of guessing. Add document-specific instructions in `app/agents/invoice_ocr/prompt.md.erb`; they are appended after it (see [Prompts](overview.md#prompts)).
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<!-- Generated from omniagent-docs/docs/stock-agents/overview.mdx by `bundle exec rake docs:sync`. Do not edit by hand. -->
|
|
2
|
+
<!-- Online version: https://omniagent.activetaskers.com/docs/stock-agents/overview -->
|
|
3
|
+
|
|
4
|
+
# Overview
|
|
5
|
+
|
|
6
|
+
Stock agents are ready-made agents shipped with the gem under `OmniAgent::StockAgents`. They bring their own prompt, model settings and tools; your project subclasses one and declares the [structured output](../agent/structured-output.md) it needs.
|
|
7
|
+
|
|
8
|
+
## Using a Stock Agent
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
rails generate omni_agent:stock_agent InvoiceOcr --from=ocr --fields=invoice_number:string total:number paid:boolean
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Generates:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# app/agents/invoice_ocr.rb
|
|
18
|
+
class InvoiceOcr < OmniAgent::StockAgents::OCR
|
|
19
|
+
output do
|
|
20
|
+
string :invoice_number
|
|
21
|
+
number :total
|
|
22
|
+
boolean :paid
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
and an optional `app/agents/invoice_ocr/prompt.md.erb`. Edit the `output` block with the full DSL (descriptions, `required: false`, enums, nested arrays and hashes), then run it like any agent:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
InvoiceOcr.new.run(Pathname("invoice.jpg")).structured_output
|
|
31
|
+
# => { invoice_number: "A-1042", total: 120.5, paid: true }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
What `run` accepts depends on the stock agent; see its page (e.g. [OCR](ocr.md)).
|
|
35
|
+
|
|
36
|
+
A subclass inherits everything from the stock agent — provider, model, `options`, tags, callbacks, tools and prompts — and can override any of it with the usual DSL (`use_model`, `options`, `output`, ...). To replace one of the stock agent's tools, define a tool with the same name under your subclass's `Tools` namespace (e.g. `InvoiceOcr::Tools::Extract`). See [Inheritance](../agent/provider.md#inheritance) for how `use_model` interacts with an inherited provider.
|
|
37
|
+
|
|
38
|
+
### Prompts
|
|
39
|
+
|
|
40
|
+
The stock agent's prompt (shipped inside the gem) is rendered first. If your subclass has `app/agents/<agent_name>/prompt.md.erb`, it is appended after it, so you can add project-specific instructions without copying the stock prompt. The same applies to [run alias](../agent/run-entrypoints.md) prompt files.
|
|
41
|
+
|
|
42
|
+
## Listing Stock Agents
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
OmniAgent::StockAgents.all # => [OmniAgent::StockAgents::OCR, ...]
|
|
46
|
+
OmniAgent::StockAgents.find(:ocr) # => OmniAgent::StockAgents::OCR
|
|
47
|
+
OmniAgent::StockAgents.find(:nope) # raises OmniAgent::UnknownStockAgentError
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Adding a Stock Agent to the Gem
|
|
51
|
+
|
|
52
|
+
From the gem repository:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
bin/rake "stock_agent:new[summarizer]"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Pass the class name as a second argument (`"stock_agent:new[ocr,OCR]"`) only when the class name isn't the camelized file name; the task then prints the Zeitwerk inflection to add in `lib/omni_agent.rb`. It creates:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
lib/omni_agent/stock_agents/
|
|
62
|
+
├── summarizer.rb # class OmniAgent::StockAgents::Summarizer < OmniAgent::StockAgent
|
|
63
|
+
└── summarizer/
|
|
64
|
+
├── prompt.md.erb # default prompt
|
|
65
|
+
└── tools/ # optional: OmniAgent::StockAgents::Summarizer::Tools::*
|
|
66
|
+
spec/lib/stock_agents/summarizer_spec.rb
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A stock agent is a regular agent with a few extra class methods:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
module OmniAgent
|
|
73
|
+
module StockAgents
|
|
74
|
+
class Summarizer < OmniAgent::StockAgent
|
|
75
|
+
description "Summarizes documents"
|
|
76
|
+
use_model "gpt-4o-mini"
|
|
77
|
+
|
|
78
|
+
# Default output, used when the host app doesn't declare its own.
|
|
79
|
+
output do
|
|
80
|
+
string :summary, description: "Short summary of the document"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
* **`description`**: Human-readable summary of what the stock agent does.
|
|
88
|
+
* **`stock_name`**: Name used by `OmniAgent::StockAgents.find` and the generator's `--from`. Defaults to the underscored class name (`Summarizer` → `:summarizer`).
|
|
89
|
+
* **`prompts_path`**: Directory with the stock prompt templates. Defaults to `lib/omni_agent/stock_agents/<stock_name>/`.
|
|
90
|
+
|
|
91
|
+
Give each new stock agent its own page in `omniagent-docs/docs/stock-agents/`.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!-- Generated from omniagent-docs/docs/supported-providers.mdx by `bundle exec rake docs:sync`. Do not edit by hand. -->
|
|
2
|
+
<!-- Online version: https://omniagent.activetaskers.com/docs/supported-providers -->
|
|
3
|
+
|
|
4
|
+
# Supported providers
|
|
5
|
+
|
|
6
|
+
This is a list of our currently supported providers.
|
|
7
|
+
|
|
8
|
+
- OpenAI
|
|
9
|
+
- Ollama
|
|
10
|
+
- Mock
|
|
11
|
+
|
|
12
|
+
If you need another integration you can open a PR for it!
|
|
13
|
+
|
|
14
|
+
## OpenAI
|
|
15
|
+
|
|
16
|
+
Requires the `openai` gem and an API key:
|
|
17
|
+
|
|
18
|
+
```dotenv
|
|
19
|
+
OPENAI_ACCESS_TOKEN=your_api_key_here
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
provider :openai, model: "gpt-4o-mini"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Ollama
|
|
27
|
+
|
|
28
|
+
Talks to a local (or remote) [Ollama](https://ollama.com) server via its OpenAI-compatible `/v1` endpoint. Depends on the `openai` gem too — no separate client library needed. Pick a tool-calling-capable model (e.g. `llama3.1`, `qwen2.5`) if your agent uses tools.
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
provider :ollama, model: "llama3.1"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Configuration is via environment variables, all optional:
|
|
35
|
+
|
|
36
|
+
| Variable | Default | Purpose |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `OLLAMA_HOST` | `http://localhost:11434` | Base URL of the Ollama server (the provider appends `/v1`) |
|
|
39
|
+
| `OLLAMA_MODEL` | `llama3.1` | Default model when none is passed to `provider`/`use_model` |
|
|
40
|
+
| `OLLAMA_EMBEDDING_MODEL` | `nomic-embed-text` | Default model for [`OmniAgent.embed`](rag.md#embeddings) |
|
|
41
|
+
| `OLLAMA_API_KEY` | `ollama` | Sent as the bearer token; Ollama ignores it, but the `openai` client requires a non-nil value |
|
|
42
|
+
|
|
43
|
+
Streaming, tool-calling and embeddings all work the same as the OpenAI provider (Ollama subclasses it and only overrides client construction and defaults).
|
|
44
|
+
|
|
45
|
+
## Mock
|
|
46
|
+
|
|
47
|
+
Returns a canned response without making network calls. Useful for tests. `embed` returns deterministic vectors derived from the text, so the same text always gets the same vector.
|