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,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'time'
|
|
7
|
+
|
|
8
|
+
module SmartRAG
|
|
9
|
+
module Core
|
|
10
|
+
class MediaJobQueue
|
|
11
|
+
class IdempotencyConflict < StandardError; end
|
|
12
|
+
|
|
13
|
+
STATUSES = %w[queued processing completed partial failed canceled].freeze
|
|
14
|
+
TERMINAL_STATUSES = %w[completed partial failed canceled].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(db:, handler:, clock: -> { Time.now }, heartbeat_interval_seconds: 30)
|
|
17
|
+
@db = db
|
|
18
|
+
@handler = handler
|
|
19
|
+
@clock = clock
|
|
20
|
+
@heartbeat_interval_seconds = [heartbeat_interval_seconds.to_f, 0.1].max
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enqueue(operation:, source:, options: {}, max_attempts: 3, idempotency_key: nil,
|
|
24
|
+
principal: 'system', staging_media_object_id: nil)
|
|
25
|
+
ensure_available!
|
|
26
|
+
key = normalize_idempotency_key(idempotency_key)
|
|
27
|
+
owner = normalize_principal(principal)
|
|
28
|
+
serialized_options = serializable_options(options)
|
|
29
|
+
fingerprint = request_fingerprint(operation, source, serialized_options)
|
|
30
|
+
existing = key && db[:media_jobs].where(principal: owner, idempotency_key: key).first
|
|
31
|
+
return deduplicate(existing, fingerprint) if existing
|
|
32
|
+
|
|
33
|
+
attributes = {
|
|
34
|
+
operation: operation.to_s, source: source.to_s,
|
|
35
|
+
options: JSON.generate(serialized_options), request_fingerprint: fingerprint,
|
|
36
|
+
status: 'queued', attempts: 0, max_attempts: max_attempts,
|
|
37
|
+
idempotency_key: key, principal: owner, staging_media_object_id: staging_media_object_id,
|
|
38
|
+
available_at: clock.call, created_at: clock.call, updated_at: clock.call
|
|
39
|
+
}
|
|
40
|
+
id = db.transaction(savepoint: true) { db[:media_jobs].insert(attributes) }
|
|
41
|
+
job(id)
|
|
42
|
+
rescue Sequel::UniqueConstraintViolation
|
|
43
|
+
raise unless key
|
|
44
|
+
existing = db[:media_jobs].where(principal: owner, idempotency_key: key).first
|
|
45
|
+
existing ? deduplicate(existing, fingerprint) : raise
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def job(id, principal: nil)
|
|
49
|
+
ensure_available!
|
|
50
|
+
row = scoped_jobs(principal).where(id: id.to_i).first
|
|
51
|
+
row && normalize(row)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def list(status: nil, limit: 20, offset: 0, principal: nil)
|
|
55
|
+
ensure_available!
|
|
56
|
+
statuses = normalize_statuses(status)
|
|
57
|
+
dataset = scoped_jobs(principal)
|
|
58
|
+
dataset = dataset.where(status: statuses) unless statuses.empty?
|
|
59
|
+
total = dataset.count
|
|
60
|
+
jobs = dataset.order(Sequel.desc(:created_at)).limit(clamp(limit, 1, 100), [offset.to_i, 0].max)
|
|
61
|
+
.all.map { |row| normalize(row) }
|
|
62
|
+
{ jobs: jobs, total: total, limit: clamp(limit, 1, 100), offset: [offset.to_i, 0].max }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def cancel(id, principal: nil)
|
|
66
|
+
mutate_job(id, principal: principal) do |row|
|
|
67
|
+
next transition_error(row, 'only queued jobs can be canceled') unless row[:status] == 'queued'
|
|
68
|
+
|
|
69
|
+
db[:media_jobs].where(id: row[:id]).update(
|
|
70
|
+
status: 'canceled', error: nil, finished_at: clock.call, updated_at: clock.call
|
|
71
|
+
)
|
|
72
|
+
cleanup_source(row, parse_json(row[:options]))
|
|
73
|
+
job(row[:id])
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def retry_job(id, principal: nil)
|
|
78
|
+
mutate_job(id, principal: principal) do |row|
|
|
79
|
+
next transition_error(row, 'only failed jobs can be retried') unless row[:status] == 'failed'
|
|
80
|
+
options = parse_json(row[:options]) || {}
|
|
81
|
+
if (options['delete_source_after'] || options[:delete_source_after]) && !File.file?(row[:source])
|
|
82
|
+
next transition_error(row, 'media job source is no longer available')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
db[:media_jobs].where(id: row[:id]).update(
|
|
86
|
+
status: 'queued', attempts: 0, result: nil, error: nil, available_at: clock.call,
|
|
87
|
+
started_at: nil, finished_at: nil, heartbeat_at: nil, lease_token: nil, updated_at: clock.call
|
|
88
|
+
)
|
|
89
|
+
job(row[:id])
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def recover_stale(timeout_seconds: 900)
|
|
94
|
+
ensure_available!
|
|
95
|
+
cutoff = clock.call - [timeout_seconds.to_i, 1].max
|
|
96
|
+
db[:media_jobs].where(status: 'processing')
|
|
97
|
+
.where { Sequel.function(:coalesce, :heartbeat_at, :started_at) < cutoff }.update(
|
|
98
|
+
status: 'queued', error: 'worker lease expired; job requeued', available_at: clock.call,
|
|
99
|
+
started_at: nil, heartbeat_at: nil, lease_token: nil, updated_at: clock.call
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def prune(retention_seconds: 604_800)
|
|
104
|
+
ensure_available!
|
|
105
|
+
cutoff = clock.call - [retention_seconds.to_i, 0].max
|
|
106
|
+
rows = db[:media_jobs].where(status: TERMINAL_STATUSES).where { finished_at < cutoff }.all
|
|
107
|
+
rows.each { |row| cleanup_source(row, parse_json(row[:options])) }
|
|
108
|
+
db[:media_jobs].where(id: rows.map { |row| row[:id] }).delete
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def statistics(stale_after_seconds: 900, principal: nil)
|
|
112
|
+
ensure_available!
|
|
113
|
+
counts = STATUSES.to_h { |status| [status.to_sym, 0] }
|
|
114
|
+
dataset = scoped_jobs(principal)
|
|
115
|
+
dataset.group_and_count(:status).all.each do |row|
|
|
116
|
+
counts[row[:status].to_sym] = row[:count].to_i
|
|
117
|
+
end
|
|
118
|
+
now = clock.call
|
|
119
|
+
oldest = dataset.where(status: 'queued').min(:created_at)
|
|
120
|
+
stale_cutoff = now - [stale_after_seconds.to_i, 1].max
|
|
121
|
+
{
|
|
122
|
+
counts: counts,
|
|
123
|
+
total: counts.values.sum,
|
|
124
|
+
oldest_queued_age_seconds: oldest ? [(now - oldest).round, 0].max : nil,
|
|
125
|
+
stale_processing: dataset.where(status: 'processing')
|
|
126
|
+
.where { Sequel.function(:coalesce, :heartbeat_at, :started_at) < stale_cutoff }.count
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def run_one
|
|
131
|
+
row = claim
|
|
132
|
+
return nil unless row
|
|
133
|
+
|
|
134
|
+
options = parse_json(row[:options])
|
|
135
|
+
result = with_heartbeat(row) do
|
|
136
|
+
handler.call(row[:operation].to_sym, row[:source], symbolize_keys(options))
|
|
137
|
+
end
|
|
138
|
+
finish(row, result, options)
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
raise unless row
|
|
141
|
+
fail_or_retry(row, e)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def run(limit: 1)
|
|
145
|
+
Array.new(limit.to_i.clamp(1, 100)).filter_map { run_one }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
attr_reader :db, :handler, :clock, :heartbeat_interval_seconds
|
|
151
|
+
|
|
152
|
+
def claim
|
|
153
|
+
row = nil
|
|
154
|
+
db.transaction do
|
|
155
|
+
dataset = db[:media_jobs].where(status: 'queued')
|
|
156
|
+
.where { available_at <= Sequel.function(:clock_timestamp) }
|
|
157
|
+
.order(:created_at).for_update.skip_locked
|
|
158
|
+
row = dataset.first
|
|
159
|
+
if row
|
|
160
|
+
lease_token = SecureRandom.hex(16)
|
|
161
|
+
db[:media_jobs].where(id: row[:id]).update(
|
|
162
|
+
status: 'processing', attempts: row[:attempts].to_i + 1,
|
|
163
|
+
started_at: clock.call, heartbeat_at: clock.call, lease_token: lease_token,
|
|
164
|
+
updated_at: clock.call, error: nil
|
|
165
|
+
)
|
|
166
|
+
row = db[:media_jobs].where(id: row[:id]).first
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
row
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def finish(row, result, options)
|
|
173
|
+
status = result[:status].to_s == 'partial' ? 'partial' : 'completed'
|
|
174
|
+
updated = leased_job(row).update(
|
|
175
|
+
status: status, result: JSON.generate(result), finished_at: clock.call,
|
|
176
|
+
heartbeat_at: nil, lease_token: nil, updated_at: clock.call
|
|
177
|
+
)
|
|
178
|
+
raise 'media job lease lost before completion' if updated.zero?
|
|
179
|
+
cleanup_source(row, options)
|
|
180
|
+
job(row[:id])
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def fail_or_retry(row, error)
|
|
184
|
+
attempts = row[:attempts].to_i
|
|
185
|
+
max_attempts = row[:max_attempts].to_i
|
|
186
|
+
if attempts < max_attempts
|
|
187
|
+
updated = leased_job(row).update(
|
|
188
|
+
status: 'queued', error: "#{error.class}: #{error.message}",
|
|
189
|
+
available_at: clock.call + (2**attempts), heartbeat_at: nil, lease_token: nil,
|
|
190
|
+
updated_at: clock.call
|
|
191
|
+
)
|
|
192
|
+
else
|
|
193
|
+
updated = leased_job(row).update(
|
|
194
|
+
status: 'failed', error: "#{error.class}: #{error.message}",
|
|
195
|
+
finished_at: clock.call, heartbeat_at: nil, lease_token: nil, updated_at: clock.call
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
raise 'media job lease lost while recording failure' if updated.zero?
|
|
199
|
+
job(row[:id])
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def with_heartbeat(row)
|
|
203
|
+
mutex = Mutex.new
|
|
204
|
+
condition = ConditionVariable.new
|
|
205
|
+
stopped = false
|
|
206
|
+
thread = Thread.new do
|
|
207
|
+
loop do
|
|
208
|
+
mutex.synchronize { condition.wait(mutex, heartbeat_interval_seconds) unless stopped }
|
|
209
|
+
break if mutex.synchronize { stopped }
|
|
210
|
+
break if heartbeat(row).zero?
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
yield
|
|
214
|
+
ensure
|
|
215
|
+
mutex&.synchronize do
|
|
216
|
+
stopped = true
|
|
217
|
+
condition.broadcast
|
|
218
|
+
end
|
|
219
|
+
thread&.join
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def heartbeat(row)
|
|
223
|
+
leased_job(row).update(heartbeat_at: clock.call, updated_at: clock.call)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def leased_job(row)
|
|
227
|
+
db[:media_jobs].where(id: row[:id], status: 'processing', lease_token: row[:lease_token])
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def normalize(row)
|
|
231
|
+
row.merge(options: parse_json(row[:options]), result: parse_json(row[:result]))
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def parse_json(value)
|
|
235
|
+
return value if value.is_a?(Hash)
|
|
236
|
+
return nil if value.nil?
|
|
237
|
+
JSON.parse(value)
|
|
238
|
+
rescue JSON::ParserError
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def serializable_options(options)
|
|
243
|
+
serialize(options)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def request_fingerprint(operation, source, options)
|
|
247
|
+
payload = {
|
|
248
|
+
'operation' => operation.to_s,
|
|
249
|
+
'source' => source.to_s,
|
|
250
|
+
'options' => canonicalize(options)
|
|
251
|
+
}
|
|
252
|
+
Digest::SHA256.hexdigest(JSON.generate(payload))
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def canonicalize(value)
|
|
256
|
+
case value
|
|
257
|
+
when Hash
|
|
258
|
+
value.keys.map(&:to_s).sort.to_h do |key|
|
|
259
|
+
original_key = value.key?(key) ? key : value.keys.find { |candidate| candidate.to_s == key }
|
|
260
|
+
[key, canonicalize(value[original_key])]
|
|
261
|
+
end
|
|
262
|
+
when Array then value.map { |item| canonicalize(item) }
|
|
263
|
+
else value
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def deduplicate(existing, fingerprint)
|
|
268
|
+
unless existing[:request_fingerprint].to_s == fingerprint
|
|
269
|
+
raise IdempotencyConflict, 'idempotency key was already used with a different request payload'
|
|
270
|
+
end
|
|
271
|
+
normalize(existing).merge(deduplicated: true)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def serialize(value)
|
|
275
|
+
case value
|
|
276
|
+
when Hash
|
|
277
|
+
value.each_with_object({}) do |(key, item), result|
|
|
278
|
+
next if item.respond_to?(:call) || item.respond_to?(:extract)
|
|
279
|
+
result[key.to_s] = serialize(item)
|
|
280
|
+
end
|
|
281
|
+
when Array then value.map { |item| serialize(item) }
|
|
282
|
+
when Symbol then value.to_s
|
|
283
|
+
when Time then value.iso8601
|
|
284
|
+
else value
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def symbolize_keys(hash)
|
|
289
|
+
return hash.map { |value| value.is_a?(Hash) ? symbolize_keys(value) : value } if hash.is_a?(Array)
|
|
290
|
+
return hash unless hash.is_a?(Hash)
|
|
291
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
292
|
+
result[key.to_sym] = value.is_a?(Hash) || value.is_a?(Array) ? symbolize_keys(value) : value
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def ensure_available!
|
|
297
|
+
raise 'media job queue requires a database connection' unless db
|
|
298
|
+
raise 'media_jobs table is missing; run database migrations' unless db.table_exists?(:media_jobs)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def mutate_job(id, principal: nil)
|
|
302
|
+
ensure_available!
|
|
303
|
+
result = nil
|
|
304
|
+
db.transaction do
|
|
305
|
+
row = scoped_jobs(principal).where(id: id.to_i).for_update.first
|
|
306
|
+
result = row ? yield(row) : nil
|
|
307
|
+
end
|
|
308
|
+
result
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def transition_error(row, message)
|
|
312
|
+
normalize(row).merge(transition_error: message)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def normalize_statuses(status)
|
|
316
|
+
values = Array(status).flat_map { |value| value.to_s.split(',') }.reject(&:empty?)
|
|
317
|
+
invalid = values - STATUSES
|
|
318
|
+
raise ArgumentError, "invalid media job status: #{invalid.join(', ')}" unless invalid.empty?
|
|
319
|
+
values
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def clamp(value, minimum, maximum)
|
|
323
|
+
[[value.to_i, minimum].max, maximum].min
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def normalize_idempotency_key(value)
|
|
327
|
+
key = value.to_s.strip
|
|
328
|
+
return nil if key.empty?
|
|
329
|
+
raise ArgumentError, 'idempotency_key exceeds 128 characters' if key.length > 128
|
|
330
|
+
key
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def normalize_principal(value)
|
|
334
|
+
principal = value.to_s.strip
|
|
335
|
+
principal = 'system' if principal.empty?
|
|
336
|
+
raise ArgumentError, 'principal exceeds 128 characters' if principal.length > 128
|
|
337
|
+
principal
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def scoped_jobs(principal)
|
|
341
|
+
dataset = db[:media_jobs]
|
|
342
|
+
principal.to_s.empty? ? dataset : dataset.where(principal: normalize_principal(principal))
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def cleanup_source(row, options)
|
|
346
|
+
return unless options && (options['delete_source_after'] || options[:delete_source_after])
|
|
347
|
+
File.delete(row[:source]) if File.file?(row[:source])
|
|
348
|
+
rescue SystemCallError
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module SmartRAG
|
|
7
|
+
module Core
|
|
8
|
+
class MediaMetadataExtractor
|
|
9
|
+
IMAGE_EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .bmp .tif .tiff .heic .heif].freeze
|
|
10
|
+
AUDIO_EXTENSIONS = %w[.mp3 .wav .m4a .aac .flac .ogg .oga .opus .wma .aif .aiff].freeze
|
|
11
|
+
VIDEO_EXTENSIONS = %w[.mp4 .mkv .mov .webm .avi .flv .wmv .m4v .ts .mpeg .mpg].freeze
|
|
12
|
+
DOCUMENT_EXTENSIONS = %w[.pdf .doc .docx .xls .xlsx .ppt .pptx .md .markdown .txt .text .html .htm .csv .json].freeze
|
|
13
|
+
MEDIA_TYPES = %w[image audio video document other].freeze
|
|
14
|
+
DEFAULT_MEDIA = {
|
|
15
|
+
format: nil, width: nil, height: nil, dpi: nil, color_mode: nil,
|
|
16
|
+
orientation: nil, exif: {}, duration_ms: nil, bitrate_bps: nil,
|
|
17
|
+
sample_rate_hz: nil, channels: nil, codec: nil, fps: nil,
|
|
18
|
+
audio_codec: nil, frame_count: nil
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def extract(file_path, media_type: nil)
|
|
22
|
+
type = normalize_media_type(media_type || detect_media_type(file_path))
|
|
23
|
+
media = DEFAULT_MEDIA.dup
|
|
24
|
+
warnings = []
|
|
25
|
+
|
|
26
|
+
case type
|
|
27
|
+
when 'image' then extract_image(file_path, media, warnings)
|
|
28
|
+
when 'audio' then extract_audio(file_path, media, warnings)
|
|
29
|
+
when 'video' then extract_video(file_path, media, warnings)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
{ media_type: type, media: media, warnings: warnings }
|
|
33
|
+
rescue ArgumentError
|
|
34
|
+
raise
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
{ media_type: type || 'other', media: media || DEFAULT_MEDIA.dup,
|
|
37
|
+
warnings: ["media metadata extraction failed: #{e.message}"] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def detect_media_type(file_path)
|
|
41
|
+
extension = File.extname(file_path.to_s).downcase
|
|
42
|
+
return 'image' if IMAGE_EXTENSIONS.include?(extension)
|
|
43
|
+
return 'audio' if AUDIO_EXTENSIONS.include?(extension)
|
|
44
|
+
return 'video' if VIDEO_EXTENSIONS.include?(extension)
|
|
45
|
+
return 'document' if DOCUMENT_EXTENSIONS.include?(extension)
|
|
46
|
+
|
|
47
|
+
detect_from_mime(file_path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def normalize_media_type(value)
|
|
53
|
+
type = value.to_s
|
|
54
|
+
type = 'other' if type.empty? || type == 'auto'
|
|
55
|
+
raise ArgumentError, "unsupported media_type: #{value}" unless MEDIA_TYPES.include?(type)
|
|
56
|
+
|
|
57
|
+
type
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def detect_from_mime(file_path)
|
|
61
|
+
return 'other' unless File.file?(file_path)
|
|
62
|
+
|
|
63
|
+
output, status = Open3.capture2e('file', '--brief', '--mime-type', file_path)
|
|
64
|
+
return 'other' unless status.success?
|
|
65
|
+
|
|
66
|
+
mime = output.strip
|
|
67
|
+
return 'image' if mime.start_with?('image/')
|
|
68
|
+
return 'audio' if mime.start_with?('audio/')
|
|
69
|
+
return 'video' if mime.start_with?('video/')
|
|
70
|
+
return 'document' if mime.start_with?('text/', 'application/pdf')
|
|
71
|
+
|
|
72
|
+
'other'
|
|
73
|
+
rescue Errno::ENOENT, SystemCallError
|
|
74
|
+
'other'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def extract_image(file_path, media, warnings)
|
|
78
|
+
python = %w[python3 python].find do |command|
|
|
79
|
+
system(command, '-c', 'import PIL', out: File::NULL, err: File::NULL)
|
|
80
|
+
end
|
|
81
|
+
unless python
|
|
82
|
+
warnings << 'Pillow (PIL) not available; image metadata skipped'
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
script = <<~PYTHON
|
|
87
|
+
import json, sys
|
|
88
|
+
from PIL import Image, ExifTags, ImageOps
|
|
89
|
+
image = Image.open(sys.argv[1])
|
|
90
|
+
frame_count = getattr(image, 'n_frames', 1)
|
|
91
|
+
image = ImageOps.exif_transpose(image)
|
|
92
|
+
info = {'format': (image.format or '').lower() or None,
|
|
93
|
+
'width': image.width, 'height': image.height,
|
|
94
|
+
'color_mode': image.mode, 'frame_count': frame_count, 'exif': {}}
|
|
95
|
+
dpi = image.info.get('dpi')
|
|
96
|
+
if dpi and isinstance(dpi[0], (int, float)):
|
|
97
|
+
info['dpi'] = round(dpi[0])
|
|
98
|
+
try:
|
|
99
|
+
raw = image.getexif()
|
|
100
|
+
allowed = {'DateTime', 'DateTimeOriginal', 'Make', 'Model', 'Orientation'}
|
|
101
|
+
for key, value in raw.items():
|
|
102
|
+
name = ExifTags.TAGS.get(key, str(key))
|
|
103
|
+
if name in allowed and isinstance(value, (int, float, str)):
|
|
104
|
+
info['exif'][name] = value
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
print(json.dumps(info))
|
|
108
|
+
PYTHON
|
|
109
|
+
output, status = Open3.capture2e(python, '-c', script, file_path)
|
|
110
|
+
unless status.success?
|
|
111
|
+
warnings << "image metadata extraction failed: #{output.strip}"
|
|
112
|
+
return
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
parsed = JSON.parse(output)
|
|
116
|
+
%w[format width height dpi color_mode frame_count exif].each { |key| media[key.to_sym] = parsed[key] if parsed.key?(key) }
|
|
117
|
+
media[:orientation] = orientation(media[:width], media[:height])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def extract_audio(file_path, media, warnings)
|
|
121
|
+
probe = ffprobe(file_path, warnings)
|
|
122
|
+
return unless probe
|
|
123
|
+
|
|
124
|
+
format = probe['format'] || {}
|
|
125
|
+
stream = Array(probe['streams']).find { |item| item['codec_type'] == 'audio' }
|
|
126
|
+
media[:format] = File.extname(file_path).downcase.delete_prefix('.')
|
|
127
|
+
media[:duration_ms] = milliseconds(format['duration'])
|
|
128
|
+
media[:bitrate_bps] = integer(format['bit_rate']) || integer(stream && stream['bit_rate'])
|
|
129
|
+
media[:sample_rate_hz] = integer(stream && stream['sample_rate'])
|
|
130
|
+
media[:channels] = integer(stream && stream['channels'])
|
|
131
|
+
media[:codec] = stream && stream['codec_name']
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def extract_video(file_path, media, warnings)
|
|
135
|
+
probe = ffprobe(file_path, warnings)
|
|
136
|
+
return unless probe
|
|
137
|
+
|
|
138
|
+
format = probe['format'] || {}
|
|
139
|
+
streams = Array(probe['streams'])
|
|
140
|
+
video = streams.find { |item| item['codec_type'] == 'video' }
|
|
141
|
+
audio = streams.find { |item| item['codec_type'] == 'audio' }
|
|
142
|
+
media[:format] = File.extname(file_path).downcase.delete_prefix('.')
|
|
143
|
+
media[:duration_ms] = milliseconds(format['duration'])
|
|
144
|
+
media[:bitrate_bps] = integer(format['bit_rate'])
|
|
145
|
+
media[:width] = integer(video && video['width'])
|
|
146
|
+
media[:height] = integer(video && video['height'])
|
|
147
|
+
media[:codec] = video && video['codec_name']
|
|
148
|
+
media[:fps] = frame_rate(video)
|
|
149
|
+
media[:audio_codec] = audio && audio['codec_name']
|
|
150
|
+
media[:orientation] = orientation(media[:width], media[:height])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def ffprobe(file_path, warnings)
|
|
154
|
+
output, status = Open3.capture2e('ffprobe', '-v', 'quiet', '-print_format', 'json',
|
|
155
|
+
'-show_format', '-show_streams', file_path)
|
|
156
|
+
unless status.success?
|
|
157
|
+
warnings << "ffprobe failed: #{output.strip}"
|
|
158
|
+
return nil
|
|
159
|
+
end
|
|
160
|
+
JSON.parse(output)
|
|
161
|
+
rescue Errno::ENOENT
|
|
162
|
+
warnings << 'ffprobe not available; audio/video metadata skipped'
|
|
163
|
+
nil
|
|
164
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
165
|
+
warnings << "ffprobe failed: #{e.message}"
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def milliseconds(value) = value.nil? ? nil : (value.to_f * 1000).round
|
|
170
|
+
def integer(value) = value.nil? ? nil : Integer(value.to_s, exception: false)
|
|
171
|
+
|
|
172
|
+
def frame_rate(stream)
|
|
173
|
+
return nil unless stream
|
|
174
|
+
numerator, denominator = stream['avg_frame_rate'].to_s.split('/')
|
|
175
|
+
return nil if numerator.to_s.empty? || denominator.to_i.zero?
|
|
176
|
+
|
|
177
|
+
(numerator.to_f / denominator.to_f).round(2)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def orientation(width, height)
|
|
181
|
+
return nil unless width && height
|
|
182
|
+
return 'square' if width == height
|
|
183
|
+
|
|
184
|
+
width > height ? 'landscape' : 'portrait'
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'media_job_queue'
|
|
4
|
+
|
|
5
|
+
module SmartRAG
|
|
6
|
+
module Core
|
|
7
|
+
class MediaObjectRegistry
|
|
8
|
+
def initialize(db:, content_store:)
|
|
9
|
+
@db = db
|
|
10
|
+
@content_store = content_store
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def attach(document_id, stored, byte_size:)
|
|
14
|
+
return unless stored
|
|
15
|
+
|
|
16
|
+
db.transaction(savepoint: true) do
|
|
17
|
+
object_id = register(stored, byte_size: byte_size)
|
|
18
|
+
detach_document(document_id, except_object_id: object_id)
|
|
19
|
+
inserted = db[:media_object_references].insert_conflict.insert(
|
|
20
|
+
media_object_id: object_id, document_id: document_id, created_at: Time.now
|
|
21
|
+
)
|
|
22
|
+
db[:media_objects].where(id: object_id).update(
|
|
23
|
+
reference_count: db[:media_object_references].where(media_object_id: object_id).count,
|
|
24
|
+
updated_at: Time.now
|
|
25
|
+
) if inserted
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def register(stored, byte_size:)
|
|
30
|
+
db[:media_objects].insert_conflict(target: :content_hash).insert(
|
|
31
|
+
content_hash: stored[:content_hash], storage_uri: stored[:storage_uri], byte_size: byte_size,
|
|
32
|
+
reference_count: 0, created_at: Time.now, updated_at: Time.now
|
|
33
|
+
)
|
|
34
|
+
db[:media_objects].where(content_hash: stored[:content_hash]).for_update.get(:id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def detach_document(document_id, except_object_id: nil)
|
|
38
|
+
refs = db[:media_object_references].where(document_id: document_id)
|
|
39
|
+
refs = refs.exclude(media_object_id: except_object_id) if except_object_id
|
|
40
|
+
object_ids = refs.select_map(:media_object_id)
|
|
41
|
+
refs.delete
|
|
42
|
+
object_ids.each { |id| refresh_count(id) }
|
|
43
|
+
object_ids
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def garbage_collect(limit: 100)
|
|
47
|
+
removed = []
|
|
48
|
+
staged_ids = db.table_exists?(:media_jobs) ? db[:media_jobs].exclude(staging_media_object_id: nil).select(:staging_media_object_id) : []
|
|
49
|
+
candidate_ids = db[:media_objects].where(reference_count: 0)
|
|
50
|
+
.exclude(id: staged_ids).order(:id)
|
|
51
|
+
.limit(limit.to_i.clamp(1, 1000)).select_map(:id)
|
|
52
|
+
candidate_ids.each do |object_id|
|
|
53
|
+
db.transaction do
|
|
54
|
+
object = db[:media_objects].where(id: object_id).for_update.first
|
|
55
|
+
next unless object
|
|
56
|
+
next unless db[:media_object_references].where(media_object_id: object_id).count.zero?
|
|
57
|
+
|
|
58
|
+
content_store.delete(object[:storage_uri])
|
|
59
|
+
removed << object_id if db[:media_objects].where(id: object_id).delete == 1
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
{ removed_count: removed.length, object_ids: removed }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def object_id_for(content_hash)
|
|
66
|
+
db[:media_objects].where(content_hash: content_hash).get(:id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
attr_reader :db, :content_store
|
|
72
|
+
|
|
73
|
+
def refresh_count(object_id)
|
|
74
|
+
count = db[:media_object_references].where(media_object_id: object_id).count
|
|
75
|
+
db[:media_objects].where(id: object_id).update(reference_count: count, updated_at: Time.now)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|