cleo_quality_review 0.1.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 +7 -0
- data/cleo_quality_review.gemspec +31 -0
- data/config/default.yml +7 -0
- data/exe/check_quality +20 -0
- data/lib/cleo_quality_review/changes_diff.rb +67 -0
- data/lib/cleo_quality_review/checks/debride.rb +65 -0
- data/lib/cleo_quality_review/checks/fasterer.rb +35 -0
- data/lib/cleo_quality_review/checks/flog.rb +35 -0
- data/lib/cleo_quality_review/checks/quality_check.rb +143 -0
- data/lib/cleo_quality_review/checks/reek.rb +53 -0
- data/lib/cleo_quality_review/checks/registry.rb +72 -0
- data/lib/cleo_quality_review/checks.rb +38 -0
- data/lib/cleo_quality_review/cli.rb +105 -0
- data/lib/cleo_quality_review/command_result.rb +21 -0
- data/lib/cleo_quality_review/command_runner.rb +27 -0
- data/lib/cleo_quality_review/configuration.rb +193 -0
- data/lib/cleo_quality_review/diff_map.rb +95 -0
- data/lib/cleo_quality_review/formatter.rb +58 -0
- data/lib/cleo_quality_review/github_review_builder.rb +140 -0
- data/lib/cleo_quality_review/github_review_publisher.rb +150 -0
- data/lib/cleo_quality_review/llm_client.rb +59 -0
- data/lib/cleo_quality_review/llm_config.rb +40 -0
- data/lib/cleo_quality_review/llm_errors.rb +19 -0
- data/lib/cleo_quality_review/llm_logger.rb +66 -0
- data/lib/cleo_quality_review/llm_providers/open_ai.rb +188 -0
- data/lib/cleo_quality_review/llm_providers/open_ai_config.rb +83 -0
- data/lib/cleo_quality_review/llm_providers/registry.rb +61 -0
- data/lib/cleo_quality_review/llm_providers/stub.rb +107 -0
- data/lib/cleo_quality_review/llm_providers.rb +44 -0
- data/lib/cleo_quality_review/options.rb +171 -0
- data/lib/cleo_quality_review/prompt_builder.rb +95 -0
- data/lib/cleo_quality_review/prompt_loader.rb +49 -0
- data/lib/cleo_quality_review/result.rb +58 -0
- data/lib/cleo_quality_review/run.rb +78 -0
- data/lib/cleo_quality_review/run_artifacts/raw_check_outputs.rb +97 -0
- data/lib/cleo_quality_review/run_artifacts.rb +146 -0
- data/lib/cleo_quality_review/runner.rb +158 -0
- data/lib/cleo_quality_review/target_resolver.rb +127 -0
- data/lib/cleo_quality_review/version.rb +7 -0
- data/lib/cleo_quality_review.rb +23 -0
- data/prompts/agent.md +53 -0
- data/prompts/github.md +29 -0
- data/prompts/human.md +23 -0
- data/prompts/pr_review.md +62 -0
- metadata +141 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm_config"
|
|
4
|
+
require_relative "llm_logger"
|
|
5
|
+
require_relative "llm_providers"
|
|
6
|
+
|
|
7
|
+
module CleoQualityReview
|
|
8
|
+
##
|
|
9
|
+
# Client for generating LLM reviews using configured provider
|
|
10
|
+
class LlmClient
|
|
11
|
+
##
|
|
12
|
+
# @param [LlmConfig] config
|
|
13
|
+
# @param [Boolean] log whether to log queries and responses
|
|
14
|
+
def initialize(config: LlmConfig.new, log: false)
|
|
15
|
+
@config = config
|
|
16
|
+
@logger = LlmLogger.new(provider_name: config.provider, enabled: log)
|
|
17
|
+
provider.validate_config(config)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
##
|
|
21
|
+
# Generate a review from the given prompt
|
|
22
|
+
# @param [String] prompt
|
|
23
|
+
# @return [String] the generated review
|
|
24
|
+
def generate_review(prompt)
|
|
25
|
+
generate_with_logging(prompt)
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
log_error(prompt, e)
|
|
28
|
+
raise
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :config, :logger
|
|
34
|
+
|
|
35
|
+
def generate_with_logging(prompt)
|
|
36
|
+
provider_client.generate_review(prompt).tap { |response| log_success(prompt, response) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def log_success(prompt, response)
|
|
40
|
+
logger.log(query: prompt, response: response)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def log_error(prompt, error)
|
|
44
|
+
logger.log(query: prompt, response: format_error(error))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def format_error(error)
|
|
48
|
+
"ERROR: #{error.class}: #{error.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def provider_client
|
|
52
|
+
provider.build_client(config: config)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def provider
|
|
56
|
+
@provider ||= LlmProviders.fetch(config.provider)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm_providers/open_ai_config"
|
|
4
|
+
|
|
5
|
+
module CleoQualityReview
|
|
6
|
+
##
|
|
7
|
+
# Configuration for LLM provider
|
|
8
|
+
class LlmConfig
|
|
9
|
+
PROVIDER = "openai"
|
|
10
|
+
|
|
11
|
+
##
|
|
12
|
+
# @param [Hash{String => String}] env environment variables
|
|
13
|
+
def initialize(env: ENV)
|
|
14
|
+
@env = env
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# @return [String] the provider name
|
|
19
|
+
def provider
|
|
20
|
+
PROVIDER
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# @return [LlmProviders::OpenAi::Config]
|
|
25
|
+
def open_ai_config
|
|
26
|
+
@open_ai_config ||= LlmProviders::OpenAi::Config.new(env: env)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# @return [LlmProviders::Stub::Config]
|
|
31
|
+
def stub_config
|
|
32
|
+
require_relative "llm_providers/stub"
|
|
33
|
+
@stub_config ||= LlmProviders::Stub::Config.new(env: env)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :env
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CleoQualityReview
|
|
4
|
+
##
|
|
5
|
+
# Base error class for CleoQualityReview
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
##
|
|
9
|
+
# Raised when required LLM configuration is missing
|
|
10
|
+
class MissingLlmConfigurationError < Error; end
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# Raised when an unsupported LLM provider is requested
|
|
14
|
+
class UnsupportedLlmProviderError < Error; end
|
|
15
|
+
|
|
16
|
+
##
|
|
17
|
+
# Base class for LLM provider-specific errors
|
|
18
|
+
class LlmProviderError < Error; end
|
|
19
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "logger"
|
|
5
|
+
|
|
6
|
+
module CleoQualityReview
|
|
7
|
+
##
|
|
8
|
+
# Logger for LLM queries and responses
|
|
9
|
+
class LlmLogger
|
|
10
|
+
LOG_DIR = "log"
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# @param [String] provider_name name of the LLM provider
|
|
14
|
+
# @param [Boolean] enabled whether logging is enabled
|
|
15
|
+
def initialize(provider_name:, enabled: false)
|
|
16
|
+
@provider_name = provider_name
|
|
17
|
+
@enabled = enabled
|
|
18
|
+
@logger = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# Log a query and response
|
|
23
|
+
# @param [String] query the prompt sent to the LLM
|
|
24
|
+
# @param [String] response the LLM response
|
|
25
|
+
# @return [void]
|
|
26
|
+
def log(query:, response:)
|
|
27
|
+
return unless enabled
|
|
28
|
+
|
|
29
|
+
logger.info(format_entry(query: query, response: response))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# @return [String] path to the log file
|
|
34
|
+
def log_path
|
|
35
|
+
File.join(LOG_DIR, "#{provider_name}.log")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :provider_name, :enabled
|
|
41
|
+
|
|
42
|
+
def logger
|
|
43
|
+
@logger ||= build_logger
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_logger
|
|
47
|
+
FileUtils.mkdir_p(LOG_DIR)
|
|
48
|
+
Logger.new(log_path, formatter: proc { |_, _, _, message| "#{message}\n" })
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def format_entry(query:, response:)
|
|
52
|
+
timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
53
|
+
<<~ENTRY
|
|
54
|
+
================================================================================
|
|
55
|
+
Timestamp: #{timestamp}
|
|
56
|
+
================================================================================
|
|
57
|
+
|
|
58
|
+
--- QUERY ---
|
|
59
|
+
#{query}
|
|
60
|
+
|
|
61
|
+
--- RESPONSE ---
|
|
62
|
+
#{response}
|
|
63
|
+
ENTRY
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "../llm_errors"
|
|
8
|
+
require_relative "open_ai_config"
|
|
9
|
+
|
|
10
|
+
module CleoQualityReview
|
|
11
|
+
module LlmProviders
|
|
12
|
+
##
|
|
13
|
+
# OpenAI provider implementation and support classes.
|
|
14
|
+
module OpenAi
|
|
15
|
+
##
|
|
16
|
+
# Value object representing an HTTP response from OpenAI.
|
|
17
|
+
#
|
|
18
|
+
# @!attribute [r] status_code
|
|
19
|
+
# @return [Integer] HTTP status code
|
|
20
|
+
# @!attribute [r] body
|
|
21
|
+
# @return [String] response body
|
|
22
|
+
HttpResponse = Struct.new(:status_code, :body, keyword_init: true) do
|
|
23
|
+
##
|
|
24
|
+
# Check if the response indicates success.
|
|
25
|
+
# @return [Boolean]
|
|
26
|
+
def success?
|
|
27
|
+
(200..299).cover?(status_code.to_i)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# Value object for a JSON POST request to OpenAI.
|
|
33
|
+
HttpRequest = Struct.new(:uri, :headers, :body, :timeout_seconds, keyword_init: true) do
|
|
34
|
+
##
|
|
35
|
+
# Execute the HTTP request.
|
|
36
|
+
# @param [Net::HTTP::Post] http_request prepared request
|
|
37
|
+
# @yield [Net::HTTP] yields configured HTTP connection
|
|
38
|
+
# @return [Object] result of the block
|
|
39
|
+
def execute(http_request)
|
|
40
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
41
|
+
http.open_timeout = timeout_seconds
|
|
42
|
+
http.read_timeout = timeout_seconds
|
|
43
|
+
http.write_timeout = timeout_seconds
|
|
44
|
+
http.request(http_request)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
##
|
|
50
|
+
# HTTP transport layer for OpenAI API requests.
|
|
51
|
+
class HttpTransport
|
|
52
|
+
##
|
|
53
|
+
# Send a POST request with JSON body.
|
|
54
|
+
# @param [HttpRequest] request JSON POST request options
|
|
55
|
+
# @return [HttpResponse]
|
|
56
|
+
def post_json(request)
|
|
57
|
+
http_request = build_request(request)
|
|
58
|
+
response = perform_request(request, http_request)
|
|
59
|
+
|
|
60
|
+
HttpResponse.new(status_code: response.code.to_i, body: response.body.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def build_request(request)
|
|
66
|
+
http_request = Net::HTTP::Post.new(request.uri)
|
|
67
|
+
request.headers.each { |key, value| http_request[key] = value }
|
|
68
|
+
http_request.body = JSON.generate(request.body)
|
|
69
|
+
http_request
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def perform_request(request, http_request)
|
|
73
|
+
request.execute(http_request)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
##
|
|
78
|
+
# Client for the OpenAI Responses API.
|
|
79
|
+
class Client
|
|
80
|
+
RESPONSES_API_URL = URI("https://api.openai.com/v1/responses")
|
|
81
|
+
|
|
82
|
+
##
|
|
83
|
+
# @param [Config] config OpenAI configuration
|
|
84
|
+
# @param [HttpTransport] http_transport transport layer for HTTP requests
|
|
85
|
+
def initialize(config:, http_transport: HttpTransport.new)
|
|
86
|
+
@config = config
|
|
87
|
+
@http_transport = http_transport
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# Generate a review using the OpenAI Responses API.
|
|
92
|
+
# @param [String] prompt the prompt to send
|
|
93
|
+
# @return [String] generated review text
|
|
94
|
+
# @raise [ApiError] if the API request fails
|
|
95
|
+
def generate_review(prompt)
|
|
96
|
+
response = execute_request(prompt)
|
|
97
|
+
parse_response(response)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
attr_reader :config, :http_transport
|
|
103
|
+
|
|
104
|
+
def execute_request(prompt)
|
|
105
|
+
timeout_seconds = config.timeout_seconds
|
|
106
|
+
http_transport.post_json(build_request(prompt, timeout_seconds))
|
|
107
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
|
|
108
|
+
raise ApiError, timeout_error_message(timeout_seconds, e)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parse_response(response)
|
|
112
|
+
raise ApiError, api_error_message(response) unless response.success?
|
|
113
|
+
|
|
114
|
+
extract_text(JSON.parse(response.body))
|
|
115
|
+
rescue JSON::ParserError => e
|
|
116
|
+
raise ApiError, "OpenAI Responses API returned invalid JSON: #{e.message}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_request(prompt, timeout_seconds)
|
|
120
|
+
HttpRequest.new(
|
|
121
|
+
uri: RESPONSES_API_URL,
|
|
122
|
+
headers: headers,
|
|
123
|
+
body: { model: config.model, input: prompt },
|
|
124
|
+
timeout_seconds: timeout_seconds,
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def timeout_error_message(timeout_seconds, error)
|
|
129
|
+
"OpenAI Responses API request timed out after #{timeout_seconds} seconds: #{error.class}: #{error.message}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def headers
|
|
133
|
+
{
|
|
134
|
+
"Authorization" => "Bearer #{config.api_key}",
|
|
135
|
+
"Content-Type" => "application/json",
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def extract_text(response)
|
|
140
|
+
output_text = response["output_text"]
|
|
141
|
+
return output_text if output_text.to_s.strip != ""
|
|
142
|
+
|
|
143
|
+
text = extract_content_text(response)
|
|
144
|
+
return text unless text.empty?
|
|
145
|
+
|
|
146
|
+
JSON.pretty_generate(response)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def extract_content_text(response)
|
|
150
|
+
Array(response["output"]).flat_map { |item| extract_item_texts(item) }.join("\n")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def extract_item_texts(item)
|
|
154
|
+
Array(item["content"]).filter_map { |content| content["text"] }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def api_error_message(response)
|
|
158
|
+
"OpenAI Responses API request failed with status #{response.status_code}: #{response.body}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
##
|
|
163
|
+
# OpenAI provider adapter for LlmClient.
|
|
164
|
+
class Provider
|
|
165
|
+
##
|
|
166
|
+
# Validate that the config has required OpenAI settings.
|
|
167
|
+
# @param [LlmConfig] config
|
|
168
|
+
# @raise [MissingLlmConfigurationError] if not configured
|
|
169
|
+
# @return [void]
|
|
170
|
+
def validate_config(config)
|
|
171
|
+
config.open_ai_config.validate
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
##
|
|
175
|
+
# Build the client instance.
|
|
176
|
+
# @param [LlmConfig] config
|
|
177
|
+
# @return [Client]
|
|
178
|
+
def build_client(config:)
|
|
179
|
+
Client.new(config: config.open_ai_config)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
##
|
|
184
|
+
# Error raised when OpenAI API requests fail.
|
|
185
|
+
class ApiError < LlmProviderError; end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../llm_errors"
|
|
4
|
+
|
|
5
|
+
module CleoQualityReview
|
|
6
|
+
module LlmProviders
|
|
7
|
+
module OpenAi
|
|
8
|
+
##
|
|
9
|
+
# Configuration for OpenAI API access.
|
|
10
|
+
class Config
|
|
11
|
+
OPEN_AI_API_KEY = "CLEO_QUALITY_REVIEW_OPEN_AI_KEY"
|
|
12
|
+
TIMEOUT_SECONDS = "CLEO_QUALITY_REVIEW_TIMEOUT_SECONDS"
|
|
13
|
+
DEFAULT_MODEL = "gpt-5.5"
|
|
14
|
+
DEFAULT_TIMEOUT_SECONDS = 180
|
|
15
|
+
|
|
16
|
+
##
|
|
17
|
+
# @param [Hash{String => String}] env environment variables
|
|
18
|
+
def initialize(env: ENV)
|
|
19
|
+
@env = env
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# @return [String] environment variable name for the API key
|
|
24
|
+
def api_key_env
|
|
25
|
+
OPEN_AI_API_KEY
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @return [String] environment variable name for the request timeout
|
|
30
|
+
def timeout_seconds_env
|
|
31
|
+
TIMEOUT_SECONDS
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
##
|
|
35
|
+
# @return [String] the OpenAI API key
|
|
36
|
+
# @raise [KeyError] if the API key is not set
|
|
37
|
+
def api_key
|
|
38
|
+
env.fetch(OPEN_AI_API_KEY)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# @return [String] the model identifier to use
|
|
43
|
+
def model = DEFAULT_MODEL
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# @return [Integer] timeout in seconds for OpenAI HTTP requests
|
|
47
|
+
# @raise [ArgumentError] if the configured timeout is not a positive integer
|
|
48
|
+
def timeout_seconds
|
|
49
|
+
value = env.fetch(TIMEOUT_SECONDS, "").to_s.strip
|
|
50
|
+
return DEFAULT_TIMEOUT_SECONDS if value.empty?
|
|
51
|
+
|
|
52
|
+
Integer(value, 10).tap do |seconds|
|
|
53
|
+
raise ArgumentError if seconds <= 0
|
|
54
|
+
end
|
|
55
|
+
rescue ArgumentError
|
|
56
|
+
raise ArgumentError, "#{TIMEOUT_SECONDS} must be a positive integer number of seconds"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# Check if the OpenAI configuration is complete.
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def configured?
|
|
63
|
+
env.fetch(OPEN_AI_API_KEY, "").to_s.strip != ""
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# Validate that the configuration is complete.
|
|
68
|
+
# @raise [MissingLlmConfigurationError] if API key is missing
|
|
69
|
+
# @raise [ArgumentError] if timeout is invalid
|
|
70
|
+
# @return [void]
|
|
71
|
+
def validate
|
|
72
|
+
raise MissingLlmConfigurationError, "Missing OpenAI API key. Set #{api_key_env}." unless configured?
|
|
73
|
+
|
|
74
|
+
timeout_seconds
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
attr_reader :env
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../llm_errors"
|
|
4
|
+
|
|
5
|
+
module CleoQualityReview
|
|
6
|
+
module LlmProviders
|
|
7
|
+
##
|
|
8
|
+
# Registry for available LLM provider implementations.
|
|
9
|
+
class Registry
|
|
10
|
+
class << self
|
|
11
|
+
##
|
|
12
|
+
# Register an LLM provider implementation.
|
|
13
|
+
# @param [String, Symbol] name provider identifier
|
|
14
|
+
# @param [Class] provider_class provider class that responds to validate_config and build_client
|
|
15
|
+
# @return [nil]
|
|
16
|
+
def register(name, provider_class)
|
|
17
|
+
providers[provider_name(name)] = provider_class
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# Resolve a provider name to a provider instance.
|
|
23
|
+
# @param [String, Symbol] name provider identifier
|
|
24
|
+
# @return [Object] provider instance
|
|
25
|
+
# @raise [UnsupportedLlmProviderError] if provider not found
|
|
26
|
+
def fetch(name)
|
|
27
|
+
providers.fetch(provider_name(name)).new
|
|
28
|
+
rescue KeyError
|
|
29
|
+
raise UnsupportedLlmProviderError, unsupported_provider_message(name)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# @return [Array<String>] registered provider names
|
|
34
|
+
def registered
|
|
35
|
+
providers.keys.sort
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @param [String, Symbol] name provider identifier
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def registered?(name)
|
|
42
|
+
providers.key?(provider_name(name))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def provider_name(name)
|
|
48
|
+
name.to_s.downcase
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def unsupported_provider_message(name)
|
|
52
|
+
"Unsupported LLM provider #{name.inspect}. Available: #{registered.join(', ')}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def providers
|
|
56
|
+
@providers ||= {}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CleoQualityReview
|
|
4
|
+
module LlmProviders
|
|
5
|
+
##
|
|
6
|
+
# Stub provider implementation for tests and local development.
|
|
7
|
+
module Stub
|
|
8
|
+
##
|
|
9
|
+
# Configuration for the stub LLM provider.
|
|
10
|
+
class Config
|
|
11
|
+
DEFAULT_RESPONSE = "This is a stub review response for testing."
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
##
|
|
15
|
+
# Configure the response for all stub clients.
|
|
16
|
+
# @param [String, Proc] response fixed response or callable
|
|
17
|
+
# @return [void]
|
|
18
|
+
def response=(response)
|
|
19
|
+
@response = response
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# @return [String, Proc] the configured response
|
|
24
|
+
def response
|
|
25
|
+
@response || DEFAULT_RESPONSE
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# Reset to default response.
|
|
30
|
+
# @return [void]
|
|
31
|
+
def reset!
|
|
32
|
+
@response = nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# @param [Hash{String => String}] env environment variables (unused, for interface compatibility)
|
|
38
|
+
def initialize(env: ENV)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# @return [String] the configured response
|
|
43
|
+
def response
|
|
44
|
+
self.class.response
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# @return [Boolean] always true for stub
|
|
49
|
+
def configured?
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
# Stub LLM client, mirrors OpenAi::Client interface.
|
|
56
|
+
class Client
|
|
57
|
+
attr_reader :received_prompts
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# @param [Config] config stub configuration
|
|
61
|
+
def initialize(config:)
|
|
62
|
+
@config = config
|
|
63
|
+
@received_prompts = []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# Generate a review by returning the configured response.
|
|
68
|
+
# @param [String] prompt the prompt sent
|
|
69
|
+
# @return [String] the configured response
|
|
70
|
+
def generate_review(prompt)
|
|
71
|
+
received_prompts << prompt
|
|
72
|
+
response = config.response
|
|
73
|
+
|
|
74
|
+
case response
|
|
75
|
+
when Proc
|
|
76
|
+
response.call(prompt)
|
|
77
|
+
else
|
|
78
|
+
response.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
attr_reader :config
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
##
|
|
88
|
+
# Stub LLM provider adapter for LlmClient.
|
|
89
|
+
class Provider
|
|
90
|
+
##
|
|
91
|
+
# Validate config - always passes for stub.
|
|
92
|
+
# @param [LlmConfig] config
|
|
93
|
+
# @return [void]
|
|
94
|
+
def validate_config(config)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
##
|
|
98
|
+
# Build the stub client.
|
|
99
|
+
# @param [LlmConfig] config
|
|
100
|
+
# @return [Client]
|
|
101
|
+
def build_client(config:)
|
|
102
|
+
Client.new(config: config.stub_config)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CleoQualityReview
|
|
4
|
+
##
|
|
5
|
+
# Namespace for bundled LLM provider implementations.
|
|
6
|
+
module LlmProviders
|
|
7
|
+
require_relative "llm_providers/registry"
|
|
8
|
+
require_relative "llm_providers/open_ai"
|
|
9
|
+
require_relative "llm_providers/stub"
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
##
|
|
13
|
+
# Register a new LLM provider for use.
|
|
14
|
+
# @param [String, Symbol] provider_name
|
|
15
|
+
# @param [Class] provider_class
|
|
16
|
+
# @return [nil]
|
|
17
|
+
def register(provider_name, provider_class)
|
|
18
|
+
Registry.register(provider_name.to_s, provider_class)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# Resolve a registered LLM provider.
|
|
23
|
+
# @param [String, Symbol] provider_name
|
|
24
|
+
# @return [Object]
|
|
25
|
+
def fetch(provider_name)
|
|
26
|
+
Registry.fetch(provider_name.to_s)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# @return [Array<String>] registered provider names
|
|
31
|
+
def registered
|
|
32
|
+
Registry.registered
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
##
|
|
36
|
+
# Has a provider with the given name been registered?
|
|
37
|
+
# @param [String, Symbol] provider_name
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def registered?(provider_name)
|
|
40
|
+
Registry.registered?(provider_name)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|