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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 781481c48138ea6ac1692f01dcc1f21b26bec770298bedc6cf90e0292d99832e
4
- data.tar.gz: 0b2ac85a437a493d763df6185fc91a36bd2602791eff90f785259cc11b587aaf
3
+ metadata.gz: 8aae91833da46887245893c38c7352702ef1780820bab8cf0860ce07cb83a0a1
4
+ data.tar.gz: 60f83973fe00f6e5355d7f9933ae960eb4e34a0d4a70c6496a605d7367d3e9c3
5
5
  SHA512:
6
- metadata.gz: c26d592b1c9f1306b422e4640b8a21c90bef15d4a54d71f9122328f6222db0f049b1f72deb8a650ab21ba141562d6e13f5eb7795390c566ea1197ca3a2c0aede
7
- data.tar.gz: 425789aaddd0b63b83fc943cff698ef239980a6e333f67c970c6bb8270e788d2c9496b0387acdf71d2bcd68bf6763d3ca91c3865dea4bf4a25c36209ff4de985
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
- ## Usage
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
- TODO: Write usage instructions here
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
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Jev
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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
- # Your code goes here...
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.1.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