bynn-decisionnode 0.4.1
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 +7 -0
- data/CHANGELOG.md +128 -0
- data/LICENSE +21 -0
- data/README.md +908 -0
- data/lib/decisionnode/batches.rb +246 -0
- data/lib/decisionnode/boosters.rb +692 -0
- data/lib/decisionnode/checks.rb +722 -0
- data/lib/decisionnode/client.rb +1100 -0
- data/lib/decisionnode/collections.rb +535 -0
- data/lib/decisionnode/document.rb +179 -0
- data/lib/decisionnode/errors.rb +138 -0
- data/lib/decisionnode/image.rb +194 -0
- data/lib/decisionnode/image_header.rb +121 -0
- data/lib/decisionnode/keys.rb +383 -0
- data/lib/decisionnode/questions.rb +135 -0
- data/lib/decisionnode/results.rb +539 -0
- data/lib/decisionnode/session.rb +286 -0
- data/lib/decisionnode/session_stream.rb +255 -0
- data/lib/decisionnode/supervisor.rb +107 -0
- data/lib/decisionnode/transport.rb +249 -0
- data/lib/decisionnode/uploads.rb +422 -0
- data/lib/decisionnode/version.rb +9 -0
- data/lib/decisionnode/video.rb +128 -0
- data/lib/decisionnode.rb +39 -0
- metadata +85 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
class DecisionNode
|
|
7
|
+
# A batch: every field of the batch object, by name or as `batch["field"]`.
|
|
8
|
+
# A batch states no price: the batch price is each model's
|
|
9
|
+
# `batch_price_factor` in `models`, and each line's charge is in its usage.
|
|
10
|
+
class Batch
|
|
11
|
+
FIELDS = %w[id state counters created_at finalized_at expires_at ended_at results_url
|
|
12
|
+
results_expire_at usage error].freeze
|
|
13
|
+
ENDED = %w[completed failed cancelled expired].freeze
|
|
14
|
+
|
|
15
|
+
# The batch object as received.
|
|
16
|
+
attr_reader :raw
|
|
17
|
+
|
|
18
|
+
FIELDS.each do |name|
|
|
19
|
+
define_method(name) { @raw[name] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(raw)
|
|
23
|
+
@raw = raw.is_a?(Hash) ? raw : {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def [](key)
|
|
27
|
+
@raw[key.to_s]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
@raw
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True once the batch is completed, failed, cancelled or expired.
|
|
35
|
+
def ended?
|
|
36
|
+
!ended_at.nil? || ENDED.include?(state)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def inspect
|
|
40
|
+
"#<#{self.class.name} #{id.inspect} state=#{state.inspect}>"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# One page of DecisionNode::Batches#list.
|
|
45
|
+
class BatchPage
|
|
46
|
+
# The batches on this page, newest first, as Batch values.
|
|
47
|
+
attr_reader :batches
|
|
48
|
+
attr_reader :offset, :limit, :total
|
|
49
|
+
# The offset of the next page, or nil on the last page.
|
|
50
|
+
attr_reader :next_offset
|
|
51
|
+
attr_reader :raw
|
|
52
|
+
|
|
53
|
+
def initialize(raw)
|
|
54
|
+
@raw = raw.is_a?(Hash) ? raw : {}
|
|
55
|
+
@batches = (@raw["batches"] || []).map { |b| Batch.new(b) }
|
|
56
|
+
@offset = @raw["offset"]
|
|
57
|
+
@limit = @raw["limit"]
|
|
58
|
+
@total = @raw["total"]
|
|
59
|
+
@next_offset = @raw["next_offset"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_h
|
|
63
|
+
@raw
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# One line of a batch's results.
|
|
68
|
+
class BatchResult
|
|
69
|
+
attr_reader :custom_id
|
|
70
|
+
# The line's HTTP status (an Integer), or "expired" or "cancelled".
|
|
71
|
+
attr_reader :status
|
|
72
|
+
# The line's headers, names in lower case.
|
|
73
|
+
attr_reader :headers
|
|
74
|
+
# A Decision for a 200 line, parsed as #decide parses a response; else nil.
|
|
75
|
+
attr_reader :decision
|
|
76
|
+
# For an error line, the error #decide would have raised for that status
|
|
77
|
+
# and body, as a value (never raised); else nil.
|
|
78
|
+
attr_reader :error
|
|
79
|
+
# The line as received.
|
|
80
|
+
attr_reader :raw
|
|
81
|
+
|
|
82
|
+
def initialize(raw, error_builder)
|
|
83
|
+
@raw = raw.is_a?(Hash) ? raw : {}
|
|
84
|
+
@custom_id = @raw["custom_id"]
|
|
85
|
+
@status = @raw["status"]
|
|
86
|
+
@headers = (@raw["headers"].is_a?(Hash) ? @raw["headers"] : {}).to_h { |k, v| [k.to_s.downcase, v] }
|
|
87
|
+
body = @raw["body"]
|
|
88
|
+
if @status == 200 && body.is_a?(Hash)
|
|
89
|
+
@decision = Decision.new(body, @headers)
|
|
90
|
+
elsif @status.is_a?(Integer)
|
|
91
|
+
@error = error_builder.call(@status, @headers, body)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ok?
|
|
96
|
+
!@decision.nil?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The Batch API: decisions at the batch price. No time is set for a batch
|
|
101
|
+
# to run or finish; requests not answered within 24 hours of finalizing
|
|
102
|
+
# expire and are not charged.
|
|
103
|
+
# Reached as DecisionNode#batches.
|
|
104
|
+
#
|
|
105
|
+
# b = dn.batches.create([{ custom_id: "t1", state: "...", questions: { ... } }])
|
|
106
|
+
# dn.batches.finalize(b.id)
|
|
107
|
+
# dn.batches.get(b.id).state
|
|
108
|
+
# dn.batches.results(b.id).each { |r| r.decision || r.error }
|
|
109
|
+
#
|
|
110
|
+
# A request is a Hash of #decide's arguments plus `custom_id`; its images
|
|
111
|
+
# follow the client's inline threshold per line, and uploaded ones are held
|
|
112
|
+
# by the batch. Every request is checked locally before anything is sent.
|
|
113
|
+
#
|
|
114
|
+
# `get`, `list`, `results` and `cancel` are retried as every call is.
|
|
115
|
+
# `create`, `add` and `finalize` are retried only on 429 and 529, never after
|
|
116
|
+
# a timeout, a lost answer or a 5xx, since a repeat could make a second draft.
|
|
117
|
+
class Batches
|
|
118
|
+
# The most bytes one add call carries; a larger list is split, in order.
|
|
119
|
+
ADD_BYTES = 10_485_760
|
|
120
|
+
NDJSON = "application/x-ndjson"
|
|
121
|
+
|
|
122
|
+
def initialize(client)
|
|
123
|
+
@client = client
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def inspect
|
|
127
|
+
"#<#{self.class.name}>"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Creates a draft (`POST /v1/batches`) with these requests, or an empty
|
|
131
|
+
# draft without any. Returns the Batch.
|
|
132
|
+
def create(requests = [])
|
|
133
|
+
lines = lines_for(requests)
|
|
134
|
+
Batch.new(call("POST", "/v1/batches", lines.join, policy: :mutation, content_type: NDJSON))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Adds requests to a draft (`POST /v1/batches/{id}/requests`), in calls of
|
|
138
|
+
# at most 10 MiB each, in order. Returns the Batch after the last call.
|
|
139
|
+
def add(batch_id, requests)
|
|
140
|
+
lines = lines_for(requests, allow_empty: false)
|
|
141
|
+
|
|
142
|
+
path = "/v1/batches/#{segment(batch_id)}/requests"
|
|
143
|
+
batch = nil
|
|
144
|
+
chunks(lines).each do |chunk|
|
|
145
|
+
batch = call("POST", path, chunk, policy: :mutation, content_type: NDJSON)
|
|
146
|
+
end
|
|
147
|
+
Batch.new(batch)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Queues a draft to run (`POST /v1/batches/{id}/finalize`). A 409
|
|
151
|
+
# `invalid_batch_state` is resolved with #get: when the batch is no longer
|
|
152
|
+
# a draft (an earlier finalize went through), the batch is returned.
|
|
153
|
+
def finalize(batch_id)
|
|
154
|
+
Batch.new(call("POST", "/v1/batches/#{segment(batch_id)}/finalize", nil, policy: :mutation))
|
|
155
|
+
rescue DecisionNodeError => e
|
|
156
|
+
raise unless e.status == 409 && e.error_type == "invalid_batch_state"
|
|
157
|
+
|
|
158
|
+
batch = get(batch_id)
|
|
159
|
+
raise e if batch.state == "draft"
|
|
160
|
+
|
|
161
|
+
batch
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# `GET /v1/batches/{id}`.
|
|
165
|
+
def get(batch_id)
|
|
166
|
+
Batch.new(call("GET", "/v1/batches/#{segment(batch_id)}", nil))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# One page of the key's batches, newest first (`GET /v1/batches`).
|
|
170
|
+
def list(offset: nil, limit: nil)
|
|
171
|
+
Checks.list!(offset, limit)
|
|
172
|
+
query = {}
|
|
173
|
+
query["offset"] = offset unless offset.nil?
|
|
174
|
+
query["limit"] = limit unless limit.nil?
|
|
175
|
+
path = query.empty? ? "/v1/batches" : "/v1/batches?#{URI.encode_www_form(query)}"
|
|
176
|
+
BatchPage.new(call("GET", path, nil))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The results of an ended batch (`GET /v1/batches/{id}/results`), one
|
|
180
|
+
# BatchResult per request in upload order. The JSON Lines file is read
|
|
181
|
+
# from the network line by line as it arrives, however many lines it has.
|
|
182
|
+
# With a block, each BatchResult is handed to it as its line arrives and
|
|
183
|
+
# nil is returned; without one, an Array of every result.
|
|
184
|
+
def results(batch_id, &block)
|
|
185
|
+
builder = @client.method(:error_from)
|
|
186
|
+
out = block ? nil : []
|
|
187
|
+
handle = lambda do |line|
|
|
188
|
+
next if line.strip.empty?
|
|
189
|
+
|
|
190
|
+
result = BatchResult.new(JSON.parse(line, max_nesting: false), builder)
|
|
191
|
+
block ? block.call(result) : out << result
|
|
192
|
+
end
|
|
193
|
+
@client.__send__(:request, "GET", "/v1/batches/#{segment(batch_id)}/results", nil, raw: true, &handle)
|
|
194
|
+
out
|
|
195
|
+
rescue JSON::ParserError => e
|
|
196
|
+
raise ServerError.new("a results line is not JSON: #{e.message}")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Cancels a batch (`POST /v1/batches/{id}/cancel`).
|
|
200
|
+
def cancel(batch_id)
|
|
201
|
+
Batch.new(call("POST", "/v1/batches/#{segment(batch_id)}/cancel", nil))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# `GET /v1/batches/{id}` with the response headers, for run_batch's waits.
|
|
205
|
+
def get_with_headers(batch_id)
|
|
206
|
+
body, headers, = @client.__send__(:request, "GET", "/v1/batches/#{segment(batch_id)}", nil)
|
|
207
|
+
[Batch.new(body), headers]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def call(method, path, body, policy: :default, content_type: nil)
|
|
213
|
+
parsed, = @client.__send__(:request, method, path, body, encoded: body.is_a?(String),
|
|
214
|
+
policy: policy, content_type: content_type)
|
|
215
|
+
parsed
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def segment(batch_id)
|
|
219
|
+
id = batch_id.respond_to?(:id) ? batch_id.id : batch_id
|
|
220
|
+
raise ArgumentError, "a batch id is required" if id.nil? || id.to_s.empty?
|
|
221
|
+
|
|
222
|
+
URI.encode_www_form_component(id.to_s)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def lines_for(requests, allow_empty: true)
|
|
226
|
+
raise ArgumentError, "requests must be an Array of Hashes" unless requests.respond_to?(:to_a)
|
|
227
|
+
|
|
228
|
+
list = requests.to_a
|
|
229
|
+
@client.__send__(:check_lines, list, allow_empty: allow_empty) # every line checked first
|
|
230
|
+
list.each_with_index.map { |req, i| @client.__send__(:batch_line, req, i) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def chunks(lines)
|
|
234
|
+
out = []
|
|
235
|
+
current = +""
|
|
236
|
+
lines.each do |line|
|
|
237
|
+
if !current.empty? && current.bytesize + line.bytesize > ADD_BYTES
|
|
238
|
+
out << current
|
|
239
|
+
current = +""
|
|
240
|
+
end
|
|
241
|
+
current << line
|
|
242
|
+
end
|
|
243
|
+
out << current
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|