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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +280 -59
- data/context7.json +1 -1
- data/lib/feels/active_model.rb +5 -0
- data/lib/feels/string.rb +12 -0
- data/lib/jev/active_model.rb +30 -0
- data/lib/jev/client.rb +16 -20
- data/lib/jev/definition.rb +91 -0
- data/lib/jev/errors.rb +1 -0
- data/lib/jev/feels.rb +12 -0
- data/lib/jev/harness.rb +215 -0
- data/lib/jev/match.rb +28 -0
- data/lib/jev/model.rb +64 -4
- data/lib/jev/query.rb +33 -0
- data/lib/jev/registry.rb +24 -5
- data/lib/jev/result.rb +165 -0
- data/lib/jev/version.rb +1 -1
- data/lib/jev-feels/active_model.rb +3 -0
- data/lib/jev.rb +183 -34
- metadata +14 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf19b7812e88098da8abdb24e2c951f11ed906bf8f0200be8d889cc9c7565d13
|
|
4
|
+
data.tar.gz: 836af8d2b70dbf6828f170a01b8d00e19ffadb4a56b5e8409c845bef1500cda4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
18
|
-
|
|
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
|
|
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
|
|
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(:
|
|
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.
|
|
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
|
-
##
|
|
115
|
+
## Definitions
|
|
79
116
|
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
`feels?` is `feels >= threshold`. The default threshold is `0.5`.
|
|
124
|
+
A decision (`decide`):
|
|
90
125
|
|
|
91
126
|
```ruby
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
154
|
-
|
|
|
155
|
-
| `Jev::ConfigurationError`
|
|
156
|
-
| `Jev::UndefinedDefinition`
|
|
157
|
-
| `Jev::AuthenticationError`
|
|
158
|
-
| `Jev::RateLimitError`
|
|
159
|
-
| `Jev::InvalidResponseError` | Unparseable or shapeless body
|
|
160
|
-
| `Jev::RequestError`
|
|
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
|
|
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": "
|
|
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
|
}
|
data/lib/feels/string.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
25
|
+
Harness.current_transport(@configuration)
|
|
20
26
|
end
|
|
21
27
|
|
|
22
|
-
def payload(text,
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
51
|
-
|
|
46
|
+
[id, Result.parse(answer, definition)]
|
|
47
|
+
end
|
|
52
48
|
end
|
|
53
49
|
end
|
|
54
50
|
end
|