maglev-rb 0.1.1 → 0.2.1
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 +46 -0
- data/README.ja.md +618 -0
- data/README.md +533 -248
- data/README.zh-CN.md +457 -250
- data/lib/generators/maglev/install/install_generator.rb +20 -0
- data/lib/generators/maglev/upgrade_index_version/upgrade_index_version_generator.rb +27 -0
- data/lib/generators/maglev/upgrade_source_identity/upgrade_source_identity_generator.rb +52 -0
- data/lib/maglev/active_record_extension.rb +141 -24
- data/lib/maglev/adapters/faraday_client.rb +94 -0
- data/lib/maglev/adapters/faraday_embedding.rb +51 -0
- data/lib/maglev/adapters/faraday_generation.rb +49 -0
- data/lib/maglev/adapters/faraday_planner.rb +88 -0
- data/lib/maglev/answerer.rb +30 -12
- data/lib/maglev/chunker.rb +39 -4
- data/lib/maglev/configuration.rb +66 -1
- data/lib/maglev/content_source_graph.rb +17 -11
- data/lib/maglev/dependency_graph.rb +72 -13
- data/lib/maglev/embedding_adapter.rb +10 -0
- data/lib/maglev/hybrid_candidate_set.rb +25 -0
- data/lib/maglev/hybrid_coordinator.rb +112 -0
- data/lib/maglev/hybrid_result.rb +25 -0
- data/lib/maglev/index_diagnostics.rb +83 -0
- data/lib/maglev/index_identity.rb +70 -0
- data/lib/maglev/index_state.rb +9 -0
- data/lib/maglev/indexer.rb +185 -35
- data/lib/maglev/knowledge_config.rb +27 -5
- data/lib/maglev/knowledge_registry.rb +33 -0
- data/lib/maglev/planner.rb +172 -0
- data/lib/maglev/planner_adapter.rb +25 -0
- data/lib/maglev/planner_evaluation.rb +49 -0
- data/lib/maglev/query_compiler.rb +197 -0
- data/lib/maglev/query_ir.rb +143 -0
- data/lib/maglev/query_validator.rb +311 -0
- data/lib/maglev/railtie.rb +9 -0
- data/lib/maglev/registry.rb +72 -0
- data/lib/maglev/reindex_job.rb +34 -2
- data/lib/maglev/relation_order.rb +16 -0
- data/lib/maglev/request.rb +22 -0
- data/lib/maglev/request_executor.rb +101 -0
- data/lib/maglev/resource_config.rb +222 -0
- data/lib/maglev/response.rb +2 -2
- data/lib/maglev/result.rb +30 -0
- data/lib/maglev/retrieval_outcome.rb +52 -0
- data/lib/maglev/retrieval_result.rb +25 -0
- data/lib/maglev/retriever.rb +282 -27
- data/lib/maglev/router.rb +77 -0
- data/lib/maglev/routing_adapter.rb +25 -0
- data/lib/maglev/schema_compiler.rb +17 -4
- data/lib/maglev/schema_snapshot.rb +159 -0
- data/lib/maglev/search_result.rb +7 -3
- data/lib/maglev/snapshot.rb +21 -1
- data/lib/maglev/snapshot_budget.rb +57 -0
- data/lib/maglev/snapshot_builder.rb +89 -11
- data/lib/maglev/source_extractor.rb +43 -0
- data/lib/maglev/source_fragment.rb +9 -0
- data/lib/maglev/structured_answer_composer.rb +67 -0
- data/lib/maglev/structured_evidence_builder.rb +56 -0
- data/lib/maglev/structured_executor.rb +157 -0
- data/lib/maglev/structured_result.rb +97 -0
- data/lib/maglev/trace.rb +56 -0
- data/lib/maglev/vector_stores/base.rb +12 -0
- data/lib/maglev/vector_stores/document.rb +14 -5
- data/lib/maglev/vector_stores/document_id.rb +27 -0
- data/lib/maglev/vector_stores/memory.rb +68 -6
- data/lib/maglev/vector_stores/metadata_filter.rb +55 -0
- data/lib/maglev/vector_stores/pgvector.rb +94 -7
- data/lib/maglev/version.rb +1 -1
- data/lib/maglev-rb.rb +3 -0
- data/lib/maglev.rb +36 -3
- data/lib/tasks/maglev.rake +43 -0
- metadata +71 -11
- data/lib/maglev/adapters/ruby_llm_attachment_extractor.rb +0 -15
- data/lib/maglev/adapters/ruby_llm_embedding.rb +0 -22
- data/lib/maglev/adapters/ruby_llm_generation.rb +0 -22
- data/lib/maglev/adapters/ruby_llm_provider.rb +0 -64
data/lib/maglev/search_result.rb
CHANGED
|
@@ -2,21 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
module Maglev
|
|
4
4
|
class SearchResult
|
|
5
|
-
attr_reader :owner, :content, :source, :distance, :chunk_index
|
|
5
|
+
attr_reader :owner, :content, :source, :distance, :chunk_index, :source_identity, :source_type, :score
|
|
6
6
|
|
|
7
|
-
def initialize(owner:, content:, source:, distance:, chunk_index: nil)
|
|
7
|
+
def initialize(owner:, content:, source:, distance:, chunk_index: nil, source_identity: nil, source_type: nil, score: nil)
|
|
8
8
|
@owner = owner
|
|
9
9
|
@content = content
|
|
10
10
|
@source = source
|
|
11
11
|
@distance = distance
|
|
12
12
|
@chunk_index = chunk_index
|
|
13
|
+
@source_identity = source_identity || source
|
|
14
|
+
@source_type = (source_type || :snapshot).to_sym
|
|
15
|
+
@score = score
|
|
13
16
|
freeze
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def similarity
|
|
20
|
+
return score.clamp(0.0, 1.0) unless score.nil?
|
|
17
21
|
return nil if distance.nil?
|
|
18
22
|
|
|
19
|
-
1.0 - distance.to_f
|
|
23
|
+
(1.0 - distance.to_f).clamp(0.0, 1.0)
|
|
20
24
|
end
|
|
21
25
|
end
|
|
22
26
|
end
|
data/lib/maglev/snapshot.rb
CHANGED
|
@@ -2,13 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
module Maglev
|
|
4
4
|
class Snapshot
|
|
5
|
-
|
|
5
|
+
attr_reader :metadata
|
|
6
|
+
|
|
7
|
+
def initialize(lines, metadata: {})
|
|
6
8
|
@text = lines.join("\n").freeze
|
|
9
|
+
@metadata = deep_freeze(metadata)
|
|
7
10
|
freeze
|
|
8
11
|
end
|
|
9
12
|
|
|
10
13
|
def to_s
|
|
11
14
|
@text
|
|
12
15
|
end
|
|
16
|
+
|
|
17
|
+
def truncated?
|
|
18
|
+
@metadata[:truncated] == true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def deep_freeze(value)
|
|
24
|
+
case value
|
|
25
|
+
when Hash
|
|
26
|
+
value.to_h { |key, item| [key, deep_freeze(item)] }.freeze
|
|
27
|
+
when Array
|
|
28
|
+
value.map { |item| deep_freeze(item) }.freeze
|
|
29
|
+
else
|
|
30
|
+
value.freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
13
33
|
end
|
|
14
34
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maglev
|
|
4
|
+
class SnapshotBudget
|
|
5
|
+
LIMIT_METHODS = {
|
|
6
|
+
attribute: :snapshot_attribute_max_characters,
|
|
7
|
+
rich_text: :snapshot_attribute_max_characters,
|
|
8
|
+
attachment: :snapshot_attribute_max_characters,
|
|
9
|
+
related_record: :snapshot_related_record_max_characters,
|
|
10
|
+
whole_snapshot: :snapshot_max_characters
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(configuration: Maglev.configuration)
|
|
14
|
+
@limits = {
|
|
15
|
+
attribute_characters: configuration.snapshot_attribute_max_characters,
|
|
16
|
+
related_record_characters: configuration.snapshot_related_record_max_characters,
|
|
17
|
+
snapshot_characters: configuration.snapshot_max_characters,
|
|
18
|
+
chunks: configuration.snapshot_max_chunks
|
|
19
|
+
}.freeze
|
|
20
|
+
@configuration = configuration
|
|
21
|
+
@sources = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def truncate(text, kind:, path:)
|
|
25
|
+
value = text.to_s
|
|
26
|
+
limit = @configuration.public_send(LIMIT_METHODS.fetch(kind))
|
|
27
|
+
return value if value.length <= limit
|
|
28
|
+
|
|
29
|
+
@sources << {
|
|
30
|
+
kind: kind,
|
|
31
|
+
path: path,
|
|
32
|
+
original_characters: value.length,
|
|
33
|
+
retained_characters: limit
|
|
34
|
+
}.freeze
|
|
35
|
+
value[0, limit]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def record_chunk_truncation(original:, retained:, path: "snapshot.chunks")
|
|
39
|
+
return if original <= retained
|
|
40
|
+
|
|
41
|
+
@sources << {
|
|
42
|
+
kind: :chunks,
|
|
43
|
+
path: path,
|
|
44
|
+
original_chunks: original,
|
|
45
|
+
retained_chunks: retained
|
|
46
|
+
}.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def metadata
|
|
50
|
+
{
|
|
51
|
+
truncated: @sources.any?,
|
|
52
|
+
limits: @limits,
|
|
53
|
+
sources: @sources.dup.freeze
|
|
54
|
+
}.freeze
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -3,24 +3,30 @@
|
|
|
3
3
|
require "cgi"
|
|
4
4
|
|
|
5
5
|
require_relative "attachment_extractor"
|
|
6
|
+
require_relative "relation_order"
|
|
6
7
|
require_relative "snapshot"
|
|
8
|
+
require_relative "snapshot_budget"
|
|
7
9
|
|
|
8
10
|
module Maglev
|
|
9
11
|
class SnapshotBuilder
|
|
10
|
-
def initialize(record, config, path: nil, visited: nil, attachment_extractor: nil, remaining_depth: Maglev.configuration.max_relation_depth)
|
|
12
|
+
def initialize(record, config, path: nil, visited: nil, attachment_extractor: nil, remaining_depth: Maglev.configuration.max_relation_depth, budget: nil)
|
|
11
13
|
@record = record
|
|
12
14
|
@config = config
|
|
13
15
|
@path = path
|
|
14
16
|
@visited = visited || {}
|
|
15
17
|
@attachment_extractor = attachment_extractor || Maglev.configuration.attachment_extractor || AttachmentExtractor.new
|
|
16
18
|
@remaining_depth = remaining_depth
|
|
19
|
+
@budget = budget || SnapshotBudget.new
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
def build
|
|
20
23
|
return Snapshot.new([]) if visited?
|
|
21
24
|
|
|
22
25
|
mark_visited
|
|
23
|
-
|
|
26
|
+
raw_lines = lines
|
|
27
|
+
text = raw_lines.join("\n")
|
|
28
|
+
text = @budget.truncate(text, kind: :whole_snapshot, path: "snapshot") unless @path
|
|
29
|
+
Snapshot.new(text.lines(chomp: true), metadata: @budget.metadata)
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
private
|
|
@@ -49,7 +55,10 @@ module Maglev
|
|
|
49
55
|
def attribute_lines
|
|
50
56
|
@config.exposed_attributes.filter_map do |attribute|
|
|
51
57
|
value = @record.public_send(attribute)
|
|
52
|
-
|
|
58
|
+
next if value.nil?
|
|
59
|
+
|
|
60
|
+
text = @budget.truncate(value, kind: :attribute, path: attribute_path(attribute))
|
|
61
|
+
"#{attribute_path(attribute)}: #{text}"
|
|
53
62
|
end
|
|
54
63
|
end
|
|
55
64
|
|
|
@@ -67,14 +76,16 @@ module Maglev
|
|
|
67
76
|
relation_records, collection = relation_records(relation)
|
|
68
77
|
relation_records.each_with_index.flat_map do |related_record, index|
|
|
69
78
|
relation_path = path_for(relation, index, collection: collection)
|
|
70
|
-
SnapshotBuilder.new(
|
|
79
|
+
child = SnapshotBuilder.new(
|
|
71
80
|
related_record,
|
|
72
81
|
related_record.class.maglev_config,
|
|
73
82
|
path: relation_path,
|
|
74
83
|
visited: @visited,
|
|
75
84
|
attachment_extractor: @attachment_extractor,
|
|
76
|
-
remaining_depth: remaining_depth
|
|
77
|
-
|
|
85
|
+
remaining_depth: remaining_depth,
|
|
86
|
+
budget: @budget
|
|
87
|
+
).build.to_s
|
|
88
|
+
@budget.truncate(child, kind: :related_record, path: relation_path).split("\n")
|
|
78
89
|
end
|
|
79
90
|
end
|
|
80
91
|
end
|
|
@@ -84,7 +95,9 @@ module Maglev
|
|
|
84
95
|
attachment_blobs(source.name).flat_map do |blob|
|
|
85
96
|
document = extract_attachment(source.name, blob)
|
|
86
97
|
if document.extracted?
|
|
87
|
-
|
|
98
|
+
path = document.source_identifier
|
|
99
|
+
text = @budget.truncate(document.text, kind: :attachment, path: path)
|
|
100
|
+
["#{path}.text: #{text}"]
|
|
88
101
|
else
|
|
89
102
|
["#{document.source_identifier}.skipped: #{document.metadata[:reason]}"]
|
|
90
103
|
end
|
|
@@ -125,7 +138,9 @@ module Maglev
|
|
|
125
138
|
def rich_text_lines
|
|
126
139
|
@config.rich_text_sources.filter_map do |source|
|
|
127
140
|
text = rich_text_value(source.name)
|
|
128
|
-
|
|
141
|
+
path = rich_text_identifier(source.name)
|
|
142
|
+
text = @budget.truncate(text, kind: :rich_text, path: path) unless text.nil?
|
|
143
|
+
"#{path}.text: #{text}" unless text.nil? || text.empty?
|
|
129
144
|
end
|
|
130
145
|
end
|
|
131
146
|
|
|
@@ -169,10 +184,12 @@ module Maglev
|
|
|
169
184
|
value = @record.public_send(relation.name)
|
|
170
185
|
records, collection = if value.nil?
|
|
171
186
|
[[], false]
|
|
187
|
+
elsif loaded_collection_proxy?(value)
|
|
188
|
+
[relation.order ? ordered_array(value.target, relation) : ordered_loaded_records(value), true]
|
|
189
|
+
elsif active_record_relation?(value)
|
|
190
|
+
[ordered_relation(value, relation).limit(relation.limit).to_a, true]
|
|
172
191
|
elsif value.respond_to?(:to_ary)
|
|
173
|
-
[value.to_ary, true]
|
|
174
|
-
elsif value.respond_to?(:limit)
|
|
175
|
-
[value.limit(relation.limit).to_a, true]
|
|
192
|
+
[ordered_array(value.to_ary, relation), true]
|
|
176
193
|
else
|
|
177
194
|
[[value], false]
|
|
178
195
|
end
|
|
@@ -180,6 +197,67 @@ module Maglev
|
|
|
180
197
|
[records.first(relation.limit), collection]
|
|
181
198
|
end
|
|
182
199
|
|
|
200
|
+
def active_record_relation?(value)
|
|
201
|
+
(defined?(ActiveRecord::Relation) && value.is_a?(ActiveRecord::Relation)) ||
|
|
202
|
+
(defined?(ActiveRecord::Associations::CollectionProxy) && value.is_a?(ActiveRecord::Associations::CollectionProxy))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def loaded_collection_proxy?(value)
|
|
206
|
+
defined?(ActiveRecord::Associations::CollectionProxy) &&
|
|
207
|
+
value.is_a?(ActiveRecord::Associations::CollectionProxy) && value.loaded?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def ordered_relation(value, relation)
|
|
211
|
+
if relation.order
|
|
212
|
+
order = effective_order(relation.order, value.klass)
|
|
213
|
+
return value.reorder(order)
|
|
214
|
+
end
|
|
215
|
+
return value unless value.order_values.empty?
|
|
216
|
+
|
|
217
|
+
primary_key = value.klass.primary_key
|
|
218
|
+
primary_key ? value.order(primary_key => :asc) : value
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def effective_order(order, klass)
|
|
222
|
+
RelationOrder.with_primary_key(order, klass)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def ordered_array(records, relation)
|
|
226
|
+
return records unless relation.order
|
|
227
|
+
|
|
228
|
+
order = relation.order
|
|
229
|
+
records.sort do |left, right|
|
|
230
|
+
comparison = order.lazy.map do |attribute, direction|
|
|
231
|
+
value = compare_values(left.public_send(attribute), right.public_send(attribute))
|
|
232
|
+
(direction == :desc) ? -value : value
|
|
233
|
+
end.find { |value| !value.zero? } || 0
|
|
234
|
+
comparison.zero? ? compare_values(record_id_for(left), record_id_for(right)) : comparison
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def compare_values(left, right)
|
|
239
|
+
return 0 if left == right
|
|
240
|
+
return -1 if left.nil?
|
|
241
|
+
return 1 if right.nil?
|
|
242
|
+
|
|
243
|
+
left <=> right
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def record_id_for(record)
|
|
247
|
+
record.respond_to?(:id) ? record.id : record.object_id
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def ordered_loaded_records(value)
|
|
251
|
+
records = value.to_ary
|
|
252
|
+
return records unless value.order_values.empty?
|
|
253
|
+
|
|
254
|
+
primary_key = value.klass.primary_key
|
|
255
|
+
return records unless primary_key
|
|
256
|
+
|
|
257
|
+
persisted, unsaved = records.partition(&:persisted?)
|
|
258
|
+
persisted.sort_by { |record| record.public_send(primary_key) } + unsaved
|
|
259
|
+
end
|
|
260
|
+
|
|
183
261
|
def path_for(relation, index, collection:)
|
|
184
262
|
segment = collection ? "#{relation.name}[#{index}]" : relation.name
|
|
185
263
|
@path ? "#{@path}.#{segment}" : segment
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "source_fragment"
|
|
4
|
+
|
|
5
|
+
module Maglev
|
|
6
|
+
class SourceExtractor
|
|
7
|
+
def call(snapshot)
|
|
8
|
+
related = {}
|
|
9
|
+
snapshot.to_s.lines(chomp: true).drop(1).filter_map do |line|
|
|
10
|
+
if (match = line.match(/\A(.+\[\d+\]) ([A-Za-z0-9_:]+)#([^ ]+)\z/))
|
|
11
|
+
related[match[1]] = [match[2], match[3]]
|
|
12
|
+
next
|
|
13
|
+
end
|
|
14
|
+
next unless line.include?(": ")
|
|
15
|
+
|
|
16
|
+
path = line.split(": ", 2).first
|
|
17
|
+
identity = related_identity(path, related) || path
|
|
18
|
+
next if identity.nil? || identity.empty?
|
|
19
|
+
|
|
20
|
+
SourceFragment.new(identity: identity, type: source_type(identity), content: line)
|
|
21
|
+
end.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def related_identity(path, related)
|
|
27
|
+
prefix, record = related.find { |candidate, _identity| path.start_with?("#{candidate}.") }
|
|
28
|
+
return unless record
|
|
29
|
+
|
|
30
|
+
model, id = record
|
|
31
|
+
"related:#{model}:#{id}:#{path.delete_prefix("#{prefix}.")}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def source_type(identity)
|
|
35
|
+
return :attachment if identity.include?("[blob:")
|
|
36
|
+
return :rich_text if identity.start_with?("rich_text.")
|
|
37
|
+
return :related_record if identity.start_with?("related:") || identity.include?("[") || identity.include?(".")
|
|
38
|
+
return :tag if identity == "tags"
|
|
39
|
+
|
|
40
|
+
:attribute
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "trace"
|
|
5
|
+
|
|
6
|
+
module Maglev
|
|
7
|
+
class GroundingError < Error
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class StructuredAnswerComposer
|
|
11
|
+
def initialize(generation_adapter: nil)
|
|
12
|
+
@generation_adapter = generation_adapter
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def compose(result)
|
|
16
|
+
unless result.is_a?(StructuredResult)
|
|
17
|
+
raise GroundingError, "A structured result is required"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Trace.instrument(:composition, trace_id: result.trace_id,
|
|
21
|
+
resource: result.plan.resource, operation: result.plan.ir&.operation) do
|
|
22
|
+
(@generation_adapter && result.status == :succeeded) ? generate(result) : result.render
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def generate(result)
|
|
29
|
+
evidence = {
|
|
30
|
+
"records" => result.evidence.records,
|
|
31
|
+
"scalar" => result.evidence.scalar,
|
|
32
|
+
"filters" => result.evidence.filters,
|
|
33
|
+
"date_ranges" => result.evidence.date_ranges,
|
|
34
|
+
"count" => result.evidence.count,
|
|
35
|
+
"truncated" => result.evidence.truncated
|
|
36
|
+
}.freeze
|
|
37
|
+
output = @generation_adapter.generate(JSON.generate(evidence))
|
|
38
|
+
unless output.is_a?(Hash) && output.keys == ["claim_paths"] && output["claim_paths"].is_a?(Array) &&
|
|
39
|
+
output["claim_paths"].any?
|
|
40
|
+
raise GroundingError, "Structured generation must return claim paths"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
output.fetch("claim_paths").map { |path| render_claim(evidence, path) }.join(" ").freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def render_claim(evidence, path)
|
|
47
|
+
valid_segments = path.is_a?(Array) && path.all? { |segment| [String, Integer].any? { |type| segment.is_a?(type) } }
|
|
48
|
+
unless valid_segments && path.length.between?(1, 3)
|
|
49
|
+
raise GroundingError, "Generated claim is absent from structured evidence"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
value = path.reduce(evidence) do |current, segment|
|
|
53
|
+
if current.is_a?(Hash) && segment.is_a?(String) && current.key?(segment)
|
|
54
|
+
current.fetch(segment)
|
|
55
|
+
elsif current.is_a?(Array) && segment.is_a?(Integer) && segment.between?(0, current.length - 1)
|
|
56
|
+
current.fetch(segment)
|
|
57
|
+
else
|
|
58
|
+
raise GroundingError, "Generated claim is absent from structured evidence"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
raise GroundingError, "Generated claim must identify one scalar evidence value" if value.is_a?(Hash) || value.is_a?(Array)
|
|
62
|
+
|
|
63
|
+
label = path.last.to_s.tr("_", " ").capitalize
|
|
64
|
+
"#{label}: #{value}."
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Maglev
|
|
6
|
+
class StructuredEvidenceBuilder
|
|
7
|
+
def initialize(plan:, relation:, resource:, rows:, bytes:)
|
|
8
|
+
raise ArgumentError, "evidence rows must be positive" unless rows.is_a?(Integer) && rows.positive?
|
|
9
|
+
raise ArgumentError, "evidence bytes must be positive" unless bytes.is_a?(Integer) && bytes.positive?
|
|
10
|
+
|
|
11
|
+
@plan = plan
|
|
12
|
+
@relation = relation
|
|
13
|
+
@resource = resource
|
|
14
|
+
@rows = rows
|
|
15
|
+
@bytes = bytes
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build
|
|
19
|
+
@filters = @plan.ir.filters.map(&:to_h)
|
|
20
|
+
@date_ranges = @filters.select { |filter| filter["operator"] == "between" }
|
|
21
|
+
StructuredEvidence.new(filters: @filters,
|
|
22
|
+
date_ranges: @date_ranges,
|
|
23
|
+
loader: method(:load_records))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def load_records
|
|
29
|
+
Trace.instrument(:execution, trace_id: @plan.trace_id, resource: @plan.resource,
|
|
30
|
+
operation: @plan.ir.operation) do |payload|
|
|
31
|
+
query_limit = [@relation.limit_value || @rows + 1, @rows + 1].min
|
|
32
|
+
fields = @resource.fields.map(&:name)
|
|
33
|
+
projected = @relation.limit(query_limit).to_a.map do |record|
|
|
34
|
+
fields.to_h { |field| [field, record.public_send(field)] }
|
|
35
|
+
end
|
|
36
|
+
records = []
|
|
37
|
+
projected.first(@rows).each do |record|
|
|
38
|
+
candidate = [*records, record]
|
|
39
|
+
break if serialized_size(candidate, truncated: projected.length > candidate.length) > @bytes
|
|
40
|
+
|
|
41
|
+
records << record
|
|
42
|
+
end
|
|
43
|
+
truncated = projected.length > records.length
|
|
44
|
+
used_bytes = serialized_size(records, truncated: truncated)
|
|
45
|
+
payload[:row_count] = records.length
|
|
46
|
+
payload[:evidence_bytes] = used_bytes
|
|
47
|
+
[records, records.length, truncated]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def serialized_size(records, truncated:)
|
|
52
|
+
JSON.generate("records" => records, "scalar" => nil, "filters" => @filters,
|
|
53
|
+
"date_ranges" => @date_ranges, "count" => records.length, "truncated" => truncated).bytesize
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "query_compiler"
|
|
4
|
+
require_relative "structured_result"
|
|
5
|
+
require_relative "trace"
|
|
6
|
+
require_relative "structured_evidence_builder"
|
|
7
|
+
require "bigdecimal"
|
|
8
|
+
|
|
9
|
+
module Maglev
|
|
10
|
+
class StructuredExecutionError < Error
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class StructuredExecutor
|
|
14
|
+
DEFAULT_TIMEOUT = 5
|
|
15
|
+
|
|
16
|
+
def initialize(timeout: DEFAULT_TIMEOUT, role: nil, wrapper: nil)
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
@role = role
|
|
19
|
+
@wrapper = wrapper
|
|
20
|
+
raise ArgumentError, "timeout must be positive" unless @timeout.respond_to?(:positive?) && @timeout.positive?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def execute(plan)
|
|
24
|
+
raise StructuredExecutionError, "A structured plan is required" unless plan.is_a?(StructuredPlan)
|
|
25
|
+
return protected_relation(plan.relation) if plan.records?
|
|
26
|
+
|
|
27
|
+
execute_with_policy(plan.relation) { execute_aggregate(plan) }
|
|
28
|
+
rescue StructuredExecutionError
|
|
29
|
+
raise
|
|
30
|
+
rescue ActiveRecord::ActiveRecordError => error
|
|
31
|
+
raise StructuredExecutionError, "Structured query execution failed: #{error.class.name}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def protected_relation(relation)
|
|
37
|
+
executor = self
|
|
38
|
+
policy = Module.new do
|
|
39
|
+
define_method(:exec_queries) do |*arguments|
|
|
40
|
+
executor.send(:execute_with_policy, self) { super(*arguments) }
|
|
41
|
+
end
|
|
42
|
+
%i[calculate pluck pick exists? ids].each do |method_name|
|
|
43
|
+
define_method(method_name) do |*arguments|
|
|
44
|
+
executor.send(:execute_with_policy, self) { super(*arguments) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
define_method(:load_async) { load }
|
|
49
|
+
end
|
|
50
|
+
relation.extending(policy)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def execute_with_policy(relation, &query)
|
|
54
|
+
operation = proc { execute_read_only(relation, &query) }
|
|
55
|
+
if @wrapper
|
|
56
|
+
unwrapped_operation = operation
|
|
57
|
+
operation = proc { @wrapper.call(&unwrapped_operation) }
|
|
58
|
+
end
|
|
59
|
+
return ActiveRecord::Base.connected_to(role: @role, prevent_writes: true, &operation) if @role
|
|
60
|
+
|
|
61
|
+
ActiveRecord::Base.while_preventing_writes(&operation)
|
|
62
|
+
rescue StructuredExecutionError
|
|
63
|
+
raise
|
|
64
|
+
rescue ActiveRecord::ActiveRecordError => error
|
|
65
|
+
raise StructuredExecutionError, "Structured query execution failed: #{error.class.name}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def execute_read_only(relation)
|
|
69
|
+
connection = relation.connection
|
|
70
|
+
connection.transaction(requires_new: true) do
|
|
71
|
+
apply_database_policy(connection)
|
|
72
|
+
yield
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def apply_database_policy(connection)
|
|
77
|
+
unless connection.adapter_name.casecmp?("PostgreSQL")
|
|
78
|
+
raise StructuredExecutionError, "Structured execution requires an adapter with enforced statement timeouts"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
milliseconds = (@timeout.to_f * 1000).ceil
|
|
82
|
+
connection.execute("SET LOCAL statement_timeout = #{milliseconds}")
|
|
83
|
+
connection.execute("SET TRANSACTION READ ONLY")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def execute_aggregate(plan)
|
|
87
|
+
aggregate = plan.aggregate
|
|
88
|
+
values = if aggregate.function == :count
|
|
89
|
+
plan.relation.pluck(plan.relation.klass.arel_table[plan.relation.klass.primary_key])
|
|
90
|
+
else
|
|
91
|
+
plan.relation.pluck(plan.aggregate_column)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
case aggregate.function
|
|
95
|
+
when :count then values.length
|
|
96
|
+
when :sum then values.sum
|
|
97
|
+
when :average then values.empty? ? nil : BigDecimal(values.sum.to_s) / values.length
|
|
98
|
+
when :minimum then values.min
|
|
99
|
+
when :maximum then values.max
|
|
100
|
+
else raise StructuredExecutionError, "The aggregate is unavailable"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.structured_executor(configuration: Maglev.configuration)
|
|
106
|
+
StructuredExecutor.new(
|
|
107
|
+
timeout: configuration.structured_query_timeout,
|
|
108
|
+
role: configuration.structured_query_role,
|
|
109
|
+
wrapper: configuration.structured_query_executor_wrapper
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.execute(plan, executor: structured_executor,
|
|
114
|
+
evidence_rows: Maglev.configuration.structured_evidence_max_rows,
|
|
115
|
+
evidence_bytes: Maglev.configuration.structured_evidence_max_bytes)
|
|
116
|
+
unless plan.is_a?(Planner::Plan)
|
|
117
|
+
raise StructuredExecutionError, "A structured planner result is required"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
unless plan.ready?
|
|
121
|
+
status = (plan.status == :invalid) ? :failed : plan.status
|
|
122
|
+
return StructuredResult.new(status: status, kind: :none, warnings: plan.warnings,
|
|
123
|
+
plan: plan, trace_id: plan.trace_id)
|
|
124
|
+
end
|
|
125
|
+
unless plan.base_relation
|
|
126
|
+
raise StructuredExecutionError, "The structured plan has no authorized base relation"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
compiled = Trace.instrument(:compilation, trace_id: plan.trace_id, resource: plan.resource,
|
|
130
|
+
operation: plan.ir.operation) do
|
|
131
|
+
QueryCompiler.new(snapshot: plan.validation.snapshot).compile(
|
|
132
|
+
validation: plan.validation, base_relation: plan.base_relation
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
resource = plan.validation.snapshot.resources.find { |candidate| candidate.identifier == plan.resource }
|
|
136
|
+
filters = plan.ir.filters.map(&:to_h)
|
|
137
|
+
date_ranges = filters.select { |filter| filter["operator"] == "between" }
|
|
138
|
+
|
|
139
|
+
if compiled.records?
|
|
140
|
+
value = executor.execute(compiled)
|
|
141
|
+
evidence = StructuredEvidenceBuilder.new(plan: plan, relation: value, resource: resource,
|
|
142
|
+
rows: evidence_rows, bytes: evidence_bytes).build
|
|
143
|
+
StructuredResult.new(status: :succeeded, kind: :relation, value: value, evidence: evidence,
|
|
144
|
+
interpretation: plan.explanation, warnings: plan.warnings, plan: plan, trace_id: plan.trace_id)
|
|
145
|
+
else
|
|
146
|
+
value = Trace.instrument(:execution, trace_id: plan.trace_id, resource: plan.resource,
|
|
147
|
+
operation: plan.ir.operation) { executor.execute(compiled) }
|
|
148
|
+
scalar_size = JSON.generate("records" => [], "scalar" => value, "filters" => filters,
|
|
149
|
+
"date_ranges" => date_ranges, "count" => 1, "truncated" => false).bytesize
|
|
150
|
+
bounded_scalar = scalar_size <= evidence_bytes
|
|
151
|
+
evidence = StructuredEvidence.new(scalar: bounded_scalar ? value : nil, filters: filters,
|
|
152
|
+
date_ranges: date_ranges, count: bounded_scalar ? 1 : 0, truncated: !bounded_scalar)
|
|
153
|
+
StructuredResult.new(status: :succeeded, kind: :aggregate, value: value, evidence: evidence,
|
|
154
|
+
interpretation: plan.explanation, warnings: plan.warnings, plan: plan, trace_id: plan.trace_id)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|