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,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :jevalyn do
4
+ desc "Check that Jevalyn can reach the TypeSafe API with the configured key"
5
+ task ping: :environment do
6
+ config = Jevalyn.config
7
+ puts "base url: #{config.base_url}"
8
+ puts "model: #{config.default_model}"
9
+ puts "key: #{config.api_key ? "set (#{config.api_key[0, 6]}...)" : "MISSING"}"
10
+ puts "mock: #{config.mock_mode}"
11
+
12
+ models = Jevalyn.client.models
13
+ puts "\nreachable. models available to this account:"
14
+ models.each { |m| puts " #{m["name"]} - #{m["description"]}" }
15
+ rescue Jevalyn::Error => e
16
+ abort "\n#{e.class}: #{e.message}"
17
+ end
18
+
19
+ desc "List the Decision and Guardrail classes this app defines"
20
+ task decisions: :environment do
21
+ Rails.application.eager_load!
22
+
23
+ [Jevalyn::Guardrail, Jevalyn::Decision].each do |base|
24
+ found = base.subclasses.reject { |k| k <= Jevalyn::Guardrail && base == Jevalyn::Decision }
25
+ next if found.empty?
26
+
27
+ puts "#{base.name.demodulize}s:"
28
+ found.sort_by(&:name).each do |klass|
29
+ questions = klass.questions.values.map { |q| "#{q.name}:#{q.type}" }.join(" ")
30
+ puts " #{klass.name.ljust(32)} #{questions}"
31
+ end
32
+ puts
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+
7
+ module Jevalyn
8
+ module Testing
9
+ # Records real evaluations to a JSON file and replays them afterwards, so a suite
10
+ # pays for each distinct request once instead of on every run.
11
+ #
12
+ # Jevalyn::Testing::Cassette.use("spec/cassettes/triage.json") do
13
+ # SupportTriage.evaluate(ticket.body)
14
+ # end
15
+ #
16
+ # First run with a real API key recorded; every run after that replays. Delete the
17
+ # file to re-record. Requests are keyed by a digest of the exact body sent, so
18
+ # changing a rubric misses the cassette rather than silently replaying stale answers.
19
+ class Cassette
20
+ attr_reader :path
21
+
22
+ def initialize(path)
23
+ @path = path.to_s
24
+ @entries = load_entries
25
+ @recorded = false
26
+ end
27
+
28
+ # Runs the block with this cassette installed.
29
+ def self.use(path, &)
30
+ cassette = new(path)
31
+ cassette.install(&)
32
+ end
33
+
34
+ def install
35
+ previous = Thread.current[:jevalyn_testing]&.[](:cassette)
36
+ store[:cassette] = self
37
+ yield self
38
+ ensure
39
+ store[:cassette] = previous
40
+ save if @recorded
41
+ end
42
+
43
+ # Returns a recorded response for this request body, or nil.
44
+ def fetch(body)
45
+ @entries[digest_for(body)]
46
+ end
47
+
48
+ def record(body, response)
49
+ @entries[digest_for(body)] = response
50
+ @recorded = true
51
+ response
52
+ end
53
+
54
+ def recorded? = @recorded
55
+
56
+ def size = @entries.size
57
+
58
+ def save
59
+ FileUtils.mkdir_p(File.dirname(path))
60
+ File.write(path, "#{JSON.pretty_generate(@entries)}\n")
61
+ @recorded = false
62
+ path
63
+ end
64
+
65
+ # A stable key for a request. Sorting keys first means a Hash built in a
66
+ # different order still hits the same entry.
67
+ def digest_for(body)
68
+ Digest::SHA256.hexdigest(JSON.generate(deep_sort(body)))[0, 32]
69
+ end
70
+
71
+ private
72
+
73
+ def deep_sort(value)
74
+ case value
75
+ when Hash then value.sort_by { |key, _| key.to_s }.to_h { |k, v| [k.to_s, deep_sort(v)] }
76
+ when Array then value.map { |item| deep_sort(item) }
77
+ else value
78
+ end
79
+ end
80
+
81
+ def load_entries
82
+ return {} unless File.exist?(path)
83
+
84
+ JSON.parse(File.read(path))
85
+ rescue JSON::ParserError => e
86
+ raise Error, "Cassette #{path} is not valid JSON: #{e.message}"
87
+ end
88
+
89
+ def store
90
+ Thread.current[:jevalyn_testing] ||= {}
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jevalyn/testing"
4
+
5
+ # Minitest integration. In test_helper.rb:
6
+ #
7
+ # require "jevalyn/testing/minitest"
8
+ #
9
+ # class ActiveSupport::TestCase
10
+ # include Jevalyn::Testing::Minitest
11
+ # end
12
+ module Jevalyn
13
+ module Testing
14
+ module Minitest
15
+ def self.included(base)
16
+ base.setup do
17
+ @jevalyn_previous_mock_mode = Jevalyn.config.mock_mode
18
+ Jevalyn.config.mock_mode = true
19
+ Jevalyn::Testing.reset!
20
+ end
21
+
22
+ base.teardown do
23
+ Jevalyn.config.mock_mode = @jevalyn_previous_mock_mode
24
+ Jevalyn::Testing.reset!
25
+ end
26
+ end
27
+
28
+ def stub_jevalyn(decision, **values, &)
29
+ Jevalyn::Testing.stub(decision, **values, &)
30
+ end
31
+
32
+ def assert_evaluated(decision, times: nil)
33
+ calls = Jevalyn::Testing.calls.select { |call| call.decision == decision }
34
+
35
+ if times
36
+ assert_equal times, calls.length,
37
+ "expected #{decision} to be evaluated #{times} time(s), got #{calls.length}"
38
+ else
39
+ refute_empty calls, "expected #{decision} to have been evaluated"
40
+ end
41
+ end
42
+
43
+ def refute_evaluated(decision)
44
+ calls = Jevalyn::Testing.calls.select { |call| call.decision == decision }
45
+ assert_empty calls, "expected #{decision} not to have been evaluated"
46
+ end
47
+
48
+ def assert_certain(result, threshold)
49
+ assert result.certain?(threshold),
50
+ "expected every answer to clear #{threshold}, but " \
51
+ "#{result.uncertain_questions(threshold).inspect} did not"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jevalyn/testing"
4
+
5
+ # RSpec integration. Add to spec/spec_helper.rb:
6
+ #
7
+ # require "jevalyn/testing/rspec"
8
+ #
9
+ # It turns mock_mode on for the suite, clears stubs between examples, and adds:
10
+ #
11
+ # stub_jevalyn(SupportTriage, department: :technical, urgent: true)
12
+ # expect(SupportTriage).to have_been_evaluated
13
+ # expect(result).to be_certain_above(0.8)
14
+ # expect(result).to have_uncertain_questions(:department)
15
+ #
16
+ # Result's own predicates come through RSpec for free, including the per-question
17
+ # ones a Decision generates:
18
+ #
19
+ # expect(result).to be_certain
20
+ # expect(result).to be_department_certain
21
+ # expect(result).not_to be_severity_certain
22
+ #
23
+ # Tag an example `:jevalyn_live` to let it reach the real API.
24
+ module Jevalyn
25
+ module Testing
26
+ module RSpecHelpers
27
+ def stub_jevalyn(decision, **values, &)
28
+ Jevalyn::Testing.stub(decision, **values, &)
29
+ end
30
+
31
+ def stub_any_jevalyn(**values, &)
32
+ Jevalyn::Testing.stub_any(**values, &)
33
+ end
34
+
35
+ def forbid_jevalyn(decision)
36
+ Jevalyn::Testing.forbid(decision)
37
+ end
38
+
39
+ def jevalyn_calls = Jevalyn::Testing.calls
40
+
41
+ # The states passed to a decision, in call order.
42
+ def jevalyn_states_for(decision)
43
+ Jevalyn::Testing.calls.select { |call| call.decision == decision }.map(&:state)
44
+ end
45
+
46
+ def jevalyn_cassette(path, &)
47
+ Jevalyn::Testing::Cassette.use(path, &)
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ if defined?(RSpec)
54
+ require "rspec/expectations"
55
+
56
+ RSpec::Matchers.define :have_been_evaluated do
57
+ match do |decision|
58
+ @calls = Jevalyn::Testing.calls.select { |call| call.decision == decision }
59
+ @calls = @calls.select { |call| values_match?(@with, call.state) } if defined?(@with)
60
+ @count ? @calls.length == @count : @calls.any?
61
+ end
62
+
63
+ chain(:times) { |count| @count = count }
64
+ chain(:once) { @count = 1 }
65
+ chain(:with_state) { |state| @with = state }
66
+
67
+ failure_message do |decision|
68
+ seen = Jevalyn::Testing.calls.map { |call| call.decision.to_s }.tally
69
+ "expected #{decision} to have been evaluated#{" #{@count} time(s)" if @count}, " \
70
+ "but #{seen.empty? ? "nothing was evaluated" : "saw: #{seen.inspect}"}"
71
+ end
72
+
73
+ failure_message_when_negated do |decision|
74
+ "expected #{decision} not to have been evaluated, but it was #{@calls.length} time(s)"
75
+ end
76
+ end
77
+
78
+ RSpec::Matchers.define :be_certain_above do |threshold|
79
+ match { |result| result.certain?(threshold) }
80
+
81
+ failure_message do |result|
82
+ "expected every answer to clear #{threshold}, but " \
83
+ "#{result.uncertain_questions(threshold).map(&:inspect).join(", ")} did not " \
84
+ "(lowest certainty #{result.min_certainty.inspect})"
85
+ end
86
+ end
87
+
88
+ # Asserts exactly which answers missed their own floors. The failure prints the
89
+ # floor and the certainty side by side, which is the thing you actually need to see
90
+ # when a per-question threshold is set wrong.
91
+ RSpec::Matchers.define :have_uncertain_questions do |*expected|
92
+ match { |result| result.uncertain_questions.sort == expected.flatten.map(&:to_sym).sort }
93
+
94
+ failure_message do |result|
95
+ rows = result.thresholds.map do |name, floor|
96
+ certainty = result.answer(name).certainty
97
+ " #{name}: certainty=#{certainty.inspect} floor=#{floor.inspect}"
98
+ end
99
+
100
+ "expected #{expected.flatten.map(&:to_sym).inspect} to be the uncertain answers, " \
101
+ "got #{result.uncertain_questions.inspect}\n#{rows.join("\n")}"
102
+ end
103
+ end
104
+
105
+ RSpec.configure do |config|
106
+ config.include Jevalyn::Testing::RSpecHelpers
107
+
108
+ config.around do |example|
109
+ previous = Jevalyn.config.mock_mode
110
+ Jevalyn.config.mock_mode = !example.metadata[:jevalyn_live]
111
+ Jevalyn::Testing.reset!
112
+ example.run
113
+ ensure
114
+ Jevalyn.config.mock_mode = previous
115
+ Jevalyn::Testing.reset!
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Jevalyn
6
+ # Keeps test suites off the network.
7
+ #
8
+ # TypeSafe has no sandbox key or mock endpoint, so `mock_mode` is entirely local:
9
+ # with it on, Client never opens a connection and answers come from here instead.
10
+ #
11
+ # Jevalyn::Testing.stub(SupportTriage, department: :technical, urgent: true)
12
+ #
13
+ # result = SupportTriage.evaluate("payouts failing")
14
+ # result.department # => :technical
15
+ #
16
+ # Values are written the way you would assert on them -- `true`, `:technical`,
17
+ # `"major"` -- and Testing expands each into a response the real API could have
18
+ # returned, probability distribution and all.
19
+ module Testing
20
+ # How lopsided a stubbed distribution is. High enough to clear a realistic
21
+ # confidence threshold without being a fake 1.0 that no real model ever returns.
22
+ DEFAULT_CONFIDENCE = 0.95
23
+
24
+ # What a stubbed `true` and `false` become.
25
+ DEFAULT_NOUL_TRUE = 0.95
26
+ DEFAULT_NOUL_FALSE = 0.05
27
+
28
+ Call = Struct.new(:decision, :state, :questions, :model, :raw, keyword_init: true)
29
+
30
+ class NoStubError < Error; end
31
+
32
+ class << self
33
+ # Every evaluation made in mock mode, oldest first.
34
+ def calls
35
+ store[:calls] ||= []
36
+ end
37
+
38
+ # Registers answers for a Decision.
39
+ #
40
+ # Jevalyn::Testing.stub(SupportTriage, urgent: true, department: :technical)
41
+ # Jevalyn::Testing.stub(SupportTriage, confidence: 0.6, department: :billing)
42
+ # Jevalyn::Testing.stub(SupportTriage) { |state| { department: route_for(state) } }
43
+ #
44
+ # `confidence:` also takes a Hash, which is how you exercise per-question floors:
45
+ # one answer landing under its floor while another clears its own.
46
+ #
47
+ # Jevalyn::Testing.stub(SupportTriage,
48
+ # department: :technical, severity: "major",
49
+ # confidence: { department: 0.7, severity: 0.65 })
50
+ #
51
+ # A block is re-run per call and receives the serialised state, so one stub can
52
+ # answer differently for different inputs.
53
+ def stub(decision, confidence: DEFAULT_CONFIDENCE, **values, &block)
54
+ stubs[key_for(decision)] = { values: values, confidence: confidence, block: block }
55
+ decision
56
+ end
57
+
58
+ # A catch-all for decisions with no stub of their own.
59
+ def stub_any(confidence: DEFAULT_CONFIDENCE, **values, &block)
60
+ stubs[:__any__] = { values: values, confidence: confidence, block: block }
61
+ end
62
+
63
+ # Raises instead of answering, to prove a code path does not call Jev.
64
+ def forbid(decision)
65
+ stubs[key_for(decision)] = :forbidden
66
+ end
67
+
68
+ def stubbed?(decision) = stubs.key?(key_for(decision))
69
+
70
+ def reset!
71
+ store[:stubs] = {}
72
+ store[:calls] = []
73
+ store[:cassette] = nil
74
+ end
75
+
76
+ # Called by Client when config.mock_mode is on. Returns a raw response Hash.
77
+ def answer(body:, questions:, decision: nil)
78
+ raw = cassette&.fetch(body) || build_response(body: body, questions: questions, decision: decision)
79
+
80
+ calls << Call.new(
81
+ decision: decision, state: body["state"], questions: questions,
82
+ model: body["model"], raw: raw
83
+ )
84
+
85
+ raw
86
+ end
87
+
88
+ # Wraps a real API call in whatever cassette is currently installed: replays a
89
+ # recorded response when there is one, records the live answer when there is not.
90
+ def through_cassette(body)
91
+ active = cassette
92
+ return yield unless active
93
+
94
+ active.fetch(body) || active.record(body, yield)
95
+ end
96
+
97
+ # Stand-in for GET /v1/models.
98
+ def models
99
+ [
100
+ { "name" => "jev-latest", "description" => "Most recent stable release" },
101
+ { "name" => "jev-preview", "description" => "Most recent release, stable or not" }
102
+ ]
103
+ end
104
+
105
+ # Turns friendly values into a response body the API could have produced.
106
+ def build_response(body:, questions:, decision: nil)
107
+ entry = stub_for(decision)
108
+
109
+ if entry == :forbidden
110
+ raise NoStubError,
111
+ "#{decision} is forbidden in this example, but something evaluated it."
112
+ end
113
+
114
+ values, confidence = resolve(entry, body)
115
+
116
+ answers = questions.each_with_object({}) do |(name, question), out|
117
+ raise NoStubError, missing_message(decision, name, questions) unless values.key?(name)
118
+
119
+ out[name.to_s] = answer_for(question, values[name], confidence_for(confidence, name))
120
+ end
121
+
122
+ {
123
+ "model" => body["model"] == "jev-latest" ? "jev-1.13.0" : body["model"],
124
+ "answers" => answers,
125
+ "usage" => { "input_tokens" => State.estimated_tokens(body["state"]), "output_tokens" => 0 }
126
+ }
127
+ end
128
+
129
+ # Builds one answer object of the right shape for a question type.
130
+ def answer_for(question, value, confidence)
131
+ case question.type
132
+ when :noul then noul_answer(value)
133
+ when :choice then choice_answer(question, value, confidence)
134
+ when :score then score_answer(question, value, confidence)
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ # `confidence:` is either one number for every answer, or a Hash naming them
141
+ # individually so a spec can put one answer under its floor and another over.
142
+ def confidence_for(confidence, name)
143
+ return confidence unless confidence.is_a?(Hash)
144
+
145
+ confidence[name] || confidence[name.to_s] || DEFAULT_CONFIDENCE
146
+ end
147
+
148
+ def noul_answer(value)
149
+ probability =
150
+ case value
151
+ when true then DEFAULT_NOUL_TRUE
152
+ when false then DEFAULT_NOUL_FALSE
153
+ when Numeric then value.to_f
154
+ else
155
+ raise ArgumentError,
156
+ "A noul stub takes true, false or a Float between 0 and 1, got #{value.inspect}."
157
+ end
158
+
159
+ { "type" => "noul", "noul" => probability.round(6) }
160
+ end
161
+
162
+ def choice_answer(question, value, confidence)
163
+ chosen = value.to_s
164
+ options = question.criteria.keys.map(&:to_s)
165
+
166
+ unless options.include?(chosen)
167
+ raise ArgumentError,
168
+ "#{chosen.inspect} is not an option of :#{question.name}. " \
169
+ "It accepts: #{options.map(&:inspect).join(", ")}."
170
+ end
171
+
172
+ {
173
+ "type" => "choice",
174
+ "choice" => chosen,
175
+ "probabilities" => distribute(options, chosen, confidence),
176
+ "confidence" => confidence.to_f.round(6)
177
+ }
178
+ end
179
+
180
+ def score_answer(question, value, confidence)
181
+ levels = question.levels
182
+ index =
183
+ case value
184
+ when Integer then value
185
+ when Float then value.round
186
+ when String, Symbol
187
+ found = levels.index(value.to_s)
188
+ unless found
189
+ raise ArgumentError,
190
+ "#{value.inspect} is not a level of :#{question.name}. " \
191
+ "It has: #{levels.map(&:inspect).join(", ")}."
192
+ end
193
+ found
194
+ else
195
+ raise ArgumentError,
196
+ "A score stub takes a level name, or its index, got #{value.inspect}."
197
+ end
198
+
199
+ unless index.between?(0, levels.length - 1)
200
+ raise ArgumentError,
201
+ "Level #{index} is out of range for :#{question.name} (0..#{levels.length - 1})."
202
+ end
203
+
204
+ keys = (0...levels.length).map(&:to_s)
205
+ probabilities = distribute(keys, index.to_s, confidence)
206
+ # The real API returns a probability-weighted score, so do the same arithmetic
207
+ # here -- a stub that always returns a whole number hides rounding bugs.
208
+ weighted = probabilities.sum { |level, probability| level.to_i * probability }
209
+
210
+ {
211
+ "type" => "score",
212
+ "score" => weighted.round(6),
213
+ "legend" => keys.zip(levels).to_h,
214
+ "probabilities" => probabilities,
215
+ "confidence" => confidence.to_f.round(6)
216
+ }
217
+ end
218
+
219
+ # Puts `confidence` of the mass on the winner and spreads the rest evenly.
220
+ def distribute(keys, winner, confidence)
221
+ confidence = confidence.to_f.clamp(0.0, 1.0)
222
+ others = keys - [winner]
223
+ remainder = others.empty? ? 0.0 : (1.0 - confidence) / others.length
224
+
225
+ keys.to_h { |key| [key, (key == winner ? confidence : remainder).round(6)] }
226
+ end
227
+
228
+ def resolve(entry, body)
229
+ unless entry
230
+ raise NoStubError, <<~MSG.strip
231
+ Jevalyn is in mock_mode and nothing is stubbed for this evaluation.
232
+
233
+ Jevalyn::Testing.stub(YourDecision, question_name: value)
234
+
235
+ Or allow real calls in this example with `Jevalyn.config.mock_mode = false`.
236
+ MSG
237
+ end
238
+
239
+ values = entry[:values] || {}
240
+ values = values.merge(entry[:block].call(body["state"]) || {}) if entry[:block]
241
+ [symbolize(values), entry[:confidence] || DEFAULT_CONFIDENCE]
242
+ end
243
+
244
+ def stub_for(decision)
245
+ stubs[key_for(decision)] || stubs[:__any__]
246
+ end
247
+
248
+ def missing_message(decision, name, questions)
249
+ <<~MSG.strip
250
+ No stubbed value for :#{name} on #{decision || "this evaluation"}.
251
+
252
+ Jevalyn::Testing.stub(#{decision || "YourDecision"}, #{questions.keys.map { |k| "#{k}: ..." }.join(", ")})
253
+ MSG
254
+ end
255
+
256
+ def symbolize(hash)
257
+ hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
258
+ end
259
+
260
+ def key_for(decision)
261
+ decision.is_a?(Class) ? decision.name || decision.object_id : decision.to_s
262
+ end
263
+
264
+ def cassette = store[:cassette]
265
+
266
+ def stubs
267
+ store[:stubs] ||= {}
268
+ end
269
+
270
+ # Thread-local so parallel specs do not stub over each other.
271
+ def store
272
+ Thread.current[:jevalyn_testing] ||= {}
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevalyn
4
+ VERSION = "0.1.0"
5
+ end
data/lib/jevalyn.rb ADDED
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jevalyn/version"
4
+ require "jevalyn/errors"
5
+ require "jevalyn/configuration"
6
+ require "jevalyn/question"
7
+ require "jevalyn/answer"
8
+ require "jevalyn/result"
9
+ require "jevalyn/state_adapters/active_record_adapter"
10
+ require "jevalyn/state"
11
+ require "jevalyn/testing"
12
+ require "jevalyn/testing/cassette"
13
+ require "jevalyn/client"
14
+ require "jevalyn/decision"
15
+ require "jevalyn/guardrail"
16
+ require "jevalyn/router"
17
+ require "jevalyn/evaluation_job"
18
+
19
+ require "jevalyn/railtie" if defined?(Rails::Railtie)
20
+
21
+ # Jevalyn is the decision layer for your Rails app.
22
+ #
23
+ # It wraps TypeSafe's Jev, a System One model: you give it a state and a set of typed
24
+ # questions, and it gives back typed, calibrated answers. A probability that something
25
+ # is true, one category out of a set you defined, a rating against your own rubric.
26
+ #
27
+ # That is the whole surface. Jev does not write prose, summarise, or reason
28
+ # open-endedly, and Jevalyn does not pretend otherwise. What it does is make a
29
+ # decision cheap enough and fast enough to sit directly in a Rails request.
30
+ #
31
+ # class SupportTriage < Jevalyn::Decision
32
+ # question :department, type: :choice,
33
+ # instructions: "Which team should handle this?",
34
+ # criteria: {
35
+ # billing: "Payments, invoicing, refunds",
36
+ # technical: "Bugs, outages, integrations",
37
+ # sales: "Pricing, upgrades, new accounts"
38
+ # }
39
+ # end
40
+ #
41
+ # SupportTriage.evaluate(ticket.body).department # => :technical
42
+ module Jevalyn
43
+ class << self
44
+ def config
45
+ @config ||= Configuration.new
46
+ end
47
+
48
+ # Set up in config/initializers/jevalyn.rb.
49
+ #
50
+ # Jevalyn.configure do |c|
51
+ # c.api_key = ENV["TYPESAFE_API_KEY"]
52
+ # end
53
+ def configure
54
+ yield config
55
+ reset_client!
56
+ config
57
+ end
58
+
59
+ # The shared Client. Thread-safe to read; rebuilt whenever config changes.
60
+ def client
61
+ @client ||= Client.new(config)
62
+ end
63
+
64
+ def logger = config.logger
65
+
66
+ # One-off evaluation without declaring a Decision class. Fine in a console or a
67
+ # rake task; in application code a Decision gives you validation and a name.
68
+ #
69
+ # Jevalyn.evaluate(
70
+ # "Help! My payouts have been failing for 3 days.",
71
+ # urgent: { type: :noul, instructions: "Does this convey urgency?" }
72
+ # )
73
+ def evaluate(state, **questions)
74
+ client.evaluate(state: state, questions: questions)
75
+ end
76
+
77
+ def reset_client!
78
+ @client = nil
79
+ end
80
+
81
+ # Resets everything, for specs that mutate configuration.
82
+ def reset!
83
+ @config = nil
84
+ @client = nil
85
+ Testing.reset!
86
+ end
87
+ end
88
+ end