ragdoll 0.1.1 → 0.1.3
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/README.md +1 -1
- data/Rakefile +52 -1
- data/app/jobs/ragdoll/extract_keywords_job.rb +28 -0
- data/app/jobs/ragdoll/extract_text_job.rb +38 -0
- data/app/jobs/ragdoll/generate_embeddings_job.rb +28 -0
- data/app/jobs/ragdoll/generate_summary_job.rb +25 -0
- data/app/lib/ragdoll/metadata_schemas.rb +332 -0
- data/app/models/ragdoll/audio_content.rb +142 -0
- data/app/models/ragdoll/content.rb +95 -0
- data/app/models/ragdoll/document.rb +611 -0
- data/app/models/ragdoll/embedding.rb +176 -0
- data/app/models/ragdoll/image_content.rb +194 -0
- data/app/models/ragdoll/text_content.rb +137 -0
- data/app/services/ragdoll/configuration_service.rb +113 -0
- data/app/services/ragdoll/document_management.rb +108 -0
- data/app/services/ragdoll/document_processor.rb +342 -0
- data/app/services/ragdoll/embedding_service.rb +202 -0
- data/app/services/ragdoll/image_description_service.rb +230 -0
- data/app/services/ragdoll/metadata_generator.rb +329 -0
- data/app/services/ragdoll/model_resolver.rb +72 -0
- data/app/services/ragdoll/search_engine.rb +51 -0
- data/app/services/ragdoll/text_chunker.rb +208 -0
- data/app/services/ragdoll/text_generation_service.rb +355 -0
- data/lib/ragdoll/core/client.rb +32 -41
- data/lib/ragdoll/core/configuration.rb +140 -156
- data/lib/ragdoll/core/database.rb +1 -1
- data/lib/ragdoll/core/model.rb +45 -0
- data/lib/ragdoll/core/version.rb +1 -1
- data/lib/ragdoll/core.rb +35 -17
- data/lib/ragdoll.rb +1 -1
- data/lib/tasks/annotate.rake +1 -1
- data/lib/tasks/db.rake +2 -2
- metadata +24 -20
- data/lib/ragdoll/core/document_management.rb +0 -110
- data/lib/ragdoll/core/document_processor.rb +0 -344
- data/lib/ragdoll/core/embedding_service.rb +0 -183
- data/lib/ragdoll/core/jobs/extract_keywords.rb +0 -32
- data/lib/ragdoll/core/jobs/extract_text.rb +0 -42
- data/lib/ragdoll/core/jobs/generate_embeddings.rb +0 -32
- data/lib/ragdoll/core/jobs/generate_summary.rb +0 -29
- data/lib/ragdoll/core/metadata_schemas.rb +0 -334
- data/lib/ragdoll/core/models/audio_content.rb +0 -175
- data/lib/ragdoll/core/models/content.rb +0 -126
- data/lib/ragdoll/core/models/document.rb +0 -678
- data/lib/ragdoll/core/models/embedding.rb +0 -204
- data/lib/ragdoll/core/models/image_content.rb +0 -227
- data/lib/ragdoll/core/models/text_content.rb +0 -169
- data/lib/ragdoll/core/search_engine.rb +0 -50
- data/lib/ragdoll/core/services/image_description_service.rb +0 -230
- data/lib/ragdoll/core/services/metadata_generator.rb +0 -335
- data/lib/ragdoll/core/text_chunker.rb +0 -210
- data/lib/ragdoll/core/text_generation_service.rb +0 -360
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record"
|
4
|
+
|
5
|
+
module Ragdoll
|
6
|
+
class Content < ActiveRecord::Base
|
7
|
+
self.table_name = "ragdoll_contents"
|
8
|
+
|
9
|
+
belongs_to :document,
|
10
|
+
class_name: "Ragdoll::Document",
|
11
|
+
foreign_key: "document_id"
|
12
|
+
|
13
|
+
has_many :embeddings,
|
14
|
+
class_name: "Ragdoll::Embedding",
|
15
|
+
as: :embeddable,
|
16
|
+
dependent: :destroy
|
17
|
+
|
18
|
+
validates :type, presence: true
|
19
|
+
validates :embedding_model, presence: true
|
20
|
+
validates :document_id, presence: true
|
21
|
+
|
22
|
+
# JSON columns are handled natively by PostgreSQL
|
23
|
+
|
24
|
+
scope :by_type, ->(content_type) { where(type: content_type) }
|
25
|
+
scope :with_embeddings, -> { joins(:embeddings).distinct }
|
26
|
+
scope :without_embeddings, -> { left_joins(:embeddings).where(embeddings: { id: nil }) }
|
27
|
+
|
28
|
+
# Generate embeddings for this content
|
29
|
+
def generate_embeddings!
|
30
|
+
return unless should_generate_embeddings?
|
31
|
+
|
32
|
+
embedding_content = content_for_embedding
|
33
|
+
return if embedding_content.blank?
|
34
|
+
|
35
|
+
# Clear existing embeddings
|
36
|
+
embeddings.destroy_all
|
37
|
+
|
38
|
+
# Use TextChunker to split content into chunks
|
39
|
+
chunks = Ragdoll::TextChunker.chunk(embedding_content)
|
40
|
+
|
41
|
+
# Generate embeddings for each chunk
|
42
|
+
embedding_service = Ragdoll::EmbeddingService.new
|
43
|
+
|
44
|
+
chunks.each_with_index do |chunk_text, index|
|
45
|
+
begin
|
46
|
+
vector = embedding_service.generate_embedding(chunk_text)
|
47
|
+
|
48
|
+
embeddings.create!(
|
49
|
+
content: chunk_text,
|
50
|
+
embedding_vector: vector,
|
51
|
+
chunk_index: index
|
52
|
+
)
|
53
|
+
rescue StandardError => e
|
54
|
+
puts "Failed to generate embedding for chunk #{index}: #{e.message}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
update!(metadata: metadata.merge("embeddings_generated_at" => Time.current))
|
59
|
+
end
|
60
|
+
|
61
|
+
# Content to use for embedding generation (overridden by subclasses)
|
62
|
+
def content_for_embedding
|
63
|
+
content
|
64
|
+
end
|
65
|
+
|
66
|
+
# Whether this content should generate embeddings
|
67
|
+
def should_generate_embeddings?
|
68
|
+
content_for_embedding.present? && embeddings.empty?
|
69
|
+
end
|
70
|
+
|
71
|
+
# Statistics
|
72
|
+
def word_count
|
73
|
+
return 0 unless content.present?
|
74
|
+
content.split(/\s+/).length
|
75
|
+
end
|
76
|
+
|
77
|
+
def character_count
|
78
|
+
content&.length || 0
|
79
|
+
end
|
80
|
+
|
81
|
+
def embedding_count
|
82
|
+
embeddings.count
|
83
|
+
end
|
84
|
+
|
85
|
+
# Search within this content type
|
86
|
+
def self.search_content(query, **options)
|
87
|
+
return none if query.blank?
|
88
|
+
|
89
|
+
where(
|
90
|
+
"to_tsvector('english', COALESCE(content, '')) @@ plainto_tsquery('english', ?)",
|
91
|
+
query
|
92
|
+
).limit(options[:limit] || 20)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,611 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record"
|
4
|
+
|
5
|
+
module Ragdoll
|
6
|
+
class Document < ActiveRecord::Base
|
7
|
+
self.table_name = "ragdoll_documents"
|
8
|
+
|
9
|
+
# PostgreSQL full-text search on summary and keywords
|
10
|
+
# Uses PostgreSQL's built-in full-text search capabilities
|
11
|
+
|
12
|
+
# File handling moved to content models - no Shrine attachment at document level
|
13
|
+
|
14
|
+
# Multi-modal content relationships using STI
|
15
|
+
has_many :contents,
|
16
|
+
class_name: "Ragdoll::Content",
|
17
|
+
foreign_key: "document_id",
|
18
|
+
dependent: :destroy
|
19
|
+
|
20
|
+
has_many :text_contents,
|
21
|
+
-> { where(type: "Ragdoll::TextContent") },
|
22
|
+
class_name: "Ragdoll::TextContent",
|
23
|
+
foreign_key: "document_id"
|
24
|
+
|
25
|
+
has_many :image_contents,
|
26
|
+
-> { where(type: "Ragdoll::ImageContent") },
|
27
|
+
class_name: "Ragdoll::ImageContent",
|
28
|
+
foreign_key: "document_id"
|
29
|
+
|
30
|
+
has_many :audio_contents,
|
31
|
+
-> { where(type: "Ragdoll::AudioContent") },
|
32
|
+
class_name: "Ragdoll::AudioContent",
|
33
|
+
foreign_key: "document_id"
|
34
|
+
|
35
|
+
# All embeddings across content types
|
36
|
+
has_many :text_embeddings, through: :text_contents, source: :embeddings
|
37
|
+
has_many :image_embeddings, through: :image_contents, source: :embeddings
|
38
|
+
has_many :audio_embeddings, through: :audio_contents, source: :embeddings
|
39
|
+
|
40
|
+
validates :location, presence: true
|
41
|
+
validates :title, presence: true
|
42
|
+
validates :document_type, presence: true,
|
43
|
+
inclusion: { in: %w[text image audio pdf docx html markdown mixed] }
|
44
|
+
validates :summary, presence: false # Allow empty summaries initially
|
45
|
+
validates :keywords, presence: false # Allow empty keywords initially
|
46
|
+
validates :status, inclusion: { in: %w[pending processing processed error] }
|
47
|
+
validates :file_modified_at, presence: true
|
48
|
+
|
49
|
+
# Ensure location is always an absolute path for file paths
|
50
|
+
before_validation :normalize_location
|
51
|
+
before_validation :set_default_file_modified_at
|
52
|
+
|
53
|
+
# JSON columns are handled natively by PostgreSQL - no serialization needed
|
54
|
+
|
55
|
+
scope :processed, -> { where(status: "processed") }
|
56
|
+
scope :by_type, ->(type) { where(document_type: type) }
|
57
|
+
scope :recent, -> { order(created_at: :desc) }
|
58
|
+
scope :with_content, -> { joins(:contents).distinct }
|
59
|
+
scope :without_content, -> { left_joins(:contents).where(contents: { id: nil }) }
|
60
|
+
|
61
|
+
# Callbacks to process content
|
62
|
+
after_commit :create_content_from_pending, on: %i[create update],
|
63
|
+
if: :has_pending_content?
|
64
|
+
|
65
|
+
def processed?
|
66
|
+
status == "processed"
|
67
|
+
end
|
68
|
+
|
69
|
+
# Multi-modal content type detection
|
70
|
+
def multi_modal?
|
71
|
+
content_types.length > 1
|
72
|
+
end
|
73
|
+
|
74
|
+
def content_types
|
75
|
+
%w[text image audio].select do |type|
|
76
|
+
send("#{type}_contents").any?
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def primary_content_type
|
81
|
+
return document_type if %w[text image audio].include?(document_type)
|
82
|
+
return content_types.first if content_types.any?
|
83
|
+
|
84
|
+
"text" # default
|
85
|
+
end
|
86
|
+
|
87
|
+
# Dynamic content method that forwards to appropriate content table
|
88
|
+
def content
|
89
|
+
type = primary_content_type
|
90
|
+
|
91
|
+
if %w[text image audio].include?(type)
|
92
|
+
# Return the combined content from the appropriate content type
|
93
|
+
# For text: actual text content
|
94
|
+
# For image: AI-generated descriptions (stored in content field)
|
95
|
+
# For audio: transcripts (stored in content field)
|
96
|
+
send("#{type}_contents").pluck(:content).compact.join("\n\n")
|
97
|
+
else
|
98
|
+
# Fallback: try to get any available content
|
99
|
+
contents.pluck(:content).compact.join("\n\n")
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Set content method for backwards compatibility
|
104
|
+
def content=(value)
|
105
|
+
# Store the content to be created after save
|
106
|
+
@pending_content = value
|
107
|
+
|
108
|
+
# If document is already persisted, create the content immediately
|
109
|
+
return unless persisted?
|
110
|
+
|
111
|
+
create_content_from_pending
|
112
|
+
end
|
113
|
+
|
114
|
+
# Content statistics
|
115
|
+
def total_word_count
|
116
|
+
text_contents.sum { |tc| tc.word_count }
|
117
|
+
end
|
118
|
+
|
119
|
+
def total_character_count
|
120
|
+
text_contents.sum { |tc| tc.character_count }
|
121
|
+
end
|
122
|
+
|
123
|
+
def total_embedding_count
|
124
|
+
%w[text image audio].sum { |type| send("#{type}_embeddings").count }
|
125
|
+
end
|
126
|
+
|
127
|
+
def embeddings_by_type
|
128
|
+
%w[text image audio].each_with_object({}) do |type, result|
|
129
|
+
result[type.to_sym] = send("#{type}_embeddings").count
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
# Document metadata methods - now using dedicated columns
|
134
|
+
def has_summary?
|
135
|
+
summary.present?
|
136
|
+
end
|
137
|
+
|
138
|
+
def has_keywords?
|
139
|
+
keywords.present?
|
140
|
+
end
|
141
|
+
|
142
|
+
def keywords_array
|
143
|
+
return [] unless keywords.present?
|
144
|
+
|
145
|
+
case keywords
|
146
|
+
when Array
|
147
|
+
keywords
|
148
|
+
when String
|
149
|
+
keywords.split(",").map(&:strip).reject(&:empty?)
|
150
|
+
else
|
151
|
+
[]
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def add_keyword(keyword)
|
156
|
+
current_keywords = keywords_array
|
157
|
+
return if current_keywords.include?(keyword.strip)
|
158
|
+
|
159
|
+
current_keywords << keyword.strip
|
160
|
+
self.keywords = current_keywords.join(", ")
|
161
|
+
end
|
162
|
+
|
163
|
+
def remove_keyword(keyword)
|
164
|
+
current_keywords = keywords_array
|
165
|
+
current_keywords.delete(keyword.strip)
|
166
|
+
self.keywords = current_keywords.join(", ")
|
167
|
+
end
|
168
|
+
|
169
|
+
# Metadata accessors for common fields
|
170
|
+
def description
|
171
|
+
metadata["description"]
|
172
|
+
end
|
173
|
+
|
174
|
+
def description=(value)
|
175
|
+
self.metadata = metadata.merge("description" => value)
|
176
|
+
end
|
177
|
+
|
178
|
+
def classification
|
179
|
+
metadata["classification"]
|
180
|
+
end
|
181
|
+
|
182
|
+
def classification=(value)
|
183
|
+
self.metadata = metadata.merge("classification" => value)
|
184
|
+
end
|
185
|
+
|
186
|
+
def tags
|
187
|
+
metadata["tags"] || []
|
188
|
+
end
|
189
|
+
|
190
|
+
def tags=(value)
|
191
|
+
self.metadata = metadata.merge("tags" => Array(value))
|
192
|
+
end
|
193
|
+
|
194
|
+
# File-related helper methods - now delegated to content models
|
195
|
+
def has_files?
|
196
|
+
contents.any? { |c| c.data.present? }
|
197
|
+
end
|
198
|
+
|
199
|
+
def total_file_size
|
200
|
+
# Could be implemented by summing file sizes from content metadata
|
201
|
+
contents.sum { |c| c.metadata.dig("file_size") || 0 }
|
202
|
+
end
|
203
|
+
|
204
|
+
def primary_file_type
|
205
|
+
# Return the document_type as the primary file type
|
206
|
+
document_type
|
207
|
+
end
|
208
|
+
|
209
|
+
# Content processing for multi-modal documents
|
210
|
+
def process_content!
|
211
|
+
# Content processing is now handled by individual content models
|
212
|
+
# This method orchestrates the overall processing
|
213
|
+
|
214
|
+
# Generate embeddings for all content
|
215
|
+
generate_embeddings_for_all_content!
|
216
|
+
|
217
|
+
# Generate structured metadata using LLM
|
218
|
+
generate_metadata!
|
219
|
+
|
220
|
+
update!(status: "processed")
|
221
|
+
end
|
222
|
+
|
223
|
+
# Generate embeddings for all content types
|
224
|
+
def generate_embeddings_for_all_content!
|
225
|
+
%w[text image audio].each do |type|
|
226
|
+
send("#{type}_contents").each(&:generate_embeddings!)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
# Generate structured metadata using LLM
|
231
|
+
def generate_metadata!
|
232
|
+
require_relative "../../lib/ragdoll/core/services/metadata_generator"
|
233
|
+
|
234
|
+
generator = Ragdoll::MetadataGenerator.new
|
235
|
+
generated_metadata = generator.generate_for_document(self)
|
236
|
+
|
237
|
+
# Validate metadata against schema
|
238
|
+
errors = Ragdoll::MetadataSchemas.validate_metadata(document_type, generated_metadata)
|
239
|
+
if errors.any?
|
240
|
+
Rails.logger.warn "Metadata validation errors: #{errors.join(', ')}" if defined?(Rails)
|
241
|
+
puts "Metadata validation errors: #{errors.join(', ')}"
|
242
|
+
end
|
243
|
+
|
244
|
+
# Merge with existing metadata (preserving user-set values)
|
245
|
+
self.metadata = metadata.merge(generated_metadata)
|
246
|
+
save!
|
247
|
+
rescue StandardError => e
|
248
|
+
Rails.logger.error "Metadata generation failed: #{e.message}" if defined?(Rails)
|
249
|
+
puts "Metadata generation failed: #{e.message}"
|
250
|
+
end
|
251
|
+
|
252
|
+
# PostgreSQL full-text search on metadata fields
|
253
|
+
def self.search_content(query, **options)
|
254
|
+
return none if query.blank?
|
255
|
+
|
256
|
+
# Use PostgreSQL's built-in full-text search across metadata fields
|
257
|
+
where(
|
258
|
+
"to_tsvector('english', COALESCE(title, '') || ' ' || COALESCE(metadata->>'summary', '') || ' ' || COALESCE(metadata->>'keywords', '') || ' ' || COALESCE(metadata->>'description', '')) @@ plainto_tsquery('english', ?)",
|
259
|
+
query
|
260
|
+
).limit(options[:limit] || 20)
|
261
|
+
end
|
262
|
+
|
263
|
+
# Faceted search by metadata fields
|
264
|
+
def self.faceted_search(query: nil, keywords: [], classification: nil, tags: [], **options)
|
265
|
+
scope = all
|
266
|
+
|
267
|
+
# Filter by keywords if provided
|
268
|
+
if keywords.any?
|
269
|
+
keywords.each do |keyword|
|
270
|
+
scope = scope.where("metadata->>'keywords' ILIKE ?", "%#{keyword}%")
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
# Filter by classification
|
275
|
+
scope = scope.where("metadata->>'classification' = ?", classification) if classification.present?
|
276
|
+
|
277
|
+
# Filter by tags
|
278
|
+
if tags.any?
|
279
|
+
tags.each do |tag|
|
280
|
+
scope = scope.where("metadata ? 'tags' AND metadata->'tags' @> ?", [tag].to_json)
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
# Apply PostgreSQL full-text search if query provided
|
285
|
+
if query.present?
|
286
|
+
scope = scope.where(
|
287
|
+
"to_tsvector('english', COALESCE(title, '') || ' ' || COALESCE(metadata->>'summary', '') || ' ' || COALESCE(metadata->>'keywords', '') || ' ' || COALESCE(metadata->>'description', '')) @@ plainto_tsquery('english', ?)",
|
288
|
+
query
|
289
|
+
)
|
290
|
+
end
|
291
|
+
|
292
|
+
scope.limit(options[:limit] || 20)
|
293
|
+
end
|
294
|
+
|
295
|
+
# Get all unique keywords from metadata
|
296
|
+
def self.all_keywords
|
297
|
+
keywords = []
|
298
|
+
where("metadata ? 'keywords'").pluck(:metadata).each do |meta|
|
299
|
+
case meta["keywords"]
|
300
|
+
when Array
|
301
|
+
keywords.concat(meta["keywords"])
|
302
|
+
when String
|
303
|
+
keywords.concat(meta["keywords"].split(",").map(&:strip))
|
304
|
+
end
|
305
|
+
end
|
306
|
+
keywords.uniq.sort
|
307
|
+
end
|
308
|
+
|
309
|
+
# Get all unique classifications
|
310
|
+
def self.all_classifications
|
311
|
+
where("metadata ? 'classification'").distinct.pluck("metadata->>'classification'").compact.sort
|
312
|
+
end
|
313
|
+
|
314
|
+
# Get all unique tags
|
315
|
+
def self.all_tags
|
316
|
+
tags = []
|
317
|
+
where("metadata ? 'tags'").pluck(:metadata).each do |meta|
|
318
|
+
tags.concat(Array(meta["tags"]))
|
319
|
+
end
|
320
|
+
tags.uniq.sort
|
321
|
+
end
|
322
|
+
|
323
|
+
# Get keyword frequencies for faceted search
|
324
|
+
def self.keyword_frequencies
|
325
|
+
frequencies = Hash.new(0)
|
326
|
+
where("metadata ? 'keywords'").pluck(:metadata).each do |meta|
|
327
|
+
case meta["keywords"]
|
328
|
+
when Array
|
329
|
+
meta["keywords"].each { |k| frequencies[k] += 1 }
|
330
|
+
when String
|
331
|
+
meta["keywords"].split(",").map(&:strip).each { |k| frequencies[k] += 1 }
|
332
|
+
end
|
333
|
+
end
|
334
|
+
frequencies.sort_by { |_k, v| -v }.to_h
|
335
|
+
end
|
336
|
+
|
337
|
+
# Hybrid search combining semantic and PostgreSQL full-text search
|
338
|
+
def self.hybrid_search(query, query_embedding: nil, **options)
|
339
|
+
limit = options[:limit] || 20
|
340
|
+
semantic_weight = options[:semantic_weight] || 0.7
|
341
|
+
text_weight = options[:text_weight] || 0.3
|
342
|
+
|
343
|
+
results = []
|
344
|
+
|
345
|
+
# Get semantic search results if embedding provided
|
346
|
+
if query_embedding
|
347
|
+
semantic_results = embeddings_search(query_embedding, limit: limit)
|
348
|
+
results.concat(semantic_results.map do |result|
|
349
|
+
result.merge(
|
350
|
+
search_type: "semantic",
|
351
|
+
weighted_score: result[:combined_score] * semantic_weight
|
352
|
+
)
|
353
|
+
end)
|
354
|
+
end
|
355
|
+
|
356
|
+
# Get PostgreSQL full-text search results
|
357
|
+
text_results = search_content(query, limit: limit)
|
358
|
+
text_results.each_with_index do |doc, index|
|
359
|
+
score = (limit - index).to_f / limit * text_weight
|
360
|
+
results << {
|
361
|
+
document_id: doc.id.to_s,
|
362
|
+
document_title: doc.title,
|
363
|
+
document_location: doc.location,
|
364
|
+
content: doc.content[0..500], # Preview
|
365
|
+
search_type: "full_text",
|
366
|
+
weighted_score: score,
|
367
|
+
document: doc
|
368
|
+
}
|
369
|
+
end
|
370
|
+
|
371
|
+
# Combine and deduplicate by document_id
|
372
|
+
combined = results.group_by { |r| r[:document_id] }
|
373
|
+
.map do |_doc_id, doc_results|
|
374
|
+
best_result = doc_results.max_by { |r| r[:weighted_score] }
|
375
|
+
total_score = doc_results.sum { |r| r[:weighted_score] }
|
376
|
+
search_types = doc_results.map { |r| r[:search_type] }.uniq
|
377
|
+
|
378
|
+
best_result.merge(
|
379
|
+
combined_score: total_score,
|
380
|
+
search_types: search_types
|
381
|
+
)
|
382
|
+
end
|
383
|
+
|
384
|
+
combined.sort_by { |r| -r[:combined_score] }.take(limit)
|
385
|
+
end
|
386
|
+
|
387
|
+
# Extract keywords from query string (words > 4 characters)
|
388
|
+
def self.extract_keywords(query:)
|
389
|
+
return [] if query.nil? || query.strip.empty?
|
390
|
+
|
391
|
+
query.split(/\s+/)
|
392
|
+
.map(&:strip)
|
393
|
+
.reject(&:empty?)
|
394
|
+
.select { |word| word.length > 4 }
|
395
|
+
end
|
396
|
+
|
397
|
+
# Get search data for indexing
|
398
|
+
def search_data
|
399
|
+
data = {
|
400
|
+
title: title,
|
401
|
+
document_type: document_type,
|
402
|
+
location: location,
|
403
|
+
status: status,
|
404
|
+
total_word_count: total_word_count,
|
405
|
+
total_character_count: total_character_count,
|
406
|
+
total_embedding_count: total_embedding_count,
|
407
|
+
content_types: content_types,
|
408
|
+
multi_modal: multi_modal?
|
409
|
+
}
|
410
|
+
|
411
|
+
# Add document metadata
|
412
|
+
data.merge!(metadata.transform_keys { |k| "metadata_#{k}" }) if metadata.present?
|
413
|
+
|
414
|
+
# Add file metadata
|
415
|
+
data.merge!(file_metadata.transform_keys { |k| "file_#{k}" }) if file_metadata.present?
|
416
|
+
|
417
|
+
data
|
418
|
+
end
|
419
|
+
|
420
|
+
def all_embeddings(content_type: nil)
|
421
|
+
content_ids = []
|
422
|
+
|
423
|
+
content_types = content_type ? [content_type.to_s] : %w[text image audio]
|
424
|
+
|
425
|
+
content_types.each do |type|
|
426
|
+
content_relation = send("#{type}_contents")
|
427
|
+
content_ids.concat(content_relation.pluck(:id)) if content_relation.any?
|
428
|
+
end
|
429
|
+
|
430
|
+
return Ragdoll::Embedding.none if content_ids.empty?
|
431
|
+
|
432
|
+
# Use the base STI class name 'Ragdoll::Content' as that's what's stored
|
433
|
+
# in polymorphic associations with STI
|
434
|
+
Ragdoll::Embedding.where(
|
435
|
+
embeddable_type: "Ragdoll::Content",
|
436
|
+
embeddable_id: content_ids
|
437
|
+
)
|
438
|
+
end
|
439
|
+
|
440
|
+
private
|
441
|
+
|
442
|
+
def has_pending_content?
|
443
|
+
@pending_content.present?
|
444
|
+
end
|
445
|
+
|
446
|
+
def create_content_from_pending
|
447
|
+
return unless @pending_content.present?
|
448
|
+
|
449
|
+
value = @pending_content
|
450
|
+
@pending_content = nil
|
451
|
+
|
452
|
+
case primary_content_type
|
453
|
+
when "text"
|
454
|
+
# Create or update the first text_content
|
455
|
+
if text_contents.any?
|
456
|
+
text_contents.first.update!(content: value)
|
457
|
+
else
|
458
|
+
text_contents.create!(
|
459
|
+
content: value,
|
460
|
+
embedding_model: default_text_model,
|
461
|
+
metadata: { manually_set: true }
|
462
|
+
)
|
463
|
+
end
|
464
|
+
when "image"
|
465
|
+
# For images, set the description (stored in content field)
|
466
|
+
if image_contents.any?
|
467
|
+
image_contents.first.update!(content: value) # content field stores description
|
468
|
+
else
|
469
|
+
image_contents.create!(
|
470
|
+
content: value, # content field stores description
|
471
|
+
embedding_model: default_image_model,
|
472
|
+
metadata: { manually_set: true }
|
473
|
+
)
|
474
|
+
end
|
475
|
+
when "audio"
|
476
|
+
# For audio, set the transcript (stored in content field)
|
477
|
+
if audio_contents.any?
|
478
|
+
audio_contents.first.update!(content: value) # content field stores transcript
|
479
|
+
else
|
480
|
+
audio_contents.create!(
|
481
|
+
content: value, # content field stores transcript
|
482
|
+
embedding_model: default_audio_model,
|
483
|
+
metadata: { manually_set: true }
|
484
|
+
)
|
485
|
+
end
|
486
|
+
else
|
487
|
+
# Default to text content
|
488
|
+
text_contents.create!(
|
489
|
+
content: value,
|
490
|
+
embedding_model: default_text_model,
|
491
|
+
metadata: { manually_set: true }
|
492
|
+
)
|
493
|
+
end
|
494
|
+
end
|
495
|
+
|
496
|
+
def self.embeddings_search(query_embedding, **options)
|
497
|
+
Ragdoll::Embedding.search_similar(query_embedding, **options)
|
498
|
+
end
|
499
|
+
|
500
|
+
# File processing is now handled by DocumentProcessor and content models
|
501
|
+
# These methods are no longer needed at the document level
|
502
|
+
|
503
|
+
# Default model names for each content type
|
504
|
+
def default_text_model
|
505
|
+
"text-embedding-3-large"
|
506
|
+
end
|
507
|
+
|
508
|
+
def default_image_model
|
509
|
+
"clip-vit-large-patch14"
|
510
|
+
end
|
511
|
+
|
512
|
+
def default_audio_model
|
513
|
+
"whisper-embedding-v1"
|
514
|
+
end
|
515
|
+
|
516
|
+
# File extraction is now handled by DocumentProcessor
|
517
|
+
# Content-specific extraction is handled by individual content models
|
518
|
+
|
519
|
+
# Get document statistics
|
520
|
+
def self.stats
|
521
|
+
{
|
522
|
+
total_documents: count,
|
523
|
+
by_status: group(:status).count,
|
524
|
+
by_type: group(:document_type).count,
|
525
|
+
multi_modal_documents: joins(:text_contents, :image_contents).distinct.count +
|
526
|
+
joins(:text_contents, :audio_contents).distinct.count +
|
527
|
+
joins(:image_contents, :audio_contents).distinct.count,
|
528
|
+
total_text_contents: joins(:text_contents).count,
|
529
|
+
total_image_contents: joins(:image_contents).count,
|
530
|
+
total_audio_contents: joins(:audio_contents).count,
|
531
|
+
total_embeddings: {
|
532
|
+
text: joins(:text_embeddings).count,
|
533
|
+
image: joins(:image_embeddings).count,
|
534
|
+
audio: joins(:audio_embeddings).count
|
535
|
+
},
|
536
|
+
storage_type: "activerecord_polymorphic"
|
537
|
+
}
|
538
|
+
end
|
539
|
+
|
540
|
+
public
|
541
|
+
|
542
|
+
# Convert document to hash representation for API responses
|
543
|
+
def to_hash(include_content: false)
|
544
|
+
{
|
545
|
+
id: id.to_s,
|
546
|
+
title: title,
|
547
|
+
location: location,
|
548
|
+
document_type: document_type,
|
549
|
+
status: status,
|
550
|
+
content_length: content&.length || 0,
|
551
|
+
file_modified_at: file_modified_at&.iso8601,
|
552
|
+
created_at: created_at&.iso8601,
|
553
|
+
updated_at: updated_at&.iso8601,
|
554
|
+
metadata: metadata || {},
|
555
|
+
content_summary: {
|
556
|
+
text_contents: text_contents.count,
|
557
|
+
image_contents: image_contents.count,
|
558
|
+
audio_contents: audio_contents.count,
|
559
|
+
embeddings_count: total_embeddings_count,
|
560
|
+
embeddings_ready: status == "processed"
|
561
|
+
}
|
562
|
+
}.tap do |hash|
|
563
|
+
if include_content
|
564
|
+
hash[:content_details] = {
|
565
|
+
text_content: text_contents.map(&:content),
|
566
|
+
image_descriptions: image_contents.map(&:description),
|
567
|
+
audio_transcripts: audio_contents.map(&:transcript)
|
568
|
+
}
|
569
|
+
end
|
570
|
+
end
|
571
|
+
end
|
572
|
+
|
573
|
+
private
|
574
|
+
|
575
|
+
def total_embeddings_count
|
576
|
+
# Count embeddings through polymorphic associations
|
577
|
+
%w[text image audio].sum do |type|
|
578
|
+
send("#{type}_contents").sum { |content| content.embeddings.count }
|
579
|
+
end
|
580
|
+
end
|
581
|
+
|
582
|
+
# Normalize location to absolute path for file paths
|
583
|
+
def normalize_location
|
584
|
+
return if location.blank?
|
585
|
+
|
586
|
+
# Don't normalize URLs or other non-file protocols
|
587
|
+
return if location.start_with?("http://", "https://", "ftp://", "sftp://")
|
588
|
+
|
589
|
+
# Convert relative file paths to absolute paths
|
590
|
+
self.location = File.expand_path(location)
|
591
|
+
end
|
592
|
+
|
593
|
+
# Set default file_modified_at if not provided
|
594
|
+
def set_default_file_modified_at
|
595
|
+
return if file_modified_at.present?
|
596
|
+
|
597
|
+
# If location is a file path that exists, use file mtime
|
598
|
+
if location.present? && !location.start_with?("http://", "https://", "ftp://", "sftp://")
|
599
|
+
expanded_location = File.expand_path(location)
|
600
|
+
self.file_modified_at = if File.exist?(expanded_location)
|
601
|
+
File.mtime(expanded_location)
|
602
|
+
else
|
603
|
+
Time.current
|
604
|
+
end
|
605
|
+
else
|
606
|
+
# For URLs or non-file locations, use current time
|
607
|
+
self.file_modified_at = Time.current
|
608
|
+
end
|
609
|
+
end
|
610
|
+
end
|
611
|
+
end
|