ragnar-cli 0.1.0.pre.3 → 0.1.0.pre.5

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.
@@ -5,6 +5,7 @@ module Ragnar
5
5
  def initialize(db_path, table_name: "documents")
6
6
  @db_path = db_path
7
7
  @table_name = table_name
8
+ @dataset_cache = nil # Cache to prevent file descriptor leaks
8
9
  ensure_database_exists
9
10
  end
10
11
 
@@ -34,16 +35,23 @@ module Ragnar
34
35
  metadata: :string
35
36
  }
36
37
 
38
+ # Clear cache before modifying dataset
39
+ clear_dataset_cache
40
+
37
41
  # Use the new open_or_create method from Lancelot
38
42
  # This automatically handles both creating new and opening existing datasets
39
43
  dataset = Lancelot::Dataset.open_or_create(@db_path, schema: schema)
40
44
  dataset.add_documents(data)
45
+
46
+ # Clear cache after modification to ensure fresh data on next read
47
+ clear_dataset_cache
41
48
  end
42
49
 
43
50
  def get_embeddings(limit: nil, offset: 0)
44
51
  return [] unless dataset_exists?
45
52
 
46
- dataset = Lancelot::Dataset.open(@db_path)
53
+ dataset = cached_dataset
54
+ return [] unless dataset
47
55
 
48
56
  # Get all documents or a subset
49
57
  docs = if limit && offset > 0
@@ -67,7 +75,8 @@ module Ragnar
67
75
  def update_reduced_embeddings(updates)
68
76
  return if updates.empty?
69
77
 
70
- dataset = Lancelot::Dataset.open(@db_path)
78
+ dataset = cached_dataset
79
+ return unless dataset
71
80
 
72
81
  # Get all existing documents and safely extract their data
73
82
  all_docs = dataset.to_a.map do |doc|
@@ -113,17 +122,24 @@ module Ragnar
113
122
  metadata: :string
114
123
  }
115
124
 
125
+ # Clear cache before recreating dataset
126
+ clear_dataset_cache
127
+
116
128
  # Remove old dataset and create new one with updated data
117
129
  FileUtils.rm_rf(@db_path)
118
130
  # Use open_or_create which will create since we just deleted the path
119
131
  dataset = Lancelot::Dataset.open_or_create(@db_path, schema: schema)
120
132
  dataset.add_documents(updated_docs)
133
+
134
+ # Clear cache after modification
135
+ clear_dataset_cache
121
136
  end
122
137
 
123
138
  def search_similar(embedding, k: 10, use_reduced: false)
124
139
  return [] unless dataset_exists?
125
140
 
126
- dataset = Lancelot::Dataset.open(@db_path)
141
+ dataset = cached_dataset
142
+ return [] unless dataset
127
143
 
128
144
  embedding_field = use_reduced ? :reduced_embedding : :embedding
129
145
 
@@ -149,7 +165,9 @@ module Ragnar
149
165
  def count
150
166
  return 0 unless dataset_exists?
151
167
 
152
- dataset = Lancelot::Dataset.open(@db_path)
168
+ dataset = cached_dataset
169
+ return 0 unless dataset
170
+
153
171
  dataset.to_a.size
154
172
  end
155
173
 
@@ -166,7 +184,18 @@ module Ragnar
166
184
  }
167
185
  end
168
186
 
169
- dataset = Lancelot::Dataset.open(@db_path)
187
+ dataset = cached_dataset
188
+ unless dataset
189
+ return {
190
+ document_count: 0,
191
+ total_documents: 0,
192
+ unique_files: 0,
193
+ total_chunks: 0,
194
+ with_embeddings: 0,
195
+ with_reduced_embeddings: 0,
196
+ total_size_mb: 0.0
197
+ }
198
+ end
170
199
 
171
200
  # Get all documents
172
201
  all_docs = dataset.to_a
@@ -214,7 +243,9 @@ module Ragnar
214
243
  def get_all_documents_with_embeddings(limit: nil)
215
244
  return [] unless dataset_exists?
216
245
 
217
- dataset = Lancelot::Dataset.open(@db_path)
246
+ dataset = cached_dataset
247
+ return [] unless dataset
248
+
218
249
  all_docs = limit ? dataset.first(limit) : dataset.to_a
219
250
 
220
251
  all_docs.select { |doc| doc[:embedding] && !doc[:embedding].empty? }
@@ -223,7 +254,8 @@ module Ragnar
223
254
  def full_text_search(query, limit: 10)
224
255
  return [] unless dataset_exists?
225
256
 
226
- dataset = Lancelot::Dataset.open(@db_path)
257
+ dataset = cached_dataset
258
+ return [] unless dataset
227
259
 
228
260
  # Use Lancelot's full-text search
229
261
  results = dataset.full_text_search(
@@ -243,11 +275,49 @@ module Ragnar
243
275
  end
244
276
  end
245
277
 
278
+ # Get the total number of documents in the database
279
+ def document_count
280
+ count
281
+ end
282
+
283
+ # Get documents by their IDs
284
+ # @param ids [Array<String>] Document IDs to fetch
285
+ # @return [Array<Hash>] Documents with their embeddings
286
+ def get_documents_by_ids(ids)
287
+ return [] if ids.empty? || !dataset_exists?
288
+
289
+ dataset = cached_dataset
290
+ return [] unless dataset
291
+
292
+ # Create ID lookup set for efficiency
293
+ id_set = ids.to_set
294
+
295
+ # Filter documents by IDs
296
+ dataset.to_a.select { |doc| id_set.include?(doc[:id]) }.map do |doc|
297
+ {
298
+ id: doc[:id],
299
+ chunk_text: doc[:chunk_text],
300
+ file_path: doc[:file_path],
301
+ chunk_index: doc[:chunk_index],
302
+ embedding: doc[:embedding],
303
+ reduced_embedding: doc[:reduced_embedding],
304
+ metadata: JSON.parse(doc[:metadata] || "{}")
305
+ }
306
+ end
307
+ end
308
+
246
309
  def dataset_exists?
247
310
  return false unless File.exist?(@db_path)
248
311
 
312
+ # Try to use cached dataset if available
313
+ if @dataset_cache
314
+ return true
315
+ end
316
+
317
+ # Otherwise check if we can open it
249
318
  begin
250
- Lancelot::Dataset.open(@db_path)
319
+ # Don't cache here, just check existence
320
+ dataset = Lancelot::Dataset.open(@db_path)
251
321
  true
252
322
  rescue
253
323
  false
@@ -263,5 +333,21 @@ module Ragnar
263
333
  def table_exists?
264
334
  dataset_exists?
265
335
  end
336
+
337
+ # Cached dataset accessor to prevent file descriptor leaks
338
+ def cached_dataset
339
+ return nil unless File.exist?(@db_path)
340
+
341
+ @dataset_cache ||= begin
342
+ Lancelot::Dataset.open(@db_path)
343
+ rescue => e
344
+ nil
345
+ end
346
+ end
347
+
348
+ # Clear the cached dataset (e.g., after modifications)
349
+ def clear_dataset_cache
350
+ @dataset_cache = nil
351
+ end
266
352
  end
267
353
  end
@@ -34,7 +34,7 @@ module Ragnar
34
34
  def embed_batch(texts, show_progress: true)
35
35
  embeddings = []
36
36
 
37
- if show_progress
37
+ if show_progress && $stdout.respond_to?(:ioctl)
38
38
  progressbar = TTY::ProgressBar.new(
39
39
  "Generating embeddings [:bar] :percent :current/:total",
40
40
  total: texts.size,
@@ -31,7 +31,7 @@ module Ragnar
31
31
 
32
32
  puts "Found #{files.size} file(s) to process" if @show_progress
33
33
 
34
- file_progress = if @show_progress
34
+ file_progress = if @show_progress && $stdout.respond_to?(:ioctl)
35
35
  TTY::ProgressBar.new(
36
36
  "Processing [:bar] :percent :current/:total - :filename",
37
37
  total: files.size,
@@ -43,13 +43,15 @@ module Ragnar
43
43
  nil
44
44
  end
45
45
 
46
- files.each do |file_path|
46
+ files.each_with_index do |file_path, idx|
47
47
  begin
48
48
  if file_progress
49
49
  # Update the progress bar with current filename
50
50
  filename = File.basename(file_path)
51
51
  filename = filename[0..27] + "..." if filename.length > 30
52
52
  file_progress.advance(0, filename: filename)
53
+ elsif @show_progress
54
+ puts "Processing (#{idx + 1}/#{files.size}): #{File.basename(file_path)}"
53
55
  end
54
56
 
55
57
  process_file(file_path, stats, file_progress)
@@ -1,43 +1,47 @@
1
1
  module Ragnar
2
- # Singleton manager for LLM instances to avoid reloading models
2
+ # Singleton manager for RubyLLM chat instances to avoid reloading models.
3
+ # Supports any RubyLLM provider (red_candle for local, openai, anthropic, etc.)
3
4
  class LLMManager
4
5
  include Singleton
5
-
6
+
6
7
  def initialize
7
- @llms = {}
8
+ @chats = {}
8
9
  @mutex = Mutex.new
9
10
  end
10
-
11
- # Get or create an LLM instance
12
- # @param model_id [String] The model identifier
13
- # @param gguf_file [String, nil] Optional GGUF file for quantized models
14
- # @return [Candle::LLM] The LLM instance
15
- def get_llm(model_id: "TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF",
16
- gguf_file: "tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf")
17
- cache_key = "#{model_id}:#{gguf_file}"
18
-
11
+
12
+ # Get or create a RubyLLM chat instance
13
+ # @param provider [String, Symbol] The RubyLLM provider (default from config)
14
+ # @param model [String] The model identifier (default from config)
15
+ # @return [RubyLLM::Chat] A cached chat instance
16
+ def get_chat(provider: nil, model: nil)
17
+ config = Config.instance
18
+ provider ||= config.llm_provider
19
+ model ||= config.llm_model
20
+
21
+ cache_key = "#{provider}:#{model}"
22
+
19
23
  @mutex.synchronize do
20
- @llms[cache_key] ||= begin
21
- puts "Loading LLM: #{model_id}..." unless @llms.key?(cache_key)
22
- if gguf_file
23
- Candle::LLM.from_pretrained(model_id, gguf_file: gguf_file)
24
- else
25
- Candle::LLM.from_pretrained(model_id)
26
- end
24
+ @chats[cache_key] ||= begin
25
+ puts "Loading LLM: #{model} (#{provider})..." if ENV['DEBUG']
26
+ Config.instance.create_chat
27
27
  end
28
28
  end
29
29
  end
30
-
31
- # Clear all cached models (useful for memory management)
30
+
31
+ # Clear all cached chat instances (useful for memory management)
32
32
  def clear_cache
33
33
  @mutex.synchronize do
34
- @llms.clear
34
+ @chats.clear
35
35
  end
36
36
  end
37
-
38
- # Get the default LLM for the application
39
- def default_llm
40
- get_llm
37
+
38
+ # Get the default chat instance for the application
39
+ def default_chat
40
+ get_chat
41
41
  end
42
+
43
+ # Backwards compatibility aliases
44
+ alias_method :get_llm, :get_chat
45
+ alias_method :default_llm, :default_chat
42
46
  end
43
- end
47
+ end
@@ -16,29 +16,55 @@ module Ragnar
16
16
  @reranker = nil # Will initialize when needed
17
17
  end
18
18
 
19
- def query(user_query, top_k: 3, verbose: false)
19
+ def query(user_query, top_k: 3, verbose: false, enable_rewriting: true, enable_reranking: false)
20
20
  puts "Processing query: #{user_query}" if verbose
21
21
 
22
- # Step 1: Rewrite and analyze the query
23
- puts "\n#{'-'*60}" if verbose
24
- puts "STEP 1: Query Analysis & Rewriting" if verbose
25
- puts "-"*60 if verbose
26
-
27
- rewritten = @rewriter.rewrite(user_query)
28
-
29
- if verbose
30
- puts "\nOriginal Query: #{user_query}"
31
- puts "\nRewritten Query Analysis:"
32
- puts " Clarified Intent: #{rewritten['clarified_intent']}"
33
- puts " Query Type: #{rewritten['query_type']}"
34
- puts " Context Needed: #{rewritten['context_needed']}"
35
- puts "\nGenerated Sub-queries (#{rewritten['sub_queries'].length}):"
36
- rewritten['sub_queries'].each_with_index do |sq, idx|
37
- puts " #{idx + 1}. #{sq}"
22
+ # Step 1: Rewrite and analyze the query (if enabled)
23
+ if enable_rewriting
24
+ puts "\n#{'-'*60}" if verbose
25
+ puts "STEP 1: Query Analysis & Rewriting" if verbose
26
+ puts "-"*60 if verbose
27
+
28
+ rewritten = @rewriter.rewrite(user_query)
29
+
30
+ # Always include the original query in sub-queries to ensure direct matches
31
+ # are found regardless of how the rewriter reformulates
32
+ sub_queries = rewritten['sub_queries'] || []
33
+ unless sub_queries.include?(user_query)
34
+ sub_queries.unshift(user_query)
38
35
  end
39
- if rewritten['key_terms'] && !rewritten['key_terms'].empty?
40
- puts "\nKey Terms Identified:"
41
- puts " #{rewritten['key_terms'].join(', ')}"
36
+ rewritten['sub_queries'] = sub_queries
37
+
38
+ if verbose
39
+ puts "\nOriginal Query: #{user_query}"
40
+ puts "\nRewritten Query Analysis:"
41
+ puts " Clarified Intent: #{rewritten['clarified_intent']}"
42
+ puts " Query Type: #{rewritten['query_type']}"
43
+ puts " Context Needed: #{rewritten['context_needed']}"
44
+ puts "\nGenerated Sub-queries (#{rewritten['sub_queries'].length}):"
45
+ rewritten['sub_queries'].each_with_index do |sq, idx|
46
+ puts " #{idx + 1}. #{sq}"
47
+ end
48
+ if rewritten['key_terms'] && !rewritten['key_terms'].empty?
49
+ puts "\nKey Terms Identified:"
50
+ puts " #{rewritten['key_terms'].join(', ')}"
51
+ end
52
+ end
53
+ else
54
+ # Skip rewriting - use original query directly
55
+ rewritten = {
56
+ 'clarified_intent' => user_query,
57
+ 'query_type' => 'direct',
58
+ 'context_needed' => 'general',
59
+ 'sub_queries' => [user_query],
60
+ 'key_terms' => []
61
+ }
62
+
63
+ if verbose
64
+ puts "\n#{'-'*60}"
65
+ puts "STEP 1: Query Analysis (Rewriting Disabled)"
66
+ puts "-"*60
67
+ puts "\nUsing original query directly"
42
68
  end
43
69
  end
44
70
 
@@ -77,18 +103,25 @@ module Ragnar
77
103
  puts "-"*60
78
104
  end
79
105
 
80
- reranked = rerank_documents(
81
- query: rewritten['clarified_intent'],
82
- documents: candidates,
83
- top_k: top_k * 2 # Get more than we need for context
84
- )
85
-
106
+ if enable_reranking
107
+ reranked = rerank_documents(
108
+ query: user_query,
109
+ documents: candidates,
110
+ top_k: top_k * 2
111
+ )
112
+ else
113
+ # Use retrieval order (RRF scores) directly — often more reliable than
114
+ # small cross-encoder rerankers on domain-specific corpora
115
+ reranked = candidates
116
+ end
117
+
86
118
  if verbose && reranked.any?
87
- puts "\nTop Reranked Documents:"
119
+ puts "\nTop #{enable_reranking ? 'Reranked' : 'Retrieved'} Documents:"
88
120
  reranked[0..2].each_with_index do |doc, idx|
89
121
  full_text = (doc[:chunk_text] || doc[:text] || "").gsub(/\s+/, ' ')
90
122
  puts " #{idx + 1}. [#{File.basename(doc[:file_path] || 'unknown')}]"
91
123
  puts " Score: #{doc[:score]&.round(4) if doc[:score]}"
124
+ puts " Distance: #{doc[:distance]&.round(4) if doc[:distance]}"
92
125
  puts " Full chunk (#{full_text.length} chars):"
93
126
  puts " \"#{full_text}\""
94
127
  puts ""
@@ -156,12 +189,12 @@ module Ragnar
156
189
  query: user_query,
157
190
  clarified: rewritten['clarified_intent'],
158
191
  answer: response,
159
- sources: context_docs.map { |d|
192
+ sources: context_docs.map { |d|
160
193
  {
161
- source_file: d[:file_path] || d[:source_file],
162
- chunk_index: d[:chunk_index]
194
+ source_file: d[:file_path] || d[:source_file] || d["file_path"],
195
+ chunk_index: d[:chunk_index] || d["chunk_index"]
163
196
  }
164
- },
197
+ }.reject { |s| s[:source_file].nil? },
165
198
  sub_queries: rewritten['sub_queries'],
166
199
  confidence: calculate_confidence(reranked[0...top_k])
167
200
  }
@@ -242,22 +275,43 @@ module Ragnar
242
275
  k: k,
243
276
  use_reduced: use_reduced
244
277
  )
245
-
278
+
246
279
  if verbose
247
- puts " Found #{vector_results.length} matches"
280
+ puts " Vector search: #{vector_results.length} matches"
248
281
  if vector_results.any?
249
282
  best = vector_results.first
250
- puts " Best match: [#{File.basename(best[:file_path] || 'unknown')}] (distance: #{best[:distance]&.round(3)})"
283
+ puts " Best vector match: [#{File.basename(best[:file_path] || 'unknown')}] (distance: #{best[:distance]&.round(3)})"
251
284
  end
252
285
  end
253
-
286
+
254
287
  # Add query index for RRF
255
288
  vector_results.each do |result|
256
289
  result[:query_idx] = idx
257
290
  result[:retrieval_method] = :vector
258
291
  end
259
-
292
+
260
293
  all_results.concat(vector_results)
294
+
295
+ # Full-text search for keyword matching (hybrid search)
296
+ begin
297
+ fts_results = @database.full_text_search(query, limit: k)
298
+ if verbose && fts_results.any?
299
+ puts " FTS: #{fts_results.length} matches"
300
+ best_fts = fts_results.first
301
+ puts " Best FTS match: [#{File.basename(best_fts[:file_path] || 'unknown')}]"
302
+ end
303
+
304
+ fts_results.each_with_index do |result, rank|
305
+ # Synthesize a distance from FTS rank (lower rank = better match)
306
+ result[:distance] = 0.1 + (rank * 0.05)
307
+ result[:query_idx] = idx
308
+ result[:retrieval_method] = :fts
309
+ end
310
+
311
+ all_results.concat(fts_results)
312
+ rescue => e
313
+ puts " FTS unavailable: #{e.message}" if verbose
314
+ end
261
315
  end
262
316
 
263
317
  if verbose
@@ -281,10 +335,18 @@ module Ragnar
281
335
 
282
336
  results.each do |result|
283
337
  doc_id = result[:id]
284
- doc_scores[doc_id] ||= {
285
- score: 0.0,
286
- document: result
287
- }
338
+ if doc_scores[doc_id]
339
+ # Prefer the document with more complete metadata
340
+ existing = doc_scores[doc_id][:document]
341
+ if result[:file_path] && !existing[:file_path]
342
+ doc_scores[doc_id][:document] = result
343
+ end
344
+ else
345
+ doc_scores[doc_id] = {
346
+ score: 0.0,
347
+ document: result
348
+ }
349
+ end
288
350
 
289
351
  # RRF formula: 1 / (k + rank)
290
352
  # Using distance as a proxy for rank (lower distance = better rank)
@@ -319,14 +381,14 @@ module Ragnar
319
381
 
320
382
  # Initialize reranker if not already done
321
383
  @reranker ||= Candle::Reranker.from_pretrained(
322
- "cross-encoder/ms-marco-MiniLM-L-12-v2"
384
+ Config.instance.reranker_model
323
385
  )
324
386
 
325
387
  # Prepare document texts - use chunk_text field
326
388
  texts = unique_docs.map { |doc| doc[:chunk_text] || doc[:text] || "" }
327
389
 
328
- # Rerank - returns array of {doc_id:, score:, text:}
329
- reranked = @reranker.rerank(query, texts)
390
+ # Rerank - use raw logits (no sigmoid) for better score separation
391
+ reranked = @reranker.rerank(query, texts, apply_sigmoid: false)
330
392
 
331
393
  # Map back to original documents with scores
332
394
  reranked.map do |result|
@@ -343,46 +405,37 @@ module Ragnar
343
405
  # In the future, we could fetch neighboring chunks for more context
344
406
  context_size = case context_needed
345
407
  when "extensive" then 5
346
- when "moderate" then 3
347
- else 2
408
+ when "moderate" then 4
409
+ else 3
348
410
  end
349
411
 
350
412
  documents.first(context_size)
351
413
  end
352
414
 
353
415
  def generate_response(query:, repacked_context:, query_type:)
354
- # Get cached LLM from manager
355
- llm = @llm_manager.default_llm
356
-
357
- # Create prompt with repacked context
358
- prompt = build_prompt(query, repacked_context, query_type)
359
-
360
- # Generate response using default config
361
- llm.generate(prompt)
416
+ # Create a fresh chat for each query to avoid conversation history bleed
417
+ chat = Config.instance.create_chat
418
+ chat.with_instructions(
419
+ "You are a helpful assistant. Answer questions based ONLY on the provided context. " \
420
+ "If the answer is not in the context, say \"I don't have enough information to answer that question.\" " \
421
+ "Be concise and direct. /no_think"
422
+ )
423
+
424
+ prompt = "Context:\n#{repacked_context}\n\nQuestion: #{query}"
425
+ response = chat.ask(prompt).content
426
+ # Strip <think>...</think> blocks that some models (e.g. Qwen3) include
427
+ strip_think_tags(response)
362
428
  rescue => e
363
429
  # Fallback to returning the repacked context
364
430
  puts "Warning: LLM generation failed (#{e.message})"
365
431
  "Based on the retrieved information:\n\n#{repacked_context[0..500]}..."
366
432
  end
367
433
 
368
- def build_prompt(query, context, query_type)
369
- base_prompt = <<~PROMPT
370
- <|system|>
371
- You are a helpful assistant. Answer questions based ONLY on the provided context.
372
- If the answer is not in the context, say "I don't have enough information to answer that question."
373
- </s>
374
- <|user|>
375
- Context:
376
- #{context}
377
-
378
- Question: #{query}
379
- </s>
380
- <|assistant|>
381
- PROMPT
382
-
383
- base_prompt
434
+ def strip_think_tags(text)
435
+ return text unless text
436
+ text.gsub(/<think>.*?<\/think>/m, '').strip
384
437
  end
385
-
438
+
386
439
  def calculate_confidence(documents)
387
440
  return 0.0 if documents.empty?
388
441
 
@@ -3,11 +3,11 @@ module Ragnar
3
3
  def initialize(llm_manager: nil)
4
4
  @llm_manager = llm_manager || LLMManager.instance
5
5
  end
6
-
6
+
7
7
  def rewrite(query)
8
- # Get the cached LLM
9
- model = @llm_manager.default_llm
10
-
8
+ # Create a fresh chat for each rewrite to avoid conversation history bleed
9
+ chat = Config.instance.create_chat
10
+
11
11
  # Define the JSON schema for structured output
12
12
  schema = {
13
13
  type: "object",
@@ -41,25 +41,28 @@ module Ragnar
41
41
  },
42
42
  required: ["clarified_intent", "query_type", "sub_queries", "key_terms", "context_needed"]
43
43
  }
44
-
44
+
45
45
  prompt = <<~PROMPT
46
46
  Analyze the following user query and break it down for retrieval-augmented generation.
47
47
  Focus on understanding the user's intent and creating effective sub-queries for searching.
48
-
48
+
49
49
  User Query: #{query}
50
-
51
- Provide a structured analysis that will help retrieve the most relevant documents.
50
+
51
+ Provide a structured analysis that will help retrieve the most relevant documents. /no_think
52
52
  PROMPT
53
-
53
+
54
54
  begin
55
- # Use structured generation with schema
56
- result = model.generate_structured(
57
- prompt,
58
- schema: schema
59
- )
60
-
61
- # The result should already be a JSON string
62
- JSON.parse(result)
55
+ response = chat.with_schema(schema).ask(prompt)
56
+ result = response.content
57
+
58
+ # RubyLLM with_schema returns parsed content; handle both String and Hash
59
+ if result.is_a?(String)
60
+ JSON.parse(result)
61
+ elsif result.is_a?(Hash)
62
+ result.transform_keys(&:to_s)
63
+ else
64
+ result
65
+ end
63
66
  rescue => e
64
67
  # Fallback to simple rewriting if structured generation fails
65
68
  {
@@ -72,4 +75,4 @@ module Ragnar
72
75
  end
73
76
  end
74
77
  end
75
- end
78
+ end