judge_rails 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +40 -1
- data/lib/generators/judge/templates/initializer.rb.tt +7 -0
- data/lib/judge/adapter.rb +1 -1
- data/lib/judge/clef.rb +52 -0
- data/lib/judge/client.rb +43 -35
- data/lib/judge/configuration.rb +35 -5
- data/lib/judge/facade.rb +2 -1
- data/lib/judge/version.rb +1 -1
- data/lib/judge.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4cd6199504db2095cd4dfc279ad386bd9effb10d5ab40719c8fe9cd8f0850822
|
|
4
|
+
data.tar.gz: 185fe243d146c57ee4d30c724b7104fe0f4624572467701ea25089a554508e27
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0640bbb3751542608ff2d22863af16e5a1132d7b9247a2595149d82b30631ea6bc09e6e89ea75858eebdb25cbf6fdeb1cf8636a7d606bf0c6baace00588c9be9
|
|
7
|
+
data.tar.gz: fd33919299343282420c12f643651b8215ed3ee0135d0fb027dfb89dc5bfef8dedcbcec9c30239671fc257f2b683d83b83064edecb9bb7408862403e01904124
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.3 (2026-10-04)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Cloudflare Clef.** A built-in `:clef` adapter sends the same questions to Clef and Clef-flash on
|
|
8
|
+
Workers AI. It reuses the Jev client, so retries, pooling, errors and instrumentation behave the same.
|
|
9
|
+
`JUDGE_ADAPTER=clef` with `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` is the whole setup.
|
|
10
|
+
- `config.cloudflare_api_token` and `config.cloudflare_account_id`, read from those two variables and
|
|
11
|
+
kept apart from `api_key`, so Jev and Clef can live in one app. `inspect` hides the token.
|
|
12
|
+
- `adapter:` on `Judge.ask` and the ActiveRecord helpers now also takes a registered name, such as
|
|
13
|
+
`adapter: :clef`, and not only an adapter object.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- With no explicit `config.model` or `JEV_MODEL`, the model follows the adapter: `clef` under `:clef`,
|
|
18
|
+
`jev-latest` otherwise. Nothing changes for Jev users. Moving an attribute to Clef changes its model,
|
|
19
|
+
so its stored judgments go stale and are recomputed.
|
|
20
|
+
|
|
3
21
|
## 0.0.2 (2026-09-26)
|
|
4
22
|
|
|
5
23
|
No change to the gem itself. The first release published by `release.yml` through RubyGems trusted
|
data/README.md
CHANGED
|
@@ -469,7 +469,7 @@ The event carries sizes and counts. It never carries the state or the key.
|
|
|
469
469
|
```ruby
|
|
470
470
|
Judge.configure do |config|
|
|
471
471
|
config.api_key = "..." # read from JEV_API_KEY or TYPESAFE_API_KEY by default
|
|
472
|
-
config.model = "jev-latest"
|
|
472
|
+
config.model = "jev-latest" # "clef" when the adapter is :clef
|
|
473
473
|
config.timeout = 10.0
|
|
474
474
|
config.open_timeout = 5.0
|
|
475
475
|
config.max_retries = 2
|
|
@@ -543,6 +543,45 @@ The default is `:jev` and stays `:jev` until you change it. `JUDGE_ADAPTER` pick
|
|
|
543
543
|
environment, and `adapter:` overrides it for a single call, which is how the test suite installs a
|
|
544
544
|
recorder.
|
|
545
545
|
|
|
546
|
+
### Cloudflare Clef
|
|
547
|
+
|
|
548
|
+
`:clef` ships with the gem. It sends the same questions to [Clef](https://developers.cloudflare.com/workers-ai/models/clef/)
|
|
549
|
+
on Cloudflare Workers AI and needs a Workers AI API token and your account id:
|
|
550
|
+
|
|
551
|
+
```sh
|
|
552
|
+
JUDGE_ADAPTER=clef
|
|
553
|
+
CLOUDFLARE_API_TOKEN=...
|
|
554
|
+
CLOUDFLARE_ACCOUNT_ID=...
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
```ruby
|
|
558
|
+
Judge.configure do |config|
|
|
559
|
+
config.adapter = :clef # or JUDGE_ADAPTER=clef
|
|
560
|
+
config.cloudflare_api_token = "..." # read from CLOUDFLARE_API_TOKEN by default
|
|
561
|
+
config.cloudflare_account_id = "..." # read from CLOUDFLARE_ACCOUNT_ID by default
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
Judge.ask(Judge.noul("Is this urgent?"), text: "Checkout is down for everyone").probability # => 0.98
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
The model follows the adapter: `clef` under `:clef`, `jev-latest` otherwise. `clef-flash` is the smaller,
|
|
568
|
+
faster one. Set it with `config.model`, `JEV_MODEL`, or a pin on one attribute:
|
|
569
|
+
|
|
570
|
+
```ruby
|
|
571
|
+
judge_attribute :urgency, Judge.noul("..."), model: "clef-flash"
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
Clef only serves those two names. Asking it for any other model raises `Judge::ConfigurationError` before
|
|
575
|
+
a request leaves. `api_key` stays the Jev key, so one app can keep `:jev` and try Clef on a single call,
|
|
576
|
+
naming the model since the configured one is still Jev's:
|
|
577
|
+
|
|
578
|
+
```ruby
|
|
579
|
+
Judge.ask(question, text: text, adapter: :clef, model: "clef")
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Switching an attribute from Jev to Clef changes its model, so its stored judgments go stale and are
|
|
583
|
+
recomputed on the next refresh, the same as changing a pin.
|
|
584
|
+
|
|
546
585
|
### You may not need an adapter at all
|
|
547
586
|
|
|
548
587
|
The portable thing is the protocol, not this registry. Anything already serving this shape works
|
|
@@ -7,6 +7,13 @@ Judge.configure do |config|
|
|
|
7
7
|
|
|
8
8
|
config.logger = Rails.logger
|
|
9
9
|
|
|
10
|
+
# Cloudflare Clef instead of Jev: set JUDGE_ADAPTER=clef, CLOUDFLARE_API_TOKEN and
|
|
11
|
+
# CLOUDFLARE_ACCOUNT_ID, or:
|
|
12
|
+
#
|
|
13
|
+
# config.adapter = :clef
|
|
14
|
+
# config.cloudflare_api_token = Rails.application.credentials.dig(:cloudflare, :api_token)
|
|
15
|
+
# config.cloudflare_account_id = Rails.application.credentials.dig(:cloudflare, :account_id)
|
|
16
|
+
|
|
10
17
|
# Defaults, uncomment to override:
|
|
11
18
|
#
|
|
12
19
|
# config.base_url = "https://api.typesafe.ai/v1/systemone"
|
data/lib/judge/adapter.rb
CHANGED
data/lib/judge/clef.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "judge/client"
|
|
4
|
+
|
|
5
|
+
module Judge
|
|
6
|
+
# Cloudflare's Clef on Workers AI. Same request and answers as Jev, behind a Cloudflare envelope
|
|
7
|
+
# and an account-scoped URL.
|
|
8
|
+
class Clef < Client
|
|
9
|
+
MODELS = %w[clef clef-flash].freeze
|
|
10
|
+
API_ROOT = "https://api.cloudflare.com/client/v4"
|
|
11
|
+
|
|
12
|
+
def initialize(config: nil, sleeper: nil, api_root: API_ROOT)
|
|
13
|
+
super(config: config, sleeper: sleeper)
|
|
14
|
+
@api_root = api_root.to_s.chomp("/")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def inspect
|
|
18
|
+
"#<Judge::Clef model=#{@config.model.inspect}>"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def endpoint(model)
|
|
24
|
+
unless MODELS.include?(model)
|
|
25
|
+
raise ConfigurationError, "Clef serves #{MODELS.join(" and ")}, not #{model.inspect}. " \
|
|
26
|
+
"Set config.model or pass model: \"clef\""
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
URI.parse("#{@api_root}/accounts/#{@config.cloudflare_account_id!}/ai/run/@cf/cloudflare/#{model}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def authorization
|
|
33
|
+
"Bearer #{@config.cloudflare_api_token!}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def unwrap(body)
|
|
37
|
+
result = body["result"]
|
|
38
|
+
return result if body["success"] != false && result.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
raise InvalidResponseError, "Cloudflare returned no result: #{error_message(JSON.generate(body))}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def error_message(body)
|
|
44
|
+
parsed = JSON.parse(body)
|
|
45
|
+
errors = parsed["errors"] if parsed.is_a?(Hash)
|
|
46
|
+
message = errors.first["message"] if errors.is_a?(Array) && errors.first.is_a?(Hash)
|
|
47
|
+
message ? message.to_s : super
|
|
48
|
+
rescue JSON::ParserError
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/judge/client.rb
CHANGED
|
@@ -44,13 +44,14 @@ module Judge
|
|
|
44
44
|
raise ArgumentError, "state must not be nil" if state.nil?
|
|
45
45
|
raise ArgumentError, "questions must not be empty" if questions.nil? || questions.empty?
|
|
46
46
|
|
|
47
|
+
model ||= @config.model
|
|
48
|
+
target = endpoint(model)
|
|
47
49
|
payload = JSON.generate(build_payload(state, questions, model))
|
|
48
|
-
event = { model: model
|
|
49
|
-
request_bytes: payload.bytesize }
|
|
50
|
+
event = { model: model, questions: questions.size, request_bytes: payload.bytesize }
|
|
50
51
|
|
|
51
52
|
instrument(event) do
|
|
52
53
|
started = monotonic
|
|
53
|
-
body = perform(payload)
|
|
54
|
+
body = unwrap(perform(target, payload))
|
|
54
55
|
set = ResultSet.from_response(body, questions: questions, latency: monotonic - started)
|
|
55
56
|
event[:latency] = set.latency
|
|
56
57
|
event[:input_tokens] = set.usage&.input_tokens
|
|
@@ -70,18 +71,33 @@ module Judge
|
|
|
70
71
|
def build_payload(state, questions, model)
|
|
71
72
|
{
|
|
72
73
|
"state" => state,
|
|
73
|
-
"model" => model
|
|
74
|
+
"model" => model,
|
|
74
75
|
"questions" => questions.to_h { |name, question| [name.to_s, question.to_payload] }
|
|
75
76
|
}
|
|
76
77
|
end
|
|
77
78
|
|
|
78
|
-
def
|
|
79
|
-
|
|
79
|
+
def endpoint(_model)
|
|
80
|
+
url = @config.base_url
|
|
81
|
+
@uri = URI.parse(url) if @uri_source != url
|
|
82
|
+
@uri_source = url
|
|
83
|
+
@uri
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def authorization
|
|
87
|
+
"Bearer #{@config.api_key!}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def unwrap(body)
|
|
91
|
+
body
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def perform(target, payload)
|
|
95
|
+
authorization = self.authorization
|
|
80
96
|
attempt = 0
|
|
81
97
|
|
|
82
98
|
loop do
|
|
83
99
|
attempt += 1
|
|
84
|
-
response = attempt_request(authorization, payload, attempt)
|
|
100
|
+
response = attempt_request(target, authorization, payload, attempt)
|
|
85
101
|
next if response.nil?
|
|
86
102
|
|
|
87
103
|
status = response.code.to_i
|
|
@@ -100,14 +116,14 @@ module Judge
|
|
|
100
116
|
after || backoff(attempt)
|
|
101
117
|
end
|
|
102
118
|
|
|
103
|
-
def attempt_request(authorization, payload, attempt)
|
|
119
|
+
def attempt_request(target, authorization, payload, attempt)
|
|
104
120
|
started = monotonic
|
|
105
|
-
response = execute(authorization, payload)
|
|
106
|
-
log(response.code, monotonic - started, attempt)
|
|
121
|
+
response = execute(target, authorization, payload)
|
|
122
|
+
log(target, response.code, monotonic - started, attempt)
|
|
107
123
|
response
|
|
108
124
|
rescue *TRANSPORT_ERRORS => e
|
|
109
|
-
close_connection
|
|
110
|
-
log(e.class.name, monotonic - started, attempt)
|
|
125
|
+
close_connection(target)
|
|
126
|
+
log(target, e.class.name, monotonic - started, attempt)
|
|
111
127
|
if attempt > @config.max_retries || UNSAFE_TO_RESEND.any? { |klass| e.is_a?(klass) }
|
|
112
128
|
raise TransportError, "#{e.class}: #{e.message}"
|
|
113
129
|
end
|
|
@@ -116,39 +132,31 @@ module Judge
|
|
|
116
132
|
nil
|
|
117
133
|
end
|
|
118
134
|
|
|
119
|
-
def execute(authorization, payload)
|
|
120
|
-
request = Net::HTTP::Post.new(
|
|
135
|
+
def execute(target, authorization, payload)
|
|
136
|
+
request = Net::HTTP::Post.new(target.request_uri)
|
|
121
137
|
request["Authorization"] = authorization
|
|
122
138
|
request["Content-Type"] = "application/json"
|
|
123
139
|
request["Accept"] = "application/json"
|
|
124
140
|
request.body = payload
|
|
125
|
-
connection.request(request)
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def uri
|
|
129
|
-
url = @config.base_url
|
|
130
|
-
@uri = URI.parse(url) if @uri_source != url
|
|
131
|
-
@uri_source = url
|
|
132
|
-
@uri
|
|
141
|
+
connection(target).request(request)
|
|
133
142
|
end
|
|
134
143
|
|
|
135
|
-
def connection_key
|
|
136
|
-
[
|
|
144
|
+
def connection_key(target)
|
|
145
|
+
[target.to_s, @config.open_timeout, @config.timeout].freeze
|
|
137
146
|
end
|
|
138
147
|
|
|
139
|
-
def connection
|
|
148
|
+
def connection(target)
|
|
140
149
|
store = connections
|
|
141
|
-
key = connection_key
|
|
150
|
+
key = connection_key(target)
|
|
142
151
|
http = store[key]
|
|
143
152
|
return http if http&.started?
|
|
144
153
|
|
|
145
|
-
store[key] = start_connection(
|
|
154
|
+
store[key] = start_connection(target)
|
|
146
155
|
end
|
|
147
156
|
|
|
148
|
-
def start_connection(
|
|
149
|
-
|
|
150
|
-
http =
|
|
151
|
-
http.use_ssl = uri.scheme == "https"
|
|
157
|
+
def start_connection(target)
|
|
158
|
+
http = Net::HTTP.new(target.host, target.port)
|
|
159
|
+
http.use_ssl = target.scheme == "https"
|
|
152
160
|
http.open_timeout = @config.open_timeout
|
|
153
161
|
http.read_timeout = @config.timeout
|
|
154
162
|
http.write_timeout = @config.timeout if http.respond_to?(:write_timeout=)
|
|
@@ -166,8 +174,8 @@ module Judge
|
|
|
166
174
|
store[:connections]
|
|
167
175
|
end
|
|
168
176
|
|
|
169
|
-
def close_connection
|
|
170
|
-
http = connections.delete(connection_key)
|
|
177
|
+
def close_connection(target)
|
|
178
|
+
http = connections.delete(connection_key(target))
|
|
171
179
|
http.finish if http&.started?
|
|
172
180
|
rescue IOError
|
|
173
181
|
nil
|
|
@@ -235,12 +243,12 @@ module Judge
|
|
|
235
243
|
body.length > limit ? "#{body[0, limit]}..." : body
|
|
236
244
|
end
|
|
237
245
|
|
|
238
|
-
def log(status, latency, attempt)
|
|
246
|
+
def log(target, status, latency, attempt)
|
|
239
247
|
logger = @config.logger
|
|
240
248
|
return unless logger
|
|
241
249
|
|
|
242
250
|
logger.debug do
|
|
243
|
-
"Judge POST #{
|
|
251
|
+
"Judge POST #{target.path} status=#{status} latency=#{latency.round(3)}s attempt=#{attempt}"
|
|
244
252
|
end
|
|
245
253
|
end
|
|
246
254
|
|
data/lib/judge/configuration.rb
CHANGED
|
@@ -4,16 +4,19 @@ module Judge
|
|
|
4
4
|
class Configuration
|
|
5
5
|
DEFAULT_BASE_URL = "https://api.typesafe.ai/v1/systemone"
|
|
6
6
|
DEFAULT_MODEL = "jev-latest"
|
|
7
|
+
CLEF_DEFAULT_MODEL = "clef"
|
|
7
8
|
|
|
8
|
-
attr_reader :api_key, :base_url, :
|
|
9
|
-
:concurrency, :adapter
|
|
9
|
+
attr_reader :api_key, :base_url, :timeout, :open_timeout, :max_retries, :max_retry_wait,
|
|
10
|
+
:concurrency, :adapter, :cloudflare_api_token, :cloudflare_account_id
|
|
10
11
|
attr_accessor :logger
|
|
11
12
|
|
|
12
13
|
def initialize
|
|
13
14
|
self.api_key = [ENV.fetch("JEV_API_KEY", nil), ENV.fetch("TYPESAFE_API_KEY", nil)]
|
|
14
15
|
.find { |value| !blank?(value) }
|
|
15
16
|
self.base_url = present_env("JEV_BASE_URL") || DEFAULT_BASE_URL
|
|
16
|
-
|
|
17
|
+
@model = present_env("JEV_MODEL")
|
|
18
|
+
self.cloudflare_api_token = ENV.fetch("CLOUDFLARE_API_TOKEN", nil)
|
|
19
|
+
self.cloudflare_account_id = ENV.fetch("CLOUDFLARE_ACCOUNT_ID", nil)
|
|
17
20
|
self.timeout = 10.0
|
|
18
21
|
self.open_timeout = 5.0
|
|
19
22
|
self.max_retries = 2
|
|
@@ -33,6 +36,18 @@ module Judge
|
|
|
33
36
|
@base_url = value.to_s.strip
|
|
34
37
|
end
|
|
35
38
|
|
|
39
|
+
def cloudflare_api_token=(value)
|
|
40
|
+
@cloudflare_api_token = blank?(value) ? nil : value.to_s.strip
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def cloudflare_account_id=(value)
|
|
44
|
+
@cloudflare_account_id = blank?(value) ? nil : value.to_s.strip
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def model
|
|
48
|
+
@model || (@adapter == :clef ? CLEF_DEFAULT_MODEL : DEFAULT_MODEL)
|
|
49
|
+
end
|
|
50
|
+
|
|
36
51
|
def model=(value)
|
|
37
52
|
raise ArgumentError, "model must not be blank" if blank?(value)
|
|
38
53
|
|
|
@@ -77,10 +92,25 @@ module Judge
|
|
|
77
92
|
raise ConfigurationError, "No Judge API key. Set JEV_API_KEY or Judge.configure { |c| c.api_key = ... }"
|
|
78
93
|
end
|
|
79
94
|
|
|
95
|
+
def cloudflare_api_token!
|
|
96
|
+
return @cloudflare_api_token if @cloudflare_api_token
|
|
97
|
+
|
|
98
|
+
raise ConfigurationError, "No Cloudflare API token. Set CLOUDFLARE_API_TOKEN or " \
|
|
99
|
+
"Judge.configure { |c| c.cloudflare_api_token = ... }"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def cloudflare_account_id!
|
|
103
|
+
return @cloudflare_account_id if @cloudflare_account_id
|
|
104
|
+
|
|
105
|
+
raise ConfigurationError, "No Cloudflare account id. Set CLOUDFLARE_ACCOUNT_ID or " \
|
|
106
|
+
"Judge.configure { |c| c.cloudflare_account_id = ... }"
|
|
107
|
+
end
|
|
108
|
+
|
|
80
109
|
def inspect
|
|
81
110
|
key = @api_key ? "[FILTERED]" : "nil"
|
|
82
|
-
|
|
83
|
-
|
|
111
|
+
token = @cloudflare_api_token ? "[FILTERED]" : "nil"
|
|
112
|
+
"#<Judge::Configuration api_key=#{key} cloudflare_api_token=#{token} base_url=#{@base_url.inspect} " \
|
|
113
|
+
"model=#{model.inspect} adapter=#{@adapter.inspect}>"
|
|
84
114
|
end
|
|
85
115
|
|
|
86
116
|
private
|
data/lib/judge/facade.rb
CHANGED
|
@@ -21,7 +21,8 @@ module Judge
|
|
|
21
21
|
normalized = normalize_questions(questions)
|
|
22
22
|
raise ArgumentError, "ask needs at least one question" if normalized.empty?
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
provider = adapter ? Adapter.build(adapter) : self.adapter
|
|
25
|
+
results = provider.call(state: text, questions: normalized, model: model)
|
|
25
26
|
return results unless single
|
|
26
27
|
|
|
27
28
|
name = normalized.keys.first
|
data/lib/judge/version.rb
CHANGED
data/lib/judge.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: judge_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hugo V
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -69,6 +69,7 @@ files:
|
|
|
69
69
|
- lib/generators/judge/templates/migration.rb.tt
|
|
70
70
|
- lib/judge.rb
|
|
71
71
|
- lib/judge/adapter.rb
|
|
72
|
+
- lib/judge/clef.rb
|
|
72
73
|
- lib/judge/client.rb
|
|
73
74
|
- lib/judge/configuration.rb
|
|
74
75
|
- lib/judge/decision.rb
|