judge_rails 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/ADVANCED.md +136 -0
- data/BENCHMARK.md +885 -0
- data/CHANGELOG.md +163 -0
- data/LICENSE.txt +21 -0
- data/README.md +588 -0
- data/lib/generators/judge/attribute_generator.rb +149 -0
- data/lib/generators/judge/install_generator.rb +36 -0
- data/lib/generators/judge/templates/initializer.rb.tt +18 -0
- data/lib/generators/judge/templates/migration.rb.tt +9 -0
- data/lib/judge/adapter.rb +69 -0
- data/lib/judge/client.rb +253 -0
- data/lib/judge/configuration.rb +103 -0
- data/lib/judge/decision.rb +24 -0
- data/lib/judge/errors.rb +36 -0
- data/lib/judge/facade.rb +75 -0
- data/lib/judge/pool.rb +97 -0
- data/lib/judge/question/choice.rb +36 -0
- data/lib/judge/question/noul.rb +22 -0
- data/lib/judge/question/score.rb +36 -0
- data/lib/judge/question.rb +93 -0
- data/lib/judge/rails/attributes.rb +161 -0
- data/lib/judge/rails/definition.rb +156 -0
- data/lib/judge/rails/jobs.rb +156 -0
- data/lib/judge/rails/locale/en.yml +6 -0
- data/lib/judge/rails/migration.rb +115 -0
- data/lib/judge/rails/refresh.rb +37 -0
- data/lib/judge/rails/refresh_job.rb +15 -0
- data/lib/judge/rails/registry.rb +57 -0
- data/lib/judge/rails/relation.rb +132 -0
- data/lib/judge/rails/scopes.rb +124 -0
- data/lib/judge/rails/storage.rb +104 -0
- data/lib/judge/rails/validator.rb +246 -0
- data/lib/judge/rails.rb +45 -0
- data/lib/judge/result.rb +173 -0
- data/lib/judge/result_set.rb +75 -0
- data/lib/judge/version.rb +5 -0
- data/lib/judge.rb +41 -0
- data/lib/judge_rails.rb +3 -0
- metadata +126 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
module Rails
|
|
5
|
+
module Storage
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def plan(record, definitions, force: false)
|
|
9
|
+
definitions.each_with_object([{}, []]) do |definition, (pending, blank)|
|
|
10
|
+
next unless loaded?(record, definition) && definition.applies_to?(record)
|
|
11
|
+
|
|
12
|
+
state = readable_state(record, definition)
|
|
13
|
+
next if state.nil?
|
|
14
|
+
|
|
15
|
+
if state.empty?
|
|
16
|
+
blank << definition unless cleared?(record, definition)
|
|
17
|
+
elsif force || stale?(record, definition, state)
|
|
18
|
+
pending[definition] = state
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def pending(record, definitions, force: false)
|
|
24
|
+
plan(record, definitions, force: force).first
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def stale_definitions(record, definitions, force: false)
|
|
28
|
+
pending, blank = plan(record, definitions, force: force)
|
|
29
|
+
pending.keys + blank
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def groups(pending)
|
|
33
|
+
pending.group_by { |definition, state| [state, definition.effective_model] }
|
|
34
|
+
.transform_values { |pairs| pairs.map(&:first) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def compute(record, definitions, force: false, adapter: nil, done: [])
|
|
38
|
+
pending, blank = plan(record, definitions, force: force)
|
|
39
|
+
blank.each do |definition|
|
|
40
|
+
clear(record, definition)
|
|
41
|
+
done << definition
|
|
42
|
+
end
|
|
43
|
+
groups(pending).each do |(state, model), group|
|
|
44
|
+
yield if block_given?
|
|
45
|
+
ask(record, state, model, group, adapter: adapter)
|
|
46
|
+
done.concat(group)
|
|
47
|
+
end
|
|
48
|
+
done
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ask(record, state, model, group, adapter: nil)
|
|
52
|
+
questions = group.to_h { |d| [d.name, d.question] }
|
|
53
|
+
results = Judge.ask(questions, text: state, model: model, adapter: adapter)
|
|
54
|
+
group.each { |d| write(record, d, results[d.name], state: state, results: results) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def write(record, definition, result, state:, results: nil)
|
|
58
|
+
raise Judge::InvalidResponseError, "no answer for #{definition.name.inspect}" if result.nil?
|
|
59
|
+
|
|
60
|
+
record.write_attribute(definition.value_column, definition.cast(result))
|
|
61
|
+
record.write_attribute(
|
|
62
|
+
definition.sidecar_column,
|
|
63
|
+
definition.sidecar(result, state_digest: definition.state_digest(state),
|
|
64
|
+
model: results&.model, latency: results&.latency)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def clear(record, definition)
|
|
69
|
+
record.write_attribute(definition.value_column, nil)
|
|
70
|
+
record.write_attribute(definition.sidecar_column, {})
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def sidecar(record, definition)
|
|
74
|
+
value = record.read_attribute(definition.sidecar_column)
|
|
75
|
+
case value
|
|
76
|
+
when Hash then value.dup
|
|
77
|
+
when String then value.empty? ? {} : JSON.parse(value)
|
|
78
|
+
else {}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stale?(record, definition, state)
|
|
83
|
+
definition.stale?(value: record.read_attribute(definition.value_column),
|
|
84
|
+
sidecar: sidecar(record, definition), state: state)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def cleared?(record, definition)
|
|
88
|
+
record.read_attribute(definition.value_column).nil? && sidecar(record, definition).empty?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def loaded?(record, definition)
|
|
92
|
+
[definition.value_column, definition.sidecar_column].all? do |column|
|
|
93
|
+
record.has_attribute?(column) || !record.class.has_attribute?(column)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def readable_state(record, definition)
|
|
98
|
+
definition.state_for(record)
|
|
99
|
+
rescue ActiveModel::MissingAttributeError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
I18n.load_path << File.expand_path("locale/en.yml", __dir__)
|
|
4
|
+
|
|
5
|
+
module Judge
|
|
6
|
+
module Rails
|
|
7
|
+
module Validator
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
def judge_validation_results
|
|
11
|
+
@judge_validation_results ||= {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def judge_judgment(text, instruction)
|
|
15
|
+
@judge_judgments && @judge_judgments[[text, instruction]]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def judge_store_judgment(text, instruction, outcome)
|
|
19
|
+
@judge_judgments ||= {}
|
|
20
|
+
@judge_judgments[[text, instruction]] = outcome
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def judge_record_validation_result(attribute, instruction, result)
|
|
24
|
+
(judge_validation_results[attribute] ||= []) << [instruction, result]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def judge_prefetch
|
|
28
|
+
return if @judge_prefetched
|
|
29
|
+
|
|
30
|
+
@judge_prefetched = true
|
|
31
|
+
::JudgeValidator.prefetch(self)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def judge_saw_text(text)
|
|
35
|
+
(@judge_seen_texts ||= Set.new) << text
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reload(*)
|
|
39
|
+
@judge_judgments = nil
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize_dup(other)
|
|
44
|
+
super
|
|
45
|
+
@judge_judgments = nil
|
|
46
|
+
@judge_validation_results = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def run_validations!
|
|
52
|
+
@judge_validation_results = {}
|
|
53
|
+
@judge_prefetched = false
|
|
54
|
+
@judge_seen_texts = nil
|
|
55
|
+
super
|
|
56
|
+
ensure
|
|
57
|
+
judge_prune_judgments
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def judge_prune_judgments
|
|
61
|
+
return unless @judge_judgments
|
|
62
|
+
|
|
63
|
+
seen = @judge_seen_texts || Set.new
|
|
64
|
+
@judge_judgments.select! { |(text, _), (kind, _)| kind == :ok && seen.include?(text) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class JudgeValidator < ActiveModel::EachValidator # rubocop:disable Style/OneClassPerFile
|
|
71
|
+
ERROR_MODES = %i[pass fail raise].freeze
|
|
72
|
+
|
|
73
|
+
attr_reader :instruction, :polarity, :threshold, :question, :on_error
|
|
74
|
+
|
|
75
|
+
def self.prefetch(record)
|
|
76
|
+
groups = Hash.new { |hash, key| hash[key] = [] }
|
|
77
|
+
|
|
78
|
+
record.class.validators.each do |validator|
|
|
79
|
+
next unless validator.is_a?(JudgeValidator) && validator.applicable?(record)
|
|
80
|
+
|
|
81
|
+
validator.attributes.each do |attribute|
|
|
82
|
+
value = record.read_attribute_for_validation(attribute)
|
|
83
|
+
next if value.blank? || record.judge_judgment(value.to_s, validator.instruction)
|
|
84
|
+
|
|
85
|
+
group = groups[value.to_s]
|
|
86
|
+
group << validator unless group.any? { |other| other.instruction == validator.instruction }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
groups.each { |text, validators| ask_batch(record, text, validators) if validators.size > 1 }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.ask_batch(record, text, validators)
|
|
94
|
+
questions = validators.each_with_index.to_h { |validator, i| [:"judge_#{i}", validator.question] }
|
|
95
|
+
results = Judge.ask(questions, text: text)
|
|
96
|
+
validators.each_with_index do |validator, i|
|
|
97
|
+
result = results[:"judge_#{i}"]
|
|
98
|
+
raise Judge::InvalidResponseError, "no answer for #{validator.instruction}" unless result
|
|
99
|
+
|
|
100
|
+
record.judge_store_judgment(text, validator.instruction, [:ok, result])
|
|
101
|
+
end
|
|
102
|
+
rescue Judge::Error => e
|
|
103
|
+
validators.each { |validator| record.judge_store_judgment(text, validator.instruction, [:error, e]) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def initialize(options)
|
|
107
|
+
@polarity, @instruction = extract_judgment(options)
|
|
108
|
+
@threshold = Float(options.fetch(:threshold, 0.5))
|
|
109
|
+
@on_error = (options[:on_error] || :pass).to_sym
|
|
110
|
+
unless ERROR_MODES.include?(@on_error)
|
|
111
|
+
raise ArgumentError, "judge on_error: must be one of #{ERROR_MODES.inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@question = Judge.noul(@instruction)
|
|
115
|
+
super
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_each(record, attribute, value)
|
|
119
|
+
return if value.blank?
|
|
120
|
+
|
|
121
|
+
kind, payload = judgment(record, attribute, value.to_s)
|
|
122
|
+
if kind == :error
|
|
123
|
+
handle_error(record, attribute, payload)
|
|
124
|
+
else
|
|
125
|
+
if record.respond_to?(:judge_record_validation_result)
|
|
126
|
+
record.judge_record_validation_result(attribute, instruction, payload)
|
|
127
|
+
end
|
|
128
|
+
add_error(record, attribute, violation_type, options[:message]) if violated?(payload)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def applicable?(record)
|
|
133
|
+
return false unless context_match?(record)
|
|
134
|
+
|
|
135
|
+
Array(options[:if]).all? { |condition| evaluate_condition(record, condition) } &&
|
|
136
|
+
Array(options[:unless]).none? { |condition| evaluate_condition(record, condition) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def extract_judgment(options)
|
|
142
|
+
refute = options[:refute]
|
|
143
|
+
assert = options[:assert]
|
|
144
|
+
if (refute && assert) || (refute.nil? && assert.nil?)
|
|
145
|
+
raise ArgumentError, "judge validation needs exactly one of refute: or assert:"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
refute ? [:refute, refute.to_s] : [:assert, assert.to_s]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def judgment(record, attribute, text)
|
|
152
|
+
record.judge_prefetch if record.respond_to?(:judge_prefetch)
|
|
153
|
+
record.judge_saw_text(text) if record.respond_to?(:judge_saw_text)
|
|
154
|
+
cached = cached_judgment(record, attribute, text)
|
|
155
|
+
return cached if cached
|
|
156
|
+
|
|
157
|
+
outcome = ask(text)
|
|
158
|
+
store_judgment(record, attribute, text, outcome)
|
|
159
|
+
outcome
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def cached_judgment(record, attribute, text)
|
|
163
|
+
return record.judge_judgment(text, instruction) if record.respond_to?(:judge_judgment)
|
|
164
|
+
|
|
165
|
+
cached_text, outcome = fallback_cache(record)&.[]([attribute, instruction])
|
|
166
|
+
outcome if cached_text == text
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def store_judgment(record, attribute, text, outcome)
|
|
170
|
+
if record.respond_to?(:judge_store_judgment)
|
|
171
|
+
record.judge_store_judgment(text, instruction, outcome)
|
|
172
|
+
elsif outcome.first == :ok
|
|
173
|
+
fallback_cache(record)&.[]=([attribute, instruction], [text, outcome])
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def fallback_cache(record)
|
|
178
|
+
return record.instance_variable_get(:@judge_judgments) if record.frozen?
|
|
179
|
+
|
|
180
|
+
record.instance_variable_get(:@judge_judgments) || record.instance_variable_set(:@judge_judgments, {})
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def ask(text)
|
|
184
|
+
[:ok, Judge.ask(question, text: text)]
|
|
185
|
+
rescue Judge::Error => e
|
|
186
|
+
[:error, e]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def violated?(result)
|
|
190
|
+
polarity == :refute ? result.true?(threshold) : !result.true?(threshold)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def violation_type
|
|
194
|
+
polarity == :refute ? :judge_refuted : :judge_unmatched
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def handle_error(record, attribute, error)
|
|
198
|
+
case on_error
|
|
199
|
+
when :fail then add_error(record, attribute, :judge_unavailable, nil)
|
|
200
|
+
when :raise then raise error
|
|
201
|
+
else log_skipped(record, attribute, error)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def log_skipped(record, attribute, error)
|
|
206
|
+
Judge::Rails.logger&.warn("[judge] #{record.class} #{attribute} not checked: " \
|
|
207
|
+
"#{error.class}: #{error.message}")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def add_error(record, attribute, type, message)
|
|
211
|
+
details = { instruction: instruction }
|
|
212
|
+
message ||= english_fallback(type)
|
|
213
|
+
details[:message] = message if message
|
|
214
|
+
details[:strict] = options[:strict] if options[:strict]
|
|
215
|
+
record.errors.add(attribute, type, **details)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def english_fallback(type)
|
|
219
|
+
key = :"errors.messages.#{type}"
|
|
220
|
+
return if I18n.exists?(key)
|
|
221
|
+
|
|
222
|
+
I18n.t(key, locale: :en, instruction: instruction)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def context_match?(record)
|
|
226
|
+
contexts = Array(record.send(:validation_context)).map(&:to_s)
|
|
227
|
+
on = options[:on]
|
|
228
|
+
except_on = options[:except_on]
|
|
229
|
+
return false if on && !Array(on).map(&:to_s).intersect?(contexts)
|
|
230
|
+
return false if except_on && Array(except_on).map(&:to_s).intersect?(contexts)
|
|
231
|
+
|
|
232
|
+
true
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def run_proc(record, condition)
|
|
236
|
+
condition.arity.zero? ? record.instance_exec(&condition) : record.instance_exec(record, &condition)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def evaluate_condition(record, condition)
|
|
240
|
+
case condition
|
|
241
|
+
when Symbol, String then record.send(condition)
|
|
242
|
+
when Proc then run_proc(record, condition)
|
|
243
|
+
else condition.respond_to?(:validate) ? condition.validate(record) : condition.call(record)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
data/lib/judge/rails.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "judge"
|
|
4
|
+
require "active_support"
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "judge/rails/definition"
|
|
7
|
+
require "judge/rails/registry"
|
|
8
|
+
require "judge/rails/storage"
|
|
9
|
+
require "judge/rails/attributes"
|
|
10
|
+
require "judge/rails/migration"
|
|
11
|
+
require "judge/rails/refresh"
|
|
12
|
+
require "judge/rails/jobs"
|
|
13
|
+
require "judge/rails/scopes"
|
|
14
|
+
require "judge/rails/relation"
|
|
15
|
+
require "judge/rails/validator"
|
|
16
|
+
|
|
17
|
+
module Judge
|
|
18
|
+
module Rails
|
|
19
|
+
CONCERNS = [Attributes, Refresh, Jobs, Scopes, Validator].freeze
|
|
20
|
+
LEASE_REGISTRY_LOCK = Mutex.new
|
|
21
|
+
|
|
22
|
+
def self.logger
|
|
23
|
+
Judge.config.logger || ActiveRecord::Base.logger
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.log_failure(context, error)
|
|
27
|
+
logger&.error("[judge] refresh failed for #{context}: #{error.class}: #{error.message}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Judge::Pool.on_worker_exit do
|
|
33
|
+
Judge::Rails::LEASE_REGISTRY_LOCK.synchronize do
|
|
34
|
+
ActiveRecord::Base.connection_handler.connection_pool_list(:all).each do |pool|
|
|
35
|
+
pool.release_connection if pool.active_connection?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
ActiveSupport.on_load(:active_record) do
|
|
41
|
+
Judge::Rails::CONCERNS.each { |mod| include mod }
|
|
42
|
+
extend Judge::Rails::Relation::ClassMethods
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
ActiveSupport.on_load(:active_job) { require "judge/rails/refresh_job" }
|
data/lib/judge/result.rb
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
class Result
|
|
5
|
+
UNSURE = :unsure
|
|
6
|
+
|
|
7
|
+
TYPES = %w[noul choice score].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :name, :question, :raw, :type, :value, :probability
|
|
10
|
+
|
|
11
|
+
def self.from_values(name:, type:, value:, question: nil, confidence: nil,
|
|
12
|
+
probabilities: nil, legend: nil)
|
|
13
|
+
kind = type.to_s
|
|
14
|
+
payload = { "type" => kind, kind => value }
|
|
15
|
+
payload["confidence"] = confidence unless confidence.nil?
|
|
16
|
+
payload["probabilities"] = probabilities.to_h { |k, v| [k.to_s, v] } if probabilities
|
|
17
|
+
payload["legend"] = legend.to_h { |k, v| [k.to_s, v] } if legend
|
|
18
|
+
new(name: name, question: question, payload: payload)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.frozen_copy(value)
|
|
22
|
+
case value
|
|
23
|
+
when Hash then value.to_h { |k, v| [frozen_copy(k), frozen_copy(v)] }.freeze
|
|
24
|
+
when Array then value.map { |v| frozen_copy(v) }.freeze
|
|
25
|
+
when String then value.dup.freeze
|
|
26
|
+
else value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(name:, question:, payload:)
|
|
31
|
+
unless payload.is_a?(Hash)
|
|
32
|
+
raise InvalidResponseError, "expected an answer object for #{name.inspect}, got #{payload.class}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@name = name&.to_sym
|
|
36
|
+
@question = question
|
|
37
|
+
@raw = self.class.frozen_copy(payload)
|
|
38
|
+
@type = resolve_type
|
|
39
|
+
check_shape!
|
|
40
|
+
@value = parse_value
|
|
41
|
+
@probability = compute_probability
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def confidence
|
|
46
|
+
raw["confidence"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def probabilities
|
|
50
|
+
raw["probabilities"] || {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def legend
|
|
54
|
+
raw["legend"] || {}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def level
|
|
58
|
+
return unless type == "score"
|
|
59
|
+
|
|
60
|
+
value.round
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def label
|
|
64
|
+
return unless type == "score"
|
|
65
|
+
|
|
66
|
+
legend[level.to_s]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def probability_of(option)
|
|
70
|
+
probabilities[option.to_s]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def true?(threshold = 0.5)
|
|
74
|
+
unless type == "noul"
|
|
75
|
+
raise ArgumentError,
|
|
76
|
+
"true? is only meaningful for noul answers, this is a #{type}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
value >= threshold
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def confident?(threshold = 0.8)
|
|
83
|
+
(confidence || probability || 0) >= threshold
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def decide(above:, below: nil)
|
|
87
|
+
raise ArgumentError, "decide needs a numeric probability" if probability.nil?
|
|
88
|
+
|
|
89
|
+
Decision.call(probability, above: above, below: below)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def to_h
|
|
93
|
+
{ name: name, type: type, value: value, probability: probability, confidence: confidence }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def inspect
|
|
97
|
+
"#<Judge::Result #{name.inspect} #{type} value=#{value.inspect} p=#{probability.inspect}>"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def resolve_type
|
|
103
|
+
kind = raw["type"] || question&.type
|
|
104
|
+
unless TYPES.include?(kind)
|
|
105
|
+
raise InvalidResponseError,
|
|
106
|
+
"unknown answer type #{kind.inspect} for #{name.inspect}"
|
|
107
|
+
end
|
|
108
|
+
if question && question.type != kind
|
|
109
|
+
raise InvalidResponseError, "a #{question.type} question got a #{kind} answer for #{name.inspect}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
kind
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def check_shape!
|
|
116
|
+
unless raw["probabilities"].nil? || raw["probabilities"].is_a?(Hash)
|
|
117
|
+
raise InvalidResponseError, "probabilities for #{name.inspect} must be an object"
|
|
118
|
+
end
|
|
119
|
+
unless raw["legend"].nil? || raw["legend"].is_a?(Hash)
|
|
120
|
+
raise InvalidResponseError, "legend for #{name.inspect} must be an object"
|
|
121
|
+
end
|
|
122
|
+
return if raw["confidence"].nil? || raw["confidence"].is_a?(Numeric)
|
|
123
|
+
|
|
124
|
+
raise InvalidResponseError, "confidence for #{name.inspect} must be a number"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def parse_value
|
|
128
|
+
case type
|
|
129
|
+
when "noul" then number("noul")
|
|
130
|
+
when "choice" then choice_value
|
|
131
|
+
when "score" then score_value
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def compute_probability
|
|
136
|
+
case type
|
|
137
|
+
when "noul" then value
|
|
138
|
+
when "choice" then probabilities[value]
|
|
139
|
+
when "score" then probabilities[level.to_s]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def choice_value
|
|
144
|
+
choice = raw["choice"]
|
|
145
|
+
raise InvalidResponseError, "missing choice in answer #{name.inspect}" unless choice.is_a?(String)
|
|
146
|
+
if question.respond_to?(:options) && !question.options.include?(choice)
|
|
147
|
+
raise InvalidResponseError, "#{choice.inspect} is not an option of #{name.inspect}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
choice
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def score_value
|
|
154
|
+
score = number("score")
|
|
155
|
+
if question.respond_to?(:max_level) && !score.between?(0, question.max_level)
|
|
156
|
+
raise InvalidResponseError, "score #{score} for #{name.inspect} is outside 0..#{question.max_level}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
score
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def number(key)
|
|
163
|
+
v = raw[key]
|
|
164
|
+
raise InvalidResponseError, "missing #{key} in answer #{name.inspect}" if v.nil?
|
|
165
|
+
unless v.is_a?(Numeric)
|
|
166
|
+
raise InvalidResponseError,
|
|
167
|
+
"#{key} in answer #{name.inspect} is not a number: #{v.inspect}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
Float(v)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
class ResultSet
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
Usage = Struct.new(:input_tokens, :output_tokens) do
|
|
8
|
+
def total
|
|
9
|
+
input_tokens.to_i + output_tokens.to_i
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :model, :usage, :latency, :raw
|
|
14
|
+
|
|
15
|
+
def initialize(results, model: nil, usage: nil, latency: nil, raw: nil)
|
|
16
|
+
@results = results.to_h { |r| [r.name, r] }.freeze
|
|
17
|
+
raise ArgumentError, "duplicate result names" if @results.size != results.size
|
|
18
|
+
|
|
19
|
+
@model = model
|
|
20
|
+
@usage = usage
|
|
21
|
+
@latency = latency
|
|
22
|
+
@raw = raw
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.from_response(body, questions:, latency: nil)
|
|
27
|
+
answers = body["answers"]
|
|
28
|
+
raise InvalidResponseError, "response has no answers" unless answers.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
results = questions.map do |name, question|
|
|
31
|
+
payload = answers[name.to_s]
|
|
32
|
+
raise InvalidResponseError, "no answer for #{name.inspect}" if payload.nil?
|
|
33
|
+
|
|
34
|
+
question.coerce(payload, name: name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
usage = parse_usage(body["usage"])
|
|
38
|
+
new(results, model: body["model"], usage: usage, latency: latency, raw: body)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.parse_usage(raw)
|
|
42
|
+
return unless raw.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
Usage.new(raw["input_tokens"], raw["output_tokens"]).freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def [](name)
|
|
48
|
+
@results[name.to_sym]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fetch(name)
|
|
52
|
+
@results.fetch(name.to_sym)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def each(&)
|
|
56
|
+
@results.each_value(&)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def names
|
|
60
|
+
@results.keys
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def size
|
|
64
|
+
@results.size
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_h
|
|
68
|
+
@results.transform_values(&:to_h)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def inspect
|
|
72
|
+
"#<Judge::ResultSet #{@results.keys.inspect} model=#{model.inspect} latency=#{latency&.round(3)}>"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/judge.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "judge/version"
|
|
4
|
+
require "judge/errors"
|
|
5
|
+
require "judge/configuration"
|
|
6
|
+
require "judge/question"
|
|
7
|
+
require "judge/question/noul"
|
|
8
|
+
require "judge/question/choice"
|
|
9
|
+
require "judge/question/score"
|
|
10
|
+
require "judge/result"
|
|
11
|
+
require "judge/result_set"
|
|
12
|
+
require "judge/decision"
|
|
13
|
+
require "judge/facade"
|
|
14
|
+
require "judge/adapter"
|
|
15
|
+
require "judge/pool"
|
|
16
|
+
|
|
17
|
+
module Judge
|
|
18
|
+
extend Facade
|
|
19
|
+
|
|
20
|
+
autoload :Client, "judge/client"
|
|
21
|
+
|
|
22
|
+
@config_lock = Mutex.new
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def config
|
|
26
|
+
@config || @config_lock.synchronize { @config ||= Configuration.new }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def configure
|
|
30
|
+
yield config
|
|
31
|
+
config
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reset_config!
|
|
35
|
+
@config = Configuration.new
|
|
36
|
+
self.adapter = nil
|
|
37
|
+
Adapter.reset_built!
|
|
38
|
+
config
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/judge_rails.rb
ADDED