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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "knowledge_config"
|
|
5
|
+
|
|
6
|
+
module Maglev
|
|
7
|
+
class ResourceConfig
|
|
8
|
+
SUPPORTED_PARAMETER_TYPES = %i[
|
|
9
|
+
string integer float decimal boolean date datetime timestamp time
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
Field = Struct.new(:name, :description, :synonyms, :enum_values, :sensitive) do
|
|
13
|
+
def initialize(**attributes)
|
|
14
|
+
attributes[:name] = attributes.fetch(:name).to_s
|
|
15
|
+
attributes[:description] = attributes[:description]&.to_s
|
|
16
|
+
attributes[:synonyms] = Array(attributes[:synonyms]).map(&:to_s).uniq.freeze
|
|
17
|
+
attributes[:enum_values] = Array(attributes[:enum_values]).map(&:to_s).uniq.freeze
|
|
18
|
+
attributes[:sensitive] = !!attributes[:sensitive]
|
|
19
|
+
super
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Association = Struct.new(:name, :resource, :description, :synonyms) do
|
|
25
|
+
def initialize(**attributes)
|
|
26
|
+
attributes[:name] = attributes.fetch(:name).to_s
|
|
27
|
+
attributes[:resource] = attributes.fetch(:resource).to_s
|
|
28
|
+
attributes[:description] = attributes[:description]&.to_s
|
|
29
|
+
attributes[:synonyms] = Array(attributes[:synonyms]).map(&:to_s).uniq.freeze
|
|
30
|
+
super
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Parameter = Struct.new(:type, :required, :nullable, :enum_values, :minimum, :maximum) do
|
|
36
|
+
def initialize(**attributes)
|
|
37
|
+
attributes[:type] = attributes.fetch(:type).to_sym
|
|
38
|
+
unless SUPPORTED_PARAMETER_TYPES.include?(attributes[:type])
|
|
39
|
+
raise ConfigurationError, "Unsupported scope parameter type #{attributes[:type]}"
|
|
40
|
+
end
|
|
41
|
+
attributes[:required] = !!attributes[:required]
|
|
42
|
+
attributes[:nullable] = !!attributes[:nullable]
|
|
43
|
+
attributes[:enum_values] = Array(attributes[:enum_values]).map(&:to_s).uniq.freeze
|
|
44
|
+
super
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Scope = Struct.new(:name, :parameters, :description) do
|
|
50
|
+
def initialize(**attributes)
|
|
51
|
+
attributes[:name] = attributes.fetch(:name).to_s
|
|
52
|
+
attributes[:parameters] = attributes.fetch(:parameters).to_h { |name, parameter| [name.to_s, parameter] }.freeze
|
|
53
|
+
attributes[:description] = attributes[:description]&.to_s
|
|
54
|
+
super
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Queryable = Struct.new(:fields, :prohibited_fields, :associations, :scopes, :aggregates, :limits, :authorization, :allow_unscoped_model_queries) do
|
|
60
|
+
def initialize(**attributes)
|
|
61
|
+
attributes[:fields] = attributes.fetch(:fields).freeze
|
|
62
|
+
attributes[:prohibited_fields] = attributes.fetch(:prohibited_fields).freeze
|
|
63
|
+
attributes[:associations] = attributes.fetch(:associations).freeze
|
|
64
|
+
attributes[:scopes] = attributes.fetch(:scopes).freeze
|
|
65
|
+
attributes[:aggregates] = attributes.fetch(:aggregates).transform_values { |values| (values == true) ? true : Array(values).freeze }.freeze
|
|
66
|
+
attributes[:limits] = attributes.fetch(:limits).freeze
|
|
67
|
+
attributes[:authorization] = attributes.fetch(:authorization)
|
|
68
|
+
attributes[:allow_unscoped_model_queries] = !!attributes[:allow_unscoped_model_queries]
|
|
69
|
+
super
|
|
70
|
+
freeze
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Entry = Struct.new(:identifier, :model_class, :description, :synonyms, :queryable, :knowledge) do
|
|
75
|
+
def initialize(**attributes)
|
|
76
|
+
attributes[:identifier] = attributes.fetch(:identifier).to_s
|
|
77
|
+
attributes[:description] = attributes[:description]&.to_s
|
|
78
|
+
attributes[:synonyms] = Array(attributes[:synonyms]).map(&:to_s).uniq.freeze
|
|
79
|
+
super
|
|
80
|
+
freeze
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class Builder
|
|
85
|
+
def initialize(model_class, identifier)
|
|
86
|
+
@model_class = model_class
|
|
87
|
+
@identifier = identifier
|
|
88
|
+
@synonyms = []
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build(&block)
|
|
92
|
+
instance_eval(&block) if block
|
|
93
|
+
Entry.new(identifier: @identifier, model_class: @model_class, description: @description,
|
|
94
|
+
synonyms: @synonyms, queryable: @queryable, knowledge: @knowledge)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def description(value)
|
|
98
|
+
@description = value.to_s
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def synonyms(*values)
|
|
102
|
+
@synonyms.concat(values)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def queryable(&block)
|
|
106
|
+
raise ConfigurationError, "queryable may only be declared once" if @queryable
|
|
107
|
+
|
|
108
|
+
@queryable = QueryableBuilder.new(@model_class).build(&block)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def knowledge(&block)
|
|
112
|
+
raise ConfigurationError, "knowledge may only be declared once" if @knowledge
|
|
113
|
+
|
|
114
|
+
@knowledge = KnowledgeConfig.build(@model_class, &block)
|
|
115
|
+
validate_knowledge_sources!
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def validate_knowledge_sources!
|
|
121
|
+
@knowledge.attached_sources.each do |source|
|
|
122
|
+
reflection = @model_class.reflect_on_association("#{source.name}_attachment") ||
|
|
123
|
+
@model_class.reflect_on_association("#{source.name}_attachments")
|
|
124
|
+
raise ConfigurationError, "Unknown attached knowledge source #{@model_class.name}.#{source.name}" unless reflection
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class QueryableBuilder
|
|
130
|
+
AUTHORIZATION_POLICIES = %i[required public].freeze
|
|
131
|
+
LIMIT_KEYS = %i[rows operations joins].freeze
|
|
132
|
+
AGGREGATES = %i[count sum average minimum maximum].freeze
|
|
133
|
+
|
|
134
|
+
def initialize(model_class)
|
|
135
|
+
@model_class = model_class
|
|
136
|
+
@fields = []
|
|
137
|
+
@prohibited_fields = []
|
|
138
|
+
@associations = []
|
|
139
|
+
@scopes = []
|
|
140
|
+
@aggregates = {}
|
|
141
|
+
@limits = {}
|
|
142
|
+
@authorization = :required
|
|
143
|
+
@allow_unscoped_model_queries = false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def build(&block)
|
|
147
|
+
instance_eval(&block) if block
|
|
148
|
+
conflicts = @fields.map(&:name) & @prohibited_fields
|
|
149
|
+
raise ConfigurationError, "Queryable fields cannot be prohibited: #{conflicts.join(", ")}" if conflicts.any?
|
|
150
|
+
|
|
151
|
+
Queryable.new(fields: @fields.uniq(&:name), prohibited_fields: @prohibited_fields.uniq.freeze,
|
|
152
|
+
associations: @associations.uniq(&:name), scopes: @scopes.uniq(&:name),
|
|
153
|
+
aggregates: @aggregates, limits: @limits, authorization: @authorization,
|
|
154
|
+
allow_unscoped_model_queries: @allow_unscoped_model_queries)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def field(name, description: nil, synonyms: [], enum: [], sensitive: false)
|
|
158
|
+
normalized = name.to_s
|
|
159
|
+
raise ConfigurationError, "Unknown queryable field #{@model_class.name}.#{normalized}" unless @model_class.attribute_names.include?(normalized)
|
|
160
|
+
|
|
161
|
+
@fields << Field.new(name: normalized, description: description, synonyms: synonyms, enum_values: enum, sensitive: sensitive)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def association(name, resource:, description: nil, synonyms: [])
|
|
165
|
+
normalized = name.to_s
|
|
166
|
+
raise ConfigurationError, "Unknown queryable association #{@model_class.name}.#{normalized}" unless @model_class.reflect_on_association(normalized.to_sym)
|
|
167
|
+
|
|
168
|
+
@associations << Association.new(name: normalized, resource: resource, description: description, synonyms: synonyms)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def prohibit(*names)
|
|
172
|
+
normalized = names.map(&:to_s)
|
|
173
|
+
unknown = normalized - @model_class.attribute_names
|
|
174
|
+
raise ConfigurationError, "Unknown prohibited field #{@model_class.name}.#{unknown.first}" if unknown.any?
|
|
175
|
+
|
|
176
|
+
@prohibited_fields.concat(normalized)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def scope(name, parameters: {}, description: nil)
|
|
180
|
+
normalized = name.to_s
|
|
181
|
+
raise ConfigurationError, "Unknown queryable scope #{@model_class.name}.#{normalized}" unless @model_class.respond_to?(normalized)
|
|
182
|
+
|
|
183
|
+
normalized_parameters = parameters.to_h do |parameter_name, schema|
|
|
184
|
+
schema = schema.transform_keys(&:to_sym)
|
|
185
|
+
[parameter_name, Parameter.new(**schema)]
|
|
186
|
+
end
|
|
187
|
+
@scopes << Scope.new(name: normalized, parameters: normalized_parameters, description: description)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def aggregates(**permissions)
|
|
191
|
+
unknown = permissions.keys - AGGREGATES
|
|
192
|
+
raise ConfigurationError, "Unknown aggregates: #{unknown.join(", ")}" if unknown.any?
|
|
193
|
+
|
|
194
|
+
permissions.each do |aggregate, fields|
|
|
195
|
+
normalized = (fields == true) ? true : Array(fields).map(&:to_s).uniq
|
|
196
|
+
unknown_fields = (normalized == true) ? [] : Array(normalized) - @model_class.attribute_names
|
|
197
|
+
raise ConfigurationError, "Unknown aggregate field #{@model_class.name}.#{unknown_fields.first}" if unknown_fields.any?
|
|
198
|
+
@aggregates[aggregate] = normalized
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def limits(**values)
|
|
203
|
+
unknown = values.keys - LIMIT_KEYS
|
|
204
|
+
raise ConfigurationError, "Unknown query limits: #{unknown.join(", ")}" if unknown.any?
|
|
205
|
+
raise ConfigurationError, "Query limits must be positive integers" unless values.values.all? { |value| value.is_a?(Integer) && value.positive? }
|
|
206
|
+
|
|
207
|
+
@limits.merge!(values)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def authorization(policy)
|
|
211
|
+
policy = policy.to_sym
|
|
212
|
+
raise ConfigurationError, "Unknown authorization policy #{policy}" unless AUTHORIZATION_POLICIES.include?(policy)
|
|
213
|
+
|
|
214
|
+
@authorization = policy
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def allow_unscoped_model_queries(value = true)
|
|
218
|
+
@allow_unscoped_model_queries = value
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
data/lib/maglev/response.rb
CHANGED
|
@@ -4,11 +4,11 @@ module Maglev
|
|
|
4
4
|
class Response
|
|
5
5
|
attr_reader :text, :sources, :metadata
|
|
6
6
|
|
|
7
|
-
def self.insufficient_context(question:)
|
|
7
|
+
def self.insufficient_context(question:, retrieval_metadata: {})
|
|
8
8
|
new(
|
|
9
9
|
text: "Insufficient context to answer the question.",
|
|
10
10
|
sources: [],
|
|
11
|
-
metadata: {question: question, reason: "insufficient_context"}
|
|
11
|
+
metadata: {question: question, reason: "insufficient_context"}.merge(retrieval_metadata)
|
|
12
12
|
)
|
|
13
13
|
end
|
|
14
14
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maglev
|
|
4
|
+
class Result
|
|
5
|
+
STATUSES = %i[succeeded clarification_required unsupported failed].freeze
|
|
6
|
+
KINDS = %i[relation scalar aggregate semantic_matches rag_answer hybrid_answer none].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :status, :route, :kind, :value, :evidence, :warnings, :trace_id,
|
|
9
|
+
:confidence, :reasons, :metadata
|
|
10
|
+
|
|
11
|
+
def initialize(status:, route:, kind:, trace_id:, value: nil, evidence: nil, warnings: [],
|
|
12
|
+
confidence: nil, reasons: [], metadata: {})
|
|
13
|
+
raise ArgumentError, "invalid result status" unless STATUSES.include?(status)
|
|
14
|
+
raise ArgumentError, "invalid result kind" unless KINDS.include?(kind)
|
|
15
|
+
raise ArgumentError, "only successful results may carry a value" if status != :succeeded && !value.nil?
|
|
16
|
+
|
|
17
|
+
@status = status
|
|
18
|
+
@route = route.to_sym
|
|
19
|
+
@kind = kind
|
|
20
|
+
@value = value
|
|
21
|
+
@evidence = evidence
|
|
22
|
+
@warnings = Array(warnings).map { |warning| warning.to_s.freeze }.freeze
|
|
23
|
+
@trace_id = trace_id.to_s.freeze
|
|
24
|
+
@confidence = confidence
|
|
25
|
+
@reasons = Array(reasons).map { |reason| reason.to_s.freeze }.freeze
|
|
26
|
+
@metadata = metadata.freeze
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maglev
|
|
4
|
+
class RetrievalOutcome
|
|
5
|
+
attr_reader :results, :minimum_similarity, :examined_count,
|
|
6
|
+
:accepted_count, :rejected_count, :best_similarity, :considered, :rejected_results,
|
|
7
|
+
:authorization_rejected_count
|
|
8
|
+
|
|
9
|
+
def initialize(results:, minimum_similarity:, examined_count:,
|
|
10
|
+
accepted_count:, rejected_count:, best_similarity:, considered: nil, rejected_results: nil,
|
|
11
|
+
authorization_rejected_count: 0)
|
|
12
|
+
@results = results.freeze
|
|
13
|
+
@minimum_similarity = minimum_similarity
|
|
14
|
+
@examined_count = examined_count
|
|
15
|
+
@accepted_count = accepted_count
|
|
16
|
+
@rejected_count = rejected_count
|
|
17
|
+
@best_similarity = best_similarity
|
|
18
|
+
@considered = (considered || results).freeze
|
|
19
|
+
@rejected_results = (rejected_results || []).freeze
|
|
20
|
+
@authorization_rejected_count = authorization_rejected_count
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def empty_reason
|
|
25
|
+
return nil if @results.any?
|
|
26
|
+
|
|
27
|
+
if @examined_count.positive?
|
|
28
|
+
:threshold_rejected
|
|
29
|
+
else
|
|
30
|
+
:no_candidates
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def threshold_rejected?
|
|
35
|
+
empty_reason == :threshold_rejected
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def no_candidates?
|
|
39
|
+
empty_reason == :no_candidates
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def metadata
|
|
43
|
+
{
|
|
44
|
+
minimum_similarity: minimum_similarity,
|
|
45
|
+
examined_count: examined_count,
|
|
46
|
+
accepted_count: accepted_count,
|
|
47
|
+
rejected_count: rejected_count,
|
|
48
|
+
best_similarity: best_similarity
|
|
49
|
+
}.freeze
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Maglev
|
|
4
|
+
class RetrievalResult
|
|
5
|
+
attr_reader :query, :considered, :selected, :rejected, :context, :budgets, :reasons, :timings, :trace_id
|
|
6
|
+
|
|
7
|
+
def initialize(query:, considered:, selected:, rejected:, context:, budgets:, reasons:, timings:, trace_id:)
|
|
8
|
+
@query = query.to_s.freeze
|
|
9
|
+
@considered = considered.freeze
|
|
10
|
+
@selected = selected.freeze
|
|
11
|
+
@rejected = rejected.map { |item| item.freeze }.freeze
|
|
12
|
+
@context = context.to_s.freeze
|
|
13
|
+
@budgets = budgets.freeze
|
|
14
|
+
@reasons = reasons.freeze
|
|
15
|
+
@timings = timings.freeze
|
|
16
|
+
@trace_id = trace_id.to_s.freeze
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def metadata
|
|
21
|
+
{trace_id: trace_id, considered_count: considered.size, selected_count: selected.size,
|
|
22
|
+
rejected_count: rejected.size, budgets: budgets, reasons: reasons, timings: timings}.freeze
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|