jev-feels 1.0.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e06314aa51799f9434268858022138a8a871b4f58332907b4be0f5d187885073
4
- data.tar.gz: 8e5e28527de0e411f38ca91b660cb6a43c77a955af2a0780227c9401d4fdfe35
3
+ metadata.gz: bf19b7812e88098da8abdb24e2c951f11ed906bf8f0200be8d889cc9c7565d13
4
+ data.tar.gz: 836af8d2b70dbf6828f170a01b8d00e19ffadb4a56b5e8409c845bef1500cda4
5
5
  SHA512:
6
- metadata.gz: 11ff5c82f81642c4e60c1c39f4567de4b3d4e4a7c0ab6bd31a4a19be9cca47b5e2642f1b1388b2cc507ab45b563d02e7ea09767f489954ef1b3bb3d57659a798
7
- data.tar.gz: 184c045587028d305a7fa8310c298687998981b655ded9e71e8c7990cb30bfeeaecac59bed11940ef3903bde5dfd49eb99a78c37e0c7e3a83f5a8274e8692589
6
+ metadata.gz: 17e714115b72dbbb3dc0a69881d135ddbb670238ce4eb6bd7221ee10db3ea6c12a4f0c7aab00c17cf78277352d3063499d9bfd507f4160e15f6aa7940716b053
7
+ data.tar.gz: 0c05ddcd181d7cfec1eaee0df0c2582e3f34431989cc3231014440657b67295cb2fac42b93eff61a2defbbfe567bb671924e5dfa86f1f6ab0b174a78187d7f3d
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 1.1.0
4
+
5
+ ### Added
6
+
7
+ - `Jev.define` accepts `choices:` (`decide`) and `levels:` (`score`, Hash, low to high).
8
+ - `Jev.decide`, `Jev.score`, `Jev.measure`, and `Jev.match`.
9
+ - Typed results: `Jev::Result::Noul`, `Jev::Result::Choice`, `Jev::Result::Score`, `Jev::Result::Batch`.
10
+ - Batch `Jev.measure(state) { |q| ... }` — one request, defined names only.
11
+ - `at_least:` on `feels?` (true / false / nil) and `confidence:` on `decide` / `match`.
12
+ - Native `case`/`in` on a batch via collapsed values.
13
+ - `Jev::Model`: `feels` / `decide` / `score` bind a field; instance `feels?`, `decide`, `score`, `measure`, `match`.
14
+ - Optional `require "feels/active_model"` and `validates_feeling`.
15
+ - `Jev.stub`, `Jev.record`, and `Jev.replay`. Tapes omit API keys.
16
+ - Opt-in String refinement / extension: `decide`, `score`, `measure`.
17
+
18
+ ### Changed
19
+
20
+ - `feels` / `feels?` / `decide` / `score` collapse a typed `Jev.measure` result.
21
+ - Yes/no `Jev.definition` / `Jev.definitions` still return the instruction string. `choices:` / `levels:` return `Jev::Definition`.
22
+
23
+ ## 1.0.0
24
+
25
+ - Initial `Jev.define` / `Jev.feels` / `Jev.feels?` API, injectable transport, and opt-in String sugar.
data/README.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # jev-feels
2
2
 
3
- Semantic checks as ordinary Ruby conditions.
3
+ Semantic decisions as ordinary Ruby.
4
4
 
5
5
  ```ruby
6
6
  # Gemfile
7
7
  gem "jev-feels"
8
8
 
9
9
  require "feels"
10
+ using Jev::Feels
10
11
 
11
12
  Jev.configure do |config|
12
13
  config.api_key = ENV.fetch("JEV_API_KEY")
@@ -14,20 +15,44 @@ end
14
15
 
15
16
  Jev.define :urgent, "Requires immediate attention or action"
16
17
 
17
- email = <<~EMAIL
18
- Our production server has stopped responding.
19
- Customers cannot access their accounts.
20
- Please investigate immediately.
21
- EMAIL
22
-
23
- if Jev.feels?(email, :urgent)
24
- puts "URGENT"
18
+ if email.body.feels?(:urgent)
19
+ email.mark(:urgent)
25
20
  end
26
21
  ```
27
22
 
23
+ The same idea continues for a decision and a scale:
24
+
25
+ ```ruby
26
+ Jev.define :support_team,
27
+ "Which team should handle this?",
28
+ choices: {
29
+ billing: "payments and refunds",
30
+ technical: "bugs and outages",
31
+ sales: "purchase questions",
32
+ other: "none of the above"
33
+ }
34
+
35
+ team = Jev.decide(email.body, :support_team)
36
+ ```
37
+
38
+ ```ruby
39
+ Jev.define :severity,
40
+ "How severe is this issue?",
41
+ levels: {
42
+ minor: "minor inconvenience",
43
+ degraded: "workaround exists",
44
+ blocking: "cannot complete the task",
45
+ critical: "major outage or severe impact"
46
+ }
47
+
48
+ severity = Jev.score(email.body, :severity)
49
+ ```
50
+
51
+ You do not need the Jev JSON wire format or the HTTP API for typical use. `Jev.measure` keeps the full typed result when you do.
52
+
28
53
  ## Ruby on Rails
29
54
 
30
- Configure once, declare predicates on the model, ask the record:
55
+ Configure once, declare on the model, ask the record:
31
56
 
32
57
  ```ruby
33
58
  # Gemfile
@@ -36,25 +61,37 @@ gem "jev-feels"
36
61
 
37
62
  ```ruby
38
63
  # config/initializers/jev.rb
64
+ require "feels/active_model"
65
+
39
66
  Jev.configure do |config|
40
67
  config.api_key = ENV.fetch("JEV_API_KEY")
41
68
  end
69
+
70
+ Jev.define :urgent, "Requires immediate attention or action"
71
+ Jev.define :support_team, "Which team should handle this?", choices: {
72
+ billing: "payments and refunds",
73
+ technical: "bugs and outages",
74
+ other: "none of the above"
75
+ }
42
76
  ```
43
77
 
44
78
  ```ruby
45
79
  class SupportEmail < ApplicationRecord
46
80
  include Jev::Model
47
81
 
48
- feels :body, :urgent, "Outage, customers cannot sign in"
82
+ feels :body, :urgent # Get definition from initializer
83
+ feels :subject, :not_important, "Subject of this email is not important" # Or define in model
84
+ decide :body, :support_team
85
+
86
+ validates_feeling :body, :urgent, threshold: 0.9
49
87
  end
50
88
 
51
89
  email.feels?(:urgent)
52
- email.feels(:urgent)
90
+ email.feels?(:not_important)
91
+ email.decide(:support_team)
53
92
  ```
54
93
 
55
- The class is the scope, the first argument is the field. No model name, no `email.body.feels?`.
56
-
57
- String sugar is still opt-in (`using Jev::Feels` or `require "jev-feels/string"`) if you want `body.feels?(:urgent)` on a raw string.
94
+ The class is the scope, the first argument is the field. `validates_feeling` accepts `allow_nil:`, `allow_blank:`, `if:`, `unless:`, `on:`, `message:`, `at_least:`. If those skip the check, there is no HTTP call. A `nil` from `at_least:` is a validation failure.
58
95
 
59
96
  ## String sugar
60
97
 
@@ -63,7 +100,9 @@ String sugar is still opt-in (`using Jev::Feels` or `require "jev-feels/string"`
63
100
  ```ruby
64
101
  using Jev::Feels
65
102
 
66
- email.feels?(:urgent)
103
+ email.body.feels?(:urgent)
104
+ email.body.decide(:support_team)
105
+ email.body.score(:severity)
67
106
  ```
68
107
 
69
108
  Or, if you really want a global patch:
@@ -71,44 +110,46 @@ Or, if you really want a global patch:
71
110
  ```ruby
72
111
  require "feels/string"
73
112
  # or: require "jev-feels/string"
74
-
75
- email.feels?(:urgent)
76
113
  ```
77
114
 
78
- ## Probability vs predicate
115
+ ## Definitions
79
116
 
80
- ```ruby
81
- email.feels(:urgent)
82
- # => 0.94
117
+ `Jev.define` is the vocabulary of the app. A symbol without extras is a yes/no check:
83
118
 
84
- email.feels?(:urgent)
85
- # => true
119
+ ```ruby
120
+ Jev.define :urgent, "Requires immediate attention or action"
121
+ Jev.define :spam, "Unsolicited or unwanted promotional content"
86
122
  ```
87
123
 
88
- `feels` is the calibrated probability that the predicate holds (a Jev [Noul](https://docs.typesafe.ai/primitives/noul.md), `0.0..1.0`).
89
- `feels?` is `feels >= threshold`. The default threshold is `0.5`.
124
+ A decision (`decide`):
90
125
 
91
126
  ```ruby
92
- email.feels?(:urgent, threshold: 0.8)
127
+ Jev.define :support_team,
128
+ "Which support team should handle this?",
129
+ choices: {
130
+ billing: "payments, invoices, charges and refunds",
131
+ technical: "bugs, outages and integrations",
132
+ sales: "purchase and plan questions",
133
+ other: "none of the above"
134
+ }
93
135
  ```
94
136
 
95
- ## Ad-hoc predicates
96
-
97
- A `String` predicate is used as-is and is **not** registered:
137
+ A scale (`score`). Hash order is low to high:
98
138
 
99
139
  ```ruby
100
- email.feels?("sounds like the sender is about to cancel their subscription")
140
+ Jev.define :severity,
141
+ "How severe is this customer issue?",
142
+ levels: {
143
+ cosmetic: "minor visual or cosmetic issue",
144
+ degraded: "functionality is degraded but a workaround exists",
145
+ blocking: "the customer cannot complete an important task",
146
+ critical: "major outage, data loss, security issue, or severe business impact"
147
+ }
101
148
  ```
102
149
 
103
- A `Symbol` must already have a definition or you get `Jev::UndefinedDefinition`.
104
-
105
- ## Definitions
150
+ Scoped names still work:
106
151
 
107
152
  ```ruby
108
- Jev.define :urgent, "Requires immediate attention or action"
109
- Jev.define :spam, "Unsolicited or unwanted promotional content"
110
- Jev.define :angry, "Expresses anger or hostility"
111
-
112
153
  Jev.define Email, :urgent, "Outage, customers cannot sign in"
113
154
  Jev.define Comment, :urgent, "Legal takedown or self-harm"
114
155
 
@@ -118,26 +159,193 @@ Jev.definitions
118
159
  Jev.definitions(Comment)
119
160
  ```
120
161
 
121
- `define` replaces an existing name in that scope. Scopes are stored by class name, so `Email`, `"Email"` and `:Email` are the same key. `include Jev::Model` plus `feels :body, :urgent, "..."` does the same define and remembers the field. `Jev.feels?(text, Email, :urgent)` uses Email's definition, then a superclass, then the global `:urgent`. `definitions` returns a frozen copy.
162
+ `define` replaces an existing name in that scope. Scopes are stored by class name, so `Email`, `"Email"` and `:Email` are the same key. A yes/no definition reads back as its instruction string. `choices:` / `levels:` read back as a `Jev::Definition`. On a Rails model, `feels` / `decide` / `score` bind a field; see [Ruby on Rails](#ruby-on-rails).
122
163
 
123
- ## Configuration
164
+ A `Symbol` must already have a definition or you get `Jev::UndefinedDefinition`. A `String` is an ad-hoc question and is not registered.
165
+
166
+ ## Asking
124
167
 
125
168
  ```ruby
126
- Jev.configure do |config|
127
- config.api_key = ENV["JEV_API_KEY"]
128
- config.base_url = "https://api.typesafe.ai"
129
- config.timeout = 10
130
- config.threshold = 0.5
131
- end
169
+ Jev.feels(email.body, :urgent) # => 0.93
170
+ Jev.feels?(email.body, :urgent) # => true / false
171
+ Jev.decide(ticket, :support_team) # => :billing
172
+ Jev.score(ticket, :severity) # => 2.37
132
173
  ```
133
174
 
134
- For tests, inject a transport and skip the network:
175
+ `feels` is the calibrated probability (`0.0..1.0`). `feels?` is `feels >= threshold`. The default threshold is `0.5`.
135
176
 
136
177
  ```ruby
137
- Jev.configure do |config|
138
- config.transport = ->(_payload) {
139
- { "answers" => { "feels" => { "type" => "noul", "noul" => 0.9 } } }
178
+ email.body.feels?(:urgent, threshold: 0.8)
179
+ ```
180
+
181
+ `decide` returns the winning Symbol. `score` returns the fractional ordinal position — not a percentage.
182
+
183
+ Ad-hoc questions skip `define`:
184
+
185
+ ```ruby
186
+ email.body.feels?("sounds like the sender is about to cancel their subscription")
187
+
188
+ Jev.decide(
189
+ ticket,
190
+ "Which support team should handle this?",
191
+ choices: {
192
+ billing: "payments and refunds",
193
+ technical: "bugs and outages",
194
+ other: "none of the above"
140
195
  }
196
+ )
197
+ ```
198
+
199
+ `Jev.feels?(text, Email, :urgent)` uses Email's definition, then a superclass, then the global `:urgent`.
200
+
201
+ ## Uncertainty
202
+
203
+ `threshold:` is a single true/false cut. `0.51` and `0.99` are the same `true`.
204
+
205
+ `at_least:` is minimum certainty in either direction. The middle band is `nil`:
206
+
207
+ ```ruby
208
+ text.feels?(:urgent, at_least: 0.8)
209
+ # p >= 0.8 => true
210
+ # p <= 0.2 => false
211
+ # 0.2 < p < 0.8 => nil
212
+ ```
213
+
214
+ Without `at_least:`, `feels?` is still always true or false.
215
+
216
+ Do not pass both `threshold:` and `at_least:`.
217
+
218
+ `decide` uses confidence, which is not the winner's probability:
219
+
220
+ ```ruby
221
+ Jev.decide(ticket, :support_team, confidence: 0.8)
222
+ # => :billing or nil
223
+ ```
224
+
225
+ ## Typed results
226
+
227
+ Convenience methods collapse a result. `measure` keeps the rest:
228
+
229
+ ```ruby
230
+ result = Jev.measure(ticket, :urgent)
231
+ result.probability
232
+ result.type # => :noul
233
+
234
+ result = Jev.measure(ticket, :support_team)
235
+ result.choice
236
+ result.confidence
237
+ result.probabilities
238
+ result.type # => :choice
239
+
240
+ result = Jev.measure(ticket, :severity)
241
+ result.score
242
+ result.confidence
243
+ result.probabilities
244
+ result.levels
245
+ result.level # => :blocking
246
+ result.type # => :score
247
+ ```
248
+
249
+ ```ruby
250
+ feels? # boolean, or nil with at_least:
251
+ feels # probability
252
+ decide # Symbol, or nil with confidence:
253
+ score # Float
254
+ ```
255
+
256
+ ## Batch
257
+
258
+ Several questions, one Jev request, one `state`:
259
+
260
+ ```ruby
261
+ result = Jev.measure(ticket) do |q|
262
+ q.feels :urgent
263
+ q.feels :angry
264
+ q.decide :support_team
265
+ q.score :severity
266
+ end
267
+
268
+ result[:urgent] # => Jev::Result::Noul
269
+ result[:support_team] # => Jev::Result::Choice
270
+ result[:severity] # => Jev::Result::Score
271
+
272
+ result.to_h
273
+ # => { urgent: true, angry: false, support_team: :billing, severity: 2.37 }
274
+ ```
275
+
276
+ A batch only uses defined names. Ad-hoc strings stay on a single `feels?` / `decide` / `score`.
277
+
278
+ ## Pattern matching
279
+
280
+ `Jev::Result::Batch` uses collapsed values. No extra HTTP calls:
281
+
282
+ ```ruby
283
+ case Jev.measure(ticket) { |q|
284
+ q.feels :urgent
285
+ q.decide :support_team
286
+ q.score :severity
287
+ }
288
+ in { urgent: true, support_team: :billing }
289
+ escalate_billing(ticket)
290
+ in { support_team: :technical }
291
+ route_to_technical(ticket)
292
+ in { urgent: true, severity: 2.0.. }
293
+ escalate(ticket)
294
+ else
295
+ manual_review(ticket)
296
+ end
297
+ ```
298
+
299
+ `feels?` collapses to `true`/`false`, `decide` to a Symbol, `score` stays a Float so ranges work.
300
+
301
+ ## `Jev.match`
302
+
303
+ A small dispatch for one `decide`. Low confidence goes to `otherwise`:
304
+
305
+ ```ruby
306
+ Jev.match(ticket, :support_team) do
307
+ on(:billing) { route_to_billing(ticket) }
308
+ on(:technical) { route_to_technical(ticket) }
309
+ on(:sales) { route_to_sales(ticket) }
310
+ otherwise { manual_review(ticket) }
311
+ end
312
+
313
+ Jev.match(ticket, :support_team, confidence: 0.8) do
314
+ on(:billing, :sales) { route_to_commercial(ticket) }
315
+ otherwise { manual_review(ticket) }
316
+ end
317
+ ```
318
+
319
+ ## Tests: stub, record, replay
320
+
321
+ Stub by definition name. The block restores the previous transport, including in other threads:
322
+
323
+ ```ruby
324
+ Jev.stub(
325
+ urgent: 0.95,
326
+ support_team: :billing,
327
+ severity: 2.4
328
+ ) do
329
+ expect(ticket.feels?(:urgent)).to be(true)
330
+ expect(Jev.decide(ticket, :support_team)).to eq(:billing)
331
+ end
332
+ ```
333
+
334
+ A full `decide` / `score` stub is a hash with `choice` or `score`, plus `confidence` and `probabilities`. To own the HTTP shape, set `config.transport`.
335
+
336
+ Record real answers and replay them later. Replay never uses the network. The tape matches the request (state and questions), not a queue index. API keys and Authorization headers are not stored.
337
+
338
+ ```ruby
339
+ tape = Jev.record do
340
+ ticket.feels?(:urgent)
341
+ Jev.decide(ticket, :support_team)
342
+ end
343
+
344
+ json = tape.to_json
345
+
346
+ Jev.replay(tape) do
347
+ ticket.feels?(:urgent)
348
+ Jev.decide(ticket, :support_team)
141
349
  end
142
350
  ```
143
351
 
@@ -146,19 +354,32 @@ Jev.reset_configuration!
146
354
  Jev.reset_definitions!
147
355
  ```
148
356
 
357
+ ## Configuration
358
+
359
+ ```ruby
360
+ Jev.configure do |config|
361
+ config.api_key = ENV["JEV_API_KEY"]
362
+ end
363
+ ```
364
+
365
+ `base_url` defaults to `https://api.typesafe.ai`, `timeout` to 10 seconds, `threshold` to `0.5`. Override only if you need to.
366
+
149
367
  ## Errors
150
368
 
151
369
  All errors inherit from `Jev::Error`. HTTP failures are wrapped; the original exception is available as `cause`. API keys are redacted from messages.
152
370
 
153
- | Error | When |
154
- | --- | --- |
155
- | `Jev::ConfigurationError` | Missing API key |
156
- | `Jev::UndefinedDefinition` | Unknown symbol predicate |
157
- | `Jev::AuthenticationError` | HTTP 401 |
158
- | `Jev::RateLimitError` | HTTP 429 |
159
- | `Jev::InvalidResponseError` | Unparseable or shapeless body |
160
- | `Jev::RequestError` | Timeouts, network errors, other HTTP failures |
371
+ | Error | When |
372
+ | --------------------------- | --------------------------------------------- |
373
+ | `Jev::ConfigurationError` | Missing API key |
374
+ | `Jev::UndefinedDefinition` | Unknown symbol predicate |
375
+ | `Jev::AuthenticationError` | HTTP 401 |
376
+ | `Jev::RateLimitError` | HTTP 429 |
377
+ | `Jev::InvalidResponseError` | Unparseable or shapeless body |
378
+ | `Jev::RequestError` | Timeouts, network errors, other HTTP failures |
379
+ | `Jev::ReplayError` | Replay tape does not contain this request |
380
+
381
+ Malformed definitions (`choices:` and `levels:` together, empty Choice, too few Score levels, duplicate keys, invalid `threshold:` / `at_least:` / `confidence:`) raise `ArgumentError`.
161
382
 
162
383
  ## What this talks to
163
384
 
164
- [Jev](https://docs.typesafe.ai/introduction.md) / TypeSafe System One. `POST https://api.typesafe.ai/v1/systemone` with a Noul question. That is an implementation detail of `feels`; the public API is just `Jev.feels` / `Jev.feels?`.
385
+ [Jev](https://docs.typesafe.ai/introduction.md) / TypeSafe System One. `POST https://api.typesafe.ai/v1/systemone`. That is an implementation detail. The public API is `Jev.feels` / `Jev.feels?` / `Jev.decide` / `Jev.score` / `Jev.measure` / `Jev.match`.
data/context7.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://context7.com/schema/context7.json",
3
3
  "projectTitle": "jev-feels",
4
- "description": "Ruby-like semantic text predicates via Jev. Jev.feels?(:urgent) and model#feels?(:urgent) are ordinary conditions, not an SDK session.",
4
+ "description": "Semantic decisions as ordinary Ruby. Jev.define :urgent, then text.feels?(:urgent), model.decide(:support_team), model.score(:severity), and Jev.measure for typed or batched results. Jev stays an implementation detail.",
5
5
  "excludeFolders": ["spec"]
6
6
  }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "feels"
4
+ require "active_model"
5
+ require_relative "../jev/active_model"
data/lib/feels/string.rb CHANGED
@@ -10,4 +10,16 @@ class String
10
10
  def feels?(...)
11
11
  Jev.feels?(self, ...)
12
12
  end
13
+
14
+ def decide(...)
15
+ Jev.decide(self, ...)
16
+ end
17
+
18
+ def score(...)
19
+ Jev.score(self, ...)
20
+ end
21
+
22
+ def measure(...)
23
+ Jev.measure(self, ...)
24
+ end
13
25
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jev
4
+ module ActiveModel
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ def validates_feeling(attribute, predicate, message: nil, threshold: nil, at_least: nil, **options)
11
+ feeling = { predicate: predicate, message: message, threshold: threshold, at_least: at_least }
12
+ validates_each(attribute, **options) do |record, attr, value|
13
+ Jev::ActiveModel.validate(record, attr, value, feeling)
14
+ end
15
+ end
16
+ end
17
+
18
+ def self.validate(record, attr, value, feeling)
19
+ text = value.is_a?(String) ? value : value.to_s
20
+ kwargs = feeling.slice(:threshold, :at_least).compact
21
+ return if Jev.feels?(text, feeling[:predicate], **kwargs) == true
22
+
23
+ record.errors.add(attr, feeling[:message] || "is not #{feeling[:predicate]}")
24
+ end
25
+ end
26
+ end
27
+
28
+ if defined?(ActiveModel::Validations::HelperMethods)
29
+ ActiveModel::Validations::HelperMethods.include(Jev::ActiveModel::ClassMethods)
30
+ end
data/lib/jev/client.rb CHANGED
@@ -9,46 +9,42 @@ module Jev
9
9
  @configuration = configuration
10
10
  end
11
11
 
12
+ def ask(text, questions)
13
+ body = transport.call(payload(text, questions))
14
+ parse(body, questions)
15
+ end
16
+
12
17
  def probability(text, instructions)
13
- extract_noul(transport.call(payload(text, instructions)))
18
+ definition = Definition.build(name: :feels, instructions: instructions)
19
+ ask(text, { QUESTION_ID => definition }).fetch(QUESTION_ID).probability
14
20
  end
15
21
 
16
22
  private
17
23
 
18
24
  def transport
19
- @configuration.transport || Transport.new(@configuration)
25
+ Harness.current_transport(@configuration)
20
26
  end
21
27
 
22
- def payload(text, instructions)
28
+ def payload(text, questions)
23
29
  {
24
30
  "model" => MODEL,
25
31
  "state" => text,
26
- "questions" => {
27
- QUESTION_ID => {
28
- "type" => "noul",
29
- "instructions" => instructions
30
- }
31
- }
32
+ "questions" => questions.to_h { |id, definition| [id, definition.to_question] }
32
33
  }
33
34
  end
34
35
 
35
- def extract_noul(body)
36
+ def parse(body, questions)
36
37
  raise InvalidResponseError, "Jev response is not a JSON object" unless body.is_a?(Hash)
37
38
 
38
39
  answers = body["answers"]
39
40
  raise InvalidResponseError, "Jev response is missing answers" unless answers.is_a?(Hash)
40
41
 
41
- answer = answers[QUESTION_ID]
42
- raise InvalidResponseError, "Jev response is missing the feels answer" unless answer.is_a?(Hash)
43
-
44
- noul = answer["noul"]
45
- raise InvalidResponseError, "Jev response is missing a noul probability" unless noul.is_a?(Numeric)
46
-
47
- noul = Float(noul)
48
- raise InvalidResponseError, "Jev noul probability is not finite" unless noul.finite?
42
+ questions.to_h do |id, definition|
43
+ answer = answers[id]
44
+ raise InvalidResponseError, "Jev response is missing the #{id} answer" unless answer.is_a?(Hash)
49
45
 
50
- # ponytail: clamp out-of-range noul; raise if Jev starts returning uncalibrated values
51
- noul.clamp(0.0, 1.0)
46
+ [id, Result.parse(answer, definition)]
47
+ end
52
48
  end
53
49
  end
54
50
  end