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,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require_relative 'media_metadata_extractor'
5
+ require_relative 'video_semantic_extractor'
6
+ require_relative 'transcript_normalizer'
7
+ require_relative 'media_safety_policy'
8
+
9
+ module SmartRAG
10
+ module Core
11
+ class MediaProcessor
12
+ CONTENT_SUMMARY_LIMIT = 4000
13
+
14
+ def initialize(document_processor:, metadata_extractor: MediaMetadataExtractor.new,
15
+ video_extractor: VideoSemanticExtractor.new, safety_policy: MediaSafetyPolicy.new,
16
+ content_store: nil, object_registry: nil, default_extractors: {}, logger: nil)
17
+ @document_processor = document_processor
18
+ @metadata_extractor = metadata_extractor
19
+ @video_extractor = video_extractor
20
+ @safety_policy = safety_policy
21
+ @content_store = content_store
22
+ @object_registry = object_registry
23
+ @default_extractors = default_extractors
24
+ @logger = logger || Logger.new($stdout)
25
+ end
26
+
27
+ def create(source, options = {})
28
+ options = default_extractors.merge(options)
29
+ file_path, materialized_file = resolve_source(source, options)
30
+ safety_policy.validate_file!(file_path)
31
+ requested_type = options[:media_type]
32
+ requested_type = nil if requested_type.to_s == 'auto'
33
+ extracted = safety_policy.with_timeout do
34
+ metadata_extractor.extract(file_path, media_type: requested_type)
35
+ end
36
+ safety_policy.validate_file!(file_path, duration_ms: extracted.dig(:media, :duration_ms))
37
+ stored = content_store&.put(file_path)
38
+ object_registry&.register(stored, byte_size: File.size(file_path)) if stored
39
+ semantic, timeline, warnings = safety_policy.with_timeout do
40
+ extract_semantic(file_path, extracted[:media_type], extracted[:media], options)
41
+ end
42
+ searchable_text = searchable_text_for(source, semantic, options)
43
+ metadata = build_metadata(file_path, extracted, semantic, searchable_text, options, stored)
44
+
45
+ chunks = timeline.empty? ? document_processor.chunk_content(searchable_text, options) : timeline_chunks(timeline)
46
+ document, sections = persist(source, metadata, chunks, options, stored)
47
+
48
+ all_warnings = Array(extracted[:warnings]) + warnings
49
+ {
50
+ document_id: document.id,
51
+ media_type: extracted[:media_type],
52
+ status: all_warnings.empty? ? 'success' : 'partial',
53
+ section_count: sections.length,
54
+ metadata: metadata.reject { |key, _| key == :content },
55
+ warnings: all_warnings
56
+ }
57
+ ensure
58
+ document_processor.cleanup_downloaded_file if document_processor.respond_to?(:cleanup_downloaded_file)
59
+ File.delete(materialized_file) if materialized_file && File.file?(materialized_file)
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :document_processor, :metadata_extractor, :video_extractor, :safety_policy,
65
+ :content_store, :default_extractors, :logger
66
+
67
+ def resolve_source(source, options)
68
+ if source.to_s.match?(%r{\A(?:file|s3)://})
69
+ raise ArgumentError, 'configured content store cannot read media job source' unless content_store
70
+ path, temporary = content_store.materialize(source)
71
+ return [path, temporary ? path : nil]
72
+ end
73
+ if source.to_s.match?(%r{\Ahttps?://})
74
+ safety_policy.validate_url!(source)
75
+ return [document_processor.download_from_url(source, options.merge(
76
+ max_file_size: safety_policy.max_bytes, download_timeout: safety_policy.command_timeout
77
+ )), nil]
78
+ end
79
+ return [source, nil] if File.file?(source)
80
+
81
+ raise ArgumentError, "Invalid source: #{source}. Must be a valid URL or file path."
82
+ end
83
+
84
+ def extract_semantic(file_path, media_type, media, options)
85
+ warnings = []
86
+ captions = []
87
+ timeline = []
88
+ ocr_text = invoke_extractor(options[:ocr_extractor], file_path, warnings, 'image OCR') if media_type == 'image'
89
+ if media_type == 'image'
90
+ caption = invoke_extractor(options[:image_describer], file_path, warnings, 'image description')
91
+ captions << caption unless caption.to_s.strip.empty?
92
+ end
93
+ if media_type == 'audio'
94
+ transcript_result = invoke_extractor(options[:audio_transcriber], file_path, warnings, 'audio transcription')
95
+ timeline = TranscriptNormalizer.entries(transcript_result, extraction_kind: 'audio_transcript')
96
+ transcript = timeline.map { |entry| entry[:text] }.join("\n")
97
+ end
98
+ if media_type == 'video'
99
+ video_result = video_extractor.extract(file_path, duration_ms: media[:duration_ms], options: options)
100
+ timeline = Array(video_result[:timeline])
101
+ warnings.concat(Array(video_result[:warnings]))
102
+ transcript_entries = timeline.select { |entry| entry[:extraction_kind] == 'transcript' }
103
+ frame_entries = timeline.select { |entry| entry[:extraction_kind] == 'frame_description' }
104
+ transcript = transcript_entries.map { |entry| entry[:text] }.join("\n")
105
+ captions.concat(frame_entries.map { |entry| entry[:text] })
106
+ end
107
+
108
+ description = options[:description]
109
+ if media_type == 'image' && description.to_s.empty? && captions.empty? && ocr_text.to_s.empty?
110
+ warnings << 'no image semantic extractor configured; indexed filename and tags only'
111
+ elsif media_type == 'audio' && transcript.to_s.empty?
112
+ warnings << 'no audio transcriber configured; indexed filename and tags only'
113
+ elsif media_type == 'video' && timeline.empty? &&
114
+ (options[:transcribe] != false || options[:llm_caption] != false)
115
+ warnings << 'no video transcriber or frame describer configured; indexed filename and tags only'
116
+ end
117
+
118
+ [{ description: description, captions: captions, transcript: transcript,
119
+ ocr_text: ocr_text, tags: Array(options[:tags]).map(&:to_s) }, timeline, warnings]
120
+ end
121
+
122
+ def invoke_extractor(extractor, file_path, warnings, label)
123
+ return nil unless extractor
124
+
125
+ extractor.respond_to?(:call) ? extractor.call(file_path) : extractor.extract(file_path)
126
+ rescue StandardError => e
127
+ warnings << "#{label} failed: #{e.message}"
128
+ nil
129
+ end
130
+
131
+ def searchable_text_for(source, semantic, options)
132
+ parts = [options[:title] || File.basename(source.to_s), semantic[:description],
133
+ semantic[:captions], semantic[:ocr_text], semantic[:transcript], semantic[:tags]].flatten
134
+ parts.map(&:to_s).map(&:strip).reject(&:empty?).uniq.join("\n\n")
135
+ end
136
+
137
+ def build_metadata(file_path, extracted, semantic, searchable_text, options, stored)
138
+ user_metadata = symbolize_keys(options[:metadata] || {})
139
+ user_metadata.merge(
140
+ schema_version: 1,
141
+ principal: options[:principal] || 'system',
142
+ file_path: file_path,
143
+ file_size: File.size(file_path),
144
+ file_type: File.extname(file_path).downcase,
145
+ content_hash: Digest::SHA256.file(file_path).hexdigest,
146
+ storage_uri: stored && stored[:storage_uri],
147
+ media_type: extracted[:media_type],
148
+ media: extracted[:media],
149
+ semantic: semantic,
150
+ title: options[:title],
151
+ author: options[:author],
152
+ description: options[:description],
153
+ content: searchable_text[0, CONTENT_SUMMARY_LIMIT]
154
+ ).compact
155
+ end
156
+
157
+ def timeline_chunks(timeline)
158
+ timeline.map.with_index do |entry, index|
159
+ timestamp = entry[:start_ms] || entry[:frame_timestamp_ms]
160
+ {
161
+ title: timeline_title(entry[:extraction_kind], timestamp, index),
162
+ content: entry[:text],
163
+ metadata: entry.reject { |key, _| key == :text }.merge(
164
+ media_type: entry[:extraction_kind] == 'audio_transcript' ? 'audio' : 'video'
165
+ )
166
+ }
167
+ end
168
+ end
169
+
170
+ def timeline_title(kind, timestamp_ms, index)
171
+ label = case kind
172
+ when 'frame_description' then 'Video frame'
173
+ when 'audio_transcript' then 'Audio transcript'
174
+ else 'Video transcript'
175
+ end
176
+ return "#{label} #{index + 1}" unless timestamp_ms
177
+
178
+ total_seconds = timestamp_ms.to_i / 1000
179
+ format('%s %02d:%02d:%02d', label, total_seconds / 3600, (total_seconds / 60) % 60, total_seconds % 60)
180
+ end
181
+
182
+ def persist(source, metadata, chunks, options, stored)
183
+ db = persistence_db
184
+ return persist_without_outer_transaction(source, metadata, chunks, options, stored) unless db
185
+
186
+ document = nil
187
+ sections = nil
188
+ db.transaction do
189
+ document = document_processor.create_or_update_document(source, metadata, options)
190
+ section_ids = db[:source_sections].where(document_id: document.id).select_map(:id)
191
+ db[:embeddings].where(source_id: section_ids).delete if section_ids.any? && db.table_exists?(:embeddings)
192
+ db[:source_sections].where(document_id: document.id).delete
193
+ sections = document_processor.save_sections(document, chunks, options)
194
+ object_registry&.attach(document.id, stored, byte_size: metadata[:file_size])
195
+ document.set_download_state(:completed)
196
+ end
197
+ [document, sections]
198
+ end
199
+
200
+ def persist_without_outer_transaction(source, metadata, chunks, options, stored)
201
+ document = document_processor.create_or_update_document(source, metadata, options)
202
+ db = document.class.db
203
+ sections = nil
204
+ db.transaction do
205
+ section_ids = db[:source_sections].where(document_id: document.id).select_map(:id)
206
+ db[:embeddings].where(source_id: section_ids).delete if section_ids.any? && db.table_exists?(:embeddings)
207
+ db[:source_sections].where(document_id: document.id).delete
208
+ sections = document_processor.save_sections(document, chunks, options)
209
+ object_registry&.attach(document.id, stored, byte_size: metadata[:file_size])
210
+ document.set_download_state(:completed)
211
+ end
212
+ [document, sections]
213
+ end
214
+
215
+ def persistence_db
216
+ ::SmartRAG::Models::SourceDocument.db
217
+ rescue StandardError
218
+ nil
219
+ end
220
+
221
+ def object_registry = @object_registry
222
+
223
+ def symbolize_keys(hash)
224
+ hash.each_with_object({}) { |(key, value), result| result[key.to_sym] = value }
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'resolv'
5
+ require 'timeout'
6
+ require 'uri'
7
+
8
+ module SmartRAG
9
+ module Core
10
+ class MediaSafetyPolicy
11
+ DEFAULT_MAX_BYTES = 50 * 1024 * 1024
12
+ DEFAULT_MAX_DURATION_MS = 2 * 60 * 60 * 1000
13
+
14
+ def initialize(config = {})
15
+ @max_bytes = config.fetch(:max_bytes, config.fetch(:max_file_size_mb, 50).to_i * 1024 * 1024)
16
+ @max_duration_ms = config.fetch(:max_duration_ms, DEFAULT_MAX_DURATION_MS).to_i
17
+ @allow_private_urls = config.fetch(:allow_private_urls, false)
18
+ @allowed_hosts = Array(config[:allowed_hosts]).map(&:downcase)
19
+ @command_timeout = config.fetch(:command_timeout_seconds, 120).to_i
20
+ end
21
+
22
+ attr_reader :max_bytes, :max_duration_ms, :command_timeout
23
+
24
+ def validate_file!(file_path, duration_ms: nil)
25
+ raise ArgumentError, "media file exceeds #{max_bytes} bytes" if File.size(file_path) > max_bytes
26
+ if duration_ms && duration_ms.to_i > max_duration_ms
27
+ raise ArgumentError, "media duration exceeds #{max_duration_ms}ms"
28
+ end
29
+ true
30
+ end
31
+
32
+ def validate_url!(source)
33
+ uri = URI.parse(source)
34
+ raise ArgumentError, 'media URL must use http or https' unless %w[http https].include?(uri.scheme)
35
+ raise ArgumentError, 'media URL must not contain credentials' if uri.userinfo
36
+ return true if @allowed_hosts.include?(uri.host.to_s.downcase)
37
+ return true if @allow_private_urls
38
+
39
+ addresses = Resolv.getaddresses(uri.host.to_s)
40
+ raise ArgumentError, 'media URL host could not be resolved' if addresses.empty?
41
+ raise ArgumentError, 'media URL resolves to a private or local address' if addresses.any? { |value| private_ip?(value) }
42
+ true
43
+ rescue URI::InvalidURIError => e
44
+ raise ArgumentError, "invalid media URL: #{e.message}"
45
+ end
46
+
47
+ def with_timeout(&block)
48
+ Timeout.timeout(command_timeout, &block)
49
+ end
50
+
51
+ private
52
+
53
+ def private_ip?(value)
54
+ ip = IPAddr.new(value)
55
+ ip.loopback? || ip.private? || ip.link_local? || ip.to_s == '0.0.0.0' || ip.to_s == '::'
56
+ rescue IPAddr::InvalidAddressError
57
+ true
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'tempfile'
5
+
6
+ module SmartRAG
7
+ module Core
8
+ class S3ContentStore
9
+ def initialize(bucket:, region: nil, prefix: 'smart-rag/media', endpoint: nil,
10
+ force_path_style: false, access_key_id: nil, secret_access_key: nil, client: nil)
11
+ @bucket = bucket.to_s
12
+ raise ArgumentError, 'media.content_store.bucket is required' if @bucket.empty?
13
+ @prefix = prefix.to_s.sub(%r{\A/+|/+$}, '')
14
+ @client = client || build_client(region:, endpoint:, force_path_style:, access_key_id:, secret_access_key:)
15
+ end
16
+
17
+ def put(file_path)
18
+ digest = Digest::SHA256.file(file_path).hexdigest
19
+ key = object_key(digest, File.extname(file_path).downcase)
20
+ client.put_object(bucket: bucket, key: key, body: File.open(file_path, 'rb')) unless exists?(key)
21
+ { content_hash: digest, storage_uri: "s3://#{bucket}/#{key}", stored_path: nil }
22
+ end
23
+
24
+ def delete(storage_uri)
25
+ key = key_from_uri(storage_uri)
26
+ client.delete_object(bucket: bucket, key: key)
27
+ true
28
+ end
29
+
30
+ def materialize(storage_uri)
31
+ key = key_from_uri(storage_uri)
32
+ extension = File.extname(key)
33
+ file = Tempfile.new(['smart-rag-object', extension])
34
+ file.close
35
+ client.get_object(bucket: bucket, key: key, response_target: file.path)
36
+ [file.path, true]
37
+ rescue StandardError
38
+ file&.unlink
39
+ raise
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :bucket, :prefix, :client
45
+
46
+ def build_client(region:, endpoint:, force_path_style:, access_key_id:, secret_access_key:)
47
+ require 'aws-sdk-s3'
48
+ options = { region: region || 'us-east-1', force_path_style: force_path_style }
49
+ options[:endpoint] = endpoint unless endpoint.to_s.empty?
50
+ unless access_key_id.to_s.empty?
51
+ options[:access_key_id] = access_key_id
52
+ options[:secret_access_key] = secret_access_key
53
+ end
54
+ Aws::S3::Client.new(**options)
55
+ rescue LoadError
56
+ raise LoadError, 'S3 content storage requires the aws-sdk-s3 gem'
57
+ end
58
+
59
+ def exists?(key)
60
+ client.head_object(bucket: bucket, key: key)
61
+ true
62
+ rescue StandardError => e
63
+ return false if e.class.name.end_with?('NotFound', 'NoSuchKey')
64
+ raise
65
+ end
66
+
67
+ def object_key(digest, extension)
68
+ [prefix, digest[0, 2], digest[2, 2], "#{digest}#{extension}"].reject(&:empty?).join('/')
69
+ end
70
+
71
+ def key_from_uri(uri)
72
+ prefix_value = "s3://#{bucket}/"
73
+ raise ArgumentError, 'storage URI does not belong to this S3 store' unless uri.to_s.start_with?(prefix_value)
74
+ uri.to_s.delete_prefix(prefix_value)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartRAG
4
+ module Core
5
+ module TranscriptNormalizer
6
+ module_function
7
+
8
+ def entries(result, extraction_kind: 'transcript')
9
+ segments = if result.is_a?(Hash)
10
+ result[:segments] || result['segments']
11
+ elsif result.is_a?(Array)
12
+ result
13
+ end
14
+ return Array(segments).filter_map { |segment| normalize_segment(segment, extraction_kind) } if segments
15
+
16
+ text = result.is_a?(Hash) ? (result[:text] || result['text']) : result
17
+ text.to_s.strip.empty? ? [] : [{ extraction_kind: extraction_kind, text: text.to_s.strip }]
18
+ end
19
+
20
+ def normalize_segment(segment, extraction_kind)
21
+ return { extraction_kind: extraction_kind, text: segment.to_s.strip } unless segment.is_a?(Hash)
22
+
23
+ text = segment[:text] || segment['text']
24
+ return nil if text.to_s.strip.empty?
25
+
26
+ {
27
+ extraction_kind: extraction_kind,
28
+ text: text.to_s.strip,
29
+ start_ms: timestamp_ms(segment, :start),
30
+ end_ms: timestamp_ms(segment, :end),
31
+ speaker: segment[:speaker] || segment['speaker']
32
+ }.compact
33
+ end
34
+
35
+ def timestamp_ms(segment, key)
36
+ explicit = segment["#{key}_ms".to_sym] || segment["#{key}_ms"]
37
+ return explicit.to_f.round unless explicit.nil?
38
+
39
+ seconds = segment[key] || segment[key.to_s]
40
+ seconds.nil? ? nil : (seconds.to_f * 1000).round
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'open3'
5
+ require 'tmpdir'
6
+ require_relative 'transcript_normalizer'
7
+
8
+ module SmartRAG
9
+ module Core
10
+ class VideoSemanticExtractor
11
+ DEFAULT_FRAME_INTERVAL_SECONDS = 30
12
+ DEFAULT_MAX_FRAMES = 12
13
+
14
+ def extract(file_path, duration_ms:, options: {})
15
+ warnings = []
16
+ timeline = []
17
+
18
+ Dir.mktmpdir('smart_rag_video') do |directory|
19
+ timeline.concat(transcript_entries(file_path, directory, options, warnings))
20
+ timeline.concat(frame_entries(file_path, directory, duration_ms, options, warnings))
21
+ end
22
+
23
+ { timeline: timeline.sort_by { |entry| entry[:start_ms] || entry[:frame_timestamp_ms] || 0 },
24
+ warnings: warnings }
25
+ rescue StandardError => e
26
+ { timeline: [], warnings: ["video semantic extraction failed: #{e.message}"] }
27
+ end
28
+
29
+ private
30
+
31
+ def transcript_entries(file_path, directory, options, warnings)
32
+ return [] if options[:transcribe] == false
33
+
34
+ transcriber = options[:video_transcriber] || options[:audio_transcriber]
35
+ unless transcriber
36
+ warnings << 'no video transcriber configured; video audio transcription skipped'
37
+ return []
38
+ end
39
+
40
+ audio_path = File.join(directory, 'audio.wav')
41
+ output, status = Open3.capture2e(
42
+ 'ffmpeg', '-y', '-v', 'error', '-i', file_path, '-vn', '-ac', '1', '-ar', '16k', audio_path
43
+ )
44
+ unless status.success? && File.file?(audio_path)
45
+ warnings << "video audio extraction failed: #{output.strip}"
46
+ return []
47
+ end
48
+
49
+ TranscriptNormalizer.entries(invoke(transcriber, audio_path))
50
+ rescue Errno::ENOENT
51
+ warnings << 'ffmpeg not available; video audio transcription skipped'
52
+ []
53
+ rescue StandardError => e
54
+ warnings << "video transcription failed: #{e.message}"
55
+ []
56
+ end
57
+
58
+ def frame_entries(file_path, directory, duration_ms, options, warnings)
59
+ return [] if options[:llm_caption] == false
60
+
61
+ describer = options[:frame_describer] || options[:image_describer]
62
+ unless describer
63
+ warnings << 'no frame describer configured; video frame descriptions skipped'
64
+ return []
65
+ end
66
+
67
+ frame_timestamps(file_path, duration_ms, options).filter_map.with_index do |timestamp_ms, index|
68
+ frame_path = File.join(directory, format('frame-%03d.jpg', index))
69
+ output, status = Open3.capture2e(
70
+ 'ffmpeg', '-y', '-v', 'error', '-ss', format('%.3f', timestamp_ms / 1000.0),
71
+ '-i', file_path, '-frames:v', '1', '-q:v', '2', frame_path
72
+ )
73
+ unless status.success? && File.file?(frame_path)
74
+ warnings << "video frame extraction failed at #{timestamp_ms}ms: #{output.strip}"
75
+ next
76
+ end
77
+
78
+ description = invoke(describer, frame_path, timestamp_ms)
79
+ next if description.to_s.strip.empty?
80
+
81
+ { extraction_kind: 'frame_description', text: description.to_s.strip,
82
+ frame_timestamp_ms: timestamp_ms, start_ms: timestamp_ms, end_ms: timestamp_ms }
83
+ rescue Errno::ENOENT
84
+ warnings << 'ffmpeg not available; video frame descriptions skipped'
85
+ break []
86
+ rescue StandardError => e
87
+ warnings << "video frame description failed at #{timestamp_ms}ms: #{e.message}"
88
+ nil
89
+ end
90
+ end
91
+
92
+ def frame_timestamps(file_path, duration_ms, options)
93
+ if options.fetch(:scene_detection, true)
94
+ scene_timestamps = detect_scenes(file_path, options)
95
+ return scene_timestamps unless scene_timestamps.empty?
96
+ end
97
+
98
+ interval_ms = [options.fetch(:frame_interval_seconds, DEFAULT_FRAME_INTERVAL_SECONDS).to_f * 1000, 1000].max.round
99
+ max_frames = [[options.fetch(:max_frames, DEFAULT_MAX_FRAMES).to_i, 1].max, 100].min
100
+ duration = duration_ms.to_i
101
+ return [0] if duration <= 0
102
+
103
+ timestamps = (0...duration).step(interval_ms).first(max_frames)
104
+ timestamps.empty? ? [0] : timestamps
105
+ end
106
+
107
+ def detect_scenes(file_path, options)
108
+ return [] if file_path.to_s.empty?
109
+
110
+ threshold = options.fetch(:scene_threshold, 0.35).to_f
111
+ max_frames = [[options.fetch(:max_frames, DEFAULT_MAX_FRAMES).to_i, 1].max, 100].min
112
+ output, status = Open3.capture2e(
113
+ 'ffmpeg', '-hide_banner', '-i', file_path, '-filter:v', "select='gt(scene,#{threshold})',showinfo",
114
+ '-an', '-f', 'null', '-'
115
+ )
116
+ return [] unless status.success?
117
+
118
+ output.scan(/pts_time:([0-9.]+)/).flatten.map { |seconds| (seconds.to_f * 1000).round }.uniq.first(max_frames)
119
+ rescue Errno::ENOENT, SystemCallError
120
+ []
121
+ end
122
+
123
+ def invoke(extractor, path, timestamp_ms = nil)
124
+ callable = extractor.respond_to?(:call) ? extractor : extractor.method(:extract)
125
+ timestamp_ms.nil? || callable.arity == 1 ? callable.call(path) : callable.call(path, timestamp_ms)
126
+ end
127
+
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module SmartRAG
6
+ class HttpAccessPolicy
7
+ class Unauthorized < StandardError; end
8
+ class QuotaExceeded < StandardError; end
9
+
10
+ def initialize(db:, config: {}, clock: -> { Time.now })
11
+ @db = db
12
+ @config = config || {}
13
+ @clock = clock
14
+ @token_digests = configured_tokens.to_h { |principal, token| [digest(token), principal.to_s] }
15
+ end
16
+
17
+ def authorize!(request)
18
+ return 'anonymous' unless config.fetch(:enabled, false)
19
+
20
+ scheme, token = request.get_header('HTTP_AUTHORIZATION').to_s.split(' ', 2)
21
+ principal = token_digests[digest(token)] if scheme.to_s.casecmp('Bearer').zero? && token
22
+ raise Unauthorized, 'invalid or missing bearer token' unless principal
23
+ principal
24
+ end
25
+
26
+ def consume!(principal, request_bytes: 0)
27
+ return unless quota_enabled?
28
+ raise QuotaExceeded, 'quota storage is unavailable' unless db&.table_exists?(:api_rate_limits)
29
+
30
+ enforce_window!(principal, 'minute', minute_start, requests: 1,
31
+ request_limit: config.dig(:quota, :requests_per_minute))
32
+ enforce_window!(principal, 'day', day_start, bytes: request_bytes.to_i,
33
+ byte_limit: config.dig(:quota, :upload_bytes_per_day))
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :db, :config, :clock, :token_digests
39
+
40
+ def configured_tokens
41
+ tokens = config[:tokens]
42
+ return tokens if tokens.is_a?(Hash)
43
+ token = config[:token].to_s
44
+ token.empty? ? {} : { config.fetch(:principal, 'default') => token }
45
+ end
46
+
47
+ def digest(value)
48
+ Digest::SHA256.hexdigest(value.to_s)
49
+ end
50
+
51
+ def quota_enabled?
52
+ quota = config[:quota] || {}
53
+ quota[:requests_per_minute].to_i.positive? || quota[:upload_bytes_per_day].to_i.positive?
54
+ end
55
+
56
+ def minute_start
57
+ now = clock.call
58
+ Time.new(now.year, now.month, now.day, now.hour, now.min, 0, now.utc_offset)
59
+ end
60
+
61
+ def day_start
62
+ now = clock.call
63
+ Time.new(now.year, now.month, now.day, 0, 0, 0, now.utc_offset)
64
+ end
65
+
66
+ def enforce_window!(principal, period, window_start, requests: 0, bytes: 0,
67
+ request_limit: nil, byte_limit: nil)
68
+ return if request_limit.to_i <= 0 && byte_limit.to_i <= 0
69
+
70
+ db.transaction do
71
+ dataset = db[:api_rate_limits].where(principal: principal, period: period, window_start: window_start)
72
+ row = dataset.for_update.first
73
+ unless row
74
+ db[:api_rate_limits].insert(principal: principal, period: period, window_start: window_start,
75
+ request_count: 0, byte_count: 0, updated_at: clock.call)
76
+ row = dataset.for_update.first
77
+ end
78
+ next_requests = row[:request_count].to_i + requests
79
+ next_bytes = row[:byte_count].to_i + bytes
80
+ raise QuotaExceeded, "#{period} request quota exceeded" if request_limit.to_i.positive? && next_requests > request_limit.to_i
81
+ raise QuotaExceeded, "#{period} upload quota exceeded" if byte_limit.to_i.positive? && next_bytes > byte_limit.to_i
82
+ dataset.update(request_count: next_requests, byte_count: next_bytes, updated_at: clock.call)
83
+ end
84
+ end
85
+ end
86
+ end