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,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# <%= class_name %> asks Jev a fixed set of questions about a piece of state and
|
|
4
|
+
# gets typed answers back. It does not generate text -- every answer is a
|
|
5
|
+
# probability, one of your options, or a level on your rubric.
|
|
6
|
+
class <%= class_name %> < Jevalyn::Decision
|
|
7
|
+
<% parsed_questions.each do |name, type| -%>
|
|
8
|
+
<% case type
|
|
9
|
+
when :noul -%>
|
|
10
|
+
# A noul returns the probability that the answer is yes, 0.0 to 1.0. You pick the
|
|
11
|
+
# cutoff -- `result.<%= name %>?` uses 0.5, `result.<%= name %>?(0.9)` is stricter.
|
|
12
|
+
question :<%= name %>, type: :noul,
|
|
13
|
+
instructions: "TODO: a yes/no question about the state",
|
|
14
|
+
criteria: {
|
|
15
|
+
true: "TODO: what a yes looks like",
|
|
16
|
+
false: "TODO: what a no looks like"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
<% when :choice -%>
|
|
20
|
+
# A choice picks one option and returns the full probability distribution plus a
|
|
21
|
+
# confidence. Give it every real option, not a shortlist -- Jev takes up to 255 and
|
|
22
|
+
# each one costs only a few tokens.
|
|
23
|
+
question :<%= name %>, type: :choice,
|
|
24
|
+
instructions: "TODO: what should the model decide?",
|
|
25
|
+
criteria: {
|
|
26
|
+
first_option: "TODO: when this option applies",
|
|
27
|
+
second_option: "TODO: when this one does instead"
|
|
28
|
+
},
|
|
29
|
+
# Optional. Without it this question uses the decision's floor below. Set it when
|
|
30
|
+
# this answer costs more to get wrong than the others.
|
|
31
|
+
confidence_threshold: <%= threshold %>
|
|
32
|
+
|
|
33
|
+
<% when :score -%>
|
|
34
|
+
# A score rates the state against ordered levels, lowest first, and returns a
|
|
35
|
+
# weighted number that can land between them: 1.6 means past the second level and
|
|
36
|
+
# not quite the third. Use `result.<%= name %>_label` for the nearest level's name.
|
|
37
|
+
question :<%= name %>, type: :score,
|
|
38
|
+
instructions: "TODO: what should the model rate?",
|
|
39
|
+
criteria: [
|
|
40
|
+
"TODO: lowest level",
|
|
41
|
+
"TODO: middle level",
|
|
42
|
+
"TODO: highest level"
|
|
43
|
+
],
|
|
44
|
+
confidence_threshold: <%= threshold %>
|
|
45
|
+
|
|
46
|
+
<% end -%>
|
|
47
|
+
<% end -%>
|
|
48
|
+
# The default floor for any question above that did not declare its own. Answers
|
|
49
|
+
# below their floor are reported by `result.uncertain?` and named by
|
|
50
|
+
# `result.uncertain_questions`. Set these by what a wrong answer costs: a misrouted
|
|
51
|
+
# ticket is cheap, an auto-approved refund is not.
|
|
52
|
+
confidence_threshold <%= threshold %>
|
|
53
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe <%= class_name %> do
|
|
6
|
+
it "declares the questions it sends" do
|
|
7
|
+
expect(described_class.question_names).to contain_exactly(<%= parsed_questions.map { |n, _| ":#{n}" }.join(", ") %>)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
<% first_name, first_type = parsed_questions.first -%>
|
|
11
|
+
it "answers <%= first_name %>" do
|
|
12
|
+
stub_jevalyn(described_class, <%= parsed_questions.map { |n, t| "#{n}: #{{ noul: "true", choice: ":first_option", score: "1" }[t]}" }.join(", ") %>)
|
|
13
|
+
|
|
14
|
+
result = described_class.evaluate("TODO: a realistic piece of state")
|
|
15
|
+
|
|
16
|
+
<% case first_type
|
|
17
|
+
when :noul -%>
|
|
18
|
+
expect(result.<%= first_name %>?).to be(true)
|
|
19
|
+
<% when :choice -%>
|
|
20
|
+
expect(result.<%= first_name %>).to eq(:first_option)
|
|
21
|
+
<% when :score -%>
|
|
22
|
+
expect(result.<%= first_name %>_level).to eq(1)
|
|
23
|
+
<% end -%>
|
|
24
|
+
expect(described_class).to have_been_evaluated.once
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Jev's answers move with the model and with your rubric wording. This is the test
|
|
28
|
+
# worth keeping honest: a handful of real inputs whose answer you are sure of, run
|
|
29
|
+
# against the live API. Tag it :jevalyn_live and run it deliberately, not in CI.
|
|
30
|
+
#
|
|
31
|
+
# it "routes real examples correctly", :jevalyn_live do
|
|
32
|
+
# result = described_class.evaluate(File.read("spec/fixtures/<%= file_name %>.txt"))
|
|
33
|
+
# expect(result.<%= first_name %>).to eq(...)
|
|
34
|
+
# end
|
|
35
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/named_base"
|
|
4
|
+
|
|
5
|
+
module Jevalyn
|
|
6
|
+
module Generators
|
|
7
|
+
# rails g jevalyn:guardrail ToolCall
|
|
8
|
+
class GuardrailGenerator < ::Rails::Generators::NamedBase
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates a Jevalyn::Guardrail in app/decisions, and a spec for it."
|
|
12
|
+
|
|
13
|
+
class_option :question, type: :string, default: "safe",
|
|
14
|
+
desc: "Name of the single noul question"
|
|
15
|
+
class_option :allow_above, type: :numeric, default: 0.9,
|
|
16
|
+
desc: "Probability required to allow"
|
|
17
|
+
class_option :spec, type: :boolean, default: true
|
|
18
|
+
|
|
19
|
+
def create_guardrail
|
|
20
|
+
template "guardrail.rb.tt", File.join("app/decisions", class_path, "#{file_name}.rb")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_spec
|
|
24
|
+
return unless options[:spec]
|
|
25
|
+
|
|
26
|
+
template "guardrail_spec.rb.tt", File.join("spec/decisions", class_path, "#{file_name}_spec.rb")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def question_name = options[:question]
|
|
32
|
+
|
|
33
|
+
def allow_above = options[:allow_above]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# <%= class_name %> is a one-question gate: it asks Jev whether something should be
|
|
4
|
+
# allowed through, and answers #allow? / #deny?.
|
|
5
|
+
#
|
|
6
|
+
# <%= class_name %>.check(payload).allow?
|
|
7
|
+
class <%= class_name %> < Jevalyn::Guardrail
|
|
8
|
+
question :<%= question_name %>, type: :noul,
|
|
9
|
+
instructions: "TODO: is this safe to allow without human review?",
|
|
10
|
+
criteria: {
|
|
11
|
+
true: "TODO: what clearly safe looks like",
|
|
12
|
+
false: "TODO: what needs a human"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# Jev returns no confidence for a noul -- the probability is the answer -- so this
|
|
16
|
+
# is the probability #allow? requires. The default is 0.5, a coin flip; set it by
|
|
17
|
+
# what letting a bad one through actually costs.
|
|
18
|
+
allow_above <%= allow_above %>
|
|
19
|
+
|
|
20
|
+
# If the API call itself fails, deny rather than allow. Set :raise to handle the
|
|
21
|
+
# outage yourself instead.
|
|
22
|
+
on_error :deny
|
|
23
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe <%= class_name %> do
|
|
6
|
+
it "allows when Jev is confident it is safe" do
|
|
7
|
+
stub_jevalyn(described_class, <%= question_name %>: 0.99)
|
|
8
|
+
|
|
9
|
+
expect(described_class.check("TODO: something safe")).to be_allow
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "denies when the probability is below the gate" do
|
|
13
|
+
stub_jevalyn(described_class, <%= question_name %>: <%= [allow_above.to_f - 0.1, 0.0].max.round(2) %>)
|
|
14
|
+
|
|
15
|
+
expect(described_class.check("TODO: something borderline")).to be_deny
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "denies rather than failing open when the API is unreachable" do
|
|
19
|
+
allow(Jevalyn.client).to receive(:evaluate).and_raise(Jevalyn::TimeoutError, "boom")
|
|
20
|
+
|
|
21
|
+
result = described_class.check("anything")
|
|
22
|
+
|
|
23
|
+
expect(result).to be_deny
|
|
24
|
+
expect(result).to be_failed
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Jevalyn
|
|
6
|
+
module Generators
|
|
7
|
+
# rails g jevalyn:install
|
|
8
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates config/initializers/jevalyn.rb and an app/decisions directory."
|
|
12
|
+
|
|
13
|
+
class_option :api_key_env, type: :string, default: "TYPESAFE_API_KEY",
|
|
14
|
+
desc: "Environment variable holding the TypeSafe API key"
|
|
15
|
+
|
|
16
|
+
def create_initializer
|
|
17
|
+
template "jevalyn.rb.tt", "config/initializers/jevalyn.rb"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create_decisions_directory
|
|
21
|
+
empty_directory "app/decisions"
|
|
22
|
+
create_file "app/decisions/.keep" unless File.exist?(File.join(destination_root,
|
|
23
|
+
"app/decisions/.keep"))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def report
|
|
27
|
+
say ""
|
|
28
|
+
say "Jevalyn is installed.", :green
|
|
29
|
+
say ""
|
|
30
|
+
say " 1. Put your key in the environment: #{options[:api_key_env]}=ts_..."
|
|
31
|
+
say " 2. Generate a decision: rails g jevalyn:decision SupportTriage"
|
|
32
|
+
say " 3. Check the connection: rails jevalyn:ping"
|
|
33
|
+
say ""
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Jevalyn.configure do |c|
|
|
4
|
+
# Your TypeSafe API key. Keep it out of the repo.
|
|
5
|
+
c.api_key = ENV["<%= options[:api_key_env] %>"]
|
|
6
|
+
|
|
7
|
+
# "jev-latest" tracks the newest stable release. An alias moves without telling
|
|
8
|
+
# you, so once you have tuned thresholds against a version, pin it here instead
|
|
9
|
+
# (e.g. "jev-1.13.0"). Every Result reports the version that actually answered.
|
|
10
|
+
c.default_model = "jev-latest"
|
|
11
|
+
|
|
12
|
+
# Jev answers in well under a second. A long timeout only means a slow request
|
|
13
|
+
# holds a Rails thread open for longer than it is worth.
|
|
14
|
+
c.timeout = 10
|
|
15
|
+
c.open_timeout = 5
|
|
16
|
+
|
|
17
|
+
# 429 (rate limited) and 529 (overloaded) are retried with exponential backoff,
|
|
18
|
+
# honouring the API's retry-after header when it sends one.
|
|
19
|
+
c.max_retries = 2
|
|
20
|
+
|
|
21
|
+
# No network in tests. Answers come from Jevalyn::Testing stubs instead; an
|
|
22
|
+
# unstubbed evaluation raises rather than silently passing.
|
|
23
|
+
c.mock_mode = Rails.env.test?
|
|
24
|
+
|
|
25
|
+
# Certainty floor a Decision uses when it does not declare its own. Leave nil to
|
|
26
|
+
# require each Decision to make its own call, which is usually the right default:
|
|
27
|
+
# the threshold for routing a support ticket is not the threshold for a refund.
|
|
28
|
+
# c.default_confidence_threshold = 0.75
|
|
29
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jevalyn
|
|
4
|
+
# One typed answer from Jev, wrapped so callers read it as Ruby rather than as a
|
|
5
|
+
# Hash of strings. Every answer knows the Question it came from, which is what lets
|
|
6
|
+
# a Choice come back as a Symbol and a Score come back with its label attached.
|
|
7
|
+
class Answer
|
|
8
|
+
attr_reader :question, :raw
|
|
9
|
+
|
|
10
|
+
def initialize(question, raw)
|
|
11
|
+
@question = question
|
|
12
|
+
@raw = raw || {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def name = question.name
|
|
16
|
+
|
|
17
|
+
def type = question.type
|
|
18
|
+
|
|
19
|
+
# The API's own certainty statistic, 0..1. Choice and Score carry one; a Noul
|
|
20
|
+
# does not, so this is nil for nouls. Use #certainty for a uniform number.
|
|
21
|
+
def confidence
|
|
22
|
+
value = raw["confidence"]
|
|
23
|
+
value&.to_f
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A 0..1 "how sure is this" usable across all three question types.
|
|
27
|
+
#
|
|
28
|
+
# For Choice and Score this is the API's own `confidence`. Jev returns no
|
|
29
|
+
# confidence for a Noul, so Jevalyn derives one from how far the probability sits
|
|
30
|
+
# from the 0.5 coin-flip: 0.92 and 0.08 are both decisive, 0.5 is not. This is
|
|
31
|
+
# Jevalyn's arithmetic, not TypeSafe's -- see Answer::Noul#decisiveness.
|
|
32
|
+
def certainty = confidence
|
|
33
|
+
|
|
34
|
+
# True when this answer clears the given confidence floor.
|
|
35
|
+
def certain?(threshold)
|
|
36
|
+
return true if threshold.nil?
|
|
37
|
+
|
|
38
|
+
value = certainty
|
|
39
|
+
return false if value.nil?
|
|
40
|
+
|
|
41
|
+
value >= threshold
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def uncertain?(threshold) = !certain?(threshold)
|
|
45
|
+
|
|
46
|
+
# The full distribution the answer was derived from, keys left as the API sent them.
|
|
47
|
+
def probabilities
|
|
48
|
+
raw["probabilities"] || {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def value = raise(NotImplementedError)
|
|
52
|
+
|
|
53
|
+
def to_h = raw
|
|
54
|
+
|
|
55
|
+
def inspect
|
|
56
|
+
"#<#{self.class.name} #{name}=#{value.inspect} certainty=#{certainty.inspect}>"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Probability that a yes/no question is a yes.
|
|
60
|
+
class Noul < Answer
|
|
61
|
+
# The raw 0..1 value. Deliberately not rounded to a boolean -- the whole point
|
|
62
|
+
# of a noul is that you pick the cutoff your use case can afford.
|
|
63
|
+
def value
|
|
64
|
+
raw["noul"]&.to_f
|
|
65
|
+
end
|
|
66
|
+
alias noul value
|
|
67
|
+
alias probability value
|
|
68
|
+
|
|
69
|
+
# How far from a coin flip the answer sits, rescaled to 0..1.
|
|
70
|
+
# 0.92 -> 0.84, 0.5 -> 0.0, 0.08 -> 0.84.
|
|
71
|
+
def decisiveness
|
|
72
|
+
return nil if value.nil?
|
|
73
|
+
|
|
74
|
+
((value - 0.5).abs * 2).round(10)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def certainty = decisiveness
|
|
78
|
+
|
|
79
|
+
# Jev returns no confidence for a noul; this stays nil on purpose.
|
|
80
|
+
def confidence = nil
|
|
81
|
+
|
|
82
|
+
def true?(threshold = 0.5)
|
|
83
|
+
return false if value.nil?
|
|
84
|
+
|
|
85
|
+
value >= threshold
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def false?(threshold = 0.5) = !true?(threshold)
|
|
89
|
+
|
|
90
|
+
# A noul has no `probabilities` field; the value is the probability of yes.
|
|
91
|
+
def probabilities
|
|
92
|
+
return {} if value.nil?
|
|
93
|
+
|
|
94
|
+
{ "true" => value, "false" => (1.0 - value).round(10) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# One option out of the declared set.
|
|
99
|
+
class Choice < Answer
|
|
100
|
+
# The winning option, as a Symbol so it reads like the criteria keys you wrote.
|
|
101
|
+
def value
|
|
102
|
+
chosen = raw["choice"]
|
|
103
|
+
chosen&.to_sym
|
|
104
|
+
end
|
|
105
|
+
alias choice value
|
|
106
|
+
|
|
107
|
+
# The winning option as the API spelled it.
|
|
108
|
+
def value_s = raw["choice"]
|
|
109
|
+
|
|
110
|
+
def probability_of(option)
|
|
111
|
+
probabilities[option.to_s]&.to_f
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Options ordered most to least likely.
|
|
115
|
+
def ranked
|
|
116
|
+
probabilities.sort_by { |_, probability| -probability.to_f }
|
|
117
|
+
.map { |option, probability| [option.to_sym, probability.to_f] }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def runner_up
|
|
121
|
+
ranked[1]&.first
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A rating against the declared levels.
|
|
126
|
+
class Score < Answer
|
|
127
|
+
# The probability-weighted score. A Float, and it lands between levels on
|
|
128
|
+
# purpose -- 1.6 means "past Frustrated, not quite Very angry".
|
|
129
|
+
def value
|
|
130
|
+
raw["score"]&.to_f
|
|
131
|
+
end
|
|
132
|
+
alias score value
|
|
133
|
+
|
|
134
|
+
# Level index mapped back to its description, as the API returned it.
|
|
135
|
+
def legend
|
|
136
|
+
raw["legend"] || {}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The nearest whole level.
|
|
140
|
+
def level
|
|
141
|
+
return nil if value.nil?
|
|
142
|
+
|
|
143
|
+
value.round
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The description of the nearest whole level, e.g. "Very angry".
|
|
147
|
+
def label
|
|
148
|
+
return nil if level.nil?
|
|
149
|
+
|
|
150
|
+
legend[level.to_s] || question.levels[level]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Highest-probability level, which is not always the nearest to #value.
|
|
154
|
+
def modal_level
|
|
155
|
+
top = probabilities.max_by { |_, probability| probability.to_f }
|
|
156
|
+
top && Integer(top.first)
|
|
157
|
+
rescue ArgumentError, TypeError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def probability_of(level_index)
|
|
162
|
+
probabilities[level_index.to_s]&.to_f
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Score normalised to 0..1 across the declared levels, for weighting in code.
|
|
166
|
+
def normalized
|
|
167
|
+
return nil if value.nil?
|
|
168
|
+
|
|
169
|
+
span = question.levels.length - 1
|
|
170
|
+
return nil if span <= 0
|
|
171
|
+
|
|
172
|
+
(value / span).round(10)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Jevalyn
|
|
7
|
+
# Thin, honest wrapper over POST /v1/systemone. It mirrors the HTTP API one-to-one:
|
|
8
|
+
# a state, a map of questions, one answer per question. Everything ergonomic lives
|
|
9
|
+
# a layer up in Decision -- this class stays boring on purpose.
|
|
10
|
+
class Client
|
|
11
|
+
USER_AGENT = "jevalyn/#{Jevalyn::VERSION} (ruby/#{RUBY_VERSION})".freeze
|
|
12
|
+
|
|
13
|
+
# Statuses worth trying again after a backoff. 429 and 529 are the API's own
|
|
14
|
+
# "slow down" and "come back later"; the rest are transient server faults.
|
|
15
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504, 529].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :config
|
|
18
|
+
|
|
19
|
+
def initialize(config = Jevalyn.config)
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Evaluates a state against a map of questions.
|
|
24
|
+
#
|
|
25
|
+
# state -- String, Hash or Array. Anything responding to #jevalyn_state or
|
|
26
|
+
# #as_json is serialised first; see Jevalyn::State.
|
|
27
|
+
# questions -- Hash of name => Jevalyn::Question, or name => Hash payload.
|
|
28
|
+
#
|
|
29
|
+
# thresholds is Jevalyn's own, never sent: a Hash of question => confidence floor,
|
|
30
|
+
# or a single number for all of them.
|
|
31
|
+
#
|
|
32
|
+
# Returns a Jevalyn::Result.
|
|
33
|
+
def evaluate(state:, questions:, model: nil, confidence_threshold: nil, thresholds: nil,
|
|
34
|
+
result_class: Result, decision: nil)
|
|
35
|
+
questions = normalize_questions(questions)
|
|
36
|
+
body = {
|
|
37
|
+
"model" => model || config.default_model,
|
|
38
|
+
"state" => State.serialize(state),
|
|
39
|
+
"questions" => questions.each_with_object({}) { |(name, q), out| out[name.to_s] = q.to_payload }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
raw = if config.mock_mode
|
|
43
|
+
Testing.answer(body: body, questions: questions, decision: decision)
|
|
44
|
+
else
|
|
45
|
+
Testing.through_cassette(body) { post(config.evaluation_url, body) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
result_class.new(questions: questions, raw: raw, thresholds: thresholds || confidence_threshold)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# GET /v1/models -- the names this account may send in the `model` field.
|
|
52
|
+
def models
|
|
53
|
+
return Testing.models if config.mock_mode
|
|
54
|
+
|
|
55
|
+
get(config.models_url).fetch("models", [])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Cheap liveness check for `rails runner` or a health endpoint.
|
|
59
|
+
def reachable?
|
|
60
|
+
models
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def normalize_questions(questions)
|
|
67
|
+
unless questions.is_a?(Hash) && !questions.empty?
|
|
68
|
+
raise ConfigurationError,
|
|
69
|
+
"evaluate needs a non-empty Hash of questions, got #{questions.inspect}."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
questions.each_with_object({}) do |(name, question), out|
|
|
73
|
+
out[name.to_sym] =
|
|
74
|
+
case question
|
|
75
|
+
when Question then question
|
|
76
|
+
when Hash then Question.build(name, **symbolize(question))
|
|
77
|
+
else
|
|
78
|
+
raise ConfigurationError,
|
|
79
|
+
"Question #{name.inspect} must be a Jevalyn::Question or a Hash, " \
|
|
80
|
+
"got #{question.class}."
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def symbolize(hash)
|
|
86
|
+
hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def post(url, body)
|
|
90
|
+
request(:post, url) { |req| req.body = JSON.generate(body) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def get(url)
|
|
94
|
+
request(:get, url)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def request(verb, url, &)
|
|
98
|
+
attempt = 0
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
response = connection.public_send(verb, url, &)
|
|
102
|
+
handle(response)
|
|
103
|
+
rescue Faraday::TimeoutError => e
|
|
104
|
+
raise TimeoutError, timeout_message(e)
|
|
105
|
+
rescue Faraday::ConnectionFailed, Faraday::SSLError => e
|
|
106
|
+
# Faraday reports an open timeout as a connection failure. From the caller's
|
|
107
|
+
# side it is still a timeout, and the difference matters when deciding whether
|
|
108
|
+
# to retry, so unwrap it rather than passing the label along.
|
|
109
|
+
raise TimeoutError, timeout_message(e) if timeout?(e)
|
|
110
|
+
|
|
111
|
+
raise ConnectionError, "Could not reach #{config.base_url}: #{e.message}"
|
|
112
|
+
rescue APIError => e
|
|
113
|
+
attempt += 1
|
|
114
|
+
raise unless e.retryable? && attempt <= config.max_retries.to_i
|
|
115
|
+
|
|
116
|
+
sleep(backoff_for(attempt, e))
|
|
117
|
+
retry
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def timeout?(error)
|
|
122
|
+
wrapped = error.respond_to?(:wrapped_exception) ? error.wrapped_exception : nil
|
|
123
|
+
|
|
124
|
+
wrapped.is_a?(Timeout::Error) || error.message.to_s.match?(/timed? ?out|execution expired/i)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def timeout_message(error)
|
|
128
|
+
"TypeSafe request timed out after #{config.timeout}s (#{error.message})"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def handle(response)
|
|
132
|
+
body = parse(response.body)
|
|
133
|
+
return body if response.success?
|
|
134
|
+
|
|
135
|
+
raise APIError.from_response(status: response.status, body: body, headers: response.headers)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse(body)
|
|
139
|
+
return {} if body.nil? || body.to_s.strip.empty?
|
|
140
|
+
return body if body.is_a?(Hash) || body.is_a?(Array)
|
|
141
|
+
|
|
142
|
+
JSON.parse(body)
|
|
143
|
+
rescue JSON::ParserError
|
|
144
|
+
body.to_s
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Honour the API's own retry-after when it sends one, exponential backoff otherwise.
|
|
148
|
+
def backoff_for(attempt, error)
|
|
149
|
+
requested = error.respond_to?(:retry_after) ? error.retry_after : nil
|
|
150
|
+
return requested if requested&.positive?
|
|
151
|
+
|
|
152
|
+
config.retry_backoff.to_f * (2**(attempt - 1))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def connection
|
|
156
|
+
@connection ||= Faraday.new do |f|
|
|
157
|
+
f.headers["Authorization"] = "Bearer #{config.api_key!}"
|
|
158
|
+
f.headers["Content-Type"] = "application/json"
|
|
159
|
+
f.headers["Accept"] = "application/json"
|
|
160
|
+
f.headers["User-Agent"] = USER_AGENT
|
|
161
|
+
f.options.timeout = config.timeout
|
|
162
|
+
f.options.open_timeout = config.open_timeout
|
|
163
|
+
f.adapter Faraday.default_adapter
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jevalyn
|
|
4
|
+
# Process-wide settings. Set these once in config/initializers/jevalyn.rb.
|
|
5
|
+
class Configuration
|
|
6
|
+
DEFAULT_BASE_URL = "https://api.typesafe.ai"
|
|
7
|
+
DEFAULT_MODEL = "jev-latest"
|
|
8
|
+
|
|
9
|
+
# The official SDKs default to 10s. Jev answers in well under a second, so a
|
|
10
|
+
# long timeout only means a slow request holds a Rails thread open.
|
|
11
|
+
DEFAULT_TIMEOUT = 10.0
|
|
12
|
+
DEFAULT_OPEN_TIMEOUT = 5.0
|
|
13
|
+
|
|
14
|
+
# 429 and 529 are expected under load; the API asks us to back off, not give up.
|
|
15
|
+
DEFAULT_MAX_RETRIES = 2
|
|
16
|
+
DEFAULT_RETRY_BACKOFF = 0.5
|
|
17
|
+
|
|
18
|
+
# Bearer token for the TypeSafe API.
|
|
19
|
+
attr_accessor :api_key
|
|
20
|
+
|
|
21
|
+
# Override to point at a proxy or a recorded fixture server.
|
|
22
|
+
attr_accessor :base_url
|
|
23
|
+
|
|
24
|
+
# Model name or alias sent when a call does not name one.
|
|
25
|
+
attr_accessor :default_model
|
|
26
|
+
|
|
27
|
+
# Per-request read timeout, in seconds.
|
|
28
|
+
attr_accessor :timeout
|
|
29
|
+
|
|
30
|
+
# Connection-open timeout, in seconds.
|
|
31
|
+
attr_accessor :open_timeout
|
|
32
|
+
|
|
33
|
+
# When true, no HTTP request is ever made -- answers come from Jevalyn::Testing.
|
|
34
|
+
attr_accessor :mock_mode
|
|
35
|
+
|
|
36
|
+
# How many times a retryable response (429 / 529 / 5xx) is retried.
|
|
37
|
+
attr_accessor :max_retries
|
|
38
|
+
|
|
39
|
+
# Base delay for exponential backoff between retries, in seconds.
|
|
40
|
+
attr_accessor :retry_backoff
|
|
41
|
+
|
|
42
|
+
# Anything responding to #info / #warn / #debug. Defaults to the Rails logger.
|
|
43
|
+
attr_accessor :logger
|
|
44
|
+
|
|
45
|
+
# Confidence floor a Decision uses when it does not declare its own.
|
|
46
|
+
attr_accessor :default_confidence_threshold
|
|
47
|
+
|
|
48
|
+
# ActiveJob queue used by .evaluate_later.
|
|
49
|
+
attr_accessor :job_queue_name
|
|
50
|
+
|
|
51
|
+
def initialize
|
|
52
|
+
@api_key = ENV.fetch("TYPESAFE_API_KEY", nil)
|
|
53
|
+
@base_url = ENV.fetch("TYPESAFE_BASE_URL", DEFAULT_BASE_URL)
|
|
54
|
+
@default_model = ENV.fetch("TYPESAFE_DEFAULT_MODEL", DEFAULT_MODEL)
|
|
55
|
+
@timeout = DEFAULT_TIMEOUT
|
|
56
|
+
@open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
57
|
+
@mock_mode = false
|
|
58
|
+
@max_retries = DEFAULT_MAX_RETRIES
|
|
59
|
+
@retry_backoff = DEFAULT_RETRY_BACKOFF
|
|
60
|
+
@logger = nil
|
|
61
|
+
@default_confidence_threshold = nil
|
|
62
|
+
@job_queue_name = :default
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Raises rather than letting a nil key turn into a confusing 401.
|
|
66
|
+
def api_key!
|
|
67
|
+
return @api_key if @api_key && !@api_key.to_s.strip.empty?
|
|
68
|
+
|
|
69
|
+
raise ConfigurationError, <<~MSG.strip
|
|
70
|
+
No TypeSafe API key configured. Set TYPESAFE_API_KEY in the environment, or
|
|
71
|
+
assign one in config/initializers/jevalyn.rb:
|
|
72
|
+
|
|
73
|
+
Jevalyn.configure do |c|
|
|
74
|
+
c.api_key = ENV["TYPESAFE_API_KEY"]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
In tests, set `c.mock_mode = true` instead and stub with Jevalyn::Testing.
|
|
78
|
+
MSG
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def evaluation_url = "#{base_url.to_s.chomp("/")}/v1/systemone"
|
|
82
|
+
|
|
83
|
+
def models_url = "#{base_url.to_s.chomp("/")}/v1/models"
|
|
84
|
+
end
|
|
85
|
+
end
|