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
data/lib/judge/facade.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
module Facade
|
|
5
|
+
DEFAULT_NAME = :answer
|
|
6
|
+
|
|
7
|
+
def noul(instructions, criteria = nil, name: nil)
|
|
8
|
+
Question::Noul.new(instructions, criteria, name: name)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def choice(instructions, options, name: nil)
|
|
12
|
+
Question::Choice.new(instructions, options, name: name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def score(instructions, levels, name: nil)
|
|
16
|
+
Question::Score.new(instructions, levels, name: name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ask(questions, text:, model: nil, adapter: nil)
|
|
20
|
+
single = single_question?(questions)
|
|
21
|
+
normalized = normalize_questions(questions)
|
|
22
|
+
raise ArgumentError, "ask needs at least one question" if normalized.empty?
|
|
23
|
+
|
|
24
|
+
results = (adapter || self.adapter).call(state: text, questions: normalized, model: model)
|
|
25
|
+
return results unless single
|
|
26
|
+
|
|
27
|
+
name = normalized.keys.first
|
|
28
|
+
results[name] || raise(InvalidResponseError, "no answer for #{name.inspect}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def adapter
|
|
32
|
+
@adapter || Adapter.resolve(config.adapter)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_writer :adapter
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def normalize_questions(input)
|
|
40
|
+
case input
|
|
41
|
+
when Question then { input.name || DEFAULT_NAME => input }
|
|
42
|
+
when String then { DEFAULT_NAME => noul(input) }
|
|
43
|
+
when Hash then named_from_hash(input)
|
|
44
|
+
when Array then named_from_array(input)
|
|
45
|
+
else raise ArgumentError, "expected a Question, String, Array or Hash, got #{input.class}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def named_from_hash(input)
|
|
50
|
+
named = input.to_h { |name, q| [name.to_sym, coerce_question(q).with_name(name)] }
|
|
51
|
+
raise ArgumentError, "duplicate question names" if named.size != input.size
|
|
52
|
+
|
|
53
|
+
named
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def single_question?(input)
|
|
57
|
+
input.is_a?(Question) || input.is_a?(String)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def coerce_question(value)
|
|
61
|
+
value.is_a?(String) ? noul(value) : value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def named_from_array(list)
|
|
65
|
+
named = list.each_with_index.to_h do |q, i|
|
|
66
|
+
question = coerce_question(q)
|
|
67
|
+
name = question.name || :"q#{i}"
|
|
68
|
+
[name, question.with_name(name)]
|
|
69
|
+
end
|
|
70
|
+
raise ArgumentError, "duplicate question names" if named.size != list.size
|
|
71
|
+
|
|
72
|
+
named
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/judge/pool.rb
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
module Pool
|
|
5
|
+
DEFAULT_CONCURRENCY = 8
|
|
6
|
+
THREAD_NAME = "judge-pool"
|
|
7
|
+
|
|
8
|
+
@worker_exit_hooks = []
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def map(items, concurrency: nil, &work)
|
|
12
|
+
list = items.to_a
|
|
13
|
+
return [] if list.empty?
|
|
14
|
+
|
|
15
|
+
workers = (concurrency || Judge.config.concurrency || DEFAULT_CONCURRENCY).to_i
|
|
16
|
+
return list.map(&work) if workers <= 1 || list.one?
|
|
17
|
+
|
|
18
|
+
fan_out(list, [workers, list.size].min, &work)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def on_worker_exit(&hook)
|
|
22
|
+
@worker_exit_hooks << hook unless @worker_exit_hooks.include?(hook)
|
|
23
|
+
hook
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def fan_out(list, workers, &work)
|
|
29
|
+
queue = Queue.new
|
|
30
|
+
list.each_with_index { |item, index| queue << [index, item] }
|
|
31
|
+
results = Array.new(list.size)
|
|
32
|
+
errors = []
|
|
33
|
+
lock = Mutex.new
|
|
34
|
+
|
|
35
|
+
threads = Array.new(workers) do
|
|
36
|
+
spawn(queue) do
|
|
37
|
+
while (job = pop(queue))
|
|
38
|
+
index, item = job
|
|
39
|
+
begin
|
|
40
|
+
value = work.call(item)
|
|
41
|
+
lock.synchronize { results[index] = value }
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
lock.synchronize { errors << e }
|
|
44
|
+
drain(queue)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
threads.each(&:join)
|
|
50
|
+
|
|
51
|
+
raise errors.first unless errors.empty?
|
|
52
|
+
|
|
53
|
+
results
|
|
54
|
+
ensure
|
|
55
|
+
drain(queue) if queue
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def spawn(queue, &body)
|
|
59
|
+
tags = log_tags
|
|
60
|
+
thread = Thread.new do
|
|
61
|
+
finished = false
|
|
62
|
+
tagged(tags, &body)
|
|
63
|
+
finished = true
|
|
64
|
+
ensure
|
|
65
|
+
drain(queue) unless finished
|
|
66
|
+
@worker_exit_hooks.each(&:call)
|
|
67
|
+
end
|
|
68
|
+
thread.name = THREAD_NAME
|
|
69
|
+
thread.report_on_exception = false
|
|
70
|
+
thread
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def log_tags
|
|
74
|
+
logger = Judge.config.logger
|
|
75
|
+
formatter = logger.respond_to?(:formatter) && logger.formatter
|
|
76
|
+
formatter.respond_to?(:current_tags) ? formatter.current_tags.dup : []
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def tagged(tags, &)
|
|
80
|
+
logger = Judge.config.logger
|
|
81
|
+
tags.empty? || !logger.respond_to?(:tagged) ? yield : logger.tagged(*tags, &)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pop(queue)
|
|
85
|
+
queue.pop(true)
|
|
86
|
+
rescue ThreadError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def drain(queue)
|
|
91
|
+
loop { queue.pop(true) }
|
|
92
|
+
rescue ThreadError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
class Question
|
|
5
|
+
class Choice < Question
|
|
6
|
+
TYPE = "choice"
|
|
7
|
+
|
|
8
|
+
def options
|
|
9
|
+
criteria.keys
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def normalize(criteria)
|
|
15
|
+
options = case criteria
|
|
16
|
+
when Hash then entries(criteria)
|
|
17
|
+
when Array then array_options(criteria)
|
|
18
|
+
else
|
|
19
|
+
raise ArgumentError,
|
|
20
|
+
"choice criteria must be an array of options or a hash of option => description"
|
|
21
|
+
end
|
|
22
|
+
raise ArgumentError, "choice needs at least two options" if options.size < 2
|
|
23
|
+
|
|
24
|
+
options
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def array_options(list)
|
|
28
|
+
names = list.map(&:to_s)
|
|
29
|
+
duplicates = names.select { |name| names.count(name) > 1 }.uniq
|
|
30
|
+
raise ArgumentError, "choice options #{duplicates.inspect} appear twice" if duplicates.any?
|
|
31
|
+
|
|
32
|
+
names.to_h { |name| [name.dup.freeze, name.tr("_", " ").freeze] }.freeze
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
class Question
|
|
5
|
+
class Noul < Question
|
|
6
|
+
TYPE = "noul"
|
|
7
|
+
KEYS = %w[true false].freeze
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def normalize(criteria)
|
|
12
|
+
return nil if criteria.nil?
|
|
13
|
+
|
|
14
|
+
unless criteria.is_a?(Hash) && criteria.keys.map(&:to_s).sort == KEYS.sort
|
|
15
|
+
raise ArgumentError, "noul criteria must be a hash with exactly 'true' and 'false' keys"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
entries(criteria)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
class Question
|
|
5
|
+
class Score < Question
|
|
6
|
+
TYPE = "score"
|
|
7
|
+
|
|
8
|
+
def levels
|
|
9
|
+
criteria
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def max_level
|
|
13
|
+
criteria.size - 1
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def normalize(criteria)
|
|
19
|
+
levels = criteria.is_a?(Range) ? criteria.to_a : criteria
|
|
20
|
+
unless levels.is_a?(Array)
|
|
21
|
+
raise ArgumentError,
|
|
22
|
+
"score criteria must be an array or range of ordered levels"
|
|
23
|
+
end
|
|
24
|
+
raise ArgumentError, "score needs at least two levels" if levels.size < 2
|
|
25
|
+
unless levels.all? { |level| level.is_a?(String) || level.is_a?(Symbol) || level.is_a?(Numeric) }
|
|
26
|
+
raise ArgumentError, "score levels must be strings, symbols or numbers"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
labels = levels.map { |level| level.to_s.dup.freeze }
|
|
30
|
+
raise ArgumentError, "score levels must be distinct" if labels.uniq.size != labels.size
|
|
31
|
+
|
|
32
|
+
labels.freeze
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Judge
|
|
7
|
+
class Question
|
|
8
|
+
attr_reader :name, :instructions, :criteria, :digest
|
|
9
|
+
|
|
10
|
+
def self.type
|
|
11
|
+
self::TYPE
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(instructions, criteria = nil, name: nil)
|
|
15
|
+
raise ArgumentError, "instructions must not be empty" if instructions.to_s.strip.empty?
|
|
16
|
+
|
|
17
|
+
@instructions = instructions.to_s.dup.freeze
|
|
18
|
+
@criteria = normalize(criteria).freeze
|
|
19
|
+
@name = name&.to_sym
|
|
20
|
+
@digest = Digest::SHA256.hexdigest(JSON.generate([type, @instructions, @criteria]))[0, 16].freeze
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def type
|
|
25
|
+
self.class.type
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def with_name(new_name)
|
|
29
|
+
return self if @name == new_name&.to_sym
|
|
30
|
+
|
|
31
|
+
self.class.new(@instructions, @criteria, name: new_name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_payload
|
|
35
|
+
payload = { "type" => type, "instructions" => @instructions }
|
|
36
|
+
payload["criteria"] = @criteria unless @criteria.nil?
|
|
37
|
+
payload
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def coerce(answer, name: @name)
|
|
41
|
+
Result.new(name: name, question: self, payload: answer)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ==(other)
|
|
45
|
+
other.is_a?(Question) && other.type == type && other.instructions == instructions &&
|
|
46
|
+
other.criteria == criteria && other.name == name
|
|
47
|
+
end
|
|
48
|
+
alias eql? ==
|
|
49
|
+
|
|
50
|
+
def hash
|
|
51
|
+
[self.class, @instructions, @criteria, @name].hash
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def inspect
|
|
55
|
+
"#<Judge::Question::#{self.class.name.split("::").last} #{@name.inspect} #{@instructions.inspect}>"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def normalize(criteria)
|
|
61
|
+
criteria
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def entries(hash)
|
|
65
|
+
hash.to_h { |key, value| [key.to_s, entry(value, nested: false)] }.tap do |normalized|
|
|
66
|
+
duplicate_keys!(hash, normalized)
|
|
67
|
+
end.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def entry(value, nested: true)
|
|
71
|
+
case value
|
|
72
|
+
when Hash then nested_entries(value)
|
|
73
|
+
when Array then value.map { |item| entry(item) }.freeze
|
|
74
|
+
when Numeric, true, false then nested ? value : value.to_s.freeze
|
|
75
|
+
else value.to_s.dup.freeze
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def nested_entries(hash)
|
|
80
|
+
normalized = hash.compact.to_h { |key, value| [key.to_s, entry(value)] }
|
|
81
|
+
duplicate_keys!(hash.compact, normalized)
|
|
82
|
+
normalized.freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def duplicate_keys!(original, normalized)
|
|
86
|
+
return if original.size == normalized.size
|
|
87
|
+
|
|
88
|
+
keys = original.keys.map(&:to_s)
|
|
89
|
+
duplicates = keys.select { |key| keys.count(key) > 1 }.uniq
|
|
90
|
+
raise ArgumentError, "criteria keys #{duplicates.inspect} appear twice once converted to strings"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
module Rails
|
|
5
|
+
module Attributes
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
define_model_callbacks :judge_refresh, only: :after
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def judge_attributes
|
|
14
|
+
@judge_attributes ||=
|
|
15
|
+
if superclass.respond_to?(:judge_attributes)
|
|
16
|
+
superclass.judge_attributes.inherit
|
|
17
|
+
else
|
|
18
|
+
Registry.new
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def judge_source(source = nil, &block)
|
|
23
|
+
@judge_default_source = block || source
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def judge_default_source
|
|
27
|
+
return @judge_default_source if @judge_default_source
|
|
28
|
+
|
|
29
|
+
superclass.judge_default_source if superclass.respond_to?(:judge_default_source)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def judge_attribute(name, question, source: nil, **options)
|
|
33
|
+
resolved = source || judge_default_source
|
|
34
|
+
unless resolved
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
"judge_attribute #{name.inspect} needs a source: or a model-level judge_source"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
definition = Definition.new(name: name, question: question, source: resolved, **options)
|
|
40
|
+
judge_attributes.add(definition)
|
|
41
|
+
define_judge_methods(definition)
|
|
42
|
+
judge_install_callbacks(definition) if respond_to?(:judge_install_callbacks)
|
|
43
|
+
judge_define_scopes(definition) if respond_to?(:judge_define_scopes)
|
|
44
|
+
definition
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def judge_definition(name)
|
|
48
|
+
judge_attributes.fetch(name)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def define_judge_methods(definition)
|
|
54
|
+
name = definition.name
|
|
55
|
+
|
|
56
|
+
define_method(:"#{name}_judge_meta") { judge_meta(name) }
|
|
57
|
+
define_method(:"#{name}_probability") { judge_meta(name)["probability"] }
|
|
58
|
+
define_method(:"#{name}_confidence") { judge_meta(name)["confidence"] }
|
|
59
|
+
define_method(:"#{name}_computed_at") { judge_computed_at(name) }
|
|
60
|
+
define_method(:"#{name}_stale?") { judge_stale?(name) }
|
|
61
|
+
|
|
62
|
+
return unless definition.type == "noul"
|
|
63
|
+
|
|
64
|
+
define_method(:"#{name}?") do |threshold = 0.5|
|
|
65
|
+
value = read_attribute(name)
|
|
66
|
+
!value.nil? && value >= threshold
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def judge_meta(name)
|
|
72
|
+
Storage.sidecar(self, self.class.judge_definition(name))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def judge_computed_at(name)
|
|
76
|
+
raw = judge_meta(name)["computed_at"]
|
|
77
|
+
raw.is_a?(String) ? Time.parse(raw) : raw
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def judge_stale?(name = nil)
|
|
81
|
+
Storage.stale_definitions(self, judge_definitions_for(Array(name))).any?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def judge_pending
|
|
85
|
+
Storage.stale_definitions(self, judge_definitions_for([])).map(&:name)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def judge_refresh(*names, force: false, adapter: nil, &)
|
|
89
|
+
Storage.compute(self, judge_definitions_for(names), force: force, adapter: adapter, &).map(&:name)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def judge_refresh!(*names, force: false, adapter: nil, &)
|
|
93
|
+
done = []
|
|
94
|
+
Storage.compute(self, judge_definitions_for(names), force: force, adapter: adapter, done: done, &)
|
|
95
|
+
rescue StandardError
|
|
96
|
+
judge_persist_after_failure(done) if done.any?
|
|
97
|
+
raise
|
|
98
|
+
else
|
|
99
|
+
judge_persist_judgments(done, adapter: adapter) if done.any?
|
|
100
|
+
done.map(&:name)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def judge_decide(name, above:, below: nil)
|
|
104
|
+
definition = self.class.judge_definition(name)
|
|
105
|
+
probability = if definition.type == "noul"
|
|
106
|
+
read_attribute(definition.value_column)
|
|
107
|
+
else
|
|
108
|
+
judge_meta(name)["probability"]
|
|
109
|
+
end
|
|
110
|
+
raise Judge::Error, "#{name} has not been computed yet" if probability.nil?
|
|
111
|
+
|
|
112
|
+
Judge::Decision.call(probability, above: above, below: below)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def judge_definitions_for(names)
|
|
118
|
+
names = names.flatten
|
|
119
|
+
return self.class.judge_attributes.to_a if names.empty?
|
|
120
|
+
|
|
121
|
+
names.map { |n| self.class.judge_definition(n) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def judge_persist_judgments(definitions, adapter: nil, inline: true)
|
|
125
|
+
return judge_create_with_judgments(adapter, inline) if new_record?
|
|
126
|
+
|
|
127
|
+
run_callbacks(:judge_refresh) do
|
|
128
|
+
next if update_columns(judge_columns_to_store(definitions))
|
|
129
|
+
|
|
130
|
+
raise ActiveRecord::RecordNotFound.new(
|
|
131
|
+
"#{self.class} #{id.inspect} no longer exists, so its new judgments were not stored",
|
|
132
|
+
self.class.name, self.class.primary_key, id
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def judge_persist_after_failure(definitions)
|
|
138
|
+
judge_persist_judgments(definitions, inline: false)
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
Judge::Rails.log_failure(self.class, e)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def judge_columns_to_store(definitions)
|
|
144
|
+
columns = definitions.flat_map { |d| [d.value_column, d.sidecar_column] }
|
|
145
|
+
.to_h { |c| [c, read_attribute(c)] }
|
|
146
|
+
now = current_time_from_proper_timezone
|
|
147
|
+
self.class.timestamp_attributes_for_update_in_model.each { |column| columns[column.to_sym] = now }
|
|
148
|
+
columns
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def judge_create_with_judgments(adapter, inline)
|
|
152
|
+
@judge_refresh_adapter = adapter
|
|
153
|
+
@judge_skip_inline = !inline
|
|
154
|
+
run_callbacks(:judge_refresh) { save! }
|
|
155
|
+
ensure
|
|
156
|
+
@judge_refresh_adapter = nil
|
|
157
|
+
@judge_skip_inline = nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Judge
|
|
6
|
+
module Rails
|
|
7
|
+
class Definition
|
|
8
|
+
CALLBACK_MODES = %i[async inline disabled].freeze
|
|
9
|
+
ERROR_MODES = %i[pass fail raise].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :name, :question, :source, :model, :callbacks, :if_condition, :on_error
|
|
12
|
+
|
|
13
|
+
def initialize(name:, question:, source:, model: nil, sync: false, callbacks: nil,
|
|
14
|
+
if_condition: nil, on_error: :pass)
|
|
15
|
+
@name = name.to_sym
|
|
16
|
+
@question = question.with_name(@name)
|
|
17
|
+
@source = validate_source!(source)
|
|
18
|
+
@model = model
|
|
19
|
+
@callbacks = normalize_callbacks(callbacks, sync)
|
|
20
|
+
@if_condition = validate_condition!(if_condition)
|
|
21
|
+
@on_error = validate!(on_error.to_sym, ERROR_MODES, "on_error")
|
|
22
|
+
validate_error_mode!
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def value_column
|
|
27
|
+
@name
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def sidecar_column
|
|
31
|
+
:"#{@name}_judge"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def type
|
|
35
|
+
@question.type
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def digest
|
|
39
|
+
model = effective_model
|
|
40
|
+
return @question.digest if model == Judge::Configuration::DEFAULT_MODEL
|
|
41
|
+
|
|
42
|
+
Digest::SHA256.hexdigest("#{@question.digest}:#{model}")[0, 16]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def effective_model
|
|
46
|
+
@model || Judge.config.model
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def sync?
|
|
50
|
+
@callbacks == :inline
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def enqueue?
|
|
54
|
+
@callbacks == :async
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def state_for(record)
|
|
58
|
+
text = case @source
|
|
59
|
+
when Symbol, String then record.public_send(@source)
|
|
60
|
+
else evaluate(@source, record)
|
|
61
|
+
end
|
|
62
|
+
Array(text).reject { |part| part.to_s.strip.empty? }.join("\n\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def state_digest(state)
|
|
66
|
+
Digest::SHA256.hexdigest(state.to_s)[0, 16]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def applies_to?(record)
|
|
70
|
+
return true if @if_condition.nil?
|
|
71
|
+
|
|
72
|
+
@if_condition.is_a?(Proc) ? evaluate(@if_condition, record) : record.public_send(@if_condition)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def cast(result)
|
|
76
|
+
case type
|
|
77
|
+
when "noul", "score" then result.value
|
|
78
|
+
when "choice" then result.value.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def sidecar(result, state_digest:, model: nil, latency: nil)
|
|
83
|
+
{
|
|
84
|
+
"digest" => digest,
|
|
85
|
+
"state_digest" => state_digest,
|
|
86
|
+
"computed_at" => Time.now.utc.iso8601,
|
|
87
|
+
"probability" => result.probability,
|
|
88
|
+
"confidence" => result.confidence,
|
|
89
|
+
"probabilities" => result.probabilities,
|
|
90
|
+
"legend" => result.legend.empty? ? nil : result.legend,
|
|
91
|
+
"model" => model,
|
|
92
|
+
"latency" => latency
|
|
93
|
+
}.compact
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def stale?(value:, sidecar:, state:)
|
|
97
|
+
return true if value.nil?
|
|
98
|
+
|
|
99
|
+
meta = sidecar || {}
|
|
100
|
+
return true if meta["digest"] != digest
|
|
101
|
+
|
|
102
|
+
meta["state_digest"] != state_digest(state)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def evaluate(callable, record)
|
|
108
|
+
callable.arity.zero? ? record.instance_exec(&callable) : callable.call(record)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_source!(source)
|
|
112
|
+
return source if source.is_a?(Symbol) || source.is_a?(String) || source.is_a?(Proc)
|
|
113
|
+
|
|
114
|
+
raise ArgumentError, "judge_attribute #{@name.inspect} source must be a Symbol, String or Proc, " \
|
|
115
|
+
"got #{source.class}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_condition!(condition)
|
|
119
|
+
return condition if condition.nil? || condition.is_a?(Symbol) || condition.is_a?(Proc)
|
|
120
|
+
|
|
121
|
+
raise ArgumentError, "if_condition must be a Symbol or a Proc, got #{condition.class}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def normalize_callbacks(callbacks, sync)
|
|
125
|
+
return :inline if sync && callbacks.nil?
|
|
126
|
+
return :async if callbacks.nil?
|
|
127
|
+
if sync
|
|
128
|
+
raise ArgumentError,
|
|
129
|
+
"sync: true already means inline callbacks; drop callbacks: #{callbacks.inspect}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
mode = case callbacks
|
|
133
|
+
when false then :disabled
|
|
134
|
+
when true then :async
|
|
135
|
+
else callbacks.to_s.to_sym
|
|
136
|
+
end
|
|
137
|
+
validate!(mode, CALLBACK_MODES, "callbacks")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_error_mode!
|
|
141
|
+
return if @on_error == :pass || @callbacks == :inline
|
|
142
|
+
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"on_error: #{@on_error.inspect} only applies to a synchronous attribute. " \
|
|
145
|
+
"#{@name.inspect} is #{@callbacks.inspect}, so the record is already committed by the time " \
|
|
146
|
+
"the call runs and nothing can be blocked. Pass sync: true, or leave on_error as :pass."
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate!(value, allowed, label)
|
|
150
|
+
return value if allowed.include?(value)
|
|
151
|
+
|
|
152
|
+
raise ArgumentError, "#{label} must be one of #{allowed.inspect}, got #{value.inspect}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|