active_genie 0.0.3 → 0.0.10
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 +146 -59
- data/VERSION +1 -1
- data/lib/active_genie/battle/README.md +39 -0
- data/lib/active_genie/battle/basic.rb +130 -0
- data/lib/active_genie/battle.rb +13 -0
- data/lib/active_genie/clients/openai_client.rb +77 -0
- data/lib/active_genie/clients/unified_client.rb +19 -0
- data/lib/active_genie/configuration/log_config.rb +14 -0
- data/lib/active_genie/configuration/openai_config.rb +56 -0
- data/lib/active_genie/configuration/providers_config.rb +37 -0
- data/lib/active_genie/configuration.rb +18 -22
- data/lib/active_genie/data_extractor/README.md +4 -4
- data/lib/active_genie/data_extractor/basic.rb +19 -9
- data/lib/active_genie/data_extractor/from_informal.rb +18 -7
- data/lib/active_genie/data_extractor.rb +5 -5
- data/lib/active_genie/league/README.md +43 -0
- data/lib/active_genie/league/elo_ranking.rb +121 -0
- data/lib/active_genie/league/free_for_all.rb +62 -0
- data/lib/active_genie/league/league.rb +120 -0
- data/lib/active_genie/league/player.rb +59 -0
- data/lib/active_genie/league/players_collection.rb +68 -0
- data/lib/active_genie/league.rb +12 -0
- data/lib/active_genie/logger.rb +45 -0
- data/lib/active_genie/scoring/README.md +4 -8
- data/lib/active_genie/scoring/basic.rb +24 -14
- data/lib/active_genie/scoring/recommended_reviews.rb +7 -9
- data/lib/active_genie/scoring.rb +5 -5
- data/lib/active_genie.rb +14 -11
- data/lib/tasks/install.rake +3 -3
- data/lib/tasks/templates/active_genie.rb +17 -0
- metadata +119 -14
- data/lib/active_genie/clients/openai.rb +0 -59
- data/lib/active_genie/clients/router.rb +0 -41
- data/lib/tasks/templates/active_ai.yml +0 -7
@@ -1,32 +1,28 @@
|
|
1
|
-
|
1
|
+
require_relative 'configuration/providers_config'
|
2
|
+
require_relative 'configuration/openai_config'
|
3
|
+
require_relative 'configuration/log_config'
|
2
4
|
|
3
5
|
module ActiveGenie
|
4
|
-
|
5
|
-
|
6
|
+
module Configuration
|
7
|
+
module_function
|
6
8
|
|
7
|
-
def
|
8
|
-
@
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
return @values if @values
|
13
|
-
|
14
|
-
@values = load_values.transform_keys(&:to_sym)
|
15
|
-
@values.each do |key, _value|
|
16
|
-
@values[key][:model] = key
|
17
|
-
@values[key] = @values[key].transform_keys(&:to_sym)
|
9
|
+
def providers
|
10
|
+
@providers ||= begin
|
11
|
+
p = ProvidersConfig.new
|
12
|
+
p.register(:openai, ActiveGenie::Configuration::OpenaiConfig)
|
13
|
+
p
|
18
14
|
end
|
19
15
|
end
|
20
16
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
return {} unless File.exist?(@path_to_config)
|
17
|
+
def log
|
18
|
+
@log ||= LogConfig.new
|
19
|
+
end
|
25
20
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
21
|
+
def to_h(configs = {})
|
22
|
+
{
|
23
|
+
providers: providers.to_h(configs.dig(:providers) || {}),
|
24
|
+
log: log.to_h(configs.dig(:log) || {})
|
25
|
+
}
|
30
26
|
end
|
31
27
|
end
|
32
28
|
end
|
@@ -95,7 +95,7 @@ result = ActiveGenie::DataExtractor.from_informal(text, schema)
|
|
95
95
|
|
96
96
|
## Interface
|
97
97
|
|
98
|
-
### `.call(text, data_to_extract,
|
98
|
+
### `.call(text, data_to_extract, config = {})`
|
99
99
|
Extracts structured data from text based on a predefined schema.
|
100
100
|
|
101
101
|
#### Parameters
|
@@ -103,9 +103,9 @@ Extracts structured data from text based on a predefined schema.
|
|
103
103
|
| --- | --- | --- | --- | --- |
|
104
104
|
| `text` | `String` | The text to analyze and extract data from | Yes | "John Doe is 25 years old" |
|
105
105
|
| `data_to_extract` | `Hash` | Schema defining the data structure to extract | Yes | `{ name: { type: 'string' } }` |
|
106
|
-
| `
|
106
|
+
| `config` | `Hash` | Additional extraction configuration | No | `{ model: "gpt-4" }` |
|
107
107
|
|
108
|
-
####
|
108
|
+
#### config
|
109
109
|
| Name | Type | Description |
|
110
110
|
| --- | --- | --- |
|
111
111
|
| `model` | `String` | The model to use for the extraction |
|
@@ -117,7 +117,7 @@ Extracts structured data from text based on a predefined schema.
|
|
117
117
|
- Explanation field for each extracted value
|
118
118
|
- Additional analysis fields when using `from_informal`
|
119
119
|
|
120
|
-
### `.from_informal(text, data_to_extract,
|
120
|
+
### `.from_informal(text, data_to_extract, config = {})`
|
121
121
|
Extends basic extraction with rhetorical analysis, particularly for litotes.
|
122
122
|
|
123
123
|
#### Additional Return Fields
|
@@ -1,9 +1,10 @@
|
|
1
|
-
|
1
|
+
|
2
|
+
require_relative '../clients/unified_client'
|
2
3
|
|
3
4
|
module ActiveGenie::DataExtractor
|
4
5
|
class Basic
|
5
|
-
def self.call(text, data_to_extract,
|
6
|
-
new(text, data_to_extract,
|
6
|
+
def self.call(text, data_to_extract, config: {})
|
7
|
+
new(text, data_to_extract, config:).call
|
7
8
|
end
|
8
9
|
|
9
10
|
# Extracts structured data from text based on a predefined schema.
|
@@ -11,9 +12,7 @@ module ActiveGenie::DataExtractor
|
|
11
12
|
# @param text [String] The input text to analyze and extract data from
|
12
13
|
# @param data_to_extract [Hash] Schema defining the data structure to extract.
|
13
14
|
# Each key in the hash represents a field to extract, and its value defines the expected type and constraints.
|
14
|
-
# @param
|
15
|
-
# @option options [String] :model The model to use for the extraction
|
16
|
-
# @option options [String] :api_key The API key to use for the extraction
|
15
|
+
# @param config [Hash] Additional config for the extraction process
|
17
16
|
#
|
18
17
|
# @return [Hash] The extracted data matching the schema structure. Each field will include
|
19
18
|
# both the extracted value and an explanation of how it was derived.
|
@@ -27,10 +26,10 @@ module ActiveGenie::DataExtractor
|
|
27
26
|
# DataExtractor.call(text, schema)
|
28
27
|
# # => { name: "John Doe", name_explanation: "Found directly in text",
|
29
28
|
# # age: 25, age_explanation: "Explicitly stated as 25 years old" }
|
30
|
-
def initialize(text, data_to_extract,
|
29
|
+
def initialize(text, data_to_extract, config: {})
|
31
30
|
@text = text
|
32
31
|
@data_to_extract = data_to_extract
|
33
|
-
@
|
32
|
+
@config = ActiveGenie::Configuration.to_h(config)
|
34
33
|
end
|
35
34
|
|
36
35
|
def call
|
@@ -47,7 +46,7 @@ module ActiveGenie::DataExtractor
|
|
47
46
|
}
|
48
47
|
}
|
49
48
|
|
50
|
-
::ActiveGenie::Clients::
|
49
|
+
::ActiveGenie::Clients::UnifiedClient.function_calling(messages, function, config:)
|
51
50
|
end
|
52
51
|
|
53
52
|
private
|
@@ -84,5 +83,16 @@ module ActiveGenie::DataExtractor
|
|
84
83
|
|
85
84
|
with_explaination
|
86
85
|
end
|
86
|
+
|
87
|
+
def config
|
88
|
+
{
|
89
|
+
all_providers: { model_tier: 'lower_tier' },
|
90
|
+
log: {
|
91
|
+
**(@config.dig(:log) || {}),
|
92
|
+
trace: self.class.name,
|
93
|
+
},
|
94
|
+
**@config
|
95
|
+
}
|
96
|
+
end
|
87
97
|
end
|
88
98
|
end
|
@@ -1,7 +1,7 @@
|
|
1
1
|
module ActiveGenie::DataExtractor
|
2
2
|
class FromInformal
|
3
|
-
def self.call(text, data_to_extract,
|
4
|
-
new(text, data_to_extract,
|
3
|
+
def self.call(text, data_to_extract, config: {})
|
4
|
+
new(text, data_to_extract, config:).call()
|
5
5
|
end
|
6
6
|
|
7
7
|
# Extracts data from informal text while also detecting litotes and their meanings.
|
@@ -9,7 +9,7 @@ module ActiveGenie::DataExtractor
|
|
9
9
|
#
|
10
10
|
# @param text [String] The informal text to analyze
|
11
11
|
# @param data_to_extract [Hash] Schema defining the data structure to extract
|
12
|
-
# @param
|
12
|
+
# @param config [Hash] Additional config for the extraction process
|
13
13
|
#
|
14
14
|
# @return [Hash] The extracted data including litote analysis. In addition to the
|
15
15
|
# schema-defined fields, includes:
|
@@ -23,17 +23,17 @@ module ActiveGenie::DataExtractor
|
|
23
23
|
# # => { mood: "positive", mood_explanation: "Speaker views weather favorably",
|
24
24
|
# # message_litote: true,
|
25
25
|
# # litote_rephrased: "The weather is good today" }
|
26
|
-
def initialize(text, data_to_extract,
|
26
|
+
def initialize(text, data_to_extract, config: {})
|
27
27
|
@text = text
|
28
28
|
@data_to_extract = data_to_extract
|
29
|
-
@
|
29
|
+
@config = ActiveGenie::Configuration.to_h(config)
|
30
30
|
end
|
31
31
|
|
32
32
|
def call
|
33
|
-
response = Basic.call(@text, data_to_extract_with_litote,
|
33
|
+
response = Basic.call(@text, data_to_extract_with_litote, config:)
|
34
34
|
|
35
35
|
if response['message_litote']
|
36
|
-
response = Basic.call(response['litote_rephrased'], @data_to_extract,
|
36
|
+
response = Basic.call(response['litote_rephrased'], @data_to_extract, config:)
|
37
37
|
end
|
38
38
|
|
39
39
|
response
|
@@ -54,5 +54,16 @@ module ActiveGenie::DataExtractor
|
|
54
54
|
}
|
55
55
|
}
|
56
56
|
end
|
57
|
+
|
58
|
+
def config
|
59
|
+
{
|
60
|
+
all_providers: { model_tier: 'lower_tier' },
|
61
|
+
**@config,
|
62
|
+
log: {
|
63
|
+
**@config.dig(:log),
|
64
|
+
trace: self.class.name,
|
65
|
+
},
|
66
|
+
}
|
67
|
+
end
|
57
68
|
end
|
58
69
|
end
|
@@ -2,16 +2,16 @@ require_relative 'data_extractor/basic'
|
|
2
2
|
require_relative 'data_extractor/from_informal'
|
3
3
|
|
4
4
|
module ActiveGenie
|
5
|
-
#
|
5
|
+
# See the [DataExtractor README](lib/active_genie/data_extractor/README.md) for more information.
|
6
6
|
module DataExtractor
|
7
7
|
module_function
|
8
8
|
|
9
|
-
def basic(
|
10
|
-
Basic.call(
|
9
|
+
def basic(...)
|
10
|
+
Basic.call(...)
|
11
11
|
end
|
12
12
|
|
13
|
-
def from_informal(
|
14
|
-
FromInformal.call(
|
13
|
+
def from_informal(...)
|
14
|
+
FromInformal.call(...)
|
15
15
|
end
|
16
16
|
end
|
17
17
|
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# league
|
2
|
+
|
3
|
+
The `ActiveGenie::League` module organizes players based on scores derived from textual evaluations and then ranks them using a multi-stage process. It leverages the scoring system from the `ActiveGenie::Scoring` module to assign initial scores, and then applies advanced ranking methods to produce a competitive league.
|
4
|
+
|
5
|
+
## Overview
|
6
|
+
|
7
|
+
The league system performs the following steps:
|
8
|
+
|
9
|
+
1. **Initial Scoring**: Each player’s textual content is evaluated using `ActiveGenie::Scoring`. This produces a `score` based on multiple expert reviews.
|
10
|
+
|
11
|
+
2. **Elimination of Poor Performers**: Players whose scores show a high coefficient of variation (indicating inconsistency) are progressively eliminated. This ensures that only competitive candidates continue in the ranking process.
|
12
|
+
|
13
|
+
3. **ELO Ranking**: If there are more than 10 eligible players, an ELO-based ranking is applied. Battles between players are simulated via `ActiveGenie::Battle`, and scores are updated using an ELO algorithm tailored to the league.
|
14
|
+
|
15
|
+
4. **Free for all Matches**: Finally, the remaining players engage in head-to-head matches where each unique pair competes. Match outcomes (wins, losses, draws) are recorded to finalize the rankings.
|
16
|
+
|
17
|
+
## Components
|
18
|
+
|
19
|
+
- **league**: Orchestrates the entire ranking process. Initializes player scores, eliminates outliers, and coordinates ranking rounds.
|
20
|
+
|
21
|
+
- **EloRanking**: Applies an ELO-based system to rank players through simulated battles. It updates players’ ELO scores based on match outcomes and predefined rules (including penalties for losses).
|
22
|
+
|
23
|
+
- **Free for all**: Conducts complete pairwise matches among eligible players to record win/loss/draw statistics and refine the final standings.
|
24
|
+
|
25
|
+
## Usage
|
26
|
+
|
27
|
+
Call the league using:
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
result = ActiveGenie::League::league.call(players, criteria, config: {})
|
31
|
+
```
|
32
|
+
|
33
|
+
- `players`: A collection of player instances, each containing textual content to be scored.
|
34
|
+
- `criteria`: A string defining the evaluation criteria used by the scoring system.
|
35
|
+
- `config`: A hash of additional parameters for customization (e.g., model, api_key).
|
36
|
+
|
37
|
+
The method processes the players through scoring, elimination, and ranking phases, then returns a hash containing the player statistics and rankings.
|
38
|
+
|
39
|
+
## Possible improvements
|
40
|
+
- Adjust initial criteria to ensure consistency
|
41
|
+
- Adjust each player's content to ensure consistency
|
42
|
+
- Support players with images or audio
|
43
|
+
- Parallelize processing battles and scoring
|
@@ -0,0 +1,121 @@
|
|
1
|
+
require_relative '../battle/basic'
|
2
|
+
|
3
|
+
module ActiveGenie::Leaderboard
|
4
|
+
class EloRanking
|
5
|
+
def self.call(players, criteria, config: {})
|
6
|
+
new(players, criteria, config:).call
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize(players, criteria, config: {})
|
10
|
+
@players = players
|
11
|
+
@criteria = criteria
|
12
|
+
@config = config
|
13
|
+
@start_time = Time.now
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
@players.each(&:generate_elo_by_score)
|
18
|
+
|
19
|
+
round_count = 0
|
20
|
+
while @players.eligible_size > MINIMAL_PLAYERS_TO_BATTLE
|
21
|
+
round = create_round(@players.tier_relegation, @players.tier_defense)
|
22
|
+
|
23
|
+
round.each do |player_a, player_b|
|
24
|
+
winner, loser = battle(player_a, player_b) # This can take a while, can be parallelized
|
25
|
+
update_elo(winner, loser)
|
26
|
+
ActiveGenie::Logger.trace({ **log, step: :elo_battle, winner_id: winner.id, loser_id: loser.id, winner_elo: winner.elo, loser_elo: loser.elo })
|
27
|
+
end
|
28
|
+
|
29
|
+
eliminate_all_relegation_players
|
30
|
+
round_count += 1
|
31
|
+
end
|
32
|
+
|
33
|
+
ActiveGenie::Logger.info({ **log, step: :elo_end, round_count:, eligible_size: @players.eligible_size })
|
34
|
+
@players
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
MATCHS_PER_PLAYER = 3
|
40
|
+
LOSE_PENALTY = 15
|
41
|
+
MINIMAL_PLAYERS_TO_BATTLE = 10
|
42
|
+
K = 32
|
43
|
+
|
44
|
+
# Create a round of matches
|
45
|
+
# each round is exactly 1 regation player vs 3 defense players for all regation players
|
46
|
+
# each match is unique (player vs player)
|
47
|
+
# each defense player is battle exactly 3 times
|
48
|
+
def create_round(relegation_players, defense_players)
|
49
|
+
matches = []
|
50
|
+
|
51
|
+
relegation_players.each do |player_a|
|
52
|
+
player_enemies = []
|
53
|
+
MATCHS_PER_PLAYER.times do
|
54
|
+
defender = nil
|
55
|
+
while defender.nil? || player_enemies.include?(defender.id)
|
56
|
+
defender = defense_players.sample
|
57
|
+
end
|
58
|
+
|
59
|
+
matches << [player_a, defender].shuffle
|
60
|
+
player_enemies << defender.id
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
matches
|
65
|
+
end
|
66
|
+
|
67
|
+
def battle(player_a, player_b)
|
68
|
+
ActiveGenie::Battle.basic(
|
69
|
+
player_a,
|
70
|
+
player_b,
|
71
|
+
@criteria,
|
72
|
+
config:
|
73
|
+
).values_at('winner', 'loser')
|
74
|
+
end
|
75
|
+
|
76
|
+
def update_elo(winner, loser)
|
77
|
+
return if winner.nil? || loser.nil?
|
78
|
+
|
79
|
+
new_winner_elo, new_loser_elo = calculate_new_elo(winner.elo, loser.elo)
|
80
|
+
|
81
|
+
winner.elo = [new_winner_elo, max_defense_elo].min
|
82
|
+
loser.elo = [new_loser_elo - LOSE_PENALTY, min_relegation_elo].max
|
83
|
+
end
|
84
|
+
|
85
|
+
def max_defense_elo
|
86
|
+
@players.tier_defense.max_by(&:elo).elo
|
87
|
+
end
|
88
|
+
|
89
|
+
def min_relegation_elo
|
90
|
+
@players.tier_relegation.min_by(&:elo).elo
|
91
|
+
end
|
92
|
+
|
93
|
+
# Read more about the formula on https://en.wikipedia.org/wiki/Elo_rating_system
|
94
|
+
def calculate_new_elo(winner_elo, loser_elo)
|
95
|
+
expected_score_a = 1 / (1 + 10**((loser_elo - winner_elo) / 400))
|
96
|
+
expected_score_b = 1 - expected_score_a
|
97
|
+
|
98
|
+
new_elo_winner = winner_elo + K * (1 - expected_score_a)
|
99
|
+
new_elo_loser = loser_elo + K * (1 - expected_score_b)
|
100
|
+
|
101
|
+
[new_elo_winner, new_elo_loser]
|
102
|
+
end
|
103
|
+
|
104
|
+
def eliminate_all_relegation_players
|
105
|
+
eliminations = @players.tier_relegation.size
|
106
|
+
@players.tier_relegation.each { |player| player.eliminated = 'tier_relegation' }
|
107
|
+
ActiveGenie::Logger.trace({ **log, step: :elo_round, eligible_size: @players.eligible_size, eliminations: })
|
108
|
+
end
|
109
|
+
|
110
|
+
def config
|
111
|
+
{ **@config }
|
112
|
+
end
|
113
|
+
|
114
|
+
def log
|
115
|
+
{
|
116
|
+
**(@config.dig(:log) || {}),
|
117
|
+
duration: Time.now - @start_time
|
118
|
+
}
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require_relative '../battle/basic'
|
2
|
+
|
3
|
+
module ActiveGenie::Leaderboard
|
4
|
+
class FreeForAll
|
5
|
+
def self.call(players, criteria, config: {})
|
6
|
+
new(players, criteria, config:).call
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize(players, criteria, config: {})
|
10
|
+
@players = players
|
11
|
+
@criteria = criteria
|
12
|
+
@config = config
|
13
|
+
@start_time = Time.now
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
matches.each do |player_a, player_b|
|
18
|
+
winner, loser = battle(player_a, player_b)
|
19
|
+
|
20
|
+
if winner.nil? || loser.nil?
|
21
|
+
player_a.free_for_all[:draw] += 1
|
22
|
+
player_b.free_for_all[:draw] += 1
|
23
|
+
else
|
24
|
+
winner.free_for_all[:win] += 1
|
25
|
+
loser.free_for_all[:lose] += 1
|
26
|
+
end
|
27
|
+
|
28
|
+
ActiveGenie::Logger.trace({**log, step: :free_for_all_battle, winner_id: winner&.id, player_a_id: player_a.id, player_a_free_for_all_score: player_a.free_for_all_score, player_b_id: player_b.id, player_b_free_for_all_score: player_b.free_for_all_score })
|
29
|
+
end
|
30
|
+
|
31
|
+
@players
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
# TODO: reduce the number of matches based on transitivity.
|
37
|
+
# For example, if A is better than B, and B is better than C, then A should clearly be better than C
|
38
|
+
def matches
|
39
|
+
@players.eligible.combination(2).to_a
|
40
|
+
end
|
41
|
+
|
42
|
+
def battle(player_a, player_b)
|
43
|
+
result = ActiveGenie::Battle.basic(
|
44
|
+
player_a,
|
45
|
+
player_b,
|
46
|
+
@criteria,
|
47
|
+
config:
|
48
|
+
)
|
49
|
+
|
50
|
+
|
51
|
+
result.values_at('winner', 'loser')
|
52
|
+
end
|
53
|
+
|
54
|
+
def config
|
55
|
+
{ **@config }
|
56
|
+
end
|
57
|
+
|
58
|
+
def log
|
59
|
+
{ **(@config.dig(:log) || {}), duration: Time.now - @start_time }
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
|
3
|
+
require_relative './players_collection'
|
4
|
+
require_relative './free_for_all'
|
5
|
+
require_relative './elo_ranking'
|
6
|
+
require_relative '../scoring/recommended_reviews'
|
7
|
+
|
8
|
+
# This class orchestrates player ranking through multiple evaluation stages
|
9
|
+
# using Elo ranking and free-for-all match simulations.
|
10
|
+
# 1. Sets initial scores
|
11
|
+
# 2. Eliminates low performers
|
12
|
+
# 3. Runs Elo ranking (for large groups)
|
13
|
+
# 4. Conducts free-for-all matches
|
14
|
+
#
|
15
|
+
# @example Basic usage
|
16
|
+
# League.call(players, criteria)
|
17
|
+
#
|
18
|
+
# @param param_players [Array] Collection of player objects to evaluate
|
19
|
+
# Example: ["Circle", "Triangle", "Square"]
|
20
|
+
# or
|
21
|
+
# [
|
22
|
+
# { content: "Circle", score: 10 },
|
23
|
+
# { content: "Triangle", score: 7 },
|
24
|
+
# { content: "Square", score: 5 }
|
25
|
+
# ]
|
26
|
+
# @param criteria [String] Evaluation criteria configuration
|
27
|
+
# Example: "What is more similar to the letter 'O'?"
|
28
|
+
# @param config [Hash] Additional configuration config
|
29
|
+
# Example: { model: "gpt-4o", api_key: ENV['OPENAI_API_KEY'] }
|
30
|
+
# @return [Hash] Final ranked player results
|
31
|
+
module ActiveGenie::League
|
32
|
+
class League
|
33
|
+
def self.call(param_players, criteria, config: {})
|
34
|
+
new(param_players, criteria, config:).call
|
35
|
+
end
|
36
|
+
|
37
|
+
def initialize(param_players, criteria, config: {})
|
38
|
+
@param_players = param_players
|
39
|
+
@criteria = criteria
|
40
|
+
@config = config
|
41
|
+
@league_id = SecureRandom.uuid
|
42
|
+
@start_time = Time.now
|
43
|
+
end
|
44
|
+
|
45
|
+
def call
|
46
|
+
set_initial_score_players
|
47
|
+
eliminate_obvious_bad_players
|
48
|
+
run_elo_ranking if players.eligible_size > 10
|
49
|
+
run_free_for_all
|
50
|
+
|
51
|
+
ActiveGenie::Logger.info({ **log, step: :league_end, top5: players.first(5).map(&:id) })
|
52
|
+
players.to_h
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
SCORE_VARIATION_THRESHOLD = 10
|
58
|
+
|
59
|
+
def set_initial_score_players
|
60
|
+
players_without_score = players.reject { |player| player.score }
|
61
|
+
players_without_score.each do |player|
|
62
|
+
player.score = generate_score(player.content) # This can take a while, can be parallelized
|
63
|
+
ActiveGenie::Logger.trace({ **log, step: :player_score, player_id: player.id, score: player.score })
|
64
|
+
end
|
65
|
+
|
66
|
+
ActiveGenie::Logger.info({ **log, step: :initial_score, evaluated_players: players_without_score.size })
|
67
|
+
end
|
68
|
+
|
69
|
+
def generate_score(content)
|
70
|
+
ActiveGenie::Scoring::Basic.call(content, @criteria, reviewers, config:)['final_score']
|
71
|
+
end
|
72
|
+
|
73
|
+
def eliminate_obvious_bad_players
|
74
|
+
eliminated_count = 0
|
75
|
+
while players.coefficient_of_variation >= SCORE_VARIATION_THRESHOLD
|
76
|
+
players.eligible.last.eliminated = 'variation_too_high'
|
77
|
+
eliminated_count += 1
|
78
|
+
end
|
79
|
+
|
80
|
+
ActiveGenie::Logger.info({ **log, step: :eliminate_obvious_bad_players, eliminated_count: })
|
81
|
+
end
|
82
|
+
|
83
|
+
def run_elo_ranking
|
84
|
+
EloRanking.call(players, @criteria, config:)
|
85
|
+
end
|
86
|
+
|
87
|
+
def run_free_for_all
|
88
|
+
FreeForAll.call(players, @criteria, config:)
|
89
|
+
end
|
90
|
+
|
91
|
+
def reviewers
|
92
|
+
[recommended_reviews['reviewer1'], recommended_reviews['reviewer2'], recommended_reviews['reviewer3']]
|
93
|
+
end
|
94
|
+
|
95
|
+
def recommended_reviews
|
96
|
+
@recommended_reviews ||= ActiveGenie::Scoring::RecommendedReviews.call(
|
97
|
+
[players.sample.content, players.sample.content].join("\n\n"),
|
98
|
+
@criteria,
|
99
|
+
config:
|
100
|
+
)
|
101
|
+
end
|
102
|
+
|
103
|
+
def players
|
104
|
+
@players ||= PlayersCollection.new(@param_players)
|
105
|
+
end
|
106
|
+
|
107
|
+
def config
|
108
|
+
{ log:, **@config }
|
109
|
+
end
|
110
|
+
|
111
|
+
def log
|
112
|
+
{
|
113
|
+
**(@config.dig(:log) || {}),
|
114
|
+
league_id: @league_id,
|
115
|
+
league_start_time: @start_time,
|
116
|
+
duration: Time.now - @start_time
|
117
|
+
}
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
|
3
|
+
module ActiveGenie::Leaderboard
|
4
|
+
class Player
|
5
|
+
def initialize(params)
|
6
|
+
params = { content: params } if params.is_a?(String)
|
7
|
+
|
8
|
+
@id = params.dig(:id) || SecureRandom.uuid
|
9
|
+
@content = params.dig(:content) || params
|
10
|
+
@score = params.dig(:score) || nil
|
11
|
+
@elo = params.dig(:elo) || nil
|
12
|
+
@free_for_all = {
|
13
|
+
win: params.dig(:free_for_all, :win) || 0,
|
14
|
+
lose: params.dig(:free_for_all, :lose) || 0,
|
15
|
+
draw: params.dig(:free_for_all, :draw) || 0
|
16
|
+
}
|
17
|
+
@eliminated = params.dig(:eliminated) || nil
|
18
|
+
end
|
19
|
+
|
20
|
+
attr_reader :id, :content, :score, :elo, :free_for_all, :eliminated
|
21
|
+
|
22
|
+
def generate_elo_by_score
|
23
|
+
return if !@elo.nil?
|
24
|
+
|
25
|
+
if @score.nil?
|
26
|
+
@elo = BASE_ELO
|
27
|
+
else
|
28
|
+
@elo = BASE_ELO + (@score - 50)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def score=(value)
|
33
|
+
@score = value
|
34
|
+
end
|
35
|
+
|
36
|
+
def elo=(value)
|
37
|
+
@elo = value
|
38
|
+
end
|
39
|
+
|
40
|
+
def eliminated=(value)
|
41
|
+
@eliminated = value
|
42
|
+
end
|
43
|
+
|
44
|
+
def free_for_all_score
|
45
|
+
@free_for_all[:win] * 3 + @free_for_all[:draw]
|
46
|
+
end
|
47
|
+
|
48
|
+
def to_h
|
49
|
+
{
|
50
|
+
id:, content:, score:, elo:,
|
51
|
+
eliminated:, free_for_all:, free_for_all_score:
|
52
|
+
}
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
BASE_ELO = 1000
|
58
|
+
end
|
59
|
+
end
|