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/retriever.rb
CHANGED
|
@@ -1,49 +1,248 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "adapters/
|
|
3
|
+
require_relative "adapters/faraday_embedding"
|
|
4
4
|
require_relative "authorization"
|
|
5
5
|
require_relative "chunk"
|
|
6
|
+
require_relative "index_identity"
|
|
6
7
|
require_relative "provider_call"
|
|
8
|
+
require_relative "retrieval_outcome"
|
|
9
|
+
require_relative "retrieval_result"
|
|
10
|
+
require_relative "context_assembler"
|
|
11
|
+
require "securerandom"
|
|
7
12
|
require_relative "search_result"
|
|
8
13
|
require_relative "vector_stores/pgvector"
|
|
9
14
|
|
|
10
15
|
module Maglev
|
|
11
16
|
class Retriever
|
|
12
|
-
|
|
17
|
+
OWNER_DIVERSE_OVERFETCH = 4
|
|
18
|
+
AUTHORIZED_OWNER_LIMIT = 1_000
|
|
19
|
+
IdentityConfiguration = Struct.new(
|
|
20
|
+
:embedding_model,
|
|
21
|
+
:embedding_dimensions,
|
|
22
|
+
:embedding_adapter_id,
|
|
23
|
+
:embedding_adapter_version,
|
|
24
|
+
:application_index_version
|
|
25
|
+
)
|
|
26
|
+
private_constant :IdentityConfiguration
|
|
27
|
+
|
|
28
|
+
def initialize(model_class, chunk_model: Chunk, embedding_adapter: Maglev.configuration.embedding_adapter, embedding_dimensions: Maglev.configuration.embedding_dimensions, chunk_size: Maglev.configuration.chunk_size, authorization: Authorization.new, vector_store: Maglev.configuration.vector_store)
|
|
13
29
|
@model_class = model_class
|
|
14
30
|
@chunk_model = chunk_model
|
|
15
|
-
@embedding_adapter = embedding_adapter || Adapters::
|
|
31
|
+
@embedding_adapter = embedding_adapter || Adapters::FaradayEmbedding.new
|
|
32
|
+
@embedding_dimensions = embedding_dimensions
|
|
33
|
+
@chunk_size = chunk_size
|
|
16
34
|
@authorization = authorization
|
|
17
35
|
@vector_store = vector_store
|
|
18
36
|
end
|
|
19
37
|
|
|
20
|
-
def search(query, limit:, owner: nil, user: nil)
|
|
38
|
+
def search(query, limit:, owner: nil, user: nil, minimum_similarity: nil)
|
|
39
|
+
@candidate_ids = nil
|
|
40
|
+
retrieval_outcome(
|
|
41
|
+
query,
|
|
42
|
+
limit: limit,
|
|
43
|
+
owner: owner,
|
|
44
|
+
user: user,
|
|
45
|
+
minimum_similarity: minimum_similarity,
|
|
46
|
+
chunks_per_owner: 1
|
|
47
|
+
).results
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def retrieve(query, limit:, owner: nil, user: nil, minimum_similarity: nil, chunks_per_owner: 1, trace_id: SecureRandom.uuid, candidates: nil)
|
|
51
|
+
started = monotonic
|
|
52
|
+
validate_candidates!(candidates)
|
|
53
|
+
@candidate_ids = candidates&.ids
|
|
54
|
+
outcome = retrieval_outcome(query, limit: limit, owner: owner, user: user,
|
|
55
|
+
minimum_similarity: minimum_similarity, chunks_per_owner: chunks_per_owner)
|
|
56
|
+
context_started = monotonic
|
|
57
|
+
context = ContextAssembler.new.assemble(outcome.results)
|
|
58
|
+
context_ms = elapsed_ms(context_started)
|
|
59
|
+
selected_ids = context.sources.map { |source| [source[:owner_type], source[:owner_id], source[:source], source[:chunk_index]] }
|
|
60
|
+
selected = outcome.results.select do |result|
|
|
61
|
+
selected_ids.include?([result.owner.class.name, result.owner.respond_to?(:id) ? result.owner.id : result.owner, result.source, result.chunk_index])
|
|
62
|
+
end
|
|
63
|
+
budget_rejected = outcome.results - selected
|
|
64
|
+
rejected = outcome.rejected_results.map { |result| {source: result, reason: :relevance_threshold} } +
|
|
65
|
+
budget_rejected.map { |result| {source: result, reason: :context_budget} }
|
|
66
|
+
timings = @last_timings.merge(context_assembly_ms: context_ms, total_ms: elapsed_ms(started)).freeze
|
|
67
|
+
reasons = rejected.map { |item| item[:reason] }
|
|
68
|
+
if outcome.considered.empty?
|
|
69
|
+
reasons << ((outcome.authorization_rejected_count.positive? || @authorization_filter_applied) ? :authorization_filtered : :no_documents)
|
|
70
|
+
end
|
|
71
|
+
reasons = reasons.uniq.freeze
|
|
72
|
+
payload = {trace_id: trace_id, model: @model_class.name, considered_count: outcome.considered.size,
|
|
73
|
+
selected_count: selected.size, rejected_count: rejected.size, timings: timings}.freeze
|
|
74
|
+
ActiveSupport::Notifications.instrument("maglev.retrieval.complete", payload)
|
|
75
|
+
RetrievalResult.new(query: query, considered: outcome.considered, selected: selected, rejected: rejected,
|
|
76
|
+
context: context.text, budgets: context.metadata, reasons: reasons, timings: timings, trace_id: trace_id)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def retrieval_outcome(query, limit:, owner: nil, user: nil, minimum_similarity: nil, chunks_per_owner: 1)
|
|
80
|
+
@authorization_scope_empty = false
|
|
81
|
+
@authorization_filter_applied = false
|
|
82
|
+
@request_tenant_id = user ? Maglev.configuration.tenant_id(user: user) : nil
|
|
83
|
+
threshold = resolve_threshold(minimum_similarity)
|
|
84
|
+
validate_limit!(limit)
|
|
85
|
+
validate_chunks_per_owner!(chunks_per_owner)
|
|
86
|
+
@current_index_version = current_index_version
|
|
87
|
+
embedding_started = monotonic
|
|
21
88
|
embedding = ProviderCall.new.call(operation: "embed") { @embedding_adapter.embed(query) }
|
|
22
|
-
|
|
89
|
+
embedding_ms = elapsed_ms(embedding_started)
|
|
90
|
+
validate_embedding!(embedding)
|
|
91
|
+
|
|
92
|
+
bounded_limit = candidate_limit(limit, owner: owner, chunks_per_owner: chunks_per_owner)
|
|
93
|
+
retrieval_started = monotonic
|
|
94
|
+
candidates = search_results(fetch_candidates(embedding, owner: owner, user: user, limit: bounded_limit))
|
|
95
|
+
candidate_count = candidates.size
|
|
96
|
+
candidates = authorize_results(candidates, user)
|
|
97
|
+
authorization_rejected_count = candidate_count - candidates.size
|
|
98
|
+
examined = sorted_results(candidates)
|
|
99
|
+
accepted, rejected = apply_threshold(examined, threshold)
|
|
100
|
+
projected = project_results(accepted, limit: limit, owner: owner, chunks_per_owner: chunks_per_owner)
|
|
101
|
+
@last_timings = {embedding_ms: embedding_ms, retrieval_ms: elapsed_ms(retrieval_started)}.freeze
|
|
102
|
+
|
|
103
|
+
RetrievalOutcome.new(
|
|
104
|
+
results: projected,
|
|
105
|
+
minimum_similarity: threshold,
|
|
106
|
+
examined_count: examined.size,
|
|
107
|
+
accepted_count: accepted.size,
|
|
108
|
+
rejected_count: rejected.size,
|
|
109
|
+
best_similarity: examined.filter_map(&:similarity).max,
|
|
110
|
+
considered: examined,
|
|
111
|
+
rejected_results: rejected,
|
|
112
|
+
authorization_rejected_count: authorization_rejected_count
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def validate_candidates!(candidates)
|
|
119
|
+
return unless candidates
|
|
120
|
+
unless candidates.is_a?(HybridCandidateSet) && candidates.model_class == @model_class
|
|
121
|
+
raise ConfigurationError, "hybrid candidates do not match the retrieval model"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
126
|
+
def elapsed_ms(started) = ((monotonic - started) * 1000).round(3)
|
|
127
|
+
|
|
128
|
+
def resolve_threshold(request_threshold)
|
|
129
|
+
threshold = request_threshold || Maglev.configuration.minimum_similarity
|
|
130
|
+
validate_threshold!(threshold)
|
|
131
|
+
threshold
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_threshold!(threshold)
|
|
135
|
+
return if threshold.nil?
|
|
136
|
+
|
|
137
|
+
unless threshold.is_a?(Numeric) && threshold.finite?
|
|
138
|
+
raise ArgumentError, "minimum_similarity must be a finite Numeric in 0.0..1.0, got: #{threshold.inspect}"
|
|
139
|
+
end
|
|
140
|
+
return if (0.0..1.0).cover?(threshold)
|
|
141
|
+
|
|
142
|
+
raise ArgumentError, "minimum_similarity must be in 0.0..1.0, got: #{threshold}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def apply_threshold(results, threshold)
|
|
146
|
+
return [results, []] if threshold.nil?
|
|
147
|
+
|
|
148
|
+
accepted = []
|
|
149
|
+
rejected = []
|
|
150
|
+
results.each do |result|
|
|
151
|
+
similarity = result.similarity
|
|
152
|
+
if similarity && similarity >= threshold
|
|
153
|
+
accepted << result
|
|
154
|
+
else
|
|
155
|
+
rejected << result
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
[accepted, rejected]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate_chunks_per_owner!(value)
|
|
162
|
+
return if value.is_a?(Integer) && value.positive?
|
|
163
|
+
|
|
164
|
+
raise ArgumentError, "chunks_per_owner must be a positive Integer, got: #{value.inspect}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def validate_limit!(value)
|
|
168
|
+
return if value.is_a?(Integer) && value.positive?
|
|
169
|
+
|
|
170
|
+
raise ArgumentError, "limit must be a positive Integer, got: #{value.inspect}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def fetch_candidates(embedding, owner:, user:, limit:)
|
|
174
|
+
if @vector_store
|
|
175
|
+
filters = filters_for(owner, user: user)
|
|
176
|
+
return [] if @authorization_scope_empty
|
|
23
177
|
|
|
24
|
-
|
|
178
|
+
return @vector_store.search(vector: embedding, filters: filters, limit: limit)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
scope = @chunk_model.where(owner_model_name: @model_class.name, index_version: @current_index_version)
|
|
182
|
+
scope = scope.where(owner_id: @candidate_ids) if @candidate_ids
|
|
25
183
|
scope = scope.where(owner: owner) if owner
|
|
184
|
+
tenant_id = @request_tenant_id
|
|
185
|
+
scope = scope.where(tenant_id: tenant_id) if tenant_id && @chunk_model.columns_hash.key?("tenant_id")
|
|
26
186
|
scope = apply_authorization_scope(scope, user) unless owner
|
|
27
|
-
|
|
28
|
-
|
|
187
|
+
scope.nearest_neighbors(:embedding, embedding, distance: "cosine").limit(limit)
|
|
188
|
+
end
|
|
29
189
|
|
|
30
|
-
|
|
190
|
+
def project_results(results, limit:, owner:, chunks_per_owner:)
|
|
191
|
+
return results.first(limit) if owner
|
|
192
|
+
|
|
193
|
+
groups = results.group_by { |result| owner_key(result.owner) }
|
|
194
|
+
selected_owners = groups.values.sort_by { |chunks| result_sort_key(chunks.first) }.first(limit)
|
|
195
|
+
selected_owners.flat_map { |chunks| chunks.first(chunks_per_owner) }.sort_by { |result| result_sort_key(result) }
|
|
31
196
|
end
|
|
32
197
|
|
|
33
|
-
|
|
198
|
+
def candidate_limit(limit, owner:, chunks_per_owner: 1)
|
|
199
|
+
[limit * chunks_per_owner * OWNER_DIVERSE_OVERFETCH,
|
|
200
|
+
Maglev.configuration.retrieval_max_candidates].min
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def sorted_results(results)
|
|
204
|
+
results.sort_by { |result| result_sort_key(result) }
|
|
205
|
+
end
|
|
34
206
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
unique_owner_results(documents).first(limit)
|
|
207
|
+
def result_sort_key(result)
|
|
208
|
+
[result.distance || Float::INFINITY, source_priority(result.source_type), *owner_key(result.owner).map(&:to_s), result.chunk_index || Float::INFINITY]
|
|
38
209
|
end
|
|
39
210
|
|
|
40
|
-
def
|
|
41
|
-
|
|
211
|
+
def owner_key(owner)
|
|
212
|
+
[owner.class.name, owner.respond_to?(:id) ? owner.id : owner]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def source_priority(type) = (type.to_sym == :attribute) ? 1 : 0
|
|
216
|
+
|
|
217
|
+
def filters_for(owner, user:)
|
|
218
|
+
filters = {owner_model_name: @model_class.name, index_version: @current_index_version}
|
|
42
219
|
if owner
|
|
43
220
|
filters[:owner_type] = owner.class.name
|
|
44
221
|
filters[:owner_id] = owner.id
|
|
45
222
|
end
|
|
46
|
-
filters
|
|
223
|
+
filters[:tenant_id] = @request_tenant_id
|
|
224
|
+
authorized_ids = authorized_owner_ids(user) if !owner && @authorization.configured? && user
|
|
225
|
+
owner_ids = if @candidate_ids && authorized_ids
|
|
226
|
+
@candidate_ids & authorized_ids
|
|
227
|
+
else
|
|
228
|
+
@candidate_ids || authorized_ids
|
|
229
|
+
end
|
|
230
|
+
@authorization_scope_empty = true if owner_ids && owner_ids.empty?
|
|
231
|
+
filters[:owner_ids] = owner_ids if owner_ids&.any?
|
|
232
|
+
VectorStores::MetadataFilter.new(**filters.compact)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def authorized_owner_ids(user)
|
|
236
|
+
scope = @authorization.scope(model: @model_class, user: user)
|
|
237
|
+
@authorization_filter_applied = true
|
|
238
|
+
return unless scope.respond_to?(:limit) && scope.respond_to?(:pluck)
|
|
239
|
+
|
|
240
|
+
primary_key = @model_class.respond_to?(:primary_key) ? @model_class.primary_key : :id
|
|
241
|
+
ids = scope.limit(AUTHORIZED_OWNER_LIMIT + 1).pluck(primary_key.to_sym)
|
|
242
|
+
raise ConfigurationError, "authorization scope exceeds #{AUTHORIZED_OWNER_LIMIT} owner ids for vector retrieval" if ids.size > AUTHORIZED_OWNER_LIMIT
|
|
243
|
+
|
|
244
|
+
@authorization_scope_empty = ids.empty?
|
|
245
|
+
ids unless ids.empty?
|
|
47
246
|
end
|
|
48
247
|
|
|
49
248
|
def apply_authorization_scope(scope, user)
|
|
@@ -57,33 +256,89 @@ module Maglev
|
|
|
57
256
|
end
|
|
58
257
|
end
|
|
59
258
|
|
|
60
|
-
def
|
|
61
|
-
|
|
62
|
-
results = []
|
|
259
|
+
def authorize_results(results, user)
|
|
260
|
+
return results unless @authorization.configured? && user
|
|
63
261
|
|
|
64
|
-
|
|
262
|
+
results.select { |result| @authorization.authorized?(record: result.owner, user: user) }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def current_index_version
|
|
266
|
+
configuration = Maglev.configuration
|
|
267
|
+
identity_configuration = IdentityConfiguration.new(
|
|
268
|
+
embedding_model: configuration.embedding_model,
|
|
269
|
+
embedding_dimensions: @embedding_dimensions,
|
|
270
|
+
embedding_adapter_id: configuration.embedding_adapter_id,
|
|
271
|
+
embedding_adapter_version: configuration.embedding_adapter_version,
|
|
272
|
+
application_index_version: configuration.application_index_version
|
|
273
|
+
)
|
|
274
|
+
IndexIdentity.new(
|
|
275
|
+
configuration: identity_configuration,
|
|
276
|
+
adapter: @embedding_adapter,
|
|
277
|
+
chunk_size: @chunk_size
|
|
278
|
+
).to_s
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def validate_embedding!(embedding)
|
|
282
|
+
return if embedding.respond_to?(:length) && embedding.length == @embedding_dimensions
|
|
283
|
+
|
|
284
|
+
actual = embedding.respond_to?(:length) ? embedding.length : "unknown"
|
|
285
|
+
raise ConfigurationError, "Embedding adapter returned #{actual} dimensions; expected #{@embedding_dimensions} dimensions"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def search_results(rows)
|
|
289
|
+
rows.filter_map do |row|
|
|
290
|
+
next unless compatible_owner_metadata?(row)
|
|
65
291
|
owner = owner_for(row)
|
|
66
|
-
next
|
|
292
|
+
next unless owner
|
|
67
293
|
|
|
68
|
-
|
|
69
|
-
results << SearchResult.new(
|
|
294
|
+
SearchResult.new(
|
|
70
295
|
owner: owner,
|
|
71
296
|
content: row.content,
|
|
72
297
|
source: row.source,
|
|
73
298
|
distance: row.respond_to?(:neighbor_distance) ? row.neighbor_distance : row.distance,
|
|
74
|
-
chunk_index: row.respond_to?(:chunk_index) ? row.chunk_index : nil
|
|
299
|
+
chunk_index: row.respond_to?(:chunk_index) ? row.chunk_index : nil,
|
|
300
|
+
source_identity: row.respond_to?(:source_identity) ? row.source_identity : row.source,
|
|
301
|
+
source_type: (row.respond_to?(:source_type) && row.source_type) ? row.source_type : inferred_source_type(row.source),
|
|
302
|
+
score: row.respond_to?(:score) ? row.score : nil
|
|
75
303
|
)
|
|
76
304
|
end
|
|
77
|
-
|
|
78
|
-
results
|
|
79
305
|
end
|
|
80
306
|
|
|
81
307
|
def owner_for(row)
|
|
82
308
|
if row.owner
|
|
83
309
|
row.owner
|
|
84
310
|
elsif row.respond_to?(:owner_type) && row.respond_to?(:owner_id)
|
|
85
|
-
|
|
311
|
+
@model_class.find_by(@model_class.primary_key => row.owner_id)
|
|
86
312
|
end
|
|
87
313
|
end
|
|
314
|
+
|
|
315
|
+
def compatible_owner_metadata?(row)
|
|
316
|
+
return true unless @vector_store
|
|
317
|
+
return false if row.respond_to?(:owner_type) && row.owner_type != @model_class.name
|
|
318
|
+
return false if row.respond_to?(:owner_model_name) && row.owner_model_name != @model_class.name
|
|
319
|
+
if @request_tenant_id
|
|
320
|
+
return false unless row.respond_to?(:tenant_id) && row.tenant_id == @request_tenant_id
|
|
321
|
+
end
|
|
322
|
+
if @vector_store.respond_to?(:contract_version) && @vector_store.contract_version >= 2
|
|
323
|
+
return false unless row.respond_to?(:owner_type) && row.respond_to?(:owner_id) && row.respond_to?(:owner_model_name)
|
|
324
|
+
if row.owner
|
|
325
|
+
return false unless row.owner.is_a?(@model_class)
|
|
326
|
+
return false unless row.owner.respond_to?(:id) && row.owner.id.to_s == row.owner_id.to_s
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
true
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def inferred_source_type(source)
|
|
334
|
+
value = source.to_s
|
|
335
|
+
return :attachment if value.include?("[blob:")
|
|
336
|
+
return :rich_text if value.start_with?("rich_text.")
|
|
337
|
+
return :related_record if value.start_with?("related:")
|
|
338
|
+
return :related_record if value.include?("[") || value.include?(".")
|
|
339
|
+
return :snapshot if value == "snapshot"
|
|
340
|
+
|
|
341
|
+
:attribute
|
|
342
|
+
end
|
|
88
343
|
end
|
|
89
344
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maglev
|
|
4
|
+
class Router
|
|
5
|
+
ROUTES = %i[structured rag hybrid unsupported clarification_required].freeze
|
|
6
|
+
MAX_RESOURCES = 12
|
|
7
|
+
MAX_ITEMS = 40
|
|
8
|
+
MAX_DESCRIPTION_CHARACTERS = 500
|
|
9
|
+
|
|
10
|
+
Decision = Struct.new(:route, :confidence, :reasons, :resources) do
|
|
11
|
+
def initialize(**attributes)
|
|
12
|
+
attributes[:route] = attributes.fetch(:route).to_sym
|
|
13
|
+
attributes[:confidence] = Float(attributes.fetch(:confidence))
|
|
14
|
+
attributes[:reasons] = Array(attributes.fetch(:reasons)).map { |reason| reason.to_s.freeze }.freeze
|
|
15
|
+
attributes[:resources] = Array(attributes.fetch(:resources)).map { |resource| resource.to_s.freeze }.freeze
|
|
16
|
+
super
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(classifier:)
|
|
22
|
+
@classifier = classifier
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def route(request)
|
|
26
|
+
raise ArgumentError, "a Maglev::Request is required" unless request.is_a?(Request)
|
|
27
|
+
|
|
28
|
+
return decision(route: request.mode, confidence: 1.0, reasons: ["explicit_mode"], request: request) unless request.mode == :auto
|
|
29
|
+
|
|
30
|
+
unless @classifier&.respond_to?(:classify)
|
|
31
|
+
raise ConfigurationError, "routing adapter is not configured"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
output = @classifier.classify(question: request.question, capabilities: capability_summaries(request))
|
|
35
|
+
raise PermanentProviderError, "Routing provider returned invalid output" unless output.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
route = output["route"]&.to_sym
|
|
38
|
+
confidence = output["confidence"]
|
|
39
|
+
reasons = output["reasons"]
|
|
40
|
+
unless ROUTES.include?(route) && confidence.is_a?(Numeric) && (0.0..1.0).cover?(confidence) &&
|
|
41
|
+
reasons.is_a?(Array) && reasons.all? { |reason| reason.is_a?(String) }
|
|
42
|
+
raise PermanentProviderError, "Routing provider returned invalid output"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
decision(route: route, confidence: confidence, reasons: reasons, request: request)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def decision(route:, confidence:, reasons:, request:)
|
|
51
|
+
Decision.new(route: route, confidence: confidence, reasons: reasons, resources: request.resources)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def capability_summaries(request)
|
|
55
|
+
request.resources.first(MAX_RESOURCES).filter_map do |identifier|
|
|
56
|
+
entry = Registry.fetch(identifier)
|
|
57
|
+
next unless entry
|
|
58
|
+
|
|
59
|
+
queryable = entry.queryable
|
|
60
|
+
knowledge = entry.knowledge
|
|
61
|
+
sources = if knowledge
|
|
62
|
+
knowledge.exposed_attributes + knowledge.attached_sources.map(&:name) + knowledge.rich_text_sources.map(&:name)
|
|
63
|
+
else
|
|
64
|
+
[]
|
|
65
|
+
end
|
|
66
|
+
{
|
|
67
|
+
identifier: entry.identifier,
|
|
68
|
+
description: entry.description.to_s.each_char.first(MAX_DESCRIPTION_CHARACTERS).join,
|
|
69
|
+
structured: !queryable.nil?,
|
|
70
|
+
rag: !knowledge.nil?,
|
|
71
|
+
fields: Array(queryable&.fields).map(&:name).first(MAX_ITEMS).freeze,
|
|
72
|
+
sources: sources.uniq.first(MAX_ITEMS).freeze
|
|
73
|
+
}.freeze
|
|
74
|
+
end.freeze
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maglev
|
|
4
|
+
class RoutingAdapter
|
|
5
|
+
def classify(question:, capabilities:)
|
|
6
|
+
raise NotImplementedError, "#{self.class.name} must implement #classify"
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class FakeRoutingAdapter < RoutingAdapter
|
|
11
|
+
attr_reader :requests
|
|
12
|
+
|
|
13
|
+
def initialize(outputs)
|
|
14
|
+
@outputs = Array(outputs).dup
|
|
15
|
+
@requests = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def classify(**request)
|
|
19
|
+
@requests << request.freeze
|
|
20
|
+
raise PermanentProviderError, "Fake router has no remaining output" if @outputs.empty?
|
|
21
|
+
|
|
22
|
+
@outputs.shift
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "configuration"
|
|
4
4
|
require_relative "errors"
|
|
5
|
+
require_relative "relation_order"
|
|
5
6
|
|
|
6
7
|
module Maglev
|
|
7
8
|
class SchemaCompiler
|
|
8
9
|
SUPPORTED_MACROS = %i[belongs_to has_one has_many].freeze
|
|
9
10
|
|
|
10
11
|
CompiledSchema = Struct.new(:model_class, :relations)
|
|
11
|
-
CompiledRelation = Struct.new(:name, :depth, :limit, :inverse, :macro, :related_class)
|
|
12
|
+
CompiledRelation = Struct.new(:name, :depth, :limit, :inverse, :macro, :related_class, :order)
|
|
12
13
|
|
|
13
14
|
def initialize(config, max_depth: Maglev.configuration.max_relation_depth)
|
|
14
15
|
@config = config
|
|
@@ -16,7 +17,7 @@ module Maglev
|
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def compile
|
|
19
|
-
CompiledSchema.new(@config.model_class, @config.relations.map { |relation| compile_relation(relation) }.freeze)
|
|
20
|
+
CompiledSchema.new(@config.model_class, @config.relations.map { |relation| compile_relation(relation) }.freeze).freeze
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
private
|
|
@@ -37,7 +38,7 @@ module Maglev
|
|
|
37
38
|
|
|
38
39
|
related_class = reflection.klass
|
|
39
40
|
unless related_class.respond_to?(:maglev_config) && related_class.maglev_config
|
|
40
|
-
raise ConfigurationError, "Related Maglev model #{related_class.name} must declare
|
|
41
|
+
raise ConfigurationError, "Related Maglev model #{related_class.name} must declare maglev_resource knowledge"
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
inverse = relation.inverse || reflection.inverse_of&.name&.to_s
|
|
@@ -45,7 +46,19 @@ module Maglev
|
|
|
45
46
|
raise ConfigurationError, "Maglev association #{@config.model_class.name}.#{relation.name} requires an inverse for invalidation"
|
|
46
47
|
end
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
order = compiled_order(relation.order, related_class)
|
|
50
|
+
CompiledRelation.new(relation.name, relation.depth, relation.limit, inverse, reflection.macro, related_class, order).freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def compiled_order(order, related_class)
|
|
54
|
+
return nil unless order
|
|
55
|
+
|
|
56
|
+
unknown = order.keys.map(&:to_s) - related_class.attribute_names.map(&:to_s)
|
|
57
|
+
if unknown.any?
|
|
58
|
+
raise ConfigurationError, "Unknown Maglev relation order attributes for #{related_class.name}: #{unknown.join(", ")}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
RelationOrder.with_primary_key(order, related_class)
|
|
49
62
|
end
|
|
50
63
|
end
|
|
51
64
|
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module Maglev
|
|
8
|
+
class SchemaSnapshot
|
|
9
|
+
DEFAULT_LIMITS = {resources: 12, fields: 40, associations: 20, bytes: 32_768}.freeze
|
|
10
|
+
|
|
11
|
+
Field = Struct.new(:name, :type, :null, :enum_values, :description, :synonyms) do
|
|
12
|
+
def initialize(**attributes)
|
|
13
|
+
attributes[:enum_values] = Array(attributes[:enum_values]).freeze
|
|
14
|
+
attributes[:synonyms] = Array(attributes[:synonyms]).freeze
|
|
15
|
+
super
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
{name: name, type: type, null: null, enum_values: enum_values, description: description, synonyms: synonyms}.freeze
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Association = Struct.new(:name, :resource, :macro, :polymorphic, :description, :synonyms) do
|
|
25
|
+
def initialize(**attributes)
|
|
26
|
+
attributes[:synonyms] = Array(attributes[:synonyms]).freeze
|
|
27
|
+
super
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
{name: name, resource: resource, macro: macro, polymorphic: polymorphic, description: description, synonyms: synonyms}.freeze
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Resource = Struct.new(:identifier, :description, :synonyms, :table_name, :primary_key, :sti_base, :inheritance_column,
|
|
37
|
+
:fields, :associations, :scopes, :aggregates, :limits, :allow_unscoped_model_queries) do
|
|
38
|
+
def initialize(**attributes)
|
|
39
|
+
attributes[:synonyms] = Array(attributes[:synonyms]).freeze
|
|
40
|
+
attributes[:fields] = attributes.fetch(:fields).freeze
|
|
41
|
+
attributes[:associations] = attributes.fetch(:associations).freeze
|
|
42
|
+
attributes[:scopes] = attributes.fetch(:scopes).freeze
|
|
43
|
+
attributes[:aggregates] = attributes.fetch(:aggregates).freeze
|
|
44
|
+
attributes[:limits] = attributes.fetch(:limits).freeze
|
|
45
|
+
super
|
|
46
|
+
freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_h
|
|
50
|
+
{
|
|
51
|
+
identifier: identifier, description: description, synonyms: synonyms, table_name: table_name,
|
|
52
|
+
primary_key: primary_key, sti_base: sti_base, inheritance_column: inheritance_column,
|
|
53
|
+
fields: fields.map(&:to_h).freeze, associations: associations.map(&:to_h).freeze,
|
|
54
|
+
scopes: scopes, aggregates: aggregates, limits: limits,
|
|
55
|
+
allow_unscoped_model_queries: allow_unscoped_model_queries
|
|
56
|
+
}.freeze
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
attr_reader :resources, :paths
|
|
61
|
+
|
|
62
|
+
def initialize(resources:, paths:, model_classes: {})
|
|
63
|
+
@resources = resources.freeze
|
|
64
|
+
@paths = paths.freeze
|
|
65
|
+
@model_classes = model_classes.freeze
|
|
66
|
+
@hash = {version: 1, resources: @resources.map(&:to_h).freeze, paths: @paths}.freeze
|
|
67
|
+
freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_h
|
|
71
|
+
@hash
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_json(*arguments)
|
|
75
|
+
@hash.to_json(*arguments)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def model_class_for(identifier)
|
|
79
|
+
@model_classes[identifier.to_s]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class Builder
|
|
83
|
+
def initialize(entries, limits: {})
|
|
84
|
+
requested = limits.transform_keys(&:to_sym)
|
|
85
|
+
unknown = requested.keys - DEFAULT_LIMITS.keys
|
|
86
|
+
valid = requested.all? do |key, value|
|
|
87
|
+
value.is_a?(Integer) && ((key == :bytes) ? value.positive? : value >= 0)
|
|
88
|
+
end
|
|
89
|
+
raise ConfigurationError, "invalid schema snapshot limits" if unknown.any? || !valid
|
|
90
|
+
|
|
91
|
+
@limits = DEFAULT_LIMITS.merge(requested) { |_key, global, request| [global, request].min }.freeze
|
|
92
|
+
@entries = entries.sort_by(&:identifier).first(@limits.fetch(:resources)).freeze
|
|
93
|
+
@entry_by_identifier = @entries.to_h { |entry| [entry.identifier, entry] }.freeze
|
|
94
|
+
@resource_identifiers = @entries.map(&:identifier).to_h { |identifier| [identifier, true] }.freeze
|
|
95
|
+
@resource_for_model = @entries.to_h { |entry| [entry.model_class.name, entry.identifier] }.freeze
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build
|
|
99
|
+
resources = @entries.map { |entry| resource_for(entry) }
|
|
100
|
+
snapshot = SchemaSnapshot.new(
|
|
101
|
+
resources: resources,
|
|
102
|
+
paths: paths_for(resources),
|
|
103
|
+
model_classes: @entries.to_h { |entry| [entry.identifier, entry.model_class] }
|
|
104
|
+
)
|
|
105
|
+
if snapshot.to_json.bytesize > @limits.fetch(:bytes)
|
|
106
|
+
raise ConfigurationError, "schema snapshot exceeds #{@limits.fetch(:bytes)} bytes"
|
|
107
|
+
end
|
|
108
|
+
snapshot
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def resource_for(entry)
|
|
114
|
+
model = entry.model_class
|
|
115
|
+
queryable = entry.queryable
|
|
116
|
+
fields = queryable.fields.reject(&:sensitive).sort_by(&:name).first(@limits.fetch(:fields)).map do |declaration|
|
|
117
|
+
column = model.columns_hash.fetch(declaration.name)
|
|
118
|
+
enum_values = declaration.enum_values.empty? ? model.defined_enums.fetch(declaration.name, {}).keys.sort : declaration.enum_values
|
|
119
|
+
Field.new(name: declaration.name, type: column.type, null: column.null, enum_values: enum_values,
|
|
120
|
+
description: declaration.description, synonyms: declaration.synonyms)
|
|
121
|
+
end
|
|
122
|
+
associations = queryable.associations.select { |declaration| @resource_identifiers.key?(declaration.resource) }
|
|
123
|
+
.sort_by(&:name).first(@limits.fetch(:associations)).map do |declaration|
|
|
124
|
+
reflection = model.reflect_on_association(declaration.name.to_sym)
|
|
125
|
+
target = @entry_by_identifier.fetch(declaration.resource)
|
|
126
|
+
unless reflection.polymorphic? || reflection.klass.base_class == target.model_class.base_class
|
|
127
|
+
raise ConfigurationError,
|
|
128
|
+
"Association #{model.name}.#{declaration.name} does not match resource #{declaration.resource}"
|
|
129
|
+
end
|
|
130
|
+
Association.new(name: declaration.name, resource: declaration.resource, macro: reflection.macro,
|
|
131
|
+
polymorphic: !!reflection.polymorphic?, description: declaration.description, synonyms: declaration.synonyms)
|
|
132
|
+
end
|
|
133
|
+
Resource.new(identifier: entry.identifier, description: entry.description, synonyms: entry.synonyms,
|
|
134
|
+
table_name: model.table_name, primary_key: model.primary_key, sti_base: @resource_for_model[model.base_class.name],
|
|
135
|
+
inheritance_column: model.inheritance_column, fields: fields, associations: associations,
|
|
136
|
+
scopes: queryable.scopes.map { |scope| scope_to_h(scope) }.freeze, aggregates: queryable.aggregates,
|
|
137
|
+
limits: queryable.limits, allow_unscoped_model_queries: queryable.allow_unscoped_model_queries)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def scope_to_h(scope)
|
|
141
|
+
{name: scope.name, description: scope.description, parameters: scope.parameters.transform_values do |parameter|
|
|
142
|
+
{type: parameter.type, required: parameter.required, nullable: parameter.nullable,
|
|
143
|
+
enum_values: parameter.enum_values, minimum: parameter.minimum, maximum: parameter.maximum}.freeze
|
|
144
|
+
end.freeze}.freeze
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def paths_for(resources)
|
|
148
|
+
by_identifier = resources.to_h { |resource| [resource.identifier, resource] }
|
|
149
|
+
resources.flat_map do |resource|
|
|
150
|
+
resource.associations.flat_map do |association|
|
|
151
|
+
first = "#{resource.identifier}.#{association.name}"
|
|
152
|
+
nested = by_identifier[association.resource]&.associations&.map { |child| "#{first}.#{child.name}" } || []
|
|
153
|
+
[first, *nested]
|
|
154
|
+
end
|
|
155
|
+
end.sort.freeze
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|