ragnar-cli 0.1.0.pre.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +439 -0
- data/exe/ragnar +6 -0
- data/lib/ragnar/chunker.rb +97 -0
- data/lib/ragnar/cli.rb +542 -0
- data/lib/ragnar/context_repacker.rb +121 -0
- data/lib/ragnar/database.rb +267 -0
- data/lib/ragnar/embedder.rb +137 -0
- data/lib/ragnar/indexer.rb +234 -0
- data/lib/ragnar/llm_manager.rb +43 -0
- data/lib/ragnar/query_processor.rb +398 -0
- data/lib/ragnar/query_rewriter.rb +75 -0
- data/lib/ragnar/topic_modeling/engine.rb +221 -0
- data/lib/ragnar/topic_modeling/labeling_strategies.rb +300 -0
- data/lib/ragnar/topic_modeling/llm_adapter.rb +131 -0
- data/lib/ragnar/topic_modeling/metrics.rb +186 -0
- data/lib/ragnar/topic_modeling/term_extractor.rb +170 -0
- data/lib/ragnar/topic_modeling/topic.rb +117 -0
- data/lib/ragnar/topic_modeling/topic_labeler.rb +61 -0
- data/lib/ragnar/topic_modeling.rb +24 -0
- data/lib/ragnar/umap_processor.rb +228 -0
- data/lib/ragnar/umap_transform_service.rb +124 -0
- data/lib/ragnar/version.rb +5 -0
- data/lib/ragnar.rb +36 -0
- data/lib/ragnar_cli.rb +2 -0
- metadata +234 -0
data/lib/ragnar/cli.rb
ADDED
@@ -0,0 +1,542 @@
|
|
1
|
+
module Ragnar
|
2
|
+
class CLI < Thor
|
3
|
+
desc "index PATH", "Index text files from PATH (file or directory)"
|
4
|
+
option :db_path, type: :string, default: Ragnar::DEFAULT_DB_PATH, desc: "Path to Lance database"
|
5
|
+
option :chunk_size, type: :numeric, default: Ragnar::DEFAULT_CHUNK_SIZE, desc: "Chunk size in tokens"
|
6
|
+
option :chunk_overlap, type: :numeric, default: Ragnar::DEFAULT_CHUNK_OVERLAP, desc: "Chunk overlap in tokens"
|
7
|
+
option :model, type: :string, default: Ragnar::DEFAULT_EMBEDDING_MODEL, desc: "Embedding model to use"
|
8
|
+
def index(path)
|
9
|
+
unless File.exist?(path)
|
10
|
+
say "Error: Path does not exist: #{path}", :red
|
11
|
+
exit 1
|
12
|
+
end
|
13
|
+
|
14
|
+
say "Indexing files from: #{path}", :green
|
15
|
+
|
16
|
+
indexer = Indexer.new(
|
17
|
+
db_path: options[:db_path],
|
18
|
+
chunk_size: options[:chunk_size],
|
19
|
+
chunk_overlap: options[:chunk_overlap],
|
20
|
+
embedding_model: options[:model]
|
21
|
+
)
|
22
|
+
|
23
|
+
begin
|
24
|
+
stats = indexer.index_path(path)
|
25
|
+
say "\nIndexing complete!", :green
|
26
|
+
say "Files processed: #{stats[:files_processed]}"
|
27
|
+
say "Chunks created: #{stats[:chunks_created]}"
|
28
|
+
say "Errors: #{stats[:errors]}" if stats[:errors] > 0
|
29
|
+
rescue => e
|
30
|
+
say "Error during indexing: #{e.message}", :red
|
31
|
+
exit 1
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
desc "train-umap", "Train UMAP model on existing embeddings"
|
36
|
+
option :db_path, type: :string, default: Ragnar::DEFAULT_DB_PATH, desc: "Path to Lance database"
|
37
|
+
option :n_components, type: :numeric, default: 50, desc: "Number of dimensions for reduction"
|
38
|
+
option :n_neighbors, type: :numeric, default: 15, desc: "Number of neighbors for UMAP"
|
39
|
+
option :min_dist, type: :numeric, default: 0.1, desc: "Minimum distance for UMAP"
|
40
|
+
option :model_path, type: :string, default: "umap_model.bin", desc: "Path to save UMAP model"
|
41
|
+
def train_umap
|
42
|
+
say "Training UMAP model on embeddings...", :green
|
43
|
+
|
44
|
+
processor = UmapProcessor.new(
|
45
|
+
db_path: options[:db_path],
|
46
|
+
model_path: options[:model_path]
|
47
|
+
)
|
48
|
+
|
49
|
+
begin
|
50
|
+
stats = processor.train(
|
51
|
+
n_components: options[:n_components],
|
52
|
+
n_neighbors: options[:n_neighbors],
|
53
|
+
min_dist: options[:min_dist]
|
54
|
+
)
|
55
|
+
|
56
|
+
say "\nUMAP training complete!", :green
|
57
|
+
say "Embeddings processed: #{stats[:embeddings_count]}"
|
58
|
+
say "Original dimensions: #{stats[:original_dims]}"
|
59
|
+
say "Reduced dimensions: #{stats[:reduced_dims]}"
|
60
|
+
say "Model saved to: #{options[:model_path]}"
|
61
|
+
rescue => e
|
62
|
+
say "Error during UMAP training: #{e.message}", :red
|
63
|
+
exit 1
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
desc "apply-umap", "Apply trained UMAP model to reduce embedding dimensions"
|
68
|
+
option :db_path, type: :string, default: Ragnar::DEFAULT_DB_PATH, desc: "Path to Lance database"
|
69
|
+
option :model_path, type: :string, default: "umap_model.bin", desc: "Path to UMAP model"
|
70
|
+
option :batch_size, type: :numeric, default: 100, desc: "Batch size for processing"
|
71
|
+
def apply_umap
|
72
|
+
unless File.exist?(options[:model_path])
|
73
|
+
say "Error: UMAP model not found at: #{options[:model_path]}", :red
|
74
|
+
say "Please run 'train-umap' first to create a model.", :yellow
|
75
|
+
exit 1
|
76
|
+
end
|
77
|
+
|
78
|
+
say "Applying UMAP model to embeddings...", :green
|
79
|
+
|
80
|
+
processor = UmapProcessor.new(
|
81
|
+
db_path: options[:db_path],
|
82
|
+
model_path: options[:model_path]
|
83
|
+
)
|
84
|
+
|
85
|
+
begin
|
86
|
+
stats = processor.apply(batch_size: options[:batch_size])
|
87
|
+
|
88
|
+
say "\nUMAP application complete!", :green
|
89
|
+
say "Embeddings processed: #{stats[:processed]}"
|
90
|
+
say "Already processed: #{stats[:skipped]}"
|
91
|
+
say "Errors: #{stats[:errors]}" if stats[:errors] > 0
|
92
|
+
rescue => e
|
93
|
+
say "Error applying UMAP: #{e.message}", :red
|
94
|
+
exit 1
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
desc "topics", "Extract and display topics from indexed documents"
|
99
|
+
option :db_path, type: :string, default: Ragnar::DEFAULT_DB_PATH, desc: "Path to Lance database"
|
100
|
+
option :min_cluster_size, type: :numeric, default: 5, desc: "Minimum documents per topic"
|
101
|
+
option :method, type: :string, default: "hybrid", desc: "Labeling method: fast, quality, or hybrid"
|
102
|
+
option :export, type: :string, desc: "Export topics to file (json or html)"
|
103
|
+
option :verbose, type: :boolean, default: false, aliases: "-v", desc: "Show detailed processing"
|
104
|
+
def topics
|
105
|
+
require_relative 'topic_modeling'
|
106
|
+
|
107
|
+
say "Extracting topics from indexed documents...", :green
|
108
|
+
|
109
|
+
# Load embeddings and documents from database
|
110
|
+
database = Database.new(options[:db_path])
|
111
|
+
|
112
|
+
begin
|
113
|
+
# Get all documents with embeddings
|
114
|
+
stats = database.get_stats
|
115
|
+
if stats[:with_embeddings] == 0
|
116
|
+
say "No documents with embeddings found. Please index some documents first.", :red
|
117
|
+
exit 1
|
118
|
+
end
|
119
|
+
|
120
|
+
say "Loading #{stats[:with_embeddings]} documents...", :yellow
|
121
|
+
|
122
|
+
# Get all documents with embeddings
|
123
|
+
docs_with_embeddings = database.get_all_documents_with_embeddings
|
124
|
+
|
125
|
+
if docs_with_embeddings.empty?
|
126
|
+
say "Could not load documents from database. Please check your database.", :red
|
127
|
+
exit 1
|
128
|
+
end
|
129
|
+
|
130
|
+
embeddings = docs_with_embeddings.map { |d| d[:embedding] }
|
131
|
+
documents = docs_with_embeddings.map { |d| d[:chunk_text] }
|
132
|
+
metadata = docs_with_embeddings.map { |d| { file_path: d[:file_path], chunk_index: d[:chunk_index] } }
|
133
|
+
|
134
|
+
say "Loaded #{embeddings.length} embeddings and #{documents.length} documents", :yellow if options[:verbose]
|
135
|
+
|
136
|
+
# Initialize topic modeling engine
|
137
|
+
engine = Ragnar::TopicModeling::Engine.new(
|
138
|
+
min_cluster_size: options[:min_cluster_size],
|
139
|
+
labeling_method: options[:method].to_sym,
|
140
|
+
verbose: options[:verbose]
|
141
|
+
)
|
142
|
+
|
143
|
+
# Extract topics
|
144
|
+
say "Clustering documents...", :yellow
|
145
|
+
topics = engine.fit(
|
146
|
+
embeddings: embeddings,
|
147
|
+
documents: documents,
|
148
|
+
metadata: metadata
|
149
|
+
)
|
150
|
+
|
151
|
+
# Display results
|
152
|
+
display_topics(topics)
|
153
|
+
|
154
|
+
# Export if requested
|
155
|
+
if options[:export]
|
156
|
+
export_topics(topics, options[:export])
|
157
|
+
end
|
158
|
+
|
159
|
+
rescue => e
|
160
|
+
say "Error extracting topics: #{e.message}", :red
|
161
|
+
say e.backtrace.first(5).join("\n") if options[:verbose]
|
162
|
+
exit 1
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
desc "search QUERY", "Search for similar documents"
|
167
|
+
option :database, type: :string, default: Ragnar::DEFAULT_DB_PATH, aliases: "-d", desc: "Path to Lance database"
|
168
|
+
option :k, type: :numeric, default: 5, desc: "Number of results to return"
|
169
|
+
option :show_scores, type: :boolean, default: false, desc: "Show similarity scores"
|
170
|
+
def search(query_text)
|
171
|
+
database = Database.new(options[:database])
|
172
|
+
embedder = Embedder.new
|
173
|
+
|
174
|
+
# Generate embedding for query
|
175
|
+
query_embedding = embedder.embed_text(query_text)
|
176
|
+
|
177
|
+
# Search for similar documents
|
178
|
+
results = database.search_similar(query_embedding, k: options[:k])
|
179
|
+
|
180
|
+
if results.empty?
|
181
|
+
say "No results found.", :yellow
|
182
|
+
return
|
183
|
+
end
|
184
|
+
|
185
|
+
say "Found #{results.length} results:\n", :green
|
186
|
+
|
187
|
+
results.each_with_index do |result, idx|
|
188
|
+
say "#{idx + 1}. File: #{result[:file_path]}", :cyan
|
189
|
+
say " Chunk: #{result[:chunk_index]}"
|
190
|
+
|
191
|
+
if options[:show_scores]
|
192
|
+
say " Distance: #{result[:distance].round(4)}"
|
193
|
+
end
|
194
|
+
|
195
|
+
# Show preview of content
|
196
|
+
preview = result[:chunk_text][0..200].gsub(/\s+/, ' ')
|
197
|
+
say " Content: #{preview}..."
|
198
|
+
say ""
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
desc "query QUESTION", "Query the RAG system"
|
203
|
+
option :db_path, type: :string, default: Ragnar::DEFAULT_DB_PATH, desc: "Path to Lance database"
|
204
|
+
option :top_k, type: :numeric, default: 3, desc: "Number of top documents to use"
|
205
|
+
option :verbose, type: :boolean, default: false, aliases: "-v", desc: "Show detailed processing steps"
|
206
|
+
option :json, type: :boolean, default: false, desc: "Output as JSON"
|
207
|
+
def query(question)
|
208
|
+
processor = QueryProcessor.new(db_path: options[:db_path])
|
209
|
+
|
210
|
+
begin
|
211
|
+
result = processor.query(question, top_k: options[:top_k], verbose: options[:verbose])
|
212
|
+
|
213
|
+
if options[:json]
|
214
|
+
puts JSON.pretty_generate(result)
|
215
|
+
else
|
216
|
+
say "\n" + "="*60, :green
|
217
|
+
say "Query: #{result[:query]}", :cyan
|
218
|
+
|
219
|
+
if result[:clarified] != result[:query]
|
220
|
+
say "Clarified: #{result[:clarified]}", :yellow
|
221
|
+
end
|
222
|
+
|
223
|
+
say "\nAnswer:", :green
|
224
|
+
say result[:answer]
|
225
|
+
|
226
|
+
if result[:confidence]
|
227
|
+
say "\nConfidence: #{result[:confidence]}%", :magenta
|
228
|
+
end
|
229
|
+
|
230
|
+
if result[:sources] && !result[:sources].empty?
|
231
|
+
say "\nSources:", :blue
|
232
|
+
result[:sources].each_with_index do |source, idx|
|
233
|
+
say " #{idx + 1}. #{source[:source_file]}" if source[:source_file]
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
if options[:verbose] && result[:sub_queries]
|
238
|
+
say "\nSub-queries used:", :yellow
|
239
|
+
result[:sub_queries].each { |sq| say " - #{sq}" }
|
240
|
+
end
|
241
|
+
|
242
|
+
say "="*60, :green
|
243
|
+
end
|
244
|
+
rescue => e
|
245
|
+
say "Error processing query: #{e.message}", :red
|
246
|
+
say e.backtrace.first(5).join("\n") if options[:verbose]
|
247
|
+
exit 1
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
desc "stats", "Show database statistics"
|
252
|
+
option :db_path, type: :string, default: Ragnar::DEFAULT_DB_PATH, desc: "Path to Lance database"
|
253
|
+
def stats
|
254
|
+
db = Database.new(options[:db_path])
|
255
|
+
stats = db.get_stats
|
256
|
+
|
257
|
+
say "\nDatabase Statistics", :green
|
258
|
+
say "-" * 30
|
259
|
+
say "Total documents: #{stats[:total_documents]}"
|
260
|
+
say "Unique files: #{stats[:unique_files]}"
|
261
|
+
say "Total chunks: #{stats[:total_chunks]}"
|
262
|
+
say "With embeddings: #{stats[:with_embeddings]}"
|
263
|
+
say "With reduced embeddings: #{stats[:with_reduced_embeddings]}"
|
264
|
+
|
265
|
+
if stats[:total_chunks] > 0
|
266
|
+
say "\nAverage chunk size: #{stats[:avg_chunk_size]} characters"
|
267
|
+
say "Embedding dimensions: #{stats[:embedding_dims]}"
|
268
|
+
say "Reduced dimensions: #{stats[:reduced_dims]}" if stats[:reduced_dims]
|
269
|
+
end
|
270
|
+
rescue => e
|
271
|
+
say "Error reading database: #{e.message}", :red
|
272
|
+
exit 1
|
273
|
+
end
|
274
|
+
|
275
|
+
desc "version", "Show version"
|
276
|
+
def version
|
277
|
+
say "Ragnar v#{Ragnar::VERSION}"
|
278
|
+
end
|
279
|
+
|
280
|
+
private
|
281
|
+
|
282
|
+
def fetch_all_documents(database)
|
283
|
+
# Temporary workaround to get all documents
|
284
|
+
# In production, we'd add a proper method to Database class
|
285
|
+
# For now, do a large search to get all docs
|
286
|
+
|
287
|
+
# Get stats to determine embedding size
|
288
|
+
stats = database.get_stats
|
289
|
+
embedding_dims = stats[:embedding_dims] || 768
|
290
|
+
|
291
|
+
# Generate a dummy embedding to search with
|
292
|
+
dummy_embedding = Array.new(embedding_dims, 0.0)
|
293
|
+
|
294
|
+
# Search for a large number to get all docs
|
295
|
+
results = database.search_similar(dummy_embedding, k: 10000)
|
296
|
+
|
297
|
+
# Return all results that have valid embeddings and text
|
298
|
+
results.select do |r|
|
299
|
+
r[:embedding] && !r[:embedding].empty? &&
|
300
|
+
r[:chunk_text] && !r[:chunk_text].empty?
|
301
|
+
end
|
302
|
+
rescue => e
|
303
|
+
say "Error loading documents: #{e.message}", :red
|
304
|
+
say e.backtrace.first(3).join("\n") if options[:verbose]
|
305
|
+
[]
|
306
|
+
end
|
307
|
+
|
308
|
+
def display_topics(topics)
|
309
|
+
say "\n" + "="*60, :green
|
310
|
+
say "Topic Analysis Results", :cyan
|
311
|
+
say "="*60, :green
|
312
|
+
|
313
|
+
if topics.empty?
|
314
|
+
say "No topics found. Try adjusting min_cluster_size.", :yellow
|
315
|
+
return
|
316
|
+
end
|
317
|
+
|
318
|
+
say "\nFound #{topics.length} topics:", :green
|
319
|
+
|
320
|
+
# Group topics by size for better visualization
|
321
|
+
large_topics = topics.select { |t| t.size >= 20 }
|
322
|
+
medium_topics = topics.select { |t| t.size >= 10 && t.size < 20 }
|
323
|
+
small_topics = topics.select { |t| t.size < 10 }
|
324
|
+
|
325
|
+
if large_topics.any?
|
326
|
+
say "\n" + "─" * 40, :blue
|
327
|
+
say "MAJOR TOPICS (≥20 docs)", :blue
|
328
|
+
say "─" * 40, :blue
|
329
|
+
display_topic_group(large_topics, :cyan)
|
330
|
+
end
|
331
|
+
|
332
|
+
if medium_topics.any?
|
333
|
+
say "\n" + "─" * 40, :yellow
|
334
|
+
say "MEDIUM TOPICS (10-19 docs)", :yellow
|
335
|
+
say "─" * 40, :yellow
|
336
|
+
display_topic_group(medium_topics, :yellow)
|
337
|
+
end
|
338
|
+
|
339
|
+
if small_topics.any?
|
340
|
+
say "\n" + "─" * 40, :white
|
341
|
+
say "MINOR TOPICS (<10 docs)", :white
|
342
|
+
say "─" * 40, :white
|
343
|
+
display_topic_group(small_topics, :white)
|
344
|
+
end
|
345
|
+
|
346
|
+
# Summary statistics
|
347
|
+
total_docs = topics.sum(&:size)
|
348
|
+
say "\n" + "="*60, :green
|
349
|
+
say "SUMMARY STATISTICS", :green
|
350
|
+
say "="*60, :green
|
351
|
+
say " Total topics: #{topics.length}"
|
352
|
+
say " Documents in topics: #{total_docs}"
|
353
|
+
say " Average topic size: #{(total_docs.to_f / topics.length).round(1)}"
|
354
|
+
|
355
|
+
if topics.any? { |t| t.coherence > 0 }
|
356
|
+
avg_coherence = topics.map(&:coherence).sum / topics.length
|
357
|
+
say " Average coherence: #{(avg_coherence * 100).round(1)}%"
|
358
|
+
end
|
359
|
+
|
360
|
+
# Distribution breakdown
|
361
|
+
say "\n Size distribution:"
|
362
|
+
say " Large (≥20): #{large_topics.length} topics, #{large_topics.sum(&:size)} docs"
|
363
|
+
say " Medium (10-19): #{medium_topics.length} topics, #{medium_topics.sum(&:size)} docs"
|
364
|
+
say " Small (<10): #{small_topics.length} topics, #{small_topics.sum(&:size)} docs"
|
365
|
+
end
|
366
|
+
|
367
|
+
def display_topic_group(topics, color)
|
368
|
+
topics.sort_by { |t| -t.size }.each_with_index do |topic, idx|
|
369
|
+
say "\n#{topic.label || 'Unlabeled'} (#{topic.size} docs)", color
|
370
|
+
|
371
|
+
# Show coherence as a bar
|
372
|
+
if topic.coherence > 0
|
373
|
+
coherence_pct = (topic.coherence * 100).round(0)
|
374
|
+
bar_length = (coherence_pct / 5).to_i
|
375
|
+
bar = "█" * bar_length + "░" * (20 - bar_length)
|
376
|
+
say " Coherence: #{bar} #{coherence_pct}%"
|
377
|
+
end
|
378
|
+
|
379
|
+
# Compact term display
|
380
|
+
say " Terms: #{topic.terms.first(6).join(' • ')}" if topic.terms.any?
|
381
|
+
|
382
|
+
# Short sample
|
383
|
+
if topic.representative_docs(k: 1).any?
|
384
|
+
preview = topic.representative_docs(k: 1).first
|
385
|
+
preview = preview[0..100] + "..." if preview.length > 100
|
386
|
+
say " \"#{preview}\"", :white
|
387
|
+
end
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
def export_topics(topics, format)
|
392
|
+
case format.downcase
|
393
|
+
when 'json'
|
394
|
+
export_topics_json(topics)
|
395
|
+
when 'html'
|
396
|
+
export_topics_html(topics)
|
397
|
+
else
|
398
|
+
say "Unknown export format: #{format}. Use 'json' or 'html'.", :red
|
399
|
+
end
|
400
|
+
end
|
401
|
+
|
402
|
+
def export_topics_json(topics)
|
403
|
+
data = {
|
404
|
+
generated_at: Time.now.iso8601,
|
405
|
+
topics: topics.map(&:to_h),
|
406
|
+
summary: {
|
407
|
+
total_topics: topics.length,
|
408
|
+
total_documents: topics.sum(&:size),
|
409
|
+
average_size: (topics.sum(&:size).to_f / topics.length).round(1)
|
410
|
+
}
|
411
|
+
}
|
412
|
+
|
413
|
+
filename = "topics_#{Time.now.strftime('%Y%m%d_%H%M%S')}.json"
|
414
|
+
File.write(filename, JSON.pretty_generate(data))
|
415
|
+
say "Topics exported to: #{filename}", :green
|
416
|
+
end
|
417
|
+
|
418
|
+
def export_topics_html(topics)
|
419
|
+
# Generate self-contained HTML with D3.js visualization
|
420
|
+
html = generate_topic_visualization_html(topics)
|
421
|
+
|
422
|
+
filename = "topics_#{Time.now.strftime('%Y%m%d_%H%M%S')}.html"
|
423
|
+
File.write(filename, html)
|
424
|
+
say "Topics visualization exported to: #{filename}", :green
|
425
|
+
|
426
|
+
# Offer to open in browser
|
427
|
+
if yes?("Open in browser?")
|
428
|
+
system("open #{filename}") rescue nil # macOS
|
429
|
+
system("xdg-open #{filename}") rescue nil # Linux
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
433
|
+
def generate_topic_visualization_html(topics)
|
434
|
+
# Convert topics to JSON for D3.js
|
435
|
+
topics_json = topics.map do |topic|
|
436
|
+
{
|
437
|
+
id: topic.id,
|
438
|
+
label: topic.label || "Topic #{topic.id}",
|
439
|
+
size: topic.size,
|
440
|
+
terms: topic.terms.first(10),
|
441
|
+
coherence: topic.coherence,
|
442
|
+
samples: topic.representative_docs(k: 2).map { |d| d[0..200] }
|
443
|
+
}
|
444
|
+
end.to_json
|
445
|
+
|
446
|
+
# HTML template with embedded D3.js
|
447
|
+
<<~HTML
|
448
|
+
<!DOCTYPE html>
|
449
|
+
<html>
|
450
|
+
<head>
|
451
|
+
<meta charset="utf-8">
|
452
|
+
<title>Topic Visualization</title>
|
453
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
454
|
+
<style>
|
455
|
+
body { font-family: -apple-system, sans-serif; margin: 20px; }
|
456
|
+
#viz { width: 100%; height: 500px; border: 1px solid #ddd; }
|
457
|
+
.topic { cursor: pointer; }
|
458
|
+
.topic:hover { opacity: 0.8; }
|
459
|
+
#details { margin-top: 20px; padding: 15px; background: #f5f5f5; }
|
460
|
+
.term { display: inline-block; margin: 5px; padding: 5px 10px; background: #e0e0e0; border-radius: 3px; }
|
461
|
+
</style>
|
462
|
+
</head>
|
463
|
+
<body>
|
464
|
+
<h1>Topic Analysis Results</h1>
|
465
|
+
<div id="viz"></div>
|
466
|
+
<div id="details">Click on a topic to see details</div>
|
467
|
+
|
468
|
+
<script>
|
469
|
+
const data = #{topics_json};
|
470
|
+
|
471
|
+
// Create bubble chart
|
472
|
+
const width = document.getElementById('viz').clientWidth;
|
473
|
+
const height = 500;
|
474
|
+
|
475
|
+
const svg = d3.select("#viz")
|
476
|
+
.append("svg")
|
477
|
+
.attr("width", width)
|
478
|
+
.attr("height", height);
|
479
|
+
|
480
|
+
// Create scale for bubble sizes
|
481
|
+
const sizeScale = d3.scaleSqrt()
|
482
|
+
.domain([0, d3.max(data, d => d.size)])
|
483
|
+
.range([10, 50]);
|
484
|
+
|
485
|
+
// Create color scale
|
486
|
+
const colorScale = d3.scaleSequential(d3.interpolateViridis)
|
487
|
+
.domain([0, 1]);
|
488
|
+
|
489
|
+
// Create force simulation
|
490
|
+
const simulation = d3.forceSimulation(data)
|
491
|
+
.force("x", d3.forceX(width / 2).strength(0.05))
|
492
|
+
.force("y", d3.forceY(height / 2).strength(0.05))
|
493
|
+
.force("collide", d3.forceCollide(d => sizeScale(d.size) + 2));
|
494
|
+
|
495
|
+
// Create bubbles
|
496
|
+
const bubbles = svg.selectAll(".topic")
|
497
|
+
.data(data)
|
498
|
+
.enter().append("g")
|
499
|
+
.attr("class", "topic");
|
500
|
+
|
501
|
+
bubbles.append("circle")
|
502
|
+
.attr("r", d => sizeScale(d.size))
|
503
|
+
.attr("fill", d => colorScale(d.coherence))
|
504
|
+
.attr("stroke", "#fff")
|
505
|
+
.attr("stroke-width", 2);
|
506
|
+
|
507
|
+
bubbles.append("text")
|
508
|
+
.text(d => d.label)
|
509
|
+
.attr("text-anchor", "middle")
|
510
|
+
.attr("dy", ".3em")
|
511
|
+
.style("font-size", d => Math.min(sizeScale(d.size) / 3, 14) + "px");
|
512
|
+
|
513
|
+
// Add click handler
|
514
|
+
bubbles.on("click", function(event, d) {
|
515
|
+
showDetails(d);
|
516
|
+
});
|
517
|
+
|
518
|
+
// Update positions
|
519
|
+
simulation.on("tick", () => {
|
520
|
+
bubbles.attr("transform", d => `translate(${d.x},${d.y})`);
|
521
|
+
});
|
522
|
+
|
523
|
+
// Show topic details
|
524
|
+
function showDetails(topic) {
|
525
|
+
const details = document.getElementById('details');
|
526
|
+
details.innerHTML = `
|
527
|
+
<h2>${topic.label}</h2>
|
528
|
+
<p><strong>Documents:</strong> ${topic.size}</p>
|
529
|
+
<p><strong>Coherence:</strong> ${(topic.coherence * 100).toFixed(1)}%</p>
|
530
|
+
<p><strong>Top Terms:</strong></p>
|
531
|
+
<div>${topic.terms.map(t => `<span class="term">${t}</span>`).join('')}</div>
|
532
|
+
<p><strong>Sample Documents:</strong></p>
|
533
|
+
${topic.samples.map(s => `<p style="font-size: 0.9em; color: #666;">"${s}..."</p>`).join('')}
|
534
|
+
`;
|
535
|
+
}
|
536
|
+
</script>
|
537
|
+
</body>
|
538
|
+
</html>
|
539
|
+
HTML
|
540
|
+
end
|
541
|
+
end
|
542
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
module Ragnar
|
2
|
+
class ContextRepacker
|
3
|
+
# Repack retrieved documents into optimized context for LLM
|
4
|
+
# This reduces redundancy and organizes information better
|
5
|
+
def self.repack(documents, query, max_tokens: 2000)
|
6
|
+
return "" if documents.empty?
|
7
|
+
|
8
|
+
# Group documents by source file
|
9
|
+
grouped = documents.group_by { |doc| doc[:file_path] || doc[:source_file] || "unknown" }
|
10
|
+
|
11
|
+
# Build repacked context
|
12
|
+
context_parts = []
|
13
|
+
|
14
|
+
grouped.each do |source, docs|
|
15
|
+
# Combine chunks from the same source
|
16
|
+
combined_text = docs.map { |d| d[:chunk_text] || d[:text] || "" }
|
17
|
+
.reject(&:empty?)
|
18
|
+
.join(" ... ")
|
19
|
+
|
20
|
+
# Remove excessive whitespace and clean up
|
21
|
+
combined_text = clean_text(combined_text)
|
22
|
+
|
23
|
+
# Add source header
|
24
|
+
context_parts << "Source: #{File.basename(source)}\n#{combined_text}"
|
25
|
+
end
|
26
|
+
|
27
|
+
# Join all parts with clear separation
|
28
|
+
full_context = context_parts.join("\n\n---\n\n")
|
29
|
+
|
30
|
+
# Trim to max tokens (rough approximation: ~4 chars per token)
|
31
|
+
max_chars = max_tokens * 4
|
32
|
+
if full_context.length > max_chars
|
33
|
+
full_context = trim_to_relevant(full_context, query, max_chars)
|
34
|
+
end
|
35
|
+
|
36
|
+
full_context
|
37
|
+
end
|
38
|
+
|
39
|
+
# Create a summary-focused repack for better coherence
|
40
|
+
def self.repack_with_summary(documents, query, llm: nil)
|
41
|
+
return "" if documents.empty?
|
42
|
+
|
43
|
+
# First do basic repacking
|
44
|
+
basic_context = repack(documents, query)
|
45
|
+
|
46
|
+
# If we have an LLM, try to create a summary
|
47
|
+
if llm
|
48
|
+
begin
|
49
|
+
summary_prompt = <<~PROMPT
|
50
|
+
<|system|>
|
51
|
+
You are a helpful assistant. Summarize the following information relevant to the query.
|
52
|
+
Focus on the most important points. Be concise.
|
53
|
+
</s>
|
54
|
+
<|user|>
|
55
|
+
Query: #{query}
|
56
|
+
|
57
|
+
Information:
|
58
|
+
#{basic_context[0..1500]}
|
59
|
+
|
60
|
+
Provide a brief summary of the key information related to the query.
|
61
|
+
</s>
|
62
|
+
<|assistant|>
|
63
|
+
PROMPT
|
64
|
+
|
65
|
+
summary = llm.generate(summary_prompt)
|
66
|
+
|
67
|
+
# Combine summary with original context
|
68
|
+
<<~CONTEXT
|
69
|
+
Summary: #{summary}
|
70
|
+
|
71
|
+
Detailed Information:
|
72
|
+
#{basic_context}
|
73
|
+
CONTEXT
|
74
|
+
rescue => e
|
75
|
+
puts "Warning: Summary generation failed: #{e.message}"
|
76
|
+
basic_context
|
77
|
+
end
|
78
|
+
else
|
79
|
+
basic_context
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def self.clean_text(text)
|
86
|
+
text
|
87
|
+
.gsub(/\s+/, ' ') # Normalize whitespace
|
88
|
+
.gsub(/\n{3,}/, "\n\n") # Remove excessive newlines
|
89
|
+
.gsub(/\.{4,}/, '...') # Normalize ellipsis
|
90
|
+
.strip
|
91
|
+
end
|
92
|
+
|
93
|
+
def self.trim_to_relevant(text, query, max_chars)
|
94
|
+
# Try to keep the most relevant parts based on query terms
|
95
|
+
query_terms = query.downcase.split(/\W+/).reject { |w| w.length < 3 }
|
96
|
+
|
97
|
+
# Score each sentence by relevance
|
98
|
+
sentences = text.split(/(?<=[.!?])\s+/)
|
99
|
+
scored_sentences = sentences.map do |sentence|
|
100
|
+
score = query_terms.sum { |term| sentence.downcase.include?(term) ? 1 : 0 }
|
101
|
+
{ sentence: sentence, score: score }
|
102
|
+
end
|
103
|
+
|
104
|
+
# Sort by score and reconstruct
|
105
|
+
scored_sentences.sort_by! { |s| -s[:score] }
|
106
|
+
|
107
|
+
result = []
|
108
|
+
current_length = 0
|
109
|
+
|
110
|
+
scored_sentences.each do |item|
|
111
|
+
sentence_length = item[:sentence].length
|
112
|
+
break if current_length + sentence_length > max_chars
|
113
|
+
|
114
|
+
result << item[:sentence]
|
115
|
+
current_length += sentence_length
|
116
|
+
end
|
117
|
+
|
118
|
+
result.join(" ")
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|