omni_agent 0.1.11 → 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 +43 -0
- data/README.md +96 -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/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/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 +12 -1
- data/lib/tasks/omni_agent_chunks_tasks.rake +16 -0
- data/lib/tasks/omni_agent_embeddings_tasks.rake +15 -0
- metadata +49 -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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
module Generators
|
|
3
|
+
# Picks the vector store and vector size for the embeddings and chunks
|
|
4
|
+
# generators, and writes what each store needs. Expects `backend` and
|
|
5
|
+
# `dimensions` class options.
|
|
6
|
+
module VectorStore
|
|
7
|
+
BACKENDS = %w[pgvector sqlite mariadb ruby].freeze
|
|
8
|
+
PGVECTOR_INDEX_MAX_DIMENSIONS = 2000
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def backend
|
|
13
|
+
@backend ||= options[:backend] || detect_backend
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def detect_backend
|
|
17
|
+
case ActiveRecord::Base.connection_db_config.adapter
|
|
18
|
+
when /postg/ then "pgvector"
|
|
19
|
+
when /sqlite/ then "sqlite"
|
|
20
|
+
when /mysql|trilogy/
|
|
21
|
+
return "mariadb" if mariadb?
|
|
22
|
+
|
|
23
|
+
raise Thor::Error, "MySQL can't search vectors without HeatWave. Use Postgres, SQLite or MariaDB 11.8+, " \
|
|
24
|
+
"or pass --backend=ruby, which loads every row on each search and only suits small tables."
|
|
25
|
+
else
|
|
26
|
+
raise Thor::Error, "Couldn't detect a vector backend for this database. Pass --backend=#{BACKENDS.join('|')}."
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def mariadb?
|
|
31
|
+
ActiveRecord::Base.with_connection(&:mariadb?)
|
|
32
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
33
|
+
raise Thor::Error, "Couldn't connect to the database to tell MySQL from MariaDB (#{e.message}). " \
|
|
34
|
+
"Create it first, or pass --backend=mariadb|ruby."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def dimensions
|
|
38
|
+
return if backend == "ruby"
|
|
39
|
+
|
|
40
|
+
@dimensions ||= options[:dimensions] || detect_dimensions
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def detect_dimensions
|
|
44
|
+
OmniAgent.embed("dimension check").length
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
raise Thor::Error, "Couldn't call the embedding model to detect its dimensions (#{e.message}). Pass --dimensions=N."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Migration lines that add a vector column (and its index) to a table.
|
|
50
|
+
def vector_column_lines(table, column)
|
|
51
|
+
case backend
|
|
52
|
+
when "pgvector"
|
|
53
|
+
lines = [ 'enable_extension "vector"', "add_column :#{table}, :#{column}, :vector, limit: #{dimensions}" ]
|
|
54
|
+
if dimensions <= PGVECTOR_INDEX_MAX_DIMENSIONS
|
|
55
|
+
lines << "add_index :#{table}, :#{column}, using: :hnsw, opclass: :vector_cosine_ops"
|
|
56
|
+
else
|
|
57
|
+
lines << "# pgvector can't index more than #{PGVECTOR_INDEX_MAX_DIMENSIONS} dimensions; searches scan the table."
|
|
58
|
+
end
|
|
59
|
+
when "sqlite"
|
|
60
|
+
[ "add_column :#{table}, :#{column}, :binary" ]
|
|
61
|
+
when "mariadb"
|
|
62
|
+
[
|
|
63
|
+
"add_column :#{table}, :#{column}, :vector, limit: #{dimensions}",
|
|
64
|
+
"# For a vector index, backfill first, then in a new migration:",
|
|
65
|
+
"# change_column_null :#{table}, :#{column}, false",
|
|
66
|
+
"# add_index :#{table}, :#{column}, type: :vector"
|
|
67
|
+
]
|
|
68
|
+
when "ruby"
|
|
69
|
+
[ "add_column :#{table}, :#{column}, :json" ]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def migration_file(name, body_lines)
|
|
74
|
+
# Like Rails' generators: after the newest migration, even one created this same second.
|
|
75
|
+
newest = Dir[File.join(destination_root, "db", "migrate", "*.rb")].map { |path| File.basename(path).to_i }.max.to_i
|
|
76
|
+
number = [ Time.now.utc.strftime("%Y%m%d%H%M%S").to_i, newest + 1 ].max
|
|
77
|
+
create_file File.join("db", "migrate", "#{number}_#{name.underscore}.rb"), <<~RUBY
|
|
78
|
+
class #{name} < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]
|
|
79
|
+
def change
|
|
80
|
+
#{body_lines.map { |line| line.empty? ? "" : " #{line}" }.join("\n")}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
RUBY
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def create_sqlite_initializer
|
|
87
|
+
return unless backend == "sqlite"
|
|
88
|
+
return if File.exist?(File.join(destination_root, "config", "initializers", "neighbor.rb"))
|
|
89
|
+
|
|
90
|
+
create_file File.join("config", "initializers", "neighbor.rb"), "# Load the sqlite-vec extension\nNeighbor::SQLite.initialize!\n"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# has_embedding arguments the store needs.
|
|
94
|
+
def vector_store_options
|
|
95
|
+
if backend == "sqlite" then [ "dimensions: #{dimensions}" ]
|
|
96
|
+
elsif backend == "ruby" then [ "using: :ruby" ]
|
|
97
|
+
else []
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def say_vector_store_setup
|
|
102
|
+
if backend == "ruby"
|
|
103
|
+
say "Warning: the Ruby backend loads every row on each search. Use it for small tables only " \
|
|
104
|
+
"(under ~10k rows), and move to pgvector, sqlite-vec or MariaDB as the table grows.", :yellow
|
|
105
|
+
say ""
|
|
106
|
+
else
|
|
107
|
+
say "Add the gems if you haven't:"
|
|
108
|
+
say " bundle add neighbor#{backend == 'sqlite' ? ' sqlite-vec' : ''}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/omni_agent/agent.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
1
3
|
module OmniAgent
|
|
2
4
|
class Agent
|
|
5
|
+
DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant with access to local tools.".freeze
|
|
6
|
+
|
|
3
7
|
attr_reader :provider
|
|
4
8
|
|
|
5
9
|
module ImplicitRunEntrypoints
|
|
@@ -39,7 +43,7 @@ module OmniAgent
|
|
|
39
43
|
|
|
40
44
|
class << self
|
|
41
45
|
def provider(name, **options)
|
|
42
|
-
if configured_with_use_model
|
|
46
|
+
if @configured_with_use_model == true
|
|
43
47
|
raise OmniAgent::Error, "Cannot combine `provider` and `use_model` in the same agent. Use either `provider ..., model: ...` or `use_model ...`."
|
|
44
48
|
end
|
|
45
49
|
|
|
@@ -52,7 +56,7 @@ module OmniAgent
|
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
def use_model(name)
|
|
55
|
-
if
|
|
59
|
+
if @provider_name || (@provider_options || {}).any?
|
|
56
60
|
raise OmniAgent::Error, "Cannot combine `provider` and `use_model` in the same agent. Use either `provider ..., model: ...` or `use_model ...`."
|
|
57
61
|
end
|
|
58
62
|
|
|
@@ -69,11 +73,22 @@ module OmniAgent
|
|
|
69
73
|
end
|
|
70
74
|
|
|
71
75
|
def tags(*tag_names)
|
|
72
|
-
return @configured_tags || [] if tag_names.empty?
|
|
76
|
+
return @configured_tags || inherited_setting(:tags) || [] if tag_names.empty?
|
|
73
77
|
|
|
74
78
|
@configured_tags = (tags + normalize_tags(tag_names)).uniq
|
|
75
79
|
end
|
|
76
80
|
|
|
81
|
+
# Declares the structured output the agent must produce, using the same
|
|
82
|
+
# DSL as Tool.input. The final response is parsed and validated against
|
|
83
|
+
# it and exposed as response.structured_output.
|
|
84
|
+
def output(&block)
|
|
85
|
+
@output_schema = OmniAgent::Schema.build(&block) if block_given?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def output_schema
|
|
89
|
+
@output_schema || inherited_setting(:output_schema)
|
|
90
|
+
end
|
|
91
|
+
|
|
77
92
|
def run_aliases(*method_names)
|
|
78
93
|
aliases = normalize_callbacks(:run_aliases, method_names)
|
|
79
94
|
|
|
@@ -90,13 +105,45 @@ module OmniAgent
|
|
|
90
105
|
end
|
|
91
106
|
|
|
92
107
|
tool_class = build_delegated_tool_class(agent_class, description: description, run_alias: run_alias, forward: forward)
|
|
93
|
-
|
|
108
|
+
add_built_tool(as, tool_class)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Gives the agent a Search<Models> tool backed by `target`: a model with
|
|
112
|
+
# `has_embedding`, a meilisearch-rails model, or anything responding to
|
|
113
|
+
# `semantic_search(query, limit:)`. `scope` runs on the target with the
|
|
114
|
+
# run context, e.g. ->(context) { where(account_id: context[:account_id]) }.
|
|
115
|
+
def searches(target, as: nil, description: nil, scope: nil, limit: 5, embedder: "default")
|
|
116
|
+
unless OmniAgent::SemanticSearch.searchable?(target)
|
|
117
|
+
raise ArgumentError,
|
|
118
|
+
"#{target.name || target.inspect} can't be searched: add `has_embedding` to it, " \
|
|
119
|
+
"include Meilisearch::Rails, or define `self.semantic_search(query, limit:)`."
|
|
120
|
+
end
|
|
94
121
|
|
|
95
|
-
|
|
122
|
+
plural = target.name.to_s.split("::").last.underscore.pluralize
|
|
123
|
+
tool_description = description || "Search #{plural.humanize(capitalize: false)} by meaning. Returns the closest matches with their ids."
|
|
124
|
+
|
|
125
|
+
tool_class = Class.new(OmniAgent::Tool) do
|
|
126
|
+
description tool_description
|
|
127
|
+
annotations read_only: true
|
|
128
|
+
|
|
129
|
+
input do
|
|
130
|
+
string :query, description: "What to look for, in natural language."
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
define_method(:execute) do |query:|
|
|
134
|
+
searched = scope ? target.instance_exec(context, &scope) : target
|
|
135
|
+
results = OmniAgent::SemanticSearch.call(searched, query, limit: limit, embedder: embedder)
|
|
136
|
+
next "No results." if results.empty?
|
|
137
|
+
|
|
138
|
+
results.map { |result| OmniAgent::Agent.__send__(:format_search_result, result, target) }.join("\n\n---\n\n")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
add_built_tool(as || "search_#{plural}", tool_class)
|
|
96
143
|
end
|
|
97
144
|
|
|
98
145
|
def configured_delegated_tool_classes
|
|
99
|
-
@delegated_tool_classes || []
|
|
146
|
+
@delegated_tool_classes || inherited_setting(:configured_delegated_tool_classes) || []
|
|
100
147
|
end
|
|
101
148
|
|
|
102
149
|
def tool_classes
|
|
@@ -111,7 +158,10 @@ module OmniAgent
|
|
|
111
158
|
[]
|
|
112
159
|
end
|
|
113
160
|
|
|
114
|
-
|
|
161
|
+
# The model calls tools by simple name, so a subclass tool or a later
|
|
162
|
+
# delegate_to with the same name replaces the inherited one.
|
|
163
|
+
delegated_tools = configured_delegated_tool_classes.reverse.uniq { |tool| tool_call_name(tool) }.reverse
|
|
164
|
+
(namespace_tools + delegated_tools + (inherited_setting(:tool_classes) || [])).uniq { |tool| tool_call_name(tool) }
|
|
115
165
|
end
|
|
116
166
|
|
|
117
167
|
def build_delegated_tool_class(agent_class, description:, run_alias:, forward:)
|
|
@@ -144,12 +194,22 @@ module OmniAgent
|
|
|
144
194
|
)
|
|
145
195
|
end
|
|
146
196
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def
|
|
152
|
-
|
|
197
|
+
# Class-level settings fall back to the parent agent, so subclasses
|
|
198
|
+
# (e.g. of a stock agent) keep its provider, options, callbacks and tools.
|
|
199
|
+
# A class's own `use_model` means "default provider + this model", so it
|
|
200
|
+
# does not inherit the parent's provider name.
|
|
201
|
+
def configured_provider_name
|
|
202
|
+
return @provider_name if @provider_name
|
|
203
|
+
return nil if @configured_with_use_model == true
|
|
204
|
+
|
|
205
|
+
inherited_setting(:configured_provider_name)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def configured_provider_options; @provider_options || inherited_setting(:configured_provider_options) || {}; end
|
|
209
|
+
def configured_model_options; @model_options || inherited_setting(:configured_model_options) || {}; end
|
|
210
|
+
def configured_with_use_model?; @configured_with_use_model == true || inherited_setting(:configured_with_use_model?) == true; end
|
|
211
|
+
def configured_before_generation_callbacks; @before_generation_callbacks || inherited_setting(:configured_before_generation_callbacks) || []; end
|
|
212
|
+
def configured_after_generation_callbacks; @after_generation_callbacks || inherited_setting(:configured_after_generation_callbacks) || []; end
|
|
153
213
|
def configured_tags; tags; end
|
|
154
214
|
|
|
155
215
|
def inherited(subclass)
|
|
@@ -159,6 +219,34 @@ module OmniAgent
|
|
|
159
219
|
|
|
160
220
|
private
|
|
161
221
|
|
|
222
|
+
def inherited_setting(reader)
|
|
223
|
+
return nil unless superclass < OmniAgent::Agent
|
|
224
|
+
|
|
225
|
+
superclass.public_send(reader)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def tool_call_name(tool)
|
|
229
|
+
tool.name ? tool.name.split("::").last : tool
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def add_built_tool(as, tool_class)
|
|
233
|
+
delegated_tools_module.const_set(delegated_tool_const_name(as), tool_class)
|
|
234
|
+
@delegated_tool_classes = configured_delegated_tool_classes + [ tool_class ]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def format_search_result(result, target)
|
|
238
|
+
return target.format_search_chunk(result) if target.respond_to?(:format_search_chunk)
|
|
239
|
+
return result.to_s unless result.respond_to?(:attributes)
|
|
240
|
+
|
|
241
|
+
text = if result.respond_to?(:embedding_text)
|
|
242
|
+
result.embedding_text
|
|
243
|
+
else
|
|
244
|
+
result.attributes.select { |_, value| value.is_a?(String) }.map { |key, value| "#{key}: #{value}" }.join("\n")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
"[#{result.class.name}##{result.id}] #{text}"
|
|
248
|
+
end
|
|
249
|
+
|
|
162
250
|
def delegated_tools_module
|
|
163
251
|
@delegated_tools_module ||= const_set(:DelegatedTools, Module.new)
|
|
164
252
|
end
|
|
@@ -241,7 +329,16 @@ module OmniAgent
|
|
|
241
329
|
sync_context_from_instance_variables(context)
|
|
242
330
|
|
|
243
331
|
messages.replace(build_messages(input: input, history: context[:history]))
|
|
332
|
+
output_schema = self.class.output_schema
|
|
333
|
+
native_output = output_schema && provider_supports_structured_output?
|
|
334
|
+
chat_options = native_output ? @chat_options.merge(output_schema: output_schema) : @chat_options
|
|
335
|
+
|
|
244
336
|
messages[0][:content] = system_prompt(context: context, prompt_method: prompt_method)
|
|
337
|
+
# Providers without native structured output (and non-strict schemas,
|
|
338
|
+
# which the model only treats as guidance) also get the schema in the prompt.
|
|
339
|
+
if output_schema && !(native_output && output_schema.strict_compatible?)
|
|
340
|
+
messages[0][:content] = "#{messages[0][:content]}\n\n#{structured_output_instructions(output_schema)}"
|
|
341
|
+
end
|
|
245
342
|
initial_messages_count = messages.length - 1
|
|
246
343
|
|
|
247
344
|
filtered_tools = tool_filter(tools: available_tools, agent_tags: self.class.tags)
|
|
@@ -256,10 +353,11 @@ module OmniAgent
|
|
|
256
353
|
"Increase OmniAgent.configuration.max_tool_iterations if more tool calls are expected."
|
|
257
354
|
end
|
|
258
355
|
|
|
259
|
-
response = provider.chat(messages: messages, tools: filtered_tools, stream: stream,
|
|
356
|
+
response = provider.chat(messages: messages, tools: filtered_tools, stream: stream, **chat_options)
|
|
260
357
|
|
|
261
358
|
if response.content && !response.tool_calls?
|
|
262
359
|
messages << { role: "assistant", content: response.content }
|
|
360
|
+
response.with_structured_output(parse_structured_output(response, output_schema)) if output_schema
|
|
263
361
|
set_after_generation_state(response: response, messages: messages, initial_messages_count: initial_messages_count)
|
|
264
362
|
run_after_generation_callbacks(input: input, context: context, messages: messages, response: response)
|
|
265
363
|
sync_context_from_instance_variables(context)
|
|
@@ -340,6 +438,41 @@ module OmniAgent
|
|
|
340
438
|
tools
|
|
341
439
|
end
|
|
342
440
|
|
|
441
|
+
def provider_supports_structured_output?
|
|
442
|
+
provider.respond_to?(:supports_structured_output?) && provider.supports_structured_output?
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def structured_output_instructions(schema)
|
|
446
|
+
"Respond only with a JSON object that matches this JSON schema, without any other text:\n" \
|
|
447
|
+
"#{JSON.pretty_generate(schema.json_schema)}"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def parse_structured_output(response, schema)
|
|
451
|
+
payload = JSON.parse(strip_json_code_fence(response.content.to_s), symbolize_names: true)
|
|
452
|
+
schema.parse_typed(payload)
|
|
453
|
+
rescue JSON::ParserError => e
|
|
454
|
+
raise_structured_output_error("returned invalid JSON for its output schema: #{e.message}", response)
|
|
455
|
+
rescue ArgumentError => e
|
|
456
|
+
raise_structured_output_error("output does not match its output schema: #{e.message}", response)
|
|
457
|
+
rescue StandardError => e
|
|
458
|
+
# e.g. a polymorphic `resolve: true` field whose record can't be found
|
|
459
|
+
raise_structured_output_error("output could not be resolved: #{e.class}: #{e.message}", response)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def raise_structured_output_error(reason, response)
|
|
463
|
+
raise OmniAgent::StructuredOutputError.new(
|
|
464
|
+
"#{self.class.name || 'Agent'} #{reason}",
|
|
465
|
+
content: response.content,
|
|
466
|
+
response: response
|
|
467
|
+
)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Models without native structured output sometimes wrap JSON in a fence.
|
|
471
|
+
def strip_json_code_fence(content)
|
|
472
|
+
match = content.strip.match(/\A```(?:json)?\s*(.*?)\s*```\z/m)
|
|
473
|
+
match ? match[1] : content
|
|
474
|
+
end
|
|
475
|
+
|
|
343
476
|
def build_messages(input:, history:)
|
|
344
477
|
normalized_history = normalize_history_messages(history)
|
|
345
478
|
|
|
@@ -406,15 +539,7 @@ module OmniAgent
|
|
|
406
539
|
end
|
|
407
540
|
|
|
408
541
|
def resolve_provider(name, model)
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
unless provider_class
|
|
412
|
-
known = OmniAgent::Providers.registry.keys.join(", ")
|
|
413
|
-
raise OmniAgent::UnknownProviderError,
|
|
414
|
-
"Unknown provider #{name.inspect}. Known providers: #{known}"
|
|
415
|
-
end
|
|
416
|
-
|
|
417
|
-
provider_class.new(model: model)
|
|
542
|
+
OmniAgent::Providers.fetch(name).new(model: model)
|
|
418
543
|
end
|
|
419
544
|
|
|
420
545
|
def run_before_generation_callbacks(input:, context:, messages:)
|
|
@@ -495,17 +620,8 @@ module OmniAgent
|
|
|
495
620
|
end
|
|
496
621
|
|
|
497
622
|
def system_prompt(context:, prompt_method: nil)
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
class_name = self.class.name
|
|
501
|
-
return "You are a helpful assistant with access to local tools." if class_name.nil?
|
|
502
|
-
|
|
503
|
-
agent_dir = inflector_underscore(class_name)
|
|
504
|
-
base_file_path = Rails.root.join("app", "agents", agent_dir, "prompt.md.erb")
|
|
505
|
-
method_file_path = if prompt_method
|
|
506
|
-
prompt_method_name = inflector_underscore(prompt_method.to_s)
|
|
507
|
-
Rails.root.join("app", "agents", agent_dir, "#{prompt_method_name}.md.erb")
|
|
508
|
-
end
|
|
623
|
+
template_paths = prompt_template_paths(prompt_method)
|
|
624
|
+
return DEFAULT_SYSTEM_PROMPT if template_paths.empty?
|
|
509
625
|
|
|
510
626
|
isolated_scope = Object.new
|
|
511
627
|
|
|
@@ -519,13 +635,26 @@ module OmniAgent
|
|
|
519
635
|
isolated_scope.instance_variable_set(ivar, instance_variable_get(ivar))
|
|
520
636
|
end
|
|
521
637
|
|
|
522
|
-
|
|
523
|
-
method_prompt = render_prompt_template(method_file_path, isolated_scope)
|
|
524
|
-
|
|
525
|
-
prompts = [ base_prompt, method_prompt ].compact.reject(&:empty?)
|
|
638
|
+
prompts = template_paths.map { |path| render_prompt_template(path, isolated_scope) }.compact.reject(&:empty?)
|
|
526
639
|
return prompts.join("\n\n") if prompts.any?
|
|
527
640
|
|
|
528
|
-
|
|
641
|
+
DEFAULT_SYSTEM_PROMPT
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Ordered list of prompt templates to render; missing files are skipped.
|
|
645
|
+
def prompt_template_paths(prompt_method)
|
|
646
|
+
return [] unless defined?(Rails)
|
|
647
|
+
|
|
648
|
+
class_name = self.class.name
|
|
649
|
+
return [] if class_name.nil?
|
|
650
|
+
|
|
651
|
+
prompt_templates_in(Rails.root.join("app", "agents", inflector_underscore(class_name)), prompt_method)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def prompt_templates_in(directory, prompt_method)
|
|
655
|
+
paths = [ File.join(directory.to_s, "prompt.md.erb") ]
|
|
656
|
+
paths << File.join(directory.to_s, "#{inflector_underscore(prompt_method.to_s)}.md.erb") if prompt_method
|
|
657
|
+
paths
|
|
529
658
|
end
|
|
530
659
|
|
|
531
660
|
def render_prompt_template(file_path, isolated_scope)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
# ActiveRecord macro that turns a document into searchable chunks. Extended
|
|
3
|
+
# into every model by the engine.
|
|
4
|
+
#
|
|
5
|
+
# class Document < ApplicationRecord
|
|
6
|
+
# has_one_attached :file
|
|
7
|
+
# has_chunks :file
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# Document.where(account_id: 1).semantic_search("refund policy") # => DocumentChunk records
|
|
11
|
+
#
|
|
12
|
+
# The source is an ActiveStorage attachment, a text column, rich text or
|
|
13
|
+
# any method returning a String or IO. When it changes, OmniAgent::IngestJob
|
|
14
|
+
# loads it into text (Loaders), splits it (Chunkers), embeds the chunks and
|
|
15
|
+
# replaces the record's chunks. The chunk model (DocumentChunk by default)
|
|
16
|
+
# belongs to the document and uses `has_embedding :content`.
|
|
17
|
+
module Chunkable
|
|
18
|
+
EMBED_BATCH_SIZE = 100
|
|
19
|
+
|
|
20
|
+
def has_chunks(source, as: :auto, chunker: :recursive, size: 1000, overlap: 200, class_name: "#{name}Chunk", later: true)
|
|
21
|
+
raise ArgumentError, "has_chunks size: must be a positive Integer" unless size.is_a?(Integer) && size.positive?
|
|
22
|
+
raise ArgumentError, "has_chunks overlap: must be between 0 and size:" unless overlap.is_a?(Integer) && overlap >= 0 && overlap < size
|
|
23
|
+
raise ArgumentError, "has_chunks later: must be true, false or a Hash of job options" unless [ true, false ].include?(later) || later.is_a?(Hash)
|
|
24
|
+
raise ArgumentError, "#{name} already uses has_embedding. A model uses has_embedding or has_chunks, not both." if respond_to?(:embedding_options)
|
|
25
|
+
|
|
26
|
+
Chunkers.fetch(chunker)
|
|
27
|
+
Loaders.fetch(as) unless as.to_s == "auto"
|
|
28
|
+
|
|
29
|
+
class_attribute :chunk_options, instance_writer: false
|
|
30
|
+
self.chunk_options = {
|
|
31
|
+
source: source.to_sym,
|
|
32
|
+
loader: as,
|
|
33
|
+
chunker: chunker,
|
|
34
|
+
size: size,
|
|
35
|
+
overlap: overlap,
|
|
36
|
+
later: later != false,
|
|
37
|
+
job_options: later.is_a?(Hash) ? Embeddable.validate_job_options!(later, "later:") : {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
has_many :chunks, -> { order(:position) }, class_name: class_name, dependent: :delete_all
|
|
41
|
+
|
|
42
|
+
extend ClassMethods
|
|
43
|
+
include InstanceMethods
|
|
44
|
+
|
|
45
|
+
validate :validate_chunk_source_type, unless: -> { as.to_s == "auto" }
|
|
46
|
+
before_save :remember_chunk_source_change
|
|
47
|
+
after_commit :ingest_changed_chunks, on: %i[create update], if: -> { @chunk_source_changed }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
module ClassMethods
|
|
51
|
+
# The closest chunks of the records in the current scope. Chunks that
|
|
52
|
+
# share a parent section (hierarchical) count once.
|
|
53
|
+
def semantic_search(query, limit: 5)
|
|
54
|
+
reflection = reflect_on_association(:chunks)
|
|
55
|
+
# ponytail: fetches 3x the limit so merged siblings still fill it; raise if hierarchical results come up short.
|
|
56
|
+
hits = reflection.klass.where(reflection.foreign_key => all.select(primary_key)).semantic_search(query, limit: limit * 3)
|
|
57
|
+
|
|
58
|
+
hits.uniq { |chunk| chunk.try(:context) ? [ chunk[reflection.foreign_key], chunk.context ] : chunk.id }.first(limit)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# How a `searches` tool shows a chunk: its document, heading path and
|
|
62
|
+
# text, or its parent section for hierarchical chunks.
|
|
63
|
+
def format_search_chunk(chunk)
|
|
64
|
+
label = [ "#{name}##{chunk[reflect_on_association(:chunks).foreign_key]}", *chunk.try(:metadata)&.dig("headings") ]
|
|
65
|
+
"[#{label.join(' › ')}] #{chunk.try(:context) || chunk.content}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Ingests records that have no chunks yet, or every record with
|
|
69
|
+
# force: true. Records no loader can read are skipped and returned as
|
|
70
|
+
# { id => error message }.
|
|
71
|
+
def ingest_all(force: false)
|
|
72
|
+
skipped = {}
|
|
73
|
+
(force ? all : all.where.missing(:chunks)).find_each do |record|
|
|
74
|
+
record.ingest!
|
|
75
|
+
rescue OmniAgent::UnsupportedDocumentError => e
|
|
76
|
+
skipped[record.id] = e.message
|
|
77
|
+
end
|
|
78
|
+
skipped
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
module InstanceMethods
|
|
83
|
+
# Loads, splits and embeds the source, then replaces this record's
|
|
84
|
+
# chunks. Chunks whose text didn't change keep their vector, so
|
|
85
|
+
# re-ingesting an edited document only embeds what's new.
|
|
86
|
+
def ingest!
|
|
87
|
+
chunk_class = chunks.klass
|
|
88
|
+
column = chunk_class.try(:embedding_options)&.dig(:column)
|
|
89
|
+
raise OmniAgent::Error, "#{chunk_class.name} needs `has_embedding :content` to store chunk vectors." unless column
|
|
90
|
+
|
|
91
|
+
pieces = chunk_pieces
|
|
92
|
+
vectors = chunks.where.not(column => nil).pluck(:content, column).to_h
|
|
93
|
+
(pieces.map { |piece| piece[:content] }.uniq - vectors.keys).each_slice(EMBED_BATCH_SIZE) do |batch|
|
|
94
|
+
vectors.merge!(batch.zip(OmniAgent.embed(batch)).to_h)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
attributes = %i[content context metadata] & chunk_class.column_names.map(&:to_sym)
|
|
98
|
+
foreign_key = self.class.reflect_on_association(:chunks).foreign_key
|
|
99
|
+
rows = pieces.each_with_index.map do |piece, position|
|
|
100
|
+
piece.slice(*attributes).merge(foreign_key => id, position: position, column => vectors[piece[:content]])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
transaction do
|
|
104
|
+
# Serializes concurrent ingests of this record, and raises
|
|
105
|
+
# RecordNotFound if it was deleted while embedding.
|
|
106
|
+
self.class.unscoped.lock.find(id)
|
|
107
|
+
chunks.delete_all
|
|
108
|
+
chunk_class.insert_all!(rows) if rows.any?
|
|
109
|
+
end
|
|
110
|
+
chunks.reset
|
|
111
|
+
rows.size
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The source as clean text, or nil when there's nothing to read.
|
|
115
|
+
def chunk_source_text
|
|
116
|
+
value = public_send(chunk_options[:source])
|
|
117
|
+
return if value.blank?
|
|
118
|
+
|
|
119
|
+
if value.respond_to?(:attached?)
|
|
120
|
+
value.blob.open { |file| Loaders.load(file, as: chunk_options[:loader], filename: value.filename.to_s, content_type: value.content_type) }
|
|
121
|
+
elsif value.respond_to?(:to_plain_text)
|
|
122
|
+
value.to_plain_text
|
|
123
|
+
else
|
|
124
|
+
Loaders.load(value.respond_to?(:read) ? value : StringIO.new(value.to_s), as: chunk_options[:loader])
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def chunk_pieces
|
|
131
|
+
text = chunk_source_text
|
|
132
|
+
return [] if text.blank?
|
|
133
|
+
|
|
134
|
+
Chunkers.chunk(text, chunker: chunk_options[:chunker], size: chunk_options[:size], overlap: chunk_options[:overlap])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def chunk_attachment_changed?
|
|
138
|
+
respond_to?(:attachment_changes) && attachment_changes.key?(chunk_options[:source].to_s)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def remember_chunk_source_change
|
|
142
|
+
source = chunk_options[:source].to_s
|
|
143
|
+
# Other sources (rich text, methods) can't tell; ingest! re-embeds only changed chunks.
|
|
144
|
+
@chunk_source_changed = if self.class.try(:reflect_on_attachment, source) then chunk_attachment_changed?
|
|
145
|
+
elsif has_attribute?(source) then will_save_change_to_attribute?(source)
|
|
146
|
+
else true
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def ingest_changed_chunks
|
|
151
|
+
@chunk_source_changed = false
|
|
152
|
+
upload_pending_attachment
|
|
153
|
+
return ingest! unless chunk_options[:later]
|
|
154
|
+
|
|
155
|
+
job_options = OmniAgent.configuration.embedding_job_options.merge(chunk_options[:job_options])
|
|
156
|
+
OmniAgent::IngestJob.set(**job_options).perform_later(self)
|
|
157
|
+
rescue OmniAgent::UnsupportedDocumentError => e
|
|
158
|
+
logger&.warn("[OmniAgent] Skipped chunking #{self.class.name}##{id}: #{e.message}")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# ActiveStorage uploads in its own after_commit, which runs after this one
|
|
162
|
+
# when has_chunks is declared first. Upload now, the way it does; it then
|
|
163
|
+
# finds nothing left to upload.
|
|
164
|
+
def upload_pending_attachment
|
|
165
|
+
source = chunk_options[:source].to_s
|
|
166
|
+
attachment_changes.delete(source).upload if chunk_attachment_changed? && attachment_changes[source].respond_to?(:upload)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# An enforced loader (as: :pdf) rejects other files at save, instead of
|
|
170
|
+
# failing later in the job.
|
|
171
|
+
def validate_chunk_source_type
|
|
172
|
+
loader = Loaders.fetch(chunk_options[:loader])
|
|
173
|
+
attachment = public_send(chunk_options[:source])
|
|
174
|
+
return unless loader.respond_to?(:handles?) && chunk_attachment_changed? && attachment.attached?
|
|
175
|
+
return if loader.handles?(filename: attachment.filename.to_s, content_type: attachment.content_type)
|
|
176
|
+
|
|
177
|
+
errors.add(chunk_options[:source], "must be a #{chunk_options[:loader].to_s.upcase} file")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
module Chunkers
|
|
3
|
+
# Small child chunks for precise matching, each carrying its parent
|
|
4
|
+
# section as context, so a search matches the child and the agent reads
|
|
5
|
+
# the parent. Parents are Markdown sections of up to `size * 4` characters.
|
|
6
|
+
module Hierarchical
|
|
7
|
+
PARENT_SIZE_FACTOR = 4
|
|
8
|
+
|
|
9
|
+
# ponytail: each child stores its parent's text, so parents are duplicated
|
|
10
|
+
# per child; store parents once with a parent_id if storage matters.
|
|
11
|
+
def self.call(text, size: 1000, overlap: 200)
|
|
12
|
+
Markdown.call(text, size: size * PARENT_SIZE_FACTOR, overlap: overlap).flat_map do |parent|
|
|
13
|
+
Recursive.call(parent[:content], size: size, overlap: overlap).map do |child|
|
|
14
|
+
{ content: child, context: parent[:content], metadata: parent[:metadata] }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
module Chunkers
|
|
3
|
+
# One chunk per Markdown section, with its heading path as metadata
|
|
4
|
+
# ("headings" => ["Billing", "Refunds"]). Sections longer than `size`
|
|
5
|
+
# are split further with Recursive. Text without headings is one section.
|
|
6
|
+
module Markdown
|
|
7
|
+
HEADING = /\A(\#{1,6})\s+(.+?)\s*#*\s*\z/
|
|
8
|
+
FENCE = /\A\s*(```|~~~)/
|
|
9
|
+
|
|
10
|
+
def self.call(text, size: 1000, overlap: 200)
|
|
11
|
+
sections(text.to_s).flat_map do |headings, body|
|
|
12
|
+
Recursive.call(body, size: size, overlap: overlap).map do |content|
|
|
13
|
+
{ content: content, metadata: headings.any? ? { "headings" => headings } : {} }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# [[headings, body], ...]: each section's body includes its own heading line.
|
|
19
|
+
def self.sections(text)
|
|
20
|
+
sections = [ [ [], +"" ] ]
|
|
21
|
+
path = []
|
|
22
|
+
in_fence = false
|
|
23
|
+
|
|
24
|
+
text.each_line do |line|
|
|
25
|
+
in_fence = !in_fence if line.match?(FENCE)
|
|
26
|
+
heading = !in_fence && line.chomp.match(HEADING)
|
|
27
|
+
|
|
28
|
+
if heading
|
|
29
|
+
level = heading[1].length
|
|
30
|
+
path = path.first(level - 1) + [ heading[2] ]
|
|
31
|
+
sections << [ path, +"" ]
|
|
32
|
+
end
|
|
33
|
+
sections.last[1] << line
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A heading with nothing under it lives on in its subsections' paths.
|
|
37
|
+
sections.reject { |headings, body| body.lines.drop(headings.any? ? 1 : 0).join.strip.empty? }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|