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