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,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
module Rails
|
|
5
|
+
module Jobs
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
RETRYABLE_ERRORS = [Judge::RateLimitError, Judge::ServerError, Judge::TransportError,
|
|
9
|
+
ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout].freeze
|
|
10
|
+
|
|
11
|
+
Payload = Struct.new(:model, :ids, :names, keyword_init: true) do
|
|
12
|
+
def model_name
|
|
13
|
+
model.name
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def enqueuer
|
|
19
|
+
@enqueuer || method(:default_enqueue)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def enqueuer=(callable)
|
|
23
|
+
unless callable.respond_to?(:call)
|
|
24
|
+
raise ArgumentError, "enqueuer must respond to call. Use reset_enqueuer! to restore ActiveJob"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@enqueuer = callable
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reset_enqueuer!
|
|
31
|
+
@enqueuer = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def enqueue_record(record, names)
|
|
35
|
+
enqueuer.call(Payload.new(model: record.class, ids: [record.id], names: Array(names)))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def perform(payload, batch_size: 100, adapter: nil, raise_retryable: false)
|
|
39
|
+
model = payload.model.is_a?(String) ? payload.model.constantize : payload.model
|
|
40
|
+
names = payload.names
|
|
41
|
+
model.where(id: payload.ids).find_each(batch_size: batch_size) do |record|
|
|
42
|
+
refresh_safely(record, names, adapter: adapter, raise_retryable: raise_retryable)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def refresh_safely(record, names = [], adapter: nil, raise_retryable: false)
|
|
47
|
+
record.judge_refresh!(*names, adapter: adapter)
|
|
48
|
+
true
|
|
49
|
+
rescue *RETRYABLE_ERRORS => e
|
|
50
|
+
raise if raise_retryable
|
|
51
|
+
|
|
52
|
+
Judge::Rails.log_failure(record.class, e)
|
|
53
|
+
false
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
Judge::Rails.log_failure(record.class, e)
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def default_enqueue(payload)
|
|
62
|
+
refresh_job.perform_later(payload.model_name, payload.ids.first, payload.names.map(&:to_s))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def refresh_job
|
|
66
|
+
require "judge/rails/refresh_job" if defined?(::ActiveJob)
|
|
67
|
+
return Judge::Rails::RefreshJob if defined?(Judge::Rails::RefreshJob)
|
|
68
|
+
|
|
69
|
+
raise Judge::ConfigurationError,
|
|
70
|
+
"async judge attributes need ActiveJob or a custom Judge::Rails::Jobs.enqueuer. " \
|
|
71
|
+
"Otherwise declare them with sync: true or callbacks: false"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class_methods do
|
|
76
|
+
def judge_install_callbacks(definition)
|
|
77
|
+
judge_install_save_callback if definition.sync? || definition.enqueue?
|
|
78
|
+
judge_install_commit_callback if definition.enqueue?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def judge_install_save_callback
|
|
84
|
+
return if defined?(@judge_save_callback) && @judge_save_callback
|
|
85
|
+
|
|
86
|
+
@judge_save_callback = true
|
|
87
|
+
before_save :judge_before_save
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def judge_install_commit_callback
|
|
91
|
+
return if defined?(@judge_commit_callback) && @judge_commit_callback
|
|
92
|
+
|
|
93
|
+
@judge_commit_callback = true
|
|
94
|
+
after_commit :judge_enqueue_refresh, on: %i[create update]
|
|
95
|
+
after_rollback :judge_forget_flagged
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def judge_refresh_later(*names)
|
|
100
|
+
names = names.flatten
|
|
101
|
+
names = self.class.judge_attributes.names if names.empty?
|
|
102
|
+
Jobs.enqueue_record(self, names)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def judge_before_save
|
|
108
|
+
automatic = self.class.judge_attributes.select { |d| d.sync? || d.enqueue? }
|
|
109
|
+
pending, blank = Storage.plan(self, automatic)
|
|
110
|
+
blank.each { |definition| Storage.clear(self, definition) }
|
|
111
|
+
judge_flag_async(pending.keys.select(&:enqueue?))
|
|
112
|
+
return if @judge_skip_inline
|
|
113
|
+
|
|
114
|
+
Storage.groups(pending.select { |definition, _| definition.sync? }).each do |(state, model), group|
|
|
115
|
+
compute_group_inline(state, model, group)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def judge_flag_async(definitions)
|
|
120
|
+
(@judge_flagged_async ||= Set.new).merge(definitions.map(&:name))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def judge_forget_flagged
|
|
124
|
+
@judge_flagged_async = nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def compute_group_inline(state, model, group)
|
|
128
|
+
Storage.ask(self, state, model, group, adapter: @judge_refresh_adapter)
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
handle_inline_error(e, group)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def handle_inline_error(error, group)
|
|
134
|
+
modes = group.map(&:on_error)
|
|
135
|
+
raise error if modes.include?(:raise)
|
|
136
|
+
|
|
137
|
+
Judge::Rails.log_failure(self.class, error)
|
|
138
|
+
return unless modes.include?(:fail)
|
|
139
|
+
|
|
140
|
+
names = group.select { |d| d.on_error == :fail }.map(&:name)
|
|
141
|
+
errors.add(:base, "could not judge #{names.join(", ")}: #{error.class}")
|
|
142
|
+
throw :abort
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def judge_enqueue_refresh
|
|
146
|
+
flagged = @judge_flagged_async
|
|
147
|
+
judge_forget_flagged
|
|
148
|
+
return if flagged.nil? || flagged.empty?
|
|
149
|
+
|
|
150
|
+
definitions = flagged.map { |name| self.class.judge_definition(name) }
|
|
151
|
+
pending = Storage.stale_definitions(self, definitions)
|
|
152
|
+
Jobs.enqueue_record(self, pending.map(&:name)) if pending.any?
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module Judge
|
|
6
|
+
module Rails
|
|
7
|
+
module Migration
|
|
8
|
+
VALUE_TYPES = { noul: :float, score: :float, choice: :string }.freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def value_type(type)
|
|
12
|
+
VALUE_TYPES.fetch(type.to_s.to_sym) do
|
|
13
|
+
raise ArgumentError,
|
|
14
|
+
"unknown judge attribute type #{type.inspect}, expected one of #{VALUE_TYPES.keys.inspect}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def value_options(type, connection = nil)
|
|
19
|
+
value_type(type) == :float && mysql?(connection) ? { limit: 53 } : {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def nullable!(null)
|
|
23
|
+
return if null
|
|
24
|
+
|
|
25
|
+
raise ArgumentError, "a judge value column must allow NULL: it is empty until judged and cleared " \
|
|
26
|
+
"when its text goes blank"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def sidecar_column(name)
|
|
30
|
+
:"#{name}_judge"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def sidecar_type(connection = nil)
|
|
34
|
+
postgresql?(connection) ? :jsonb : :json
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def sidecar_options(connection = nil)
|
|
38
|
+
mysql?(connection) ? {} : { null: false, default: {} }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def postgresql?(connection = nil)
|
|
42
|
+
adapter_name(connection).start_with?("postg")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def mysql?(connection = nil)
|
|
46
|
+
adapter_name(connection).match?(/mysql|trilogy/)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def adapter_name(connection = nil)
|
|
50
|
+
target = connection.respond_to?(:delegate) ? connection.delegate : connection
|
|
51
|
+
target = ActiveRecord::Base.lease_connection unless target.respond_to?(:adapter_name)
|
|
52
|
+
target.adapter_name.to_s.downcase
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def judge_attribute(table, name, type, index: true, null: true)
|
|
57
|
+
Migration.nullable!(null)
|
|
58
|
+
add_column table, name, Migration.value_type(type), null: null,
|
|
59
|
+
**Migration.value_options(type, connection)
|
|
60
|
+
add_column table, Migration.sidecar_column(name), Migration.sidecar_type(connection),
|
|
61
|
+
**Migration.sidecar_options(connection)
|
|
62
|
+
add_index table, name if index
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def remove_judge_attribute(table, name, type = nil, index: true, null: true)
|
|
66
|
+
if type.nil? && connection.is_a?(ActiveRecord::Migration::CommandRecorder)
|
|
67
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
68
|
+
"remove_judge_attribute #{table.inspect}, #{name.inspect} needs its type " \
|
|
69
|
+
"(:noul, :choice or :score) to be reversible"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sidecar = Migration.sidecar_column(name)
|
|
73
|
+
remove_index table, name, if_exists: true if index
|
|
74
|
+
remove_column table, sidecar, Migration.sidecar_type(connection),
|
|
75
|
+
**Migration.sidecar_options(connection)
|
|
76
|
+
value_options = type ? Migration.value_options(type, connection) : {}
|
|
77
|
+
remove_column table, name, (type && Migration.value_type(type)), null: null, **value_options
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
module TableDefinition
|
|
81
|
+
def judge_attribute(name, type, index: true, null: true)
|
|
82
|
+
Migration.nullable!(null)
|
|
83
|
+
conn = judge_connection
|
|
84
|
+
column name, Migration.value_type(type), null: null, index: index,
|
|
85
|
+
**Migration.value_options(type, conn)
|
|
86
|
+
column Migration.sidecar_column(name), Migration.sidecar_type(conn),
|
|
87
|
+
**Migration.sidecar_options(conn)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def judge_connection
|
|
93
|
+
conn = instance_variable_get(:@conn)
|
|
94
|
+
conn if conn.respond_to?(:adapter_name)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
module Table
|
|
99
|
+
def judge_attribute(name, type, index: true, null: true)
|
|
100
|
+
Migration.nullable!(null)
|
|
101
|
+
column name, Migration.value_type(type), null: null, **Migration.value_options(type, @base)
|
|
102
|
+
column Migration.sidecar_column(name), Migration.sidecar_type(@base),
|
|
103
|
+
**Migration.sidecar_options(@base)
|
|
104
|
+
index name if index
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
ActiveSupport.on_load(:active_record) do
|
|
112
|
+
ActiveRecord::Migration.include(Judge::Rails::Migration)
|
|
113
|
+
ActiveRecord::ConnectionAdapters::TableDefinition.include(Judge::Rails::Migration::TableDefinition)
|
|
114
|
+
ActiveRecord::ConnectionAdapters::Table.include(Judge::Rails::Migration::Table)
|
|
115
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
module Rails
|
|
5
|
+
module Refresh
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
Summary = Struct.new(:records, :computed, :skipped, :failed, :calls, keyword_init: true) do
|
|
9
|
+
def to_h
|
|
10
|
+
{ records: records, computed: computed, skipped: skipped, failed: failed, calls: calls }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class_methods do
|
|
15
|
+
def judge_refresh_all(scope = all, batch_size: 100, force: false, resume: false, adapter: nil)
|
|
16
|
+
summary = Summary.new(records: 0, computed: 0, skipped: 0, failed: 0, calls: 0)
|
|
17
|
+
effective_force = force && !resume
|
|
18
|
+
scope.find_each(batch_size: batch_size) do |record|
|
|
19
|
+
summary.records += 1
|
|
20
|
+
judge_refresh_record(record, effective_force, adapter, summary)
|
|
21
|
+
end
|
|
22
|
+
summary
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def judge_refresh_record(record, force, adapter, summary)
|
|
28
|
+
changed = record.judge_refresh!(force: force, adapter: adapter) { summary.calls += 1 }
|
|
29
|
+
changed.empty? ? summary.skipped += 1 : summary.computed += 1
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
summary.failed += 1
|
|
32
|
+
Judge::Rails.log_failure(record.class, e)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
module Rails
|
|
5
|
+
class RefreshJob < ActiveJob::Base
|
|
6
|
+
queue_as { "default" }
|
|
7
|
+
retry_on(*Jobs::RETRYABLE_ERRORS, wait: :polynomially_longer, attempts: 5)
|
|
8
|
+
|
|
9
|
+
def perform(model_name, id, names = [])
|
|
10
|
+
payload = Jobs::Payload.new(model: model_name.constantize, ids: [id], names: names)
|
|
11
|
+
Jobs.perform(payload, raise_retryable: true)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
module Rails
|
|
5
|
+
class Registry
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(parent = nil)
|
|
9
|
+
@parent = parent
|
|
10
|
+
@own = {}
|
|
11
|
+
@version = 0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(definition)
|
|
15
|
+
@own[definition.name] = definition
|
|
16
|
+
@version += 1
|
|
17
|
+
definition
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def version
|
|
21
|
+
@version + (@parent ? @parent.version : 0)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def [](name)
|
|
25
|
+
@own[name.to_sym] || @parent&.[](name)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fetch(name)
|
|
29
|
+
self[name] || raise(ArgumentError, "no judge attribute named #{name.inspect}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_h
|
|
33
|
+
current = version
|
|
34
|
+
return @flattened if @flattened_version == current
|
|
35
|
+
|
|
36
|
+
@flattened_version = current
|
|
37
|
+
@flattened = (@parent ? @parent.to_h : {}).merge(@own).freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def each(&)
|
|
41
|
+
to_h.each_value(&)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def names
|
|
45
|
+
to_h.keys
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def empty?
|
|
49
|
+
to_h.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def inherit
|
|
53
|
+
self.class.new(self)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
module Rails
|
|
5
|
+
module Relation
|
|
6
|
+
ADHOC_NAME = :judge_adhoc
|
|
7
|
+
|
|
8
|
+
Target = Struct.new(:question, :option, :level) do
|
|
9
|
+
def measure(result)
|
|
10
|
+
case question.type
|
|
11
|
+
when "choice" then result.probability_of(option) || 0.0
|
|
12
|
+
else result.value
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def passes?(result, threshold)
|
|
17
|
+
return result.value >= level - 0.5 if question.type == "score"
|
|
18
|
+
|
|
19
|
+
measure(result) >= threshold
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module ClassMethods
|
|
24
|
+
def judge_filter(question, limit: nil, source: nil, threshold: 0.5, option: nil, at_least: nil,
|
|
25
|
+
model: nil, adapter: nil, concurrency: nil)
|
|
26
|
+
question = judge_adhoc_question(question)
|
|
27
|
+
target = judge_target(question, :judge_filter, option: option, at_least: at_least)
|
|
28
|
+
judge_check_threshold!(threshold)
|
|
29
|
+
judged = judge_judge(question, :judge_filter, limit: limit, source: source, model: model,
|
|
30
|
+
adapter: adapter, concurrency: concurrency)
|
|
31
|
+
judged.select { |_record, result| target.passes?(result, threshold) }.keys
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def judge_map(question, limit: nil, source: nil, model: nil, adapter: nil, concurrency: nil)
|
|
35
|
+
judge_judge(judge_adhoc_question(question), :judge_map, limit: limit, source: source, model: model,
|
|
36
|
+
adapter: adapter, concurrency: concurrency)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def judge_sort(question, limit: nil, source: nil, dir: :desc, option: nil, model: nil, adapter: nil,
|
|
40
|
+
concurrency: nil)
|
|
41
|
+
direction = dir.to_s.downcase.to_sym
|
|
42
|
+
unless %i[asc desc].include?(direction)
|
|
43
|
+
raise ArgumentError, "dir must be :asc or :desc, got #{dir.inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
question = judge_adhoc_question(question)
|
|
47
|
+
target = judge_target(question, :judge_sort, option: option, at_least: :any)
|
|
48
|
+
judged = judge_judge(question, :judge_sort, limit: limit, source: source, model: model,
|
|
49
|
+
adapter: adapter, concurrency: concurrency)
|
|
50
|
+
sign = direction == :asc ? 1 : -1
|
|
51
|
+
judged.sort_by { |_record, result| sign * target.measure(result) }.map(&:first)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def judge_adhoc_question(question)
|
|
57
|
+
question.is_a?(Judge::Question) ? question : Judge.noul(question.to_s)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def judge_target(question, caller_name, option:, at_least:)
|
|
61
|
+
case question.type
|
|
62
|
+
when "choice" then Target.new(question, judge_target_option(question, option, caller_name), nil)
|
|
63
|
+
when "score" then Target.new(question, nil, judge_target_level(question, at_least, caller_name))
|
|
64
|
+
else Target.new(question, nil, nil)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def judge_target_option(question, option, caller_name)
|
|
69
|
+
return option.to_s if question.options.include?(option.to_s)
|
|
70
|
+
|
|
71
|
+
raise ArgumentError, "#{caller_name} with a choice question needs option: one of " \
|
|
72
|
+
"#{question.options.inspect}, got #{option.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def judge_target_level(question, at_least, caller_name)
|
|
76
|
+
return nil if at_least == :any
|
|
77
|
+
|
|
78
|
+
if at_least.nil?
|
|
79
|
+
raise ArgumentError, "#{caller_name} with a score question needs at_least: one of " \
|
|
80
|
+
"#{question.levels.inspect}"
|
|
81
|
+
end
|
|
82
|
+
judge_level(at_least, question.levels, caller_name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def judge_check_threshold!(threshold)
|
|
86
|
+
return if threshold.is_a?(Numeric) && threshold.between?(0, 1)
|
|
87
|
+
|
|
88
|
+
raise ArgumentError, "threshold must be a number between 0 and 1, got #{threshold.inspect}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def judge_judge(question, caller_name, limit:, source:, model:, adapter:, concurrency:)
|
|
92
|
+
judge_check_limit!(limit, caller_name)
|
|
93
|
+
definition = Definition.new(name: ADHOC_NAME, question: question,
|
|
94
|
+
source: judge_adhoc_source(source, caller_name))
|
|
95
|
+
|
|
96
|
+
records = all.limit([all.limit_value&.then { |value| Integer(value) }, limit].compact.min).to_a
|
|
97
|
+
# Built here so the workers below never check out a database connection.
|
|
98
|
+
judgeable = records.map { |record| [record, definition.state_for(record)] }
|
|
99
|
+
.reject { |_record, state| state.empty? }
|
|
100
|
+
|
|
101
|
+
texts = judgeable.map(&:last).uniq
|
|
102
|
+
answers = Judge::Pool.map(texts, concurrency: concurrency) do |text|
|
|
103
|
+
Judge.ask(definition.question, text: text, model: model, adapter: adapter)
|
|
104
|
+
end
|
|
105
|
+
by_text = texts.zip(answers).to_h
|
|
106
|
+
|
|
107
|
+
judgeable.to_h { |record, state| [record, by_text.fetch(state)] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def judge_check_limit!(limit, caller_name)
|
|
111
|
+
if limit.nil?
|
|
112
|
+
raise ArgumentError,
|
|
113
|
+
"#{caller_name} requires limit:. It makes one API call per row, so an unbounded " \
|
|
114
|
+
"scan would judge the whole table. Pass an explicit limit: you are willing to pay for."
|
|
115
|
+
end
|
|
116
|
+
return if limit.is_a?(Integer) && limit.positive?
|
|
117
|
+
|
|
118
|
+
raise ArgumentError, "limit: must be a positive Integer, got #{limit.inspect}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def judge_adhoc_source(source, caller_name)
|
|
122
|
+
resolved = source || (respond_to?(:judge_default_source) && judge_default_source)
|
|
123
|
+
return resolved if resolved
|
|
124
|
+
|
|
125
|
+
raise ArgumentError,
|
|
126
|
+
"#{caller_name} needs source: (a column symbol or a callable returning the text to judge), " \
|
|
127
|
+
"or a model-level judge_source"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judge
|
|
4
|
+
module Rails
|
|
5
|
+
module Scopes
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
DIRECTIONS = %i[asc desc].freeze
|
|
9
|
+
NUMERIC_LEVEL = /\A-?\d+\z/
|
|
10
|
+
|
|
11
|
+
class_methods do
|
|
12
|
+
def judge_define_scopes(definition)
|
|
13
|
+
column = definition.value_column
|
|
14
|
+
name = definition.name
|
|
15
|
+
|
|
16
|
+
judge_scope(:"#{name}_unknown") { all.where(column => nil) }
|
|
17
|
+
judge_scope(:"order_by_#{name}") { |dir = :desc| all.order(column => judge_order_direction(dir)) }
|
|
18
|
+
|
|
19
|
+
case definition.type
|
|
20
|
+
when "noul" then judge_define_noul_scopes(name, column)
|
|
21
|
+
when "choice" then judge_define_choice_scopes(name, column, definition.question.options)
|
|
22
|
+
when "score" then judge_define_score_scopes(name, column, definition.question.levels)
|
|
23
|
+
end
|
|
24
|
+
judge_define_model_scopes
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def judge_define_noul_scopes(name, column)
|
|
30
|
+
judge_scope(:"#{name}_above") do |probability|
|
|
31
|
+
all.where(arel_table[column].gteq(judge_probability(probability, name)))
|
|
32
|
+
end
|
|
33
|
+
judge_scope(:"#{name}_below") do |probability|
|
|
34
|
+
all.where(arel_table[column].lteq(judge_probability(probability, name)))
|
|
35
|
+
end
|
|
36
|
+
judge_scope(:"#{name}_between") do |low, high|
|
|
37
|
+
all.where(arel_table[column].gt(judge_probability(low, name)))
|
|
38
|
+
.where(arel_table[column].lt(judge_probability(high, name)))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def judge_define_choice_scopes(name, column, options)
|
|
43
|
+
judge_scope(:"#{name}_is") { |*values| all.where(column => judge_options(values, options, name)) }
|
|
44
|
+
judge_scope(:"#{name}_not") do |*values|
|
|
45
|
+
all.where.not(column => judge_options(values, options, name))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def judge_define_score_scopes(name, column, levels)
|
|
50
|
+
judge_scope(:"#{name}_at_least") do |level|
|
|
51
|
+
all.where(arel_table[column].gteq(judge_level(level, levels, name) - 0.5))
|
|
52
|
+
end
|
|
53
|
+
judge_scope(:"#{name}_at_most") do |level|
|
|
54
|
+
all.where(arel_table[column].lt(judge_level(level, levels, name) + 0.5))
|
|
55
|
+
end
|
|
56
|
+
judge_scope(:"#{name}_level") do |level|
|
|
57
|
+
index = judge_level(level, levels, name)
|
|
58
|
+
all.where(column => (index - 0.5)...(index + 0.5))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def judge_define_model_scopes
|
|
63
|
+
judge_scope(:judge_computed) do
|
|
64
|
+
judge_value_columns.reduce(all) { |relation, column| relation.where.not(column => nil) }
|
|
65
|
+
end
|
|
66
|
+
judge_scope(:judge_uncomputed) do
|
|
67
|
+
columns = judge_value_columns
|
|
68
|
+
columns.empty? ? all : all.where(columns.map { |c| arel_table[c].eq(nil) }.reduce(:or))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def judge_scope(name, &body)
|
|
73
|
+
return if singleton_class.method_defined?(name) && !judge_generated_scope?(name)
|
|
74
|
+
|
|
75
|
+
(@judge_generated_scopes ||= Set.new) << name
|
|
76
|
+
singleton_class.send(:define_method, name, &body)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def judge_generated_scope?(name)
|
|
80
|
+
ancestors.grep(Class).any? do |klass|
|
|
81
|
+
klass.instance_variable_get(:@judge_generated_scopes)&.include?(name)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def judge_value_columns
|
|
86
|
+
judge_attributes.map(&:value_column)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def judge_order_direction(dir)
|
|
90
|
+
direction = dir.to_s.downcase.to_sym
|
|
91
|
+
return direction if DIRECTIONS.include?(direction)
|
|
92
|
+
|
|
93
|
+
raise ArgumentError, "order direction must be one of #{DIRECTIONS.inspect}, got #{dir.inspect}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def judge_probability(value, name)
|
|
97
|
+
return value if value.is_a?(Numeric) && value.between?(0, 1)
|
|
98
|
+
|
|
99
|
+
raise ArgumentError, "#{name} scopes take a probability between 0 and 1, got #{value.inspect}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def judge_options(values, options, name)
|
|
103
|
+
picked = values.flatten.map(&:to_s)
|
|
104
|
+
raise ArgumentError, "#{name} needs at least one option" if picked.empty?
|
|
105
|
+
|
|
106
|
+
unknown = picked - options
|
|
107
|
+
return picked if unknown.empty?
|
|
108
|
+
|
|
109
|
+
raise ArgumentError,
|
|
110
|
+
"unknown #{name} option(s) #{unknown.inspect}, expected one of #{options.inspect}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def judge_level(level, levels, name)
|
|
114
|
+
by_index = level.is_a?(Integer) && !levels.all? { |label| label.match?(NUMERIC_LEVEL) }
|
|
115
|
+
index = by_index ? level : levels.index(level.to_s)
|
|
116
|
+
return index if index&.between?(0, levels.size - 1)
|
|
117
|
+
|
|
118
|
+
raise ArgumentError, "unknown #{name} level #{level.inspect}, expected one of #{levels.inspect} " \
|
|
119
|
+
"or 0..#{levels.size - 1}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|