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.
@@ -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
@@ -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" }
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ VERSION = "0.0.1"
5
+ 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "judge/rails"