maglev-rb 0.1.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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +408 -0
  4. data/README.zh-CN.md +408 -0
  5. data/lib/generators/maglev/install/install_generator.rb +70 -0
  6. data/lib/maglev/active_record_extension.rb +100 -0
  7. data/lib/maglev/adapters/ruby_llm_attachment_extractor.rb +15 -0
  8. data/lib/maglev/adapters/ruby_llm_embedding.rb +22 -0
  9. data/lib/maglev/adapters/ruby_llm_generation.rb +22 -0
  10. data/lib/maglev/adapters/ruby_llm_provider.rb +64 -0
  11. data/lib/maglev/answerer.rb +58 -0
  12. data/lib/maglev/attachment_extractor.rb +106 -0
  13. data/lib/maglev/authorization.rb +43 -0
  14. data/lib/maglev/chunk.rb +14 -0
  15. data/lib/maglev/chunker.rb +58 -0
  16. data/lib/maglev/configuration.rb +76 -0
  17. data/lib/maglev/content_source_graph.rb +66 -0
  18. data/lib/maglev/context_assembler.rb +94 -0
  19. data/lib/maglev/context_preview.rb +13 -0
  20. data/lib/maglev/dependency_graph.rb +129 -0
  21. data/lib/maglev/embedding_adapter.rb +9 -0
  22. data/lib/maglev/errors.rb +21 -0
  23. data/lib/maglev/extracted_document.rb +36 -0
  24. data/lib/maglev/generation_adapter.rb +9 -0
  25. data/lib/maglev/indexer.rb +175 -0
  26. data/lib/maglev/knowledge_config.rb +136 -0
  27. data/lib/maglev/prompt_builder.rb +27 -0
  28. data/lib/maglev/provider_call.rb +49 -0
  29. data/lib/maglev/provider_configuration.rb +18 -0
  30. data/lib/maglev/railtie.rb +19 -0
  31. data/lib/maglev/reindex_job.rb +16 -0
  32. data/lib/maglev/response.rb +26 -0
  33. data/lib/maglev/retriever.rb +89 -0
  34. data/lib/maglev/schema_compiler.rb +51 -0
  35. data/lib/maglev/search_result.rb +22 -0
  36. data/lib/maglev/snapshot.rb +14 -0
  37. data/lib/maglev/snapshot_builder.rb +204 -0
  38. data/lib/maglev/vector_stores/base.rb +31 -0
  39. data/lib/maglev/vector_stores/document.rb +39 -0
  40. data/lib/maglev/vector_stores/memory.rb +76 -0
  41. data/lib/maglev/vector_stores/pgvector.rb +94 -0
  42. data/lib/maglev/version.rb +5 -0
  43. data/lib/maglev.rb +37 -0
  44. data/lib/tasks/maglev.rake +31 -0
  45. metadata +202 -0
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Maglev
6
+ module Adapters
7
+ class RubyLLMProvider
8
+ def initialize(provider)
9
+ @provider = provider
10
+ end
11
+
12
+ def embed(text)
13
+ result = translate_errors do
14
+ context.embed(
15
+ text,
16
+ model: @provider.model,
17
+ provider: :openai,
18
+ assume_model_exists: true,
19
+ dimensions: @provider.dimensions
20
+ )
21
+ end
22
+ result.vectors.first.is_a?(Array) ? result.vectors.first : result.vectors
23
+ end
24
+
25
+ def generate(prompt)
26
+ response = translate_errors do
27
+ context.chat(
28
+ model: @provider.model,
29
+ provider: :openai,
30
+ assume_model_exists: true
31
+ ).ask(prompt)
32
+ end
33
+ response.respond_to?(:content) ? response.content : response.to_s
34
+ end
35
+
36
+ private
37
+
38
+ def context
39
+ @context ||= begin
40
+ require "ruby_llm"
41
+
42
+ RubyLLM.context do |configuration|
43
+ configuration.openai_api_base = @provider.url
44
+ configuration.openai_api_key = @provider.api_key
45
+ configuration.request_timeout = Maglev.configuration.provider_timeout
46
+ configuration.max_retries = 0
47
+ configuration.logger = Maglev.configuration.logger
48
+ end
49
+ end
50
+ end
51
+
52
+ def translate_errors
53
+ yield
54
+ rescue RubyLLM::RateLimitError, RubyLLM::OverloadedError,
55
+ RubyLLM::ServerError, RubyLLM::ServiceUnavailableError,
56
+ Faraday::TimeoutError, Faraday::ConnectionFailed => error
57
+ raise RetryableProviderError, error.message
58
+ rescue RubyLLM::Error, RubyLLM::ConfigurationError,
59
+ RubyLLM::ModelNotFoundError => error
60
+ raise PermanentProviderError, error.message
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "adapters/ruby_llm_generation"
5
+ require_relative "authorization"
6
+ require_relative "context_assembler"
7
+ require_relative "prompt_builder"
8
+ require_relative "provider_call"
9
+ require_relative "response"
10
+ require_relative "retriever"
11
+
12
+ module Maglev
13
+ class Answerer
14
+ def initialize(model_class, retriever: Retriever.new(model_class),
15
+ generation_adapter: Maglev.configuration.generation_adapter,
16
+ authorization: Authorization.new)
17
+ @model_class = model_class
18
+ @retriever = retriever
19
+ @generation_adapter = generation_adapter || Adapters::RubyLLMGeneration.new
20
+ @authorization = authorization
21
+ end
22
+
23
+ def ask(question, limit:, owner: nil, user: nil)
24
+ @authorization.authorize(record: owner, user: user) if owner
25
+
26
+ results = if user
27
+ @retriever.search(question, limit: limit, owner: owner, user: user)
28
+ else
29
+ @retriever.search(question, limit: limit, owner: owner)
30
+ end
31
+ results = results.select { |result| @authorization.authorized?(record: result.owner, user: user) }
32
+ return Response.insufficient_context(question: question) if results.empty?
33
+
34
+ context = nil
35
+ ActiveSupport::Notifications.instrument("maglev.query.retrieval", model: @model_class.name, result_count: results.size) do
36
+ context = ContextAssembler.new.assemble(results)
37
+ end
38
+ return Response.insufficient_context(question: question) if context.sources.empty?
39
+
40
+ prompt = PromptBuilder.new.build(question: question, context: context.text)
41
+ text = nil
42
+ ActiveSupport::Notifications.instrument("maglev.query.generation", model: @model_class.name) do
43
+ text = ProviderCall.new.call(operation: "generate") { @generation_adapter.generate(prompt) }
44
+ end
45
+ Response.new(
46
+ text: text,
47
+ sources: context.sources,
48
+ metadata: context.metadata.merge(question: question, owner_scope: owner && owner_scope(owner))
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ def owner_scope(owner)
55
+ {owner_type: owner.class.name, owner_id: owner.respond_to?(:id) ? owner.id : nil}
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ require_relative "configuration"
6
+ require_relative "extracted_document"
7
+
8
+ module Maglev
9
+ class AttachmentExtractor
10
+ def initialize(allowed_content_types: Maglev.configuration.attachment_allowed_content_types,
11
+ max_bytes: Maglev.configuration.attachment_max_bytes,
12
+ max_characters: Maglev.configuration.attachment_max_characters)
13
+ @allowed_content_types = allowed_content_types
14
+ @max_bytes = max_bytes
15
+ @max_characters = max_characters
16
+ end
17
+
18
+ def extract(blob, source_name:)
19
+ source_identifier = source_identifier(source_name, blob)
20
+ metadata = metadata_for(blob)
21
+ return skipped(source_identifier, "unsupported_content_type", metadata) unless allowed?(blob)
22
+ return skipped(source_identifier, "size_limit", metadata) if too_large?(blob)
23
+
24
+ text = extract_text(blob)
25
+ text, truncated = truncate(text)
26
+ ExtractedDocument.extracted(
27
+ source_identifier: source_identifier,
28
+ text: text,
29
+ metadata: metadata.merge(strategy: "deterministic", truncated: truncated)
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def source_identifier(source_name, blob)
36
+ "#{source_name}[blob:#{blob_identifier(blob)}]"
37
+ end
38
+
39
+ def blob_identifier(blob)
40
+ if blob.respond_to?(:id) && blob.id
41
+ blob.id
42
+ elsif blob.respond_to?(:key) && blob.key
43
+ blob.key
44
+ else
45
+ filename(blob)
46
+ end
47
+ end
48
+
49
+ def metadata_for(blob)
50
+ {
51
+ filename: filename(blob),
52
+ content_type: content_type(blob),
53
+ byte_size: byte_size(blob)
54
+ }
55
+ end
56
+
57
+ def filename(blob)
58
+ blob.respond_to?(:filename) ? blob.filename.to_s : "unknown"
59
+ end
60
+
61
+ def content_type(blob)
62
+ blob.respond_to?(:content_type) ? blob.content_type.to_s : ""
63
+ end
64
+
65
+ def byte_size(blob)
66
+ blob.respond_to?(:byte_size) ? blob.byte_size.to_i : 0
67
+ end
68
+
69
+ def allowed?(blob)
70
+ @allowed_content_types.include?(content_type(blob))
71
+ end
72
+
73
+ def too_large?(blob)
74
+ byte_size(blob) > @max_bytes
75
+ end
76
+
77
+ def extract_text(blob)
78
+ text = blob.download.to_s
79
+ html?(blob) ? sanitize_html(text) : text
80
+ end
81
+
82
+ def html?(blob)
83
+ %w[text/html application/xhtml+xml].include?(content_type(blob))
84
+ end
85
+
86
+ def sanitize_html(html)
87
+ without_scripts = html.gsub(%r{<script\b[^>]*>.*?</script>}im, " ")
88
+ .gsub(%r{<style\b[^>]*>.*?</style>}im, " ")
89
+ CGI.unescapeHTML(without_scripts.gsub(/<[^>]+>/, " ").squeeze(" ").strip)
90
+ end
91
+
92
+ def truncate(text)
93
+ return [text, false] if text.length <= @max_characters
94
+
95
+ [text[0, @max_characters], true]
96
+ end
97
+
98
+ def skipped(source_identifier, reason, metadata)
99
+ ExtractedDocument.skipped(
100
+ source_identifier: source_identifier,
101
+ reason: reason,
102
+ metadata: metadata.merge(strategy: "deterministic")
103
+ )
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "errors"
5
+
6
+ module Maglev
7
+ class Authorization
8
+ def initialize(adapter: Maglev.configuration.authorization_adapter)
9
+ @adapter = adapter
10
+ end
11
+
12
+ def configured?
13
+ !@adapter.nil?
14
+ end
15
+
16
+ def scope(model:, user:)
17
+ return model.all unless configured? && user
18
+
19
+ @adapter.scope(model: model, user: user)
20
+ end
21
+
22
+ def authorize(record:, user:)
23
+ return true unless configured? && user
24
+
25
+ result = @adapter.authorize(record: record, user: user)
26
+ raise AuthorizationError, "Maglev authorization denied #{record.class.name}##{record_id(record)}" if result == false
27
+
28
+ true
29
+ end
30
+
31
+ def authorized?(record:, user:)
32
+ authorize(record: record, user: user)
33
+ rescue AuthorizationError
34
+ false
35
+ end
36
+
37
+ private
38
+
39
+ def record_id(record)
40
+ record.respond_to?(:id) ? record.id : "unknown"
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "neighbor"
5
+
6
+ module Maglev
7
+ class Chunk < ActiveRecord::Base
8
+ self.table_name = "maglev_chunks"
9
+
10
+ belongs_to :owner, polymorphic: true
11
+
12
+ has_neighbors :embedding
13
+ end
14
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maglev
4
+ class Chunker
5
+ def initialize(max_characters: Maglev.configuration.chunk_size)
6
+ @max_characters = max_characters
7
+ end
8
+
9
+ def call(text)
10
+ text.to_s.split(/\n{2,}/).flat_map { |part| split_part(part.strip) }.reject(&:empty?)
11
+ end
12
+
13
+ private
14
+
15
+ def split_part(part)
16
+ return [] if part.empty?
17
+ return [part] if part.length <= @max_characters
18
+
19
+ line_chunks = split_lines(part)
20
+ return line_chunks if line_chunks.length > 1
21
+
22
+ chunks = []
23
+ current = +""
24
+
25
+ part.split(/\s+/).each do |word|
26
+ candidate = current.empty? ? word : "#{current} #{word}"
27
+ if candidate.length > @max_characters && !current.empty?
28
+ chunks << current
29
+ current = word
30
+ else
31
+ current = candidate
32
+ end
33
+ end
34
+
35
+ chunks << current unless current.empty?
36
+ chunks
37
+ end
38
+
39
+ def split_lines(part)
40
+ chunks = []
41
+ current = +""
42
+
43
+ part.lines(chomp: true).each do |line|
44
+ candidate = current.empty? ? line : "#{current}\n#{line}"
45
+ if candidate.length > @max_characters && !current.empty?
46
+ chunks << current
47
+ chunks.concat(split_part(line))
48
+ current = +""
49
+ else
50
+ current = candidate
51
+ end
52
+ end
53
+
54
+ chunks << current unless current.empty?
55
+ chunks
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "provider_configuration"
4
+
5
+ module Maglev
6
+ class << self
7
+ def configuration
8
+ @configuration ||= Configuration.new
9
+ end
10
+
11
+ def configure
12
+ yield configuration
13
+ end
14
+ end
15
+
16
+ class Configuration
17
+ attr_accessor :embedding_adapter, :chunk_size,
18
+ :generation_adapter, :context_max_characters,
19
+ :context_per_owner_characters, :explain_question, :max_relation_depth,
20
+ :attachment_extractor, :attachment_allowed_content_types,
21
+ :attachment_max_bytes, :attachment_max_characters, :authorization_adapter,
22
+ :provider_max_attempts, :provider_timeout, :source_redactor, :logger,
23
+ :vector_store
24
+
25
+ def initialize
26
+ @embedding_provider = ProviderConfiguration.new(
27
+ url: "https://api.openai.com/v1",
28
+ model: "text-embedding-3-small",
29
+ dimensions: 1536
30
+ )
31
+ @generation_provider = ProviderConfiguration.new(
32
+ url: "https://api.openai.com/v1",
33
+ model: "gpt-4.1-mini"
34
+ )
35
+ @chunk_size = 1000
36
+ @context_max_characters = 4000
37
+ @context_per_owner_characters = 1200
38
+ @explain_question = "Explain what the available knowledge says about this record."
39
+ @max_relation_depth = 3
40
+ @attachment_allowed_content_types = ["text/plain", "text/markdown", "text/html", "application/xhtml+xml"]
41
+ @attachment_max_bytes = 5 * 1024 * 1024
42
+ @attachment_max_characters = 20_000
43
+ @provider_max_attempts = 2
44
+ @provider_timeout = 30
45
+ @source_redactor = nil
46
+ end
47
+
48
+ def embedding_provider
49
+ yield @embedding_provider if block_given?
50
+ @embedding_provider
51
+ end
52
+
53
+ def generation_provider
54
+ yield @generation_provider if block_given?
55
+ @generation_provider
56
+ end
57
+
58
+ def embedding_model = @embedding_provider.model
59
+
60
+ def embedding_model=(model)
61
+ @embedding_provider.model = model
62
+ end
63
+
64
+ def embedding_dimensions = @embedding_provider.dimensions
65
+
66
+ def embedding_dimensions=(dimensions)
67
+ @embedding_provider.dimensions = dimensions
68
+ end
69
+
70
+ def generation_model = @generation_provider.model
71
+
72
+ def generation_model=(model)
73
+ @generation_provider.model = model
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "reindex_job"
4
+
5
+ module Maglev
6
+ class ContentSourceGraph
7
+ class << self
8
+ def register(config)
9
+ register_active_storage if config.attached_sources.any?
10
+ register_action_text if config.rich_text_sources.any?
11
+ end
12
+
13
+ def reindex_attachment_owner(attachment)
14
+ record = attachment.record
15
+ return unless record && declared_attached?(record.class, attachment.name)
16
+
17
+ ReindexJob.perform_later(record.class.name, record.id) if record.id
18
+ end
19
+
20
+ def reindex_rich_text_owner(rich_text)
21
+ record = rich_text.record
22
+ return unless record && declared_rich_text?(record.class, rich_text.name)
23
+
24
+ ReindexJob.perform_later(record.class.name, record.id) if record.id
25
+ end
26
+
27
+ private
28
+
29
+ def register_active_storage
30
+ return unless defined?(ActiveStorage::Attachment)
31
+ return if ActiveStorage::Attachment.method_defined?(:maglev_reindex_attachment_owner)
32
+
33
+ ActiveStorage::Attachment.class_eval do
34
+ def maglev_reindex_attachment_owner
35
+ Maglev::ContentSourceGraph.reindex_attachment_owner(self)
36
+ end
37
+
38
+ after_commit :maglev_reindex_attachment_owner, on: %i[create destroy]
39
+ end
40
+ end
41
+
42
+ def register_action_text
43
+ return unless defined?(ActionText::RichText)
44
+ return if ActionText::RichText.method_defined?(:maglev_reindex_rich_text_owner)
45
+
46
+ ActionText::RichText.class_eval do
47
+ def maglev_reindex_rich_text_owner
48
+ Maglev::ContentSourceGraph.reindex_rich_text_owner(self)
49
+ end
50
+
51
+ after_commit :maglev_reindex_rich_text_owner, on: %i[create update destroy]
52
+ end
53
+ end
54
+
55
+ def declared_attached?(klass, name)
56
+ config = klass.respond_to?(:maglev_config) ? klass.maglev_config : nil
57
+ config&.attached_sources&.any? { |source| source.name == name.to_s }
58
+ end
59
+
60
+ def declared_rich_text?(klass, name)
61
+ config = klass.respond_to?(:maglev_config) ? klass.maglev_config : nil
62
+ config&.rich_text_sources&.any? { |source| source.name == name.to_s }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maglev
4
+ class ContextAssembler
5
+ Context = Struct.new(:text, :sources, :metadata)
6
+
7
+ def initialize(max_characters: nil, per_owner_characters: nil)
8
+ max_characters ||= Maglev.configuration.context_max_characters
9
+ per_owner_characters ||= Maglev.configuration.context_per_owner_characters
10
+ @max_characters = max_characters
11
+ @per_owner_characters = per_owner_characters
12
+ end
13
+
14
+ def assemble(results)
15
+ entries = []
16
+ sources = []
17
+ owner_characters = Hash.new(0)
18
+ text_length = 0
19
+
20
+ sorted_results(results).each do |result|
21
+ marker = "[S#{sources.length + 1}]"
22
+ content = redacted_content(result)
23
+ entry = entry_for(result, marker, content)
24
+ owner_key = owner_key(result.owner)
25
+
26
+ next if owner_characters[owner_key] + content.length > @per_owner_characters
27
+ next if text_length + separator_length(entries) + entry.length > @max_characters
28
+
29
+ separator_length = separator_length(entries)
30
+ entries << entry
31
+ text_length += separator_length + entry.length
32
+ owner_characters[owner_key] += content.length
33
+ sources << source_for(result, marker, content)
34
+ end
35
+
36
+ text = entries.join("\n")
37
+ Context.new(
38
+ text: text,
39
+ sources: sources,
40
+ metadata: {context_characters: text.length, source_count: sources.length}
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ def sorted_results(results)
47
+ results.sort_by { |result| result.distance || Float::INFINITY }
48
+ end
49
+
50
+ def entry_for(result, marker, content)
51
+ "#{marker} #{owner_label(result.owner)} chunk #{result.chunk_index} source: #{result.source}\n#{content}"
52
+ end
53
+
54
+ def source_for(result, marker, content)
55
+ {
56
+ marker: marker,
57
+ owner_type: owner_type(result.owner),
58
+ owner_id: owner_id(result.owner),
59
+ source: result.source,
60
+ chunk_index: result.chunk_index,
61
+ content: content,
62
+ distance: result.distance,
63
+ similarity: result.similarity
64
+ }
65
+ end
66
+
67
+ def redacted_content(result)
68
+ redactor = Maglev.configuration.source_redactor
69
+ return result.content unless redactor
70
+
71
+ redactor.call(result.content, result)
72
+ end
73
+
74
+ def separator_length(entries)
75
+ entries.empty? ? 0 : 1
76
+ end
77
+
78
+ def owner_key(owner)
79
+ [owner_type(owner), owner_id(owner), owner.object_id]
80
+ end
81
+
82
+ def owner_label(owner)
83
+ "#{owner_type(owner)}##{owner_id(owner)}"
84
+ end
85
+
86
+ def owner_type(owner)
87
+ owner.class.name
88
+ end
89
+
90
+ def owner_id(owner)
91
+ owner.respond_to?(:id) ? owner.id : owner
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maglev
4
+ class ContextPreview
5
+ attr_reader :text, :metadata
6
+
7
+ def initialize(text:, metadata:)
8
+ @text = text
9
+ @metadata = metadata.freeze
10
+ freeze
11
+ end
12
+ end
13
+ end