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
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://
|
|
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
|
data/lib/smart_rag/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|