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
data/examples/common.rb CHANGED
@@ -29,7 +29,7 @@ module Examples
29
29
  llm: {
30
30
  provider: ENV["SMARTRAG_LLM_PROVIDER"] || ENV["LLM_PROVIDER"] || "openai",
31
31
  api_key: ENV["OPENAI_API_KEY"] || ENV["LLM_API_KEY"] || "ollama-local",
32
- endpoint: ENV["LLM_ENDPOINT"] || "http://localhost:11434/v1/chat/completions",
32
+ endpoint: ENV["LLM_ENDPOINT"] || "http://192.168.1.48:11434/v1/chat/completions",
33
33
  model: ENV["LLM_MODEL"] || "qwen3",
34
34
  },
35
35
  }
data/exe/smart-rag-db ADDED
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # SmartRAG database bootstrap CLI.
5
+ #
6
+ # Usage:
7
+ # smart-rag-db create # create the database
8
+ # smart-rag-db drop # drop the database
9
+ # smart-rag-db migrate # enable pg extensions + run migrations
10
+ # smart-rag-db seed # load full-text search seed data
11
+ # smart-rag-db reset # drop + create + migrate + seed
12
+
13
+ require "optparse"
14
+
15
+ COMMANDS = %w[create drop migrate seed reset].freeze
16
+
17
+ command = ARGV.shift
18
+ abort "Usage: smart-rag-db {#{COMMANDS.join('|')}}" unless COMMANDS.include?(command)
19
+
20
+ require "smart_rag"
21
+
22
+ # --- helpers ---------------------------------------------------------------
23
+
24
+ def load_db_config
25
+ SmartRAG::Config.load[:database]
26
+ rescue StandardError => e
27
+ warn "Warning: could not load config (#{e.message}); falling back to SMARTRAG_DB_* env vars."
28
+ {
29
+ adapter: "postgresql",
30
+ host: ENV["SMARTRAG_DB_HOST"] || "localhost",
31
+ port: ENV["SMARTRAG_DB_PORT"] || 5432,
32
+ database: ENV["SMARTRAG_DB_NAME"] || "smart_rag_development",
33
+ username: ENV["SMARTRAG_DB_USER"] || "postgres",
34
+ password: ENV["SMARTRAG_DB_PASSWORD"],
35
+ pool: 5,
36
+ encoding: "UTF8",
37
+ timeout: 5000,
38
+ }
39
+ end
40
+
41
+ def windows?
42
+ /mswin|mingw|cygwin/i.match?(RbConfig::CONFIG["host_os"])
43
+ end
44
+
45
+ def running_as_root?
46
+ Process.respond_to?(:uid) && Process.uid == 0
47
+ end
48
+
49
+ def connect(config, database: nil)
50
+ require "sequel"
51
+ cfg = config.reject { |key, _| key == :extensions || key.to_s == "extensions" }
52
+ cfg = cfg.merge(database: database) if database
53
+ Sequel.connect(cfg)
54
+ end
55
+
56
+ def migrations_dir
57
+ File.expand_path("../db/migrations", __dir__)
58
+ end
59
+
60
+ def seeds_file
61
+ File.expand_path("../db/seeds/text_search_configs.sql", __dir__)
62
+ end
63
+
64
+ def print_hints(command)
65
+ warn "You may need to:"
66
+ warn " 1. Ensure PostgreSQL is running"
67
+ warn " 2. Set SMARTRAG_DB_* environment variables (or create a .env file)"
68
+ warn " 3. Use `sudo -u postgres smart-rag-db #{command}` when running as root"
69
+ end
70
+
71
+ # --- commands --------------------------------------------------------------
72
+
73
+ def create_database(config)
74
+ database = config[:database].to_s
75
+ host = (config[:host] || "localhost").to_s
76
+ port = (config[:port] || 5432).to_s
77
+ username = (config[:username] || "postgres").to_s
78
+
79
+ created = false
80
+ if host == "localhost" && running_as_root? && !windows?
81
+ created = system("sudo", "-u", username, "createdb", "-h", host, "-p", port, database)
82
+ end
83
+
84
+ return true if created
85
+
86
+ db = connect(config, database: "postgres")
87
+ db.execute("CREATE DATABASE #{database}")
88
+ db.disconnect
89
+ true
90
+ end
91
+
92
+ def drop_database(config)
93
+ database = config[:database].to_s
94
+ host = (config[:host] || "localhost").to_s
95
+ port = (config[:port] || 5432).to_s
96
+ username = (config[:username] || "postgres").to_s
97
+
98
+ dropped = false
99
+ if host == "localhost" && running_as_root? && !windows?
100
+ dropped = system("sudo", "-u", username, "dropdb", "-h", host, "-p", port, "--if-exists", database)
101
+ end
102
+
103
+ return true if dropped
104
+
105
+ db = connect(config, database: "postgres")
106
+ db.execute("DROP DATABASE IF EXISTS #{database}")
107
+ db.disconnect
108
+ true
109
+ end
110
+
111
+ def migrate_database(config)
112
+ require "sequel"
113
+ Sequel.extension :migration
114
+
115
+ db = connect(config)
116
+ db.run "CREATE EXTENSION IF NOT EXISTS vector"
117
+ begin
118
+ db.run "CREATE EXTENSION IF NOT EXISTS pg_jieba"
119
+ rescue Sequel::DatabaseError => e
120
+ puts "Warning: pg_jieba extension not available (#{e.message}); Chinese full-text will fall back to 'simple'"
121
+ end
122
+
123
+ Sequel::Migrator.run(db, migrations_dir)
124
+ puts "Migrations completed"
125
+ db.disconnect
126
+ end
127
+
128
+ def seed_database(config)
129
+ abort "Seeds file not found: #{seeds_file}" unless File.exist?(seeds_file)
130
+
131
+ db = connect(config)
132
+ db.run(File.read(seeds_file))
133
+ puts "Database seeded"
134
+ db.disconnect
135
+ end
136
+
137
+ # --- dispatch ---------------------------------------------------------------
138
+
139
+ begin
140
+ config = load_db_config
141
+
142
+ case command
143
+ when "create"
144
+ create_database(config)
145
+ puts "Database #{config[:database]} created"
146
+ when "drop"
147
+ drop_database(config)
148
+ puts "Database #{config[:database]} dropped"
149
+ when "migrate"
150
+ migrate_database(config)
151
+ when "seed"
152
+ seed_database(config)
153
+ when "reset"
154
+ drop_database(config)
155
+ create_database(config)
156
+ migrate_database(config)
157
+ seed_database(config)
158
+ end
159
+ rescue StandardError => e
160
+ warn "smart-rag-db #{command} failed: #{e.message}"
161
+ print_hints(command)
162
+ exit 1
163
+ end
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+ require 'smart_rag'
6
+
7
+ options = { batch_size: 10, interval: 2.0, once: false }
8
+ OptionParser.new do |parser|
9
+ parser.banner = 'Usage: smart-rag-media-worker [options]'
10
+ parser.on('--once', 'Process one batch and exit') { options[:once] = true }
11
+ parser.on('--batch-size N', Integer, 'Jobs per batch (default: 10)') { |value| options[:batch_size] = value }
12
+ parser.on('--interval SECONDS', Float, 'Idle polling interval (default: 2)') { |value| options[:interval] = value }
13
+ end.parse!
14
+
15
+ config = SmartRAG::Config.load(ENV['SMARTRAG_CONFIG_PATH'])
16
+ rag = SmartRAG::SmartRAG.new(config)
17
+ rag.recover_media_jobs
18
+ last_pruned_at = Time.at(0)
19
+ last_recovered_at = Time.now
20
+
21
+ loop do
22
+ jobs = rag.run_media_jobs(limit: options[:batch_size])
23
+ if Time.now - last_recovered_at >= 60
24
+ rag.recover_media_jobs
25
+ last_recovered_at = Time.now
26
+ end
27
+ if Time.now - last_pruned_at >= 3600
28
+ rag.prune_media_jobs
29
+ rag.garbage_collect_media_objects
30
+ last_pruned_at = Time.now
31
+ end
32
+ break if options[:once]
33
+ sleep(options[:interval]) if jobs.empty?
34
+ end
@@ -8,6 +8,8 @@ module SmartRAG
8
8
  # If file_path is a Hash, return it directly (already a config hash)
9
9
  return symbolize_keys(file_path) if file_path.is_a?(Hash)
10
10
 
11
+ load_dotenv
12
+
11
13
  file_path ||= default_config_path
12
14
 
13
15
  unless File.exist?(file_path)
@@ -73,6 +75,16 @@ module SmartRAG
73
75
  symbolize_keys(config) if config.is_a?(Hash)
74
76
  end
75
77
 
78
+ # Load .env from the current directory so ERB `<%= ENV['...'] %>`
79
+ # templates and SMARTRAG_* variables work consistently. Falls back to
80
+ # real environment variables when the dotenv gem is unavailable.
81
+ def load_dotenv
82
+ require "dotenv"
83
+ Dotenv.load
84
+ rescue LoadError
85
+ # dotenv is optional; plain environment variables still work
86
+ end
87
+
76
88
  private
77
89
 
78
90
  def default_config_path
@@ -3,6 +3,8 @@ require 'net/http'
3
3
  require 'fileutils'
4
4
  require 'tempfile'
5
5
  require 'digest'
6
+ require 'timeout'
7
+ require_relative 'media_safety_policy'
6
8
  require_relative '../../smart_rag'
7
9
  require_relative '../models'
8
10
  require_relative '../chunker/markdown_chunker'
@@ -149,7 +151,15 @@ module SmartRAG
149
151
  # @param [String] url Source URL
150
152
  # @param [Hash] options Download options
151
153
  # @return [String] Path to downloaded file
152
- def download_from_url(url, options = {})
154
+ def download_from_url(url, options = {}, redirect_count = 0)
155
+ max_redirects = options.fetch(:max_redirects, 5).to_i
156
+ raise ArgumentError, "too many redirects (max #{max_redirects})" if redirect_count > max_redirects
157
+
158
+ MediaSafetyPolicy.new(
159
+ max_bytes: options[:max_file_size] || 50 * 1024 * 1024,
160
+ allow_private_urls: options.fetch(:allow_private_urls, false),
161
+ allowed_hosts: options[:allowed_hosts]
162
+ ).validate_url!(url)
153
163
  uri = URI.parse(url)
154
164
  @logger.info "Downloading from URL: #{url}"
155
165
 
@@ -161,34 +171,59 @@ module SmartRAG
161
171
  temp_file.close
162
172
 
163
173
  # Download the file
164
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
174
+ timeout = options.fetch(:download_timeout, 30).to_i
175
+ max_bytes = options.fetch(:max_file_size, 50 * 1024 * 1024).to_i
176
+ redirect_url = nil
177
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https',
178
+ open_timeout: timeout, read_timeout: timeout) do |http|
165
179
  request = Net::HTTP::Get.new(uri)
166
180
  # Set user agent to avoid being blocked
167
181
  request['User-Agent'] = 'SmartRAG Document Processor/1.0'
168
182
 
169
- response = http.request(request)
170
-
171
- case response.code
172
- when '200'
173
- File.write(temp_path, response.body)
174
- when '301', '302', '303', '307', '308'
175
- # Follow redirect
176
- redirect_url = response['Location']
177
- @logger.info "Redirecting to: #{redirect_url}"
178
- return download_from_url(redirect_url, options)
179
- else
180
- raise "HTTP Error: #{response.code} - #{response.message}"
183
+ http.request(request) do |response|
184
+ case response.code
185
+ when '200'
186
+ content_length = response['Content-Length'].to_i
187
+ raise ArgumentError, "download exceeds #{max_bytes} bytes" if content_length > max_bytes
188
+
189
+ written = 0
190
+ File.open(temp_path, 'wb') do |file|
191
+ response.read_body do |chunk|
192
+ written += chunk.bytesize
193
+ raise ArgumentError, "download exceeds #{max_bytes} bytes" if written > max_bytes
194
+ file.write(chunk)
195
+ end
196
+ end
197
+ when '301', '302', '303', '307', '308'
198
+ redirect_url = URI.join(url, response['Location']).to_s
199
+ @logger.info "Redirecting to: #{redirect_url}"
200
+ else
201
+ raise "HTTP Error: #{response.code} - #{response.message}"
202
+ end
181
203
  end
182
204
  end
183
205
 
206
+ if redirect_url
207
+ File.delete(temp_path) if File.exist?(temp_path)
208
+ return download_from_url(redirect_url, options, redirect_count + 1)
209
+ end
210
+
184
211
  @downloaded_file = temp_path
185
212
  @logger.info "Downloaded file to: #{temp_path}"
186
213
  temp_path
187
214
  rescue StandardError => e
215
+ File.delete(temp_path) if defined?(temp_path) && temp_path && File.exist?(temp_path)
188
216
  @logger.error "Download failed: #{e.message}"
189
217
  raise e
190
218
  end
191
219
 
220
+ def cleanup_downloaded_file
221
+ return unless @downloaded_file && File.exist?(@downloaded_file)
222
+
223
+ File.delete(@downloaded_file)
224
+ @downloaded_file = nil
225
+ end
226
+
192
227
  # Extract metadata from file
193
228
  # @param [String] file_path Path to file
194
229
  # @param [Hash] options Metadata options
@@ -235,7 +270,12 @@ module SmartRAG
235
270
  ext = File.extname(file_path).downcase
236
271
  if ['.md', '.markdown'].include?(ext)
237
272
  @logger.info "Detected markdown source; skipping conversion"
238
- return File.read(file_path)
273
+ return read_text_file(file_path)
274
+ end
275
+
276
+ if ['.txt', '.text'].include?(ext)
277
+ @logger.info "Detected plain text source; skipping markitdown conversion"
278
+ return read_text_file(file_path)
239
279
  end
240
280
 
241
281
  # Use markitdown bridge for conversion
@@ -256,7 +296,7 @@ module SmartRAG
256
296
 
257
297
  retries = 0
258
298
  begin
259
- markdown = bridge.convert(file_path)
299
+ markdown = normalize_text_content(bridge.convert(file_path))
260
300
 
261
301
  raise 'Conversion failed: empty result' if markdown.nil? || markdown.strip.empty?
262
302
 
@@ -281,6 +321,27 @@ module SmartRAG
281
321
  raise e
282
322
  end
283
323
 
324
+ def read_text_file(file_path)
325
+ normalize_text_content(File.binread(file_path))
326
+ rescue StandardError => e
327
+ @logger.error "Failed to read text file #{file_path}: #{e.message}"
328
+ raise e
329
+ end
330
+
331
+ def normalize_text_content(content)
332
+ return '' if content.nil?
333
+
334
+ normalized = content.is_a?(String) ? content.dup : content.to_s
335
+
336
+ begin
337
+ normalized = normalized.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
338
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
339
+ normalized = normalized.force_encoding(Encoding::UTF_8).scrub
340
+ end
341
+
342
+ normalized.scrub
343
+ end
344
+
284
345
  # Create or update document record
285
346
  # @param [String] source Original source
286
347
  # @param [Hash] metadata Document metadata
@@ -302,6 +363,7 @@ module SmartRAG
302
363
  source_type: source_type,
303
364
  source_uri: normalized_source_uri,
304
365
  content_hash: content_hash,
366
+ principal: options[:principal] || metadata[:principal] || 'system',
305
367
  metadata: metadata.to_json
306
368
  }
307
369
 
@@ -355,11 +417,13 @@ module SmartRAG
355
417
  # @option options [Boolean] :generate_tags Whether to generate tags for sections
356
418
  def save_sections(document, chunks, options = {})
357
419
  sections = chunks.each_with_index.map do |chunk, index|
420
+ chunk_metadata = chunk[:metadata] || {}
358
421
  {
359
422
  document_id: document.id,
360
423
  section_title: chunk[:title],
361
424
  section_number: index + 1,
362
425
  content: chunk[:content],
426
+ metadata: chunk_metadata.to_json,
363
427
  created_at: Time.now,
364
428
  updated_at: Time.now
365
429
  }
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+
6
+ module SmartRAG
7
+ module Core
8
+ class LocalContentStore
9
+ def initialize(root:)
10
+ @root = File.expand_path(root)
11
+ FileUtils.mkdir_p(@root)
12
+ end
13
+
14
+ def put(file_path)
15
+ digest = Digest::SHA256.file(file_path).hexdigest
16
+ extension = File.extname(file_path).downcase
17
+ destination = File.join(root, digest[0, 2], digest[2, 2], "#{digest}#{extension}")
18
+ FileUtils.mkdir_p(File.dirname(destination))
19
+ copy_atomically(file_path, destination) unless File.exist?(destination)
20
+ { content_hash: digest, storage_uri: "file://#{destination}", stored_path: destination }
21
+ end
22
+
23
+ def delete(storage_uri)
24
+ path = storage_uri.to_s.delete_prefix('file://')
25
+ return false unless path.start_with?("#{root}#{File::SEPARATOR}") && File.file?(path)
26
+
27
+ File.delete(path)
28
+ true
29
+ end
30
+
31
+ def materialize(storage_uri)
32
+ path = storage_uri.to_s.delete_prefix('file://')
33
+ raise ArgumentError, 'storage URI does not belong to this local store' unless path.start_with?("#{root}#{File::SEPARATOR}")
34
+ raise ArgumentError, 'stored media object is missing' unless File.file?(path)
35
+ [path, false]
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :root
41
+
42
+ def copy_atomically(source, destination)
43
+ temporary = "#{destination}.#{Process.pid}.tmp"
44
+ FileUtils.cp(source, temporary)
45
+ File.rename(temporary, destination)
46
+ ensure
47
+ File.delete(temporary) if defined?(temporary) && File.exist?(temporary)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require 'net/http'
6
+ require 'open3'
7
+ require 'timeout'
8
+ require 'uri'
9
+
10
+ module SmartRAG
11
+ module Core
12
+ module MediaExtractors
13
+ class OpenAICompatible
14
+ def initialize(base_url:, api_key:, vision_model: nil, transcription_model: nil,
15
+ timeout_seconds: 120, language: nil)
16
+ @base_url = base_url.to_s.sub(%r{/+$}, '')
17
+ @api_key = api_key
18
+ @vision_model = vision_model
19
+ @transcription_model = transcription_model
20
+ @timeout_seconds = timeout_seconds.to_i
21
+ @language = language
22
+ end
23
+
24
+ def describe(file_path, _timestamp_ms = nil)
25
+ raise ArgumentError, 'vision_model is not configured' if @vision_model.to_s.empty?
26
+
27
+ mime = mime_type(file_path)
28
+ body = {
29
+ model: @vision_model,
30
+ messages: [{ role: 'user', content: [
31
+ { type: 'text', text: 'Describe this image precisely for retrieval. Include visible text and important objects.' },
32
+ { type: 'image_url', image_url: { url: "data:#{mime};base64,#{Base64.strict_encode64(File.binread(file_path))}" } }
33
+ ] }],
34
+ temperature: 0.1
35
+ }
36
+ parsed = post_json('/v1/chat/completions', body)
37
+ parsed.dig('choices', 0, 'message', 'content').to_s.strip
38
+ end
39
+
40
+ alias extract describe
41
+
42
+ def transcribe(file_path)
43
+ raise ArgumentError, 'transcription_model is not configured' if @transcription_model.to_s.empty?
44
+
45
+ boundary = "SmartRAG#{rand(1_000_000_000)}"
46
+ fields = { 'model' => @transcription_model, 'response_format' => 'verbose_json',
47
+ 'timestamp_granularities[]' => 'segment' }
48
+ fields['language'] = @language unless @language.to_s.empty?
49
+ body = multipart_body(boundary, fields, file_path)
50
+ response = request('/v1/audio/transcriptions', body, "multipart/form-data; boundary=#{boundary}")
51
+ JSON.parse(response.body)
52
+ end
53
+
54
+ private
55
+
56
+ def post_json(path, body)
57
+ response = request(path, JSON.generate(body), 'application/json')
58
+ JSON.parse(response.body)
59
+ end
60
+
61
+ def request(path, body, content_type)
62
+ uri = URI.parse("#{@base_url}#{path}")
63
+ request = Net::HTTP::Post.new(uri)
64
+ request['Authorization'] = "Bearer #{@api_key}" unless @api_key.to_s.empty?
65
+ request['Content-Type'] = content_type
66
+ request.body = body
67
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https',
68
+ open_timeout: @timeout_seconds, read_timeout: @timeout_seconds) do |http|
69
+ http.request(request)
70
+ end
71
+ raise "media model HTTP #{response.code}: #{response.body.to_s[0, 300]}" unless response.is_a?(Net::HTTPSuccess)
72
+ response
73
+ end
74
+
75
+ def multipart_body(boundary, fields, file_path)
76
+ body = +''
77
+ fields.each do |key, value|
78
+ body << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{key}\"\r\n\r\n#{value}\r\n"
79
+ end
80
+ body << "--#{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"#{File.basename(file_path)}\"\r\n"
81
+ body << "Content-Type: #{mime_type(file_path)}\r\n\r\n"
82
+ body << File.binread(file_path)
83
+ body << "\r\n--#{boundary}--\r\n"
84
+ body
85
+ end
86
+
87
+ def mime_type(path)
88
+ { '.jpg' => 'image/jpeg', '.jpeg' => 'image/jpeg', '.png' => 'image/png',
89
+ '.webp' => 'image/webp', '.wav' => 'audio/wav', '.mp3' => 'audio/mpeg',
90
+ '.m4a' => 'audio/mp4' }.fetch(File.extname(path).downcase, 'application/octet-stream')
91
+ end
92
+ end
93
+
94
+ class Tesseract
95
+ def initialize(language: nil, timeout_seconds: 60)
96
+ @language = language
97
+ @timeout_seconds = timeout_seconds.to_i
98
+ end
99
+
100
+ def available?
101
+ system('which', 'tesseract', out: File::NULL, err: File::NULL)
102
+ end
103
+
104
+ def extract(file_path)
105
+ raise 'tesseract is not installed' unless available?
106
+
107
+ args = ['tesseract', file_path, 'stdout']
108
+ args.concat(['-l', @language]) unless @language.to_s.empty?
109
+ output = nil
110
+ status = nil
111
+ Timeout.timeout(@timeout_seconds) { output, status = Open3.capture2e(*args) }
112
+ raise "tesseract failed: #{output.to_s.strip}" unless status.success?
113
+
114
+ output.to_s.strip
115
+ end
116
+ end
117
+
118
+ module Factory
119
+ module_function
120
+
121
+ def build(config)
122
+ media = config || {}
123
+ extractors = {}
124
+ if media[:openai]
125
+ adapter = OpenAICompatible.new(**media[:openai])
126
+ extractors[:image_describer] = adapter.method(:describe) if media.dig(:openai, :vision_model)
127
+ if media.dig(:openai, :transcription_model)
128
+ extractors[:audio_transcriber] = adapter.method(:transcribe)
129
+ extractors[:video_transcriber] = adapter.method(:transcribe)
130
+ end
131
+ end
132
+ if media[:ocr]&.fetch(:provider, nil).to_s == 'tesseract'
133
+ extractors[:ocr_extractor] = Tesseract.new(**media[:ocr].reject { |key, _| key == :provider })
134
+ end
135
+ extractors
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end