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,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
|