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.
@@ -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