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,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
|
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
|