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,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jevalyn
|
|
4
|
+
# A named set of questions your app asks about a piece of state.
|
|
5
|
+
#
|
|
6
|
+
# class SupportTriage < Jevalyn::Decision
|
|
7
|
+
# question :urgent, type: :noul,
|
|
8
|
+
# instructions: "Does this convey urgency?"
|
|
9
|
+
#
|
|
10
|
+
# question :department, type: :choice,
|
|
11
|
+
# instructions: "Which team should handle this?",
|
|
12
|
+
# criteria: {
|
|
13
|
+
# billing: "Payments, invoicing, refunds",
|
|
14
|
+
# technical: "Bugs, outages, integrations",
|
|
15
|
+
# sales: "Pricing, upgrades, new accounts"
|
|
16
|
+
# }
|
|
17
|
+
#
|
|
18
|
+
# confidence_threshold 0.75
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# result = SupportTriage.evaluate(ticket.body)
|
|
22
|
+
# result.department # => :technical
|
|
23
|
+
# result.certain? # => true
|
|
24
|
+
#
|
|
25
|
+
# Every question is validated when the class body runs, so a rubric with eleven
|
|
26
|
+
# score levels fails on boot rather than as a 422 on a Friday afternoon.
|
|
27
|
+
class Decision
|
|
28
|
+
class << self
|
|
29
|
+
# Declares one question. Keys map one-to-one onto the API's question object,
|
|
30
|
+
# except `confidence_threshold`, which is Jevalyn's and is never sent.
|
|
31
|
+
def question(name, type:, instructions:, criteria: nil, confidence_threshold: nil)
|
|
32
|
+
name = name.to_sym
|
|
33
|
+
|
|
34
|
+
if questions.key?(name)
|
|
35
|
+
raise InvalidQuestionError,
|
|
36
|
+
"#{self.name || "Decision"} already declares a question named :#{name}."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
own_questions[name] = Question.build(name, type: type, instructions: instructions, criteria: criteria)
|
|
40
|
+
unless confidence_threshold.nil?
|
|
41
|
+
own_thresholds[name] =
|
|
42
|
+
validate_threshold!(confidence_threshold,
|
|
43
|
+
"confidence_threshold for :#{name}")
|
|
44
|
+
end
|
|
45
|
+
reset_result_class!
|
|
46
|
+
own_questions[name]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Sugar for the three types. `noul :urgent, "Does this convey urgency?"`
|
|
50
|
+
def noul(name, instructions, criteria: nil, confidence_threshold: nil)
|
|
51
|
+
question(name, type: :noul, instructions: instructions, criteria: criteria,
|
|
52
|
+
confidence_threshold: confidence_threshold)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def choice(name, instructions, criteria, confidence_threshold: nil)
|
|
56
|
+
question(name, type: :choice, instructions: instructions, criteria: criteria,
|
|
57
|
+
confidence_threshold: confidence_threshold)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def score(name, instructions, criteria, confidence_threshold: nil)
|
|
61
|
+
question(name, type: :score, instructions: instructions, criteria: criteria,
|
|
62
|
+
confidence_threshold: confidence_threshold)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Reads or sets the default confidence floor for this decision's questions.
|
|
66
|
+
# A question that declares its own overrides this; see #confidence_threshold_for.
|
|
67
|
+
# Called with no argument it reads; the inherited or global value is the fallback.
|
|
68
|
+
def confidence_threshold(value = :__read__)
|
|
69
|
+
if value == :__read__
|
|
70
|
+
return @confidence_threshold if defined?(@confidence_threshold) && @confidence_threshold
|
|
71
|
+
return superclass.confidence_threshold if superclass.respond_to?(:confidence_threshold)
|
|
72
|
+
|
|
73
|
+
return Jevalyn.config.default_confidence_threshold
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@confidence_threshold = validate_threshold!(value, "confidence_threshold")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The floor one question is judged against: its own if it declared one, this
|
|
80
|
+
# decision's default otherwise, and the global default under that.
|
|
81
|
+
def confidence_threshold_for(name)
|
|
82
|
+
declared = thresholds_by_question[name.to_sym]
|
|
83
|
+
return declared unless declared.nil?
|
|
84
|
+
|
|
85
|
+
confidence_threshold
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Every question's resolved floor, which is what a Result is judged against.
|
|
89
|
+
def thresholds
|
|
90
|
+
questions.keys.to_h { |name| [name, confidence_threshold_for(name)] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Per-question floors declared on this class and its ancestors.
|
|
94
|
+
def thresholds_by_question
|
|
95
|
+
inherited = superclass.respond_to?(:thresholds_by_question) ? superclass.thresholds_by_question : {}
|
|
96
|
+
inherited.merge(own_thresholds)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Pins this decision to a specific model. Worth doing once you have tuned
|
|
100
|
+
# thresholds against a version -- `jev-latest` moves without telling you.
|
|
101
|
+
def model(value = :__read__)
|
|
102
|
+
if value == :__read__
|
|
103
|
+
return @model if defined?(@model) && @model
|
|
104
|
+
return superclass.model if superclass.respond_to?(:model)
|
|
105
|
+
|
|
106
|
+
return nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@model = value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# All questions, inherited ones first.
|
|
113
|
+
def questions
|
|
114
|
+
inherited = superclass.respond_to?(:questions) ? superclass.questions : {}
|
|
115
|
+
inherited.merge(own_questions)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def question_names = questions.keys
|
|
119
|
+
|
|
120
|
+
# Evaluates the state and returns a Jevalyn::Result with a reader per question.
|
|
121
|
+
#
|
|
122
|
+
# Two ways to override the declared floors for one call. `confidence_threshold:`
|
|
123
|
+
# applies one number to every question; `thresholds:` names them individually and
|
|
124
|
+
# wins over the blanket value where both are given.
|
|
125
|
+
#
|
|
126
|
+
# SupportTriage.evaluate(body, confidence_threshold: 0.95)
|
|
127
|
+
# SupportTriage.evaluate(body, thresholds: { department: 0.9 })
|
|
128
|
+
def evaluate(state, model: nil, confidence_threshold: :__default__, thresholds: nil,
|
|
129
|
+
client: Jevalyn.client)
|
|
130
|
+
ensure_questions!
|
|
131
|
+
|
|
132
|
+
client.evaluate(
|
|
133
|
+
state: state,
|
|
134
|
+
questions: questions,
|
|
135
|
+
model: model || self.model,
|
|
136
|
+
thresholds: resolve_thresholds(confidence_threshold, thresholds),
|
|
137
|
+
result_class: result_class,
|
|
138
|
+
decision: self
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Same call, on an ActiveJob queue. The block runs with the Result once the job
|
|
143
|
+
# completes; see Jevalyn::EvaluationJob for the handler contract.
|
|
144
|
+
def evaluate_later(state, on:, model: nil, queue: nil, **job_options)
|
|
145
|
+
ensure_questions!
|
|
146
|
+
EvaluationJob.enqueue(
|
|
147
|
+
decision: self, state: state, handler: on, model: model, queue: queue, **job_options
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The request body that `evaluate` would send, without sending it. Handy in a
|
|
152
|
+
# console for checking token cost before wiring a decision into a hot path.
|
|
153
|
+
def payload_for(state, model: nil)
|
|
154
|
+
{
|
|
155
|
+
"model" => model || self.model || Jevalyn.config.default_model,
|
|
156
|
+
"state" => State.serialize(state),
|
|
157
|
+
"questions" => questions.each_with_object({}) { |(name, q), out| out[name.to_s] = q.to_payload }
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Rough input-token estimate for a given state. The real number comes back in
|
|
162
|
+
# Result#input_tokens; this is for sizing things up beforehand.
|
|
163
|
+
def estimated_tokens(state)
|
|
164
|
+
State.estimated_tokens(payload_for(state))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def result_class
|
|
168
|
+
@result_class ||= build_result_class
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Overridden by Guardrail to bolt #allow? onto the result.
|
|
172
|
+
def build_result_class
|
|
173
|
+
Result.class_for(questions)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def own_questions
|
|
177
|
+
@own_questions ||= {}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def own_thresholds
|
|
181
|
+
@own_thresholds ||= {}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def inherited(subclass)
|
|
187
|
+
super
|
|
188
|
+
subclass.instance_variable_set(:@own_questions, {})
|
|
189
|
+
subclass.instance_variable_set(:@own_thresholds, {})
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Call-site overrides, least specific first: the declared floors, then a blanket
|
|
193
|
+
# value applied to every question, then per-question values on top of that.
|
|
194
|
+
def resolve_thresholds(blanket, per_question)
|
|
195
|
+
resolved =
|
|
196
|
+
if blanket == :__default__
|
|
197
|
+
thresholds
|
|
198
|
+
else
|
|
199
|
+
validate_threshold!(blanket, "confidence_threshold")
|
|
200
|
+
questions.keys.to_h { |name| [name, blanket] }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
return resolved if per_question.nil?
|
|
204
|
+
|
|
205
|
+
per_question.each_with_object(resolved.dup) do |(name, value), out|
|
|
206
|
+
name = name.to_sym
|
|
207
|
+
|
|
208
|
+
unless questions.key?(name)
|
|
209
|
+
raise ConfigurationError,
|
|
210
|
+
"#{self.name || "This Decision"} declares no question named #{name.inspect}. " \
|
|
211
|
+
"It has: #{question_names.map(&:inspect).join(", ")}."
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
out[name] = validate_threshold!(value, name.inspect)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def validate_threshold!(value, label)
|
|
219
|
+
return value if value.nil? || (value.is_a?(Numeric) && value.between?(0, 1))
|
|
220
|
+
|
|
221
|
+
raise ConfigurationError,
|
|
222
|
+
"#{label} must be nil or a number between 0 and 1, got #{value.inspect}."
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def ensure_questions!
|
|
226
|
+
return unless questions.empty?
|
|
227
|
+
|
|
228
|
+
raise ConfigurationError,
|
|
229
|
+
"#{name || "This Decision"} declares no questions. Add at least one with " \
|
|
230
|
+
"`question :name, type: :noul, instructions: \"...\"`."
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def reset_result_class!
|
|
234
|
+
@result_class = nil
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jevalyn
|
|
4
|
+
# Base class for everything Jevalyn raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the gem is asked to do something it has not been configured for
|
|
8
|
+
# (missing API key, unknown question type, a Decision that declares no questions).
|
|
9
|
+
class ConfigurationError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised at class-definition time when a Decision declares a malformed question.
|
|
12
|
+
class InvalidQuestionError < ConfigurationError; end
|
|
13
|
+
|
|
14
|
+
# Raised when a Result is asked for a question that was never declared.
|
|
15
|
+
class UnknownQuestionError < Error; end
|
|
16
|
+
|
|
17
|
+
# The request never completed.
|
|
18
|
+
class TimeoutError < Error; end
|
|
19
|
+
|
|
20
|
+
# The request could not reach the API at all (DNS, TLS, refused connection).
|
|
21
|
+
class ConnectionError < Error; end
|
|
22
|
+
|
|
23
|
+
# The API answered with a non-2xx status.
|
|
24
|
+
class APIError < Error
|
|
25
|
+
attr_reader :status, :body, :response_headers
|
|
26
|
+
|
|
27
|
+
def initialize(message = nil, status: nil, body: nil, response_headers: nil)
|
|
28
|
+
@status = status
|
|
29
|
+
@body = body
|
|
30
|
+
@response_headers = response_headers || {}
|
|
31
|
+
super(message || default_message)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Builds the most specific error class for a given HTTP status.
|
|
35
|
+
def self.from_response(status:, body:, headers: {})
|
|
36
|
+
klass = case status
|
|
37
|
+
when 401 then AuthenticationError
|
|
38
|
+
when 403 then PermissionDeniedError
|
|
39
|
+
when 404 then NotFoundError
|
|
40
|
+
when 422 then InvalidRequestError
|
|
41
|
+
when 429 then RateLimitError
|
|
42
|
+
when 529 then OverloadedError
|
|
43
|
+
when 500..599 then ServerError
|
|
44
|
+
else self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
klass.new(extract_message(body), status: status, body: body, response_headers: headers)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.extract_message(body)
|
|
51
|
+
return body if body.is_a?(String)
|
|
52
|
+
return nil unless body.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
error = body["error"]
|
|
55
|
+
return error if error.is_a?(String)
|
|
56
|
+
return error["message"] if error.is_a?(Hash) && error["message"]
|
|
57
|
+
|
|
58
|
+
body["message"] || body["detail"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# True when retrying the identical request has a reasonable chance of succeeding.
|
|
62
|
+
def retryable?
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def default_message = "TypeSafe API returned HTTP #{status}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 401 -- missing or invalid API key.
|
|
72
|
+
class AuthenticationError < APIError
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def default_message
|
|
76
|
+
"TypeSafe rejected the API key. Check Jevalyn.config.api_key / ENV[\"TYPESAFE_API_KEY\"]."
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# 403 -- the key is valid but not allowed to do this.
|
|
81
|
+
class PermissionDeniedError < APIError; end
|
|
82
|
+
|
|
83
|
+
# 404 -- unknown endpoint or model.
|
|
84
|
+
class NotFoundError < APIError; end
|
|
85
|
+
|
|
86
|
+
# 422 -- the request body failed validation. The body names the offending field.
|
|
87
|
+
class InvalidRequestError < APIError
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def default_message = "TypeSafe rejected the request body (HTTP 422)"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# 429 -- over the account's tokens-per-second or requests-per-minute limit.
|
|
94
|
+
class RateLimitError < APIError
|
|
95
|
+
def retryable? = true
|
|
96
|
+
|
|
97
|
+
# Seconds the API asked us to wait, when it said.
|
|
98
|
+
def retry_after
|
|
99
|
+
value = response_headers["retry-after"] || response_headers["Retry-After"]
|
|
100
|
+
Float(value)
|
|
101
|
+
rescue ArgumentError, TypeError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def default_message = "TypeSafe rate limit exceeded (HTTP 429)"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# 529 -- TypeSafe is temporarily overloaded.
|
|
111
|
+
class OverloadedError < APIError
|
|
112
|
+
def retryable? = true
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def default_message = "TypeSafe is overloaded (HTTP 529)"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# 5xx other than 529.
|
|
120
|
+
class ServerError < APIError
|
|
121
|
+
def retryable? = true
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jevalyn
|
|
4
|
+
# Runs a Decision on an ActiveJob queue.
|
|
5
|
+
#
|
|
6
|
+
# Jev answers in well under a second, so the default advice is to call `evaluate`
|
|
7
|
+
# inline and keep the decision in the request's control flow -- that is the whole
|
|
8
|
+
# point of a System One model. Reach for `evaluate_later` when the decision is not
|
|
9
|
+
# on the critical path: backfilling triage over old tickets, scoring a batch
|
|
10
|
+
# overnight, or anywhere a third-party outage must not take a request down with it.
|
|
11
|
+
#
|
|
12
|
+
# SupportTriage.evaluate_later(ticket, on: TicketRouter)
|
|
13
|
+
# SupportTriage.evaluate_later(ticket, on: "TicketRouter.route")
|
|
14
|
+
#
|
|
15
|
+
# The handler is called with (result, state). It is named rather than passed as a
|
|
16
|
+
# block because a block cannot be serialised onto a queue.
|
|
17
|
+
module EvaluationJob
|
|
18
|
+
class << self
|
|
19
|
+
def enqueue(decision:, state:, handler:, model: nil, queue: nil, **options)
|
|
20
|
+
ensure_active_job!
|
|
21
|
+
|
|
22
|
+
job_class.set(queue: queue || Jevalyn.config.job_queue_name, **options).perform_later(
|
|
23
|
+
decision.name,
|
|
24
|
+
serializable(state),
|
|
25
|
+
handler_name(handler),
|
|
26
|
+
model
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def job_class
|
|
31
|
+
@job_class ||= build_job_class
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def ensure_active_job!
|
|
37
|
+
return if defined?(::ActiveJob::Base)
|
|
38
|
+
|
|
39
|
+
raise ConfigurationError,
|
|
40
|
+
"evaluate_later needs ActiveJob, which is not loaded. Use `evaluate` for " \
|
|
41
|
+
"an inline call -- Jev answers fast enough to sit in a request."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A Decision's state has to survive a round trip through the queue. GlobalID
|
|
45
|
+
# handles ActiveRecord; everything else has to already be JSON-shaped.
|
|
46
|
+
def serializable(state)
|
|
47
|
+
return state if state.is_a?(String) || state.is_a?(Hash) || state.is_a?(Array)
|
|
48
|
+
return state if defined?(::GlobalID) && state.respond_to?(:to_global_id)
|
|
49
|
+
|
|
50
|
+
State.serialize(state)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def handler_name(handler)
|
|
54
|
+
return handler if handler.is_a?(String)
|
|
55
|
+
return handler.name if handler.is_a?(Class) || handler.is_a?(Module)
|
|
56
|
+
|
|
57
|
+
raise ConfigurationError,
|
|
58
|
+
"`on:` must be a class, module, or a \"ClassName.method\" string -- a job " \
|
|
59
|
+
"handler has to survive serialisation, so it cannot be a block or a lambda."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_job_class
|
|
63
|
+
klass = Class.new(::ActiveJob::Base) do
|
|
64
|
+
def perform(decision_name, state, handler_name, model = nil)
|
|
65
|
+
decision = Object.const_get(decision_name)
|
|
66
|
+
result = decision.evaluate(state, model: model)
|
|
67
|
+
|
|
68
|
+
target, method_name = handler_name.split(".", 2)
|
|
69
|
+
Object.const_get(target).public_send(method_name || "call", result, state)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Named so backends that serialise by class name have something stable to use.
|
|
74
|
+
Jevalyn.const_set(:Job, klass) unless Jevalyn.const_defined?(:Job)
|
|
75
|
+
klass
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jevalyn
|
|
4
|
+
# A Decision narrowed to one job: should this be allowed through?
|
|
5
|
+
#
|
|
6
|
+
# class ToolCallGuardrail < Jevalyn::Guardrail
|
|
7
|
+
# question :safe_to_execute, type: :noul,
|
|
8
|
+
# instructions: "Is this tool call safe to run without human review?"
|
|
9
|
+
#
|
|
10
|
+
# allow_above 0.9
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# ToolCallGuardrail.check(tool_call).allow? # => false
|
|
14
|
+
#
|
|
15
|
+
# A guardrail declares exactly one noul question. Jev returns no confidence for a
|
|
16
|
+
# noul -- the probability *is* the answer -- so the gate is the probability itself,
|
|
17
|
+
# and `allow_above` is where you set it. The default is 0.5, which is a coin flip
|
|
18
|
+
# and almost certainly not what you want in front of anything destructive.
|
|
19
|
+
class Guardrail < Decision
|
|
20
|
+
DEFAULT_ALLOW_ABOVE = 0.5
|
|
21
|
+
|
|
22
|
+
# A guardrail that fails open is worse than one that fails loudly, so an error
|
|
23
|
+
# from the API denies rather than allows.
|
|
24
|
+
DEFAULT_ON_ERROR = :deny
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Probability the noul must reach for #allow? to be true.
|
|
28
|
+
def allow_above(value = :__read__)
|
|
29
|
+
if value == :__read__
|
|
30
|
+
return @allow_above if defined?(@allow_above) && @allow_above
|
|
31
|
+
return superclass.allow_above if superclass.respond_to?(:allow_above)
|
|
32
|
+
|
|
33
|
+
return DEFAULT_ALLOW_ABOVE
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
unless value.is_a?(Numeric) && value.between?(0, 1)
|
|
37
|
+
raise ConfigurationError,
|
|
38
|
+
"allow_above must be a number between 0 and 1, got #{value.inspect}."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@allow_above = value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# What to do when the API call itself fails: :deny (default) or :raise.
|
|
45
|
+
def on_error(value = :__read__)
|
|
46
|
+
if value == :__read__
|
|
47
|
+
return @on_error if defined?(@on_error) && @on_error
|
|
48
|
+
return superclass.on_error if superclass.respond_to?(:on_error)
|
|
49
|
+
|
|
50
|
+
return DEFAULT_ON_ERROR
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
unless %i[deny raise].include?(value)
|
|
54
|
+
raise ConfigurationError, "on_error must be :deny or :raise, got #{value.inspect}."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@on_error = value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Runs the guardrail. Returns a Result answering #allow? and #deny?.
|
|
61
|
+
def check(state, **options)
|
|
62
|
+
evaluate(state, **options)
|
|
63
|
+
rescue APIError, TimeoutError, ConnectionError => e
|
|
64
|
+
raise if on_error == :raise
|
|
65
|
+
|
|
66
|
+
Jevalyn.logger&.warn("[jevalyn] #{name} denied by default: #{e.class}: #{e.message}")
|
|
67
|
+
denied_result(e)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def evaluate(...)
|
|
71
|
+
ensure_single_noul!
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_result_class
|
|
76
|
+
decision = self
|
|
77
|
+
|
|
78
|
+
Class.new(super) do
|
|
79
|
+
# The gate is read at call time so `allow_above` can be changed after the
|
|
80
|
+
# class body has run -- in an initializer, or per environment.
|
|
81
|
+
define_method(:threshold) { decision.allow_above }
|
|
82
|
+
|
|
83
|
+
# The raw probability the gate is compared against.
|
|
84
|
+
define_method(:probability) { answer(decision.question_names.first).value }
|
|
85
|
+
|
|
86
|
+
def allow?
|
|
87
|
+
value = probability
|
|
88
|
+
!value.nil? && value >= threshold
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def deny? = !allow?
|
|
92
|
+
|
|
93
|
+
# Set when the guardrail denied because the call failed, not because Jev said no.
|
|
94
|
+
attr_reader :error
|
|
95
|
+
|
|
96
|
+
def failed? = !error.nil?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# A denial the caller can treat like any other, carrying the cause.
|
|
103
|
+
def denied_result(error)
|
|
104
|
+
question_key = question_names.first.to_s
|
|
105
|
+
raw = { "answers" => { question_key => { "type" => "noul", "noul" => 0.0 } } }
|
|
106
|
+
|
|
107
|
+
result = result_class.new(questions: questions, raw: raw, thresholds: thresholds)
|
|
108
|
+
result.instance_variable_set(:@error, error)
|
|
109
|
+
result
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ensure_single_noul!
|
|
113
|
+
declared = questions.values
|
|
114
|
+
|
|
115
|
+
if declared.size != 1
|
|
116
|
+
raise ConfigurationError,
|
|
117
|
+
"#{name || "A Guardrail"} must declare exactly one question, found " \
|
|
118
|
+
"#{declared.size}. Use a Jevalyn::Decision when you need more than one."
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
return if declared.first.type == :noul
|
|
122
|
+
|
|
123
|
+
raise ConfigurationError,
|
|
124
|
+
"#{name || "A Guardrail"} question :#{declared.first.name} is a " \
|
|
125
|
+
":#{declared.first.type}. A guardrail asks one yes/no question, so it " \
|
|
126
|
+
"must be a :noul."
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|