active_genie 0.0.12 → 0.0.18
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 +65 -22
- data/VERSION +1 -1
- data/lib/active_genie/battle/README.md +7 -7
- data/lib/active_genie/battle/basic.rb +47 -31
- data/lib/active_genie/battle.rb +4 -0
- data/lib/active_genie/clients/anthropic_client.rb +110 -0
- data/lib/active_genie/clients/google_client.rb +158 -0
- data/lib/active_genie/clients/helpers/retry.rb +29 -0
- data/lib/active_genie/clients/openai_client.rb +61 -83
- data/lib/active_genie/clients/unified_client.rb +4 -4
- data/lib/active_genie/concerns/loggable.rb +44 -0
- data/lib/active_genie/configuration/log_config.rb +1 -1
- data/lib/active_genie/configuration/providers/anthropic_config.rb +54 -0
- data/lib/active_genie/configuration/providers/base_config.rb +85 -0
- data/lib/active_genie/configuration/providers/deepseek_config.rb +54 -0
- data/lib/active_genie/configuration/providers/google_config.rb +56 -0
- data/lib/active_genie/configuration/providers/openai_config.rb +54 -0
- data/lib/active_genie/configuration/providers_config.rb +7 -4
- data/lib/active_genie/configuration/runtime_config.rb +35 -0
- data/lib/active_genie/configuration.rb +18 -4
- data/lib/active_genie/data_extractor/basic.rb +15 -2
- data/lib/active_genie/data_extractor.rb +4 -0
- data/lib/active_genie/logger.rb +40 -21
- data/lib/active_genie/ranking/elo_round.rb +71 -50
- data/lib/active_genie/ranking/free_for_all.rb +31 -14
- data/lib/active_genie/ranking/player.rb +11 -16
- data/lib/active_genie/ranking/players_collection.rb +4 -4
- data/lib/active_genie/ranking/ranking.rb +74 -19
- data/lib/active_genie/ranking/ranking_scoring.rb +3 -3
- data/lib/active_genie/scoring/basic.rb +44 -25
- data/lib/active_genie/scoring.rb +3 -0
- data/lib/tasks/benchmark.rake +27 -0
- metadata +91 -70
- data/lib/active_genie/configuration/openai_config.rb +0 -56
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 81b6b3ccf366bdeb07e1dfc1942749e4a1d48da74735c48a95cb9d53afb61b33
|
4
|
+
data.tar.gz: df2d1ee4ac8bbcfa031b261bedd228ed5c3a8772c055e312360d6a4ad2f699fa
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d3a2ff8342483f8b475f0e60d91fa839ba57b0853e82e637ba4e761fd9ae749917e5ae134803200bfe3fd4bab658b297c1888c88d6c433d4f2c0a0694face6aa
|
7
|
+
data.tar.gz: a4b37bd1e6ba7a3a4b6edea20bd37f7e2dd11142182b65b8e62707e993d9c42d44486c2247e342ab8a81b01778456dd5bf15616261e01d6c0dd556757646da18
|
data/README.md
CHANGED
@@ -2,16 +2,10 @@
|
|
2
2
|
> The lodash for GenAI, stop reinventing the wheel
|
3
3
|
|
4
4
|
[](https://badge.fury.io/rb/active_genie)
|
5
|
-
[](https://github.com/roriz/active_genie/actions/workflows/benchmark.yml)
|
6
6
|
|
7
|
-
ActiveGenie is a Ruby gem that provides
|
8
|
-
|
9
|
-
## Features
|
10
|
-
|
11
|
-
- 🎯 **Data Extraction**: Extract structured data from unstructured text with type validation
|
12
|
-
- 📊 **Data Scoring**: Multi-reviewer evaluation system
|
13
|
-
- ⚔️ **Data Battle**: Battle between two data like a political debate
|
14
|
-
- 💭 **Data Ranking**: Consistent rank data using scoring + elo ranking + battles
|
7
|
+
ActiveGenie is a Ruby gem that provides valuable solutions powered by Generative AI (GenAI) models. Just like Lodash or ActiveStorage, ActiveGenie brings a set of Modules reach real value fast and reliable.
|
8
|
+
ActiveGenie is backed by a custom benchmarking system that ensures consistent quality and performance across different models and providers in every release.
|
15
9
|
|
16
10
|
## Installation
|
17
11
|
|
@@ -41,6 +35,7 @@ end
|
|
41
35
|
## Quick Start
|
42
36
|
|
43
37
|
### Data Extractor
|
38
|
+
|
44
39
|
Extract structured data from text using AI-powered analysis, handling informal language and complex expressions.
|
45
40
|
|
46
41
|
```ruby
|
@@ -55,13 +50,17 @@ schema = {
|
|
55
50
|
minimum: 0
|
56
51
|
},
|
57
52
|
size: {
|
58
|
-
type: '
|
53
|
+
type: 'number',
|
59
54
|
minimum: 35,
|
60
55
|
maximum: 46
|
61
56
|
}
|
62
57
|
}
|
63
58
|
|
64
|
-
result = ActiveGenie::DataExtractor.call(
|
59
|
+
result = ActiveGenie::DataExtractor.call(
|
60
|
+
text,
|
61
|
+
schema,
|
62
|
+
config: { provider: :openai, model: 'gpt-4o-mini' } # optional
|
63
|
+
)
|
65
64
|
# => {
|
66
65
|
# brand: "Nike",
|
67
66
|
# brand_explanation: "Brand name found at start of text",
|
@@ -72,6 +71,8 @@ result = ActiveGenie::DataExtractor.call(text, schema)
|
|
72
71
|
# }
|
73
72
|
```
|
74
73
|
|
74
|
+
*Recommended model*: `gpt-4o-mini`
|
75
|
+
|
75
76
|
Features:
|
76
77
|
- Structured data extraction with type validation
|
77
78
|
- Schema-based extraction with custom constraints
|
@@ -80,14 +81,18 @@ Features:
|
|
80
81
|
|
81
82
|
See the [Data Extractor README](lib/active_genie/data_extractor/README.md) for informal text processing, advanced schemas, and detailed interface documentation.
|
82
83
|
|
83
|
-
###
|
84
|
+
### Scoring
|
84
85
|
Text evaluation system that provides detailed scoring and feedback using multiple expert reviewers. Get balanced scoring through AI-powered expert reviewers that automatically adapt to your content.
|
85
86
|
|
86
87
|
```ruby
|
87
88
|
text = "The code implements a binary search algorithm with O(log n) complexity"
|
88
89
|
criteria = "Evaluate technical accuracy and clarity"
|
89
90
|
|
90
|
-
result = ActiveGenie::Scoring.basic(
|
91
|
+
result = ActiveGenie::Scoring.basic(
|
92
|
+
text,
|
93
|
+
criteria,
|
94
|
+
config: { provider: :anthropic, model: 'claude-3-5-haiku-20241022' } # optional
|
95
|
+
)
|
91
96
|
# => {
|
92
97
|
# algorithm_expert_score: 95,
|
93
98
|
# algorithm_expert_reasoning: "Accurately describes binary search and its complexity",
|
@@ -97,6 +102,8 @@ result = ActiveGenie::Scoring.basic(text, criteria)
|
|
97
102
|
# }
|
98
103
|
```
|
99
104
|
|
105
|
+
*Recommended model*: `claude-3-5-haiku-20241022`
|
106
|
+
|
100
107
|
Features:
|
101
108
|
- Multi-reviewer evaluation with automatic expert selection
|
102
109
|
- Detailed feedback with scoring reasoning
|
@@ -105,26 +112,33 @@ Features:
|
|
105
112
|
|
106
113
|
See the [Scoring README](lib/active_genie/scoring/README.md) for advanced usage, custom reviewers, and detailed interface documentation.
|
107
114
|
|
108
|
-
###
|
115
|
+
### Battle
|
109
116
|
AI-powered battle evaluation system that determines winners between two players based on specified criteria.
|
110
117
|
|
111
118
|
```ruby
|
112
119
|
require 'active_genie'
|
113
120
|
|
114
|
-
|
115
|
-
|
121
|
+
player_1 = "Implementation uses dependency injection for better testability"
|
122
|
+
player_2 = "Code has high test coverage but tightly coupled components"
|
116
123
|
criteria = "Evaluate code quality and maintainability"
|
117
124
|
|
118
|
-
result = ActiveGenie::Battle.call(
|
125
|
+
result = ActiveGenie::Battle.call(
|
126
|
+
player_1,
|
127
|
+
player_2,
|
128
|
+
criteria,
|
129
|
+
config: { provider: :google, model: 'gemini-2.0-flash-lite' } # optional
|
130
|
+
)
|
119
131
|
# => {
|
120
132
|
# winner_player: "Implementation uses dependency injection for better testability",
|
121
|
-
# reasoning: "Player
|
122
|
-
# which allows for easier testing and component replacement. While Player
|
133
|
+
# reasoning: "Player 1 implementation demonstrates better maintainability through dependency injection,
|
134
|
+
# which allows for easier testing and component replacement. While Player 2 has good test coverage,
|
123
135
|
# the tight coupling makes the code harder to maintain and modify.",
|
124
136
|
# what_could_be_changed_to_avoid_draw: "Focus on specific architectural patterns and design principles"
|
125
137
|
# }
|
126
138
|
```
|
127
139
|
|
140
|
+
*Recommended model*: `gemini-2.0-flash-lite`
|
141
|
+
|
128
142
|
Features:
|
129
143
|
- Multi-reviewer evaluation with automatic expert selection
|
130
144
|
- Detailed feedback with scoring reasoning
|
@@ -133,23 +147,28 @@ Features:
|
|
133
147
|
|
134
148
|
See the [Battle README](lib/active_genie/battle/README.md) for advanced usage, custom reviewers, and detailed interface documentation.
|
135
149
|
|
136
|
-
###
|
150
|
+
### Ranking
|
137
151
|
The Ranking module provides competitive ranking through multi-stage evaluation:
|
138
152
|
|
139
|
-
|
140
153
|
```ruby
|
141
154
|
require 'active_genie'
|
142
155
|
|
143
156
|
players = ['REST API', 'GraphQL API', 'SOAP API', 'gRPC API', 'Websocket API']
|
144
157
|
criteria = "Best one to be used into a high changing environment"
|
145
158
|
|
146
|
-
result = ActiveGenie::Ranking.call(
|
159
|
+
result = ActiveGenie::Ranking.call(
|
160
|
+
players,
|
161
|
+
criteria,
|
162
|
+
config: { provider: :google, model: 'gemini-2.0-flash-lite' } # optional
|
163
|
+
)
|
147
164
|
# => {
|
148
165
|
# winner_player: "gRPC API",
|
149
166
|
# reasoning: "gRPC API is the best one to be used into a high changing environment",
|
150
167
|
# }
|
151
168
|
```
|
152
169
|
|
170
|
+
*Recommended model*: `gemini-2.0-flash-lite`
|
171
|
+
|
153
172
|
- **Multi-phase ranking system** combining expert scoring and ELO algorithms
|
154
173
|
- **Automatic elimination** of inconsistent performers using statistical analysis
|
155
174
|
- **Dynamic ranking adjustments** based on simulated pairwise battles, from bottom to top
|
@@ -157,10 +176,34 @@ result = ActiveGenie::Ranking.call(players, criteria)
|
|
157
176
|
See the [Ranking README](lib/active_genie/ranking/README.md) for implementation details, configuration, and advanced ranking strategies.
|
158
177
|
|
159
178
|
### Text Summarizer (Future)
|
179
|
+
### Categorizer (Future)
|
160
180
|
### Language detector (Future)
|
161
181
|
### Translator (Future)
|
162
182
|
### Sentiment analyzer (Future)
|
163
183
|
|
184
|
+
## Benchmarking 🧪
|
185
|
+
|
186
|
+
ActiveGenie includes a comprehensive benchmarking system to ensure consistent, high-quality outputs across different LLM models and providers.
|
187
|
+
|
188
|
+
```ruby
|
189
|
+
# Run all benchmarks
|
190
|
+
bundle exec rake active_genie:benchmark
|
191
|
+
|
192
|
+
# Run benchmarks for a specific module
|
193
|
+
bundle exec rake active_genie:benchmark[data_extractor]
|
194
|
+
```
|
195
|
+
|
196
|
+
### Latest Results
|
197
|
+
|
198
|
+
| Model | Overall Precision |
|
199
|
+
|-------|-------------------|
|
200
|
+
| claude-3-5-haiku-20241022 | 92.25% |
|
201
|
+
| gemini-2.0-flash-lite | 84.25% |
|
202
|
+
| gpt-4o-mini | 62.75% |
|
203
|
+
| deepseek-chat | 57.25% |
|
204
|
+
|
205
|
+
See the [Benchmark README](benchmark/README.md) for detailed results, methodology, and how to contribute to our test suite.
|
206
|
+
|
164
207
|
## Configuration
|
165
208
|
|
166
209
|
| Config | Description | Default |
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.0.
|
1
|
+
0.0.18
|
@@ -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
|
-
|
16
|
-
|
15
|
+
player_1 = "Implementation uses dependency injection for better testability"
|
16
|
+
player_2 = "Code has high test coverage but tightly coupled components"
|
17
17
|
criteria = "Evaluate code quality and maintainability"
|
18
18
|
|
19
|
-
result = ActiveGenie::Battle::Basic.call(
|
19
|
+
result = ActiveGenie::Battle::Basic.call(player_1, player_2, 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::Basic.call(player_a, player_b, criteria)
|
|
27
27
|
```
|
28
28
|
|
29
29
|
## Interface
|
30
|
-
### Basic.call(
|
31
|
-
- `
|
32
|
-
- `
|
30
|
+
### Basic.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
|
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
|
37
|
+
- `winner_player` [String, Hash] - The winning player's content (either player_1 or player_2)
|
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
|
@@ -18,17 +18,17 @@ module ActiveGenie::Battle
|
|
18
18
|
new(...).call
|
19
19
|
end
|
20
20
|
|
21
|
-
# @param
|
22
|
-
# @param
|
21
|
+
# @param player_1 [String] The content or submission from the first player
|
22
|
+
# @param player_2 [String] The content or submission from the second player
|
23
23
|
# @param criteria [String] The evaluation criteria or rules to assess against
|
24
|
-
# @param config [Hash] Additional configuration
|
24
|
+
# @param config [Hash] Additional configuration options that modify the battle evaluation behavior
|
25
25
|
# @return [Hash] The evaluation result containing the winner and reasoning
|
26
|
-
# @return [String] :winner The
|
26
|
+
# @return [String] :winner The winner, either player_1 or player_2
|
27
27
|
# @return [String] :reasoning Detailed explanation of why the winner was chosen
|
28
28
|
# @return [String] :what_could_be_changed_to_avoid_draw A suggestion on how to avoid a draw
|
29
|
-
def initialize(
|
30
|
-
@
|
31
|
-
@
|
29
|
+
def initialize(player_1, player_2, criteria, config: {})
|
30
|
+
@player_1 = player_1
|
31
|
+
@player_2 = player_2
|
32
32
|
@criteria = criteria
|
33
33
|
@config = ActiveGenie::Configuration.to_h(config)
|
34
34
|
end
|
@@ -37,8 +37,8 @@ module ActiveGenie::Battle
|
|
37
37
|
messages = [
|
38
38
|
{ role: 'system', content: PROMPT },
|
39
39
|
{ role: 'user', content: "criteria: #{@criteria}" },
|
40
|
-
{ role: 'user', content: "
|
41
|
-
{ role: 'user', content: "
|
40
|
+
{ role: 'user', content: "player_1: #{@player_1}" },
|
41
|
+
{ role: 'user', content: "player_2: #{@player_2}" },
|
42
42
|
]
|
43
43
|
|
44
44
|
response = ::ActiveGenie::Clients::UnifiedClient.function_calling(
|
@@ -48,6 +48,15 @@ module ActiveGenie::Battle
|
|
48
48
|
config: @config
|
49
49
|
)
|
50
50
|
|
51
|
+
ActiveGenie::Logger.debug({
|
52
|
+
code: :battle,
|
53
|
+
player_1: @player_1[0..30],
|
54
|
+
player_2: @player_2[0..30],
|
55
|
+
criteria: @criteria[0..30],
|
56
|
+
winner: response['impartial_judge_winner'],
|
57
|
+
reasoning: response['impartial_judge_winner_reasoning']
|
58
|
+
})
|
59
|
+
|
51
60
|
response_formatted(response)
|
52
61
|
end
|
53
62
|
|
@@ -56,23 +65,22 @@ module ActiveGenie::Battle
|
|
56
65
|
def response_formatted(response)
|
57
66
|
winner = response['impartial_judge_winner']
|
58
67
|
loser = case response['impartial_judge_winner']
|
59
|
-
when '
|
60
|
-
when '
|
61
|
-
else 'draw'
|
68
|
+
when 'player_1' then 'player_2'
|
69
|
+
when 'player_2' then 'player_1'
|
62
70
|
end
|
63
71
|
|
64
|
-
{ winner
|
72
|
+
{ 'winner' => winner, 'loser' => loser, 'reasoning' => response['impartial_judge_winner_reasoning'] }
|
65
73
|
end
|
66
74
|
|
67
75
|
PROMPT = <<~PROMPT
|
68
|
-
Based on two players,
|
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.
|
69
77
|
|
70
78
|
# Steps
|
71
|
-
1.
|
72
|
-
2.
|
73
|
-
3.
|
74
|
-
4.
|
75
|
-
5. The impartial judge chooses
|
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.
|
76
84
|
|
77
85
|
# Output Format
|
78
86
|
- The impartial judge chooses this player as the winner.
|
@@ -85,25 +93,25 @@ module ActiveGenie::Battle
|
|
85
93
|
|
86
94
|
FUNCTION = {
|
87
95
|
name: 'battle_evaluation',
|
88
|
-
description: 'Evaluate a battle between
|
96
|
+
description: 'Evaluate a battle between player_1 and player_2 using predefined criteria and identify the winner.',
|
89
97
|
schema: {
|
90
98
|
type: "object",
|
91
99
|
properties: {
|
92
|
-
|
100
|
+
player_1_sell_himself: {
|
93
101
|
type: 'string',
|
94
|
-
description: '
|
102
|
+
description: 'player_1 presents their strengths and how they meet the criteria. Max of 100 words.',
|
95
103
|
},
|
96
|
-
|
104
|
+
player_2_sell_himself: {
|
97
105
|
type: 'string',
|
98
|
-
description: '
|
106
|
+
description: 'player_2 presents their strengths and how they meet the criteria. Max of 100 words.',
|
99
107
|
},
|
100
|
-
|
108
|
+
player_1_arguments: {
|
101
109
|
type: 'string',
|
102
|
-
description: '
|
110
|
+
description: 'player_1 arguments for why they should be the winner compared to player_2. Max of 100 words.',
|
103
111
|
},
|
104
|
-
|
112
|
+
player_2_counter: {
|
105
113
|
type: 'string',
|
106
|
-
description: '
|
114
|
+
description: 'player_2 counter arguments for why they should be the winner compared to player_1. Max of 100 words.',
|
107
115
|
},
|
108
116
|
impartial_judge_winner_reasoning: {
|
109
117
|
type: 'string',
|
@@ -111,10 +119,18 @@ module ActiveGenie::Battle
|
|
111
119
|
},
|
112
120
|
impartial_judge_winner: {
|
113
121
|
type: 'string',
|
114
|
-
description: '
|
115
|
-
enum: ['
|
122
|
+
description: 'Who is the winner based on the impartial judge reasoning?',
|
123
|
+
enum: ['player_1', 'player_2']
|
116
124
|
},
|
117
|
-
}
|
125
|
+
},
|
126
|
+
required: [
|
127
|
+
'player_1_sell_himself',
|
128
|
+
'player_2_sell_himself',
|
129
|
+
'player_1_arguments',
|
130
|
+
'player_2_counter',
|
131
|
+
'impartial_judge_winner_reasoning',
|
132
|
+
'impartial_judge_winner'
|
133
|
+
]
|
118
134
|
}
|
119
135
|
}
|
120
136
|
end
|
data/lib/active_genie/battle.rb
CHANGED
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'net/http'
|
3
|
+
require 'uri'
|
4
|
+
require_relative './helpers/retry'
|
5
|
+
|
6
|
+
module ActiveGenie
|
7
|
+
module Clients
|
8
|
+
# Client for interacting with the Anthropic (Claude) API with json response
|
9
|
+
class AnthropicClient
|
10
|
+
class AnthropicError < StandardError; end
|
11
|
+
class RateLimitError < AnthropicError; end
|
12
|
+
|
13
|
+
def initialize(config)
|
14
|
+
@app_config = config
|
15
|
+
end
|
16
|
+
|
17
|
+
# Requests structured JSON output from the Anthropic Claude model based on a schema.
|
18
|
+
#
|
19
|
+
# @param messages [Array<Hash>] A list of messages representing the conversation history.
|
20
|
+
# Each hash should have :role ('user', 'assistant', or 'system') and :content (String).
|
21
|
+
# Claude uses 'user', 'assistant', and 'system' roles.
|
22
|
+
# @param function [Hash] A JSON schema definition describing the desired output format.
|
23
|
+
# @param model_tier [Symbol, nil] A symbolic representation of the model quality/size tier.
|
24
|
+
# @param config [Hash] Optional configuration overrides:
|
25
|
+
# - :api_key [String] Override the default API key.
|
26
|
+
# - :model [String] Override the model name directly.
|
27
|
+
# - :max_retries [Integer] Max retries for the request.
|
28
|
+
# - :retry_delay [Integer] Initial delay for retries.
|
29
|
+
# - :anthropic_version [String] Override the default Anthropic API version.
|
30
|
+
# @return [Hash, nil] The parsed JSON object matching the schema, or nil if parsing fails or content is empty.
|
31
|
+
def function_calling(messages, function, model_tier: nil, config: {})
|
32
|
+
model = config[:runtime][:model] || @app_config.tier_to_model(model_tier)
|
33
|
+
|
34
|
+
system_message = messages.find { |m| m[:role] == 'system' }&.dig(:content) || ''
|
35
|
+
user_messages = messages.select { |m| m[:role] == 'user' || m[:role] == 'assistant' }
|
36
|
+
.map { |m| { role: m[:role], content: m[:content] } }
|
37
|
+
|
38
|
+
anthropic_function = function
|
39
|
+
anthropic_function[:input_schema] = function[:schema]
|
40
|
+
anthropic_function.delete(:schema)
|
41
|
+
|
42
|
+
payload = {
|
43
|
+
model:,
|
44
|
+
system: system_message,
|
45
|
+
messages: user_messages,
|
46
|
+
tools: [anthropic_function],
|
47
|
+
tool_choice: { name: anthropic_function[:name], type: 'tool' },
|
48
|
+
max_tokens: config[:runtime][:max_tokens],
|
49
|
+
temperature: config[:runtime][:temperature] || 0,
|
50
|
+
}
|
51
|
+
|
52
|
+
api_key = config[:runtime][:api_key] || @app_config.api_key
|
53
|
+
headers = DEFAULT_HEADERS.merge(
|
54
|
+
'x-api-key': api_key,
|
55
|
+
'anthropic-version': config[:anthropic_version] || ANTHROPIC_VERSION
|
56
|
+
).compact
|
57
|
+
|
58
|
+
retry_with_backoff(config:) do
|
59
|
+
response = request(payload, headers, config:)
|
60
|
+
content = response.dig('content', 0, 'input')
|
61
|
+
|
62
|
+
ActiveGenie::Logger.trace({code: :function_calling, payload:, parsed_response: content})
|
63
|
+
|
64
|
+
content
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
DEFAULT_HEADERS = {
|
71
|
+
'Content-Type': 'application/json',
|
72
|
+
}
|
73
|
+
ANTHROPIC_VERSION = '2023-06-01'
|
74
|
+
|
75
|
+
def request(payload, headers, config:)
|
76
|
+
start_time = Time.now
|
77
|
+
|
78
|
+
retry_with_backoff(config:) do
|
79
|
+
response = Net::HTTP.post(
|
80
|
+
URI("#{@app_config.api_url}/v1/messages"),
|
81
|
+
payload.to_json,
|
82
|
+
headers
|
83
|
+
)
|
84
|
+
|
85
|
+
if response.is_a?(Net::HTTPTooManyRequests)
|
86
|
+
raise RateLimitError, "Anthropic API rate limit exceeded: #{response.body}"
|
87
|
+
end
|
88
|
+
|
89
|
+
raise AnthropicError, response.body unless response.is_a?(Net::HTTPSuccess)
|
90
|
+
|
91
|
+
return nil if response.body.empty?
|
92
|
+
|
93
|
+
parsed_body = JSON.parse(response.body)
|
94
|
+
|
95
|
+
ActiveGenie::Logger.trace({
|
96
|
+
code: :llm_usage,
|
97
|
+
input_tokens: parsed_body.dig('usage', 'input_tokens'),
|
98
|
+
output_tokens: parsed_body.dig('usage', 'output_tokens'),
|
99
|
+
total_tokens: parsed_body.dig('usage', 'input_tokens') + parsed_body.dig('usage', 'output_tokens'),
|
100
|
+
model: payload[:model],
|
101
|
+
duration: Time.now - start_time,
|
102
|
+
usage: parsed_body.dig('usage')
|
103
|
+
})
|
104
|
+
|
105
|
+
parsed_body
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'net/http'
|
3
|
+
require 'uri'
|
4
|
+
require_relative './helpers/retry'
|
5
|
+
|
6
|
+
module ActiveGenie
|
7
|
+
module Clients
|
8
|
+
# Client for interacting with the Google Generative Language API.
|
9
|
+
class GoogleClient
|
10
|
+
class GoogleError < StandardError; end
|
11
|
+
class RateLimitError < GoogleError; end
|
12
|
+
|
13
|
+
API_VERSION_PATH = '/v1beta/models'.freeze
|
14
|
+
DEFAULT_HEADERS = {
|
15
|
+
'Content-Type': 'application/json',
|
16
|
+
}.freeze
|
17
|
+
|
18
|
+
def initialize(config)
|
19
|
+
@app_config = config
|
20
|
+
end
|
21
|
+
|
22
|
+
# Requests structured JSON output from the Google Generative Language model based on a schema.
|
23
|
+
#
|
24
|
+
# @param messages [Array<Hash>] A list of messages representing the conversation history.
|
25
|
+
# Each hash should have :role ('user' or 'model') and :content (String).
|
26
|
+
# Google Generative Language uses 'user' and 'model' roles.
|
27
|
+
# @param function [Hash] A JSON schema definition describing the desired output format.
|
28
|
+
# @param model_tier [Symbol, nil] A symbolic representation of the model quality/size tier.
|
29
|
+
# @param config [Hash] Optional configuration overrides:
|
30
|
+
# - :api_key [String] Override the default API key.
|
31
|
+
# - :model [String] Override the model name directly.
|
32
|
+
# - :max_retries [Integer] Max retries for the request.
|
33
|
+
# - :retry_delay [Integer] Initial delay for retries.
|
34
|
+
# @return [Hash, nil] The parsed JSON object matching the schema, or nil if parsing fails or content is empty.
|
35
|
+
def function_calling(messages, function, model_tier: nil, config: {})
|
36
|
+
model = config[:runtime][:model] || @app_config.tier_to_model(model_tier)
|
37
|
+
api_key = config[:runtime][:api_key] || @app_config.api_key
|
38
|
+
|
39
|
+
contents = convert_messages_to_contents(messages, function)
|
40
|
+
contents << output_as_json_schema(function)
|
41
|
+
|
42
|
+
payload = {
|
43
|
+
contents: contents,
|
44
|
+
generationConfig: {
|
45
|
+
response_mime_type: "application/json",
|
46
|
+
temperature: 0.1
|
47
|
+
}
|
48
|
+
}
|
49
|
+
|
50
|
+
url = URI("#{@app_config.api_url}#{API_VERSION_PATH}/#{model}:generateContent?key=#{api_key}")
|
51
|
+
|
52
|
+
retry_with_backoff(config:) do
|
53
|
+
response = request(url, payload, model, config:)
|
54
|
+
|
55
|
+
json_string = response&.dig('candidates', 0, 'content', 'parts', 0, 'text')
|
56
|
+
|
57
|
+
return nil if json_string.nil? || json_string.empty?
|
58
|
+
|
59
|
+
parsed_response = JSON.parse(json_string)
|
60
|
+
|
61
|
+
ActiveGenie::Logger.trace({ code: :function_calling, payload:, parsed_response: })
|
62
|
+
|
63
|
+
normalize_function_output(parsed_response)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def normalize_function_output(output)
|
70
|
+
output = if output.is_a?(Array)
|
71
|
+
output.dig(0, 'properties') || output.dig(0)
|
72
|
+
else
|
73
|
+
output
|
74
|
+
end
|
75
|
+
|
76
|
+
output.dig('input_schema', 'properties') || output
|
77
|
+
end
|
78
|
+
|
79
|
+
def request(url, payload, model, config:)
|
80
|
+
start_time = Time.now
|
81
|
+
|
82
|
+
retry_with_backoff(config:) do
|
83
|
+
response = Net::HTTP.post(url, payload.to_json, DEFAULT_HEADERS)
|
84
|
+
|
85
|
+
case response
|
86
|
+
when Net::HTTPSuccess
|
87
|
+
return nil if response.body.nil? || response.body.empty?
|
88
|
+
|
89
|
+
parsed_body = JSON.parse(response.body)
|
90
|
+
|
91
|
+
usage_metadata = parsed_body['usageMetadata'] || {}
|
92
|
+
prompt_tokens = usage_metadata['promptTokenCount'] || 0
|
93
|
+
candidates_tokens = usage_metadata['candidatesTokenCount'] || 0
|
94
|
+
total_tokens = usage_metadata['totalTokenCount'] || (prompt_tokens + candidates_tokens)
|
95
|
+
|
96
|
+
ActiveGenie::Logger.trace({
|
97
|
+
code: :llm_usage,
|
98
|
+
input_tokens: prompt_tokens,
|
99
|
+
output_tokens: candidates_tokens,
|
100
|
+
total_tokens: total_tokens,
|
101
|
+
model: model,
|
102
|
+
duration: Time.now - start_time,
|
103
|
+
usage: usage_metadata # Log the whole usage block
|
104
|
+
})
|
105
|
+
|
106
|
+
parsed_body
|
107
|
+
|
108
|
+
when Net::HTTPTooManyRequests
|
109
|
+
# Rate Limit Error
|
110
|
+
raise RateLimitError, "Google API rate limit exceeded (HTTP 429): #{response.body}"
|
111
|
+
|
112
|
+
else
|
113
|
+
# Other Errors
|
114
|
+
raise GoogleError, "Google API error (HTTP #{response.code}): #{response.body}"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
rescue JSON::ParserError => e
|
118
|
+
raise GoogleError, "Failed to parse Google API response body: #{e.message} - Body: #{response&.body}"
|
119
|
+
end
|
120
|
+
|
121
|
+
ROLE_TO_GOOGLE_ROLE = {
|
122
|
+
user: 'user',
|
123
|
+
assistant: 'model',
|
124
|
+
}.freeze
|
125
|
+
|
126
|
+
# Converts standard message format to Google's 'contents' format
|
127
|
+
# and injects JSON schema instructions.
|
128
|
+
# @param messages [Array<Hash>] Array of { role: 'user'/'assistant'/'system', content: '...' }
|
129
|
+
# @param function_schema [Hash] The JSON schema for the desired output.
|
130
|
+
# @return [Array<Hash>] Array formatted for Google's 'contents' field.
|
131
|
+
def convert_messages_to_contents(messages, function_schema)
|
132
|
+
messages.map do |message|
|
133
|
+
{
|
134
|
+
role: ROLE_TO_GOOGLE_ROLE[message[:role].to_sym] || 'user',
|
135
|
+
parts: [{ text: message[:content] }]
|
136
|
+
}
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def output_as_json_schema(function_schema)
|
141
|
+
json_instruction = <<~PROMPT
|
142
|
+
Generate a JSON object that strictly adheres to the following JSON schema:
|
143
|
+
|
144
|
+
```json
|
145
|
+
#{JSON.pretty_generate(function_schema)}
|
146
|
+
```
|
147
|
+
|
148
|
+
IMPORTANT: Only output the raw JSON object. Do not include any other text, explanations, or markdown formatting like ```json ... ``` wrappers around the final output.
|
149
|
+
PROMPT
|
150
|
+
|
151
|
+
{
|
152
|
+
role: 'user',
|
153
|
+
parts: [{ text: json_instruction }]
|
154
|
+
}
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|