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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.ja.md +618 -0
  4. data/README.md +533 -248
  5. data/README.zh-CN.md +457 -250
  6. data/lib/generators/maglev/install/install_generator.rb +20 -0
  7. data/lib/generators/maglev/upgrade_index_version/upgrade_index_version_generator.rb +27 -0
  8. data/lib/generators/maglev/upgrade_source_identity/upgrade_source_identity_generator.rb +52 -0
  9. data/lib/maglev/active_record_extension.rb +141 -24
  10. data/lib/maglev/adapters/faraday_client.rb +94 -0
  11. data/lib/maglev/adapters/faraday_embedding.rb +51 -0
  12. data/lib/maglev/adapters/faraday_generation.rb +49 -0
  13. data/lib/maglev/adapters/faraday_planner.rb +88 -0
  14. data/lib/maglev/answerer.rb +30 -12
  15. data/lib/maglev/chunker.rb +39 -4
  16. data/lib/maglev/configuration.rb +66 -1
  17. data/lib/maglev/content_source_graph.rb +17 -11
  18. data/lib/maglev/dependency_graph.rb +72 -13
  19. data/lib/maglev/embedding_adapter.rb +10 -0
  20. data/lib/maglev/hybrid_candidate_set.rb +25 -0
  21. data/lib/maglev/hybrid_coordinator.rb +112 -0
  22. data/lib/maglev/hybrid_result.rb +25 -0
  23. data/lib/maglev/index_diagnostics.rb +83 -0
  24. data/lib/maglev/index_identity.rb +70 -0
  25. data/lib/maglev/index_state.rb +9 -0
  26. data/lib/maglev/indexer.rb +185 -35
  27. data/lib/maglev/knowledge_config.rb +27 -5
  28. data/lib/maglev/knowledge_registry.rb +33 -0
  29. data/lib/maglev/planner.rb +172 -0
  30. data/lib/maglev/planner_adapter.rb +25 -0
  31. data/lib/maglev/planner_evaluation.rb +49 -0
  32. data/lib/maglev/query_compiler.rb +197 -0
  33. data/lib/maglev/query_ir.rb +143 -0
  34. data/lib/maglev/query_validator.rb +311 -0
  35. data/lib/maglev/railtie.rb +9 -0
  36. data/lib/maglev/registry.rb +72 -0
  37. data/lib/maglev/reindex_job.rb +34 -2
  38. data/lib/maglev/relation_order.rb +16 -0
  39. data/lib/maglev/request.rb +22 -0
  40. data/lib/maglev/request_executor.rb +101 -0
  41. data/lib/maglev/resource_config.rb +222 -0
  42. data/lib/maglev/response.rb +2 -2
  43. data/lib/maglev/result.rb +30 -0
  44. data/lib/maglev/retrieval_outcome.rb +52 -0
  45. data/lib/maglev/retrieval_result.rb +25 -0
  46. data/lib/maglev/retriever.rb +282 -27
  47. data/lib/maglev/router.rb +77 -0
  48. data/lib/maglev/routing_adapter.rb +25 -0
  49. data/lib/maglev/schema_compiler.rb +17 -4
  50. data/lib/maglev/schema_snapshot.rb +159 -0
  51. data/lib/maglev/search_result.rb +7 -3
  52. data/lib/maglev/snapshot.rb +21 -1
  53. data/lib/maglev/snapshot_budget.rb +57 -0
  54. data/lib/maglev/snapshot_builder.rb +89 -11
  55. data/lib/maglev/source_extractor.rb +43 -0
  56. data/lib/maglev/source_fragment.rb +9 -0
  57. data/lib/maglev/structured_answer_composer.rb +67 -0
  58. data/lib/maglev/structured_evidence_builder.rb +56 -0
  59. data/lib/maglev/structured_executor.rb +157 -0
  60. data/lib/maglev/structured_result.rb +97 -0
  61. data/lib/maglev/trace.rb +56 -0
  62. data/lib/maglev/vector_stores/base.rb +12 -0
  63. data/lib/maglev/vector_stores/document.rb +14 -5
  64. data/lib/maglev/vector_stores/document_id.rb +27 -0
  65. data/lib/maglev/vector_stores/memory.rb +68 -6
  66. data/lib/maglev/vector_stores/metadata_filter.rb +55 -0
  67. data/lib/maglev/vector_stores/pgvector.rb +94 -7
  68. data/lib/maglev/version.rb +1 -1
  69. data/lib/maglev-rb.rb +3 -0
  70. data/lib/maglev.rb +36 -3
  71. data/lib/tasks/maglev.rake +43 -0
  72. metadata +71 -11
  73. data/lib/maglev/adapters/ruby_llm_attachment_extractor.rb +0 -15
  74. data/lib/maglev/adapters/ruby_llm_embedding.rb +0 -22
  75. data/lib/maglev/adapters/ruby_llm_generation.rb +0 -22
  76. data/lib/maglev/adapters/ruby_llm_provider.rb +0 -64
@@ -1,49 +1,248 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "adapters/ruby_llm_embedding"
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
- def initialize(model_class, chunk_model: Chunk, embedding_adapter: Maglev.configuration.embedding_adapter, authorization: Authorization.new, vector_store: Maglev.configuration.vector_store)
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::RubyLLMEmbedding.new
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
- return search_vector_store(embedding, limit: limit, owner: owner, user: user) if @vector_store
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
- scope = @chunk_model.where(owner_model_name: @model_class.name)
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
- rows = scope
28
- .nearest_neighbors(:embedding, embedding, distance: "cosine")
187
+ scope.nearest_neighbors(:embedding, embedding, distance: "cosine").limit(limit)
188
+ end
29
189
 
30
- unique_owner_results(rows).first(limit)
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
- private
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 search_vector_store(embedding, limit:, owner:, user:)
36
- documents = @vector_store.search(vector: embedding, filters: filters_for(owner), limit: limit)
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 filters_for(owner)
41
- filters = {owner_model_name: @model_class.name}
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 unique_owner_results(rows)
61
- seen_owners = {}
62
- results = []
259
+ def authorize_results(results, user)
260
+ return results unless @authorization.configured? && user
63
261
 
64
- rows.each do |row|
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 if seen_owners[owner]
292
+ next unless owner
67
293
 
68
- seen_owners[owner] = true
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
- row.owner_type.constantize.find(row.owner_id)
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 has_knowledge"
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
- CompiledRelation.new(relation.name, relation.depth, relation.limit, inverse, reflection.macro, related_class)
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