active_genie 0.25.1 → 0.26.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/VERSION +1 -1
  4. data/lib/active_genie/battle/README.md +7 -7
  5. data/lib/active_genie/battle/generalist.json +36 -0
  6. data/lib/active_genie/battle/generalist.md +16 -0
  7. data/lib/active_genie/battle/generalist.rb +16 -69
  8. data/lib/active_genie/clients/providers/anthropic_client.rb +61 -40
  9. data/lib/active_genie/clients/providers/base_client.rb +44 -57
  10. data/lib/active_genie/clients/providers/deepseek_client.rb +57 -52
  11. data/lib/active_genie/clients/providers/google_client.rb +58 -60
  12. data/lib/active_genie/clients/providers/openai_client.rb +52 -55
  13. data/lib/active_genie/clients/unified_client.rb +4 -4
  14. data/lib/active_genie/config/battle_config.rb +2 -0
  15. data/lib/active_genie/config/llm_config.rb +3 -1
  16. data/lib/active_genie/config/log_config.rb +38 -14
  17. data/lib/active_genie/config/providers/anthropic_config.rb +2 -2
  18. data/lib/active_genie/config/providers/deepseek_config.rb +2 -2
  19. data/lib/active_genie/config/providers/google_config.rb +2 -2
  20. data/lib/active_genie/config/providers/openai_config.rb +2 -2
  21. data/lib/active_genie/config/providers_config.rb +4 -4
  22. data/lib/active_genie/config/scoring_config.rb +2 -0
  23. data/lib/active_genie/configuration.rb +14 -8
  24. data/lib/active_genie/data_extractor/from_informal.json +11 -0
  25. data/lib/active_genie/data_extractor/from_informal.rb +5 -13
  26. data/lib/active_genie/data_extractor/generalist.json +9 -0
  27. data/lib/active_genie/data_extractor/generalist.rb +12 -11
  28. data/lib/active_genie/errors/invalid_log_output_error.rb +19 -0
  29. data/lib/active_genie/logger.rb +13 -5
  30. data/lib/active_genie/{concerns → ranking/concerns}/loggable.rb +2 -5
  31. data/lib/active_genie/ranking/elo_round.rb +30 -28
  32. data/lib/active_genie/ranking/free_for_all.rb +30 -22
  33. data/lib/active_genie/ranking/player.rb +53 -19
  34. data/lib/active_genie/ranking/players_collection.rb +17 -13
  35. data/lib/active_genie/ranking/ranking.rb +21 -20
  36. data/lib/active_genie/ranking/ranking_scoring.rb +2 -20
  37. data/lib/active_genie/scoring/generalist.json +9 -0
  38. data/lib/active_genie/scoring/generalist.md +46 -0
  39. data/lib/active_genie/scoring/generalist.rb +13 -65
  40. data/lib/active_genie/scoring/recommended_reviewers.rb +2 -2
  41. metadata +11 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c91ce12dad609d42839c1d71d172ae5ed6922357374097ec29de8bcbb0c7a3fd
4
- data.tar.gz: 4de847656eff030d4bdf93bf8f492584c5ace9458af7f13b962941eabbe1e308
3
+ metadata.gz: 848de45258263935accfd02ce16d14eb96c6b5a73f42979fe0a74e5b9b746fb4
4
+ data.tar.gz: cb1f9eb843559a7040dc20b1442e120325d734b7610a75b57bfde91cba63dc4e
5
5
  SHA512:
6
- metadata.gz: 0c9c8fbf3f4e940408d3a1051f280aec1da7d196ac61ba1fa02812b68376411a74198a44fb888fbd0c3cf385d39330c7c2265a3a73448c6b2a9179a1c29bdd48
7
- data.tar.gz: 2d7001c0bb3b2ce274ecef96eaf10a0228d949f447d5df7cccca4f686c6f2b14273c71d376f5f7b8e39d9448a3ee27bd2f1358f4a7a69cdad2ac4fd34dfac574
6
+ metadata.gz: 12f38e95348fae6beab3cc7a74cfdefc132ef9e8308303f2df04a40703d6f86a9fe6f859c958dc8a1d9fca52d41ceb1f380324c5b34d5d13e0bde4a702362830
7
+ data.tar.gz: c512e6ae0fb0b47403f9c2fc3be5c55a458320877fe2aae93780af1fab4e95b2fa1553ff60a62ec30653933f77a5c8892e96227ee637e8ab03a30cf0c9ad177d
data/README.md CHANGED
@@ -118,13 +118,13 @@ AI-powered battle evaluation system that determines winners between two players
118
118
  ```ruby
119
119
  require 'active_genie'
120
120
 
121
- player_1 = "Implementation uses dependency injection for better testability"
122
- player_2 = "Code has high test coverage but tightly coupled components"
121
+ player_a = "Implementation uses dependency injection for better testability"
122
+ player_b = "Code has high test coverage but tightly coupled components"
123
123
  criteria = "Evaluate code quality and maintainability"
124
124
 
125
125
  result = ActiveGenie::Battle.call(
126
- player_1,
127
- player_2,
126
+ player_a,
127
+ player_b,
128
128
  criteria,
129
129
  config: { provider: :google, model: 'gemini-2.0-flash-lite' } # optional
130
130
  )
@@ -238,7 +238,7 @@ ActiveGenie.configure do |config|
238
238
  config.llm.client = InternalCompanyApi
239
239
  end
240
240
  # or
241
- ActiveGenie::Battle.call('player_1', 'player_2', 'criteria', { client: InternalCompanyApi })
241
+ ActiveGenie::Battle.call('player_a', 'player_b', 'criteria', { client: InternalCompanyApi })
242
242
  ```
243
243
 
244
244
  ## Observability
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.25.1
1
+ 0.26.0
@@ -12,11 +12,11 @@ AI-powered battle evaluation system that determines winners between two players
12
12
  Evaluate a battle between two players with simple text content:
13
13
 
14
14
  ```ruby
15
- player_1 = "Implementation uses dependency injection for better testability"
16
- player_2 = "Code has high test coverage but tightly coupled components"
15
+ player_a = "Implementation uses dependency injection for better testability"
16
+ player_b = "Code has high test coverage but tightly coupled components"
17
17
  criteria = "Evaluate code quality and maintainability"
18
18
 
19
- result = ActiveGenie::Battle.call(player_1, player_2, criteria)
19
+ result = ActiveGenie::Battle.call(player_a, player_b, criteria)
20
20
  # => {
21
21
  # winner_player: "Implementation uses dependency injection for better testability",
22
22
  # reasoning: "Player A's implementation demonstrates better maintainability through dependency injection,
@@ -27,13 +27,13 @@ result = ActiveGenie::Battle.call(player_1, player_2, criteria)
27
27
  ```
28
28
 
29
29
  ## Interface
30
- ### .call(player_1, player_2, criteria, config: {})
31
- - `player_1` [String, Hash] - The content or submission from the first player
32
- - `player_2` [String, Hash] - The content or submission from the second player
30
+ ### .call(player_a, player_b, criteria, config: {})
31
+ - `player_a` [String, Hash] - The content or submission from the first player
32
+ - `player_b` [String, Hash] - The content or submission from the second player
33
33
  - `criteria` [String] - The evaluation criteria or rules to assess against
34
34
  - `config` [Hash] - Additional configuration config that modify the battle evaluation behavior
35
35
 
36
36
  Returns a Hash containing:
37
- - `winner_player` [String, Hash] - The winning player's content (either player_1 or player_2)
37
+ - `winner_player` [String, Hash] - The winning player's content (either player_a or player_b)
38
38
  - `reasoning` [String] - Detailed explanation of why the winner was chosen
39
39
  - `what_could_be_changed_to_avoid_draw` [String] - A suggestion on how to avoid a draw
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "battle_evaluation",
3
+ "description": "Evaluate a battle between player_a and player_b using predefined criteria and identify the winner.",
4
+ "parameters": {
5
+ "type": "object",
6
+ "properties": {
7
+ "player_a_sell_himself": {
8
+ "type": "string",
9
+ "description": "player_a presents their strengths and how they meet the criteria. Max of 100 words."
10
+ },
11
+ "player_b_sell_himself": {
12
+ "type": "string",
13
+ "description": "player_b presents their strengths and how they meet the criteria. Max of 100 words."
14
+ },
15
+ "player_a_arguments": {
16
+ "type": "string",
17
+ "description": "player_a arguments for why they should be the winner compared to player_b. Max of 100 words."
18
+ },
19
+ "player_b_counter": {
20
+ "type": "string",
21
+ "description": "player_b counter arguments for why they should be the winner compared to player_a. Max of 100 words."
22
+ },
23
+ "impartial_judge_winner_reasoning": {
24
+ "type": "string",
25
+ "description": "The detailed reasoning about why the impartial judge chose the winner. Max of 100 words."
26
+ },
27
+ "impartial_judge_winner": {
28
+ "type": "string",
29
+ "description": "Who is the winner based on the impartial judge reasoning?",
30
+ "enum": ["player_a", "player_b"]
31
+ }
32
+ },
33
+ "required": ["player_a_sell_himself", "player_b_sell_himself", "player_a_arguments", "player_b_counter",
34
+ "impartial_judge_winner_reasoning", "impartial_judge_winner"]
35
+ }
36
+ }
@@ -0,0 +1,16 @@
1
+ Based on two players, player_a and player_b, they will battle against each other based on criteria. Criteria are vital as they provide a clear metric to compare the players. Follow these criteria strictly.
2
+
3
+ # Steps
4
+ 1. player_a presents their strengths and how they meet the criteria. Max of 100 words.
5
+ 2. player_b presents their strengths and how they meet the criteria. Max of 100 words.
6
+ 3. player_a argues why they should be the winner compared to player_b. Max of 100 words.
7
+ 4. player_b counter-argues why they should be the winner compared to player_a. Max of 100 words.
8
+ 5. The impartial judge chooses the winner.
9
+
10
+ # Output Format
11
+ - The impartial judge chooses this player as the winner.
12
+
13
+ # Notes
14
+ - Avoid resulting in a draw. Use reasoning or make fair assumptions if needed.
15
+ - Critically assess each player's adherence to the criteria.
16
+ - Clearly communicate the reasoning behind your decision.
@@ -19,17 +19,17 @@ module ActiveGenie
19
19
  new(...).call
20
20
  end
21
21
 
22
- # @param player_1 [String] The content or submission from the first player
23
- # @param player_2 [String] The content or submission from the second player
22
+ # @param player_a [String] The content or submission from the first player
23
+ # @param player_b [String] The content or submission from the second player
24
24
  # @param criteria [String] The evaluation criteria or rules to assess against
25
25
  # @param config [Hash] Additional configuration options that modify the battle evaluation behavior
26
26
  # @return [Hash] The evaluation result containing the winner and reasoning
27
- # @return [String] :winner The winner, either player_1 or player_2
27
+ # @return [String] :winner The winner, either player_a or player_b
28
28
  # @return [String] :reasoning Detailed explanation of why the winner was chosen
29
29
  # @return [String] :what_could_be_changed_to_avoid_draw A suggestion on how to avoid a draw
30
- def initialize(player_1, player_2, criteria, config: {})
31
- @player_1 = player_1
32
- @player_2 = player_2
30
+ def initialize(player_a, player_b, criteria, config: {})
31
+ @player_a = player_a
32
+ @player_b = player_b
33
33
  @criteria = criteria
34
34
  @config = ActiveGenie.configuration.merge(config)
35
35
  end
@@ -38,8 +38,8 @@ module ActiveGenie
38
38
  messages = [
39
39
  { role: 'system', content: PROMPT },
40
40
  { role: 'user', content: "criteria: #{@criteria}" },
41
- { role: 'user', content: "player_1: #{@player_1}" },
42
- { role: 'user', content: "player_2: #{@player_2}" }
41
+ { role: 'user', content: "player_a: #{@player_a}" },
42
+ { role: 'user', content: "player_b: #{@player_b}" }
43
43
  ]
44
44
 
45
45
  response = ::ActiveGenie::Clients::UnifiedClient.function_calling(
@@ -50,8 +50,8 @@ module ActiveGenie
50
50
 
51
51
  ActiveGenie::Logger.call({
52
52
  code: :battle,
53
- player_1: @player_1[0..30],
54
- player_2: @player_2[0..30],
53
+ player_a: @player_a[0..30],
54
+ player_b: @player_b[0..30],
55
55
  criteria: @criteria[0..30],
56
56
  winner: response['impartial_judge_winner'],
57
57
  reasoning: response['impartial_judge_winner_reasoning']
@@ -60,73 +60,20 @@ module ActiveGenie
60
60
  response_formatted(response)
61
61
  end
62
62
 
63
+ PROMPT = File.read(File.join(__dir__, 'generalist.md'))
64
+ FUNCTION = JSON.parse(File.read(File.join(__dir__, 'generalist.json')), symbolize_names: true)
65
+
63
66
  private
64
67
 
65
68
  def response_formatted(response)
66
69
  winner = response['impartial_judge_winner']
67
- loser = case response['impartial_judge_winner']
68
- when 'player_1' then 'player_2'
69
- when 'player_2' then 'player_1'
70
+ loser = case winner
71
+ when 'player_a' then 'player_b'
72
+ when 'player_b' then 'player_a'
70
73
  end
71
74
 
72
75
  { 'winner' => winner, 'loser' => loser, 'reasoning' => response['impartial_judge_winner_reasoning'] }
73
76
  end
74
-
75
- PROMPT = <<~PROMPT
76
- Based on two players, player_1 and player_2, they will battle against each other based on criteria. Criteria are vital as they provide a clear metric to compare the players. Follow these criteria strictly.
77
-
78
- # Steps
79
- 1. player_1 presents their strengths and how they meet the criteria. Max of 100 words.
80
- 2. player_2 presents their strengths and how they meet the criteria. Max of 100 words.
81
- 3. player_1 argues why they should be the winner compared to player_2. Max of 100 words.
82
- 4. player_2 counter-argues why they should be the winner compared to player_1. Max of 100 words.
83
- 5. The impartial judge chooses the winner.
84
-
85
- # Output Format
86
- - The impartial judge chooses this player as the winner.
87
-
88
- # Notes
89
- - Avoid resulting in a draw. Use reasoning or make fair assumptions if needed.
90
- - Critically assess each player's adherence to the criteria.
91
- - Clearly communicate the reasoning behind your decision.
92
- PROMPT
93
-
94
- FUNCTION = {
95
- name: 'battle_evaluation',
96
- description: 'Evaluate a battle between player_1 and player_2 using predefined criteria and identify the winner.',
97
- parameters: {
98
- type: 'object',
99
- properties: {
100
- player_1_sell_himself: {
101
- type: 'string',
102
- description: 'player_1 presents their strengths and how they meet the criteria. Max of 100 words.'
103
- },
104
- player_2_sell_himself: {
105
- type: 'string',
106
- description: 'player_2 presents their strengths and how they meet the criteria. Max of 100 words.'
107
- },
108
- player_1_arguments: {
109
- type: 'string',
110
- description: 'player_1 arguments for why they should be the winner compared to player_2. Max of 100 words.'
111
- },
112
- player_2_counter: {
113
- type: 'string',
114
- description: 'player_2 counter arguments for why they should be the winner compared to player_1. Max of 100 words.'
115
- },
116
- impartial_judge_winner_reasoning: {
117
- type: 'string',
118
- description: 'The detailed reasoning about why the impartial judge chose the winner. Max of 100 words.'
119
- },
120
- impartial_judge_winner: {
121
- type: 'string',
122
- description: 'Who is the winner based on the impartial judge reasoning?',
123
- enum: %w[player_1 player_2]
124
- }
125
- },
126
- required: %w[player_1_sell_himself player_2_sell_himself player_1_arguments player_2_counter
127
- impartial_judge_winner_reasoning impartial_judge_winner]
128
- }
129
- }.freeze
130
77
  end
131
78
  end
132
79
  end
@@ -3,17 +3,12 @@
3
3
  require 'json'
4
4
  require 'net/http'
5
5
  require 'uri'
6
- require_relative './base_client'
6
+ require_relative 'base_client'
7
7
 
8
8
  module ActiveGenie
9
9
  module Clients
10
10
  # Client for interacting with the Anthropic (Claude) API with json response
11
11
  class AnthropicClient < BaseClient
12
- class AnthropicError < ClientError; end
13
- class RateLimitError < AnthropicError; end
14
-
15
- ANTHROPIC_ENDPOINT = '/v1/messages'
16
-
17
12
  # Requests structured JSON output from the Anthropic Claude model based on a schema.
18
13
  #
19
14
  # @param messages [Array<Hash>] A list of messages representing the conversation history.
@@ -22,56 +17,82 @@ module ActiveGenie
22
17
  # @param function [Hash] A JSON schema definition describing the desired output format.
23
18
  # @return [Hash, nil] The parsed JSON object matching the schema, or nil if parsing fails or content is empty.
24
19
  def function_calling(messages, function)
25
- model = @config.llm.model || @config.providers.anthropic.tier_to_model(@config.llm.model_tier)
26
-
27
- system_message = messages.find { |m| m[:role] == 'system' }&.dig(:content) || ''
28
- user_messages = messages.select { |m| %w[user assistant].include?(m[:role]) }
29
- .map { |m| { role: m[:role], content: m[:content] } }
30
-
31
- anthropic_function = function.dup
32
- anthropic_function[:input_schema] = function[:parameters]
33
- anthropic_function.delete(:parameters)
20
+ system_message, user_messages = split_messages(messages)
21
+ tool = function_to_tool(function)
34
22
 
35
23
  payload = {
36
24
  model:,
37
25
  system: system_message,
38
26
  messages: user_messages,
39
- tools: [anthropic_function],
40
- tool_choice: { name: anthropic_function[:name], type: 'tool' },
27
+ tools: [tool],
28
+ tool_choice: { name: tool[:name], type: 'tool' },
41
29
  max_tokens: @config.llm.max_tokens,
42
30
  temperature: @config.llm.temperature || 0
43
31
  }
44
32
 
45
- headers = {
46
- 'x-api-key': @config.providers.anthropic.api_key,
47
- 'anthropic-version': @config.providers.anthropic.anthropic_version
48
- }.compact
49
-
50
- retry_with_backoff do
51
- start_time = Time.now
52
- url = "#{@config.providers.anthropic.api_url}#{ANTHROPIC_ENDPOINT}"
33
+ request(payload).dig('content', 0, 'input')
34
+ end
53
35
 
54
- response = post(url, payload, headers: headers)
36
+ ANTHROPIC_ENDPOINT = '/v1/messages'
55
37
 
56
- content = response.dig('content', 0, 'input')
38
+ private
57
39
 
58
- ActiveGenie::Logger.call({
59
- code: :llm_usage,
60
- input_tokens: response.dig('usage', 'input_tokens'),
61
- output_tokens: response.dig('usage', 'output_tokens'),
62
- total_tokens: response.dig('usage',
63
- 'input_tokens') + response.dig('usage',
64
- 'output_tokens'),
65
- model: payload[:model],
66
- duration: Time.now - start_time,
67
- usage: response['usage']
68
- })
40
+ def split_messages(messages)
41
+ system_message = messages.find { |m| m[:role] == 'system' }&.dig(:content) || ''
42
+ user_messages = messages.select { |m| %w[user assistant].include?(m[:role]) }
43
+ .map { |m| m.slice(:role, :content) }
69
44
 
70
- ActiveGenie::Logger.call({ code: :function_calling, payload:, parsed_response: content })
45
+ [system_message, user_messages]
46
+ end
71
47
 
72
- content
48
+ def function_to_tool(function)
49
+ function.dup.tap do |f|
50
+ f[:input_schema] = function[:parameters]
51
+ f.delete(:parameters)
73
52
  end
74
53
  end
54
+
55
+ def request(payload)
56
+ response = post(url, payload, headers:)
57
+
58
+ ActiveGenie::Logger.call(
59
+ {
60
+ code: :llm_usage,
61
+ input_tokens: response.dig('usage', 'input_tokens'),
62
+ output_tokens: response.dig('usage', 'output_tokens'),
63
+ total_tokens: response.dig('usage',
64
+ 'input_tokens') + response.dig('usage',
65
+ 'output_tokens'),
66
+ model: payload[:model],
67
+ usage: response['usage']
68
+ }
69
+ )
70
+ ActiveGenie::Logger.call(
71
+ {
72
+ code: :function_calling,
73
+ fine_tune: true,
74
+ payload:,
75
+ parsed_response: response.dig('content', 0, 'input')
76
+ }
77
+ )
78
+
79
+ response
80
+ end
81
+
82
+ def url
83
+ "#{@config.providers.anthropic.api_url}#{ANTHROPIC_ENDPOINT}"
84
+ end
85
+
86
+ def model
87
+ @config.llm.model || @config.providers.anthropic.tier_to_model(@config.llm.model_tier)
88
+ end
89
+
90
+ def headers
91
+ {
92
+ 'x-api-key': @config.providers.anthropic.api_key,
93
+ 'anthropic-version': @config.providers.anthropic.anthropic_version
94
+ }.compact
95
+ end
75
96
  end
76
97
  end
77
98
  end
@@ -7,7 +7,7 @@ module ActiveGenie
7
7
 
8
8
  DEFAULT_HEADERS = {
9
9
  'Content-Type': 'application/json',
10
- 'Accept': 'application/json',
10
+ Accept: 'application/json',
11
11
  'User-Agent': "ActiveGenie/#{ActiveGenie::VERSION}"
12
12
  }.freeze
13
13
 
@@ -29,7 +29,8 @@ module ActiveGenie
29
29
  def get(endpoint, params: {}, headers: {})
30
30
  uri = build_uri(endpoint, params)
31
31
  request = Net::HTTP::Get.new(uri)
32
- execute_request(uri, request, headers)
32
+ apply_headers(request, headers)
33
+ execute_request(uri, request)
33
34
  end
34
35
 
35
36
  # Make a POST request to the specified endpoint
@@ -42,7 +43,8 @@ module ActiveGenie
42
43
  uri = build_uri(endpoint, params)
43
44
  request = Net::HTTP::Post.new(uri)
44
45
  request.body = payload.to_json
45
- execute_request(uri, request, headers)
46
+ apply_headers(request, headers)
47
+ execute_request(uri, request)
46
48
  end
47
49
 
48
50
  # Make a PUT request to the specified endpoint
@@ -55,7 +57,8 @@ module ActiveGenie
55
57
  uri = build_uri(endpoint)
56
58
  request = Net::HTTP::Put.new(uri)
57
59
  request.body = payload.to_json
58
- execute_request(uri, request, headers)
60
+ apply_headers(request, headers)
61
+ execute_request(uri, request)
59
62
  end
60
63
 
61
64
  # Make a DELETE request to the specified endpoint
@@ -67,58 +70,40 @@ module ActiveGenie
67
70
  def delete(endpoint, headers: {}, params: {})
68
71
  uri = build_uri(endpoint, params)
69
72
  request = Net::HTTP::Delete.new(uri)
70
- execute_request(uri, request, headers)
73
+ apply_headers(request, headers)
74
+ execute_request(uri, request)
71
75
  end
72
76
 
73
77
  protected
74
78
 
75
- # Execute a request with retry logic and proper error handling
76
- #
77
- # @param uri [URI] The URI for the request
78
- # @param request [Net::HTTP::Request] The request object
79
- # @param headers [Hash] Additional headers to include
80
- # @return [Hash, nil] The parsed JSON response or nil if empty
81
- def execute_request(uri, request, headers)
79
+ def execute_request(uri, request)
82
80
  start_time = Time.now
83
81
 
84
- # Apply headers
85
- apply_headers(request, headers)
82
+ response = http_request(request, uri)
86
83
 
87
- http = create_http_client(uri)
84
+ raise ClientError, "Unexpected response: #{response.code} - #{response.body}" unless response.is_a?(Net::HTTPSuccess)
88
85
 
89
- response = http.request(request)
86
+ parsed_response = parse_response(response)
90
87
 
91
- case response
92
- when Net::HTTPSuccess
93
- parsed_response = parse_response(response)
88
+ log_request_details(uri:, request:, response:, start_time:, parsed_response:)
94
89
 
95
- log_request_details(
96
- uri: uri,
97
- method: request.method,
98
- status: response.code,
99
- duration: Time.now - start_time,
100
- response: parsed_response
101
- )
102
-
103
- parsed_response
104
- when Net::HTTPTooManyRequests, Net::HTTPClientError, Net::HTTPServerError
105
- raise ClientError, "HTTP Error: #{response.code} - #{response.body}"
106
- else
107
- raise ClientError, "Unexpected response: #{response.code} - #{response.body}"
108
- end
90
+ parsed_response
109
91
  end
110
92
 
111
93
  # Create and configure an HTTP client
112
94
  #
113
95
  # @param uri [URI] The URI for the request
114
96
  # @return [Net::HTTP] Configured HTTP client
115
- def create_http_client(uri)
97
+ def http_request(request, uri)
116
98
  http = Net::HTTP.new(uri.host, uri.port)
117
99
  http.use_ssl = (uri.scheme == 'https')
118
100
  http.verify_mode = OpenSSL::SSL::VERIFY_PEER
119
101
  http.read_timeout = @config.llm.read_timeout || DEFAULT_TIMEOUT
120
102
  http.open_timeout = @config.llm.open_timeout || DEFAULT_OPEN_TIMEOUT
121
- http
103
+
104
+ retry_with_backoff do
105
+ http.request(request)
106
+ end
122
107
  end
123
108
 
124
109
  # Apply headers to the request
@@ -165,51 +150,53 @@ module ActiveGenie
165
150
  # Log request details if logging is enabled
166
151
  #
167
152
  # @param details [Hash] Request and response details
168
- def log_request_details(details)
153
+ def log_request_details(uri:, request:, response:, start_time:, parsed_response:)
169
154
  ActiveGenie::Logger.call(
170
155
  {
171
156
  code: :http_request,
172
- uri: details[:uri].to_s,
173
- method: details[:method],
174
- status: details[:status],
175
- duration: details[:duration],
176
- response_size: details[:response].to_s.bytesize
157
+ uri: uri.to_s,
158
+ method: request.method,
159
+ status: response.code,
160
+ duration: Time.now - start_time,
161
+ response_size: parsed_response.to_s.bytesize
177
162
  }
178
163
  )
179
164
  end
180
165
 
181
- # Retry a block with exponential backoff
182
- #
183
- # @yield The block to retry
184
- # @return [Object] The result of the block
166
+ # FIXME: split the retry_with_backoff method into a separate class
167
+ # rubocop:disable Metrics/MethodLength
185
168
  def retry_with_backoff
186
- max_retries = @config.llm.max_retries || DEFAULT_MAX_RETRIES
187
- retry_delay = @config.llm.retry_delay || DEFAULT_RETRY_DELAY
188
-
189
169
  retries = 0
190
170
 
191
171
  begin
192
172
  yield
193
- rescue Net::HTTPError => e
194
- raise unless retries < max_retries
173
+ rescue Net::OpenTimeout, Net::ReadTimeout, ClientError => e
174
+ raise if retries > max_retries
195
175
 
196
176
  sleep_time = retry_delay * (2**retries)
197
177
  retries += 1
198
178
 
199
179
  ActiveGenie::Logger.call(
200
- {
201
- code: :retry_attempt,
202
- attempt: retries,
203
- max_retries: max_retries,
204
- delay: sleep_time,
205
- error: e.message
206
- }
180
+ code: :retry_attempt,
181
+ attempt: retries,
182
+ max_retries: max_retries,
183
+ delay: sleep_time,
184
+ error: e.message
207
185
  )
208
186
 
209
187
  sleep(sleep_time)
210
188
  retry
211
189
  end
212
190
  end
191
+ # rubocop:enable Metrics/MethodLength
192
+
193
+ def max_retries
194
+ @config.llm.max_retries || DEFAULT_MAX_RETRIES
195
+ end
196
+
197
+ def retry_delay
198
+ @config.llm.retry_delay || DEFAULT_RETRY_DELAY
199
+ end
213
200
  end
214
201
  end
215
202
  end