typesafe-jev 0.4.1 → 0.7.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 +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/typesafe/answer.rb +134 -0
- data/lib/typesafe/choice_answer.rb +53 -0
- data/lib/typesafe/client.rb +17 -8
- data/lib/typesafe/errors.rb +178 -0
- data/lib/typesafe/jev.rb +7 -6
- data/lib/typesafe/noul_answer.rb +34 -0
- data/lib/typesafe/response.rb +127 -0
- data/lib/typesafe/score_answer.rb +84 -0
- data/lib/typesafe/usage.rb +64 -0
- data/lib/typesafe/version.rb +1 -1
- data/lib/typesafe.rb +10 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0627352ab282ec9a72607ebbc8d39bc73e2577231c567b4c66a7fc74e6140a0e
|
|
4
|
+
data.tar.gz: 218abce1d559c90b75e1b6bcf5872ff3f661d453dc6dd5c9bd15613141a89f1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ff6c07bf97933605fe0aa29c6d63fc6ed2390fa9cde14e9fc0016d17c373b45a85482f58ccf98b4df5279bb1c43e9174db8521faa4599c0ab2656eb94bb04b48
|
|
7
|
+
data.tar.gz: d6cd7d83fcff85966c653434fa64a5fd87a0a5fa23a7970643c5669ac298b367a4ead66d7eb44ebefdb32ab4f2a54963af1bdf5a9eeb2254952ecca9b966addd
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
- **Breaking**: `Typesafe::Client#evaluate` (and `Typesafe::Jev.evaluate`) now raises typed `Typesafe::Error` subclasses on any non-2xx response instead of raw `Net::HTTPClientException`/`Net::HTTPFatalError`: `BadRequestError` (400), `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `UnprocessableEntityError` (422, with `#errors`), `RateLimitError` (429, with `#retry_after`), `OverloadedError` (529) and `ServerError` (5xx), all under the `Typesafe::APIError` base with `status`, `body`, `headers`, `request_id` and `#retryable?`. `Typesafe::Error` is the root rescuable class.
|
|
6
|
+
|
|
7
|
+
## 0.6.0
|
|
8
|
+
|
|
9
|
+
- **Breaking**: `Typesafe::Client#evaluate` (and `Typesafe::Jev.evaluate`) now returns a typed `Typesafe::Response` instead of the raw parsed Hash; answers come back as `NoulAnswer`/`ChoiceAnswer`/`ScoreAnswer` objects accessible via `response[question_id]` (String or Symbol). Use `response.to_h` for the previous raw-Hash behavior.
|
|
10
|
+
|
|
11
|
+
## 0.5.0
|
|
12
|
+
|
|
13
|
+
- Add response classes mirroring the question classes: abstract `Typesafe::Answer` base with `Answer.from_h` dispatching on the `type` tag, plus `Typesafe::NoulAnswer` (`noul`), `Typesafe::ChoiceAnswer` (`choice`, `probabilities`, `confidence`) and `Typesafe::ScoreAnswer` (`score`, `legend`, `probabilities`, `confidence`). Frozen, immutable value objects that validate their inputs and serialize via `#to_h`/`#to_json`.
|
|
14
|
+
- Add `Typesafe::Usage` (token counts) and `Typesafe::Response` (`model`, `answers`, `usage`), with `Response.from_json`/`from_h` to parse an API response body into typed Answer objects; `Response#[]` accepts String or Symbol question ids.
|
|
15
|
+
|
|
3
16
|
## 0.4.1
|
|
4
17
|
|
|
5
18
|
- Fix gemspec metadata: point `source_code_uri`/`changelog_uri` and author/email at the `dtheofr` GitHub account.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
# Abstract base class for the three answer types returned by the TypeSafe
|
|
5
|
+
# System One API: {NoulAnswer}, {ChoiceAnswer}, and {ScoreAnswer}.
|
|
6
|
+
#
|
|
7
|
+
# An answer is an immutable value object mirroring its question: it carries
|
|
8
|
+
# what the model returned for one question, but not the question id — that
|
|
9
|
+
# is the Hash key under which the answer appears in a {Response}.
|
|
10
|
+
#
|
|
11
|
+
# Use {Answer.from_h} (or {Response.from_json}) to build typed answers from
|
|
12
|
+
# a parsed response body:
|
|
13
|
+
#
|
|
14
|
+
# Typesafe::Answer.from_h({ "type" => "noul", "noul" => 0.95 })
|
|
15
|
+
# # => #<Typesafe::NoulAnswer @noul=0.95>
|
|
16
|
+
class Answer
|
|
17
|
+
# Builds the typed answer matching +hash+'s +type+ tag.
|
|
18
|
+
#
|
|
19
|
+
# @param hash [Hash] one entry of the API response's +answers+ map, with
|
|
20
|
+
# String or Symbol keys.
|
|
21
|
+
# @return [NoulAnswer, ChoiceAnswer, ScoreAnswer]
|
|
22
|
+
# @raise [ArgumentError] if +hash+ is not a Hash or its +type+ is missing
|
|
23
|
+
# or unknown.
|
|
24
|
+
def self.from_h(hash)
|
|
25
|
+
unless hash.is_a?(Hash)
|
|
26
|
+
raise ArgumentError, "answer must be a Hash, got #{hash.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
type = hash["type"] || hash[:type]
|
|
30
|
+
case type
|
|
31
|
+
when "noul" then NoulAnswer.from_h(hash)
|
|
32
|
+
when "choice" then ChoiceAnswer.from_h(hash)
|
|
33
|
+
when "score" then ScoreAnswer.from_h(hash)
|
|
34
|
+
else
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
"unknown answer type #{type.inspect} (expected \"noul\", \"choice\", or \"score\")"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [String] the API answer type tag ("noul", "choice", or "score").
|
|
41
|
+
# @raise [NotImplementedError] on the abstract base class.
|
|
42
|
+
def type
|
|
43
|
+
raise NotImplementedError, "#{self.class} must implement #type"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Hash] the answer shape returned by the TypeSafe API.
|
|
47
|
+
def to_h
|
|
48
|
+
{ type: type }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [String] the answer serialized as JSON.
|
|
52
|
+
def to_json(state = nil)
|
|
53
|
+
JSON.generate(to_h, state)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Answers compare equal when they are of the same class and serialize
|
|
57
|
+
# to the same API shape.
|
|
58
|
+
def ==(other)
|
|
59
|
+
other.class == self.class && other.to_h == to_h
|
|
60
|
+
end
|
|
61
|
+
alias eql? ==
|
|
62
|
+
|
|
63
|
+
def hash
|
|
64
|
+
[self.class, to_h].hash
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Validates that +value+ is a non-empty String and returns a frozen copy.
|
|
70
|
+
def validate_string(value, name)
|
|
71
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
72
|
+
raise ArgumentError, "#{name} must be a non-empty String"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
value.dup.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Validates that +value+ is a finite Numeric, optionally within +range+.
|
|
79
|
+
def validate_number(value, name, range: nil)
|
|
80
|
+
unless value.is_a?(Numeric) && value.finite?
|
|
81
|
+
raise ArgumentError, "#{name} must be a finite Numeric, got #{value.inspect}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if range && !range.cover?(value)
|
|
85
|
+
raise ArgumentError, "#{name} must be between #{range.min} and #{range.max}, got #{value.inspect}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
value
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Validates that +value+ is a probability between 0 and 1.
|
|
92
|
+
def validate_probability(value, name)
|
|
93
|
+
validate_number(value, name, range: 0.0..1.0)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Validates a probability distribution: a non-empty Hash with String or
|
|
97
|
+
# Symbol keys mapping to probabilities in 0..1 that sum to 1. When
|
|
98
|
+
# +allowed_keys+ is given, the keys must match it exactly.
|
|
99
|
+
def normalize_probabilities(probabilities, allowed_keys: nil)
|
|
100
|
+
unless probabilities.is_a?(Hash) && !probabilities.empty?
|
|
101
|
+
raise ArgumentError, "probabilities must be a non-empty Hash of option or level keys to probabilities"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
normalized = probabilities.each_with_object({}) do |(key, probability), result|
|
|
105
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
106
|
+
raise ArgumentError, "probabilities keys must be Strings or Symbols, got #{key.inspect}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
result[key.is_a?(String) ? key.dup.freeze : key] =
|
|
110
|
+
validate_probability(probability, "probabilities[#{key.inspect}]")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
sum = normalized.values.sum
|
|
114
|
+
unless (sum - 1.0).abs <= 1e-6
|
|
115
|
+
raise ArgumentError, "probabilities must sum to 1, got #{sum}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if allowed_keys
|
|
119
|
+
expected = allowed_keys.map(&:to_s)
|
|
120
|
+
actual = normalized.keys.map(&:to_s)
|
|
121
|
+
missing = expected - actual
|
|
122
|
+
unexpected = actual - expected
|
|
123
|
+
unless missing.empty? && unexpected.empty?
|
|
124
|
+
problems = []
|
|
125
|
+
problems << "missing #{missing.map(&:inspect).join(", ")}" unless missing.empty?
|
|
126
|
+
problems << "unexpected #{unexpected.map(&:inspect).join(", ")}" unless unexpected.empty?
|
|
127
|
+
raise ArgumentError, "probabilities keys must match the legend keys (#{problems.join("; ")})"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
normalized.freeze
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
# The answer to a {Choice} question: the selected option, the probability
|
|
5
|
+
# distribution across every option, and a confidence between 0 and 1.
|
|
6
|
+
#
|
|
7
|
+
# Typesafe::ChoiceAnswer.new(
|
|
8
|
+
# choice: "billing",
|
|
9
|
+
# probabilities: { "billing" => 0.88, "technical" => 0.12, "sales" => 0.0 },
|
|
10
|
+
# confidence: 0.81
|
|
11
|
+
# )
|
|
12
|
+
class ChoiceAnswer < Answer
|
|
13
|
+
# @return [String] the highest-probability option.
|
|
14
|
+
attr_reader :choice
|
|
15
|
+
|
|
16
|
+
# @return [Hash] every option mapped to its probability.
|
|
17
|
+
attr_reader :probabilities
|
|
18
|
+
|
|
19
|
+
# @return [Numeric] how certain the model is, between 0 and 1.
|
|
20
|
+
attr_reader :confidence
|
|
21
|
+
|
|
22
|
+
# @param choice [String] the selected option.
|
|
23
|
+
# @param probabilities [Hash] option keys (String or Symbol) mapped to
|
|
24
|
+
# probabilities in 0..1; the values must sum to 1.
|
|
25
|
+
# @param confidence [Numeric] the model's confidence, between 0 and 1.
|
|
26
|
+
# @raise [ArgumentError] if any attribute is invalid.
|
|
27
|
+
def initialize(choice:, probabilities:, confidence:)
|
|
28
|
+
@choice = validate_string(choice, "choice")
|
|
29
|
+
@probabilities = normalize_probabilities(probabilities)
|
|
30
|
+
@confidence = validate_probability(confidence, "confidence")
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param hash [Hash] a +choice+ answer entry from the API response, with
|
|
35
|
+
# String or Symbol keys.
|
|
36
|
+
# @return [ChoiceAnswer]
|
|
37
|
+
def self.from_h(hash)
|
|
38
|
+
new(
|
|
39
|
+
choice: hash["choice"] || hash[:choice],
|
|
40
|
+
probabilities: hash["probabilities"] || hash[:probabilities],
|
|
41
|
+
confidence: hash["confidence"] || hash[:confidence]
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def type
|
|
46
|
+
"choice"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_h
|
|
50
|
+
{ type: type, choice: choice, probabilities: probabilities, confidence: confidence }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/typesafe/client.rb
CHANGED
|
@@ -40,8 +40,9 @@ module Typesafe
|
|
|
40
40
|
freeze
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
# Evaluates a state against a map of questions and returns
|
|
44
|
-
#
|
|
43
|
+
# Evaluates a state against a map of questions and returns a {Response}
|
|
44
|
+
# with one typed {Answer} per question, e.g. `response[:refund_requested]`.
|
|
45
|
+
# `response.to_h` gives the raw parsed body as a Hash.
|
|
45
46
|
#
|
|
46
47
|
# @param state [String, Object, Array] the content to evaluate; passed
|
|
47
48
|
# through as-is.
|
|
@@ -49,11 +50,16 @@ module Typesafe
|
|
|
49
50
|
# to Question objects; answers come back under the same keys.
|
|
50
51
|
# @param model [String, nil] model for this call; falls back to the model
|
|
51
52
|
# given at initialization.
|
|
52
|
-
# @return [
|
|
53
|
+
# @return [Response] the parsed response.
|
|
53
54
|
# @raise [ArgumentError] if +questions+ is not a non-empty Hash of
|
|
54
|
-
# String/Symbol keys to Question values,
|
|
55
|
-
#
|
|
56
|
-
#
|
|
55
|
+
# String/Symbol keys to Question values, +model+ is invalid, or the
|
|
56
|
+
# response body is not a valid response shape.
|
|
57
|
+
# @raise [JSON::ParserError] if the response body is not valid JSON.
|
|
58
|
+
# @raise [Typesafe::APIError] (or a subclass) on any non-2xx HTTP response:
|
|
59
|
+
# {Typesafe::BadRequestError}, {Typesafe::AuthenticationError},
|
|
60
|
+
# {Typesafe::PermissionDeniedError}, {Typesafe::NotFoundError},
|
|
61
|
+
# {Typesafe::UnprocessableEntityError}, {Typesafe::RateLimitError},
|
|
62
|
+
# {Typesafe::OverloadedError} and {Typesafe::ServerError}.
|
|
57
63
|
def evaluate(state:, questions:, model: nil)
|
|
58
64
|
model = model.nil? ? self.model : freeze_string(model, "model must be a non-empty String")
|
|
59
65
|
questions = validate_questions!(questions)
|
|
@@ -65,8 +71,11 @@ module Typesafe
|
|
|
65
71
|
)
|
|
66
72
|
|
|
67
73
|
response = post(body)
|
|
68
|
-
response.
|
|
69
|
-
|
|
74
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
75
|
+
raise Errors.from_response(status: response.code.to_i, headers: response, body: response.body)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
Response.from_json(response.body)
|
|
70
79
|
end
|
|
71
80
|
|
|
72
81
|
private
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
# Root of all errors raised by the gem, so users can `rescue Typesafe::Error`.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# A non-2xx HTTP response from the TypeSafe API.
|
|
8
|
+
#
|
|
9
|
+
# begin
|
|
10
|
+
# client.evaluate(state:, questions:)
|
|
11
|
+
# rescue Typesafe::RateLimitError => e
|
|
12
|
+
# sleep(e.retry_after || 1.0)
|
|
13
|
+
# retry
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# The `detail` field of an error body comes in three shapes, all of which
|
|
17
|
+
# are rendered into the exception message:
|
|
18
|
+
#
|
|
19
|
+
# * a plain String — `{"detail": "Unknown model: jev-99"}`
|
|
20
|
+
# * a Hash with error_type/message —
|
|
21
|
+
# `{"detail": {"error_type": "authentication_error", "message": "..."}}`
|
|
22
|
+
# * an Array of validation entries (FastAPI/pydantic style) —
|
|
23
|
+
# `{"detail": [{"type": "missing", "loc": ["body", "questions"], "msg": "Field required"}]}`
|
|
24
|
+
class APIError < Error
|
|
25
|
+
# @return [Integer] the HTTP status code.
|
|
26
|
+
attr_reader :status
|
|
27
|
+
# @return [Hash, Array, String, nil] the parsed JSON body (Hash or Array),
|
|
28
|
+
# the raw body String when it is not valid JSON, or nil for an empty body.
|
|
29
|
+
attr_reader :body
|
|
30
|
+
# @return [Hash{String => String}] the response headers, as received.
|
|
31
|
+
attr_reader :headers
|
|
32
|
+
# @return [String, nil] the +x-typesafe-request-id+ response header, when present.
|
|
33
|
+
attr_reader :request_id
|
|
34
|
+
|
|
35
|
+
# @param status [Integer] the HTTP status code.
|
|
36
|
+
# @param body [Hash, Array, String, nil] the parsed or raw response body.
|
|
37
|
+
# @param headers [Hash{String => String}] the response headers.
|
|
38
|
+
# @param message [String, nil] overrides the rendered +detail+-based message.
|
|
39
|
+
def initialize(status:, body: nil, headers: {}, message: nil)
|
|
40
|
+
@status = status
|
|
41
|
+
@body = body
|
|
42
|
+
@headers = headers
|
|
43
|
+
@request_id = header("x-typesafe-request-id")
|
|
44
|
+
super(message || "The TypeSafe API returned status #{status}.")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# True when the request may succeed if retried after a delay: rate limits
|
|
48
|
+
# (429), overload (529) and server errors (5xx).
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def retryable?
|
|
51
|
+
status == 429 || status == 529 || (500..599).cover?(status)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Case-insensitive header lookup, since header casing depends on the server.
|
|
57
|
+
def header(name)
|
|
58
|
+
headers.each { |key, value| return value if key.to_s.downcase == name }
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The request was invalid (400).
|
|
64
|
+
class BadRequestError < APIError; end
|
|
65
|
+
|
|
66
|
+
# Missing or invalid API key (401).
|
|
67
|
+
class AuthenticationError < APIError; end
|
|
68
|
+
|
|
69
|
+
# Access denied (403). The API also returns this for a missing API key.
|
|
70
|
+
class PermissionDeniedError < APIError; end
|
|
71
|
+
|
|
72
|
+
# The endpoint or resource was not found (404).
|
|
73
|
+
class NotFoundError < APIError; end
|
|
74
|
+
|
|
75
|
+
# The request body failed server-side validation (422).
|
|
76
|
+
class UnprocessableEntityError < APIError
|
|
77
|
+
# The parsed validation entries, e.g.
|
|
78
|
+
# `{"type" => "missing", "loc" => ["body", "questions"], "msg" => "Field required"}`;
|
|
79
|
+
# empty when the body carries none.
|
|
80
|
+
# @return [Array<Hash>]
|
|
81
|
+
def errors
|
|
82
|
+
detail = body.is_a?(Hash) ? body["detail"] : nil
|
|
83
|
+
detail.is_a?(Array) ? detail : []
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The rate limit was exceeded (429).
|
|
88
|
+
class RateLimitError < APIError
|
|
89
|
+
# The server's requested wait before retrying, parsed from the
|
|
90
|
+
# +Retry-After+ (seconds) or +Retry-After-Ms+ (milliseconds) header.
|
|
91
|
+
# @return [Float, Integer, nil]
|
|
92
|
+
def retry_after
|
|
93
|
+
if (ms = header("retry-after-ms"))
|
|
94
|
+
Float(ms) / 1000
|
|
95
|
+
elsif (seconds = header("retry-after"))
|
|
96
|
+
Float(seconds)
|
|
97
|
+
end
|
|
98
|
+
rescue ArgumentError, TypeError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# TypeSafe is temporarily overloaded (529).
|
|
104
|
+
class OverloadedError < APIError; end
|
|
105
|
+
|
|
106
|
+
# The server failed to process the request (5xx, excluding 529).
|
|
107
|
+
class ServerError < APIError; end
|
|
108
|
+
|
|
109
|
+
# Builds error instances from raw HTTP responses.
|
|
110
|
+
module Errors
|
|
111
|
+
module_function
|
|
112
|
+
|
|
113
|
+
# Maps a non-2xx response to the matching error class.
|
|
114
|
+
#
|
|
115
|
+
# @param status [Integer] the HTTP status code.
|
|
116
|
+
# @param headers [Hash, #each_header] the response headers.
|
|
117
|
+
# @param body [String, Hash, Array, nil] the raw or parsed response body.
|
|
118
|
+
# @return [APIError] an instance of the class matching +status+.
|
|
119
|
+
def from_response(status:, headers: {}, body: nil)
|
|
120
|
+
parsed_body = parse_body(body)
|
|
121
|
+
klass = STATUS_CLASSES.fetch(status) do
|
|
122
|
+
(500..599).cover?(status) ? ServerError : APIError
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
klass.new(
|
|
126
|
+
status: status,
|
|
127
|
+
body: parsed_body.is_a?(String) ? parsed_body.dup.freeze : parsed_body.freeze,
|
|
128
|
+
headers: normalize_headers(headers).freeze,
|
|
129
|
+
message: message_from_body(parsed_body, status)
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def parse_body(body)
|
|
134
|
+
return body unless body.is_a?(String)
|
|
135
|
+
|
|
136
|
+
JSON.parse(body)
|
|
137
|
+
rescue JSON::ParserError, TypeError
|
|
138
|
+
body
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def message_from_body(parsed_body, status)
|
|
142
|
+
detail = parsed_body.is_a?(Hash) ? parsed_body["detail"] : nil
|
|
143
|
+
case detail
|
|
144
|
+
when String then detail
|
|
145
|
+
when Hash then detail["message"] || detail[:message] || "Invalid request."
|
|
146
|
+
when Array then detail.map { |entry| validation_message(entry) }.join("; ")
|
|
147
|
+
else "The TypeSafe API returned status #{status}."
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validation_message(entry)
|
|
152
|
+
return entry.to_s unless entry.is_a?(Hash)
|
|
153
|
+
|
|
154
|
+
loc = Array(entry["loc"]).join(".")
|
|
155
|
+
msg = entry["msg"] || entry[:msg]
|
|
156
|
+
loc.empty? ? msg.to_s : "#{loc}: #{msg}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def normalize_headers(headers)
|
|
160
|
+
return headers.each_header.to_h if headers.respond_to?(:each_header)
|
|
161
|
+
|
|
162
|
+
headers.dup
|
|
163
|
+
rescue TypeError
|
|
164
|
+
{}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
STATUS_CLASSES = {
|
|
168
|
+
400 => BadRequestError,
|
|
169
|
+
401 => AuthenticationError,
|
|
170
|
+
403 => PermissionDeniedError,
|
|
171
|
+
404 => NotFoundError,
|
|
172
|
+
422 => UnprocessableEntityError,
|
|
173
|
+
429 => RateLimitError,
|
|
174
|
+
529 => OverloadedError
|
|
175
|
+
}.freeze
|
|
176
|
+
private_constant :STATUS_CLASSES
|
|
177
|
+
end
|
|
178
|
+
end
|
data/lib/typesafe/jev.rb
CHANGED
|
@@ -31,11 +31,12 @@ module Typesafe
|
|
|
31
31
|
# @param questions [Hash{String, Symbol => Question}] question ids mapped
|
|
32
32
|
# to Question objects; answers come back under the same keys.
|
|
33
33
|
# @param model [String, nil] must be nil or "jev-latest".
|
|
34
|
-
# @return [
|
|
35
|
-
# @raise [ArgumentError] if +questions+ is invalid
|
|
36
|
-
# pinned one.
|
|
37
|
-
# @raise [
|
|
38
|
-
#
|
|
34
|
+
# @return [Response] the parsed response.
|
|
35
|
+
# @raise [ArgumentError] if +questions+ is invalid, +model+ is not the
|
|
36
|
+
# pinned one, or the response body is not a valid response shape.
|
|
37
|
+
# @raise [JSON::ParserError] if the response body is not valid JSON.
|
|
38
|
+
# @raise [Typesafe::APIError] (or a subclass) on any non-2xx HTTP response;
|
|
39
|
+
# see {Typesafe::Client#evaluate}.
|
|
39
40
|
def evaluate(state:, questions:, model: nil)
|
|
40
41
|
if model && model != PINNED_MODEL
|
|
41
42
|
raise ArgumentError,
|
|
@@ -53,7 +54,7 @@ module Typesafe
|
|
|
53
54
|
# to Question objects.
|
|
54
55
|
# @param api_key [String, nil] the TypeSafe API key; falls back to the
|
|
55
56
|
# +TYPESAFE_API_KEY+ environment variable.
|
|
56
|
-
# @return [
|
|
57
|
+
# @return [Response] the parsed response.
|
|
57
58
|
def self.evaluate(state:, questions:, api_key: nil)
|
|
58
59
|
new(api_key: api_key).evaluate(state: state, questions: questions)
|
|
59
60
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
# The answer to a {Noul} question: the yes/no probability on a scale from
|
|
5
|
+
# 0 (no) to 1 (yes).
|
|
6
|
+
#
|
|
7
|
+
# Typesafe::NoulAnswer.new(noul: 0.95)
|
|
8
|
+
class NoulAnswer < Answer
|
|
9
|
+
# @return [Numeric] the yes/no answer, between 0 (no) and 1 (yes).
|
|
10
|
+
attr_reader :noul
|
|
11
|
+
|
|
12
|
+
# @param noul [Numeric] the yes/no probability, between 0 and 1.
|
|
13
|
+
# @raise [ArgumentError] if +noul+ is not a Numeric between 0 and 1.
|
|
14
|
+
def initialize(noul:)
|
|
15
|
+
@noul = validate_probability(noul, "noul")
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param hash [Hash] a +noul+ answer entry from the API response, with
|
|
20
|
+
# String or Symbol keys.
|
|
21
|
+
# @return [NoulAnswer]
|
|
22
|
+
def self.from_h(hash)
|
|
23
|
+
new(noul: hash["noul"] || hash[:noul])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def type
|
|
27
|
+
"noul"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{ type: type, noul: noul }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
# A System One evaluation response: the model that performed the
|
|
5
|
+
# evaluation, one typed {Answer} per question (keyed by the question ids
|
|
6
|
+
# from the request), and the token {Usage}.
|
|
7
|
+
#
|
|
8
|
+
# response = Typesafe::Response.from_json(json)
|
|
9
|
+
# response[:refund_requested] # => #<Typesafe::NoulAnswer @noul=0.95>
|
|
10
|
+
#
|
|
11
|
+
# The answer objects do not carry their question id — it is the Hash key
|
|
12
|
+
# under which each answer appears in {#answers}, mirroring how questions
|
|
13
|
+
# are sent.
|
|
14
|
+
class Response
|
|
15
|
+
# @return [String] the model that performed the evaluation.
|
|
16
|
+
attr_reader :model
|
|
17
|
+
|
|
18
|
+
# @return [Hash] question ids (String or Symbol) mapped to Answer objects.
|
|
19
|
+
attr_reader :answers
|
|
20
|
+
|
|
21
|
+
# @return [Usage] the token usage for the request.
|
|
22
|
+
attr_reader :usage
|
|
23
|
+
|
|
24
|
+
# @param model [String] the model that performed the evaluation.
|
|
25
|
+
# @param answers [Hash] question ids (String or Symbol) mapped to Answer
|
|
26
|
+
# objects or answer Hashes; Hashes are parsed via {Answer.from_h}.
|
|
27
|
+
# @param usage [Usage, Hash] a Usage object, or a +usage+ Hash parsed
|
|
28
|
+
# via {Usage.from_h}.
|
|
29
|
+
# @raise [ArgumentError] if any attribute is invalid or an answer is not
|
|
30
|
+
# parseable.
|
|
31
|
+
def initialize(model:, answers:, usage:)
|
|
32
|
+
@model = freeze_string(model, "model must be a non-empty String")
|
|
33
|
+
@answers = normalize_answers(answers)
|
|
34
|
+
@usage = usage.is_a?(Usage) ? usage : Usage.from_h(usage)
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Parses a raw JSON response body from the TypeSafe API.
|
|
39
|
+
#
|
|
40
|
+
# @param json [String] the response body.
|
|
41
|
+
# @return [Response]
|
|
42
|
+
# @raise [ArgumentError] if the body is not a valid response shape.
|
|
43
|
+
# @raise [JSON::ParserError] if +json+ is not valid JSON.
|
|
44
|
+
def self.from_json(json)
|
|
45
|
+
from_h(JSON.parse(json))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param hash [Hash] the parsed response body, with String or Symbol keys.
|
|
49
|
+
# @return [Response]
|
|
50
|
+
# @raise [ArgumentError] if +hash+ is not a valid response shape.
|
|
51
|
+
def self.from_h(hash)
|
|
52
|
+
unless hash.is_a?(Hash)
|
|
53
|
+
raise ArgumentError, "response must be a Hash, got #{hash.inspect}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
new(
|
|
57
|
+
model: hash["model"] || hash[:model],
|
|
58
|
+
answers: hash["answers"] || hash[:answers],
|
|
59
|
+
usage: hash["usage"] || hash[:usage]
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The answer for a question id.
|
|
64
|
+
#
|
|
65
|
+
# @param id [String, Symbol] the question id used in the request.
|
|
66
|
+
# @return [Answer, nil] the matching answer, or nil if the id is unknown.
|
|
67
|
+
def [](id)
|
|
68
|
+
answers[id] || (id.is_a?(Symbol) ? answers[id.to_s] : nil)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Hash] the response shape returned by the TypeSafe API.
|
|
72
|
+
def to_h
|
|
73
|
+
{ model: model, answers: answers.transform_values(&:to_h), usage: usage.to_h }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [String] the response serialized as JSON.
|
|
77
|
+
def to_json(state = nil)
|
|
78
|
+
JSON.generate(to_h, state)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Responses compare equal when they are of the same class and serialize
|
|
82
|
+
# to the same API shape.
|
|
83
|
+
def ==(other)
|
|
84
|
+
other.class == self.class && other.to_h == to_h
|
|
85
|
+
end
|
|
86
|
+
alias eql? ==
|
|
87
|
+
|
|
88
|
+
def hash
|
|
89
|
+
[self.class, to_h].hash
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Validates that +model+ is a non-empty String and returns a frozen copy.
|
|
95
|
+
def freeze_string(value, error_message)
|
|
96
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
97
|
+
raise ArgumentError, error_message
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
value.dup.freeze
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Validates that +answers+ is a non-empty Hash of question ids to
|
|
104
|
+
# (or parseable) Answer objects, and freezes its keys.
|
|
105
|
+
def normalize_answers(answers)
|
|
106
|
+
unless answers.is_a?(Hash) && !answers.empty?
|
|
107
|
+
raise ArgumentError, "answers must be a non-empty Hash mapping question ids to answers"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
answers.each_with_object({}) do |(id, answer), normalized|
|
|
111
|
+
unless (id.is_a?(String) && !id.strip.empty?) || (id.is_a?(Symbol) && !id.to_s.strip.empty?)
|
|
112
|
+
raise ArgumentError, "answers keys must be non-empty String or Symbol question ids, got #{id.inspect}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
normalized[id.is_a?(String) ? id.dup.freeze : id] =
|
|
116
|
+
if answer.is_a?(Answer)
|
|
117
|
+
answer
|
|
118
|
+
elsif answer.is_a?(Hash)
|
|
119
|
+
Answer.from_h(answer)
|
|
120
|
+
else
|
|
121
|
+
raise ArgumentError,
|
|
122
|
+
"answers[#{id.inspect}] must be an answer Hash or Answer object, got #{answer.inspect}"
|
|
123
|
+
end
|
|
124
|
+
end.freeze
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
# The answer to a {Score} question: the probability-weighted value across
|
|
5
|
+
# the levels (which can land between two levels), the legend mapping each
|
|
6
|
+
# level back to its description, the probability distribution across
|
|
7
|
+
# levels, and a confidence between 0 and 1.
|
|
8
|
+
#
|
|
9
|
+
# Typesafe::ScoreAnswer.new(
|
|
10
|
+
# score: 1.6,
|
|
11
|
+
# legend: { "1" => "Calm", "2" => "Frustrated", "3" => "Very angry" },
|
|
12
|
+
# probabilities: { "1" => 0.6, "2" => 0.35, "3" => 0.05 },
|
|
13
|
+
# confidence: 0.7
|
|
14
|
+
# )
|
|
15
|
+
class ScoreAnswer < Answer
|
|
16
|
+
# @return [Numeric] the probability-weighted answer across the levels;
|
|
17
|
+
# can land between levels.
|
|
18
|
+
attr_reader :score
|
|
19
|
+
|
|
20
|
+
# @return [Hash] each level number mapped back to its description.
|
|
21
|
+
attr_reader :legend
|
|
22
|
+
|
|
23
|
+
# @return [Hash] each level mapped to its probability; keys match the
|
|
24
|
+
# legend.
|
|
25
|
+
attr_reader :probabilities
|
|
26
|
+
|
|
27
|
+
# @return [Numeric] how certain the model is, between 0 and 1.
|
|
28
|
+
attr_reader :confidence
|
|
29
|
+
|
|
30
|
+
# @param score [Numeric] the probability-weighted answer across levels.
|
|
31
|
+
# @param legend [Hash] level keys (String or Symbol) mapped to non-empty
|
|
32
|
+
# String descriptions.
|
|
33
|
+
# @param probabilities [Hash] the same level keys mapped to probabilities
|
|
34
|
+
# in 0..1; the values must sum to 1 and the keys must match +legend+.
|
|
35
|
+
# @param confidence [Numeric] the model's confidence, between 0 and 1.
|
|
36
|
+
# @raise [ArgumentError] if any attribute is invalid.
|
|
37
|
+
def initialize(score:, legend:, probabilities:, confidence:)
|
|
38
|
+
@score = validate_number(score, "score")
|
|
39
|
+
@legend = normalize_legend(legend)
|
|
40
|
+
@probabilities = normalize_probabilities(probabilities, allowed_keys: @legend.keys)
|
|
41
|
+
@confidence = validate_probability(confidence, "confidence")
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param hash [Hash] a +score+ answer entry from the API response, with
|
|
46
|
+
# String or Symbol keys.
|
|
47
|
+
# @return [ScoreAnswer]
|
|
48
|
+
def self.from_h(hash)
|
|
49
|
+
new(
|
|
50
|
+
score: hash["score"] || hash[:score],
|
|
51
|
+
legend: hash["legend"] || hash[:legend],
|
|
52
|
+
probabilities: hash["probabilities"] || hash[:probabilities],
|
|
53
|
+
confidence: hash["confidence"] || hash[:confidence]
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def type
|
|
58
|
+
"score"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_h
|
|
62
|
+
{ type: type, score: score, legend: legend, probabilities: probabilities, confidence: confidence }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Validates that +legend+ is a non-empty Hash of level keys to
|
|
68
|
+
# non-empty String descriptions, with frozen copies of the descriptions.
|
|
69
|
+
def normalize_legend(legend)
|
|
70
|
+
unless legend.is_a?(Hash) && !legend.empty?
|
|
71
|
+
raise ArgumentError, "legend must be a non-empty Hash of level keys to descriptions"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
legend.each_with_object({}) do |(key, description), normalized|
|
|
75
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
76
|
+
raise ArgumentError, "legend keys must be Strings or Symbols, got #{key.inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
normalized[key.is_a?(String) ? key.dup.freeze : key] =
|
|
80
|
+
validate_string(description, "legend[#{key.inspect}]")
|
|
81
|
+
end.freeze
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
# Token usage for one evaluation request.
|
|
5
|
+
#
|
|
6
|
+
# Typesafe::Usage.new(input_tokens: 296, output_tokens: 20)
|
|
7
|
+
class Usage
|
|
8
|
+
# @return [Integer] the number of input tokens.
|
|
9
|
+
attr_reader :input_tokens
|
|
10
|
+
|
|
11
|
+
# @return [Integer] the number of output tokens.
|
|
12
|
+
attr_reader :output_tokens
|
|
13
|
+
|
|
14
|
+
# @param input_tokens [Integer] the number of input tokens.
|
|
15
|
+
# @param output_tokens [Integer] the number of output tokens.
|
|
16
|
+
# @raise [ArgumentError] if a token count is not a non-negative Integer.
|
|
17
|
+
def initialize(input_tokens:, output_tokens:)
|
|
18
|
+
@input_tokens = validate_token_count(input_tokens, "input_tokens")
|
|
19
|
+
@output_tokens = validate_token_count(output_tokens, "output_tokens")
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param hash [Hash] a +usage+ entry from the API response, with String
|
|
24
|
+
# or Symbol keys.
|
|
25
|
+
# @return [Usage]
|
|
26
|
+
# @raise [ArgumentError] if +hash+ is not a Hash or a token count is
|
|
27
|
+
# missing or invalid.
|
|
28
|
+
def self.from_h(hash)
|
|
29
|
+
unless hash.is_a?(Hash)
|
|
30
|
+
raise ArgumentError, "usage must be a Hash, got #{hash.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
new(
|
|
34
|
+
input_tokens: hash["input_tokens"] || hash[:input_tokens],
|
|
35
|
+
output_tokens: hash["output_tokens"] || hash[:output_tokens]
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Hash] the +usage+ shape returned by the TypeSafe API.
|
|
40
|
+
def to_h
|
|
41
|
+
{ input_tokens: input_tokens, output_tokens: output_tokens }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Usages compare equal when their token counts match.
|
|
45
|
+
def ==(other)
|
|
46
|
+
other.class == self.class && other.to_h == to_h
|
|
47
|
+
end
|
|
48
|
+
alias eql? ==
|
|
49
|
+
|
|
50
|
+
def hash
|
|
51
|
+
[self.class, to_h].hash
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def validate_token_count(value, name)
|
|
57
|
+
unless value.is_a?(Integer) && value >= 0
|
|
58
|
+
raise ArgumentError, "#{name} must be a non-negative Integer, got #{value.inspect}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/typesafe/version.rb
CHANGED
data/lib/typesafe.rb
CHANGED
|
@@ -3,10 +3,20 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
require_relative "typesafe/version"
|
|
6
|
+
|
|
6
7
|
require_relative "typesafe/question"
|
|
7
8
|
require_relative "typesafe/noul"
|
|
8
9
|
require_relative "typesafe/choice"
|
|
9
10
|
require_relative "typesafe/score"
|
|
11
|
+
|
|
12
|
+
require_relative "typesafe/answer"
|
|
13
|
+
require_relative "typesafe/noul_answer"
|
|
14
|
+
require_relative "typesafe/choice_answer"
|
|
15
|
+
require_relative "typesafe/score_answer"
|
|
16
|
+
|
|
17
|
+
require_relative "typesafe/usage"
|
|
18
|
+
require_relative "typesafe/response"
|
|
19
|
+
require_relative "typesafe/errors"
|
|
10
20
|
require_relative "typesafe/client"
|
|
11
21
|
require_relative "typesafe/jev"
|
|
12
22
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: typesafe-jev
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dtheofr
|
|
@@ -62,12 +62,19 @@ files:
|
|
|
62
62
|
- LICENSE
|
|
63
63
|
- README.md
|
|
64
64
|
- lib/typesafe.rb
|
|
65
|
+
- lib/typesafe/answer.rb
|
|
65
66
|
- lib/typesafe/choice.rb
|
|
67
|
+
- lib/typesafe/choice_answer.rb
|
|
66
68
|
- lib/typesafe/client.rb
|
|
69
|
+
- lib/typesafe/errors.rb
|
|
67
70
|
- lib/typesafe/jev.rb
|
|
68
71
|
- lib/typesafe/noul.rb
|
|
72
|
+
- lib/typesafe/noul_answer.rb
|
|
69
73
|
- lib/typesafe/question.rb
|
|
74
|
+
- lib/typesafe/response.rb
|
|
70
75
|
- lib/typesafe/score.rb
|
|
76
|
+
- lib/typesafe/score_answer.rb
|
|
77
|
+
- lib/typesafe/usage.rb
|
|
71
78
|
- lib/typesafe/version.rb
|
|
72
79
|
homepage: https://docs.typesafe.ai
|
|
73
80
|
licenses:
|