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,53 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
module Chunkers
|
|
3
|
+
# Splits on the coarsest boundary that fits: headings, then paragraphs,
|
|
4
|
+
# lines, sentences and words, cutting mid-word only as a last resort.
|
|
5
|
+
# Pieces are merged back up to `size` characters, and each chunk starts
|
|
6
|
+
# with up to `overlap` characters of whole pieces from the one before.
|
|
7
|
+
module Recursive
|
|
8
|
+
# Zero-width, so every separator stays attached to its text.
|
|
9
|
+
SEPARATORS = [
|
|
10
|
+
/(?=^\#{1,6}\s)/, # Markdown headings
|
|
11
|
+
/(?<=\n\n)/, # paragraphs
|
|
12
|
+
/(?<=\n)/, # lines
|
|
13
|
+
/(?<=[.!?]\s)/, # sentences
|
|
14
|
+
/(?<=\s)/ # words
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def self.call(text, size: 1000, overlap: 200)
|
|
18
|
+
merge(split(text.to_s, size, SEPARATORS), size, overlap)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.split(text, size, separators)
|
|
22
|
+
return [ text ] if text.length <= size
|
|
23
|
+
|
|
24
|
+
separator, *rest = separators
|
|
25
|
+
return text.scan(/.{1,#{size}}/m) if separator.nil?
|
|
26
|
+
|
|
27
|
+
text.split(separator).flat_map { |piece| split(piece, size, rest) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.merge(pieces, size, overlap)
|
|
31
|
+
chunks = []
|
|
32
|
+
current = []
|
|
33
|
+
|
|
34
|
+
pieces.each do |piece|
|
|
35
|
+
if current.any? && length(current) + piece.length > size
|
|
36
|
+
chunks << current.join
|
|
37
|
+
current.shift while current.any? && (length(current) > overlap || length(current) + piece.length > size)
|
|
38
|
+
end
|
|
39
|
+
current << piece
|
|
40
|
+
end
|
|
41
|
+
chunks << current.join if current.any?
|
|
42
|
+
|
|
43
|
+
chunks.map(&:strip).reject(&:empty?)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.length(pieces)
|
|
47
|
+
pieces.sum(&:length)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private_class_method :split, :merge, :length
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
# Strategies that split text into chunks for `has_chunks`. A chunker is
|
|
3
|
+
# anything that responds to `call(text, size:, overlap:)` and returns
|
|
4
|
+
# Strings, or Hashes with :content and optional :context and :metadata.
|
|
5
|
+
module Chunkers
|
|
6
|
+
def self.registry
|
|
7
|
+
{
|
|
8
|
+
recursive: Recursive,
|
|
9
|
+
markdown: Markdown,
|
|
10
|
+
hierarchical: Hierarchical
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.fetch(chunker)
|
|
15
|
+
return chunker if chunker.respond_to?(:call)
|
|
16
|
+
|
|
17
|
+
registry.fetch(chunker.to_sym) do
|
|
18
|
+
raise ArgumentError, "Unknown chunker #{chunker.inspect}. Use #{registry.keys.join(', ')}, or an object that responds to call."
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Runs a chunker and normalizes its output to Hashes, dropping blank chunks.
|
|
23
|
+
def self.chunk(text, chunker:, size:, overlap:)
|
|
24
|
+
fetch(chunker).call(text, size: size, overlap: overlap).filter_map do |chunk|
|
|
25
|
+
chunk = chunk.is_a?(Hash) ? chunk.to_h.transform_keys(&:to_sym) : { content: chunk.to_s }
|
|
26
|
+
content = chunk[:content].to_s.strip
|
|
27
|
+
{ content: content, context: chunk[:context].presence, metadata: chunk[:metadata] || {} } unless content.empty?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -2,7 +2,8 @@ module OmniAgent
|
|
|
2
2
|
class Configuration
|
|
3
3
|
attr_accessor :default_provider, :default_model, :max_retries, :retry_base_delay, :max_tool_iterations,
|
|
4
4
|
:max_delegation_depth, :eval_judge_provider, :eval_judge_model, :eval_cache_enabled, :eval_cache_path,
|
|
5
|
-
:mcp_allowed_origins, :mcp_max_request_bytes
|
|
5
|
+
:mcp_allowed_origins, :mcp_max_request_bytes,
|
|
6
|
+
:embedding_provider, :embedding_model, :document_loaders
|
|
6
7
|
|
|
7
8
|
def initialize
|
|
8
9
|
@default_provider = :openai
|
|
@@ -17,6 +18,18 @@ module OmniAgent
|
|
|
17
18
|
@eval_cache_path = "tmp/omni_agent_eval_cache.json"
|
|
18
19
|
@mcp_allowed_origins = []
|
|
19
20
|
@mcp_max_request_bytes = 1_048_576
|
|
21
|
+
@embedding_provider = nil
|
|
22
|
+
@embedding_model = nil
|
|
23
|
+
@embedding_job_options = {}
|
|
24
|
+
@document_loaders = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :embedding_job_options
|
|
28
|
+
|
|
29
|
+
# ActiveJob `set` options for OmniAgent::EmbedJob (has_embedding later: true),
|
|
30
|
+
# e.g. { queue: :embeddings, priority: 10 }.
|
|
31
|
+
def embedding_job_options=(options)
|
|
32
|
+
@embedding_job_options = OmniAgent::Embeddable.validate_job_options!(options, "embedding_job_options")
|
|
20
33
|
end
|
|
21
34
|
end
|
|
22
35
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
# The plain-Markdown documentation bundled in the gem's docs/ directory, so
|
|
3
|
+
# coding agents working in a host app can read it offline. Generated from
|
|
4
|
+
# omniagent-docs/ with `bundle exec rake docs:sync`.
|
|
5
|
+
module Docs
|
|
6
|
+
ROOT = File.expand_path("../../docs", __dir__)
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def root
|
|
10
|
+
ROOT
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Page names relative to the docs root, without the .md extension
|
|
14
|
+
# (e.g. "tools", "mcp/authentication").
|
|
15
|
+
def pages
|
|
16
|
+
Dir.glob("**/*.md", base: root).map { |path| path.delete_suffix(".md") }.sort
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def path(page = "index")
|
|
20
|
+
name = page.to_s.strip.delete_prefix("/").delete_suffix(".md")
|
|
21
|
+
return unless pages.include?(name)
|
|
22
|
+
|
|
23
|
+
File.join(root, "#{name}.md")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def read(page = "index")
|
|
27
|
+
file = path(page)
|
|
28
|
+
File.read(file) if file
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
# ActiveRecord macro that keeps an embedding of some attributes up to date
|
|
3
|
+
# and adds `semantic_search`. Extended into every model by the engine.
|
|
4
|
+
#
|
|
5
|
+
# class Document < ApplicationRecord
|
|
6
|
+
# has_embedding :title, :content
|
|
7
|
+
# end
|
|
8
|
+
#
|
|
9
|
+
# Document.where(account_id: 1).semantic_search("refund policy", limit: 5)
|
|
10
|
+
#
|
|
11
|
+
# The default backend is the neighbor gem (pgvector, sqlite-vec, MariaDB).
|
|
12
|
+
# `later: true` embeds in OmniAgent::EmbedJob after commit instead; pass a
|
|
13
|
+
# Hash (`later: { queue: :embeddings }`) to override the global
|
|
14
|
+
# `embedding_job_options` for this model.
|
|
15
|
+
#
|
|
16
|
+
# `using: :ruby` stores vectors in a JSON column and ranks them in Ruby;
|
|
17
|
+
# it loads every row per search, so it only suits small tables.
|
|
18
|
+
module Embeddable
|
|
19
|
+
BACKENDS = %i[neighbor ruby].freeze
|
|
20
|
+
# The options ActiveJob's `set` takes, for `later:` and embedding_job_options.
|
|
21
|
+
JOB_OPTIONS = %i[queue priority wait wait_until].freeze
|
|
22
|
+
|
|
23
|
+
def self.validate_job_options!(options, setting)
|
|
24
|
+
unknown = options.keys.map(&:to_sym) - JOB_OPTIONS
|
|
25
|
+
return options.to_h.transform_keys(&:to_sym) if unknown.empty?
|
|
26
|
+
|
|
27
|
+
raise ArgumentError, "Unknown #{setting} option #{unknown.first.inspect}. Use #{JOB_OPTIONS.join(', ')}."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def has_embedding(*sources, column: :embedding, dimensions: nil, using: :neighbor, later: false)
|
|
31
|
+
raise ArgumentError, "has_embedding requires at least one attribute" if sources.empty?
|
|
32
|
+
raise ArgumentError, "has_embedding using: must be :neighbor or :ruby" unless BACKENDS.include?(using)
|
|
33
|
+
raise ArgumentError, "has_embedding later: must be true, false or a Hash of job options" unless [ true, false ].include?(later) || later.is_a?(Hash)
|
|
34
|
+
raise ArgumentError, "#{name} already uses has_chunks. A model uses has_embedding or has_chunks, not both." if respond_to?(:chunk_options)
|
|
35
|
+
|
|
36
|
+
class_attribute :embedding_options, instance_writer: false
|
|
37
|
+
self.embedding_options = {
|
|
38
|
+
sources: sources.map(&:to_sym),
|
|
39
|
+
column: column.to_sym,
|
|
40
|
+
using: using,
|
|
41
|
+
job_options: later.is_a?(Hash) ? Embeddable.validate_job_options!(later, "later:") : {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if using == :neighbor
|
|
45
|
+
unless respond_to?(:has_neighbors)
|
|
46
|
+
raise OmniAgent::MissingDependencyError,
|
|
47
|
+
"has_embedding needs the neighbor gem: add `gem \"neighbor\"` to your Gemfile. " \
|
|
48
|
+
"Without a vector database, `using: :ruby` works for small tables only."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
has_neighbors column, dimensions: dimensions
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
extend ClassMethods
|
|
55
|
+
include InstanceMethods
|
|
56
|
+
|
|
57
|
+
if later
|
|
58
|
+
after_commit :embed_later, on: %i[create update], if: -> { embedding_sources.any? { |source| saved_change_to_attribute?(source) } }
|
|
59
|
+
else
|
|
60
|
+
before_save :assign_embedding, if: -> { embedding_sources.any? { |source| will_save_change_to_attribute?(source) } }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
module ClassMethods
|
|
65
|
+
def semantic_search(query, limit: 5)
|
|
66
|
+
vector = OmniAgent.embed(query)
|
|
67
|
+
column = embedding_options[:column]
|
|
68
|
+
|
|
69
|
+
if embedding_options[:using] == :ruby
|
|
70
|
+
# ponytail: loads every embedded row and ranks in Ruby, O(n) per search; use neighbor past ~10k rows.
|
|
71
|
+
all.where.not(column => nil).to_a.max_by(limit) { |record| cosine_similarity(record[column], vector) }
|
|
72
|
+
else
|
|
73
|
+
all.nearest_neighbors(column, vector, distance: "cosine").first(limit)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Embeds records that have no vector yet, or every record with
|
|
78
|
+
# force: true (e.g. after switching embedding models).
|
|
79
|
+
def embed_all(force: false, batch_size: 100)
|
|
80
|
+
column = embedding_options[:column]
|
|
81
|
+
records = force ? all : all.where(column => nil)
|
|
82
|
+
|
|
83
|
+
records.find_in_batches(batch_size: batch_size) do |batch|
|
|
84
|
+
batch = batch.reject { |record| record.embedding_text.blank? }
|
|
85
|
+
next if batch.empty?
|
|
86
|
+
|
|
87
|
+
OmniAgent.embed(batch.map(&:embedding_text)).zip(batch) do |vector, record|
|
|
88
|
+
record.update_column(column, vector)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def cosine_similarity(a, b)
|
|
96
|
+
norms = Math.sqrt(a.sum { |x| x * x }) * Math.sqrt(b.sum { |x| x * x })
|
|
97
|
+
norms.zero? ? 0.0 : a.zip(b).sum { |x, y| x * y } / norms
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
module InstanceMethods
|
|
102
|
+
# The text that gets embedded, and what a `searches` tool shows the model.
|
|
103
|
+
def embedding_text
|
|
104
|
+
embedding_sources.filter_map { |source| public_send(source).presence&.to_s }.join("\n\n")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def embed!
|
|
108
|
+
update_column(embedding_options[:column], embedding_for(embedding_text))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def embedding_sources
|
|
114
|
+
embedding_options[:sources]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def embedding_for(text)
|
|
118
|
+
text.blank? ? nil : OmniAgent.embed(text)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def assign_embedding
|
|
122
|
+
self[embedding_options[:column]] = embedding_for(embedding_text)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def embed_later
|
|
126
|
+
job_options = OmniAgent.configuration.embedding_job_options.merge(embedding_options[:job_options])
|
|
127
|
+
OmniAgent::EmbedJob.set(**job_options).perform_later(self)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/omni_agent/engine.rb
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# EmbedJob (has_embedding later:) needs ActiveJob. Loading its railtie here,
|
|
2
|
+
# during Bundler.require, also works in apps that don't require it themselves.
|
|
3
|
+
require "active_job/railtie"
|
|
4
|
+
|
|
1
5
|
module OmniAgent
|
|
2
6
|
class Engine < ::Rails::Engine
|
|
3
7
|
isolate_namespace OmniAgent
|
|
@@ -5,5 +9,12 @@ module OmniAgent
|
|
|
5
9
|
initializer "omni_agent.mcp_routing" do
|
|
6
10
|
ActionDispatch::Routing::Mapper.include(OmniAgent::MCP::Routing)
|
|
7
11
|
end
|
|
12
|
+
|
|
13
|
+
initializer "omni_agent.active_record" do
|
|
14
|
+
ActiveSupport.on_load(:active_record) do
|
|
15
|
+
extend OmniAgent::Embeddable
|
|
16
|
+
extend OmniAgent::Chunkable
|
|
17
|
+
end
|
|
18
|
+
end
|
|
8
19
|
end
|
|
9
20
|
end
|
data/lib/omni_agent/errors.rb
CHANGED
|
@@ -7,6 +7,18 @@ module OmniAgent
|
|
|
7
7
|
class MaxDelegationDepthError < Error; end
|
|
8
8
|
class EvalAssertionError < Error; end
|
|
9
9
|
class MCPError < Error; end
|
|
10
|
+
# Carries the raw model output so callers can log, retry or repair it.
|
|
11
|
+
class StructuredOutputError < Error
|
|
12
|
+
attr_reader :content, :response
|
|
13
|
+
|
|
14
|
+
def initialize(message = nil, content: nil, response: nil)
|
|
15
|
+
super(message)
|
|
16
|
+
@content = content
|
|
17
|
+
@response = response
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
class UnknownStockAgentError < Error; end
|
|
21
|
+
class UnsupportedDocumentError < Error; end
|
|
10
22
|
end
|
|
11
23
|
|
|
12
24
|
Error = Errors::Error
|
|
@@ -16,4 +28,7 @@ module OmniAgent
|
|
|
16
28
|
MaxDelegationDepthError = Errors::MaxDelegationDepthError
|
|
17
29
|
EvalAssertionError = Errors::EvalAssertionError
|
|
18
30
|
MCPError = Errors::MCPError
|
|
31
|
+
StructuredOutputError = Errors::StructuredOutputError
|
|
32
|
+
UnknownStockAgentError = Errors::UnknownStockAgentError
|
|
33
|
+
UnsupportedDocumentError = Errors::UnsupportedDocumentError
|
|
19
34
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
module Loaders
|
|
3
|
+
# Visible text only, with block elements on their own paragraphs. Uses
|
|
4
|
+
# Loofah, which Rails already depends on.
|
|
5
|
+
module HTML
|
|
6
|
+
EXTENSIONS = %w[.html .htm .xhtml].freeze
|
|
7
|
+
CONTENT_TYPES = %w[text/html application/xhtml+xml].freeze
|
|
8
|
+
|
|
9
|
+
def self.handles?(filename:, content_type:)
|
|
10
|
+
Loaders.matches?(filename, content_type, EXTENSIONS, CONTENT_TYPES)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.call(io, **)
|
|
14
|
+
require "loofah"
|
|
15
|
+
document = Loofah.document(io.read)
|
|
16
|
+
document.css("script, style, noscript, template").remove
|
|
17
|
+
document.to_text
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
module Loaders
|
|
3
|
+
# Reads the file as-is, so the Markdown and hierarchical chunkers can
|
|
4
|
+
# split on its headings.
|
|
5
|
+
module Markdown
|
|
6
|
+
EXTENSIONS = %w[.md .markdown .mdx].freeze
|
|
7
|
+
CONTENT_TYPES = %w[text/markdown text/x-markdown text/x-web-markdown].freeze
|
|
8
|
+
|
|
9
|
+
def self.handles?(filename:, content_type:)
|
|
10
|
+
Loaders.matches?(filename, content_type, EXTENSIONS, CONTENT_TYPES)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.call(io, **)
|
|
14
|
+
io.read
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
module Loaders
|
|
3
|
+
# Text-based PDFs, via the optional pdf-reader gem. Scanned PDFs have no
|
|
4
|
+
# text layer and come out empty; they need OCR, in a custom loader.
|
|
5
|
+
module PDF
|
|
6
|
+
EXTENSIONS = %w[.pdf].freeze
|
|
7
|
+
CONTENT_TYPES = %w[application/pdf].freeze
|
|
8
|
+
|
|
9
|
+
def self.handles?(filename:, content_type:)
|
|
10
|
+
Loaders.matches?(filename, content_type, EXTENSIONS, CONTENT_TYPES)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.call(io, **)
|
|
14
|
+
begin
|
|
15
|
+
require "pdf-reader"
|
|
16
|
+
rescue LoadError
|
|
17
|
+
raise OmniAgent::MissingDependencyError, "Reading PDFs needs the pdf-reader gem: add `gem \"pdf-reader\"` to your Gemfile."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# pdf-reader pads text with spaces to keep the page layout.
|
|
21
|
+
::PDF::Reader.new(io).pages.map { |page| page.text.gsub(/[ \t]{2,}/, " ") }.join("\n\n")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
module Loaders
|
|
3
|
+
module Text
|
|
4
|
+
EXTENSIONS = %w[.txt .text .csv .log].freeze
|
|
5
|
+
|
|
6
|
+
def self.handles?(filename:, content_type:)
|
|
7
|
+
content_type.to_s.start_with?("text/") || Loaders.matches?(filename, content_type, EXTENSIONS, [])
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.call(io, **)
|
|
11
|
+
io.read
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
# Strategies that turn a document into text for `has_chunks`. A loader is
|
|
3
|
+
# anything that responds to `call(io, filename:, content_type:)` and returns
|
|
4
|
+
# a String. Loaders that also answer `handles?(filename:, content_type:)`
|
|
5
|
+
# take part in auto-detection; register your own in
|
|
6
|
+
# `config.document_loaders` (they're tried before the built-in ones).
|
|
7
|
+
module Loaders
|
|
8
|
+
# Content types that say nothing about the format, so detection skips them.
|
|
9
|
+
GENERIC_CONTENT_TYPES = [ "application/octet-stream", "binary/octet-stream" ].freeze
|
|
10
|
+
SNIFF_BYTES = 8192
|
|
11
|
+
|
|
12
|
+
def self.registry
|
|
13
|
+
built_in = { pdf: PDF, html: HTML, markdown: Markdown, text: Text }
|
|
14
|
+
OmniAgent.configuration.document_loaders.transform_keys(&:to_sym).merge(built_in) { |_name, custom, _built_in| custom }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.fetch(loader)
|
|
18
|
+
return loader if loader.respond_to?(:call)
|
|
19
|
+
|
|
20
|
+
registry.fetch(loader.to_sym) do
|
|
21
|
+
raise ArgumentError, "Unknown loader #{loader.inspect}. Use :auto, #{registry.keys.join(', ')}, or an object that responds to call."
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Reads a document into clean text, with the given loader or, for
|
|
26
|
+
# as: :auto, the first one that handles its filename or content type.
|
|
27
|
+
# Without either, it looks at the first bytes: PDF and HTML are
|
|
28
|
+
# recognized, anything else without NUL bytes is read as text.
|
|
29
|
+
def self.load(io, as: :auto, filename: nil, content_type: nil)
|
|
30
|
+
content_type = nil if GENERIC_CONTENT_TYPES.include?(content_type)
|
|
31
|
+
loader = as.to_s == "auto" ? detect(io, filename: filename, content_type: content_type) : fetch(as)
|
|
32
|
+
|
|
33
|
+
clean(loader.call(io, filename: filename, content_type: content_type))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.detect(io, filename:, content_type:)
|
|
37
|
+
loader = registry.values.find { |candidate| candidate.respond_to?(:handles?) && candidate.handles?(filename: filename, content_type: content_type) }
|
|
38
|
+
loader || sniff(io) || raise(
|
|
39
|
+
OmniAgent::UnsupportedDocumentError,
|
|
40
|
+
"Can't read #{filename || 'this document'} (#{content_type || 'unknown type'}). " \
|
|
41
|
+
"Pass `as:` a loader, or register one in config.document_loaders."
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.sniff(io)
|
|
46
|
+
sample = io.read(SNIFF_BYTES).to_s
|
|
47
|
+
io.rewind
|
|
48
|
+
|
|
49
|
+
if sample.start_with?("%PDF-") then PDF
|
|
50
|
+
elsif sample.match?(/\A\s*<(!doctype html|html)/i) then HTML
|
|
51
|
+
elsif !sample.include?("\0") then Text
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Fixes encodings and strips what databases and chunkers trip on:
|
|
56
|
+
# invalid bytes, NULs (Postgres rejects them), CRLFs and runs of blank lines.
|
|
57
|
+
def self.clean(text)
|
|
58
|
+
text.to_s.dup.force_encoding(Encoding::UTF_8).scrub("").delete("\u0000")
|
|
59
|
+
.gsub(/\r\n?/, "\n").gsub(/[ \t]+$/, "").gsub(/\n{3,}/, "\n\n").strip
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.matches?(filename, content_type, extensions, content_types)
|
|
63
|
+
content_types.include?(content_type.to_s.split(";").first) || extensions.include?(File.extname(filename.to_s).downcase)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private_class_method :detect, :sniff, :clean
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -13,6 +13,20 @@ module OmniAgent
|
|
|
13
13
|
raise NotImplementedError, "Providers must implement #chat"
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Returns one embedding vector (Array of Floats) per text in `texts`.
|
|
17
|
+
# `model` is the embedding model; it defaults per provider, never to the
|
|
18
|
+
# chat model.
|
|
19
|
+
def embed(texts, model: nil)
|
|
20
|
+
raise NotImplementedError, "#{self.class.name.split('::').last} does not support embeddings"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Providers that return true receive an agent's output schema as the
|
|
24
|
+
# `output_schema:` keyword of #chat. Otherwise the schema is only
|
|
25
|
+
# described in the system prompt.
|
|
26
|
+
def supports_structured_output?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
16
30
|
def validate_messages!(messages, allowed_roles:)
|
|
17
31
|
unless messages.is_a?(Array)
|
|
18
32
|
raise OmniAgent::Error, "messages must be an array"
|
|
@@ -1,20 +1,25 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "digest"
|
|
3
|
+
|
|
1
4
|
module OmniAgent
|
|
2
5
|
module Providers
|
|
3
6
|
class Mock < Base
|
|
4
7
|
LOREM_IPSUM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
|
|
5
8
|
|
|
6
|
-
def chat(messages:, tools: [], stream: nil, **_options)
|
|
9
|
+
def chat(messages:, tools: [], stream: nil, output_schema: nil, **_options)
|
|
7
10
|
validate_messages!(messages, allowed_roles: %i[system user assistant tool])
|
|
8
11
|
|
|
12
|
+
content = output_schema ? JSON.generate(sample_output(output_schema)) : LOREM_IPSUM
|
|
13
|
+
|
|
9
14
|
if stream
|
|
10
|
-
|
|
15
|
+
content.split(" ").each_with_index do |word, index|
|
|
11
16
|
chunk = index.zero? ? word : " #{word}"
|
|
12
17
|
stream.call(OmniAgent::Streaming::Event.text(chunk))
|
|
13
18
|
end
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
OmniAgent::Providers::Response.new(
|
|
17
|
-
content:
|
|
22
|
+
content: content,
|
|
18
23
|
raw_request: {
|
|
19
24
|
model: model,
|
|
20
25
|
messages: messages,
|
|
@@ -24,7 +29,7 @@ module OmniAgent
|
|
|
24
29
|
"choices" => [
|
|
25
30
|
{
|
|
26
31
|
"message" => {
|
|
27
|
-
"content" =>
|
|
32
|
+
"content" => content
|
|
28
33
|
}
|
|
29
34
|
}
|
|
30
35
|
]
|
|
@@ -33,6 +38,16 @@ module OmniAgent
|
|
|
33
38
|
)
|
|
34
39
|
end
|
|
35
40
|
|
|
41
|
+
# Deterministic placeholder vectors derived from the text, so specs can
|
|
42
|
+
# index and search without calling a real embedding model.
|
|
43
|
+
def embed(texts, model: nil)
|
|
44
|
+
texts.map { |text| Digest::SHA256.digest(text.to_s).bytes.first(8).map { |byte| byte / 255.0 } }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def supports_structured_output?
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
36
51
|
protected
|
|
37
52
|
|
|
38
53
|
def default_api_key
|
|
@@ -42,6 +57,76 @@ module OmniAgent
|
|
|
42
57
|
def default_model
|
|
43
58
|
"mock"
|
|
44
59
|
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Builds a placeholder value for every property so agents with an output
|
|
64
|
+
# schema get a parseable response without calling a real model. Raises
|
|
65
|
+
# when a field needs a value the mock can't invent (pattern, custom
|
|
66
|
+
# validate:, polymorphic resolve: true).
|
|
67
|
+
def sample_output(output_schema)
|
|
68
|
+
unresolvable = output_schema.polymorphics.find { |_name, group| group[:resolve] }
|
|
69
|
+
unsatisfiable!(unresolvable.first, "it is polymorphic with resolve: true") if unresolvable
|
|
70
|
+
|
|
71
|
+
sample = sample_object(output_schema.json_schema)
|
|
72
|
+
|
|
73
|
+
output_schema.validators.each do |name, validator|
|
|
74
|
+
passed = begin
|
|
75
|
+
validator.call(sample[name]) != false
|
|
76
|
+
rescue StandardError
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
unsatisfiable!(name, "its validate: proc rejects the placeholder #{sample[name].inspect}") unless passed
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sample
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def sample_object(schema, path = nil)
|
|
86
|
+
(schema[:properties] || {}).to_h do |name, property|
|
|
87
|
+
[ name, sample_value(property, [ path, name ].compact.join(".")) ]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def sample_value(property, path)
|
|
92
|
+
return property[:enum].first if property[:enum]
|
|
93
|
+
|
|
94
|
+
case property[:type]
|
|
95
|
+
when "string" then sample_string(property, path)
|
|
96
|
+
when "integer" then sample_number(property, integer: true)
|
|
97
|
+
when "number" then sample_number(property).to_f
|
|
98
|
+
when "boolean" then true
|
|
99
|
+
when "array" then Array.new([ property[:minItems].to_i, 1 ].max) { sample_value(property[:items] || {}, "#{path}[]") }
|
|
100
|
+
when "object" then sample_object(property, path)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def sample_number(property, integer: false)
|
|
105
|
+
minimum = property[:minimum]
|
|
106
|
+
maximum = property[:maximum]
|
|
107
|
+
return integer ? minimum.ceil : minimum if minimum
|
|
108
|
+
return integer ? maximum.floor : maximum if maximum && maximum < 0
|
|
109
|
+
|
|
110
|
+
0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def sample_string(property, path)
|
|
114
|
+
text = LOREM_IPSUM
|
|
115
|
+
text = text.ljust(property[:minLength], "x") if property[:minLength]
|
|
116
|
+
text = text[0, property[:maxLength]] if property[:maxLength]
|
|
117
|
+
|
|
118
|
+
if property[:pattern] && !text.match?(Regexp.new(property[:pattern]))
|
|
119
|
+
unsatisfiable!(path, "its value must match the pattern #{property[:pattern]}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
text
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def unsatisfiable!(field, reason)
|
|
126
|
+
raise OmniAgent::Error,
|
|
127
|
+
"The mock provider can't generate structured output for `#{field}` because #{reason}. " \
|
|
128
|
+
"Stub the provider's #chat response in this test instead."
|
|
129
|
+
end
|
|
45
130
|
end
|
|
46
131
|
end
|
|
47
132
|
end
|