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
|
@@ -12,13 +12,14 @@ module OmniAgent
|
|
|
12
12
|
"Please add `gem 'openai'` to your Gemfile."
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def chat(messages:, tools: [], stream: nil, **options)
|
|
15
|
+
def chat(messages:, tools: [], stream: nil, output_schema: nil, **options)
|
|
16
16
|
validate_messages!(messages, allowed_roles: %i[system user assistant tool])
|
|
17
17
|
|
|
18
18
|
messages = messages.map do |msg|
|
|
19
19
|
clean_msg = msg.reject { |_, v| v.nil? }
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
# Arrays are OpenAI content parts (text, image_url, file) and pass through.
|
|
22
|
+
if !clean_msg.key?(:tool_calls) && !clean_msg[:content].is_a?(String) && !clean_msg[:content].is_a?(Array)
|
|
22
23
|
clean_msg[:content] = ""
|
|
23
24
|
end
|
|
24
25
|
|
|
@@ -42,6 +43,7 @@ module OmniAgent
|
|
|
42
43
|
messages: messages
|
|
43
44
|
}
|
|
44
45
|
payload[:tools] = openai_tools if openai_tools.any?
|
|
46
|
+
payload[:response_format] = format_response_format(output_schema) if output_schema
|
|
45
47
|
payload.merge!(options) if options.any?
|
|
46
48
|
|
|
47
49
|
response = if stream
|
|
@@ -55,8 +57,26 @@ module OmniAgent
|
|
|
55
57
|
raise OmniAgent::Error, "Error during #{provider_label} chat: #{e.message}"
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
def embed(texts, model: nil)
|
|
61
|
+
response = with_retries do
|
|
62
|
+
client.embeddings.create(input: texts, model: model || default_embedding_model)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
response.data.map(&:embedding)
|
|
66
|
+
rescue => e
|
|
67
|
+
raise OmniAgent::Error, "Error during #{provider_label} embed: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def supports_structured_output?
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
58
74
|
protected
|
|
59
75
|
|
|
76
|
+
def default_embedding_model
|
|
77
|
+
"text-embedding-3-small"
|
|
78
|
+
end
|
|
79
|
+
|
|
60
80
|
def provider_label
|
|
61
81
|
"OpenAI"
|
|
62
82
|
end
|
|
@@ -109,6 +129,17 @@ module OmniAgent
|
|
|
109
129
|
}
|
|
110
130
|
end
|
|
111
131
|
|
|
132
|
+
def format_response_format(output_schema)
|
|
133
|
+
{
|
|
134
|
+
type: "json_schema",
|
|
135
|
+
json_schema: {
|
|
136
|
+
name: "structured_output",
|
|
137
|
+
schema: output_schema.json_schema,
|
|
138
|
+
strict: output_schema.strict_compatible?
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
112
143
|
def parse_response(raw_response, raw_request)
|
|
113
144
|
provider_raw_response = raw_response.respond_to?(:to_h) ? raw_response.to_h : raw_response
|
|
114
145
|
choices = raw_response.respond_to?(:choices) ? raw_response.choices : provider_raw_response["choices"]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module OmniAgent
|
|
2
2
|
module Providers
|
|
3
3
|
class Response
|
|
4
|
-
attr_reader :content, :tool_calls, :raw_response, :raw_request, :generated_messages
|
|
4
|
+
attr_reader :content, :tool_calls, :raw_response, :raw_request, :generated_messages, :structured_output
|
|
5
5
|
|
|
6
6
|
def initialize(content:, tool_calls: [], raw_response: nil, raw_request: nil, generated_messages: [])
|
|
7
7
|
@content = content
|
|
@@ -23,6 +23,11 @@ module OmniAgent
|
|
|
23
23
|
@generated_messages = Array(messages)
|
|
24
24
|
self
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
def with_structured_output(output)
|
|
28
|
+
@structured_output = output
|
|
29
|
+
self
|
|
30
|
+
end
|
|
26
31
|
end
|
|
27
32
|
end
|
|
28
33
|
end
|
data/lib/omni_agent/providers.rb
CHANGED
|
@@ -9,5 +9,12 @@ module OmniAgent
|
|
|
9
9
|
mock_judge: OmniAgent::Providers::MockJudge
|
|
10
10
|
}
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
def self.fetch(name)
|
|
14
|
+
registry.fetch(name.to_sym) do
|
|
15
|
+
raise OmniAgent::UnknownProviderError,
|
|
16
|
+
"Unknown provider #{name.inspect}. Known providers: #{registry.keys.join(', ')}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
12
19
|
end
|
|
13
20
|
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
# A typed object schema declared with the Tool::SchemaBuilder DSL. Tools use
|
|
3
|
+
# it to describe and validate their input; agents use it to describe and
|
|
4
|
+
# validate their structured output.
|
|
5
|
+
class Schema
|
|
6
|
+
STRICT_UNSUPPORTED_KEYWORDS = %i[minLength maxLength].freeze
|
|
7
|
+
|
|
8
|
+
TYPE_CHECKS = {
|
|
9
|
+
"string" => ->(value) { value.is_a?(String) },
|
|
10
|
+
"integer" => ->(value) { value.is_a?(Integer) },
|
|
11
|
+
"number" => ->(value) { value.is_a?(Numeric) },
|
|
12
|
+
"boolean" => ->(value) { value == true || value == false },
|
|
13
|
+
"array" => ->(value) { value.is_a?(Array) },
|
|
14
|
+
"object" => ->(value) { value.is_a?(Hash) }
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :properties, :required, :validators, :polymorphics
|
|
18
|
+
|
|
19
|
+
def self.build(&block)
|
|
20
|
+
builder = Tool::SchemaBuilder.new
|
|
21
|
+
builder.instance_eval(&block)
|
|
22
|
+
|
|
23
|
+
new(
|
|
24
|
+
properties: builder.properties,
|
|
25
|
+
required: builder.required_fields,
|
|
26
|
+
validators: builder.validators,
|
|
27
|
+
polymorphics: builder.polymorphics
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(properties: {}, required: [], validators: {}, polymorphics: {})
|
|
32
|
+
@properties = properties
|
|
33
|
+
@required = required
|
|
34
|
+
@validators = validators
|
|
35
|
+
@polymorphics = polymorphics
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def json_schema
|
|
39
|
+
{
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: properties,
|
|
42
|
+
required: required,
|
|
43
|
+
additionalProperties: false
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether the schema satisfies the constraints of strict structured-output
|
|
48
|
+
# modes (e.g. OpenAI's `strict: true`): every property required, no
|
|
49
|
+
# free-form objects, and no keywords those modes reject.
|
|
50
|
+
def strict_compatible?
|
|
51
|
+
strict_object?(json_schema)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Filters unknown keys, validates the rest, and returns a symbol-keyed hash.
|
|
55
|
+
# Raises ArgumentError when validation fails.
|
|
56
|
+
def parse(values)
|
|
57
|
+
parse_values(values, check_types: false)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Like #parse, but top-level values must also match their declared JSON
|
|
61
|
+
# type, and nulls for optional fields are treated as absent.
|
|
62
|
+
def parse_typed(values)
|
|
63
|
+
parse_values(values, check_types: true)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def parse_values(values, check_types:)
|
|
69
|
+
raise ArgumentError, "expected an object, got #{values.class}" unless values.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
kwargs = values.transform_keys(&:to_sym)
|
|
72
|
+
valid_keys = properties.keys.map(&:to_sym)
|
|
73
|
+
filtered = kwargs.slice(*valid_keys)
|
|
74
|
+
|
|
75
|
+
validate_required!(filtered)
|
|
76
|
+
validate_types!(filtered) if check_types
|
|
77
|
+
validate_enums!(filtered)
|
|
78
|
+
validate_constraints!(filtered)
|
|
79
|
+
run_custom_validators!(filtered)
|
|
80
|
+
resolve_polymorphics!(filtered)
|
|
81
|
+
|
|
82
|
+
filtered
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def strict_object?(schema)
|
|
86
|
+
return false unless schema[:additionalProperties] == false
|
|
87
|
+
|
|
88
|
+
nested = schema[:properties] || {}
|
|
89
|
+
return false unless nested.keys.map(&:to_s).sort == Array(schema[:required]).map(&:to_s).sort
|
|
90
|
+
|
|
91
|
+
nested.values.all? { |property| strict_property?(property) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def strict_property?(property)
|
|
95
|
+
return false if STRICT_UNSUPPORTED_KEYWORDS.any? { |keyword| property.key?(keyword) }
|
|
96
|
+
|
|
97
|
+
case property[:type]
|
|
98
|
+
when "object" then strict_object?(property)
|
|
99
|
+
when "array" then strict_property?(property[:items] || {})
|
|
100
|
+
else true
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_required!(kwargs)
|
|
105
|
+
missing = required.map(&:to_sym) - kwargs.keys
|
|
106
|
+
return if missing.empty?
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "missing required argument(s): #{missing.join(', ')}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_types!(kwargs)
|
|
112
|
+
required_keys = required.map(&:to_sym)
|
|
113
|
+
kwargs.reject! { |key, value| value.nil? && !required_keys.include?(key) }
|
|
114
|
+
|
|
115
|
+
properties.each do |name, property|
|
|
116
|
+
key = name.to_sym
|
|
117
|
+
next unless kwargs.key?(key)
|
|
118
|
+
|
|
119
|
+
type_check = TYPE_CHECKS[property[:type]]
|
|
120
|
+
next if type_check.nil? || type_check.call(kwargs[key])
|
|
121
|
+
|
|
122
|
+
raise ArgumentError, "#{name} must be of type #{property[:type]}, got #{kwargs[key].inspect}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_enums!(kwargs)
|
|
127
|
+
properties.each do |name, property|
|
|
128
|
+
allowed_values = property[:enum]
|
|
129
|
+
next unless allowed_values
|
|
130
|
+
|
|
131
|
+
key = name.to_sym
|
|
132
|
+
next unless kwargs.key?(key)
|
|
133
|
+
|
|
134
|
+
unless allowed_values.include?(kwargs[key])
|
|
135
|
+
raise ArgumentError, "invalid value for #{name}: #{kwargs[key].inspect} (must be one of: #{allowed_values.join(', ')})"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_constraints!(kwargs)
|
|
141
|
+
properties.each do |name, property|
|
|
142
|
+
key = name.to_sym
|
|
143
|
+
next unless kwargs.key?(key)
|
|
144
|
+
|
|
145
|
+
check_constraints!(name, property, kwargs[key])
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def check_constraints!(name, property, value)
|
|
150
|
+
if property[:minLength] && value.is_a?(String) && value.length < property[:minLength]
|
|
151
|
+
raise ArgumentError, "#{name} must be at least #{property[:minLength]} characters"
|
|
152
|
+
end
|
|
153
|
+
if property[:maxLength] && value.is_a?(String) && value.length > property[:maxLength]
|
|
154
|
+
raise ArgumentError, "#{name} must be at most #{property[:maxLength]} characters"
|
|
155
|
+
end
|
|
156
|
+
if property[:pattern] && value.is_a?(String) && !value.match?(Regexp.new(property[:pattern]))
|
|
157
|
+
raise ArgumentError, "#{name} does not match required pattern #{property[:pattern]}"
|
|
158
|
+
end
|
|
159
|
+
if property[:minimum] && value.is_a?(Numeric) && value < property[:minimum]
|
|
160
|
+
raise ArgumentError, "#{name} must be >= #{property[:minimum]}"
|
|
161
|
+
end
|
|
162
|
+
if property[:maximum] && value.is_a?(Numeric) && value > property[:maximum]
|
|
163
|
+
raise ArgumentError, "#{name} must be <= #{property[:maximum]}"
|
|
164
|
+
end
|
|
165
|
+
if property[:minItems] && value.is_a?(Array) && value.length < property[:minItems]
|
|
166
|
+
raise ArgumentError, "#{name} must have at least #{property[:minItems]} items"
|
|
167
|
+
end
|
|
168
|
+
if property[:maxItems] && value.is_a?(Array) && value.length > property[:maxItems]
|
|
169
|
+
raise ArgumentError, "#{name} must have at most #{property[:maxItems]} items"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def run_custom_validators!(kwargs)
|
|
174
|
+
validators.each do |name, validator|
|
|
175
|
+
key = name.to_sym
|
|
176
|
+
next unless kwargs.key?(key)
|
|
177
|
+
|
|
178
|
+
result = validator.call(kwargs[key])
|
|
179
|
+
raise ArgumentError, "invalid value for #{name}" if result == false
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def resolve_polymorphics!(kwargs)
|
|
184
|
+
polymorphics.each do |name, group|
|
|
185
|
+
next unless group[:resolve]
|
|
186
|
+
|
|
187
|
+
type_field = group[:type_field]
|
|
188
|
+
id_field = group[:id_field]
|
|
189
|
+
next unless kwargs.key?(type_field) && kwargs.key?(id_field)
|
|
190
|
+
|
|
191
|
+
type_value = kwargs.delete(type_field)
|
|
192
|
+
id_value = kwargs.delete(id_field)
|
|
193
|
+
|
|
194
|
+
klass = Object.const_get(type_value.to_s)
|
|
195
|
+
record = klass.find(id_value)
|
|
196
|
+
kwargs[name.to_sym] = record
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
# Runs a semantic search on anything `Agent.searches` accepts: a model or
|
|
3
|
+
# relation with `has_embedding` (or its own `semantic_search(query, limit:)`),
|
|
4
|
+
# or a meilisearch-rails model, searched with one of its index embedders.
|
|
5
|
+
module SemanticSearch
|
|
6
|
+
def self.searchable?(target)
|
|
7
|
+
target.respond_to?(:semantic_search) || meilisearch?(target)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.call(target, query, limit:, embedder: "default")
|
|
11
|
+
return target.semantic_search(query, limit: limit) if target.respond_to?(:semantic_search)
|
|
12
|
+
return meilisearch(target.all, query, limit: limit, embedder: embedder) if meilisearch?(target)
|
|
13
|
+
|
|
14
|
+
raise ArgumentError, "#{target.inspect} can't be searched"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.meilisearch?(target)
|
|
18
|
+
model = target.is_a?(Class) ? target : target.try(:klass)
|
|
19
|
+
defined?(::Meilisearch::Rails) && model.is_a?(Class) && model < ::Meilisearch::Rails
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Equality conditions of the relation become Meilisearch filters (those
|
|
23
|
+
# attributes must be filterable_attributes). Hits are then checked
|
|
24
|
+
# against the full relation, so no condition is ever silently skipped.
|
|
25
|
+
def self.meilisearch(relation, query, limit:, embedder:)
|
|
26
|
+
params = { limit: limit, hybrid: { embedder: embedder } }
|
|
27
|
+
filter = relation.where_values_hash.map { |attribute, value| meilisearch_condition(attribute, value) }
|
|
28
|
+
params[:filter] = filter if filter.any?
|
|
29
|
+
|
|
30
|
+
hits = relation.klass.ms_search(query, params).first(limit)
|
|
31
|
+
allowed = relation.where(relation.klass.primary_key => hits.map(&:id)).pluck(relation.klass.primary_key).to_set
|
|
32
|
+
hits.select { |hit| allowed.include?(hit.id) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.meilisearch_condition(attribute, value)
|
|
36
|
+
case value
|
|
37
|
+
when nil then "#{attribute} IS NULL"
|
|
38
|
+
when Array then "#{attribute} IN [#{value.map(&:to_json).join(', ')}]"
|
|
39
|
+
else "#{attribute} = #{value.to_json}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private_class_method :meilisearch, :meilisearch_condition
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
# Base class for stock agents: ready-made agents shipped inside the gem under
|
|
3
|
+
# OmniAgent::StockAgents. Host apps use them as-is or subclass them to declare
|
|
4
|
+
# their own `output` schema, extra prompt instructions, tools or model.
|
|
5
|
+
#
|
|
6
|
+
# module OmniAgent::StockAgents
|
|
7
|
+
# class Summarizer < OmniAgent::StockAgent
|
|
8
|
+
# description "Summarizes documents"
|
|
9
|
+
# use_model "gpt-4o-mini"
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Prompts are read from lib/omni_agent/stock_agents/<stock_name>/ (override
|
|
14
|
+
# with `prompts_path`) and rendered before any prompts the host app defines
|
|
15
|
+
# for its subclass in app/agents/<agent_name>/.
|
|
16
|
+
class StockAgent < Agent
|
|
17
|
+
class << self
|
|
18
|
+
def description(text = nil)
|
|
19
|
+
@description = text if text
|
|
20
|
+
@description || inherited_setting(:description) || "No description provided."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def stock_name(value = nil)
|
|
24
|
+
@stock_name = value.to_sym if value
|
|
25
|
+
@stock_name || (stock_definition? ? default_stock_name : inherited_setting(:stock_name))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def prompts_path(path = nil)
|
|
29
|
+
@prompts_path = path.to_s if path
|
|
30
|
+
@prompts_path || (stock_definition? ? File.join(OmniAgent::StockAgents.root, stock_name.to_s) : inherited_setting(:prompts_path))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# True for the agents defined by the gem itself, as opposed to host-app
|
|
36
|
+
# subclasses, which inherit the stock name and prompts of their parent.
|
|
37
|
+
def stock_definition?
|
|
38
|
+
name.to_s.start_with?("OmniAgent::StockAgents::")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def default_stock_name
|
|
42
|
+
name.split("::").last
|
|
43
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
|
|
44
|
+
.gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
|
|
45
|
+
.downcase
|
|
46
|
+
.to_sym
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def prompt_template_paths(prompt_method)
|
|
53
|
+
stock_directory = self.class.prompts_path
|
|
54
|
+
stock_templates = stock_directory ? prompt_templates_in(stock_directory, prompt_method) : []
|
|
55
|
+
|
|
56
|
+
stock_templates + super
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
You read documents from images and PDFs and transcribe them exactly.
|
|
2
|
+
|
|
3
|
+
- When you receive several images or files, they are pages of the same document, in the order given.
|
|
4
|
+
- Transcribe only what is visible. Never guess or invent text, numbers or dates; leave the field empty when it is illegible or missing.
|
|
5
|
+
- Keep numbers, currency symbols, dates and identifiers exactly as written.
|
|
6
|
+
- Any text sent alongside the images is an instruction from the user about what to extract.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module OmniAgent
|
|
5
|
+
module StockAgents
|
|
6
|
+
# Reads photos, scans and PDFs with a vision model. `run` takes one source
|
|
7
|
+
# or an array of them (pages of the same document, in order):
|
|
8
|
+
#
|
|
9
|
+
# OCR.new.run(Pathname("receipt.jpg"))
|
|
10
|
+
# OCR.new.run([ Pathname("page1.png"), params[:page2], record.scan ])
|
|
11
|
+
# OCR.new.run([ "Only the totals table matters", URI("https://example.com/scan.png") ])
|
|
12
|
+
#
|
|
13
|
+
# Sources: Pathname, IO-like objects (File, StringIO, uploaded files),
|
|
14
|
+
# ActiveStorage attachments/blobs, and http(s) URIs for images. Strings are
|
|
15
|
+
# always instructions, never file paths, so user text can't read server files.
|
|
16
|
+
class OCR < OmniAgent::StockAgent
|
|
17
|
+
description "Extracts text and structured data from photos, scans and PDFs"
|
|
18
|
+
use_model "gpt-4o"
|
|
19
|
+
|
|
20
|
+
SIGNATURES = {
|
|
21
|
+
"image/png" => "\x89PNG".b,
|
|
22
|
+
"image/jpeg" => "\xFF\xD8\xFF".b,
|
|
23
|
+
"image/gif" => "GIF8".b,
|
|
24
|
+
"application/pdf" => "%PDF".b
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Default structured output. Host apps override it by subclassing
|
|
28
|
+
# and declaring their own `output do ... end` block.
|
|
29
|
+
output do
|
|
30
|
+
string :text, description: "Full text of the document, in reading order"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_messages(input:, history:)
|
|
36
|
+
# [input].flatten, not Array(input): Array() splits an IO into its lines.
|
|
37
|
+
super(input: input.is_a?(String) ? input : [ input ].flatten.map { |source| content_part(source) }, history: history)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def content_part(source)
|
|
41
|
+
case source
|
|
42
|
+
when String then { type: "text", text: source }
|
|
43
|
+
when URI::HTTP then { type: "image_url", image_url: { url: source.to_s } }
|
|
44
|
+
when Pathname then file_part(source.binread)
|
|
45
|
+
else
|
|
46
|
+
if source.respond_to?(:download) then file_part(source.download)
|
|
47
|
+
elsif source.respond_to?(:read) then file_part(source.read.tap { source.rewind if source.respond_to?(:rewind) })
|
|
48
|
+
else raise ArgumentError, "#{self.class.name} can't read a #{source.class}: pass a Pathname, IO, ActiveStorage attachment or URI"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def file_part(bytes)
|
|
54
|
+
type = media_type(bytes)
|
|
55
|
+
url = "data:#{type};base64,#{[ bytes ].pack('m0')}"
|
|
56
|
+
return { type: "file", file: { filename: "document.pdf", file_data: url } } if type == "application/pdf"
|
|
57
|
+
|
|
58
|
+
{ type: "image_url", image_url: { url: url } }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def media_type(bytes)
|
|
62
|
+
bytes = bytes.b
|
|
63
|
+
return "image/webp" if bytes.start_with?("RIFF") && bytes[8, 4] == "WEBP"
|
|
64
|
+
|
|
65
|
+
SIGNATURES.find { |_type, magic| bytes.start_with?(magic) }&.first ||
|
|
66
|
+
raise(OmniAgent::Error, "#{self.class.name} reads PNG, JPEG, GIF, WEBP and PDF files only")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
# Namespace for the stock (default) agents shipped with the gem. Each one is
|
|
3
|
+
# an OmniAgent::StockAgent subclass in lib/omni_agent/stock_agents/<name>.rb,
|
|
4
|
+
# with its prompt templates in lib/omni_agent/stock_agents/<name>/.
|
|
5
|
+
module StockAgents
|
|
6
|
+
def self.root
|
|
7
|
+
File.expand_path("stock_agents", __dir__)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.all
|
|
11
|
+
constants.sort.filter_map do |const_name|
|
|
12
|
+
const = const_get(const_name)
|
|
13
|
+
const if const.is_a?(Class) && const < OmniAgent::StockAgent
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.find(name)
|
|
18
|
+
agent_class = all.find { |stock_agent| stock_agent.stock_name == name.to_sym }
|
|
19
|
+
return agent_class if agent_class
|
|
20
|
+
|
|
21
|
+
available = all.map(&:stock_name).join(", ")
|
|
22
|
+
raise OmniAgent::UnknownStockAgentError,
|
|
23
|
+
"Unknown stock agent #{name.inspect}. Available stock agents: #{available.empty? ? 'none' : available}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/omni_agent/tool.rb
CHANGED
|
@@ -47,38 +47,19 @@ module OmniAgent
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def input(&block)
|
|
50
|
-
if block_given?
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@required = builder.required_fields
|
|
56
|
-
@validators = builder.validators
|
|
57
|
-
@polymorphics = builder.polymorphics
|
|
58
|
-
end
|
|
50
|
+
@input_schema = Schema.build(&block) if block_given?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def input_schema
|
|
54
|
+
@input_schema || Schema.new
|
|
59
55
|
end
|
|
60
56
|
|
|
61
57
|
def json_schema
|
|
62
|
-
|
|
63
|
-
type: "object",
|
|
64
|
-
properties: @properties || {},
|
|
65
|
-
required: @required || [],
|
|
66
|
-
additionalProperties: false
|
|
67
|
-
}
|
|
58
|
+
input_schema.json_schema
|
|
68
59
|
end
|
|
69
60
|
|
|
70
61
|
def parse_arguments(arguments_hash)
|
|
71
|
-
|
|
72
|
-
valid_keys = (@properties || {}).keys.map(&:to_sym)
|
|
73
|
-
filtered = kwargs.slice(*valid_keys)
|
|
74
|
-
|
|
75
|
-
validate_required!(filtered)
|
|
76
|
-
validate_enums!(filtered)
|
|
77
|
-
validate_constraints!(filtered)
|
|
78
|
-
run_custom_validators!(filtered)
|
|
79
|
-
resolve_polymorphics!(filtered)
|
|
80
|
-
|
|
81
|
-
filtered
|
|
62
|
+
input_schema.parse(arguments_hash)
|
|
82
63
|
end
|
|
83
64
|
|
|
84
65
|
def stops_generation(value = true)
|
|
@@ -91,87 +72,6 @@ module OmniAgent
|
|
|
91
72
|
|
|
92
73
|
private
|
|
93
74
|
|
|
94
|
-
def validate_required!(kwargs)
|
|
95
|
-
missing = (@required || []).map(&:to_sym) - kwargs.keys
|
|
96
|
-
return if missing.empty?
|
|
97
|
-
|
|
98
|
-
raise ArgumentError, "missing required argument(s): #{missing.join(', ')}"
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def validate_enums!(kwargs)
|
|
102
|
-
(@properties || {}).each do |name, property|
|
|
103
|
-
allowed_values = property[:enum]
|
|
104
|
-
next unless allowed_values
|
|
105
|
-
|
|
106
|
-
key = name.to_sym
|
|
107
|
-
next unless kwargs.key?(key)
|
|
108
|
-
|
|
109
|
-
unless allowed_values.include?(kwargs[key])
|
|
110
|
-
raise ArgumentError, "invalid value for #{name}: #{kwargs[key].inspect} (must be one of: #{allowed_values.join(', ')})"
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def validate_constraints!(kwargs)
|
|
116
|
-
(@properties || {}).each do |name, property|
|
|
117
|
-
key = name.to_sym
|
|
118
|
-
next unless kwargs.key?(key)
|
|
119
|
-
|
|
120
|
-
check_constraints!(name, property, kwargs[key])
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def check_constraints!(name, property, value)
|
|
125
|
-
if property[:minLength] && value.is_a?(String) && value.length < property[:minLength]
|
|
126
|
-
raise ArgumentError, "#{name} must be at least #{property[:minLength]} characters"
|
|
127
|
-
end
|
|
128
|
-
if property[:maxLength] && value.is_a?(String) && value.length > property[:maxLength]
|
|
129
|
-
raise ArgumentError, "#{name} must be at most #{property[:maxLength]} characters"
|
|
130
|
-
end
|
|
131
|
-
if property[:pattern] && value.is_a?(String) && !value.match?(Regexp.new(property[:pattern]))
|
|
132
|
-
raise ArgumentError, "#{name} does not match required pattern #{property[:pattern]}"
|
|
133
|
-
end
|
|
134
|
-
if property[:minimum] && value.is_a?(Numeric) && value < property[:minimum]
|
|
135
|
-
raise ArgumentError, "#{name} must be >= #{property[:minimum]}"
|
|
136
|
-
end
|
|
137
|
-
if property[:maximum] && value.is_a?(Numeric) && value > property[:maximum]
|
|
138
|
-
raise ArgumentError, "#{name} must be <= #{property[:maximum]}"
|
|
139
|
-
end
|
|
140
|
-
if property[:minItems] && value.is_a?(Array) && value.length < property[:minItems]
|
|
141
|
-
raise ArgumentError, "#{name} must have at least #{property[:minItems]} items"
|
|
142
|
-
end
|
|
143
|
-
if property[:maxItems] && value.is_a?(Array) && value.length > property[:maxItems]
|
|
144
|
-
raise ArgumentError, "#{name} must have at most #{property[:maxItems]} items"
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def run_custom_validators!(kwargs)
|
|
149
|
-
(@validators || {}).each do |name, validator|
|
|
150
|
-
key = name.to_sym
|
|
151
|
-
next unless kwargs.key?(key)
|
|
152
|
-
|
|
153
|
-
result = validator.call(kwargs[key])
|
|
154
|
-
raise ArgumentError, "invalid value for #{name}" if result == false
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def resolve_polymorphics!(kwargs)
|
|
159
|
-
(@polymorphics || {}).each do |name, group|
|
|
160
|
-
next unless group[:resolve]
|
|
161
|
-
|
|
162
|
-
type_field = group[:type_field]
|
|
163
|
-
id_field = group[:id_field]
|
|
164
|
-
next unless kwargs.key?(type_field) && kwargs.key?(id_field)
|
|
165
|
-
|
|
166
|
-
type_value = kwargs.delete(type_field)
|
|
167
|
-
id_value = kwargs.delete(id_field)
|
|
168
|
-
|
|
169
|
-
klass = Object.const_get(type_value.to_s)
|
|
170
|
-
record = klass.find(id_value)
|
|
171
|
-
kwargs[name.to_sym] = record
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
|
|
175
75
|
def normalize_tags(tag_names)
|
|
176
76
|
raise ArgumentError, "tags requires at least one tag" if tag_names.empty?
|
|
177
77
|
|
data/lib/omni_agent/version.rb
CHANGED
data/lib/omni_agent.rb
CHANGED
|
@@ -5,7 +5,8 @@ require "zeitwerk"
|
|
|
5
5
|
loader = Zeitwerk::Loader.for_gem
|
|
6
6
|
loader.inflector.inflect(
|
|
7
7
|
"openai" => "OpenAI", "cli" => "CLI", "mcp" => "MCP",
|
|
8
|
-
"oauth" => "OAuth", "jwt_verifier" => "JWTVerifier", "http_client" => "HTTPClient"
|
|
8
|
+
"oauth" => "OAuth", "jwt_verifier" => "JWTVerifier", "http_client" => "HTTPClient",
|
|
9
|
+
"pdf" => "PDF", "html" => "HTML", "ocr" => "OCR"
|
|
9
10
|
)
|
|
10
11
|
loader.ignore(File.expand_path("generators", __dir__))
|
|
11
12
|
loader.setup
|
|
@@ -26,5 +27,15 @@ module OmniAgent
|
|
|
26
27
|
def configure
|
|
27
28
|
yield(configuration)
|
|
28
29
|
end
|
|
30
|
+
|
|
31
|
+
# Embeds a String into one vector, or an Array of Strings into one vector
|
|
32
|
+
# per text. Index and query with the same provider and model, or the
|
|
33
|
+
# vectors are not comparable.
|
|
34
|
+
def embed(input, provider: nil, model: nil)
|
|
35
|
+
provider_name = provider || configuration.embedding_provider || configuration.default_provider
|
|
36
|
+
vectors = Providers.fetch(provider_name).new.embed(Array(input), model: model || configuration.embedding_model)
|
|
37
|
+
|
|
38
|
+
input.is_a?(Array) ? vectors : vectors.first
|
|
39
|
+
end
|
|
29
40
|
end
|
|
30
41
|
end
|