hunch 0.3.0 → 0.4.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/README.md +72 -68
- data/lib/hunch/backends/stub.rb +5 -5
- data/lib/hunch/backends/{jev.rb → system_one.rb} +24 -16
- data/lib/hunch/configuration.rb +3 -19
- data/lib/hunch/decision.rb +3 -0
- data/lib/hunch/errors.rb +1 -0
- data/lib/hunch/result.rb +20 -6
- data/lib/hunch/version.rb +1 -1
- data/lib/hunch.rb +8 -9
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7f094c2b401f7331d45ca78228ceec41c45aacd76104ca3af0a9bf7708362f4
|
|
4
|
+
data.tar.gz: 57450f766b733602126cf65e128aa98245926a7b73679c5684f9aacd6e824d47
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bbef66e803a4362cc7e72f52096d01f412060ddb2d68fb7fd8b79172de9acb335ddd724a0b0d566b8c3a497d36779ff9c1f5fb8e31c0d17680d6038b50f69293
|
|
7
|
+
data.tar.gz: 99ccaed307b8501dd8ea9ecc1aab28dcaf6d86accb24e79a2107caf0e3971dfd354c17ea5a157211a738b1fae74665a8cc98cb95f6fa0049b2068ba3e7f793c3
|
data/README.md
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Probabilistic control flow for Ruby.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
_how likely is this, given that_ — and the API reads that way:
|
|
5
|
+
Hunch lets you ask questions about text or application data and use the
|
|
6
|
+
answers in your Ruby code.
|
|
8
7
|
|
|
9
8
|
```ruby
|
|
10
9
|
if Hunch.almost_certainly?("this order is fraudulent", given: order.attributes)
|
|
@@ -12,10 +11,11 @@ if Hunch.almost_certainly?("this order is fraudulent", given: order.attributes)
|
|
|
12
11
|
end
|
|
13
12
|
```
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
You can get a probability, choose between options, or rate something on a
|
|
15
|
+
scale. Questions are written in plain English; answers come back as Ruby values.
|
|
16
|
+
|
|
17
|
+
Hunch asks a System One model, such as [TypeSafe's Jev](https://typesafe.ai),
|
|
18
|
+
which answers with calibrated probabilities rather than text.
|
|
19
19
|
|
|
20
20
|
## Installation
|
|
21
21
|
|
|
@@ -23,21 +23,29 @@ instead of text — fast and cheap enough to sit inside a request cycle.
|
|
|
23
23
|
gem "hunch"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
Point Hunch at a System One endpoint. Jev is available through
|
|
27
|
+
[OpenRouter](https://openrouter.ai/typesafe):
|
|
28
|
+
|
|
26
29
|
```ruby
|
|
30
|
+
# config/initializers/hunch.rb
|
|
27
31
|
Hunch.configure do |config|
|
|
28
|
-
config.
|
|
32
|
+
config.backend = Hunch::Backends::SystemOne.new(
|
|
33
|
+
url: "https://openrouter.ai/api/alpha/decisions",
|
|
34
|
+
api_key: ENV["OPENROUTER_API_KEY"],
|
|
35
|
+
model: "typesafe/jev-1.13"
|
|
36
|
+
)
|
|
29
37
|
end
|
|
30
38
|
```
|
|
31
39
|
|
|
32
|
-
##
|
|
40
|
+
## Usage
|
|
33
41
|
|
|
34
|
-
**`chance`**
|
|
42
|
+
**`chance`** returns a probability between 0 and 1:
|
|
35
43
|
|
|
36
44
|
```ruby
|
|
37
45
|
Hunch.chance("written by a real human, not spam", given: bio) # => 0.87
|
|
38
46
|
```
|
|
39
47
|
|
|
40
|
-
|
|
48
|
+
Use a named threshold when you want a boolean:
|
|
41
49
|
|
|
42
50
|
```ruby
|
|
43
51
|
Hunch.possibly?("fraudulent", given: order) # chance >= 0.25
|
|
@@ -45,14 +53,14 @@ Hunch.likely?("fraudulent", given: order) # chance >= 0.5
|
|
|
45
53
|
Hunch.probably?("fraudulent", given: order) # chance >= 0.75
|
|
46
54
|
Hunch.almost_certainly?("fraudulent", given: order) # chance >= 0.93
|
|
47
55
|
|
|
48
|
-
Hunch.configure { |c| c.levels[:
|
|
49
|
-
Hunch.
|
|
56
|
+
Hunch.configure { |c| c.levels[:definitely] = 0.99 }
|
|
57
|
+
Hunch.definitely?("fraudulent", given: order) # chance >= 0.99
|
|
50
58
|
```
|
|
51
59
|
|
|
52
60
|
Configure built-in thresholds with the matching level names, for example
|
|
53
61
|
`Hunch.configure { |c| c.levels[:probably] = 0.8 }`.
|
|
54
62
|
|
|
55
|
-
**`pick`**
|
|
63
|
+
**`pick`** chooses one of the options you provide:
|
|
56
64
|
|
|
57
65
|
```ruby
|
|
58
66
|
Hunch.pick(:ham, :spam, given: email) # => :spam
|
|
@@ -60,7 +68,7 @@ Hunch.pick(urgent: "needs a reply today", routine: "can wait",
|
|
|
60
68
|
given: ticket) # => :routine
|
|
61
69
|
```
|
|
62
70
|
|
|
63
|
-
**`rate`**
|
|
71
|
+
**`rate`** returns a rating with a position on an ordered scale:
|
|
64
72
|
|
|
65
73
|
```ruby
|
|
66
74
|
mood = Hunch.rate(:calm, :frustrated, :livid, given: email)
|
|
@@ -69,14 +77,16 @@ mood.position # => 1.4
|
|
|
69
77
|
mood >= :livid # => false
|
|
70
78
|
```
|
|
71
79
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
Use `pick` for categories such as billing, support, and sales. Use `rate`
|
|
81
|
+
for ordered levels such as low, medium, and high.
|
|
82
|
+
|
|
83
|
+
Both accept symbols or `symbol: "description"` pairs, or a mix of the two.
|
|
84
|
+
Add `question:` if the options need more context. `given:` supplies the data
|
|
85
|
+
to evaluate. These two keywords are reserved and can't be used as option names.
|
|
76
86
|
|
|
77
87
|
## Batching
|
|
78
88
|
|
|
79
|
-
|
|
89
|
+
Use `Hunch.decide` to ask several questions about the same data in one API call:
|
|
80
90
|
|
|
81
91
|
```ruby
|
|
82
92
|
result = Hunch.decide(given: mail.raw_source) do |q|
|
|
@@ -94,13 +104,11 @@ result.mood.level # => :livid
|
|
|
94
104
|
|
|
95
105
|
## In a Rails app
|
|
96
106
|
|
|
97
|
-
|
|
98
|
-
repo — its test suite covers each snippet, and all of them have been run
|
|
99
|
-
against the live model.
|
|
107
|
+
The [`example/`](example) Rails app contains these examples and their tests.
|
|
100
108
|
|
|
101
109
|
### Validations
|
|
102
110
|
|
|
103
|
-
|
|
111
|
+
Check a display name and bio during validation:
|
|
104
112
|
|
|
105
113
|
```ruby
|
|
106
114
|
class Signup < ApplicationRecord
|
|
@@ -136,13 +144,13 @@ signup.valid? # => false
|
|
|
136
144
|
signup.errors[:bio] # => ["reads like spam"]
|
|
137
145
|
```
|
|
138
146
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
147
|
+
These validations rescue `Hunch::APIError`, so an API failure adds no
|
|
148
|
+
validation error. Other validations, including the email presence check,
|
|
149
|
+
still apply.
|
|
142
150
|
|
|
143
151
|
### Inbound email
|
|
144
152
|
|
|
145
|
-
|
|
153
|
+
Route incoming email by its contents:
|
|
146
154
|
|
|
147
155
|
```ruby
|
|
148
156
|
class SortingMailbox < ApplicationMailbox
|
|
@@ -162,7 +170,7 @@ end
|
|
|
162
170
|
|
|
163
171
|
### Error triage
|
|
164
172
|
|
|
165
|
-
|
|
173
|
+
Choose whether to ignore an error, send a notification, or page someone:
|
|
166
174
|
|
|
167
175
|
```ruby
|
|
168
176
|
class ErrorTriage
|
|
@@ -189,7 +197,7 @@ end
|
|
|
189
197
|
|
|
190
198
|
### Job retries
|
|
191
199
|
|
|
192
|
-
|
|
200
|
+
Use the error message and attempt count to decide whether to retry:
|
|
193
201
|
|
|
194
202
|
```ruby
|
|
195
203
|
class WebhookDeliveryJob < ApplicationJob
|
|
@@ -208,12 +216,9 @@ class WebhookDeliveryJob < ApplicationJob
|
|
|
208
216
|
end
|
|
209
217
|
```
|
|
210
218
|
|
|
211
|
-
A `503 upstream timeout` retries; a `404 endpoint not found` doesn't.
|
|
212
|
-
|
|
213
219
|
### Enum coercion
|
|
214
220
|
|
|
215
|
-
|
|
216
|
-
your enum's values:
|
|
221
|
+
Map imported text to an existing enum value:
|
|
217
222
|
|
|
218
223
|
```ruby
|
|
219
224
|
class Order < ApplicationRecord
|
|
@@ -249,21 +254,26 @@ end
|
|
|
249
254
|
|
|
250
255
|
## Testing
|
|
251
256
|
|
|
252
|
-
|
|
257
|
+
Use the stub backend to supply answers without making API calls:
|
|
253
258
|
|
|
254
259
|
```ruby
|
|
255
|
-
Hunch.backend = Hunch::Backends::Stub.new(fraud: 0.95, team: :billing, mood: :calm)
|
|
260
|
+
Hunch.backend = Hunch::Backends::Stub.new({ fraud: 0.95, team: :billing, mood: :calm })
|
|
256
261
|
```
|
|
257
262
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
`
|
|
263
|
+
Use the question's key to supply its answer, or `:answer` for calls outside
|
|
264
|
+
a `Hunch.decide` block. Stub values depend on the method:
|
|
265
|
+
|
|
266
|
+
- `chance` and its predicates: a probability or boolean.
|
|
267
|
+
- `pick`: a symbol or a hash of probabilities.
|
|
268
|
+
- `rate`: a level symbol or numeric position.
|
|
269
|
+
|
|
270
|
+
Missing answers raise unless you pass a fallback, as in
|
|
271
|
+
`Hunch::Backends::Stub.new({}, fallback: 0.5)`. The stub records each call
|
|
272
|
+
in `calls` for assertions.
|
|
263
273
|
|
|
264
274
|
```ruby
|
|
265
275
|
test "spam is dropped without a ticket" do
|
|
266
|
-
Hunch.backend = Hunch::Backends::Stub.new(answer: :spam)
|
|
276
|
+
Hunch.backend = Hunch::Backends::Stub.new({ answer: :spam })
|
|
267
277
|
assert_no_difference -> { Ticket.count } do
|
|
268
278
|
receive_inbound_email_from_mail(subject: "You have WON", body: "claim your prize")
|
|
269
279
|
end
|
|
@@ -274,38 +284,32 @@ end
|
|
|
274
284
|
|
|
275
285
|
```ruby
|
|
276
286
|
Hunch.configure do |config|
|
|
277
|
-
config.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
287
|
+
config.backend = Hunch::Backends::SystemOne.new(
|
|
288
|
+
url: "https://openrouter.ai/api/alpha/decisions",
|
|
289
|
+
api_key: ENV["OPENROUTER_API_KEY"], # a missing key raises when you ask
|
|
290
|
+
model: "typesafe/jev-1.13",
|
|
291
|
+
timeout: 5, # seconds to read the response
|
|
292
|
+
open_timeout: 2, # seconds to connect
|
|
293
|
+
max_retries: 2 # 429/5xx/timeouts, with backoff, honours Retry-After
|
|
294
|
+
)
|
|
295
|
+
config.levels[:definitely] = 0.99
|
|
284
296
|
end
|
|
285
297
|
```
|
|
286
298
|
|
|
287
|
-
|
|
299
|
+
## Errors
|
|
288
300
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
Hunch.configure do |config|
|
|
295
|
-
config.api_key = ENV["OPENROUTER_API_KEY"]
|
|
296
|
-
config.url = "https://openrouter.ai/api/alpha/decisions"
|
|
297
|
-
config.model = "typesafe/jev-1.13"
|
|
298
|
-
end
|
|
299
|
-
```
|
|
301
|
+
`Hunch::APIError` covers failures worth working around: timeouts, lost
|
|
302
|
+
connections, rate limits, server errors, and answers the backend got wrong
|
|
303
|
+
(`Hunch::InvalidAnswerError`). `AuthenticationError`, `ValidationError`, and
|
|
304
|
+
`ConfigurationError` mean your setup needs fixing, so they are not
|
|
305
|
+
`APIError`s.
|
|
300
306
|
|
|
301
|
-
##
|
|
307
|
+
## Backends
|
|
302
308
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
orders of magnitude slower and more expensive. Same interface, different
|
|
308
|
-
guarantees — choose accordingly.
|
|
309
|
+
`Hunch::Backends::SystemOne` talks to any endpoint that speaks the System One
|
|
310
|
+
decisions protocol of noul, choice, and score questions. `Hunch::Backends::Stub`
|
|
311
|
+
is for [testing](#testing). A backend is any object with a
|
|
312
|
+
`decide(state:, questions:)` method that returns `{ "answers" => ... }`.
|
|
309
313
|
|
|
310
314
|
## License
|
|
311
315
|
|
data/lib/hunch/backends/stub.rb
CHANGED
|
@@ -3,13 +3,13 @@ module Hunch
|
|
|
3
3
|
class Stub
|
|
4
4
|
attr_reader :calls
|
|
5
5
|
|
|
6
|
-
def initialize(
|
|
6
|
+
def initialize(answers = {}, fallback: nil)
|
|
7
7
|
@answers = answers
|
|
8
|
-
@
|
|
8
|
+
@fallback = fallback
|
|
9
9
|
@calls = []
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def decide(state:, questions
|
|
12
|
+
def decide(state:, questions:)
|
|
13
13
|
@calls << { state:, questions: }
|
|
14
14
|
answers = questions.to_h do |key, question|
|
|
15
15
|
[key.to_s, answer_for(key, question)]
|
|
@@ -20,10 +20,10 @@ module Hunch
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
22
|
def answer_for(key, question)
|
|
23
|
-
value = @answers.fetch(key, @
|
|
23
|
+
value = @answers.fetch(key, @fallback)
|
|
24
24
|
if value.nil?
|
|
25
25
|
raise MissingStubAnswer,
|
|
26
|
-
"no stubbed answer for #{key.inspect}: Stub.new(#{key}: ...) or pass
|
|
26
|
+
"no stubbed answer for #{key.inspect}: Stub.new({ #{key}: ... }) or pass fallback:"
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
case question.type
|
|
@@ -4,17 +4,24 @@ require "uri"
|
|
|
4
4
|
|
|
5
5
|
module Hunch
|
|
6
6
|
module Backends
|
|
7
|
-
class
|
|
8
|
-
def initialize(
|
|
9
|
-
@
|
|
7
|
+
class SystemOne
|
|
8
|
+
def initialize(url:, api_key:, model:, timeout: 5, open_timeout: 2, max_retries: 2, transport: nil, sleeper: nil)
|
|
9
|
+
@url = url
|
|
10
|
+
@api_key = api_key
|
|
11
|
+
@model = model
|
|
12
|
+
@timeout = timeout
|
|
13
|
+
@open_timeout = open_timeout
|
|
14
|
+
@max_retries = max_retries
|
|
10
15
|
@transport = transport || method(:http_post)
|
|
11
16
|
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
12
17
|
end
|
|
13
18
|
|
|
14
|
-
def decide(state:, questions
|
|
19
|
+
def decide(state:, questions:)
|
|
20
|
+
raise ConfigurationError, "no API key for #{@url}" if @api_key.nil? || @api_key.empty?
|
|
21
|
+
|
|
15
22
|
payload = {
|
|
16
23
|
"state" => state,
|
|
17
|
-
"model" =>
|
|
24
|
+
"model" => @model,
|
|
18
25
|
"questions" => questions.to_h { |key, question| [key.to_s, question.payload] }
|
|
19
26
|
}
|
|
20
27
|
with_retries { handle(*@transport.call(payload)) }
|
|
@@ -28,7 +35,7 @@ module Hunch
|
|
|
28
35
|
attempts += 1
|
|
29
36
|
yield
|
|
30
37
|
rescue RateLimitError, OverloadedError, ServerError, TimeoutError, ConnectionError => e
|
|
31
|
-
raise if attempts > @
|
|
38
|
+
raise if attempts > @max_retries
|
|
32
39
|
|
|
33
40
|
@sleeper.call(e.respond_to?(:retry_after) && e.retry_after || backoff(attempts))
|
|
34
41
|
retry
|
|
@@ -41,7 +48,7 @@ module Hunch
|
|
|
41
48
|
|
|
42
49
|
def handle(status, headers, body)
|
|
43
50
|
case status
|
|
44
|
-
when 200..299 then
|
|
51
|
+
when 200..299 then parse(body)
|
|
45
52
|
when 401 then raise AuthenticationError, error_message(body, "missing or invalid API key")
|
|
46
53
|
when 422 then raise ValidationError, error_message(body, "invalid request")
|
|
47
54
|
when 429 then raise RateLimitError.new(error_message(body, "rate limited"), retry_after: retry_after(headers))
|
|
@@ -51,6 +58,12 @@ module Hunch
|
|
|
51
58
|
end
|
|
52
59
|
end
|
|
53
60
|
|
|
61
|
+
def parse(body)
|
|
62
|
+
JSON.parse(body)
|
|
63
|
+
rescue JSON::ParserError, TypeError
|
|
64
|
+
raise APIError, "response was not valid JSON"
|
|
65
|
+
end
|
|
66
|
+
|
|
54
67
|
def error_message(body, fallback)
|
|
55
68
|
parsed = JSON.parse(body)
|
|
56
69
|
error = parsed["error"] || parsed["message"] || fallback
|
|
@@ -65,9 +78,9 @@ module Hunch
|
|
|
65
78
|
end
|
|
66
79
|
|
|
67
80
|
def http_post(payload)
|
|
68
|
-
uri = URI(@
|
|
81
|
+
uri = URI(@url)
|
|
69
82
|
request = Net::HTTP::Post.new(uri)
|
|
70
|
-
request["Authorization"] = "Bearer #{api_key
|
|
83
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
71
84
|
request["Content-Type"] = "application/json"
|
|
72
85
|
request["User-Agent"] = "hunch-ruby/#{VERSION}"
|
|
73
86
|
request.body = JSON.generate(payload)
|
|
@@ -75,8 +88,8 @@ module Hunch
|
|
|
75
88
|
response = Net::HTTP.start(
|
|
76
89
|
uri.host, uri.port,
|
|
77
90
|
use_ssl: uri.scheme == "https",
|
|
78
|
-
open_timeout: @
|
|
79
|
-
read_timeout: @
|
|
91
|
+
open_timeout: @open_timeout,
|
|
92
|
+
read_timeout: @timeout
|
|
80
93
|
) { |http| http.request(request) }
|
|
81
94
|
|
|
82
95
|
[response.code.to_i, response.each_header.to_h, response.body]
|
|
@@ -85,11 +98,6 @@ module Hunch
|
|
|
85
98
|
rescue SocketError, SystemCallError, EOFError, OpenSSL::SSL::SSLError => e
|
|
86
99
|
raise ConnectionError, e.message
|
|
87
100
|
end
|
|
88
|
-
|
|
89
|
-
def api_key!
|
|
90
|
-
@config.api_key or raise ConfigurationError,
|
|
91
|
-
"no API key: set TYPESAFE_API_KEY or Hunch.configure { |c| c.api_key = ... }"
|
|
92
|
-
end
|
|
93
101
|
end
|
|
94
102
|
end
|
|
95
103
|
end
|
data/lib/hunch/configuration.rb
CHANGED
|
@@ -2,16 +2,10 @@ module Hunch
|
|
|
2
2
|
class Configuration
|
|
3
3
|
LEVELS = { possibly: 0.25, likely: 0.5, probably: 0.75, almost_certainly: 0.93 }.freeze
|
|
4
4
|
|
|
5
|
-
attr_accessor :
|
|
5
|
+
attr_accessor :levels
|
|
6
6
|
attr_writer :backend
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
|
-
@api_key = ENV["TYPESAFE_API_KEY"]
|
|
10
|
-
@model = "jev-latest"
|
|
11
|
-
@url = "https://api.typesafe.ai/v1/systemone"
|
|
12
|
-
@timeout = 5
|
|
13
|
-
@open_timeout = 2
|
|
14
|
-
@max_retries = 2
|
|
15
9
|
@levels = LEVELS.dup
|
|
16
10
|
@backend = nil
|
|
17
11
|
end
|
|
@@ -29,18 +23,8 @@ module Hunch
|
|
|
29
23
|
end
|
|
30
24
|
|
|
31
25
|
def backend
|
|
32
|
-
@backend
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
|
|
37
|
-
def resolve(backend)
|
|
38
|
-
case backend
|
|
39
|
-
when nil, :jev then Backends::Jev.new(self)
|
|
40
|
-
when :stub then Backends::Stub.new
|
|
41
|
-
when Symbol then raise ConfigurationError, "unknown backend #{backend.inspect}"
|
|
42
|
-
else backend
|
|
43
|
-
end
|
|
26
|
+
@backend or raise ConfigurationError,
|
|
27
|
+
"no backend: Hunch.configure { |c| c.backend = Hunch::Backends::SystemOne.new(url:, api_key:, model:) }"
|
|
44
28
|
end
|
|
45
29
|
end
|
|
46
30
|
end
|
data/lib/hunch/decision.rb
CHANGED
|
@@ -56,6 +56,9 @@ module Hunch
|
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def add(question)
|
|
59
|
+
if Result::RESERVED_KEYS.include?(question.key)
|
|
60
|
+
raise ArgumentError, "#{question.key} is reserved on results; choose another key"
|
|
61
|
+
end
|
|
59
62
|
raise ArgumentError, "duplicate question key #{question.key}" if @questions.key?(question.key)
|
|
60
63
|
|
|
61
64
|
@questions[question.key] = question
|
data/lib/hunch/errors.rb
CHANGED
|
@@ -5,6 +5,7 @@ module Hunch
|
|
|
5
5
|
class AuthenticationError < Error; end
|
|
6
6
|
class ValidationError < Error; end
|
|
7
7
|
class APIError < Error; end
|
|
8
|
+
class InvalidAnswerError < APIError; end
|
|
8
9
|
class ServerError < APIError; end
|
|
9
10
|
class OverloadedError < APIError; end
|
|
10
11
|
class TimeoutError < APIError; end
|
data/lib/hunch/result.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
module Hunch
|
|
2
2
|
class Result
|
|
3
|
+
RESERVED_KEYS = %i[model usage to_h].freeze
|
|
4
|
+
|
|
3
5
|
attr_reader :model, :usage
|
|
4
6
|
|
|
5
7
|
def initialize(questions:, answers:, model: nil, usage: nil)
|
|
@@ -8,7 +10,7 @@ module Hunch
|
|
|
8
10
|
@values = {}
|
|
9
11
|
questions.each do |key, question|
|
|
10
12
|
raw = answers[key.to_s] || answers[key]
|
|
11
|
-
raise
|
|
13
|
+
raise InvalidAnswerError, "backend returned no answer for #{key}" unless raw
|
|
12
14
|
|
|
13
15
|
build(key, question, raw)
|
|
14
16
|
end
|
|
@@ -25,26 +27,38 @@ module Hunch
|
|
|
25
27
|
def build(key, question, raw)
|
|
26
28
|
case question.type
|
|
27
29
|
when :noul then build_noul(key, question, raw)
|
|
28
|
-
when :choice then build_choice(key, raw)
|
|
30
|
+
when :choice then build_choice(key, question, raw)
|
|
29
31
|
when :rate then build_rate(key, question, raw)
|
|
30
32
|
end
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
def build_noul(key, question, raw)
|
|
34
|
-
probability = raw["noul"]
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
probability = raw["noul"]
|
|
37
|
+
unless probability.is_a?(Numeric) && probability.between?(0, 1)
|
|
38
|
+
raise InvalidAnswerError, "#{key} needs a probability, got #{probability.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
set(key, probability.to_f)
|
|
42
|
+
define(:"#{key}?") { probability >= question.threshold } if question.threshold
|
|
37
43
|
end
|
|
38
44
|
|
|
39
|
-
def build_choice(key, raw)
|
|
45
|
+
def build_choice(key, question, raw)
|
|
40
46
|
probabilities = (raw["probabilities"] || {}).transform_keys(&:to_sym)
|
|
41
47
|
choice = raw["choice"]&.to_sym || probabilities.max_by { |_, p| p }&.first
|
|
48
|
+
unless question.options.key?(choice)
|
|
49
|
+
raise InvalidAnswerError, "#{key} needs one of #{question.options.keys.join(", ")}, got #{choice.inspect}"
|
|
50
|
+
end
|
|
51
|
+
|
|
42
52
|
set(key, choice)
|
|
43
53
|
define(:"#{key}_probabilities") { probabilities }
|
|
44
54
|
define(:"#{key}_confidence") { raw["confidence"] }
|
|
45
55
|
end
|
|
46
56
|
|
|
47
57
|
def build_rate(key, question, raw)
|
|
58
|
+
unless raw["score"].is_a?(Numeric) && raw["score"].finite?
|
|
59
|
+
raise InvalidAnswerError, "#{key} needs a position, got #{raw["score"].inspect}"
|
|
60
|
+
end
|
|
61
|
+
|
|
48
62
|
levels = question.levels.keys
|
|
49
63
|
by_index = raw["probabilities"] || {}
|
|
50
64
|
probabilities = levels.each_with_index.to_h do |level, i|
|
data/lib/hunch/version.rb
CHANGED
data/lib/hunch.rb
CHANGED
|
@@ -5,7 +5,7 @@ require_relative "hunch/rating"
|
|
|
5
5
|
require_relative "hunch/configuration"
|
|
6
6
|
require_relative "hunch/decision"
|
|
7
7
|
require_relative "hunch/result"
|
|
8
|
-
require_relative "hunch/backends/
|
|
8
|
+
require_relative "hunch/backends/system_one"
|
|
9
9
|
require_relative "hunch/backends/stub"
|
|
10
10
|
|
|
11
11
|
module Hunch
|
|
@@ -32,17 +32,11 @@ module Hunch
|
|
|
32
32
|
yield decision
|
|
33
33
|
raise ArgumentError, "decide needs at least one question" if decision.questions.empty?
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
Result.new(
|
|
37
|
-
questions: decision.questions,
|
|
38
|
-
answers: raw["answers"] || {},
|
|
39
|
-
model: raw["model"],
|
|
40
|
-
usage: raw["usage"]
|
|
41
|
-
)
|
|
35
|
+
ask(decision.questions, given)
|
|
42
36
|
end
|
|
43
37
|
|
|
44
38
|
def chance(question, given:, yes: nil, no: nil)
|
|
45
|
-
|
|
39
|
+
ask({ answer: Questions::Noul.new(key: :answer, question:, yes:, no:, threshold: nil) }, given).answer
|
|
46
40
|
end
|
|
47
41
|
|
|
48
42
|
Configuration::LEVELS.each_key do |level|
|
|
@@ -61,6 +55,11 @@ module Hunch
|
|
|
61
55
|
|
|
62
56
|
private
|
|
63
57
|
|
|
58
|
+
def ask(questions, state)
|
|
59
|
+
raw = configuration.backend.decide(state:, questions:)
|
|
60
|
+
Result.new(questions:, answers: raw["answers"] || {}, model: raw["model"], usage: raw["usage"])
|
|
61
|
+
end
|
|
62
|
+
|
|
64
63
|
def custom_level(name)
|
|
65
64
|
return unless name.to_s.end_with?("?")
|
|
66
65
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hunch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Carl Dawson
|
|
@@ -20,8 +20,8 @@ files:
|
|
|
20
20
|
- LICENSE.txt
|
|
21
21
|
- README.md
|
|
22
22
|
- lib/hunch.rb
|
|
23
|
-
- lib/hunch/backends/jev.rb
|
|
24
23
|
- lib/hunch/backends/stub.rb
|
|
24
|
+
- lib/hunch/backends/system_one.rb
|
|
25
25
|
- lib/hunch/configuration.rb
|
|
26
26
|
- lib/hunch/decision.rb
|
|
27
27
|
- lib/hunch/errors.rb
|