jevalyn 0.1.0
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/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +533 -0
- data/lib/generators/jevalyn/decision/decision_generator.rb +60 -0
- data/lib/generators/jevalyn/decision/templates/decision.rb.tt +53 -0
- data/lib/generators/jevalyn/decision/templates/decision_spec.rb.tt +35 -0
- data/lib/generators/jevalyn/guardrail/guardrail_generator.rb +36 -0
- data/lib/generators/jevalyn/guardrail/templates/guardrail.rb.tt +23 -0
- data/lib/generators/jevalyn/guardrail/templates/guardrail_spec.rb.tt +26 -0
- data/lib/generators/jevalyn/install/install_generator.rb +37 -0
- data/lib/generators/jevalyn/install/templates/jevalyn.rb.tt +29 -0
- data/lib/jevalyn/answer.rb +176 -0
- data/lib/jevalyn/client.rb +167 -0
- data/lib/jevalyn/configuration.rb +85 -0
- data/lib/jevalyn/decision.rb +238 -0
- data/lib/jevalyn/errors.rb +123 -0
- data/lib/jevalyn/evaluation_job.rb +79 -0
- data/lib/jevalyn/guardrail.rb +130 -0
- data/lib/jevalyn/question.rb +184 -0
- data/lib/jevalyn/railtie.rb +37 -0
- data/lib/jevalyn/result.rb +164 -0
- data/lib/jevalyn/router.rb +175 -0
- data/lib/jevalyn/state.rb +80 -0
- data/lib/jevalyn/state_adapters/active_record_adapter.rb +26 -0
- data/lib/jevalyn/tasks/jevalyn.rake +35 -0
- data/lib/jevalyn/testing/cassette.rb +94 -0
- data/lib/jevalyn/testing/minitest.rb +55 -0
- data/lib/jevalyn/testing/rspec.rb +118 -0
- data/lib/jevalyn/testing.rb +276 -0
- data/lib/jevalyn/version.rb +5 -0
- data/lib/jevalyn.rb +88 -0
- metadata +123 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jevalyn
|
|
4
|
+
# A single typed question. Jev has exactly three kinds and Jevalyn validates the
|
|
5
|
+
# shape of each one when the Decision class is defined, so a malformed rubric is a
|
|
6
|
+
# boot-time error rather than a 422 in production.
|
|
7
|
+
class Question
|
|
8
|
+
# Jev accepts up to 255 options on a Choice.
|
|
9
|
+
MAX_CHOICE_OPTIONS = 255
|
|
10
|
+
|
|
11
|
+
# A Score needs at least two levels and takes up to ten.
|
|
12
|
+
MIN_SCORE_LEVELS = 2
|
|
13
|
+
MAX_SCORE_LEVELS = 10
|
|
14
|
+
|
|
15
|
+
TYPES = %i[noul choice score].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :name, :instructions, :criteria
|
|
18
|
+
|
|
19
|
+
def self.build(name, type:, instructions:, criteria: nil)
|
|
20
|
+
klass = case type.to_sym
|
|
21
|
+
when :noul then Noul
|
|
22
|
+
when :choice then Choice
|
|
23
|
+
when :score then Score
|
|
24
|
+
else
|
|
25
|
+
raise InvalidQuestionError,
|
|
26
|
+
"Unknown question type #{type.inspect} for :#{name}. " \
|
|
27
|
+
"Jev supports #{TYPES.map(&:inspect).join(", ")}."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
klass.new(name, instructions: instructions, criteria: criteria)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(name, instructions:, criteria: nil)
|
|
34
|
+
@name = name.to_sym
|
|
35
|
+
@instructions = instructions
|
|
36
|
+
@criteria = criteria
|
|
37
|
+
validate!
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def type = self.class::TYPE
|
|
42
|
+
|
|
43
|
+
# The JSON body for this question, as the API wants it.
|
|
44
|
+
def to_payload
|
|
45
|
+
payload = { "type" => type.to_s, "instructions" => instructions }
|
|
46
|
+
payload["criteria"] = criteria_payload unless criteria_payload.nil?
|
|
47
|
+
payload
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Wraps the raw answer hash the API returned for this question.
|
|
51
|
+
def build_answer(raw)
|
|
52
|
+
answer_class.new(self, raw)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def criteria_payload = criteria
|
|
58
|
+
|
|
59
|
+
def validate!
|
|
60
|
+
validate_instructions!
|
|
61
|
+
validate_criteria!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The API accepts a string, object or array here -- anything JSON-shaped that
|
|
65
|
+
# reads as an instruction. What it does not accept is nothing.
|
|
66
|
+
def validate_instructions!
|
|
67
|
+
return if instructions.is_a?(String) && !instructions.strip.empty?
|
|
68
|
+
return if instructions.is_a?(Hash) && !instructions.empty?
|
|
69
|
+
return if instructions.is_a?(Array) && !instructions.empty?
|
|
70
|
+
|
|
71
|
+
raise InvalidQuestionError,
|
|
72
|
+
"Question :#{name} needs non-empty :instructions (a String, Hash or Array), " \
|
|
73
|
+
"got #{instructions.inspect}."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_criteria!
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Probability that the answer to a yes/no question is yes.
|
|
81
|
+
class Noul < Question
|
|
82
|
+
TYPE = :noul
|
|
83
|
+
|
|
84
|
+
def answer_class = Answer::Noul
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Optional. When given it explains what a yes and a no mean.
|
|
89
|
+
def validate_criteria!
|
|
90
|
+
return if criteria.nil?
|
|
91
|
+
|
|
92
|
+
unless criteria.is_a?(Hash)
|
|
93
|
+
raise InvalidQuestionError,
|
|
94
|
+
"Question :#{name} is a :noul, so :criteria must be a Hash with " \
|
|
95
|
+
":true and/or :false keys, got #{criteria.class}."
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
unknown = criteria.keys.map(&:to_s) - %w[true false]
|
|
99
|
+
return if unknown.empty?
|
|
100
|
+
|
|
101
|
+
raise InvalidQuestionError,
|
|
102
|
+
"Question :#{name} is a :noul, so :criteria accepts only :true and :false. " \
|
|
103
|
+
"Unknown key(s): #{unknown.join(", ")}."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def criteria_payload
|
|
107
|
+
return nil if criteria.nil?
|
|
108
|
+
|
|
109
|
+
criteria.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# One option out of a labelled set.
|
|
114
|
+
class Choice < Question
|
|
115
|
+
TYPE = :choice
|
|
116
|
+
|
|
117
|
+
def answer_class = Answer::Choice
|
|
118
|
+
|
|
119
|
+
# The option keys, in declaration order.
|
|
120
|
+
def options = criteria.keys.map(&:to_sym)
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def validate_criteria!
|
|
125
|
+
unless criteria.is_a?(Hash) && !criteria.empty?
|
|
126
|
+
raise InvalidQuestionError,
|
|
127
|
+
"Question :#{name} is a :choice, so :criteria must be a non-empty Hash of " \
|
|
128
|
+
"option => description, got #{criteria.inspect}."
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
if criteria.size > MAX_CHOICE_OPTIONS
|
|
132
|
+
raise InvalidQuestionError,
|
|
133
|
+
"Question :#{name} declares #{criteria.size} options; Jev accepts at most " \
|
|
134
|
+
"#{MAX_CHOICE_OPTIONS}."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
bad = criteria.reject { |_, value| value.nil? || value.is_a?(String) }
|
|
138
|
+
return if bad.empty?
|
|
139
|
+
|
|
140
|
+
raise InvalidQuestionError,
|
|
141
|
+
"Question :#{name} has non-String descriptions for option(s) " \
|
|
142
|
+
"#{bad.keys.join(", ")}. Use a String, or nil when the option needs no detail."
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def criteria_payload
|
|
146
|
+
criteria.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A rating against ordered levels.
|
|
151
|
+
class Score < Question
|
|
152
|
+
TYPE = :score
|
|
153
|
+
|
|
154
|
+
def answer_class = Answer::Score
|
|
155
|
+
|
|
156
|
+
# The level descriptions, lowest first.
|
|
157
|
+
def levels = criteria.map(&:to_s)
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def validate_criteria!
|
|
162
|
+
unless criteria.is_a?(Array)
|
|
163
|
+
raise InvalidQuestionError,
|
|
164
|
+
"Question :#{name} is a :score, so :criteria must be an ordered Array of " \
|
|
165
|
+
"level descriptions, got #{criteria.class}."
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
unless criteria.size.between?(MIN_SCORE_LEVELS, MAX_SCORE_LEVELS)
|
|
169
|
+
raise InvalidQuestionError,
|
|
170
|
+
"Question :#{name} declares #{criteria.size} level(s); a Jev score takes " \
|
|
171
|
+
"between #{MIN_SCORE_LEVELS} and #{MAX_SCORE_LEVELS}."
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
bad = criteria.reject { |level| level.is_a?(String) || level.is_a?(Symbol) }
|
|
175
|
+
return if bad.empty?
|
|
176
|
+
|
|
177
|
+
raise InvalidQuestionError,
|
|
178
|
+
"Question :#{name} has non-String level(s): #{bad.map(&:inspect).join(", ")}."
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def criteria_payload = levels
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Jevalyn
|
|
6
|
+
# Wires Jevalyn into a Rails app: the generators, a sensible logger, and
|
|
7
|
+
# app/decisions as an autoload path so decision classes live next to models.
|
|
8
|
+
class Railtie < ::Rails::Railtie
|
|
9
|
+
config.jevalyn = ActiveSupport::OrderedOptions.new if defined?(ActiveSupport::OrderedOptions)
|
|
10
|
+
|
|
11
|
+
generators do
|
|
12
|
+
require "generators/jevalyn/install/install_generator"
|
|
13
|
+
require "generators/jevalyn/decision/decision_generator"
|
|
14
|
+
require "generators/jevalyn/guardrail/guardrail_generator"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer "jevalyn.logger" do
|
|
18
|
+
Jevalyn.config.logger ||= Rails.logger
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A Rails app almost never wants a real API call in its test suite, and a key is
|
|
22
|
+
# usually absent there anyway. Opt back in per example with `:jevalyn_live`.
|
|
23
|
+
initializer "jevalyn.mock_mode" do
|
|
24
|
+
Jevalyn.config.mock_mode = true if Rails.env.test? && Jevalyn.config.api_key.nil?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
initializer "jevalyn.autoload_paths" do |app|
|
|
28
|
+
decisions = app.root.join("app/decisions")
|
|
29
|
+
app.config.autoload_paths << decisions.to_s if decisions.exist?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `rails jevalyn:ping` -- checks the key and the network without leaving the shell.
|
|
33
|
+
rake_tasks do
|
|
34
|
+
load File.expand_path("tasks/jevalyn.rake", __dir__)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jevalyn
|
|
4
|
+
# Everything one evaluation returned: the typed answers, which model answered, and
|
|
5
|
+
# what it cost. A Result is read-only and safe to pass around or serialise into a job.
|
|
6
|
+
class Result
|
|
7
|
+
# What a client sends in the `usage` position when nothing came back.
|
|
8
|
+
EMPTY_USAGE = { "input_tokens" => 0, "output_tokens" => 0 }.freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :answers, :raw, :questions
|
|
11
|
+
|
|
12
|
+
# questions -- Hash of name => Question, in declaration order.
|
|
13
|
+
# raw -- the parsed response body, untouched.
|
|
14
|
+
# thresholds -- Hash of name => confidence floor, already resolved by the Decision.
|
|
15
|
+
# A nil floor means that question was not asked to clear anything.
|
|
16
|
+
def initialize(questions:, raw:, thresholds: nil)
|
|
17
|
+
@questions = questions
|
|
18
|
+
@raw = raw || {}
|
|
19
|
+
@thresholds = normalize_thresholds(thresholds)
|
|
20
|
+
@answers = build_answers
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The versioned model that actually answered, e.g. "jev-1.13.0". Worth logging:
|
|
24
|
+
# an alias like jev-latest moves under you, and this is what tells you when it did.
|
|
25
|
+
def model = raw["model"]
|
|
26
|
+
|
|
27
|
+
def usage = raw["usage"] || EMPTY_USAGE
|
|
28
|
+
|
|
29
|
+
def input_tokens = usage["input_tokens"].to_i
|
|
30
|
+
|
|
31
|
+
# Free, as of jev-1.13 -- TypeSafe bills on input tokens only.
|
|
32
|
+
def output_tokens = usage["output_tokens"].to_i
|
|
33
|
+
|
|
34
|
+
def [](name)
|
|
35
|
+
answer(name).value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The Answer object for a question, rather than its value.
|
|
39
|
+
def answer(name)
|
|
40
|
+
@answers.fetch(name.to_sym) do
|
|
41
|
+
raise UnknownQuestionError,
|
|
42
|
+
"No answer named #{name.inspect}. This result has: " \
|
|
43
|
+
"#{@answers.keys.map(&:inspect).join(", ")}."
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def key?(name) = @answers.key?(name.to_sym)
|
|
48
|
+
|
|
49
|
+
# Plain Hash of question name => unwrapped value.
|
|
50
|
+
def values
|
|
51
|
+
@answers.transform_values(&:value)
|
|
52
|
+
end
|
|
53
|
+
alias to_h values
|
|
54
|
+
|
|
55
|
+
# The floor each question is judged against. Read it to see what a Decision
|
|
56
|
+
# actually resolved, which is worth logging alongside the answers.
|
|
57
|
+
attr_reader :thresholds
|
|
58
|
+
|
|
59
|
+
def threshold_for(name)
|
|
60
|
+
@thresholds[name.to_sym]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# True when every answer clears its own floor. Pass a number to judge them all
|
|
64
|
+
# against that one instead. Nouls are measured on how far they sit from a coin
|
|
65
|
+
# flip, since Jev returns no confidence for them.
|
|
66
|
+
def certain?(threshold = nil)
|
|
67
|
+
uncertain_questions(threshold).empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def uncertain?(threshold = nil) = !certain?(threshold)
|
|
71
|
+
|
|
72
|
+
# True when one named answer clears its floor.
|
|
73
|
+
def certain_for?(name, threshold = :__declared__)
|
|
74
|
+
threshold = threshold_for(name) if threshold == :__declared__
|
|
75
|
+
|
|
76
|
+
answer(name).certain?(threshold)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Names of the answers that fell below their floor -- the ones worth routing to
|
|
80
|
+
# a human or a slower model.
|
|
81
|
+
def uncertain_questions(threshold = nil)
|
|
82
|
+
@answers.reject do |name, answer|
|
|
83
|
+
answer.certain?(threshold || @thresholds[name])
|
|
84
|
+
end.keys
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The least certain answer's certainty, across every question.
|
|
88
|
+
def min_certainty
|
|
89
|
+
@answers.values.filter_map(&:certainty).min
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# How far each answer sits above (or below) its own floor. Negative means it
|
|
93
|
+
# missed. Useful for logging which decisions are running close to the line.
|
|
94
|
+
def certainty_margins
|
|
95
|
+
@answers.each_with_object({}) do |(name, answer), out|
|
|
96
|
+
floor = @thresholds[name]
|
|
97
|
+
certainty = answer.certainty
|
|
98
|
+
out[name] = floor.nil? || certainty.nil? ? nil : (certainty - floor).round(10)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def each(&) = @answers.each(&)
|
|
103
|
+
|
|
104
|
+
include Enumerable
|
|
105
|
+
|
|
106
|
+
def inspect
|
|
107
|
+
pairs = values.map { |name, value| "#{name}=#{value.inspect}" }.join(" ")
|
|
108
|
+
"#<#{self.class.name.nil? ? "Jevalyn::Result" : self.class.name} #{pairs} model=#{model.inspect}>"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Builds a Result subclass with a reader per question, so a Decision's answers
|
|
112
|
+
# read as `result.department` rather than `result[:department]`.
|
|
113
|
+
def self.class_for(questions)
|
|
114
|
+
Class.new(self) do
|
|
115
|
+
questions.each_value do |question|
|
|
116
|
+
name = question.name
|
|
117
|
+
|
|
118
|
+
define_method(name) { self[name] }
|
|
119
|
+
define_method(:"#{name}_answer") { answer(name) }
|
|
120
|
+
define_method(:"#{name}_certainty") { answer(name).certainty }
|
|
121
|
+
define_method(:"#{name}_probabilities") { answer(name).probabilities }
|
|
122
|
+
define_method(:"#{name}_threshold") { threshold_for(name) }
|
|
123
|
+
define_method(:"#{name}_certain?") { |threshold = :__declared__| certain_for?(name, threshold) }
|
|
124
|
+
define_method(:"#{name}_uncertain?") { |threshold = :__declared__| !certain_for?(name, threshold) }
|
|
125
|
+
|
|
126
|
+
case question.type
|
|
127
|
+
when :noul
|
|
128
|
+
define_method(:"#{name}?") { |threshold = 0.5| answer(name).true?(threshold) }
|
|
129
|
+
when :choice
|
|
130
|
+
define_method(:"#{name}_confidence") { answer(name).confidence }
|
|
131
|
+
when :score
|
|
132
|
+
define_method(:"#{name}_confidence") { answer(name).confidence }
|
|
133
|
+
define_method(:"#{name}_label") { answer(name).label }
|
|
134
|
+
define_method(:"#{name}_level") { answer(name).level }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Accepts a Hash of floors, a single number meaning "all of them", or nothing.
|
|
143
|
+
def normalize_thresholds(thresholds)
|
|
144
|
+
case thresholds
|
|
145
|
+
when nil then questions.keys.to_h { |name| [name, nil] }.freeze
|
|
146
|
+
when Numeric then questions.keys.to_h { |name| [name, thresholds] }.freeze
|
|
147
|
+
when Hash
|
|
148
|
+
questions.keys.to_h { |name| [name, thresholds[name] || thresholds[name.to_s]] }.freeze
|
|
149
|
+
else
|
|
150
|
+
raise ConfigurationError,
|
|
151
|
+
"thresholds must be a Hash of question => floor, a single number, or nil; " \
|
|
152
|
+
"got #{thresholds.class}."
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def build_answers
|
|
157
|
+
raw_answers = raw["answers"] || {}
|
|
158
|
+
|
|
159
|
+
questions.each_with_object({}) do |(name, question), out|
|
|
160
|
+
out[name] = question.build_answer(raw_answers[name.to_s])
|
|
161
|
+
end.freeze
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jevalyn
|
|
4
|
+
# Sends work to a handler based on a decision, with a confidence floor underneath.
|
|
5
|
+
#
|
|
6
|
+
# Two shapes, and they compose. A plain dispatch table:
|
|
7
|
+
#
|
|
8
|
+
# router = Jevalyn::Router.new do |r|
|
|
9
|
+
# r.route :simple_lookup, to: OrderLookup
|
|
10
|
+
# r.route :open_ended, to: llm_client # anything responding to #call
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# router.dispatch(:simple_lookup, state: order)
|
|
14
|
+
#
|
|
15
|
+
# Or a decision-driven router, which is the reason this class exists -- Jev picks
|
|
16
|
+
# the branch, and anything it is not sure about goes somewhere safer:
|
|
17
|
+
#
|
|
18
|
+
# router = Jevalyn::Router.new(SupportTriage, on: :department) do |r|
|
|
19
|
+
# r.route :billing, to: BillingInbox
|
|
20
|
+
# r.route :technical, to: ->(state, result) { Oncall.page(state, result) }
|
|
21
|
+
# r.route :sales, to: SalesInbox
|
|
22
|
+
# r.uncertain_below to: HumanQueue
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# router.call(ticket)
|
|
26
|
+
#
|
|
27
|
+
# Handlers are called with (state, result) when they take two arguments and (state)
|
|
28
|
+
# when they take one, so a plain Proc, a Decision, a job class and a service object
|
|
29
|
+
# all work without adapters.
|
|
30
|
+
class Router
|
|
31
|
+
Route = Struct.new(:key, :handler, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
attr_reader :decision, :question, :routes
|
|
34
|
+
|
|
35
|
+
# decision -- optional Jevalyn::Decision subclass that picks the branch.
|
|
36
|
+
# on -- the choice question whose answer names the route. Defaults to the
|
|
37
|
+
# decision's only question when it declares just one.
|
|
38
|
+
def initialize(decision = nil, on: nil)
|
|
39
|
+
@decision = decision
|
|
40
|
+
@question = on
|
|
41
|
+
@routes = {}
|
|
42
|
+
@fallback = nil
|
|
43
|
+
@floor = nil
|
|
44
|
+
@floor_handler = nil
|
|
45
|
+
|
|
46
|
+
yield self if block_given?
|
|
47
|
+
|
|
48
|
+
validate_question! if decision
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Registers a handler under a key.
|
|
52
|
+
def route(key, to:)
|
|
53
|
+
@routes[key.to_sym] = Route.new(key: key.to_sym, handler: to)
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Where anything unrouted goes. Without one, an unrouted key raises.
|
|
58
|
+
def fallback(to:)
|
|
59
|
+
@fallback = to
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Where an answer that misses its confidence floor goes, whatever the answer was.
|
|
64
|
+
# This is the confidence-gated half: a wrong-but-confident answer is a routing bug,
|
|
65
|
+
# a not-confident answer is a known unknown and belongs with a human or a slower
|
|
66
|
+
# model.
|
|
67
|
+
#
|
|
68
|
+
# With no threshold it uses the floor the question itself declares, so the number
|
|
69
|
+
# lives in one place:
|
|
70
|
+
#
|
|
71
|
+
# r.uncertain_below to: HumanQueue # the decision's own floor
|
|
72
|
+
# r.uncertain_below 0.9, to: HumanQueue # stricter, just for this router
|
|
73
|
+
def uncertain_below(threshold = nil, to:)
|
|
74
|
+
@floor = threshold
|
|
75
|
+
@floor_handler = to
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Calls a registered handler directly, skipping the decision.
|
|
80
|
+
def dispatch(key, state:, result: nil)
|
|
81
|
+
registered = @routes[key.to_sym]
|
|
82
|
+
|
|
83
|
+
unless registered
|
|
84
|
+
return invoke(@fallback, state, result) if @fallback
|
|
85
|
+
|
|
86
|
+
raise UnknownQuestionError,
|
|
87
|
+
"No route for #{key.inspect}. Registered: #{@routes.keys.map(&:inspect).join(", ")}."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
invoke(registered.handler, state, result)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Runs the decision, then dispatches on its answer.
|
|
94
|
+
def call(state, **options)
|
|
95
|
+
unless decision
|
|
96
|
+
raise ConfigurationError,
|
|
97
|
+
"This Router has no decision, so it can only #dispatch(key, state:). " \
|
|
98
|
+
"Build it as Router.new(SomeDecision, on: :question) to use #call."
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
result = decision.evaluate(state, **options)
|
|
102
|
+
answer = result.answer(question_name)
|
|
103
|
+
|
|
104
|
+
floor = @floor || result.threshold_for(question_name)
|
|
105
|
+
return invoke(@floor_handler, state, result) if @floor_handler && answer.uncertain?(floor)
|
|
106
|
+
|
|
107
|
+
dispatch(answer.value, state: state, result: result)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The decision's answer without dispatching -- useful in specs and consoles.
|
|
111
|
+
def decide(state, **options)
|
|
112
|
+
decision.evaluate(state, **options)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def keys = @routes.keys
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def question_name
|
|
120
|
+
@question || decision.question_names.first
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_question!
|
|
124
|
+
name = question_name
|
|
125
|
+
|
|
126
|
+
unless decision.questions.key?(name)
|
|
127
|
+
raise ConfigurationError,
|
|
128
|
+
"#{decision.name} declares no question named #{name.inspect}. " \
|
|
129
|
+
"It has: #{decision.question_names.map(&:inspect).join(", ")}."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
declared = decision.questions[name]
|
|
133
|
+
|
|
134
|
+
unless declared.type == :choice
|
|
135
|
+
raise ConfigurationError,
|
|
136
|
+
"Router routes on a :choice question; #{decision.name}##{name} is a " \
|
|
137
|
+
":#{declared.type}."
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
unrouted = declared.options - @routes.keys
|
|
141
|
+
return if unrouted.empty? || @fallback || @floor_handler
|
|
142
|
+
|
|
143
|
+
raise ConfigurationError,
|
|
144
|
+
"#{decision.name}##{name} can answer #{unrouted.map(&:inspect).join(", ")}, " \
|
|
145
|
+
"which this Router has no route for. Add them, or a `fallback to:`."
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Handlers vary in shape on purpose: a Decision subclass, a Proc that only wants
|
|
149
|
+
# the state, and a service object that wants both should all work unadapted.
|
|
150
|
+
def invoke(handler, state, result)
|
|
151
|
+
return handler.evaluate(state) if handler.is_a?(Class) && handler <= Decision
|
|
152
|
+
|
|
153
|
+
unless handler.respond_to?(:call)
|
|
154
|
+
raise ConfigurationError,
|
|
155
|
+
"Route handler #{handler.inspect} must respond to #call, or be a " \
|
|
156
|
+
"Jevalyn::Decision subclass."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
return handler.call(state, result) if accepts_two?(handler)
|
|
160
|
+
|
|
161
|
+
handler.call(state)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def accepts_two?(handler)
|
|
165
|
+
method = handler.respond_to?(:parameters) ? handler : handler.method(:call)
|
|
166
|
+
parameters = method.parameters
|
|
167
|
+
|
|
168
|
+
return true if parameters.any? { |type, _| type == :rest }
|
|
169
|
+
|
|
170
|
+
parameters.count { |type, _| %i[req opt].include?(type) } >= 2
|
|
171
|
+
rescue NameError
|
|
172
|
+
false
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Jevalyn
|
|
6
|
+
# Turns whatever you hand a Decision into something the API accepts as `state`:
|
|
7
|
+
# a String, an Object or an Array.
|
|
8
|
+
#
|
|
9
|
+
# The convention is one method. If an object responds to #jevalyn_state, that is the
|
|
10
|
+
# state -- which is how a model says "send these five columns, not all forty".
|
|
11
|
+
# Otherwise Jevalyn falls back to #as_json, then to #to_s.
|
|
12
|
+
module State
|
|
13
|
+
# Jev's per-request budget: 64k tokens for state plus every question, and 32k for
|
|
14
|
+
# state plus the single longest question. Tokens are not characters, so this is a
|
|
15
|
+
# deliberately loose guard against posting a whole database row set by accident.
|
|
16
|
+
ROUGH_CHARS_PER_TOKEN = 4
|
|
17
|
+
SOFT_CHAR_LIMIT = 32_000 * ROUGH_CHARS_PER_TOKEN
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def serialize(object)
|
|
22
|
+
state = coerce(object)
|
|
23
|
+
|
|
24
|
+
if state.nil? || (state.respond_to?(:empty?) && state.empty?)
|
|
25
|
+
raise ConfigurationError,
|
|
26
|
+
"State is empty. Jev needs something to evaluate -- pass a String, a Hash, " \
|
|
27
|
+
"an Array, or an object that responds to #jevalyn_state."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
state
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def coerce(object)
|
|
34
|
+
case object
|
|
35
|
+
when nil then nil
|
|
36
|
+
when String then object
|
|
37
|
+
when Symbol then object.to_s
|
|
38
|
+
# Numbers and booleans are valid JSON and read better to the model as
|
|
39
|
+
# themselves than as quoted strings.
|
|
40
|
+
when Numeric, true, false then object
|
|
41
|
+
when Hash then stringify(object)
|
|
42
|
+
when Array then object.map { |item| coerce(item) }
|
|
43
|
+
else
|
|
44
|
+
coerce_object(object)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Rough token estimate, for logging and for the oversize warning. Not exact --
|
|
49
|
+
# only the API knows the real count, and it reports it back in `usage`.
|
|
50
|
+
def estimated_tokens(state)
|
|
51
|
+
json = state.is_a?(String) ? state : JSON.generate(state)
|
|
52
|
+
(json.length / ROUGH_CHARS_PER_TOKEN.to_f).ceil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def oversized?(state)
|
|
56
|
+
json = state.is_a?(String) ? state : JSON.generate(state)
|
|
57
|
+
json.length > SOFT_CHAR_LIMIT
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def coerce_object(object)
|
|
61
|
+
return coerce(object.jevalyn_state) if object.respond_to?(:jevalyn_state)
|
|
62
|
+
|
|
63
|
+
if defined?(::ActiveRecord::Base) && object.is_a?(::ActiveRecord::Base)
|
|
64
|
+
return StateAdapters::ActiveRecordAdapter.serialize(object)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if defined?(::ActiveRecord::Relation) && object.is_a?(::ActiveRecord::Relation)
|
|
68
|
+
return object.map { |record| coerce(record) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return coerce(object.as_json) if object.respond_to?(:as_json)
|
|
72
|
+
|
|
73
|
+
object.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stringify(hash)
|
|
77
|
+
hash.each_with_object({}) { |(key, value), out| out[key.to_s] = coerce(value) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jevalyn
|
|
4
|
+
module StateAdapters
|
|
5
|
+
# Serialises an ActiveRecord model into the `state` field.
|
|
6
|
+
#
|
|
7
|
+
# The default is `record.as_json`, which sends every column. That is usually the
|
|
8
|
+
# wrong thing: it burns tokens on ids and timestamps Jev has no use for, and it
|
|
9
|
+
# ships PII to a third party that did not need it. Narrow it with `only:`, or
|
|
10
|
+
# define #jevalyn_state on the model and forget the adapter exists.
|
|
11
|
+
class ActiveRecordAdapter
|
|
12
|
+
# Columns that are noise in almost every decision.
|
|
13
|
+
DEFAULT_EXCEPT = %w[id created_at updated_at].freeze
|
|
14
|
+
|
|
15
|
+
def self.serialize(record, only: nil, except: nil)
|
|
16
|
+
return record.jevalyn_state if only.nil? && except.nil? && record.respond_to?(:jevalyn_state)
|
|
17
|
+
|
|
18
|
+
options = {}
|
|
19
|
+
options[:only] = Array(only).map(&:to_s) if only
|
|
20
|
+
options[:except] = Array(except).map(&:to_s) if except
|
|
21
|
+
|
|
22
|
+
State.stringify(record.as_json(**options))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|