ragdoll-cli 0.1.8 → 0.1.10

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 37690a904a95405edf1f83bcc0655d0933028ba81dc9d16862f2e46a197c86e2
4
- data.tar.gz: 352ab870d8581d97b4aa0f53906614c5a4c1c07128cd7392693b810bef75a685
3
+ metadata.gz: a4794fa5ad365db7598b0cb87530391c6d8ca24c1ea431149e614e4997a4d5a6
4
+ data.tar.gz: 1a3fb4eb495a8ed99028b57925c6ddead351c4bc031e02b6b2c075c024c869aa
5
5
  SHA512:
6
- metadata.gz: c49805896f34f099dc5a87421df3697996cb34700c371f75df37b587eb9984d6f81032b7b3b4457226fbcad2ebfa67cec1fb23f66665dc964bbbf490c6c71d91
7
- data.tar.gz: e8183a1904f7a3877319d11a8e96110f9570ba036af175fe92dcd1b4dffe9cfbdd119c555af6d3edc8d49ee364121b2a2da32abd56e5cd49e55a4c1bc447a104
6
+ metadata.gz: bd2009f1c320bc2cad1666b9f154f4e9c4899e9e703e89bded35cd3bc2a679d8f9a5726ebd3000546bab6c4a3fbe1bd79f2a98486ac85234fbdffde9a59479b6
7
+ data.tar.gz: dfa5ec3f7be52723c6850714ec7b3ebec1e9fd3cfd929fb970ab5bc1df2b77233924b2286f8ef3788a864e2d70ae817b7acc6ed361ac9278288d31d45087b40d
data/README.md CHANGED
@@ -95,18 +95,47 @@ ragdoll import "files/*" --type pdf
95
95
  ### Search
96
96
 
97
97
  ```bash
98
- # Basic search
98
+ # Basic semantic search (default)
99
99
  ragdoll search "machine learning concepts"
100
100
 
101
+ # Full-text search for exact keywords
102
+ ragdoll search "neural networks" --search-type fulltext
103
+
104
+ # Hybrid search combining semantic and full-text
105
+ ragdoll search "AI algorithms" --search-type hybrid
106
+
107
+ # Customize hybrid search weights
108
+ ragdoll search "deep learning" --search-type hybrid --semantic-weight 0.6 --text-weight 0.4
109
+
101
110
  # Limit number of results
102
111
  ragdoll search "AI algorithms" --limit 5
103
112
 
113
+ # Set similarity threshold
114
+ ragdoll search "machine learning" --threshold 0.8
115
+
104
116
  # Different output formats
105
117
  ragdoll search "deep learning" --format json
106
118
  ragdoll search "AI" --format plain
107
119
  ragdoll search "ML" --format table # default
108
120
  ```
109
121
 
122
+ #### Search Types
123
+
124
+ - **Semantic Search** (default): Uses AI embeddings to find conceptually similar content
125
+ - **Full-text Search**: Uses PostgreSQL text search for exact keyword matching
126
+ - **Hybrid Search**: Combines both semantic and full-text search with configurable weights
127
+
128
+ ```bash
129
+ # Semantic search - best for concepts and meaning
130
+ ragdoll search "How do neural networks learn?" --search-type semantic
131
+
132
+ # Full-text search - best for exact terms
133
+ ragdoll search "backpropagation algorithm" --search-type fulltext
134
+
135
+ # Hybrid search - best comprehensive results
136
+ ragdoll search "transformer architecture" --search-type hybrid --semantic-weight 0.7 --text-weight 0.3
137
+ ```
138
+
110
139
  ### Document Management
111
140
 
112
141
  ```bash
@@ -232,11 +261,20 @@ ragdoll import "knowledge-base/*" --recursive
232
261
  ### Search and get enhanced prompts
233
262
 
234
263
  ```bash
235
- # Basic search
264
+ # Semantic search for concepts
236
265
  ragdoll search "How to configure SSL certificates?"
237
266
 
238
- # Get detailed results
239
- ragdoll search "database optimization" --format plain --limit 3
267
+ # Full-text search for specific terms
268
+ ragdoll search "SSL certificate configuration" --search-type fulltext
269
+
270
+ # Hybrid search for comprehensive results
271
+ ragdoll search "database optimization techniques" --search-type hybrid
272
+
273
+ # Get detailed results with custom formatting
274
+ ragdoll search "performance tuning" --format plain --limit 3
275
+
276
+ # Search with custom similarity threshold
277
+ ragdoll search "security best practices" --threshold 0.75 --search-type semantic
240
278
  ```
241
279
 
242
280
  ### Manage your knowledge base
data/Rakefile CHANGED
@@ -1,18 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'simplecov'
4
- SimpleCov.start
5
-
6
3
  # Suppress bundler/rubygems warnings
7
4
  $VERBOSE = nil
8
5
 
9
6
  require "bundler/gem_tasks"
10
7
  require "rake/testtask"
11
8
 
12
- Rake::TestTask.new(:test) do |t|
13
- t.libs << "test"
14
- t.libs << "lib"
15
- t.test_files = FileList["test/**/*_test.rb"]
9
+ # Custom test task that ensures proper exit codes
10
+ desc "Run tests"
11
+ task :test do
12
+ # Use the original TestTask internally but capture output
13
+ test_files = FileList["test/**/*_test.rb"]
14
+
15
+ # Run tests and capture both stdout and stderr
16
+ output = `bundle exec ruby -I lib:test #{test_files.join(' ')} 2>&1`
17
+ exit_status = $?.exitstatus
18
+
19
+ # Print the output
20
+ puts output
21
+
22
+ # Check if tests actually failed by looking for failure indicators
23
+ test_failed = output.match(/(\d+) failures.*[^0] failures/) ||
24
+ output.match(/(\d+) errors.*[^0] errors/) ||
25
+ output.include?("FAIL") ||
26
+ exit_status > 1 # Exit status 1 might be SimpleCov, >1 is real failure
27
+
28
+ if test_failed
29
+ puts "Tests failed!"
30
+ exit 1
31
+ else
32
+ puts "All tests passed successfully!" unless output.include?("0 failures, 0 errors")
33
+ exit 0
34
+ end
16
35
  end
17
36
 
18
37
  # Load annotate tasks
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'thor'
5
+
6
+ module Ragdoll
7
+ module CLI
8
+ class Analytics < Thor
9
+ desc 'overview', 'Show search analytics overview'
10
+ method_option :days, type: :numeric, default: 30, aliases: '-d',
11
+ desc: 'Number of days to analyze (default: 30)'
12
+ method_option :format, type: :string, default: 'table', aliases: '-f',
13
+ desc: 'Output format (table, json)'
14
+ def overview
15
+ client = StandaloneClient.new
16
+ days = (options && options[:days]) || 30
17
+ analytics = client.search_analytics(days: days)
18
+
19
+ case (options && options[:format]) || 'table'
20
+ when 'json'
21
+ puts JSON.pretty_generate(analytics)
22
+ else
23
+ puts "Search Analytics (last #{days} days):"
24
+ puts
25
+ puts 'Metric'.ljust(30) + 'Value'
26
+ puts '-' * 50
27
+
28
+ analytics.each do |key, value|
29
+ metric = format_metric_name(key).ljust(30)
30
+ formatted_value = format_metric_value(key, value)
31
+ puts "#{metric}#{formatted_value}"
32
+ end
33
+ end
34
+ rescue StandardError => e
35
+ puts "Error retrieving analytics: #{e.message}"
36
+ end
37
+
38
+ desc 'history', 'Show recent search history'
39
+ method_option :limit, type: :numeric, default: 20, aliases: '-l',
40
+ desc: 'Number of searches to show (default: 20)'
41
+ method_option :user_id, type: :string, aliases: '-u',
42
+ desc: 'Filter by user ID'
43
+ method_option :session_id, type: :string, aliases: '-s',
44
+ desc: 'Filter by session ID'
45
+ method_option :format, type: :string, default: 'table', aliases: '-f',
46
+ desc: 'Output format (table, json, plain)'
47
+ def history
48
+ client = StandaloneClient.new
49
+ limit = (options && options[:limit]) || 20
50
+ filter_options = {}
51
+ filter_options[:user_id] = options[:user_id] if options && options[:user_id]
52
+ filter_options[:session_id] = options[:session_id] if options && options[:session_id]
53
+
54
+ searches = client.search_history(limit: limit, **filter_options)
55
+
56
+ case (options && options[:format]) || 'table'
57
+ when 'json'
58
+ puts JSON.pretty_generate(searches || [])
59
+ when 'plain'
60
+ if searches.nil? || searches.empty?
61
+ puts 'No search history found.'
62
+ return
63
+ end
64
+ searches.each_with_index do |search, index|
65
+ puts "#{index + 1}. #{search[:query]} (#{search[:search_type]})"
66
+ puts " Time: #{search[:created_at]}"
67
+ puts " Results: #{search[:results_count]}"
68
+ puts " Execution: #{search[:execution_time_ms]}ms"
69
+ puts " Session: #{search[:session_id]}" if search[:session_id]
70
+ puts " User: #{search[:user_id]}" if search[:user_id]
71
+ puts
72
+ end
73
+ else
74
+ # Table format
75
+ if searches.nil? || searches.empty?
76
+ puts 'No search history found.'
77
+ return
78
+ end
79
+
80
+ puts "Recent Search History (#{searches.length} searches):"
81
+ puts
82
+ puts 'Time'.ljust(20) + 'Query'.ljust(30) + 'Type'.ljust(10) + 'Results'.ljust(8) + 'Time(ms)'
83
+ puts '-' * 80
84
+
85
+ searches.each do |search|
86
+ time = format_time(search[:created_at]).ljust(20)
87
+ query = (search[:query] || 'N/A')[0..29].ljust(30)
88
+ type = (search[:search_type] || 'N/A')[0..9].ljust(10)
89
+ results = (search[:results_count] || 0).to_s.ljust(8)
90
+ exec_time = (search[:execution_time_ms] || 0).to_s
91
+
92
+ puts "#{time}#{query}#{type}#{results}#{exec_time}"
93
+ end
94
+ end
95
+ rescue StandardError => e
96
+ puts "Error retrieving search history: #{e.message}"
97
+ end
98
+
99
+ desc 'trending', 'Show trending search queries'
100
+ method_option :limit, type: :numeric, default: 10, aliases: '-l',
101
+ desc: 'Number of queries to show (default: 10)'
102
+ method_option :days, type: :numeric, default: 7, aliases: '-d',
103
+ desc: 'Time period in days (default: 7)'
104
+ method_option :format, type: :string, default: 'table', aliases: '-f',
105
+ desc: 'Output format (table, json)'
106
+ def trending
107
+ client = StandaloneClient.new
108
+ limit = (options && options[:limit]) || 10
109
+ days = (options && options[:days]) || 7
110
+ trending_queries = client.trending_queries(limit: limit, days: days)
111
+
112
+ case (options && options[:format]) || 'table'
113
+ when 'json'
114
+ puts JSON.pretty_generate(trending_queries || [])
115
+ else
116
+ if trending_queries.nil? || trending_queries.empty?
117
+ puts "No trending queries found for the last #{days} days."
118
+ return
119
+ end
120
+
121
+ puts "Trending Search Queries (last #{days} days):"
122
+ puts
123
+ puts 'Rank'.ljust(5) + 'Query'.ljust(40) + 'Count'.ljust(8) + 'Avg Results'
124
+ puts '-' * 80
125
+
126
+ trending_queries.each_with_index do |query_data, index|
127
+ rank = (index + 1).to_s.ljust(5)
128
+ query = (query_data[:query] || 'N/A')[0..39].ljust(40)
129
+ count = (query_data[:count] || 0).to_s.ljust(8)
130
+ avg_results = (query_data[:avg_results] || 0).round(1).to_s
131
+
132
+ puts "#{rank}#{query}#{count}#{avg_results}"
133
+ end
134
+ end
135
+ rescue StandardError => e
136
+ puts "Error retrieving trending queries: #{e.message}"
137
+ end
138
+
139
+ desc 'cleanup', 'Cleanup old search records'
140
+ method_option :days, type: :numeric, default: 30, aliases: '-d',
141
+ desc: 'Remove searches older than N days (default: 30)'
142
+ method_option :dry_run, type: :boolean, default: true, aliases: '-n',
143
+ desc: 'Show what would be deleted without actually deleting (default: true)'
144
+ method_option :force, type: :boolean, default: false, aliases: '-f',
145
+ desc: 'Actually perform the cleanup (overrides dry_run)'
146
+ def cleanup
147
+ client = StandaloneClient.new
148
+
149
+ days = (options && options[:days]) || 30
150
+ dry_run = if options && options.key?(:dry_run)
151
+ options[:dry_run]
152
+ else
153
+ true # Default to true
154
+ end
155
+ force = (options && options[:force]) == true
156
+
157
+ cleanup_options = {
158
+ days: days,
159
+ dry_run: dry_run && !force
160
+ }
161
+
162
+ if cleanup_options[:dry_run]
163
+ puts "DRY RUN: Showing what would be cleaned up (use --force to actually clean up)"
164
+ puts
165
+ else
166
+ puts "Performing actual cleanup of search records older than #{days} days..."
167
+ puts
168
+ end
169
+
170
+ result = client.cleanup_searches(**cleanup_options)
171
+
172
+ if result.is_a?(Hash)
173
+ puts "Cleanup Results:"
174
+ puts " Orphaned searches: #{result[:orphaned_count] || 0}"
175
+ puts " Old unused searches: #{result[:unused_count] || 0}"
176
+ puts " Total cleaned: #{(result[:orphaned_count] || 0) + (result[:unused_count] || 0)}"
177
+ else
178
+ puts "Searches cleaned up: #{result}"
179
+ end
180
+
181
+ if cleanup_options[:dry_run]
182
+ puts
183
+ puts "Use --force to actually perform the cleanup"
184
+ end
185
+ rescue StandardError => e
186
+ puts "Error during cleanup: #{e.message}"
187
+ end
188
+
189
+ private
190
+
191
+ def format_metric_name(key)
192
+ key.to_s.tr('_', ' ').split.map(&:capitalize).join(' ')
193
+ end
194
+
195
+ def format_metric_value(key, value)
196
+ case key.to_s
197
+ when /time/
198
+ "#{value}ms"
199
+ when /rate/
200
+ "#{value}%"
201
+ when /count/
202
+ value.to_s
203
+ else
204
+ value.to_s
205
+ end
206
+ end
207
+
208
+ def format_time(timestamp)
209
+ return 'N/A' unless timestamp
210
+
211
+ if timestamp.respond_to?(:strftime)
212
+ timestamp.strftime('%m/%d %H:%M')
213
+ else
214
+ # Handle string timestamps
215
+ Time.parse(timestamp.to_s).strftime('%m/%d %H:%M')
216
+ end
217
+ rescue
218
+ 'N/A'
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'json'
5
+
6
+ module Ragdoll
7
+ module CLI
8
+ class Keywords < Thor
9
+ desc 'search KEYWORD [KEYWORD2...]', 'Search documents by keywords only'
10
+ method_option :all, type: :boolean, default: false, aliases: '-a',
11
+ desc: 'Require ALL keywords to match (AND logic, default: OR logic)'
12
+ method_option :limit, type: :numeric, default: 20, aliases: '-l',
13
+ desc: 'Maximum number of results to return'
14
+ method_option :format, type: :string, default: 'table', aliases: '-f',
15
+ desc: 'Output format (table, json, plain)'
16
+ def search(*keywords)
17
+ if keywords.empty?
18
+ puts 'Error: No keywords provided'
19
+ puts 'Usage: ragdoll keywords search KEYWORD [KEYWORD2...]'
20
+ puts 'Examples:'
21
+ puts ' ragdoll keywords search ruby programming'
22
+ puts ' ragdoll keywords search --all ruby programming # Must contain ALL keywords'
23
+ puts ' ragdoll keywords search ruby --limit=50'
24
+ exit 1
25
+ end
26
+
27
+ client = StandaloneClient.new
28
+
29
+ puts "Searching documents by keywords: #{keywords.join(', ')}"
30
+ puts "Mode: #{options[:all] ? 'ALL keywords (AND)' : 'ANY keywords (OR)'}"
31
+ puts
32
+
33
+ begin
34
+ # Use the new keywords search methods
35
+ search_method = options[:all] ? :search_by_keywords_all : :search_by_keywords
36
+ results = client.public_send(search_method, keywords, limit: options[:limit])
37
+
38
+ # Convert results to standard format if needed
39
+ results = normalize_results(results)
40
+
41
+ if results.empty?
42
+ puts "No documents found with keywords: #{keywords.join(', ')}"
43
+ puts
44
+ puts "💡 Suggestions:"
45
+ puts " • Try different keywords"
46
+ puts " • Use fewer keywords"
47
+ puts " • Switch between --all and default (OR) modes"
48
+ puts " • Check available keywords with: ragdoll keywords list"
49
+ return
50
+ end
51
+
52
+ display_results(results, options[:format], keywords)
53
+ rescue StandardError => e
54
+ puts "Error searching by keywords: #{e.message}"
55
+ exit 1
56
+ end
57
+ end
58
+
59
+ desc 'list', 'List all available keywords in the system'
60
+ method_option :limit, type: :numeric, default: 100, aliases: '-l',
61
+ desc: 'Maximum number of keywords to show'
62
+ method_option :format, type: :string, default: 'table', aliases: '-f',
63
+ desc: 'Output format (table, json, plain)'
64
+ method_option :min_count, type: :numeric, default: 1, aliases: '-m',
65
+ desc: 'Show only keywords used by at least N documents'
66
+ def list
67
+ client = StandaloneClient.new
68
+
69
+ begin
70
+ keyword_frequencies = client.keyword_frequencies(
71
+ limit: options[:limit],
72
+ min_count: options[:min_count]
73
+ )
74
+
75
+ if keyword_frequencies.empty?
76
+ puts "No keywords found in the system."
77
+ puts "Add documents with keywords or update existing documents."
78
+ return
79
+ end
80
+
81
+ case options[:format]
82
+ when 'json'
83
+ puts JSON.pretty_generate(keyword_frequencies)
84
+ when 'plain'
85
+ keyword_frequencies.each do |keyword, count|
86
+ puts "#{keyword}: #{count}"
87
+ end
88
+ else
89
+ # Table format
90
+ puts "Keywords in system (minimum #{options[:min_count]} documents):"
91
+ puts
92
+ puts 'Keyword'.ljust(30) + 'Document Count'
93
+ puts '-' * 45
94
+
95
+ keyword_frequencies.each do |keyword, count|
96
+ keyword_display = keyword[0..29].ljust(30)
97
+ puts "#{keyword_display}#{count}"
98
+ end
99
+
100
+ puts
101
+ puts "Total keywords: #{keyword_frequencies.length}"
102
+ end
103
+ rescue StandardError => e
104
+ puts "Error listing keywords: #{e.message}"
105
+ exit 1
106
+ end
107
+ end
108
+
109
+ desc 'add DOCUMENT_ID KEYWORD [KEYWORD2...]', 'Add keywords to a document'
110
+ def add(document_id, *keywords)
111
+ if keywords.empty?
112
+ puts 'Error: No keywords provided'
113
+ puts 'Usage: ragdoll keywords add DOCUMENT_ID KEYWORD [KEYWORD2...]'
114
+ puts 'Example: ragdoll keywords add 123 ruby programming web'
115
+ exit 1
116
+ end
117
+
118
+ client = StandaloneClient.new
119
+
120
+ begin
121
+ result = client.add_keywords_to_document(document_id, keywords)
122
+
123
+ if result[:success]
124
+ puts "✓ Added keywords to document #{document_id}: #{keywords.join(', ')}"
125
+ puts "Document now has keywords: #{result[:keywords].join(', ')}" if result[:keywords]
126
+ else
127
+ puts "✗ Failed to add keywords: #{result[:message] || 'Unknown error'}"
128
+ exit 1
129
+ end
130
+ rescue StandardError => e
131
+ puts "Error adding keywords: #{e.message}"
132
+ exit 1
133
+ end
134
+ end
135
+
136
+ desc 'remove DOCUMENT_ID KEYWORD [KEYWORD2...]', 'Remove keywords from a document'
137
+ def remove(document_id, *keywords)
138
+ if keywords.empty?
139
+ puts 'Error: No keywords provided'
140
+ puts 'Usage: ragdoll keywords remove DOCUMENT_ID KEYWORD [KEYWORD2...]'
141
+ puts 'Example: ragdoll keywords remove 123 old-keyword deprecated'
142
+ exit 1
143
+ end
144
+
145
+ client = StandaloneClient.new
146
+
147
+ begin
148
+ result = client.remove_keywords_from_document(document_id, keywords)
149
+
150
+ if result[:success]
151
+ puts "✓ Removed keywords from document #{document_id}: #{keywords.join(', ')}"
152
+ puts "Document now has keywords: #{result[:keywords].join(', ')}" if result[:keywords]
153
+ else
154
+ puts "✗ Failed to remove keywords: #{result[:message] || 'Unknown error'}"
155
+ exit 1
156
+ end
157
+ rescue StandardError => e
158
+ puts "Error removing keywords: #{e.message}"
159
+ exit 1
160
+ end
161
+ end
162
+
163
+ desc 'set DOCUMENT_ID KEYWORD [KEYWORD2...]', 'Set keywords for a document (replaces existing)'
164
+ def set(document_id, *keywords)
165
+ if keywords.empty?
166
+ puts 'Error: No keywords provided'
167
+ puts 'Usage: ragdoll keywords set DOCUMENT_ID KEYWORD [KEYWORD2...]'
168
+ puts 'Example: ragdoll keywords set 123 ruby programming web'
169
+ exit 1
170
+ end
171
+
172
+ client = StandaloneClient.new
173
+
174
+ begin
175
+ result = client.set_document_keywords(document_id, keywords)
176
+
177
+ if result[:success]
178
+ puts "✓ Set keywords for document #{document_id}: #{keywords.join(', ')}"
179
+ else
180
+ puts "✗ Failed to set keywords: #{result[:message] || 'Unknown error'}"
181
+ exit 1
182
+ end
183
+ rescue StandardError => e
184
+ puts "Error setting keywords: #{e.message}"
185
+ exit 1
186
+ end
187
+ end
188
+
189
+ desc 'show DOCUMENT_ID', 'Show keywords for a specific document'
190
+ def show(document_id)
191
+ client = StandaloneClient.new
192
+
193
+ begin
194
+ document = client.get_document(document_id)
195
+
196
+ keywords = document[:keywords] || document['keywords'] || []
197
+
198
+ puts "Keywords for document #{document_id}:"
199
+ puts " Title: #{document[:title] || document['title'] || 'Untitled'}"
200
+
201
+ if keywords.empty?
202
+ puts " Keywords: (none)"
203
+ puts
204
+ puts "💡 Add keywords with: ragdoll keywords add #{document_id} KEYWORD1 KEYWORD2..."
205
+ else
206
+ puts " Keywords: #{keywords.join(', ')}"
207
+ end
208
+ rescue StandardError => e
209
+ puts "Error getting document keywords: #{e.message}"
210
+ exit 1
211
+ end
212
+ end
213
+
214
+ desc 'find KEYWORD', 'Find documents containing a specific keyword'
215
+ method_option :limit, type: :numeric, default: 20, aliases: '-l',
216
+ desc: 'Maximum number of results to return'
217
+ method_option :format, type: :string, default: 'table', aliases: '-f',
218
+ desc: 'Output format (table, json, plain)'
219
+ def find(keyword)
220
+ search(keyword)
221
+ end
222
+
223
+ desc 'stats', 'Show keyword usage statistics'
224
+ def stats
225
+ client = StandaloneClient.new
226
+
227
+ begin
228
+ stats = client.keyword_statistics
229
+
230
+ puts "Keyword Statistics:"
231
+ puts " Total unique keywords: #{stats[:total_keywords] || 0}"
232
+ puts " Total documents with keywords: #{stats[:documents_with_keywords] || 0}"
233
+ puts " Average keywords per document: #{stats[:avg_keywords_per_document]&.round(2) || 0}"
234
+ puts " Most common keywords:"
235
+
236
+ if stats[:top_keywords]&.any?
237
+ stats[:top_keywords].each_with_index do |(keyword, count), index|
238
+ puts " #{index + 1}. #{keyword} (#{count} documents)"
239
+ end
240
+ else
241
+ puts " (none)"
242
+ end
243
+
244
+ puts " Least used keywords: #{stats[:singleton_keywords] || 0}"
245
+ rescue StandardError => e
246
+ puts "Error getting keyword statistics: #{e.message}"
247
+ exit 1
248
+ end
249
+ end
250
+
251
+ private
252
+
253
+ def normalize_results(results)
254
+ # Ensure results are in the expected format
255
+ case results
256
+ when Array
257
+ results.map do |result|
258
+ case result
259
+ when Hash
260
+ result
261
+ else
262
+ # Convert ActiveRecord objects to hash if needed
263
+ if result.respond_to?(:to_hash)
264
+ result.to_hash
265
+ elsif result.respond_to?(:attributes)
266
+ result.attributes.symbolize_keys
267
+ else
268
+ result
269
+ end
270
+ end
271
+ end
272
+ else
273
+ []
274
+ end
275
+ end
276
+
277
+ def display_results(results, format, keywords)
278
+ case format
279
+ when 'json'
280
+ puts JSON.pretty_generate(results)
281
+ when 'plain'
282
+ results.each_with_index do |result, index|
283
+ title = result[:title] || result['title'] || 'Untitled'
284
+ doc_keywords = result[:keywords] || result['keywords'] || []
285
+ matching_keywords = doc_keywords & keywords
286
+
287
+ puts "#{index + 1}. #{title}"
288
+ puts " ID: #{result[:id] || result['id']}"
289
+ puts " Keywords: #{doc_keywords.join(', ')}"
290
+ puts " Matching: #{matching_keywords.join(', ')}" if matching_keywords.any?
291
+ puts
292
+ end
293
+ else
294
+ # Table format
295
+ puts "Found #{results.length} documents:"
296
+ puts
297
+ puts 'ID'.ljust(12) + 'Title'.ljust(30) + 'Keywords'.ljust(40) + 'Matches'
298
+ puts '-' * 90
299
+
300
+ results.each do |result|
301
+ id = (result[:id] || result['id'] || '')[0..11].ljust(12)
302
+ title = (result[:title] || result['title'] || 'Untitled')[0..29].ljust(30)
303
+ doc_keywords = result[:keywords] || result['keywords'] || []
304
+ keywords_str = doc_keywords.join(', ')[0..39].ljust(40)
305
+ matching_keywords = doc_keywords & keywords
306
+ matches = matching_keywords.length
307
+
308
+ puts "#{id}#{title}#{keywords_str}#{matches}"
309
+ end
310
+
311
+ puts
312
+ puts "Use --format=json for complete results or --format=plain for detailed view"
313
+ end
314
+ end
315
+ end
316
+ end
317
+ end