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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +408 -0
- data/README.zh-CN.md +408 -0
- data/lib/generators/maglev/install/install_generator.rb +70 -0
- data/lib/maglev/active_record_extension.rb +100 -0
- data/lib/maglev/adapters/ruby_llm_attachment_extractor.rb +15 -0
- data/lib/maglev/adapters/ruby_llm_embedding.rb +22 -0
- data/lib/maglev/adapters/ruby_llm_generation.rb +22 -0
- data/lib/maglev/adapters/ruby_llm_provider.rb +64 -0
- data/lib/maglev/answerer.rb +58 -0
- data/lib/maglev/attachment_extractor.rb +106 -0
- data/lib/maglev/authorization.rb +43 -0
- data/lib/maglev/chunk.rb +14 -0
- data/lib/maglev/chunker.rb +58 -0
- data/lib/maglev/configuration.rb +76 -0
- data/lib/maglev/content_source_graph.rb +66 -0
- data/lib/maglev/context_assembler.rb +94 -0
- data/lib/maglev/context_preview.rb +13 -0
- data/lib/maglev/dependency_graph.rb +129 -0
- data/lib/maglev/embedding_adapter.rb +9 -0
- data/lib/maglev/errors.rb +21 -0
- data/lib/maglev/extracted_document.rb +36 -0
- data/lib/maglev/generation_adapter.rb +9 -0
- data/lib/maglev/indexer.rb +175 -0
- data/lib/maglev/knowledge_config.rb +136 -0
- data/lib/maglev/prompt_builder.rb +27 -0
- data/lib/maglev/provider_call.rb +49 -0
- data/lib/maglev/provider_configuration.rb +18 -0
- data/lib/maglev/railtie.rb +19 -0
- data/lib/maglev/reindex_job.rb +16 -0
- data/lib/maglev/response.rb +26 -0
- data/lib/maglev/retriever.rb +89 -0
- data/lib/maglev/schema_compiler.rb +51 -0
- data/lib/maglev/search_result.rb +22 -0
- data/lib/maglev/snapshot.rb +14 -0
- data/lib/maglev/snapshot_builder.rb +204 -0
- data/lib/maglev/vector_stores/base.rb +31 -0
- data/lib/maglev/vector_stores/document.rb +39 -0
- data/lib/maglev/vector_stores/memory.rb +76 -0
- data/lib/maglev/vector_stores/pgvector.rb +94 -0
- data/lib/maglev/version.rb +5 -0
- data/lib/maglev.rb +37 -0
- data/lib/tasks/maglev.rake +31 -0
- 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
|
data/lib/maglev/chunk.rb
ADDED
|
@@ -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
|