ruby_todo 0.4.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.env.template +2 -0
- data/.rspec +1 -0
- data/CHANGELOG.md +10 -0
- data/README.md +56 -72
- data/ai_assistant_implementation.md +611 -0
- data/db/migrate/20240328_add_is_default_to_notebooks.rb +10 -0
- data/delete_notebooks.rb +20 -0
- data/implementation_steps.md +130 -0
- data/lib/ruby_todo/ai_assistant/common_query_handler.rb +378 -0
- data/lib/ruby_todo/ai_assistant/configuration_management.rb +27 -0
- data/lib/ruby_todo/ai_assistant/openai_integration.rb +333 -0
- data/lib/ruby_todo/ai_assistant/task_creation.rb +86 -0
- data/lib/ruby_todo/ai_assistant/task_management.rb +327 -0
- data/lib/ruby_todo/ai_assistant/task_search.rb +362 -0
- data/lib/ruby_todo/cli.rb +296 -146
- data/lib/ruby_todo/commands/ai_assistant.rb +449 -0
- data/lib/ruby_todo/database.rb +58 -84
- data/lib/ruby_todo/models/notebook.rb +44 -10
- data/lib/ruby_todo/version.rb +1 -1
- data/progress_ai_test.md +126 -0
- data/protectors_tasks.json +159 -0
- data/test_ai_assistant.rb +55 -0
- data/test_migration.rb +55 -0
- metadata +46 -1
@@ -0,0 +1,333 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyTodo
|
4
|
+
module OpenAIDocumentation
|
5
|
+
CLI_DOCUMENTATION = <<~DOCS
|
6
|
+
Available ruby_todo commands:
|
7
|
+
|
8
|
+
Task Management:
|
9
|
+
1. List tasks:
|
10
|
+
ruby_todo task:list [NOTEBOOK]
|
11
|
+
- Lists all tasks in a notebook or all notebooks if no name provided
|
12
|
+
- To filter by status: ruby_todo task:list [NOTEBOOK] --status STATUS
|
13
|
+
- Example: ruby_todo task:list protectors --status in_progress
|
14
|
+
|
15
|
+
2. Search tasks:
|
16
|
+
ruby_todo task:search SEARCH_TERM
|
17
|
+
- Searches for tasks containing the search term
|
18
|
+
|
19
|
+
3. Show task details:
|
20
|
+
ruby_todo task:show NOTEBOOK TASK_ID
|
21
|
+
- Shows detailed information about a specific task
|
22
|
+
|
23
|
+
4. Add task:
|
24
|
+
ruby_todo task:add [NOTEBOOK] [TITLE]
|
25
|
+
- Add a new task to a notebook
|
26
|
+
- Interactive prompts for title, description, priority, due date, and tags
|
27
|
+
|
28
|
+
5. Edit task:
|
29
|
+
ruby_todo task:edit [NOTEBOOK] [TASK_ID]
|
30
|
+
- Edit an existing task's details
|
31
|
+
|
32
|
+
6. Delete task:
|
33
|
+
ruby_todo task:delete [NOTEBOOK] [TASK_ID]
|
34
|
+
- Delete a task from a notebook
|
35
|
+
|
36
|
+
7. Move task:
|
37
|
+
ruby_todo task:move [NOTEBOOK] [TASK_ID] [STATUS]
|
38
|
+
- Move a task to a different status
|
39
|
+
- STATUS can be: todo, in_progress, done, archived
|
40
|
+
|
41
|
+
Notebook Management:
|
42
|
+
8. List notebooks:
|
43
|
+
ruby_todo notebook:list
|
44
|
+
- List all notebooks
|
45
|
+
|
46
|
+
9. Create notebook:
|
47
|
+
ruby_todo notebook:create NAME
|
48
|
+
- Create a new notebook
|
49
|
+
|
50
|
+
10. Set default notebook:
|
51
|
+
ruby_todo notebook:set_default NAME
|
52
|
+
- Set a notebook as the default
|
53
|
+
|
54
|
+
Template Management:
|
55
|
+
11. List templates:
|
56
|
+
ruby_todo template:list
|
57
|
+
- List all templates
|
58
|
+
|
59
|
+
12. Show template:
|
60
|
+
ruby_todo template:show NAME
|
61
|
+
- Show details of a specific template
|
62
|
+
|
63
|
+
13. Create template:
|
64
|
+
ruby_todo template:create NAME --title TITLE
|
65
|
+
- Create a new task template
|
66
|
+
|
67
|
+
14. Delete template:
|
68
|
+
ruby_todo template:delete NAME
|
69
|
+
- Delete a template
|
70
|
+
|
71
|
+
15. Use template:
|
72
|
+
ruby_todo template:use NAME NOTEBOOK
|
73
|
+
- Create a task from a template in the specified notebook
|
74
|
+
|
75
|
+
Other Commands:
|
76
|
+
16. Export tasks:
|
77
|
+
ruby_todo export [NOTEBOOK] [FILENAME]
|
78
|
+
- Export tasks from a notebook to a JSON file
|
79
|
+
|
80
|
+
17. Import tasks:
|
81
|
+
ruby_todo import [FILENAME]
|
82
|
+
- Import tasks from a JSON or CSV file
|
83
|
+
|
84
|
+
18. Show statistics:
|
85
|
+
ruby_todo stats [NOTEBOOK]
|
86
|
+
- Show statistics for a notebook or all notebooks
|
87
|
+
|
88
|
+
19. Initialize:
|
89
|
+
ruby_todo init
|
90
|
+
- Initialize a new todo list
|
91
|
+
|
92
|
+
Note: All commands use colons (e.g., 'task:list', 'notebook:list').
|
93
|
+
Available statuses: todo, in_progress, done, archived
|
94
|
+
DOCS
|
95
|
+
end
|
96
|
+
|
97
|
+
module OpenAIPromptBuilder
|
98
|
+
include OpenAIDocumentation
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def build_messages(prompt, context)
|
103
|
+
messages = [
|
104
|
+
{ role: "system", content: build_system_prompt(context) },
|
105
|
+
{ role: "user", content: prompt }
|
106
|
+
]
|
107
|
+
|
108
|
+
say "\nSystem prompt:\n#{messages.first[:content]}\n" if options[:verbose]
|
109
|
+
say "\nUser prompt:\n#{prompt}\n" if options[:verbose]
|
110
|
+
|
111
|
+
messages
|
112
|
+
end
|
113
|
+
|
114
|
+
def build_system_prompt(context)
|
115
|
+
prompt = build_base_prompt
|
116
|
+
prompt += build_command_examples
|
117
|
+
prompt += build_json_format_requirements
|
118
|
+
|
119
|
+
if context[:matching_tasks]&.any?
|
120
|
+
prompt += build_matching_tasks_info(context)
|
121
|
+
end
|
122
|
+
|
123
|
+
prompt += build_final_instructions
|
124
|
+
prompt
|
125
|
+
end
|
126
|
+
|
127
|
+
def build_base_prompt
|
128
|
+
prompt = "You are a task management assistant that generates ruby_todo CLI commands. "
|
129
|
+
prompt += "Your role is to analyze user requests and generate the appropriate ruby_todo command(s). "
|
130
|
+
prompt += "You should respond to ALL types of requests, not just task movement requests."
|
131
|
+
prompt += "\n\nHere are the available commands and their usage:\n"
|
132
|
+
prompt += CLI_DOCUMENTATION
|
133
|
+
prompt += "\nBased on the user's request, generate command(s) that follow these formats exactly."
|
134
|
+
prompt += "\n\nStatus Mapping:"
|
135
|
+
prompt += "\n- 'pending' maps to 'todo'"
|
136
|
+
prompt += "\n- 'in progress' maps to 'in_progress'"
|
137
|
+
prompt += "\nPlease use the mapped status values in your commands."
|
138
|
+
prompt
|
139
|
+
end
|
140
|
+
|
141
|
+
def build_command_examples
|
142
|
+
prompt = "\n\nImportant command formats for common requests:"
|
143
|
+
prompt += "\n- To list high priority tasks: ruby_todo task:list [NOTEBOOK] --priority high"
|
144
|
+
prompt += "\n- To list tasks with a specific status: ruby_todo task:list [NOTEBOOK] --status [STATUS]"
|
145
|
+
prompt += "\n- To list all notebooks: ruby_todo notebook:list"
|
146
|
+
prompt += "\n- To create a task: ruby_todo task:add [NOTEBOOK] [TITLE]"
|
147
|
+
prompt += "\n- To move a task to a status: ruby_todo task:move [NOTEBOOK] [TASK_ID] [STATUS]"
|
148
|
+
|
149
|
+
prompt += "\n\nExamples of specific requests and corresponding commands:"
|
150
|
+
prompt += "\n- 'show me all high priority tasks' → ruby_todo task:list protectors --priority high"
|
151
|
+
prompt += "\n- 'list tasks that are in progress' → ruby_todo task:list protectors --status in_progress"
|
152
|
+
prompt += "\n- 'show me all notebooks' → ruby_todo notebook:list"
|
153
|
+
prompt += "\n- 'move task 5 to done' → ruby_todo task:move protectors 5 done"
|
154
|
+
prompt
|
155
|
+
end
|
156
|
+
|
157
|
+
def build_json_format_requirements
|
158
|
+
prompt = "\n\nYou MUST respond with a JSON object containing:"
|
159
|
+
prompt += "\n- 'commands': an array of commands to execute"
|
160
|
+
prompt += "\n- 'explanation': a brief explanation of what the commands will do"
|
161
|
+
prompt
|
162
|
+
end
|
163
|
+
|
164
|
+
def build_matching_tasks_info(context)
|
165
|
+
prompt = "\n\nRelevant tasks found in the system:\n"
|
166
|
+
context[:matching_tasks].each do |task|
|
167
|
+
task_info = format_task_info(task)
|
168
|
+
prompt += "- #{task_info}\n"
|
169
|
+
end
|
170
|
+
|
171
|
+
if context[:target_status] && context[:search_term]
|
172
|
+
# Break this into multiple lines to avoid exceeding line length
|
173
|
+
prompt += "\n\nI will move these tasks matching "
|
174
|
+
prompt += "'#{context[:search_term]}' to "
|
175
|
+
prompt += "'#{context[:target_status]}' status."
|
176
|
+
end
|
177
|
+
prompt
|
178
|
+
end
|
179
|
+
|
180
|
+
def build_final_instructions
|
181
|
+
prompt = "\n\nEven if no tasks match a search or if your request isn't about task movement, "
|
182
|
+
prompt += "I still need you to return a JSON response with commands and explanation."
|
183
|
+
prompt += "\n\nExample JSON Response:"
|
184
|
+
prompt += "\n```json"
|
185
|
+
prompt += "\n{"
|
186
|
+
prompt += "\n \"commands\": ["
|
187
|
+
prompt += "\n \"ruby_todo task:list protectors\","
|
188
|
+
prompt += "\n \"ruby_todo stats protectors\""
|
189
|
+
prompt += "\n ],"
|
190
|
+
prompt += "\n \"explanation\": \"Listing all tasks and statistics for the protectors notebook\""
|
191
|
+
prompt += "\n}"
|
192
|
+
prompt += "\n```"
|
193
|
+
|
194
|
+
prompt += "\n\nNote that all commands use colons, not underscores (e.g., 'task:list', not 'task_list')."
|
195
|
+
prompt
|
196
|
+
end
|
197
|
+
|
198
|
+
def format_task_info(task)
|
199
|
+
"Task #{task[:task_id]} in notebook '#{task[:notebook]}': " \
|
200
|
+
"#{task[:title]} (Status: #{task[:status]})"
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
module OpenAIIntegration
|
205
|
+
include OpenAIDocumentation
|
206
|
+
include OpenAIPromptBuilder
|
207
|
+
|
208
|
+
def query_openai(prompt, context, api_key)
|
209
|
+
say "\nMaking OpenAI API call...".blue if options[:verbose]
|
210
|
+
client = OpenAI::Client.new(access_token: api_key)
|
211
|
+
messages = build_messages(prompt, context)
|
212
|
+
say "Sending request to OpenAI..." if options[:verbose]
|
213
|
+
|
214
|
+
response = client.chat(parameters: build_openai_parameters(messages))
|
215
|
+
|
216
|
+
say "\nOpenAI API call completed".green if options[:verbose]
|
217
|
+
|
218
|
+
log_raw_response(response) if options[:verbose]
|
219
|
+
|
220
|
+
parsed_response = handle_openai_response(response)
|
221
|
+
|
222
|
+
log_parsed_response(parsed_response) if options[:verbose] && parsed_response
|
223
|
+
|
224
|
+
parsed_response
|
225
|
+
end
|
226
|
+
|
227
|
+
private
|
228
|
+
|
229
|
+
def build_openai_parameters(messages)
|
230
|
+
{
|
231
|
+
model: "gpt-4o-mini",
|
232
|
+
messages: messages,
|
233
|
+
temperature: 0.7,
|
234
|
+
max_tokens: 500
|
235
|
+
}
|
236
|
+
end
|
237
|
+
|
238
|
+
def log_raw_response(response)
|
239
|
+
say "\n=== RAW OPENAI RESPONSE ==="
|
240
|
+
if response && response.dig("choices", 0, "message", "content")
|
241
|
+
say response["choices"][0]["message"]["content"]
|
242
|
+
else
|
243
|
+
say "No content in response"
|
244
|
+
end
|
245
|
+
say "=== END RAW RESPONSE ===\n"
|
246
|
+
end
|
247
|
+
|
248
|
+
def log_parsed_response(parsed_response)
|
249
|
+
say "\n=== PARSED RESPONSE DETAILS ==="
|
250
|
+
say "Commands array type: #{parsed_response["commands"].class}"
|
251
|
+
say "Number of commands: #{parsed_response["commands"].size}"
|
252
|
+
parsed_response["commands"].each_with_index do |cmd, i|
|
253
|
+
say "Command #{i + 1}: '#{cmd}'"
|
254
|
+
end
|
255
|
+
say "=== END RESPONSE DETAILS ===\n"
|
256
|
+
end
|
257
|
+
|
258
|
+
def handle_openai_response(response)
|
259
|
+
return nil unless response&.dig("choices", 0, "message", "content")
|
260
|
+
|
261
|
+
content = response["choices"][0]["message"]["content"]
|
262
|
+
say "\nAI Response:\n#{content}\n" if options[:verbose]
|
263
|
+
|
264
|
+
parse_json_from_content(content)
|
265
|
+
end
|
266
|
+
|
267
|
+
def parse_json_from_content(content)
|
268
|
+
# Process the content to extract JSON
|
269
|
+
json_content = process_json_content(content)
|
270
|
+
|
271
|
+
say "\nProcessed JSON content:\n#{json_content}\n" if options[:verbose]
|
272
|
+
|
273
|
+
# Parse the JSON
|
274
|
+
result = JSON.parse(json_content)
|
275
|
+
|
276
|
+
# Ensure required keys exist
|
277
|
+
validate_and_fix_json_result(result)
|
278
|
+
|
279
|
+
result
|
280
|
+
rescue JSON::ParserError => e
|
281
|
+
handle_json_parse_error(content, e)
|
282
|
+
end
|
283
|
+
|
284
|
+
def process_json_content(content)
|
285
|
+
# Remove markdown formatting if present
|
286
|
+
json_content = content.gsub(/```(?:json)?\n(.*?)\n```/m, '\1')
|
287
|
+
# Strip any leading/trailing whitespace, braces are required
|
288
|
+
json_content = json_content.strip
|
289
|
+
# Add braces if they're missing
|
290
|
+
json_content = "{#{json_content}}" unless json_content.start_with?("{") && json_content.end_with?("}")
|
291
|
+
|
292
|
+
json_content
|
293
|
+
end
|
294
|
+
|
295
|
+
def validate_and_fix_json_result(result)
|
296
|
+
# Ensure we have the required keys
|
297
|
+
if !result.key?("commands") || !result["commands"].is_a?(Array)
|
298
|
+
say "Warning: AI response missing 'commands' array. Adding empty array.".yellow if options[:verbose]
|
299
|
+
result["commands"] = []
|
300
|
+
end
|
301
|
+
|
302
|
+
if !result.key?("explanation") || !result["explanation"].is_a?(String)
|
303
|
+
say "Warning: AI response missing 'explanation'. Adding default.".yellow if options[:verbose]
|
304
|
+
result["explanation"] = "Command execution completed."
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
def handle_json_parse_error(content, error)
|
309
|
+
say "Error parsing AI response: #{error.message}".red if options[:verbose]
|
310
|
+
|
311
|
+
# Try to extract commands from plain text as fallback
|
312
|
+
commands = extract_commands_from_text(content)
|
313
|
+
|
314
|
+
if commands.any?
|
315
|
+
say "Extracted #{commands.size} commands from text response".yellow if options[:verbose]
|
316
|
+
return {
|
317
|
+
"commands" => commands,
|
318
|
+
"explanation" => "Commands extracted from non-JSON response."
|
319
|
+
}
|
320
|
+
end
|
321
|
+
|
322
|
+
nil
|
323
|
+
end
|
324
|
+
|
325
|
+
def extract_commands_from_text(content)
|
326
|
+
commands = []
|
327
|
+
content.scan(/ruby_todo\s+\S+(?:\s+\S+)*/) do |cmd|
|
328
|
+
commands << cmd.strip
|
329
|
+
end
|
330
|
+
commands
|
331
|
+
end
|
332
|
+
end
|
333
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyTodo
|
4
|
+
module TaskCreation
|
5
|
+
private
|
6
|
+
|
7
|
+
def task_creation_query?(prompt_lower)
|
8
|
+
(prompt_lower.include?("create") || prompt_lower.include?("add")) &&
|
9
|
+
(prompt_lower.include?("task") || prompt_lower.include?("todo"))
|
10
|
+
end
|
11
|
+
|
12
|
+
def handle_task_creation(prompt, prompt_lower)
|
13
|
+
say "\n=== Detecting task creation request ===" if options[:verbose]
|
14
|
+
|
15
|
+
title = extract_task_title(prompt)
|
16
|
+
return false unless title
|
17
|
+
|
18
|
+
notebook_name = determine_notebook_name(prompt_lower)
|
19
|
+
return false unless notebook_name
|
20
|
+
|
21
|
+
priority = determine_priority(prompt_lower)
|
22
|
+
create_task(notebook_name, title, priority)
|
23
|
+
|
24
|
+
true
|
25
|
+
end
|
26
|
+
|
27
|
+
def extract_task_title(prompt)
|
28
|
+
# Try to extract title from quotes first
|
29
|
+
title_match = prompt.match(/'([^']+)'|"([^"]+)"/)
|
30
|
+
if title_match
|
31
|
+
title_match[1] || title_match[2]
|
32
|
+
else
|
33
|
+
extract_title_from_text(prompt)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def extract_title_from_text(prompt)
|
38
|
+
potential_title = prompt
|
39
|
+
.gsub(/(?:create|add)\s+(?:a\s+)?(?:new\s+)?(?:task|todo)/i, "")
|
40
|
+
.gsub(/(?:in|to)\s+(?:the\s+)?notebook/i, "")
|
41
|
+
.gsub(/(?:with|as)\s+(?:high|medium|low)\s+priority/i, "")
|
42
|
+
.strip
|
43
|
+
|
44
|
+
return nil if potential_title.empty?
|
45
|
+
|
46
|
+
potential_title
|
47
|
+
end
|
48
|
+
|
49
|
+
def determine_notebook_name(prompt_lower)
|
50
|
+
return nil unless Notebook.default_notebook
|
51
|
+
|
52
|
+
if prompt_lower =~ /(?:in|to)\s+(?:the\s+)?notebook\s+['"]?([^'"]+)['"]?/i
|
53
|
+
notebook_name = Regexp.last_match(1).strip
|
54
|
+
return notebook_name if Notebook.exists?(notebook_name)
|
55
|
+
end
|
56
|
+
|
57
|
+
Notebook.default_notebook.name
|
58
|
+
end
|
59
|
+
|
60
|
+
def determine_priority(prompt_lower)
|
61
|
+
if prompt_lower.include?("high priority") || prompt_lower.match(/priority.*high/)
|
62
|
+
"high"
|
63
|
+
elsif prompt_lower.include?("medium priority") || prompt_lower.match(/priority.*medium/)
|
64
|
+
"medium"
|
65
|
+
elsif prompt_lower.include?("low priority") || prompt_lower.match(/priority.*low/)
|
66
|
+
"low"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def create_task(notebook_name, title, priority)
|
71
|
+
say "\nCreating task in notebook: #{notebook_name}" if options[:verbose]
|
72
|
+
|
73
|
+
notebook = Notebook.find_by_name(notebook_name)
|
74
|
+
task = notebook.create_task(title: title)
|
75
|
+
|
76
|
+
if priority
|
77
|
+
task.update(tags: priority)
|
78
|
+
say "Created task with #{priority} priority: #{title}".green
|
79
|
+
else
|
80
|
+
say "Created task: #{title}".green
|
81
|
+
end
|
82
|
+
|
83
|
+
task
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|