docit 0.1.0 → 0.2.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/CHANGELOG.md +26 -0
- data/CONTRIBUTING.md +1 -1
- data/README.md +61 -6
- data/app/controllers/docit/ui_controller.rb +16 -4
- data/lib/docit/ai/anthropic_client.rb +57 -0
- data/lib/docit/ai/autodoc_runner.rb +168 -0
- data/lib/docit/ai/client.rb +23 -0
- data/lib/docit/ai/configuration.rb +66 -0
- data/lib/docit/ai/doc_writer.rb +120 -0
- data/lib/docit/ai/gap_detector.rb +54 -0
- data/lib/docit/ai/groq_client.rb +55 -0
- data/lib/docit/ai/openai_client.rb +55 -0
- data/lib/docit/ai/prompt_builder.rb +124 -0
- data/lib/docit/ai/scaffold_generator.rb +140 -0
- data/lib/docit/ai/tag_injector.rb +52 -0
- data/lib/docit/ai.rb +13 -0
- data/lib/docit/version.rb +1 -1
- data/lib/docit.rb +2 -0
- data/lib/generators/docit/ai_setup/ai_setup_generator.rb +106 -0
- data/lib/generators/docit/install/install_generator.rb +178 -3
- data/lib/tasks/docit.rake +19 -0
- metadata +25 -10
- data/.github/workflows/ci.yml +0 -30
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module Ai
|
|
5
|
+
class GapDetector
|
|
6
|
+
SKIP_PREFIXES = %w[docit/ rails/ active_storage/ action_mailbox/].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(controller_filter: nil)
|
|
9
|
+
@controller_filter = controller_filter
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def detect
|
|
13
|
+
RouteInspector.eager_load_controllers!
|
|
14
|
+
|
|
15
|
+
all_routes.each_with_object([]) do |route_info, gaps|
|
|
16
|
+
controller = route_info[:controller]
|
|
17
|
+
action = route_info[:action]
|
|
18
|
+
|
|
19
|
+
next if Registry.find(controller: controller, action: action)
|
|
20
|
+
|
|
21
|
+
routes = RouteInspector.routes_for(controller, action)
|
|
22
|
+
next if routes.empty?
|
|
23
|
+
|
|
24
|
+
gaps << {
|
|
25
|
+
controller: controller,
|
|
26
|
+
action: action,
|
|
27
|
+
path: routes.first[:path],
|
|
28
|
+
method: routes.first[:method]
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def all_routes
|
|
36
|
+
Rails.application.routes.routes.filter_map do |route|
|
|
37
|
+
controller_path = route.defaults[:controller]
|
|
38
|
+
action = route.defaults[:action]
|
|
39
|
+
next if controller_path.nil? || action.nil?
|
|
40
|
+
next if skip_route?(controller_path)
|
|
41
|
+
|
|
42
|
+
controller_class = "#{controller_path}_controller".camelize
|
|
43
|
+
next if @controller_filter && controller_class != @controller_filter
|
|
44
|
+
|
|
45
|
+
{ controller: controller_class, action: action }
|
|
46
|
+
end.uniq
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def skip_route?(controller_path)
|
|
50
|
+
SKIP_PREFIXES.any? { |prefix| controller_path.start_with?(prefix) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Docit
|
|
8
|
+
module Ai
|
|
9
|
+
class GroqClient
|
|
10
|
+
API_URL = "https://api.groq.com/openai/v1/chat/completions"
|
|
11
|
+
|
|
12
|
+
def initialize(api_key:, model:)
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
@model = model
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate(prompt)
|
|
18
|
+
uri = URI(API_URL)
|
|
19
|
+
request = Net::HTTP::Post.new(uri)
|
|
20
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
21
|
+
request["Content-Type"] = "application/json"
|
|
22
|
+
request.body = {
|
|
23
|
+
model: @model,
|
|
24
|
+
messages: [{ role: "user", content: prompt }],
|
|
25
|
+
temperature: 0.2
|
|
26
|
+
}.to_json
|
|
27
|
+
|
|
28
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60) do |http|
|
|
29
|
+
http.request(request)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
handle_response(response)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def handle_response(response)
|
|
38
|
+
body = parse_json(response, "Groq")
|
|
39
|
+
|
|
40
|
+
if response.is_a?(Net::HTTPSuccess) == false
|
|
41
|
+
message = body.dig("error", "message") || "Unknown API error"
|
|
42
|
+
raise Error, "Groq API error (#{response.code}): #{message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
body.dig("choices", 0, "message", "content") || raise(Error, "Empty response from Groq")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_json(response, provider_name)
|
|
49
|
+
JSON.parse(response.body)
|
|
50
|
+
rescue JSON::ParserError
|
|
51
|
+
raise Error, "#{provider_name} returned invalid JSON (HTTP #{response.code})"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Docit
|
|
8
|
+
module Ai
|
|
9
|
+
class OpenaiClient
|
|
10
|
+
API_URL = "https://api.openai.com/v1/chat/completions"
|
|
11
|
+
|
|
12
|
+
def initialize(api_key:, model:)
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
@model = model
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate(prompt)
|
|
18
|
+
uri = URI(API_URL)
|
|
19
|
+
request = Net::HTTP::Post.new(uri)
|
|
20
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
21
|
+
request["Content-Type"] = "application/json"
|
|
22
|
+
request.body = {
|
|
23
|
+
model: @model,
|
|
24
|
+
messages: [{ role: "user", content: prompt }],
|
|
25
|
+
temperature: 0.2
|
|
26
|
+
}.to_json
|
|
27
|
+
|
|
28
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60) do |http|
|
|
29
|
+
http.request(request)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
handle_response(response)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def handle_response(response)
|
|
38
|
+
body = parse_json(response, "OpenAI")
|
|
39
|
+
|
|
40
|
+
if response.is_a?(Net::HTTPSuccess) == false
|
|
41
|
+
message = body.dig("error", "message") || "Unknown API error"
|
|
42
|
+
raise Error, "OpenAI API error (#{response.code}): #{message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
body.dig("choices", 0, "message", "content") || raise(Error, "Empty response from OpenAI")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_json(response, provider_name)
|
|
49
|
+
JSON.parse(response.body)
|
|
50
|
+
rescue JSON::ParserError
|
|
51
|
+
raise Error, "#{provider_name} returned invalid JSON (HTTP #{response.code})"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module Ai
|
|
5
|
+
class PromptBuilder
|
|
6
|
+
DSL_REFERENCE = <<~DSL
|
|
7
|
+
Available DSL methods inside a `doc :action_name do ... end` block:
|
|
8
|
+
summary "Short description"
|
|
9
|
+
description "Detailed description"
|
|
10
|
+
tags "TagName"
|
|
11
|
+
deprecated value: true
|
|
12
|
+
security :bearer_auth
|
|
13
|
+
|
|
14
|
+
parameter :name, location: :query|:path|:header, type: :string|:integer|:boolean, required: true/false, description: "..."
|
|
15
|
+
parameter :id, location: :path, type: :string, required: true
|
|
16
|
+
|
|
17
|
+
request_body required: true do
|
|
18
|
+
property :field, type: :string|:integer|:boolean|:array|:object|:file, required: true/false, example: "value"
|
|
19
|
+
property :field, type: :string, enum: %w[option1 option2]
|
|
20
|
+
property :nested, type: :object do
|
|
21
|
+
property :child, type: :string
|
|
22
|
+
end
|
|
23
|
+
property :items, type: :array do
|
|
24
|
+
property :id, type: :string
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
response 200, "Description" do
|
|
29
|
+
property :field, type: :string, example: "value"
|
|
30
|
+
property :nested, type: :object do
|
|
31
|
+
property :child, type: :string
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
response 404, "Not found" do
|
|
35
|
+
property :error, type: :string, example: "Not found"
|
|
36
|
+
end
|
|
37
|
+
DSL
|
|
38
|
+
|
|
39
|
+
EXAMPLE_DOC = <<~EXAMPLE
|
|
40
|
+
doc :register do
|
|
41
|
+
summary "Register a new user"
|
|
42
|
+
description "Creates a customer account and sends verification email"
|
|
43
|
+
tags "Authentication"
|
|
44
|
+
|
|
45
|
+
request_body required: true do
|
|
46
|
+
property :email, type: :string, required: true, example: "user@example.com"
|
|
47
|
+
property :password, type: :string, required: true, format: :password
|
|
48
|
+
property :full_name, type: :string, required: true, example: "John Doe"
|
|
49
|
+
property :role, type: :string, enum: %w[customer provider]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
response 201, "Account created successfully" do
|
|
53
|
+
property :user_id, type: :string, example: "123e4567-e89b-12d3-a456-426614174000"
|
|
54
|
+
property :email, type: :string, example: "user@example.com"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
response 422, "Validation error" do
|
|
58
|
+
property :errors, type: :object do
|
|
59
|
+
property :email, type: :array, items: :string
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
EXAMPLE
|
|
64
|
+
|
|
65
|
+
def initialize(gap:)
|
|
66
|
+
@gap = gap
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build
|
|
70
|
+
<<~PROMPT
|
|
71
|
+
You are generating Docit DSL documentation for a Ruby on Rails API endpoint.
|
|
72
|
+
|
|
73
|
+
#{DSL_REFERENCE}
|
|
74
|
+
|
|
75
|
+
Here is a complete example of a well-documented endpoint:
|
|
76
|
+
#{EXAMPLE_DOC}
|
|
77
|
+
|
|
78
|
+
Now generate documentation for:
|
|
79
|
+
- Controller: #{@gap[:controller]}
|
|
80
|
+
- Action: #{@gap[:action]}
|
|
81
|
+
- HTTP method: #{@gap[:method].upcase}
|
|
82
|
+
- Path: #{@gap[:path]}
|
|
83
|
+
|
|
84
|
+
Controller source code:
|
|
85
|
+
```ruby
|
|
86
|
+
#{controller_source}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Rules:
|
|
90
|
+
- Output ONLY the `doc :#{@gap[:action]} do ... end` block
|
|
91
|
+
- No module wrapper, no explanation, no markdown fences
|
|
92
|
+
- Infer parameters from the path (e.g., {id} → path parameter)
|
|
93
|
+
- Infer request body from params usage in the controller
|
|
94
|
+
- Infer response structure from render calls
|
|
95
|
+
- Use realistic examples
|
|
96
|
+
- Include appropriate error responses
|
|
97
|
+
- Use the controller name to determine appropriate tags
|
|
98
|
+
PROMPT
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def source_available?
|
|
102
|
+
path = controller_file_path
|
|
103
|
+
path && File.exist?(path)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def controller_source
|
|
109
|
+
path = controller_file_path
|
|
110
|
+
return "# Source not available" if path && File.exist?(path) == false
|
|
111
|
+
return "# Source not available" if path.nil?
|
|
112
|
+
|
|
113
|
+
File.read(path)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def controller_file_path
|
|
117
|
+
return nil if defined?(Rails) == false || Rails.respond_to?(:root) == false || Rails.root.nil?
|
|
118
|
+
|
|
119
|
+
relative = @gap[:controller].underscore
|
|
120
|
+
Rails.root.join("app", "controllers", "#{relative}.rb").to_s
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
|
|
6
|
+
module Docit
|
|
7
|
+
module Ai
|
|
8
|
+
class ScaffoldGenerator
|
|
9
|
+
def initialize(output: $stdout)
|
|
10
|
+
@output = output
|
|
11
|
+
@files_written = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
check_base_setup!
|
|
16
|
+
gaps = detect_gaps
|
|
17
|
+
|
|
18
|
+
if gaps.empty?
|
|
19
|
+
@output.puts "No undocumented endpoints found."
|
|
20
|
+
return @files_written
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@output.puts "Found #{gaps.length} endpoint#{"s" if gaps.length > 1} to scaffold:"
|
|
24
|
+
gaps.each { |g| @output.puts " #{g[:method].upcase} #{g[:path]} (#{g[:controller]}##{g[:action]})" }
|
|
25
|
+
@output.puts ""
|
|
26
|
+
|
|
27
|
+
grouped = gaps.group_by { |g| g[:controller] }
|
|
28
|
+
|
|
29
|
+
grouped.each do |controller, controller_gaps|
|
|
30
|
+
writer = DocWriter.new(controller_name: controller)
|
|
31
|
+
|
|
32
|
+
if writer.file_exists?
|
|
33
|
+
existing_actions = existing_doc_actions(writer.doc_file_path)
|
|
34
|
+
controller_gaps = controller_gaps.reject { |g| existing_actions.include?(g[:action]) }
|
|
35
|
+
next if controller_gaps.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
blocks = controller_gaps.map { |gap| build_placeholder(gap, controller) }
|
|
39
|
+
writer.write(blocks)
|
|
40
|
+
@files_written << writer.doc_file_path
|
|
41
|
+
|
|
42
|
+
relative = writer.doc_file_path.sub("#{Rails.root}/", "")
|
|
43
|
+
@output.puts " Created: #{relative}"
|
|
44
|
+
|
|
45
|
+
if writer.inject_use_docs
|
|
46
|
+
controller_relative = File.join("app", "controllers", "#{controller.underscore}.rb")
|
|
47
|
+
@output.puts " Added use_docs to #{controller_relative}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
inject_tags(grouped)
|
|
52
|
+
|
|
53
|
+
@output.puts ""
|
|
54
|
+
@output.puts "Scaffolded #{gaps.length} endpoint#{"s" if gaps.length > 1} in #{@files_written.length} file#{"s" if @files_written.length > 1}."
|
|
55
|
+
@output.puts "Fill in the TODO placeholders in your doc files."
|
|
56
|
+
@files_written
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def detect_gaps
|
|
62
|
+
RouteInspector.eager_load_controllers!
|
|
63
|
+
|
|
64
|
+
detector = GapDetector.new
|
|
65
|
+
detector.detect
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check_base_setup!
|
|
69
|
+
unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
70
|
+
raise Docit::Error, "Docit requires a Rails application. Run this command from your app root."
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
initializer = Rails.root.join("config", "initializers", "docit.rb")
|
|
74
|
+
return unless File.exist?(initializer) == false
|
|
75
|
+
|
|
76
|
+
raise Docit::Error, "Docit is not installed. Run: rails generate docit:install"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_placeholder(gap, controller)
|
|
80
|
+
tag = derive_tag(controller)
|
|
81
|
+
method = gap[:method].upcase
|
|
82
|
+
path = gap[:path]
|
|
83
|
+
|
|
84
|
+
lines = []
|
|
85
|
+
lines << "doc :#{gap[:action]} do"
|
|
86
|
+
lines << " summary \"TODO: #{method} #{path}\""
|
|
87
|
+
lines << " tags \"#{tag}\""
|
|
88
|
+
|
|
89
|
+
if gap[:path].include?("{")
|
|
90
|
+
path_params = gap[:path].scan(/\{(\w+)\}/).flatten
|
|
91
|
+
path_params.each do |param|
|
|
92
|
+
lines << " parameter :#{param}, location: :path, type: :string, required: true"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if %w[POST PUT PATCH].include?(method)
|
|
97
|
+
lines << ""
|
|
98
|
+
lines << " request_body required: true do"
|
|
99
|
+
lines << " # TODO: Add request properties"
|
|
100
|
+
lines << " # property :name, type: :string, required: true"
|
|
101
|
+
lines << " end"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
lines << ""
|
|
105
|
+
lines << " response #{default_status(method)}, \"TODO: Add description\" do"
|
|
106
|
+
lines << " # TODO: Add response properties"
|
|
107
|
+
lines << " # property :id, type: :integer"
|
|
108
|
+
lines << " end"
|
|
109
|
+
lines << "end"
|
|
110
|
+
|
|
111
|
+
lines.join("\n")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def derive_tag(controller)
|
|
115
|
+
controller.delete_suffix("Controller").split("::").last
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def default_status(method)
|
|
119
|
+
case method
|
|
120
|
+
when "POST" then 201
|
|
121
|
+
when "DELETE" then 204
|
|
122
|
+
else 200
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def existing_doc_actions(path)
|
|
127
|
+
content = File.read(path)
|
|
128
|
+
content.scan(/doc\s+:(\w+)/).flatten
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def inject_tags(grouped)
|
|
132
|
+
tags = grouped.keys.map { |c| derive_tag(c) }.uniq
|
|
133
|
+
return if tags.empty?
|
|
134
|
+
|
|
135
|
+
injected = TagInjector.new(tags: tags).inject
|
|
136
|
+
injected.each { |tag| @output.puts " Added tag \"#{tag}\" to config/initializers/docit.rb" }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module Ai
|
|
5
|
+
class TagInjector
|
|
6
|
+
def initialize(tags:)
|
|
7
|
+
@tags = tags.uniq
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def inject
|
|
11
|
+
return [] if initializer_path && File.exist?(initializer_path) == false
|
|
12
|
+
|
|
13
|
+
content = File.read(initializer_path)
|
|
14
|
+
existing_tags = content.scan(/config\.tag\s+["']([^"']+)["']/).flatten
|
|
15
|
+
|
|
16
|
+
new_tags = @tags - existing_tags
|
|
17
|
+
return [] if new_tags.empty?
|
|
18
|
+
|
|
19
|
+
lines = new_tags.map do |tag|
|
|
20
|
+
desc = "#{tag} management endpoints"
|
|
21
|
+
" config.tag \"#{tag}\", description: \"#{desc}\""
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
insertion_point = find_insertion_point(content)
|
|
25
|
+
return [] if insertion_point.nil?
|
|
26
|
+
|
|
27
|
+
content = content.insert(insertion_point, "\n#{lines.join("\n")}")
|
|
28
|
+
File.write(initializer_path, content)
|
|
29
|
+
|
|
30
|
+
new_tags
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def initializer_path
|
|
36
|
+
return nil if defined?(Rails) == false
|
|
37
|
+
|
|
38
|
+
Rails.root.join("config", "initializers", "docit.rb").to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def find_insertion_point(content)
|
|
42
|
+
last_tag = content.rindex(/config\.tag\s+/)
|
|
43
|
+
if last_tag
|
|
44
|
+
content.index("\n", last_tag)
|
|
45
|
+
else
|
|
46
|
+
last_config = content.rindex(/config\.\w+/)
|
|
47
|
+
content.index("\n", last_config) if last_config
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/docit/ai.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ai/configuration"
|
|
4
|
+
require_relative "ai/client"
|
|
5
|
+
require_relative "ai/openai_client"
|
|
6
|
+
require_relative "ai/anthropic_client"
|
|
7
|
+
require_relative "ai/groq_client"
|
|
8
|
+
require_relative "ai/gap_detector"
|
|
9
|
+
require_relative "ai/prompt_builder"
|
|
10
|
+
require_relative "ai/doc_writer"
|
|
11
|
+
require_relative "ai/tag_injector"
|
|
12
|
+
require_relative "ai/autodoc_runner"
|
|
13
|
+
require_relative "ai/scaffold_generator"
|
data/lib/docit/version.rb
CHANGED
data/lib/docit.rb
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Docit
|
|
6
|
+
module Generators
|
|
7
|
+
class AiSetupGenerator < Rails::Generators::Base
|
|
8
|
+
desc "Configures AI provider for Docit autodoc generation"
|
|
9
|
+
|
|
10
|
+
PROVIDER_OPTIONS = {
|
|
11
|
+
"1" => "openai",
|
|
12
|
+
"2" => "anthropic",
|
|
13
|
+
"3" => "groq"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def prompt_provider
|
|
17
|
+
say ""
|
|
18
|
+
say "Select your AI provider:"
|
|
19
|
+
say " 1. OpenAI"
|
|
20
|
+
say " 2. Anthropic"
|
|
21
|
+
say " 3. Groq (free tier available)"
|
|
22
|
+
say ""
|
|
23
|
+
|
|
24
|
+
choice = ask_choice("Enter choice (1-3):", PROVIDER_OPTIONS.keys)
|
|
25
|
+
@provider = PROVIDER_OPTIONS[choice]
|
|
26
|
+
|
|
27
|
+
say "Selected: #{@provider.capitalize}", :green
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def prompt_api_key
|
|
31
|
+
loop do
|
|
32
|
+
@api_key = ask_secret("Enter your #{@provider.capitalize} API key:")
|
|
33
|
+
return if @api_key.empty? == false
|
|
34
|
+
|
|
35
|
+
say "API key cannot be blank", :red
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def save_configuration
|
|
40
|
+
model = Docit::Ai::Configuration::DEFAULT_MODELS[@provider]
|
|
41
|
+
Docit::Ai::Configuration.save(
|
|
42
|
+
provider: @provider,
|
|
43
|
+
model: model,
|
|
44
|
+
api_key: @api_key
|
|
45
|
+
)
|
|
46
|
+
say "Saved AI configuration to .docit_ai.yml", :green
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def update_gitignore
|
|
50
|
+
gitignore = Rails.root.join(".gitignore")
|
|
51
|
+
if File.exist?(gitignore) == false
|
|
52
|
+
say "Warning: .gitignore not found. Add .docit_ai.yml manually to avoid committing your API key.", :yellow
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
content = File.read(gitignore)
|
|
57
|
+
return if content.include?(".docit_ai.yml")
|
|
58
|
+
|
|
59
|
+
File.open(gitignore, "a") do |f|
|
|
60
|
+
f.puts "" if content.end_with?("\n") == false
|
|
61
|
+
f.puts "# Docit AI configuration (contains API key)"
|
|
62
|
+
f.puts ".docit_ai.yml"
|
|
63
|
+
end
|
|
64
|
+
say "Added .docit_ai.yml to .gitignore", :green
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def print_instructions
|
|
68
|
+
say ""
|
|
69
|
+
say "Docit AI configured successfully!", :green
|
|
70
|
+
say ""
|
|
71
|
+
say "Docit stores your API key in .docit_ai.yml with restricted file permissions."
|
|
72
|
+
say "Keep that file out of version control."
|
|
73
|
+
say ""
|
|
74
|
+
say "Next steps:"
|
|
75
|
+
say " rails docit:autodoc # document all undocumented endpoints"
|
|
76
|
+
say " rails docit:autodoc[Api::V1::UsersController] # document a specific controller"
|
|
77
|
+
say " DRY_RUN=1 rails docit:autodoc # preview without writing files"
|
|
78
|
+
say ""
|
|
79
|
+
say "To reconfigure, run this generator again."
|
|
80
|
+
say ""
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def ask_choice(prompt, choices)
|
|
86
|
+
loop do
|
|
87
|
+
choice = ask(prompt).to_s.strip
|
|
88
|
+
return choice if choices.include?(choice)
|
|
89
|
+
|
|
90
|
+
say "Invalid choice. Please enter #{choices.join(", ")}.", :red
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def ask_secret(prompt)
|
|
95
|
+
if $stdin.respond_to?(:noecho) && $stdin.tty?
|
|
96
|
+
shell.say(prompt, nil, false)
|
|
97
|
+
value = $stdin.noecho(&:gets).to_s.strip
|
|
98
|
+
shell.say("")
|
|
99
|
+
value
|
|
100
|
+
else
|
|
101
|
+
ask(prompt).to_s.strip
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|