n2b 0.2.4 → 0.3.0
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 +2 -15
- data/lib/n2b/cli.rb +358 -16
- data/lib/n2b/errors.rb +7 -0
- data/lib/n2b/llm/claude.rb +43 -5
- data/lib/n2b/llm/gemini.rb +38 -4
- data/lib/n2b/llm/open_ai.rb +33 -3
- data/lib/n2b/version.rb +1 -1
- data/lib/n2b.rb +2 -2
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 698bca4814bd42e8587efb9d278ce1cb090b3ea23afd645c9c082b1c277ea22d
|
4
|
+
data.tar.gz: c7ec9715a8b5807b1ea5eb90e448fd70767fdc3e6ace987e1b123e29ee37dd33
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7d00b68b8e2925b63d4bf6b2e4842a18034e2dc318686d55261480c2612f47f8574379df85ae166670a721aa6bd41406d08323cddb0ebd831e3777dc1773ca1a
|
7
|
+
data.tar.gz: e7b3621fcf9051d433ca0fba961b12d348b03f9d73981d641b252b4a4223b8edfec0f8ee438f6c603382ca62c1cf4679e9960f6c40f16b84c644d6939290dbe5
|
data/README.md
CHANGED
@@ -56,29 +56,16 @@ n2rscrum "Create a user authentication system"
|
|
56
56
|
|
57
57
|
## Configuration
|
58
58
|
|
59
|
-
Create a config file at `~/.n2b/config.yml` with your API keys
|
59
|
+
Create a config file at `~/.n2b/config.yml` with your API keys:
|
60
60
|
|
61
|
-
```bash
|
62
|
-
export N2B_CONFIG_FILE=/path/to/your/config.yml
|
63
|
-
```
|
64
|
-
|
65
|
-
Example config file:
|
66
61
|
```yaml
|
67
|
-
llm: claude # or openai
|
62
|
+
llm: claude # or openai
|
68
63
|
claude:
|
69
64
|
key: your-anthropic-api-key
|
70
65
|
model: claude-3-opus-20240229 # or opus, haiku, sonnet
|
71
66
|
openai:
|
72
67
|
key: your-openai-api-key
|
73
68
|
model: gpt-4 # or gpt-3.5-turbo
|
74
|
-
gemini:
|
75
|
-
key: your-google-api-key
|
76
|
-
model: gemini-flash # uses gemini-2.0-flash model
|
77
|
-
```
|
78
|
-
|
79
|
-
You can also set the history file location using the `N2B_HISTORY_FILE` environment variable:
|
80
|
-
```bash
|
81
|
-
export N2B_HISTORY_FILE=/path/to/your/history
|
82
69
|
```
|
83
70
|
|
84
71
|
## Quick Example N2B
|
data/lib/n2b/cli.rb
CHANGED
@@ -11,12 +11,69 @@ module N2B
|
|
11
11
|
|
12
12
|
def execute
|
13
13
|
config = get_config(reconfigure: @options[:config])
|
14
|
-
|
15
|
-
|
14
|
+
user_input = @args.join(' ') # All remaining args form user input/prompt addition
|
15
|
+
|
16
|
+
if @options[:diff]
|
17
|
+
handle_diff_analysis(config)
|
18
|
+
elsif user_input.empty? # No input text after options
|
16
19
|
puts "Enter your natural language command:"
|
17
20
|
input_text = $stdin.gets.chomp
|
21
|
+
process_natural_language_command(input_text, config)
|
22
|
+
else # Natural language command
|
23
|
+
process_natural_language_command(user_input, config)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
protected
|
28
|
+
|
29
|
+
def handle_diff_analysis(config)
|
30
|
+
vcs_type = get_vcs_type
|
31
|
+
if vcs_type == :none
|
32
|
+
puts "Error: Not a git or hg repository."
|
33
|
+
exit 1
|
34
|
+
end
|
35
|
+
|
36
|
+
# Get requirements file from parsed options
|
37
|
+
requirements_filepath = @options[:requirements]
|
38
|
+
user_prompt_addition = @args.join(' ') # All remaining args are user prompt addition
|
39
|
+
|
40
|
+
requirements_content = nil
|
41
|
+
if requirements_filepath
|
42
|
+
unless File.exist?(requirements_filepath)
|
43
|
+
puts "Error: Requirements file not found: #{requirements_filepath}"
|
44
|
+
exit 1
|
45
|
+
end
|
46
|
+
requirements_content = File.read(requirements_filepath)
|
47
|
+
end
|
48
|
+
|
49
|
+
diff_output = execute_vcs_diff(vcs_type)
|
50
|
+
analyze_diff(diff_output, config, user_prompt_addition, requirements_content)
|
51
|
+
end
|
52
|
+
|
53
|
+
def get_vcs_type
|
54
|
+
if Dir.exist?(File.join(Dir.pwd, '.git'))
|
55
|
+
:git
|
56
|
+
elsif Dir.exist?(File.join(Dir.pwd, '.hg'))
|
57
|
+
:hg
|
58
|
+
else
|
59
|
+
:none
|
18
60
|
end
|
61
|
+
end
|
19
62
|
|
63
|
+
def execute_vcs_diff(vcs_type)
|
64
|
+
case vcs_type
|
65
|
+
when :git
|
66
|
+
`git diff HEAD`
|
67
|
+
when :hg
|
68
|
+
`hg diff`
|
69
|
+
else
|
70
|
+
"" # Should not happen if get_vcs_type logic is correct and checked before calling
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def process_natural_language_command(input_text, config)
|
20
77
|
bash_commands = call_llm(input_text, config)
|
21
78
|
|
22
79
|
puts "\nTranslated #{get_user_shell} Commands:"
|
@@ -24,10 +81,10 @@ module N2B
|
|
24
81
|
puts bash_commands['commands']
|
25
82
|
puts "------------------------"
|
26
83
|
if bash_commands['explanation']
|
27
|
-
puts "Explanation:"
|
84
|
+
puts "Explanation:"
|
28
85
|
puts bash_commands['explanation']
|
29
86
|
puts "------------------------"
|
30
|
-
end
|
87
|
+
end
|
31
88
|
|
32
89
|
if @options[:execute]
|
33
90
|
puts "Press Enter to execute these commands, or Ctrl+C to cancel."
|
@@ -38,9 +95,258 @@ module N2B
|
|
38
95
|
end
|
39
96
|
end
|
40
97
|
|
41
|
-
|
98
|
+
def build_diff_analysis_prompt(diff_output, user_prompt_addition = "", requirements_content = nil)
|
99
|
+
default_system_prompt = <<-SYSTEM_PROMPT.strip
|
100
|
+
You are a senior software developer reviewing a code diff.
|
101
|
+
Your task is to provide a constructive and detailed analysis of the changes.
|
102
|
+
Focus on identifying potential bugs, suggesting improvements in code quality, style, performance, and security.
|
103
|
+
Also, provide a concise summary of the changes.
|
42
104
|
|
43
|
-
|
105
|
+
IMPORTANT: When referring to specific issues or improvements, always include:
|
106
|
+
- The exact file path (e.g., "lib/n2b/cli.rb")
|
107
|
+
- The specific line numbers or line ranges (e.g., "line 42" or "lines 15-20")
|
108
|
+
- The exact code snippet you're referring to when possible
|
109
|
+
|
110
|
+
This helps users quickly locate and understand the issues you identify.
|
111
|
+
|
112
|
+
SPECIAL FOCUS ON TEST COVERAGE:
|
113
|
+
Pay special attention to whether the developer has provided adequate test coverage for the changes:
|
114
|
+
- Look for new test files or modifications to existing test files
|
115
|
+
- Check if new functionality has corresponding tests
|
116
|
+
- Evaluate if edge cases and error conditions are tested
|
117
|
+
- Assess if the tests are meaningful and comprehensive
|
118
|
+
- Note any missing test coverage that should be added
|
119
|
+
|
120
|
+
NOTE: In addition to the diff, you will also receive the current code context around the changed areas.
|
121
|
+
This provides better understanding of the surrounding code and helps with more accurate analysis.
|
122
|
+
The user may provide additional instructions or specific requirements below.
|
123
|
+
SYSTEM_PROMPT
|
124
|
+
|
125
|
+
user_instructions_section = ""
|
126
|
+
unless user_prompt_addition.to_s.strip.empty?
|
127
|
+
user_instructions_section = "User Instructions:\n#{user_prompt_addition.strip}\n\n"
|
128
|
+
end
|
129
|
+
|
130
|
+
requirements_section = ""
|
131
|
+
if requirements_content && !requirements_content.to_s.strip.empty?
|
132
|
+
requirements_section = <<-REQUIREMENTS_BLOCK
|
133
|
+
CRITICAL REQUIREMENTS EVALUATION:
|
134
|
+
You must carefully evaluate whether the code changes meet the following requirements from the ticket/task.
|
135
|
+
For each requirement, explicitly state whether it is:
|
136
|
+
- ✅ IMPLEMENTED: The requirement is fully satisfied by the changes
|
137
|
+
- ⚠️ PARTIALLY IMPLEMENTED: The requirement is partially addressed but needs more work
|
138
|
+
- ❌ NOT IMPLEMENTED: The requirement is not addressed by these changes
|
139
|
+
- 🔍 UNCLEAR: Cannot determine from the diff whether the requirement is met
|
140
|
+
|
141
|
+
--- BEGIN REQUIREMENTS ---
|
142
|
+
#{requirements_content.strip}
|
143
|
+
--- END REQUIREMENTS ---
|
144
|
+
|
145
|
+
REQUIREMENTS_BLOCK
|
146
|
+
end
|
147
|
+
|
148
|
+
analysis_intro = "Analyze the following diff based on the general instructions above and these specific requirements (if any):"
|
149
|
+
|
150
|
+
# Extract context around changed lines
|
151
|
+
context_sections = extract_code_context_from_diff(diff_output)
|
152
|
+
context_info = ""
|
153
|
+
unless context_sections.empty?
|
154
|
+
context_info = "\n\nCurrent Code Context (for better analysis):\n"
|
155
|
+
context_sections.each do |file_path, sections|
|
156
|
+
context_info += "\n--- #{file_path} ---\n"
|
157
|
+
sections.each do |section|
|
158
|
+
context_info += "Lines #{section[:start_line]}-#{section[:end_line]}:\n"
|
159
|
+
context_info += "```\n#{section[:content]}\n```\n\n"
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
json_instruction = <<-JSON_INSTRUCTION.strip
|
165
|
+
CRITICAL: Return ONLY a valid JSON object with the keys "summary", "errors" (as a list of strings), "improvements" (as a list of strings), "test_coverage" (as a string), and "requirements_evaluation" (as a string, only if requirements were provided).
|
166
|
+
Do not include any explanatory text before or after the JSON.
|
167
|
+
Each error and improvement should include specific file paths and line numbers.
|
168
|
+
|
169
|
+
Example format:
|
170
|
+
{
|
171
|
+
"summary": "Brief description of the changes",
|
172
|
+
"errors": [
|
173
|
+
"lib/example.rb line 42: Potential null pointer exception when accessing user.name without checking if user is nil",
|
174
|
+
"src/main.js lines 15-20: Missing error handling for async operation"
|
175
|
+
],
|
176
|
+
"improvements": [
|
177
|
+
"lib/example.rb line 30: Consider using a constant for the magic number 42",
|
178
|
+
"src/utils.py lines 5-10: This method could be simplified using list comprehension"
|
179
|
+
],
|
180
|
+
"test_coverage": "Good: New functionality in lib/example.rb has corresponding tests in test/example_test.rb. Missing: No tests for error handling edge cases in the new validation method.",
|
181
|
+
"requirements_evaluation": "✅ IMPLEMENTED: User authentication feature is fully implemented in auth.rb. ⚠️ PARTIALLY IMPLEMENTED: Error handling is present but lacks specific error codes. ❌ NOT IMPLEMENTED: Email notifications are not addressed in this diff."
|
182
|
+
}
|
183
|
+
JSON_INSTRUCTION
|
184
|
+
|
185
|
+
full_prompt = [
|
186
|
+
default_system_prompt,
|
187
|
+
user_instructions_section,
|
188
|
+
requirements_section,
|
189
|
+
analysis_intro,
|
190
|
+
"Diff:\n```\n#{diff_output}\n```",
|
191
|
+
context_info,
|
192
|
+
json_instruction
|
193
|
+
].select { |s| s && !s.empty? }.join("\n\n") # Join non-empty sections with double newlines
|
194
|
+
|
195
|
+
full_prompt
|
196
|
+
end
|
197
|
+
|
198
|
+
def analyze_diff(diff_output, config, user_prompt_addition = "", requirements_content = nil)
|
199
|
+
prompt = build_diff_analysis_prompt(diff_output, user_prompt_addition, requirements_content)
|
200
|
+
analysis_json_str = call_llm_for_diff_analysis(prompt, config)
|
201
|
+
|
202
|
+
begin
|
203
|
+
# Try to extract JSON from response that might have text before it
|
204
|
+
json_content = extract_json_from_response(analysis_json_str)
|
205
|
+
analysis_result = JSON.parse(json_content)
|
206
|
+
|
207
|
+
puts "\nCode Diff Analysis:"
|
208
|
+
puts "-------------------"
|
209
|
+
puts "Summary:"
|
210
|
+
puts analysis_result['summary'] || "No summary provided."
|
211
|
+
puts "\nPotential Errors:"
|
212
|
+
errors_list = analysis_result['errors']
|
213
|
+
errors_list = [errors_list] if errors_list.is_a?(String) && !errors_list.empty?
|
214
|
+
errors_list = [] if errors_list.nil? || (errors_list.is_a?(String) && errors_list.empty?)
|
215
|
+
puts errors_list.any? ? errors_list.map{|err| "- #{err}"}.join("\n") : "No errors identified."
|
216
|
+
|
217
|
+
puts "\nSuggested Improvements:"
|
218
|
+
improvements_list = analysis_result['improvements']
|
219
|
+
improvements_list = [improvements_list] if improvements_list.is_a?(String) && !improvements_list.empty?
|
220
|
+
improvements_list = [] if improvements_list.nil? || (improvements_list.is_a?(String) && improvements_list.empty?)
|
221
|
+
puts improvements_list.any? ? improvements_list.map{|imp| "- #{imp}"}.join("\n") : "No improvements suggested."
|
222
|
+
|
223
|
+
puts "\nTest Coverage Assessment:"
|
224
|
+
test_coverage = analysis_result['test_coverage']
|
225
|
+
puts test_coverage && !test_coverage.to_s.strip.empty? ? test_coverage : "No test coverage assessment provided."
|
226
|
+
|
227
|
+
# Show requirements evaluation if requirements were provided
|
228
|
+
requirements_eval = analysis_result['requirements_evaluation']
|
229
|
+
if requirements_eval && !requirements_eval.to_s.strip.empty?
|
230
|
+
puts "\nRequirements Evaluation:"
|
231
|
+
puts requirements_eval
|
232
|
+
end
|
233
|
+
puts "-------------------"
|
234
|
+
rescue JSON::ParserError => e # Handles cases where the JSON string (even fallback) is malformed
|
235
|
+
puts "Critical Error: Failed to parse JSON response for diff analysis: #{e.message}"
|
236
|
+
puts "Raw response was: #{analysis_json_str}"
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
def extract_json_from_response(response)
|
241
|
+
# First try to parse the response as-is
|
242
|
+
begin
|
243
|
+
JSON.parse(response)
|
244
|
+
return response
|
245
|
+
rescue JSON::ParserError
|
246
|
+
# If that fails, try to find JSON within the response
|
247
|
+
end
|
248
|
+
|
249
|
+
# Look for JSON object starting with { and ending with }
|
250
|
+
json_start = response.index('{')
|
251
|
+
return response unless json_start
|
252
|
+
|
253
|
+
# Find the matching closing brace
|
254
|
+
brace_count = 0
|
255
|
+
json_end = nil
|
256
|
+
(json_start...response.length).each do |i|
|
257
|
+
case response[i]
|
258
|
+
when '{'
|
259
|
+
brace_count += 1
|
260
|
+
when '}'
|
261
|
+
brace_count -= 1
|
262
|
+
if brace_count == 0
|
263
|
+
json_end = i
|
264
|
+
break
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
return response unless json_end
|
270
|
+
|
271
|
+
response[json_start..json_end]
|
272
|
+
end
|
273
|
+
|
274
|
+
def extract_code_context_from_diff(diff_output)
|
275
|
+
context_sections = {}
|
276
|
+
current_file = nil
|
277
|
+
|
278
|
+
diff_output.each_line do |line|
|
279
|
+
line = line.chomp
|
280
|
+
|
281
|
+
# Parse file headers (e.g., "diff --git a/lib/n2b/cli.rb b/lib/n2b/cli.rb")
|
282
|
+
if line.start_with?('diff --git')
|
283
|
+
# Extract file path from "diff --git a/path b/path"
|
284
|
+
match = line.match(/diff --git a\/(.+) b\/(.+)/)
|
285
|
+
current_file = match[2] if match # Use the "b/" path (new file)
|
286
|
+
elsif line.start_with?('+++')
|
287
|
+
# Alternative way to get file path from "+++ b/path"
|
288
|
+
match = line.match(/\+\+\+ b\/(.+)/)
|
289
|
+
current_file = match[1] if match
|
290
|
+
elsif line.start_with?('@@') && current_file
|
291
|
+
# Parse hunk header (e.g., "@@ -10,7 +10,8 @@")
|
292
|
+
match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/)
|
293
|
+
if match
|
294
|
+
old_start = match[1].to_i
|
295
|
+
new_start = match[2].to_i
|
296
|
+
|
297
|
+
# Use the new file line numbers for context
|
298
|
+
context_start = [new_start - 5, 1].max # 5 lines before, but not less than 1
|
299
|
+
context_end = new_start + 10 # 10 lines after the start
|
300
|
+
|
301
|
+
# Read the actual file content
|
302
|
+
if File.exist?(current_file)
|
303
|
+
file_lines = File.readlines(current_file)
|
304
|
+
# Adjust end to not exceed file length
|
305
|
+
context_end = [context_end, file_lines.length].min
|
306
|
+
|
307
|
+
if context_start <= file_lines.length
|
308
|
+
context_content = file_lines[(context_start-1)...context_end].map.with_index do |content, idx|
|
309
|
+
line_num = context_start + idx
|
310
|
+
"#{line_num.to_s.rjust(4)}: #{content.rstrip}"
|
311
|
+
end.join("\n")
|
312
|
+
|
313
|
+
context_sections[current_file] ||= []
|
314
|
+
context_sections[current_file] << {
|
315
|
+
start_line: context_start,
|
316
|
+
end_line: context_end,
|
317
|
+
content: context_content
|
318
|
+
}
|
319
|
+
end
|
320
|
+
end
|
321
|
+
end
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
context_sections
|
326
|
+
end
|
327
|
+
|
328
|
+
def call_llm_for_diff_analysis(prompt, config)
|
329
|
+
begin
|
330
|
+
llm_service_name = config['llm']
|
331
|
+
llm = case llm_service_name
|
332
|
+
when 'openai'
|
333
|
+
N2M::Llm::OpenAi.new(config)
|
334
|
+
when 'claude'
|
335
|
+
N2M::Llm::Claude.new(config)
|
336
|
+
when 'gemini'
|
337
|
+
N2M::Llm::Gemini.new(config)
|
338
|
+
else
|
339
|
+
# Should not happen if config is validated, but as a safeguard:
|
340
|
+
raise N2B::Error, "Unsupported LLM service: #{llm_service_name}"
|
341
|
+
end
|
342
|
+
|
343
|
+
response_json_str = llm.analyze_code_diff(prompt) # Call the new dedicated method
|
344
|
+
response_json_str
|
345
|
+
rescue N2B::LlmApiError => e # This catches errors from analyze_code_diff
|
346
|
+
puts "Error communicating with the LLM: #{e.message}"
|
347
|
+
return '{"summary": "Error: Could not analyze diff due to LLM API error.", "errors": [], "improvements": []}'
|
348
|
+
end
|
349
|
+
end
|
44
350
|
|
45
351
|
def append_to_llm_history_file(commands)
|
46
352
|
File.open(HISTORY_FILE, 'a') do |file|
|
@@ -56,10 +362,11 @@ module N2B
|
|
56
362
|
end
|
57
363
|
|
58
364
|
def call_llm(prompt, config)
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
365
|
+
begin # Added begin for LlmApiError rescue
|
366
|
+
llm = config['llm'] == 'openai' ? N2M::Llm::OpenAi.new(config) : N2M::Llm::Claude.new(config)
|
367
|
+
|
368
|
+
# This content is specific to bash command generation
|
369
|
+
content = <<-EOF
|
63
370
|
Translate the following natural language command to bash commands: #{prompt}\n\nProvide only the #{get_user_shell} commands for #{ get_user_os }. the commands should be separated by newlines.
|
64
371
|
#{' the user is in directory'+Dir.pwd if config['privacy']['send_current_directory']}.
|
65
372
|
#{' the user sent past requests to you and got these answers '+read_llm_history_file if config['privacy']['send_llm_history'] }
|
@@ -70,10 +377,28 @@ module N2B
|
|
70
377
|
EOF
|
71
378
|
|
72
379
|
|
73
|
-
|
380
|
+
response_json_str = llm.make_request(content)
|
74
381
|
|
75
|
-
append_to_llm_history_file("#{prompt}\n#{
|
76
|
-
|
382
|
+
append_to_llm_history_file("#{prompt}\n#{response_json_str}") # Storing the raw JSON string
|
383
|
+
# The original call_llm was expected to return a hash after JSON.parse,
|
384
|
+
# but it was actually returning the string. Let's assume it should return a parsed Hash.
|
385
|
+
# However, the calling method `process_natural_language_command` accesses it like `bash_commands['commands']`
|
386
|
+
# which implies it expects a Hash. Let's ensure call_llm returns a Hash.
|
387
|
+
# This internal JSON parsing is for the *content* of a successful LLM response.
|
388
|
+
# The LlmApiError for network/auth issues should be caught before this.
|
389
|
+
begin
|
390
|
+
parsed_response = JSON.parse(response_json_str)
|
391
|
+
parsed_response
|
392
|
+
rescue JSON::ParserError => e
|
393
|
+
puts "Error parsing LLM response JSON for command generation: #{e.message}"
|
394
|
+
# This is a fallback for when the LLM response *content* is not valid JSON.
|
395
|
+
{ "commands" => ["echo 'Error: LLM returned invalid JSON content.'"], "explanation" => "The response from the language model was not valid JSON." }
|
396
|
+
end
|
397
|
+
rescue N2B::LlmApiError => e
|
398
|
+
puts "Error communicating with the LLM: #{e.message}"
|
399
|
+
# This is the fallback for LlmApiError (network, auth, etc.)
|
400
|
+
{ "commands" => ["echo 'LLM API error occurred. Please check your configuration and network.'"], "explanation" => "Failed to connect to the LLM." }
|
401
|
+
end
|
77
402
|
end
|
78
403
|
|
79
404
|
def get_user_shell
|
@@ -165,15 +490,23 @@ module N2B
|
|
165
490
|
|
166
491
|
|
167
492
|
def parse_options
|
168
|
-
options = { execute: false, config: nil }
|
493
|
+
options = { execute: false, config: nil, diff: false, requirements: nil }
|
169
494
|
|
170
|
-
OptionParser.new do |opts|
|
495
|
+
parser = OptionParser.new do |opts|
|
171
496
|
opts.banner = "Usage: n2b [options] [natural language command]"
|
172
497
|
|
173
498
|
opts.on('-x', '--execute', 'Execute the commands after confirmation') do
|
174
499
|
options[:execute] = true
|
175
500
|
end
|
176
501
|
|
502
|
+
opts.on('-d', '--diff', 'Analyze git/hg diff with AI') do
|
503
|
+
options[:diff] = true
|
504
|
+
end
|
505
|
+
|
506
|
+
opts.on('-r', '--requirements FILE', 'Requirements file for diff analysis') do |file|
|
507
|
+
options[:requirements] = file
|
508
|
+
end
|
509
|
+
|
177
510
|
opts.on('-h', '--help', 'Print this help') do
|
178
511
|
puts opts
|
179
512
|
exit
|
@@ -182,7 +515,16 @@ module N2B
|
|
182
515
|
opts.on('-c', '--config', 'Configure the API key and model') do
|
183
516
|
options[:config] = true
|
184
517
|
end
|
185
|
-
end
|
518
|
+
end
|
519
|
+
|
520
|
+
begin
|
521
|
+
parser.parse!(@args)
|
522
|
+
rescue OptionParser::InvalidOption => e
|
523
|
+
puts "Error: #{e.message}"
|
524
|
+
puts ""
|
525
|
+
puts parser.help
|
526
|
+
exit 1
|
527
|
+
end
|
186
528
|
|
187
529
|
options
|
188
530
|
end
|
data/lib/n2b/errors.rb
ADDED
data/lib/n2b/llm/claude.rb
CHANGED
@@ -31,12 +31,12 @@ module N2M
|
|
31
31
|
end
|
32
32
|
# check for errors
|
33
33
|
if response.code != '200'
|
34
|
-
|
35
|
-
puts response.body
|
36
|
-
exit 1
|
34
|
+
raise N2B::LlmApiError.new("LLM API Error: #{response.code} #{response.message} - #{response.body}")
|
37
35
|
end
|
38
36
|
answer = JSON.parse(response.body)['content'].first['text']
|
39
37
|
begin
|
38
|
+
# The llm_response.json file is likely for debugging and can be kept or removed.
|
39
|
+
# For this refactoring, I'll keep it as it doesn't affect the error handling logic.
|
40
40
|
File.open('llm_response.json', 'w') do |f|
|
41
41
|
f.write(answer)
|
42
42
|
end
|
@@ -46,16 +46,54 @@ module N2M
|
|
46
46
|
# gsub all \n with \\n that are inside "
|
47
47
|
#
|
48
48
|
answer.gsub!(/"([^"]*)"/) { |match| match.gsub(/\n/, "\\n") }
|
49
|
+
# The llm_response.json file is likely for debugging and can be kept or removed.
|
49
50
|
File.open('llm_response.json', 'w') do |f|
|
50
51
|
f.write(answer)
|
51
52
|
end
|
52
53
|
answer = JSON.parse(answer)
|
53
54
|
rescue JSON::ParserError
|
54
|
-
|
55
|
-
|
55
|
+
# This specific JSON parsing error is about the LLM's *response content*, not an API error.
|
56
|
+
# It should probably be handled differently, but the subtask is about LlmApiError.
|
57
|
+
# For now, keeping existing behavior for this part.
|
58
|
+
puts "Error parsing JSON from LLM response: #{answer}" # Clarified error message
|
59
|
+
answer = { 'explanation' => answer} # Default fallback
|
56
60
|
end
|
57
61
|
answer
|
58
62
|
end
|
63
|
+
|
64
|
+
def analyze_code_diff(prompt_content)
|
65
|
+
# This method assumes prompt_content is the full, ready-to-send prompt
|
66
|
+
# including all instructions for the LLM (system message, diff, user additions, JSON format).
|
67
|
+
uri = URI.parse('https://api.anthropic.com/v1/messages')
|
68
|
+
request = Net::HTTP::Post.new(uri)
|
69
|
+
request.content_type = 'application/json'
|
70
|
+
request['X-API-Key'] = @config['access_key']
|
71
|
+
request['anthropic-version'] = '2023-06-01'
|
72
|
+
|
73
|
+
request.body = JSON.dump({
|
74
|
+
"model" => MODELS[@config['model']],
|
75
|
+
"max_tokens" => @config['max_tokens'] || 1024, # Allow overriding max_tokens from config
|
76
|
+
"messages" => [
|
77
|
+
{
|
78
|
+
"role" => "user", # The entire prompt is passed as a single user message
|
79
|
+
"content" => prompt_content
|
80
|
+
}
|
81
|
+
]
|
82
|
+
})
|
83
|
+
|
84
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
85
|
+
http.request(request)
|
86
|
+
end
|
87
|
+
|
88
|
+
if response.code != '200'
|
89
|
+
raise N2B::LlmApiError.new("LLM API Error: #{response.code} #{response.message} - #{response.body}")
|
90
|
+
end
|
91
|
+
|
92
|
+
# Return the raw JSON string. CLI's call_llm_for_diff_analysis will handle parsing.
|
93
|
+
# The Claude API for messages returns the analysis in response.body['content'].first['text']
|
94
|
+
# which should itself be a JSON string as per our prompt's instructions.
|
95
|
+
JSON.parse(response.body)['content'].first['text']
|
96
|
+
end
|
59
97
|
end
|
60
98
|
end
|
61
99
|
end
|
data/lib/n2b/llm/gemini.rb
CHANGED
@@ -35,10 +35,7 @@ module N2M
|
|
35
35
|
|
36
36
|
# check for errors
|
37
37
|
if response.code != '200'
|
38
|
-
|
39
|
-
puts response.body
|
40
|
-
puts "Config: #{@config.inspect}"
|
41
|
-
exit 1
|
38
|
+
raise N2B::LlmApiError.new("LLM API Error: #{response.code} #{response.message} - #{response.body}")
|
42
39
|
end
|
43
40
|
|
44
41
|
parsed_response = JSON.parse(response.body)
|
@@ -64,6 +61,43 @@ module N2M
|
|
64
61
|
end
|
65
62
|
answer
|
66
63
|
end
|
64
|
+
|
65
|
+
def analyze_code_diff(prompt_content)
|
66
|
+
# This method assumes prompt_content is the full, ready-to-send prompt
|
67
|
+
# including all instructions for the LLM (system message, diff, user additions, JSON format).
|
68
|
+
model = MODELS[@config['model']] || 'gemini-flash' # Or a specific model for analysis if different
|
69
|
+
uri = URI.parse("#{API_URI}/#{model}:generateContent?key=#{@config['access_key']}")
|
70
|
+
|
71
|
+
request = Net::HTTP::Post.new(uri)
|
72
|
+
request.content_type = 'application/json'
|
73
|
+
|
74
|
+
request.body = JSON.dump({
|
75
|
+
"contents" => [{
|
76
|
+
"parts" => [{
|
77
|
+
"text" => prompt_content # The entire prompt is passed as text
|
78
|
+
}]
|
79
|
+
}],
|
80
|
+
# Gemini specific: Ensure JSON output if possible via generationConfig
|
81
|
+
# However, the primary method is instructing it within the prompt itself.
|
82
|
+
# "generationConfig": {
|
83
|
+
# "responseMimeType": "application/json", # This might be too restrictive or not always work as expected
|
84
|
+
# }
|
85
|
+
})
|
86
|
+
|
87
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
88
|
+
http.request(request)
|
89
|
+
end
|
90
|
+
|
91
|
+
if response.code != '200'
|
92
|
+
raise N2B::LlmApiError.new("LLM API Error: #{response.code} #{response.message} - #{response.body}")
|
93
|
+
end
|
94
|
+
|
95
|
+
parsed_response = JSON.parse(response.body)
|
96
|
+
# Return the raw JSON string. CLI's call_llm_for_diff_analysis will handle parsing.
|
97
|
+
# The Gemini API returns the analysis in parsed_response['candidates'].first['content']['parts'].first['text']
|
98
|
+
# which should itself be a JSON string as per our prompt's instructions.
|
99
|
+
parsed_response['candidates'].first['content']['parts'].first['text']
|
100
|
+
end
|
67
101
|
end
|
68
102
|
end
|
69
103
|
end
|
data/lib/n2b/llm/open_ai.rb
CHANGED
@@ -33,9 +33,7 @@ module N2M
|
|
33
33
|
|
34
34
|
# check for errors
|
35
35
|
if response.code != '200'
|
36
|
-
|
37
|
-
puts response.body
|
38
|
-
exit 1
|
36
|
+
raise N2B::LlmApiError.new("LLM API Error: #{response.code} #{response.message} - #{response.body}")
|
39
37
|
end
|
40
38
|
answer = JSON.parse(response.body)['choices'].first['message']['content']
|
41
39
|
begin
|
@@ -47,6 +45,38 @@ module N2M
|
|
47
45
|
end
|
48
46
|
answer
|
49
47
|
end
|
48
|
+
|
49
|
+
def analyze_code_diff(prompt_content)
|
50
|
+
# This method assumes prompt_content is the full, ready-to-send prompt
|
51
|
+
# including all instructions for the LLM (system message, diff, user additions, JSON format).
|
52
|
+
request = Net::HTTP::Post.new(API_URI)
|
53
|
+
request.content_type = 'application/json'
|
54
|
+
request['Authorization'] = "Bearer #{@config['access_key']}"
|
55
|
+
|
56
|
+
request.body = JSON.dump({
|
57
|
+
"model" => MODELS[@config['model']],
|
58
|
+
"response_format" => { "type" => "json_object" }, # Crucial for OpenAI to return JSON
|
59
|
+
"messages" => [
|
60
|
+
{
|
61
|
+
"role" => "user", # The entire prompt is passed as a single user message
|
62
|
+
"content" => prompt_content
|
63
|
+
}
|
64
|
+
],
|
65
|
+
"max_tokens" => @config['max_tokens'] || 1500 # Allow overriding, ensure it's enough for JSON
|
66
|
+
})
|
67
|
+
|
68
|
+
response = Net::HTTP.start(API_URI.hostname, API_URI.port, use_ssl: true) do |http|
|
69
|
+
http.request(request)
|
70
|
+
end
|
71
|
+
|
72
|
+
if response.code != '200'
|
73
|
+
raise N2B::LlmApiError.new("LLM API Error: #{response.code} #{response.message} - #{response.body}")
|
74
|
+
end
|
75
|
+
|
76
|
+
# Return the raw JSON string. CLI's call_llm_for_diff_analysis will handle parsing.
|
77
|
+
# OpenAI with json_object mode should return the JSON directly in 'choices'.first.message.content
|
78
|
+
JSON.parse(response.body)['choices'].first['message']['content']
|
79
|
+
end
|
50
80
|
end
|
51
81
|
end
|
52
82
|
end
|
data/lib/n2b/version.rb
CHANGED
data/lib/n2b.rb
CHANGED
@@ -9,12 +9,12 @@ require 'n2b/version'
|
|
9
9
|
require 'n2b/llm/claude'
|
10
10
|
require 'n2b/llm/open_ai'
|
11
11
|
require 'n2b/llm/gemini'
|
12
|
+
require 'n2b/errors' # Load custom errors
|
12
13
|
require 'n2b/base'
|
13
14
|
require 'n2b/cli'
|
14
15
|
|
15
16
|
require 'n2b/irb'
|
16
17
|
|
17
18
|
module N2B
|
18
|
-
|
19
|
-
|
19
|
+
# Error class is now defined in n2b/errors.rb
|
20
20
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: n2b
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stefan Nothegger
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-06-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: json
|
@@ -67,6 +67,7 @@ files:
|
|
67
67
|
- lib/n2b.rb
|
68
68
|
- lib/n2b/base.rb
|
69
69
|
- lib/n2b/cli.rb
|
70
|
+
- lib/n2b/errors.rb
|
70
71
|
- lib/n2b/irb.rb
|
71
72
|
- lib/n2b/llm/claude.rb
|
72
73
|
- lib/n2b/llm/gemini.rb
|
@@ -94,7 +95,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
95
|
- !ruby/object:Gem::Version
|
95
96
|
version: '0'
|
96
97
|
requirements: []
|
97
|
-
rubygems_version: 3.5.
|
98
|
+
rubygems_version: 3.5.22
|
98
99
|
signing_key:
|
99
100
|
specification_version: 4
|
100
101
|
summary: Convert natural language to bash commands or ruby code and help with debugging.
|