jev 0.1.0 → 0.2.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 +43 -0
- data/README.md +181 -4
- data/lib/jev/client.rb +49 -0
- data/lib/jev/collection.rb +22 -0
- data/lib/jev/query.rb +47 -0
- data/lib/jev/question/choice.rb +39 -0
- data/lib/jev/question/noul.rb +31 -0
- data/lib/jev/question/score.rb +48 -0
- data/lib/jev/question.rb +36 -0
- data/lib/jev/response.rb +15 -0
- data/lib/jev/version.rb +1 -1
- data/lib/jev.rb +29 -1
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8aae91833da46887245893c38c7352702ef1780820bab8cf0860ce07cb83a0a1
|
|
4
|
+
data.tar.gz: 60f83973fe00f6e5355d7f9933ae960eb4e34a0d4a70c6496a605d7367d3e9c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d2498f4e69f5fa99b1dcd30826c9d5a7eae36afb2a3567df67c0f15b6039d239be2c034601f8d21a6612a6f4f4dca9eabedcfa9e4fcb510c570d61ac1052015e
|
|
7
|
+
data.tar.gz: 93379eee809c5bacf64493097e9c046d51e4906820990d99c4fddd9de08e849684a1341b6ff252604064e56ee94609b1c1e8241d4e3112cec17d6c8da63d8dfc
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.2.0] - 2026-09-20
|
|
9
|
+
|
|
10
|
+
First release with a working client. 0.1.0 shipped an empty module.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `Jev::Query` for building a batch of questions against a single state, with
|
|
15
|
+
`ask` (noul), `choose` (choice) and `score` (score).
|
|
16
|
+
- `Jev::Question` and its three subclasses, sharing a uniform `result` for the
|
|
17
|
+
typed value to branch on, alongside type-specific accessors:
|
|
18
|
+
- `Noul` — `noul`, plus a per-question `threshold:` (default `0.5`).
|
|
19
|
+
- `Choice` — `choice`, `probabilities`, `confidence`. Up to 255 options.
|
|
20
|
+
- `Score` — `score`, `legend`, `probabilities`, `confidence`, plus `level` and
|
|
21
|
+
`label` for the most probable level. 2 to 10 levels.
|
|
22
|
+
- `criteria:` on noul questions, `options:` on choice, `levels:` on score, all
|
|
23
|
+
serialised to the API's `criteria` field.
|
|
24
|
+
- `Jev::Response` exposing the resolved `model` (e.g. `jev-1.13.0`, not
|
|
25
|
+
`jev-latest`), token `usage`, and `answers`.
|
|
26
|
+
- `Jev::Collection`, an Enumerable set of answers supporting attribute access
|
|
27
|
+
(`answers.is_urgent`) and lookup by identifier (`answers[:is_urgent]`).
|
|
28
|
+
- An error hierarchy under `Jev::Error`: `Jev::APIError` (carrying `status` and
|
|
29
|
+
`body`) with `AuthenticationError` (401), `ValidationError` (422),
|
|
30
|
+
`RateLimitError` (429) and `OverloadedError` (529).
|
|
31
|
+
- `Jev.api_key` for global configuration, and `Jev::Client.new(api_key)` for
|
|
32
|
+
callers that need more than one key.
|
|
33
|
+
|
|
34
|
+
### Notes
|
|
35
|
+
|
|
36
|
+
- Rate limits are not retried. The API documentation recommends exponential
|
|
37
|
+
backoff on 429 and 529; this client raises `Jev::RateLimitError` and
|
|
38
|
+
`Jev::OverloadedError` so that policy stays with the caller.
|
|
39
|
+
- Requests are sent as `jev-latest` and use `Net::HTTP`'s default timeouts.
|
|
40
|
+
- Reading `result` on an unanswered question raises `Jev::Error` rather than
|
|
41
|
+
returning a falsy value.
|
|
42
|
+
|
|
43
|
+
[0.2.0]: https://github.com/virolea/jev/releases/tag/v0.2.0
|
data/README.md
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
A Ruby client for the [Typesafe](https://typesafe.ai) Jev model API.
|
|
4
4
|
|
|
5
|
-
> Status: early skeleton — the client surface is not implemented yet.
|
|
6
|
-
|
|
7
5
|
## Installation
|
|
8
6
|
|
|
9
7
|
Install the gem and add to the application's Gemfile by executing:
|
|
@@ -18,9 +16,188 @@ If bundler is not being used to manage dependencies, install the gem by executin
|
|
|
18
16
|
gem install jev
|
|
19
17
|
```
|
|
20
18
|
|
|
21
|
-
##
|
|
19
|
+
## Configuration
|
|
20
|
+
|
|
21
|
+
Set your API key once, typically in an initializer:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
Jev.api_key = _YOUR_JEV_API_KEY_
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
response = Jev::Query.new("Help! My payouts have been failing for 3 days.").perform do |query|
|
|
31
|
+
query.ask(:is_urgent, "Does this convey urgency?")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
response.answers.is_urgent.result # => true
|
|
35
|
+
response.answers.is_urgent.noul # => 0.95
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## The three question types
|
|
39
|
+
|
|
40
|
+
Every question in a single query is evaluated in parallel against the same state, so
|
|
41
|
+
adding questions costs far less than making another call. Prefer several narrow questions
|
|
42
|
+
over one broad one.
|
|
43
|
+
|
|
44
|
+
### `ask` — noul
|
|
45
|
+
|
|
46
|
+
Evaluates the truth of a statement, returning a probability between 0 and 1.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
query.ask(:is_urgent, "Does this convey urgency?",
|
|
50
|
+
criteria: {
|
|
51
|
+
"true" => "Explicitly time-sensitive",
|
|
52
|
+
"false" => "No urgency expressed"
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`criteria` is optional, but it is how you scope a noul — worth providing whenever the
|
|
57
|
+
statement could be read more than one way.
|
|
58
|
+
|
|
59
|
+
### `choose` — choice
|
|
60
|
+
|
|
61
|
+
Selects one option from a set. Up to 255 options.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
query.choose(:department, "Which team should handle this?",
|
|
65
|
+
options: {
|
|
66
|
+
"billing" => "Payments, invoicing, refunds",
|
|
67
|
+
"technical" => "Bugs, outages, integrations",
|
|
68
|
+
"sales" => "Pricing, upgrades, new accounts"
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### `score` — score
|
|
73
|
+
|
|
74
|
+
Rates the state against an ordered rubric of 2 to 10 levels.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
query.score(:frustration, "How frustrated is the customer?",
|
|
78
|
+
levels: ["Calm", "Frustrated", "Very angry"])
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Reading answers
|
|
82
|
+
|
|
83
|
+
Every answer exposes `result` — the typed value to branch on — whatever its type:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
answers = response.answers
|
|
87
|
+
|
|
88
|
+
answers.is_urgent.result # => true (noul, thresholded)
|
|
89
|
+
answers.department.result # => "billing" (the chosen option)
|
|
90
|
+
answers.frustration.result # => 1.05 (the probability-weighted score)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Underneath that, each type exposes what the model actually returned:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
answers.is_urgent.noul # => 0.95
|
|
97
|
+
answers.department.probabilities # => { "billing" => 0.88, "technical" => 0.12, "sales" => 0.0 }
|
|
98
|
+
answers.department.confidence # => 0.81
|
|
99
|
+
answers.frustration.level # => 1 (most probable level)
|
|
100
|
+
answers.frustration.label # => "Frustrated" (that level, via the legend)
|
|
101
|
+
answers.frustration.probabilities # => { "0" => 0.0, "1" => 0.95, "2" => 0.05 }
|
|
102
|
+
```
|
|
22
103
|
|
|
23
|
-
|
|
104
|
+
Reading `result` on a question that has not been answered raises `Jev::Error` rather than
|
|
105
|
+
returning a falsy value — so "the model said no" is never confused with "we never asked".
|
|
106
|
+
|
|
107
|
+
Answers are enumerable, which is where combining atomic questions pays off:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
answers.map(&:identifier) # => [:is_urgent, :department, :frustration]
|
|
111
|
+
answers.to_h { |a| [a.identifier, a.result] } # => { is_urgent: true, department: "billing", frustration: 1.05 }
|
|
112
|
+
answers[:department] # => the question, looked up by identifier
|
|
113
|
+
answers.count # => 3
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Note that `result` is truthy for any answered choice or score, so `select(&:result)` only
|
|
117
|
+
narrows a set of nouls.
|
|
118
|
+
|
|
119
|
+
### Thresholds
|
|
120
|
+
|
|
121
|
+
A noul's `result` is its probability compared against a threshold, which defaults to 0.5.
|
|
122
|
+
The threshold belongs to the question, since the confidence you need is part of what you
|
|
123
|
+
are asking:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
query.ask(:is_urgent, "Does this convey urgency?", threshold: 0.8)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The raw probability is always available via `noul` if you would rather route on it yourself.
|
|
130
|
+
|
|
131
|
+
### Acting on a score
|
|
132
|
+
|
|
133
|
+
A score answer gives you two different views, and which you want depends on what you are doing.
|
|
134
|
+
|
|
135
|
+
**For a decision, use `level` or `label`.** These report the most probable level, which is
|
|
136
|
+
what the model actually asserted:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
case answers.frustration.label
|
|
140
|
+
when "Very angry" then escalate_to_human
|
|
141
|
+
when "Frustrated" then flag_for_review
|
|
142
|
+
else autorespond
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**For ranking or aggregation, use `result`**, the probability-weighted value. It is the only
|
|
147
|
+
one of the two that gives a total order or a meaningful average:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
tickets.sort_by { |ticket| -ticket.answers.frustration.result }
|
|
151
|
+
weekly_average = scores.sum(&:result) / scores.size
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Be careful thresholding `result` directly. A rubric's levels are ordered but not evenly
|
|
155
|
+
spaced, so a cutoff like `result >= 1.5` assumes a scale the rubric does not really have.
|
|
156
|
+
It can also mislead on a split distribution: probabilities of
|
|
157
|
+
`{ "0" => 0.5, "1" => 0.1, "2" => 0.4 }` average to `0.9`, pointing near *Frustrated*,
|
|
158
|
+
the one level the model considers least likely. `level` reports `0` there, which is honest.
|
|
159
|
+
Check `confidence` before acting on a weighted score.
|
|
160
|
+
|
|
161
|
+
Ties in `level` resolve to the lower level.
|
|
162
|
+
|
|
163
|
+
## Response metadata
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
response.model # => "jev-1.13.0" — the version that actually answered
|
|
167
|
+
response.usage.input_tokens # => 318
|
|
168
|
+
response.usage.output_tokens # => 34
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Note that `model` is the *resolved* version. Requests are sent as `jev-latest`, so this is
|
|
172
|
+
how you find out what you actually got.
|
|
173
|
+
|
|
174
|
+
## Errors
|
|
175
|
+
|
|
176
|
+
All errors inherit from `Jev::Error`:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
begin
|
|
180
|
+
query.perform
|
|
181
|
+
rescue Jev::AuthenticationError # 401
|
|
182
|
+
rescue Jev::ValidationError # 422
|
|
183
|
+
rescue Jev::RateLimitError # 429
|
|
184
|
+
rescue Jev::OverloadedError # 529
|
|
185
|
+
rescue Jev::APIError => e # any other non-success status
|
|
186
|
+
e.status # => 500
|
|
187
|
+
e.body # => the raw response body
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**Rate limits are not retried for you.** The API documentation recommends exponential
|
|
192
|
+
backoff on 429 and 529; this client raises instead, so that policy stays yours:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
begin
|
|
196
|
+
query.perform
|
|
197
|
+
rescue Jev::RateLimitError, Jev::OverloadedError
|
|
198
|
+
# back off and retry on your own terms
|
|
199
|
+
end
|
|
200
|
+
```
|
|
24
201
|
|
|
25
202
|
## Development
|
|
26
203
|
|
data/lib/jev/client.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jev
|
|
4
|
+
class Client
|
|
5
|
+
API_URL = "https://api.typesafe.ai/v1/systemone"
|
|
6
|
+
MODEL = "jev-latest"
|
|
7
|
+
|
|
8
|
+
ERRORS_BY_STATUS = {
|
|
9
|
+
401 => AuthenticationError,
|
|
10
|
+
422 => ValidationError,
|
|
11
|
+
429 => RateLimitError,
|
|
12
|
+
529 => OverloadedError
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(api_key)
|
|
16
|
+
raise ArgumentError, "Missing API key" unless api_key
|
|
17
|
+
|
|
18
|
+
@api_key = api_key
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def request(state:, questions:)
|
|
22
|
+
body = { model: MODEL, state: state, questions: questions }
|
|
23
|
+
|
|
24
|
+
response = Net::HTTP.post(uri, body.to_json, request_headers)
|
|
25
|
+
raise error_for(response) unless response.is_a?(Net::HTTPSuccess)
|
|
26
|
+
|
|
27
|
+
JSON.parse(response.body)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def error_for(response)
|
|
33
|
+
status = response.code.to_i
|
|
34
|
+
|
|
35
|
+
ERRORS_BY_STATUS.fetch(status, APIError).new(status, response.body)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def uri
|
|
39
|
+
URI(API_URL)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def request_headers
|
|
43
|
+
{
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
"Authorization": "Bearer #{@api_key}"
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jev
|
|
4
|
+
class Collection
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
def initialize(questions)
|
|
8
|
+
@questions = questions
|
|
9
|
+
@questions.each do |question|
|
|
10
|
+
next if respond_to?(question.identifier)
|
|
11
|
+
|
|
12
|
+
define_singleton_method(question.identifier) { question }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def each(&) = @questions.each(&)
|
|
17
|
+
|
|
18
|
+
def [](identifier)
|
|
19
|
+
@questions.find { |question| question.identifier == identifier.to_sym }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/jev/query.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jev
|
|
4
|
+
class Query
|
|
5
|
+
def initialize(state)
|
|
6
|
+
@state = state
|
|
7
|
+
@questions = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def ask(identifier, content, **options)
|
|
11
|
+
add(Question::Noul, identifier, content, **options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def choose(identifier, content, options:)
|
|
15
|
+
add(Question::Choice, identifier, content, options: options)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def score(identifier, content, levels:)
|
|
19
|
+
add(Question::Score, identifier, content, levels: levels)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def questions = Collection.new(@questions.values)
|
|
23
|
+
|
|
24
|
+
def perform
|
|
25
|
+
yield self if block_given?
|
|
26
|
+
|
|
27
|
+
payload = Jev.client.request(state: @state, questions: @questions.transform_values(&:to_h))
|
|
28
|
+
distribute_answers_across_questions(payload["answers"])
|
|
29
|
+
|
|
30
|
+
Response.new(payload, answers: questions)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def add(type, identifier, content, **options)
|
|
36
|
+
identifier = identifier.to_sym
|
|
37
|
+
raise ArgumentError, "#{identifier} has already been asked" if @questions.key?(identifier)
|
|
38
|
+
|
|
39
|
+
@questions[identifier] = type.new(identifier, content, **options)
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def distribute_answers_across_questions(answers)
|
|
44
|
+
answers.each { |identifier, answer| @questions.fetch(identifier.to_sym).answer_with(answer) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jev
|
|
4
|
+
class Question
|
|
5
|
+
class Choice < Question
|
|
6
|
+
MAX_OPTIONS = 255
|
|
7
|
+
|
|
8
|
+
attr_reader :choice, :probabilities, :confidence
|
|
9
|
+
|
|
10
|
+
def initialize(identifier, content, options:)
|
|
11
|
+
super(identifier, content)
|
|
12
|
+
@options = options.transform_keys(&:to_s)
|
|
13
|
+
validate_options!
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def answer_with(answer)
|
|
17
|
+
@choice = answer["choice"]
|
|
18
|
+
@probabilities = answer["probabilities"]
|
|
19
|
+
@confidence = answer["confidence"]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def answered? = !@choice.nil?
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def type = :choice
|
|
27
|
+
|
|
28
|
+
def value = @choice
|
|
29
|
+
|
|
30
|
+
def criteria = @options
|
|
31
|
+
|
|
32
|
+
def validate_options!
|
|
33
|
+
return if @options.size.between?(1, MAX_OPTIONS)
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, "a choice question takes 1 to #{MAX_OPTIONS} options, got #{@options.size}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jev
|
|
4
|
+
class Question
|
|
5
|
+
class Noul < Question
|
|
6
|
+
DEFAULT_THRESHOLD = 0.5
|
|
7
|
+
|
|
8
|
+
attr_reader :noul
|
|
9
|
+
|
|
10
|
+
def initialize(identifier, content, threshold: DEFAULT_THRESHOLD, criteria: nil)
|
|
11
|
+
super(identifier, content)
|
|
12
|
+
@threshold = threshold
|
|
13
|
+
@criteria = criteria&.transform_keys(&:to_s)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def answer_with(answer)
|
|
17
|
+
@noul = answer["noul"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def answered? = !@noul.nil?
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :criteria
|
|
25
|
+
|
|
26
|
+
def type = :noul
|
|
27
|
+
|
|
28
|
+
def value = @noul > @threshold
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jev
|
|
4
|
+
class Question
|
|
5
|
+
class Score < Question
|
|
6
|
+
LEVELS = (2..10)
|
|
7
|
+
|
|
8
|
+
attr_reader :score, :legend, :probabilities, :confidence
|
|
9
|
+
|
|
10
|
+
def initialize(identifier, content, levels:)
|
|
11
|
+
super(identifier, content)
|
|
12
|
+
@levels = Array(levels)
|
|
13
|
+
validate_levels!
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def answer_with(answer)
|
|
17
|
+
@score = answer["score"]
|
|
18
|
+
@legend = answer["legend"]
|
|
19
|
+
@probabilities = answer["probabilities"]
|
|
20
|
+
@confidence = answer["confidence"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def answered? = !@score.nil?
|
|
24
|
+
|
|
25
|
+
def level
|
|
26
|
+
ensure_answered!
|
|
27
|
+
|
|
28
|
+
probabilities.max_by { |_, probability| probability }.first.to_i
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def label = legend[level.to_s]
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def type = :score
|
|
36
|
+
|
|
37
|
+
def value = @score
|
|
38
|
+
|
|
39
|
+
def criteria = @levels
|
|
40
|
+
|
|
41
|
+
def validate_levels!
|
|
42
|
+
return if LEVELS.cover?(@levels.size)
|
|
43
|
+
|
|
44
|
+
raise ArgumentError, "a score question takes #{LEVELS.min} to #{LEVELS.max} levels, got #{@levels.size}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/jev/question.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jev
|
|
4
|
+
class Question
|
|
5
|
+
attr_reader :identifier
|
|
6
|
+
|
|
7
|
+
def initialize(identifier, content)
|
|
8
|
+
@identifier = identifier.to_sym
|
|
9
|
+
@content = content
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_h = { type: type, instructions: @content, criteria: criteria }.compact
|
|
13
|
+
|
|
14
|
+
def answer_with(_answer)
|
|
15
|
+
raise NotImplementedError, "#{self.class} must implement #answer_with"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def answered?
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #answered?"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def result
|
|
23
|
+
ensure_answered!
|
|
24
|
+
|
|
25
|
+
value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def ensure_answered!
|
|
31
|
+
raise Error, "#{@identifier} has not been answered" unless answered?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def criteria = nil
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/jev/response.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jev
|
|
4
|
+
class Response
|
|
5
|
+
Usage = Struct.new(:input_tokens, :output_tokens)
|
|
6
|
+
|
|
7
|
+
attr_reader :model, :usage, :answers
|
|
8
|
+
|
|
9
|
+
def initialize(payload, answers:)
|
|
10
|
+
@model = payload["model"]
|
|
11
|
+
@usage = Usage.new(*(payload["usage"] || {}).values_at("input_tokens", "output_tokens"))
|
|
12
|
+
@answers = answers
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/jev/version.rb
CHANGED
data/lib/jev.rb
CHANGED
|
@@ -1,8 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "zeitwerk"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "json"
|
|
3
6
|
require_relative "jev/version"
|
|
4
7
|
|
|
8
|
+
loader = Zeitwerk::Loader.for_gem
|
|
9
|
+
loader.setup
|
|
10
|
+
|
|
5
11
|
module Jev
|
|
6
12
|
class Error < StandardError; end
|
|
7
|
-
|
|
13
|
+
|
|
14
|
+
class APIError < Error
|
|
15
|
+
attr_reader :status, :body
|
|
16
|
+
|
|
17
|
+
def initialize(status, body)
|
|
18
|
+
@status = status
|
|
19
|
+
@body = body
|
|
20
|
+
super("Jev API error #{status}: #{body}")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class AuthenticationError < APIError; end
|
|
25
|
+
class ValidationError < APIError; end
|
|
26
|
+
class RateLimitError < APIError; end
|
|
27
|
+
class OverloadedError < APIError; end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
attr_accessor :api_key
|
|
31
|
+
|
|
32
|
+
def client
|
|
33
|
+
@client ||= Client.new(api_key)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
8
36
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jev
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vincent Rolea
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: zeitwerk
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.6'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.6'
|
|
12
26
|
description: A Ruby client for the Jev model API from Typesafe (https://typesafe.ai).
|
|
13
27
|
email:
|
|
14
28
|
- 3525369+virolea@users.noreply.github.com
|
|
@@ -16,10 +30,19 @@ executables: []
|
|
|
16
30
|
extensions: []
|
|
17
31
|
extra_rdoc_files: []
|
|
18
32
|
files:
|
|
33
|
+
- CHANGELOG.md
|
|
19
34
|
- LICENSE.txt
|
|
20
35
|
- README.md
|
|
21
36
|
- Rakefile
|
|
22
37
|
- lib/jev.rb
|
|
38
|
+
- lib/jev/client.rb
|
|
39
|
+
- lib/jev/collection.rb
|
|
40
|
+
- lib/jev/query.rb
|
|
41
|
+
- lib/jev/question.rb
|
|
42
|
+
- lib/jev/question/choice.rb
|
|
43
|
+
- lib/jev/question/noul.rb
|
|
44
|
+
- lib/jev/question/score.rb
|
|
45
|
+
- lib/jev/response.rb
|
|
23
46
|
- lib/jev/version.rb
|
|
24
47
|
- sig/jev.rbs
|
|
25
48
|
homepage: https://github.com/virolea/jev
|