smart_rag 0.1.0 → 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/.env.example +252 -0
- data/.rspec +2 -0
- data/AGENTS.md +33 -0
- data/API_DOCUMENTATION.md +828 -0
- data/CHANGELOG.md +16 -1
- data/ER-diagram.mmd +144 -0
- data/Gemfile +50 -0
- data/Gemfile.lock +398 -0
- data/Hybrid_Reranking.md +171 -0
- data/README.en.md +420 -28
- data/README.md +534 -63
- data/Rakefile +268 -0
- data/SETUP_GUIDE.md +650 -0
- data/SmartChunking.md +180 -0
- data/USAGE_EXAMPLES.md +1002 -0
- data/config/llm_config.yml +4 -2
- data/config/smart_rag.yml +45 -1
- data/config.ru +15 -0
- data/db/migrations/006_create_text_search_configs.rb +3 -2
- data/db/migrations/008_create_embeddings.rb +5 -4
- data/db/migrations/012_add_metadata_to_source_sections.rb +11 -0
- data/db/migrations/013_create_media_jobs.rb +25 -0
- data/db/migrations/014_add_media_job_operations_indexes.rb +11 -0
- data/db/migrations/015_add_media_leases_and_objects.rb +80 -0
- data/db/migrations/016_add_document_principals_and_staging_references.rb +38 -0
- data/db/migrations/017_add_media_job_request_fingerprint.rb +48 -0
- data/db/seeds/text_search_configs.sql +3 -3
- data/design.md +1057 -0
- data/docs/API_DOCUMENTATION.md +838 -0
- data/docs/DOCUMENTATION_INDEX.en.md +60 -0
- data/docs/DOCUMENTATION_INDEX.md +65 -0
- data/docs/FIX_SUMMARY.md +256 -0
- data/docs/FIX_SUMMARY_COMPLETE.md +273 -0
- data/docs/Hybrid_Reranking.md +171 -0
- data/docs/MIGRATION_GUIDE.md +151 -0
- data/docs/PERFORMANCE_GUIDE.md +58 -0
- data/docs/SETUP_GUIDE.md +659 -0
- data/docs/SmartChunking.md +180 -0
- data/docs/USAGE_EXAMPLES.md +1008 -0
- data/docs/design.md +1057 -0
- data/docs/evidence_pack.md +211 -0
- data/docs/requirements.md +376 -0
- data/docs/retrieval_plan.md +251 -0
- data/docs/smartrag_improvement_plan.md +201 -0
- data/docs/smartrag_refactor.md +216 -0
- data/docs/todo.md +931 -0
- data/examples/common.rb +1 -1
- data/exe/smart-rag-db +163 -0
- data/exe/smart-rag-media-worker +34 -0
- data/lib/smart_rag/config.rb +12 -0
- data/lib/smart_rag/core/document_processor.rb +80 -16
- data/lib/smart_rag/core/local_content_store.rb +51 -0
- data/lib/smart_rag/core/media_extractors.rb +140 -0
- data/lib/smart_rag/core/media_job_queue.rb +353 -0
- data/lib/smart_rag/core/media_metadata_extractor.rb +188 -0
- data/lib/smart_rag/core/media_object_registry.rb +79 -0
- data/lib/smart_rag/core/media_processor.rb +228 -0
- data/lib/smart_rag/core/media_safety_policy.rb +61 -0
- data/lib/smart_rag/core/s3_content_store.rb +78 -0
- data/lib/smart_rag/core/transcript_normalizer.rb +44 -0
- data/lib/smart_rag/core/video_semantic_extractor.rb +130 -0
- data/lib/smart_rag/http_access_policy.rb +86 -0
- data/lib/smart_rag/http_app.rb +188 -0
- data/lib/smart_rag/models/embedding.rb +1 -1
- data/lib/smart_rag/models/research_topic.rb +1 -1
- data/lib/smart_rag/models/research_topic_section.rb +5 -0
- data/lib/smart_rag/models/research_topic_tag.rb +5 -0
- data/lib/smart_rag/models/search_log.rb +1 -1
- data/lib/smart_rag/models/section_fts.rb +5 -0
- data/lib/smart_rag/models/section_tag.rb +5 -0
- data/lib/smart_rag/models/source_document.rb +1 -1
- data/lib/smart_rag/models/source_section.rb +1 -1
- data/lib/smart_rag/models/tag.rb +1 -1
- data/lib/smart_rag/models/text_search_config.rb +5 -0
- data/lib/smart_rag/retrieve.rb +72 -1
- data/lib/smart_rag/services/embedding_service.rb +1 -1
- data/lib/smart_rag/services/fulltext_search_service.rb +11 -13
- data/lib/smart_rag/services/hybrid_search_service.rb +15 -11
- data/lib/smart_rag/services/summarization_service.rb +1 -1
- data/lib/smart_rag/services/tag_service.rb +1 -1
- data/lib/smart_rag/version.rb +1 -1
- data/lib/smart_rag.rb +264 -30
- data/patch_language.rb +27 -0
- data/requirements.md +376 -0
- data/source_documents_export.json +11072 -0
- data/todo.md +931 -0
- data/workers/analyze_content.rb +6 -2
- data/workers/get_embedding.rb +1 -1
- metadata +151 -38
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'rack'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
require_relative 'http_access_policy'
|
|
9
|
+
require_relative 'core/media_job_queue'
|
|
10
|
+
|
|
11
|
+
module SmartRAG
|
|
12
|
+
class HttpApp
|
|
13
|
+
OPERATIONS = %w[add_document add_media add_image add_audio add_video].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(rag:, extractors: {}, access_policy: nil)
|
|
16
|
+
@rag = rag
|
|
17
|
+
@extractors = symbolize_keys(extractors)
|
|
18
|
+
@access_policy = access_policy
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(env)
|
|
22
|
+
request = Rack::Request.new(env)
|
|
23
|
+
return health if request.get? && request.path == '/healthz'
|
|
24
|
+
principal = authorize(request)
|
|
25
|
+
request.env['smart_rag.principal'] = principal
|
|
26
|
+
return retrieve(request) if request.post? && request.path == '/v1/retrieve'
|
|
27
|
+
return ingest(request) if request.post? && request.path == '/v1/media'
|
|
28
|
+
return list_media_jobs(request) if request.get? && request.path == '/v1/media/jobs'
|
|
29
|
+
if request.get? && request.path == '/v1/media/jobs/stats'
|
|
30
|
+
return json(200, access_policy ? rag.media_job_statistics(principal: principal) : rag.media_job_statistics)
|
|
31
|
+
end
|
|
32
|
+
if request.post? && (match = request.path.match(%r{\A/v1/media/jobs/(\d+)/(cancel|retry)\z}))
|
|
33
|
+
return mutate_media_job(match[1], match[2], principal)
|
|
34
|
+
end
|
|
35
|
+
if request.get? && request.path.match?(%r{\A/v1/media/jobs/\d+\z})
|
|
36
|
+
job_id = request.path.split('/').last
|
|
37
|
+
job = access_policy ? rag.media_job(job_id, principal: principal) : rag.media_job(job_id)
|
|
38
|
+
return job ? json(200, job) : json(404, error: 'media job not found')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
json(404, error: 'not found')
|
|
42
|
+
rescue JSON::ParserError => e
|
|
43
|
+
json(400, error: "invalid JSON: #{e.message}")
|
|
44
|
+
rescue ArgumentError => e
|
|
45
|
+
json(400, error: e.message)
|
|
46
|
+
rescue HttpAccessPolicy::Unauthorized => e
|
|
47
|
+
json(401, error: e.message)
|
|
48
|
+
rescue HttpAccessPolicy::QuotaExceeded => e
|
|
49
|
+
json(429, error: e.message)
|
|
50
|
+
rescue Core::MediaJobQueue::IdempotencyConflict => e
|
|
51
|
+
json(409, error: e.message, code: 'idempotency_conflict')
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
json(500, error: "#{e.class}: #{e.message}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
attr_reader :rag, :extractors, :access_policy
|
|
59
|
+
|
|
60
|
+
def authorize(request)
|
|
61
|
+
return 'anonymous' unless access_policy
|
|
62
|
+
principal = access_policy.authorize!(request)
|
|
63
|
+
bytes = request.post? && request.path == '/v1/media' ? request.content_length.to_i : 0
|
|
64
|
+
access_policy.consume!(principal, request_bytes: bytes)
|
|
65
|
+
principal
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def health
|
|
69
|
+
payload = { status: 'ok' }
|
|
70
|
+
payload[:media_jobs] = rag.media_job_statistics if rag.respond_to?(:media_job_statistics)
|
|
71
|
+
json(200, payload)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
json(503, status: 'degraded', error: "media job queue unavailable: #{e.message}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def list_media_jobs(request)
|
|
77
|
+
options = { status: request.params['status'], limit: request.params.fetch('limit', 20),
|
|
78
|
+
offset: request.params.fetch('offset', 0) }
|
|
79
|
+
options[:principal] = request.env['smart_rag.principal'] if access_policy
|
|
80
|
+
json(200, rag.media_jobs(**options))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def mutate_media_job(job_id, action, principal)
|
|
84
|
+
result = if action == 'cancel'
|
|
85
|
+
access_policy ? rag.cancel_media_job(job_id, principal: principal) : rag.cancel_media_job(job_id)
|
|
86
|
+
else
|
|
87
|
+
access_policy ? rag.retry_media_job(job_id, principal: principal) : rag.retry_media_job(job_id)
|
|
88
|
+
end
|
|
89
|
+
return json(404, error: 'media job not found') unless result
|
|
90
|
+
return json(409, result) if result[:transition_error]
|
|
91
|
+
|
|
92
|
+
json(200, result)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def retrieve(request)
|
|
96
|
+
body = json_body(request)
|
|
97
|
+
plan = symbolize_keys(body.fetch('plan', body))
|
|
98
|
+
plan[:_principal] = request.env['smart_rag.principal'] if access_policy
|
|
99
|
+
json(200, rag.retrieve(plan: plan))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def ingest(request)
|
|
103
|
+
payload, source, cleanup_path = if request.media_type.to_s.start_with?('multipart/form-data')
|
|
104
|
+
multipart_payload(request)
|
|
105
|
+
else
|
|
106
|
+
json_payload(request)
|
|
107
|
+
end
|
|
108
|
+
operation = payload.fetch(:operation).to_s
|
|
109
|
+
raise ArgumentError, "unsupported operation: #{operation}" unless OPERATIONS.include?(operation)
|
|
110
|
+
raise ArgumentError, 'source is required' if source.to_s.empty?
|
|
111
|
+
|
|
112
|
+
options = symbolize_keys(payload[:options] || {}).merge(extractors)
|
|
113
|
+
options[:principal] = request.env['smart_rag.principal'] if access_policy
|
|
114
|
+
if options.delete(:async)
|
|
115
|
+
source, cleanup_path = persist_async_upload(source, cleanup_path)
|
|
116
|
+
staged = rag.stage_media_upload(source) if File.file?(source) && rag.respond_to?(:stage_media_upload)
|
|
117
|
+
if staged
|
|
118
|
+
File.delete(source) if File.file?(source)
|
|
119
|
+
source = staged[:storage_uri]
|
|
120
|
+
options[:staging_media_object_id] = staged[:media_object_id]
|
|
121
|
+
else
|
|
122
|
+
options[:delete_source_after] = true if File.file?(source)
|
|
123
|
+
end
|
|
124
|
+
options[:idempotency_key] ||= request.get_header('HTTP_IDEMPOTENCY_KEY')
|
|
125
|
+
return json(202, rag.enqueue_media(source, options.merge(operation: operation.to_sym)))
|
|
126
|
+
end
|
|
127
|
+
json(200, rag.public_send(operation, source, options))
|
|
128
|
+
ensure
|
|
129
|
+
File.delete(cleanup_path) if cleanup_path && File.exist?(cleanup_path)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def json_payload(request)
|
|
133
|
+
payload = symbolize_keys(json_body(request))
|
|
134
|
+
[payload, payload[:source], nil]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def multipart_payload(request)
|
|
138
|
+
params = request.params
|
|
139
|
+
upload = params['file']
|
|
140
|
+
tempfile = upload.is_a?(Hash) && (upload[:tempfile] || upload['tempfile'])
|
|
141
|
+
filename = upload.is_a?(Hash) && (upload[:filename] || upload['filename'])
|
|
142
|
+
raise ArgumentError, 'multipart file is required' unless tempfile
|
|
143
|
+
|
|
144
|
+
extension = File.extname(filename.to_s)
|
|
145
|
+
source = tempfile.path
|
|
146
|
+
unless extension.empty? || File.extname(source).downcase == extension.downcase
|
|
147
|
+
preserved = File.join(Dir.tmpdir, "smart-rag-upload-#{Process.pid}-#{object_id}#{extension}")
|
|
148
|
+
FileUtils.cp(source, preserved)
|
|
149
|
+
source = preserved
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
payload = {
|
|
153
|
+
operation: params['operation'],
|
|
154
|
+
options: params['options'].to_s.empty? ? {} : JSON.parse(params['options'])
|
|
155
|
+
}
|
|
156
|
+
[symbolize_keys(payload), source, source == tempfile.path ? nil : source]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def json_body(request)
|
|
160
|
+
content = request.body.read
|
|
161
|
+
content.empty? ? {} : JSON.parse(content)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def persist_async_upload(source, cleanup_path)
|
|
165
|
+
return [source, cleanup_path] unless File.file?(source)
|
|
166
|
+
|
|
167
|
+
directory = ENV.fetch('SMARTRAG_MEDIA_JOB_UPLOAD_DIR', File.join(Dir.tmpdir, 'smart-rag-media-jobs'))
|
|
168
|
+
FileUtils.mkdir_p(directory)
|
|
169
|
+
extension = File.extname(source)
|
|
170
|
+
destination = File.join(directory, "#{SecureRandom.uuid}#{extension}")
|
|
171
|
+
FileUtils.cp(source, destination)
|
|
172
|
+
[destination, cleanup_path]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def symbolize_keys(value)
|
|
176
|
+
return value.map { |item| symbolize_keys(item) } if value.is_a?(Array)
|
|
177
|
+
return value unless value.is_a?(Hash)
|
|
178
|
+
|
|
179
|
+
value.each_with_object({}) do |(key, item), result|
|
|
180
|
+
result[key.to_sym] = symbolize_keys(item)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def json(status, payload)
|
|
185
|
+
[status, { 'content-type' => 'application/json' }, [JSON.generate(payload)]]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -7,7 +7,7 @@ module SmartRAG
|
|
|
7
7
|
class ResearchTopic < Sequel::Model
|
|
8
8
|
# Set dataset after database is connected
|
|
9
9
|
def self.set_dataset_from_db
|
|
10
|
-
set_dataset(:research_topics)
|
|
10
|
+
set_dataset(Sequel::Model.db[:research_topics])
|
|
11
11
|
end
|
|
12
12
|
include FactoryBotHelpers
|
|
13
13
|
plugin :validation_helpers
|
|
@@ -5,6 +5,11 @@ module SmartRAG
|
|
|
5
5
|
module Models
|
|
6
6
|
# ResearchTopicSection model for many-to-many relationship
|
|
7
7
|
class ResearchTopicSection < Sequel::Model(:research_topic_sections)
|
|
8
|
+
# Set dataset after database is connected
|
|
9
|
+
def self.set_dataset_from_db
|
|
10
|
+
set_dataset(Sequel::Model.db[:research_topic_sections])
|
|
11
|
+
end
|
|
12
|
+
|
|
8
13
|
include FactoryBotHelpers
|
|
9
14
|
plugin :validation_helpers
|
|
10
15
|
plugin :timestamps, update_on_create: false
|
|
@@ -5,6 +5,11 @@ module SmartRAG
|
|
|
5
5
|
module Models
|
|
6
6
|
# ResearchTopicTag model for many-to-many relationship between topics and tags
|
|
7
7
|
class ResearchTopicTag < Sequel::Model(:research_topic_tags)
|
|
8
|
+
# Set dataset after database is connected
|
|
9
|
+
def self.set_dataset_from_db
|
|
10
|
+
set_dataset(Sequel::Model.db[:research_topic_tags])
|
|
11
|
+
end
|
|
12
|
+
|
|
8
13
|
include FactoryBotHelpers
|
|
9
14
|
plugin :validation_helpers
|
|
10
15
|
plugin :timestamps, update_on_create: false
|
|
@@ -7,7 +7,7 @@ module SmartRAG
|
|
|
7
7
|
class SearchLog < Sequel::Model
|
|
8
8
|
# Set dataset after database is connected
|
|
9
9
|
def self.set_dataset_from_db
|
|
10
|
-
set_dataset(:search_logs)
|
|
10
|
+
set_dataset(Sequel::Model.db[:search_logs])
|
|
11
11
|
end
|
|
12
12
|
include FactoryBotHelpers
|
|
13
13
|
plugin :validation_helpers
|
|
@@ -5,6 +5,11 @@ module SmartRAG
|
|
|
5
5
|
module Models
|
|
6
6
|
# SectionFts model for full-text search optimization
|
|
7
7
|
class SectionFts < Sequel::Model(:section_fts)
|
|
8
|
+
# Set dataset after database is connected
|
|
9
|
+
def self.set_dataset_from_db
|
|
10
|
+
set_dataset(Sequel::Model.db[:section_fts])
|
|
11
|
+
end
|
|
12
|
+
|
|
8
13
|
include FactoryBotHelpers
|
|
9
14
|
plugin :validation_helpers
|
|
10
15
|
plugin :timestamps, update_on_create: true
|
|
@@ -5,6 +5,11 @@ module SmartRAG
|
|
|
5
5
|
module Models
|
|
6
6
|
# SectionTag model for many-to-many relationship between sections and tags
|
|
7
7
|
class SectionTag < Sequel::Model(:section_tags)
|
|
8
|
+
# Set dataset after database is connected
|
|
9
|
+
def self.set_dataset_from_db
|
|
10
|
+
set_dataset(Sequel::Model.db[:section_tags])
|
|
11
|
+
end
|
|
12
|
+
|
|
8
13
|
include FactoryBotHelpers
|
|
9
14
|
plugin :validation_helpers
|
|
10
15
|
plugin :timestamps, update_on_create: false
|
|
@@ -7,7 +7,7 @@ module SmartRAG
|
|
|
7
7
|
class SourceDocument < Sequel::Model
|
|
8
8
|
# Set dataset after database is connected
|
|
9
9
|
def self.set_dataset_from_db
|
|
10
|
-
set_dataset(:source_documents)
|
|
10
|
+
set_dataset(Sequel::Model.db[:source_documents])
|
|
11
11
|
end
|
|
12
12
|
plugin :validation_helpers
|
|
13
13
|
plugin :timestamps, update_on_create: true
|
|
@@ -7,7 +7,7 @@ module SmartRAG
|
|
|
7
7
|
class SourceSection < Sequel::Model
|
|
8
8
|
# Set dataset after database is connected
|
|
9
9
|
def self.set_dataset_from_db
|
|
10
|
-
set_dataset(:source_sections)
|
|
10
|
+
set_dataset(Sequel::Model.db[:source_sections])
|
|
11
11
|
end
|
|
12
12
|
include FactoryBotHelpers
|
|
13
13
|
plugin :validation_helpers
|
data/lib/smart_rag/models/tag.rb
CHANGED
|
@@ -5,6 +5,11 @@ module SmartRAG
|
|
|
5
5
|
module Models
|
|
6
6
|
# TextSearchConfig model for language-specific search configurations
|
|
7
7
|
class TextSearchConfig < Sequel::Model(:text_search_configs)
|
|
8
|
+
# Set dataset after database is connected
|
|
9
|
+
def self.set_dataset_from_db
|
|
10
|
+
set_dataset(Sequel::Model.db[:text_search_configs])
|
|
11
|
+
end
|
|
12
|
+
|
|
8
13
|
include FactoryBotHelpers
|
|
9
14
|
plugin :validation_helpers
|
|
10
15
|
# This model uses text_search_configs table with language_code as PK
|
data/lib/smart_rag/retrieve.rb
CHANGED
|
@@ -17,8 +17,12 @@ module SmartRAG
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def execute(plan:)
|
|
20
|
+
@document_metadata_cache = {}
|
|
21
|
+
@section_topic_cache = {}
|
|
20
22
|
normalized_plan = normalize_plan(plan)
|
|
21
23
|
validate_plan!(normalized_plan)
|
|
24
|
+
principal = normalized_plan.delete(:_principal)
|
|
25
|
+
normalized_plan = scope_plan_to_principal(normalized_plan, principal) if principal
|
|
22
26
|
|
|
23
27
|
started_at = monotonic_now
|
|
24
28
|
generated_at = Time.now.utc.iso8601
|
|
@@ -57,7 +61,7 @@ module SmartRAG
|
|
|
57
61
|
|
|
58
62
|
pack = {
|
|
59
63
|
version: normalized_plan[:version] || '0.1',
|
|
60
|
-
plan: normalized_plan,
|
|
64
|
+
plan: public_plan(normalized_plan),
|
|
61
65
|
plan_id: plan_id,
|
|
62
66
|
request_id: request_id,
|
|
63
67
|
generated_at: generated_at,
|
|
@@ -345,6 +349,9 @@ module SmartRAG
|
|
|
345
349
|
if filters[:source_type]
|
|
346
350
|
applied[:source_type] = Array(filters[:source_type]).map(&:to_s)
|
|
347
351
|
end
|
|
352
|
+
if filters[:media_type]
|
|
353
|
+
applied[:media_type] = Array(filters[:media_type]).map(&:to_s)
|
|
354
|
+
end
|
|
348
355
|
if filters[:source_uri_prefix]
|
|
349
356
|
applied[:source_uri_prefix] = Array(filters[:source_uri_prefix]).map(&:to_s)
|
|
350
357
|
end
|
|
@@ -479,6 +486,12 @@ module SmartRAG
|
|
|
479
486
|
return false unless allowed.include?(actual)
|
|
480
487
|
end
|
|
481
488
|
|
|
489
|
+
if filters[:media_type]
|
|
490
|
+
allowed = Array(filters[:media_type]).map { |value| value.to_s.downcase }
|
|
491
|
+
actual = candidate.dig(:metadata, :media_type) || candidate.dig(:metadata, 'media_type')
|
|
492
|
+
return false unless allowed.include?(actual.to_s.downcase)
|
|
493
|
+
end
|
|
494
|
+
|
|
482
495
|
if filters[:source_uri_prefix]
|
|
483
496
|
prefixes = Array(filters[:source_uri_prefix]).map(&:to_s)
|
|
484
497
|
uri = candidate[:source_uri].to_s
|
|
@@ -491,6 +504,11 @@ module SmartRAG
|
|
|
491
504
|
return false if required_topic_ids.any? && (required_topic_ids & section_topics).empty?
|
|
492
505
|
end
|
|
493
506
|
|
|
507
|
+
if filters[:principal]
|
|
508
|
+
actual = document_principal_for(candidate[:document_id])
|
|
509
|
+
return false unless actual == filters[:principal].to_s
|
|
510
|
+
end
|
|
511
|
+
|
|
494
512
|
true
|
|
495
513
|
end
|
|
496
514
|
|
|
@@ -520,9 +538,46 @@ module SmartRAG
|
|
|
520
538
|
|
|
521
539
|
metadata[:section_id] ||= section_id if section_id
|
|
522
540
|
metadata[:document_id] ||= document_id if document_id
|
|
541
|
+
section_metadata = extract_section_metadata(section)
|
|
542
|
+
metadata = document_metadata_for(document_id).merge(metadata).merge(section_metadata)
|
|
523
543
|
metadata
|
|
524
544
|
end
|
|
525
545
|
|
|
546
|
+
def extract_section_metadata(section)
|
|
547
|
+
raw = if section.is_a?(Hash)
|
|
548
|
+
section[:metadata] || section['metadata']
|
|
549
|
+
elsif section.respond_to?(:metadata)
|
|
550
|
+
section.metadata
|
|
551
|
+
end
|
|
552
|
+
parsed = raw.is_a?(String) ? JSON.parse(raw) : raw
|
|
553
|
+
parsed.is_a?(Hash) ? symbolize_keys(parsed) : {}
|
|
554
|
+
rescue JSON::ParserError
|
|
555
|
+
{}
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def document_metadata_for(document_id)
|
|
559
|
+
return {} if document_id.nil?
|
|
560
|
+
return {} unless defined?(::SmartRAG) && ::SmartRAG.respond_to?(:db) && ::SmartRAG.db
|
|
561
|
+
|
|
562
|
+
@document_metadata_cache ||= {}
|
|
563
|
+
return @document_metadata_cache[document_id] if @document_metadata_cache.key?(document_id)
|
|
564
|
+
|
|
565
|
+
raw = ::SmartRAG.db[:source_documents].where(id: document_id).get(:metadata)
|
|
566
|
+
parsed = raw.is_a?(String) ? JSON.parse(raw) : raw
|
|
567
|
+
@document_metadata_cache[document_id] = parsed.is_a?(Hash) ? symbolize_keys(parsed) : {}
|
|
568
|
+
rescue StandardError
|
|
569
|
+
{}
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def document_principal_for(document_id)
|
|
573
|
+
return nil if document_id.nil? || !::SmartRAG.db
|
|
574
|
+
@document_principal_cache ||= {}
|
|
575
|
+
return @document_principal_cache[document_id] if @document_principal_cache.key?(document_id)
|
|
576
|
+
@document_principal_cache[document_id] = ::SmartRAG.db[:source_documents].where(id: document_id).get(:principal)
|
|
577
|
+
rescue StandardError
|
|
578
|
+
nil
|
|
579
|
+
end
|
|
580
|
+
|
|
526
581
|
def extract_vector_score(result, mode)
|
|
527
582
|
return numeric_or_nil(result[:vector_score]) if result.is_a?(Hash) && result.key?(:vector_score)
|
|
528
583
|
return numeric_or_nil(result[:similarity]) if mode == 'semantic' && result.is_a?(Hash) && result.key?(:similarity)
|
|
@@ -575,6 +630,22 @@ module SmartRAG
|
|
|
575
630
|
deep_symbolize(plan)
|
|
576
631
|
end
|
|
577
632
|
|
|
633
|
+
def scope_plan_to_principal(plan, principal)
|
|
634
|
+
allowed_ids = ::SmartRAG.db[:source_documents].where(principal: principal.to_s).select_map(:id)
|
|
635
|
+
requested = Array(plan.dig(:global_filters, :document_ids)).map(&:to_i)
|
|
636
|
+
allowed_ids &= requested unless requested.empty?
|
|
637
|
+
filters = symbolize_keys(plan[:global_filters] || {}).merge(
|
|
638
|
+
document_ids: allowed_ids,
|
|
639
|
+
principal: principal.to_s
|
|
640
|
+
)
|
|
641
|
+
plan.merge(global_filters: filters)
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def public_plan(plan)
|
|
645
|
+
filters = symbolize_keys(plan[:global_filters] || {}).reject { |key, _| key == :principal }
|
|
646
|
+
plan.merge(global_filters: filters)
|
|
647
|
+
end
|
|
648
|
+
|
|
578
649
|
def validate_plan!(plan)
|
|
579
650
|
raise ArgumentError, 'Retrieval plan must be a hash' unless plan.is_a?(Hash)
|
|
580
651
|
raise ArgumentError, 'Retrieval plan requires queries' unless plan[:queries].is_a?(Array) && !plan[:queries].empty?
|
|
@@ -24,7 +24,7 @@ module SmartRAG
|
|
|
24
24
|
Dir.glob(File.join(workers_dir, '*.rb')).each { |file| require file }
|
|
25
25
|
|
|
26
26
|
# Initialize SmartPrompt engine
|
|
27
|
-
config_path = @config[:config_path] || 'config/llm_config.yml'
|
|
27
|
+
config_path = @config[:config_path] || File.expand_path('../../../config/llm_config.yml', __dir__)
|
|
28
28
|
@smart_prompt_engine = SmartPrompt::Engine.new(config_path)
|
|
29
29
|
rescue StandardError => e
|
|
30
30
|
log_error('Failed to initialize SmartPrompt engine', e)
|
|
@@ -319,7 +319,8 @@ module SmartRAG
|
|
|
319
319
|
Sequel[:source_documents][:author],
|
|
320
320
|
Sequel[:source_documents][:publication_date],
|
|
321
321
|
Sequel[:source_sections][:section_number],
|
|
322
|
-
Sequel[:source_documents][:metadata]
|
|
322
|
+
Sequel[:source_documents][:metadata].as(:document_metadata),
|
|
323
|
+
Sequel[:source_sections][:metadata].as(:section_metadata)
|
|
323
324
|
)
|
|
324
325
|
|
|
325
326
|
result = dataset.first
|
|
@@ -333,21 +334,18 @@ module SmartRAG
|
|
|
333
334
|
section_number: result[:section_number]
|
|
334
335
|
}
|
|
335
336
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
begin
|
|
339
|
-
parsed = JSON.parse(result[:metadata])
|
|
340
|
-
metadata.merge!(parsed) if parsed.is_a?(Hash)
|
|
341
|
-
rescue JSON::ParserError
|
|
342
|
-
# Ignore malformed metadata strings
|
|
343
|
-
end
|
|
344
|
-
elsif result[:metadata].is_a?(Hash)
|
|
345
|
-
metadata.merge!(result[:metadata])
|
|
346
|
-
end
|
|
347
|
-
end
|
|
337
|
+
metadata.merge!(parse_metadata(result[:document_metadata]))
|
|
338
|
+
metadata.merge!(parse_metadata(result[:section_metadata]))
|
|
348
339
|
metadata
|
|
349
340
|
end
|
|
350
341
|
|
|
342
|
+
def parse_metadata(value)
|
|
343
|
+
parsed = value.is_a?(String) ? JSON.parse(value) : value
|
|
344
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
345
|
+
rescue JSON::ParserError
|
|
346
|
+
{}
|
|
347
|
+
end
|
|
348
|
+
|
|
351
349
|
# Simplify result for quick search
|
|
352
350
|
def simplify_result(result)
|
|
353
351
|
{
|
|
@@ -702,6 +702,12 @@ module SmartRAG
|
|
|
702
702
|
document_id: document_id
|
|
703
703
|
}
|
|
704
704
|
end
|
|
705
|
+
section_metadata = if section.is_a?(Hash)
|
|
706
|
+
section[:metadata] || section['metadata']
|
|
707
|
+
elsif section.respond_to?(:metadata)
|
|
708
|
+
section.metadata
|
|
709
|
+
end
|
|
710
|
+
base_metadata.merge!(parse_metadata(section_metadata))
|
|
705
711
|
|
|
706
712
|
if document_id && document_id != ''
|
|
707
713
|
begin
|
|
@@ -715,18 +721,9 @@ module SmartRAG
|
|
|
715
721
|
# Merge document metadata (may contain category, author, etc.)
|
|
716
722
|
if doc[:metadata]
|
|
717
723
|
@logger.debug "Document metadata found: #{doc[:metadata].inspect}"
|
|
718
|
-
parsed_metadata =
|
|
719
|
-
begin
|
|
720
|
-
JSON.parse(doc[:metadata])
|
|
721
|
-
rescue StandardError
|
|
722
|
-
{}
|
|
723
|
-
end
|
|
724
|
-
else
|
|
725
|
-
doc[:metadata]
|
|
726
|
-
end
|
|
727
|
-
parsed_metadata = symbolize_keys(parsed_metadata) if parsed_metadata.is_a?(Hash)
|
|
724
|
+
parsed_metadata = parse_metadata(doc[:metadata])
|
|
728
725
|
@logger.debug "Parsed metadata: #{parsed_metadata.inspect}"
|
|
729
|
-
base_metadata
|
|
726
|
+
base_metadata = parsed_metadata.merge(base_metadata)
|
|
730
727
|
else
|
|
731
728
|
@logger.debug 'Document has no metadata field or is nil'
|
|
732
729
|
end
|
|
@@ -753,6 +750,13 @@ module SmartRAG
|
|
|
753
750
|
end
|
|
754
751
|
end
|
|
755
752
|
|
|
753
|
+
def parse_metadata(value)
|
|
754
|
+
parsed = value.is_a?(String) ? JSON.parse(value) : value
|
|
755
|
+
parsed.is_a?(Hash) ? symbolize_keys(parsed) : {}
|
|
756
|
+
rescue JSON::ParserError
|
|
757
|
+
{}
|
|
758
|
+
end
|
|
759
|
+
|
|
756
760
|
def calculate_score_stats(results)
|
|
757
761
|
return {} if results.empty?
|
|
758
762
|
|
|
@@ -26,7 +26,7 @@ module SmartRAG
|
|
|
26
26
|
Dir.glob(File.join(workers_dir, '*.rb')).each { |file| require file }
|
|
27
27
|
|
|
28
28
|
# Initialize SmartPrompt engine
|
|
29
|
-
config_path = @config[:config_path] || "config/llm_config.yml"
|
|
29
|
+
config_path = @config[:config_path] || File.expand_path("../../../config/llm_config.yml", __dir__)
|
|
30
30
|
@smart_prompt_engine = SmartPrompt::Engine.new(config_path)
|
|
31
31
|
|
|
32
32
|
@logger.info "SummarizationService initialized"
|
|
@@ -27,7 +27,7 @@ module SmartRAG
|
|
|
27
27
|
Dir.glob(File.join(workers_dir, "*.rb")).each { |file| require file }
|
|
28
28
|
|
|
29
29
|
# Initialize SmartPrompt engine
|
|
30
|
-
config_path = @config[:config_path] || "config/llm_config.yml"
|
|
30
|
+
config_path = @config[:config_path] || File.expand_path("../../../config/llm_config.yml", __dir__)
|
|
31
31
|
@smart_prompt_engine = SmartPrompt::Engine.new(config_path)
|
|
32
32
|
rescue StandardError => e
|
|
33
33
|
log_error("Failed to initialize TagService", e)
|
data/lib/smart_rag/version.rb
CHANGED