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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +252 -0
  3. data/.rspec +2 -0
  4. data/AGENTS.md +33 -0
  5. data/API_DOCUMENTATION.md +828 -0
  6. data/CHANGELOG.md +16 -1
  7. data/ER-diagram.mmd +144 -0
  8. data/Gemfile +50 -0
  9. data/Gemfile.lock +398 -0
  10. data/Hybrid_Reranking.md +171 -0
  11. data/README.en.md +420 -28
  12. data/README.md +534 -63
  13. data/Rakefile +268 -0
  14. data/SETUP_GUIDE.md +650 -0
  15. data/SmartChunking.md +180 -0
  16. data/USAGE_EXAMPLES.md +1002 -0
  17. data/config/llm_config.yml +4 -2
  18. data/config/smart_rag.yml +45 -1
  19. data/config.ru +15 -0
  20. data/db/migrations/006_create_text_search_configs.rb +3 -2
  21. data/db/migrations/008_create_embeddings.rb +5 -4
  22. data/db/migrations/012_add_metadata_to_source_sections.rb +11 -0
  23. data/db/migrations/013_create_media_jobs.rb +25 -0
  24. data/db/migrations/014_add_media_job_operations_indexes.rb +11 -0
  25. data/db/migrations/015_add_media_leases_and_objects.rb +80 -0
  26. data/db/migrations/016_add_document_principals_and_staging_references.rb +38 -0
  27. data/db/migrations/017_add_media_job_request_fingerprint.rb +48 -0
  28. data/db/seeds/text_search_configs.sql +3 -3
  29. data/design.md +1057 -0
  30. data/docs/API_DOCUMENTATION.md +838 -0
  31. data/docs/DOCUMENTATION_INDEX.en.md +60 -0
  32. data/docs/DOCUMENTATION_INDEX.md +65 -0
  33. data/docs/FIX_SUMMARY.md +256 -0
  34. data/docs/FIX_SUMMARY_COMPLETE.md +273 -0
  35. data/docs/Hybrid_Reranking.md +171 -0
  36. data/docs/MIGRATION_GUIDE.md +151 -0
  37. data/docs/PERFORMANCE_GUIDE.md +58 -0
  38. data/docs/SETUP_GUIDE.md +659 -0
  39. data/docs/SmartChunking.md +180 -0
  40. data/docs/USAGE_EXAMPLES.md +1008 -0
  41. data/docs/design.md +1057 -0
  42. data/docs/evidence_pack.md +211 -0
  43. data/docs/requirements.md +376 -0
  44. data/docs/retrieval_plan.md +251 -0
  45. data/docs/smartrag_improvement_plan.md +201 -0
  46. data/docs/smartrag_refactor.md +216 -0
  47. data/docs/todo.md +931 -0
  48. data/examples/common.rb +1 -1
  49. data/exe/smart-rag-db +163 -0
  50. data/exe/smart-rag-media-worker +34 -0
  51. data/lib/smart_rag/config.rb +12 -0
  52. data/lib/smart_rag/core/document_processor.rb +80 -16
  53. data/lib/smart_rag/core/local_content_store.rb +51 -0
  54. data/lib/smart_rag/core/media_extractors.rb +140 -0
  55. data/lib/smart_rag/core/media_job_queue.rb +353 -0
  56. data/lib/smart_rag/core/media_metadata_extractor.rb +188 -0
  57. data/lib/smart_rag/core/media_object_registry.rb +79 -0
  58. data/lib/smart_rag/core/media_processor.rb +228 -0
  59. data/lib/smart_rag/core/media_safety_policy.rb +61 -0
  60. data/lib/smart_rag/core/s3_content_store.rb +78 -0
  61. data/lib/smart_rag/core/transcript_normalizer.rb +44 -0
  62. data/lib/smart_rag/core/video_semantic_extractor.rb +130 -0
  63. data/lib/smart_rag/http_access_policy.rb +86 -0
  64. data/lib/smart_rag/http_app.rb +188 -0
  65. data/lib/smart_rag/models/embedding.rb +1 -1
  66. data/lib/smart_rag/models/research_topic.rb +1 -1
  67. data/lib/smart_rag/models/research_topic_section.rb +5 -0
  68. data/lib/smart_rag/models/research_topic_tag.rb +5 -0
  69. data/lib/smart_rag/models/search_log.rb +1 -1
  70. data/lib/smart_rag/models/section_fts.rb +5 -0
  71. data/lib/smart_rag/models/section_tag.rb +5 -0
  72. data/lib/smart_rag/models/source_document.rb +1 -1
  73. data/lib/smart_rag/models/source_section.rb +1 -1
  74. data/lib/smart_rag/models/tag.rb +1 -1
  75. data/lib/smart_rag/models/text_search_config.rb +5 -0
  76. data/lib/smart_rag/retrieve.rb +72 -1
  77. data/lib/smart_rag/services/embedding_service.rb +1 -1
  78. data/lib/smart_rag/services/fulltext_search_service.rb +11 -13
  79. data/lib/smart_rag/services/hybrid_search_service.rb +15 -11
  80. data/lib/smart_rag/services/summarization_service.rb +1 -1
  81. data/lib/smart_rag/services/tag_service.rb +1 -1
  82. data/lib/smart_rag/version.rb +1 -1
  83. data/lib/smart_rag.rb +264 -30
  84. data/patch_language.rb +27 -0
  85. data/requirements.md +376 -0
  86. data/source_documents_export.json +11072 -0
  87. data/todo.md +931 -0
  88. data/workers/analyze_content.rb +6 -2
  89. data/workers/get_embedding.rb +1 -1
  90. 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