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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +96 -2
  4. data/Rakefile +4 -0
  5. data/app/jobs/omni_agent/embed_job.rb +10 -0
  6. data/app/jobs/omni_agent/ingest_job.rb +12 -0
  7. data/docs/agent/callbacks.md +60 -0
  8. data/docs/agent/delegation.md +56 -0
  9. data/docs/agent/provider.md +93 -0
  10. data/docs/agent/run-entrypoints.md +81 -0
  11. data/docs/agent/streaming.md +62 -0
  12. data/docs/agent/structured-output.md +80 -0
  13. data/docs/configuration.md +68 -0
  14. data/docs/evals/caching.md +21 -0
  15. data/docs/evals/generating-and-running.md +87 -0
  16. data/docs/evals/golden-sets.md +32 -0
  17. data/docs/evals/judge-provider.md +20 -0
  18. data/docs/generators.md +143 -0
  19. data/docs/index.md +98 -0
  20. data/docs/intro.md +93 -0
  21. data/docs/mcp/authentication.md +226 -0
  22. data/docs/mcp/defining-servers.md +196 -0
  23. data/docs/mcp/transports.md +166 -0
  24. data/docs/rag.md +372 -0
  25. data/docs/stock-agents/ocr.md +52 -0
  26. data/docs/stock-agents/overview.md +91 -0
  27. data/docs/supported-providers.md +47 -0
  28. data/docs/tools.md +171 -0
  29. data/exe/omni_agent +48 -10
  30. data/lib/generators/omni_agent/chunks/chunks_generator.rb +130 -0
  31. data/lib/generators/omni_agent/embeddings/embeddings_generator.rb +72 -0
  32. data/lib/generators/omni_agent/install/install_generator.rb +78 -1
  33. data/lib/generators/omni_agent/stock_agent/stock_agent_generator.rb +73 -0
  34. data/lib/generators/omni_agent/vector_store.rb +113 -0
  35. data/lib/omni_agent/agent.rb +168 -39
  36. data/lib/omni_agent/chunkable.rb +181 -0
  37. data/lib/omni_agent/chunkers/hierarchical.rb +20 -0
  38. data/lib/omni_agent/chunkers/markdown.rb +41 -0
  39. data/lib/omni_agent/chunkers/recursive.rb +53 -0
  40. data/lib/omni_agent/chunkers.rb +31 -0
  41. data/lib/omni_agent/configuration.rb +14 -1
  42. data/lib/omni_agent/docs.rb +32 -0
  43. data/lib/omni_agent/embeddable.rb +131 -0
  44. data/lib/omni_agent/engine.rb +11 -0
  45. data/lib/omni_agent/errors.rb +15 -0
  46. data/lib/omni_agent/loaders/html.rb +21 -0
  47. data/lib/omni_agent/loaders/markdown.rb +18 -0
  48. data/lib/omni_agent/loaders/pdf.rb +25 -0
  49. data/lib/omni_agent/loaders/text.rb +15 -0
  50. data/lib/omni_agent/loaders.rb +68 -0
  51. data/lib/omni_agent/providers/base.rb +14 -0
  52. data/lib/omni_agent/providers/mock.rb +89 -4
  53. data/lib/omni_agent/providers/ollama.rb +4 -0
  54. data/lib/omni_agent/providers/openai.rb +33 -2
  55. data/lib/omni_agent/providers/response.rb +6 -1
  56. data/lib/omni_agent/providers.rb +7 -0
  57. data/lib/omni_agent/schema.rb +200 -0
  58. data/lib/omni_agent/semantic_search.rb +45 -0
  59. data/lib/omni_agent/stock_agent.rb +59 -0
  60. data/lib/omni_agent/stock_agents/ocr/prompt.md.erb +6 -0
  61. data/lib/omni_agent/stock_agents/ocr.rb +70 -0
  62. data/lib/omni_agent/stock_agents.rb +26 -0
  63. data/lib/omni_agent/tool.rb +7 -107
  64. data/lib/omni_agent/version.rb +1 -1
  65. data/lib/omni_agent.rb +12 -1
  66. data/lib/tasks/omni_agent_chunks_tasks.rake +16 -0
  67. data/lib/tasks/omni_agent_embeddings_tasks.rake +15 -0
  68. metadata +49 -8
  69. data/app/assets/stylesheets/omni_agent/application.css +0 -15
  70. data/app/controllers/omni_agent/application_controller.rb +0 -4
  71. data/app/helpers/omni_agent/application_helper.rb +0 -4
  72. data/app/jobs/omni_agent/application_job.rb +0 -4
  73. data/app/mailers/omni_agent/application_mailer.rb +0 -6
  74. data/app/models/omni_agent/application_record.rb +0 -5
  75. 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
@@ -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 configured_provider_name || configured_provider_options.any?
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
- delegated_tools_module.const_set(delegated_tool_const_name(as), tool_class)
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
- @delegated_tool_classes = configured_delegated_tool_classes + [ tool_class ]
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
- namespace_tools + configured_delegated_tool_classes
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
- def configured_provider_name; @provider_name; end
148
- def configured_provider_options; @provider_options || {}; end
149
- def configured_model_options; @model_options || {}; end
150
- def configured_with_use_model?; @configured_with_use_model == true; end
151
- def configured_before_generation_callbacks; @before_generation_callbacks || []; end
152
- def configured_after_generation_callbacks; @after_generation_callbacks || []; end
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, **@chat_options)
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
- provider_class = OmniAgent::Providers.registry[name.to_sym]
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
- return "You are a helpful assistant with access to local tools." unless defined?(Rails)
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
- base_prompt = render_prompt_template(base_file_path, isolated_scope)
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
- "You are a helpful assistant with access to local tools."
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