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 +4 -4
- data/README.md +42 -4
- data/Rakefile +26 -7
- data/lib/ragdoll/cli/commands/analytics.rb +222 -0
- data/lib/ragdoll/cli/commands/keywords.rb +317 -0
- data/lib/ragdoll/cli/commands/search.rb +133 -10
- data/lib/ragdoll/cli/commands/update.rb +1 -1
- data/lib/ragdoll/cli/standalone_client.rb +233 -2
- data/lib/ragdoll/cli/version.rb +1 -1
- data/lib/ragdoll/cli.rb +235 -23
- metadata +174 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4794fa5ad365db7598b0cb87530391c6d8ca24c1ea431149e614e4997a4d5a6
|
|
4
|
+
data.tar.gz: 1a3fb4eb495a8ed99028b57925c6ddead351c4bc031e02b6b2c075c024c869aa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
264
|
+
# Semantic search for concepts
|
|
236
265
|
ragdoll search "How to configure SSL certificates?"
|
|
237
266
|
|
|
238
|
-
#
|
|
239
|
-
ragdoll search "
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|