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,535 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
class DecisionNode
|
|
7
|
+
# Collections of faces, a workspace's own and the public ones it may use
|
|
8
|
+
# (`celebrities` is an id like any other), and knowledge bases: collections
|
|
9
|
+
# of type "text" holding a workspace's own documents, which the `knowledge`
|
|
10
|
+
# booster reads. Every call returns the API's object as sent, a Hash with
|
|
11
|
+
# the API's field names; #list returns the collections in the order sent.
|
|
12
|
+
# One Hash shape serves both kinds, told apart by its "type".
|
|
13
|
+
#
|
|
14
|
+
# `create` and `enroll` send an Idempotency-Key, a new UUID for each call and
|
|
15
|
+
# the same on every attempt of it, so the API creates, enrolls and bills once
|
|
16
|
+
# however often the call is tried. They retry as every call does, and also
|
|
17
|
+
# on a 409 `idempotency_in_progress` after its Retry-After; a 409
|
|
18
|
+
# `idempotency_key_reused` is raised. `update_item` carries a key the same
|
|
19
|
+
# way, as do #add_document and #replace_document. Reads and deletes carry
|
|
20
|
+
# no key. An answer the API replays for a repeated key is returned as sent.
|
|
21
|
+
class Collections
|
|
22
|
+
NAME_MAX = 80
|
|
23
|
+
REFERENCE_MAX = 64
|
|
24
|
+
LABEL_MAX = 128
|
|
25
|
+
# The API's pattern, as its 422 names it, and the same rule matched on the
|
|
26
|
+
# whole string (Ruby's ^ and $ match at line ends). An empty reference
|
|
27
|
+
# fails the pattern, as the API answers it.
|
|
28
|
+
REFERENCE_PATTERN = "^[A-Za-z0-9_.:-]{1,64}$"
|
|
29
|
+
REFERENCE_RULE = /\A[A-Za-z0-9_.:-]{1,64}\z/
|
|
30
|
+
ECHO_CHARS = 64
|
|
31
|
+
METADATA_KEYS = 20
|
|
32
|
+
METADATA_KEY_CHARS = 40
|
|
33
|
+
METADATA_TEXT_CHARS = 500
|
|
34
|
+
METADATA_KEY_RULE = /\A[a-z0-9_]{1,40}\z/
|
|
35
|
+
SCOPE_KEY_PATTERN = "^[a-z0-9_]{1,40}$"
|
|
36
|
+
SCOPE_KEY_RULE = /\A[a-z0-9_]{1,40}\z/
|
|
37
|
+
TITLE_MAX = 200
|
|
38
|
+
PIN_RANK_MAX = 1_000
|
|
39
|
+
# wait_until_ready: the longest wait between reads, and how much longer
|
|
40
|
+
# each wait is than the one before.
|
|
41
|
+
POLL_CAP = 10.0
|
|
42
|
+
POLL_GROWTH = 1.5
|
|
43
|
+
MIN_POLL_INTERVAL = 0.1
|
|
44
|
+
# list_items' search text, in characters; its orders; and how much of a
|
|
45
|
+
# wrong order the API's message echoes.
|
|
46
|
+
Q_MAX = 200
|
|
47
|
+
SORTS = %w[newest oldest title].freeze
|
|
48
|
+
SORT_ECHO = 16
|
|
49
|
+
# The value of an argument not given, so a label given as nil is sent as
|
|
50
|
+
# null, which clears it.
|
|
51
|
+
UNSET = Object.new.freeze
|
|
52
|
+
private_constant :NAME_MAX, :REFERENCE_MAX, :LABEL_MAX, :REFERENCE_PATTERN, :REFERENCE_RULE, :ECHO_CHARS,
|
|
53
|
+
:METADATA_KEYS, :METADATA_KEY_CHARS, :METADATA_TEXT_CHARS, :METADATA_KEY_RULE, :UNSET,
|
|
54
|
+
:SCOPE_KEY_PATTERN, :SCOPE_KEY_RULE, :TITLE_MAX, :PIN_RANK_MAX, :POLL_CAP, :POLL_GROWTH,
|
|
55
|
+
:MIN_POLL_INTERVAL, :Q_MAX, :SORTS, :SORT_ECHO
|
|
56
|
+
|
|
57
|
+
def initialize(client)
|
|
58
|
+
@client = client
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def inspect
|
|
62
|
+
"#<#{self.class.name}>"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Creates a collection (`POST /v1/collections`). `type` is "face" (faces)
|
|
66
|
+
# or "text" (a knowledge base); `name` is 1 to 80 characters. A knowledge
|
|
67
|
+
# base may take a `scope_key`, 1 to 40 of a-z, 0-9 and _, fixed for its
|
|
68
|
+
# life: the metadata key whose value scopes a document to, say, one
|
|
69
|
+
# mission. It is sent only when given.
|
|
70
|
+
def create(name:, type: "face", scope_key: nil)
|
|
71
|
+
type = type.to_s if type.is_a?(Symbol)
|
|
72
|
+
entries = []
|
|
73
|
+
unless %w[face text].include?(type)
|
|
74
|
+
entries << Checks.entry("literal_error", %w[body type], "Input should be 'face' or 'text'", echoed(type),
|
|
75
|
+
{ "expected" => "'face' or 'text'" })
|
|
76
|
+
end
|
|
77
|
+
entries.concat(string_entries(%w[body name], name, 1, NAME_MAX))
|
|
78
|
+
unless scope_key.nil? || (scope_key.is_a?(String) && scope_key.match?(SCOPE_KEY_RULE))
|
|
79
|
+
entries << Checks.entry("string_pattern_mismatch", %w[body scope_key],
|
|
80
|
+
"String should match pattern '#{SCOPE_KEY_PATTERN}'", echoed(scope_key))
|
|
81
|
+
end
|
|
82
|
+
raise Checks.invalid(entries) unless entries.empty?
|
|
83
|
+
|
|
84
|
+
body = { "type" => type, "name" => name }
|
|
85
|
+
body["scope_key"] = scope_key unless scope_key.nil?
|
|
86
|
+
call("POST", "/v1/collections", body, key: SecureRandom.uuid)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The workspace's collections, then the public ones it may use
|
|
90
|
+
# (`GET /v1/collections`), as an Array in the order sent.
|
|
91
|
+
def list
|
|
92
|
+
body = call("GET", "/v1/collections")
|
|
93
|
+
body.is_a?(Hash) ? body["collections"] : body
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `GET /v1/collections/{id}`.
|
|
97
|
+
def get(collection_id)
|
|
98
|
+
call("GET", "/v1/collections/#{segment(collection_id)}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# `DELETE /v1/collections/{id}`. A knowledge base answers
|
|
102
|
+
# `{"id", "deleted" => true}` once purged within 10 s, else
|
|
103
|
+
# `{"id", "status" => "purging"}` while the purge finishes in the
|
|
104
|
+
# background; both are success.
|
|
105
|
+
def delete(collection_id)
|
|
106
|
+
call("DELETE", "/v1/collections/#{segment(collection_id)}")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Enrolls one face from one image (`POST /v1/collections/{id}/items`). The
|
|
110
|
+
# image is sent as a decision sends one: a URL as given, local bytes inline
|
|
111
|
+
# under the inline threshold, else uploaded. Only a URL's rules are checked
|
|
112
|
+
# before sending; any other fault of the picture is the API's 422
|
|
113
|
+
# `image_unreadable`. `reference` is 1 to 64 of A-Z, a-z, 0-9 and _ . : -;
|
|
114
|
+
# `label`, when given, at most 128 characters. `metadata`, when given, is a
|
|
115
|
+
# Hash of at most 20 keys of 1 to 40 of a-z, 0-9 and _, each value a text of
|
|
116
|
+
# at most 500 characters, a number, true or false.
|
|
117
|
+
def enroll(collection_id, image:, reference:, label: nil, metadata: nil)
|
|
118
|
+
entries = []
|
|
119
|
+
entries << Checks.entry("missing", %w[body image], "Field required", nil) if image.nil?
|
|
120
|
+
entries.concat(string_entries(%w[body reference], reference, 1, REFERENCE_MAX, REFERENCE_RULE))
|
|
121
|
+
entries.concat(string_entries(%w[body label], label, 1, LABEL_MAX)) unless label.nil?
|
|
122
|
+
entries.concat(metadata_entries(metadata)) unless metadata.nil?
|
|
123
|
+
raise Checks.invalid(entries) unless entries.empty?
|
|
124
|
+
|
|
125
|
+
images = @client.__send__(:prepared_images, [image])
|
|
126
|
+
Checks.image_url!(image.id, image.url) if image.is_a?(Image) && image.url
|
|
127
|
+
path = "/v1/collections/#{segment(collection_id)}/items"
|
|
128
|
+
2.times do |attempt|
|
|
129
|
+
wires, placed = @client.__send__(:image_wires, images)
|
|
130
|
+
body = { "image" => wires.first, "reference" => reference }
|
|
131
|
+
body["label"] = label unless label.nil?
|
|
132
|
+
body["metadata"] = metadata unless metadata.nil?
|
|
133
|
+
begin
|
|
134
|
+
# A resend after a stale upload carries a new upload id, so it is a
|
|
135
|
+
# new request with its own key.
|
|
136
|
+
return call("POST", path, body, key: SecureRandom.uuid)
|
|
137
|
+
rescue BadRequestError => e
|
|
138
|
+
raise unless attempt.zero? && !placed.empty? && Uploads::STALE_UPLOAD.include?(e.error_type)
|
|
139
|
+
|
|
140
|
+
@client.__send__(:drop_uploads, placed)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Edits an item (`PATCH /v1/collections/{id}/items/{item_id}`), sending
|
|
146
|
+
# only the fields given: a `label` given as nil clears it, and `metadata`
|
|
147
|
+
# replaces the item's whole object ({} clears it). A document also takes
|
|
148
|
+
# `pinned`, `pin_rank` (0 to 1,000) and `title` (1 to 200 characters):
|
|
149
|
+
# each is applied at once, never re-embedded or billed, except a new title,
|
|
150
|
+
# which makes a new version. The reference and the label are checked as
|
|
151
|
+
# #enroll checks them. It carries an Idempotency-Key and retries as
|
|
152
|
+
# #create and #enroll do. Returns the item as sent.
|
|
153
|
+
def update_item(collection_id, item_id, reference: UNSET, label: UNSET, metadata: UNSET, pinned: UNSET,
|
|
154
|
+
pin_rank: UNSET, title: UNSET)
|
|
155
|
+
given = { "reference" => reference, "label" => label, "metadata" => metadata, "pinned" => pinned,
|
|
156
|
+
"pin_rank" => pin_rank, "title" => title }.reject { |_, v| v.equal?(UNSET) }
|
|
157
|
+
entries = document_entries(given)
|
|
158
|
+
entries << Checks.entry("missing", %w[body], "Send reference, label, metadata or any of them", nil) if given.empty?
|
|
159
|
+
raise Checks.invalid(entries) unless entries.empty?
|
|
160
|
+
|
|
161
|
+
call("PATCH", "/v1/collections/#{segment(collection_id)}/items/#{segment(item_id)}", given,
|
|
162
|
+
key: SecureRandom.uuid)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Adds a document to a knowledge base (`POST /v1/collections/{id}/items`)
|
|
166
|
+
# and returns the item as sent: "queued", or with `wait: true` (the
|
|
167
|
+
# server holds the call up to 10 s) "ready" once it is, else as it stands.
|
|
168
|
+
#
|
|
169
|
+
# dn.collections.add_document(kb, document: DecisionNode::Document.new(path: "policy.pdf"), wait: true)
|
|
170
|
+
#
|
|
171
|
+
# document:: a DecisionNode::Document. A text, and a text or Markdown file
|
|
172
|
+
# under the client's inline threshold, are sent inline; any
|
|
173
|
+
# other file is uploaded first and named by its upload id.
|
|
174
|
+
# title:: 1 to 200 characters; required for a text, else a file's name
|
|
175
|
+
# without its extension by default.
|
|
176
|
+
# reference, label, metadata:: as #enroll's. The knowledge base's scope
|
|
177
|
+
# key in `metadata` scopes the document; without it the
|
|
178
|
+
# document is global.
|
|
179
|
+
# pinned:: true: always given to a request that names the knowledge base.
|
|
180
|
+
# pin_rank:: 0 to 1,000, the order of pinned documents, lowest first.
|
|
181
|
+
#
|
|
182
|
+
# Each field is sent only when given. It carries an Idempotency-Key, a new
|
|
183
|
+
# UUID for each call and the same on every attempt of it. An upload this
|
|
184
|
+
# call made that the API calls stale is uploaded again and the call sent
|
|
185
|
+
# once more, with a new key; an upload id you gave is never re-uploaded.
|
|
186
|
+
def add_document(collection_id, document:, title: nil, reference: nil, pinned: nil, pin_rank: nil, label: nil,
|
|
187
|
+
metadata: nil, wait: false)
|
|
188
|
+
fields = { "title" => title, "reference" => reference, "pinned" => pinned, "pin_rank" => pin_rank,
|
|
189
|
+
"label" => label, "metadata" => metadata }.compact
|
|
190
|
+
document_write("POST", "/v1/collections/#{segment(collection_id)}/items", document, fields, wait)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Replaces a document whole (`PUT /v1/collections/{id}/items/{item_id}`),
|
|
194
|
+
# with #add_document's arguments. A new text, format or title is a new
|
|
195
|
+
# version, the old one serving until the new one is ready; the same text
|
|
196
|
+
# again changes nothing. Returns the item as sent.
|
|
197
|
+
def replace_document(collection_id, item_id, document:, title: nil, reference: nil, pinned: nil, pin_rank: nil,
|
|
198
|
+
label: nil, metadata: nil, wait: false)
|
|
199
|
+
fields = { "title" => title, "reference" => reference, "pinned" => pinned, "pin_rank" => pin_rank,
|
|
200
|
+
"label" => label, "metadata" => metadata }.compact
|
|
201
|
+
document_write("PUT", "/v1/collections/#{segment(collection_id)}/items/#{segment(item_id)}", document, fields,
|
|
202
|
+
wait)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# One item (`GET /v1/collections/{id}/items/{item_id}`); with
|
|
206
|
+
# `include_text: true` a document's stored text too (nil while no version
|
|
207
|
+
# serves). Byte offsets in chunks and passages index that text's UTF-8 bytes.
|
|
208
|
+
def get_item(collection_id, item_id, include_text: false)
|
|
209
|
+
path = "/v1/collections/#{segment(collection_id)}/items/#{segment(item_id)}"
|
|
210
|
+
call("GET", include_text == true ? "#{path}?include=text" : path)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# One page of a document's live chunks
|
|
214
|
+
# (`GET /v1/collections/{id}/items/{item_id}/chunks`), `offset` and `limit`
|
|
215
|
+
# (1 to 100) sent only when given.
|
|
216
|
+
def list_chunks(collection_id, item_id, offset: nil, limit: nil)
|
|
217
|
+
Checks.list!(offset, limit)
|
|
218
|
+
query = { "offset" => offset, "limit" => limit }.compact
|
|
219
|
+
path = "/v1/collections/#{segment(collection_id)}/items/#{segment(item_id)}/chunks"
|
|
220
|
+
call("GET", query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Deletes every failed document of a knowledge base
|
|
224
|
+
# (`DELETE /v1/collections/{id}/items?status=failed`) and returns the
|
|
225
|
+
# answer as sent, `{"deleted" => n}`.
|
|
226
|
+
def delete_failed(collection_id)
|
|
227
|
+
call("DELETE", "/v1/collections/#{segment(collection_id)}/items?status=failed")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Waits on the client for documents to be ingested. With an `item_id` it
|
|
231
|
+
# reads the item until its status is "ready" or "failed" and returns it,
|
|
232
|
+
# whichever it is (a failed item says why in its "error"). Without one it
|
|
233
|
+
# reads the knowledge base until no document is queued or processing and
|
|
234
|
+
# returns it.
|
|
235
|
+
#
|
|
236
|
+
# The first read is made at once; then it waits `poll_interval` seconds
|
|
237
|
+
# (at least 0.1, default 1), half as long again after each read up to 10
|
|
238
|
+
# s. Each read retries as any call does, after the Retry-After of a 429 or
|
|
239
|
+
# 529. Past `timeout` seconds (default 300) it raises
|
|
240
|
+
# DecisionNode::TimeoutError.
|
|
241
|
+
def wait_until_ready(collection_id, item_id = nil, timeout: 300, poll_interval: 1.0)
|
|
242
|
+
unless poll_interval.is_a?(Numeric) && poll_interval.finite? && poll_interval >= MIN_POLL_INTERVAL
|
|
243
|
+
raise ArgumentError, "poll_interval must be at least #{MIN_POLL_INTERVAL} seconds, got #{poll_interval.inspect}"
|
|
244
|
+
end
|
|
245
|
+
unless timeout.is_a?(Numeric) && timeout.positive?
|
|
246
|
+
raise ArgumentError, "timeout must be a number of seconds above 0, got #{timeout.inspect}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
interval = poll_interval.to_f
|
|
250
|
+
waited = 0.0
|
|
251
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
252
|
+
loop do
|
|
253
|
+
found = item_id.nil? ? get(collection_id) : get_item(collection_id, item_id)
|
|
254
|
+
return found if settled?(found, item_id)
|
|
255
|
+
|
|
256
|
+
elapsed = [waited, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started].max
|
|
257
|
+
if elapsed >= timeout
|
|
258
|
+
what = item_id.nil? ? "the documents of #{collection_id} were" : "the document #{item_id} was"
|
|
259
|
+
raise TimeoutError, "#{what} not ready within #{timeout} s"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The last wait ends at the timeout, for one more read.
|
|
263
|
+
wait = [interval, timeout - elapsed].min
|
|
264
|
+
@client.__send__(:pause, wait)
|
|
265
|
+
waited += wait
|
|
266
|
+
interval = [interval * POLL_GROWTH, POLL_CAP].min
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Retrieves passages without a decision (`POST /v1/collections/{id}/search`),
|
|
271
|
+
# billed as one knowledge call (a full-scope key). `query` is required;
|
|
272
|
+
# `scope`, `match`, `filter`, `top_k`, `pinned` and `max_tokens` (an
|
|
273
|
+
# Integer or "auto") are sent only when given, as given, as the knowledge
|
|
274
|
+
# booster's options. Returns a KnowledgeReading with every passage's text
|
|
275
|
+
# (an UnknownReading holding the answer when it has a field missing or of
|
|
276
|
+
# the wrong type).
|
|
277
|
+
def search(collection_id, query:, scope: nil, match: nil, filter: nil, top_k: nil, pinned: nil, max_tokens: nil)
|
|
278
|
+
body = { "query" => query, "scope" => scope, "match" => match, "filter" => filter, "top_k" => top_k,
|
|
279
|
+
"pinned" => pinned, "max_tokens" => max_tokens }.reject { |k, v| v.nil? && k != "query" }
|
|
280
|
+
Reading.from("knowledge", call("POST", "/v1/collections/#{segment(collection_id)}/search", body))
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# One page of a collection's items (`GET /v1/collections/{id}/items`),
|
|
284
|
+
# with `offset`, `limit`, `reference` (the items of that reference),
|
|
285
|
+
# `reference_prefix` (the items whose reference starts with it) sent only
|
|
286
|
+
# when given; `reference` and `reference_prefix` not both. A knowledge
|
|
287
|
+
# base also takes `q` (1 to 200 characters, matched without regard to
|
|
288
|
+
# case against a document's title, file name or reference) and `sort`
|
|
289
|
+
# ("newest", "oldest" or "title"), each sent only when given.
|
|
290
|
+
def list_items(collection_id, offset: nil, limit: nil, reference: nil, reference_prefix: nil, q: nil, sort: nil)
|
|
291
|
+
Checks.list!(offset, limit)
|
|
292
|
+
unless reference.nil? || reference_prefix.nil?
|
|
293
|
+
Checks.usage("Send query parameter reference or reference_prefix, not both.")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
query = {}
|
|
297
|
+
query["offset"] = offset unless offset.nil?
|
|
298
|
+
query["limit"] = limit unless limit.nil?
|
|
299
|
+
{ "reference" => reference, "reference_prefix" => reference_prefix }.each do |name, value|
|
|
300
|
+
next if value.nil?
|
|
301
|
+
|
|
302
|
+
reference_query!(name, value)
|
|
303
|
+
query[name] = value
|
|
304
|
+
end
|
|
305
|
+
sort = sort.to_s if sort.is_a?(Symbol)
|
|
306
|
+
unless q.nil? || (q.is_a?(String) && q.length.between?(1, Q_MAX))
|
|
307
|
+
Checks.usage("Query parameter q must be 1 to #{Q_MAX} characters.")
|
|
308
|
+
end
|
|
309
|
+
unless sort.nil? || SORTS.include?(sort)
|
|
310
|
+
Checks.usage("Query parameter sort must be newest, oldest or title, got #{printable(sort.to_s, SORT_ECHO)}.")
|
|
311
|
+
end
|
|
312
|
+
query["q"] = q unless q.nil?
|
|
313
|
+
query["sort"] = sort unless sort.nil?
|
|
314
|
+
path = "/v1/collections/#{segment(collection_id)}/items"
|
|
315
|
+
call("GET", query.empty? ? path : "#{path}?#{query.map { |k, v| "#{k}=#{URI.encode_uri_component(v.to_s)}" }.join('&')}")
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# `DELETE /v1/collections/{id}/items/{item_id}`.
|
|
319
|
+
def delete_item(collection_id, item_id)
|
|
320
|
+
call("DELETE", "/v1/collections/#{segment(collection_id)}/items/#{segment(item_id)}")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
private
|
|
324
|
+
|
|
325
|
+
def call(method, path, body = nil, key: nil, timeout: nil)
|
|
326
|
+
return @client.__send__(:request, method, path, body, timeout: timeout).first if key.nil?
|
|
327
|
+
|
|
328
|
+
parsed, = @client.__send__(:request, method, path, body, policy: :idempotent, timeout: timeout,
|
|
329
|
+
extra_headers: { "Idempotency-Key" => key })
|
|
330
|
+
parsed
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Whether wait_until_ready may stop: an item ready or failed, or a
|
|
334
|
+
# knowledge base with nothing queued or processing.
|
|
335
|
+
def settled?(found, item_id)
|
|
336
|
+
return false unless found.is_a?(Hash)
|
|
337
|
+
return %w[ready failed].include?(found["status"]) unless item_id.nil?
|
|
338
|
+
|
|
339
|
+
counts = found["status_counts"].is_a?(Hash) ? found["status_counts"] : {}
|
|
340
|
+
%w[queued processing].all? { |k| counts[k].nil? || counts[k] == 0 }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# An item write: the fields checked, the document read (and uploaded when
|
|
344
|
+
# it is not sent inline), then the call with its own Idempotency-Key.
|
|
345
|
+
def document_write(method, path, document, fields, wait)
|
|
346
|
+
raise ArgumentError, "document must be a DecisionNode::Document, got #{document.class}" unless document.is_a?(Document)
|
|
347
|
+
|
|
348
|
+
item = document_entries(fields.slice("reference", "pin_rank", "label", "metadata", "pinned"))
|
|
349
|
+
title = fields.key?("title") ? document_entries(fields.slice("title")) : nil
|
|
350
|
+
entries = []
|
|
351
|
+
if document.text?
|
|
352
|
+
entries.concat(title || [missing_title])
|
|
353
|
+
entries << Checks.entry("string_too_short", %w[body text], "The text is empty", "") if document.text.empty?
|
|
354
|
+
unless document.format.nil? || Document::FORMATS.include?(document.format)
|
|
355
|
+
entries << Checks.entry("literal_error", %w[body format], "Input should be 'markdown' or 'text'",
|
|
356
|
+
echoed(document.format))
|
|
357
|
+
end
|
|
358
|
+
raise Checks.invalid(entries + item) unless entries.empty? && item.empty?
|
|
359
|
+
end
|
|
360
|
+
# A file's own faults (its type, size) come before the fields'.
|
|
361
|
+
loaded = document.load
|
|
362
|
+
inline = inline_text(loaded)
|
|
363
|
+
unless document.text?
|
|
364
|
+
entries.concat(title || (inline && loaded.filename.nil? ? [missing_title] : []))
|
|
365
|
+
raise Checks.invalid(entries + item) unless entries.empty? && item.empty?
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
path = "#{path}?wait=true" if wait == true
|
|
369
|
+
timeout = @client.__send__(:write_timeout, wait == true)
|
|
370
|
+
return call(method, path, inline_body(loaded, inline, fields), key: SecureRandom.uuid, timeout: timeout) if inline
|
|
371
|
+
|
|
372
|
+
2.times do |attempt|
|
|
373
|
+
upload_id = loaded.kind == :upload ? loaded.upload_id : @client.__send__(:upload_knowledge, [loaded]).first
|
|
374
|
+
body = { "upload_id" => upload_id }
|
|
375
|
+
body["title"] = fields["title"] if fields.key?("title")
|
|
376
|
+
body.merge!(fields.except("title"))
|
|
377
|
+
begin
|
|
378
|
+
# A resend after a stale upload names a new upload, so it is a new
|
|
379
|
+
# request with its own key.
|
|
380
|
+
return call(method, path, body, key: SecureRandom.uuid, timeout: timeout)
|
|
381
|
+
rescue BadRequestError => e
|
|
382
|
+
raise unless attempt.zero? && loaded.kind == :file && Uploads::STALE_UPLOAD.include?(e.error_type)
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# A text, or a text or Markdown file whose bytes total under the client's
|
|
388
|
+
# inline threshold and read as UTF-8 without a NUL: the text and its
|
|
389
|
+
# format. nil for anything to upload.
|
|
390
|
+
def inline_text(loaded)
|
|
391
|
+
return [loaded.text, loaded.format] if loaded.kind == :text
|
|
392
|
+
return nil unless loaded.kind == :file && %w[text/plain text/markdown].include?(loaded.content_type)
|
|
393
|
+
return nil unless loaded.data.bytesize < @client.inline_threshold
|
|
394
|
+
|
|
395
|
+
text = loaded.data.dup.force_encoding(Encoding::UTF_8)
|
|
396
|
+
return nil unless text.valid_encoding? && !text.include?("\0")
|
|
397
|
+
|
|
398
|
+
[text, loaded.content_type == "text/markdown" ? "markdown" : "text"]
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# The body of a document sent inline: its title (a file's name without its
|
|
402
|
+
# extension when none is given), its text and format, then the fields.
|
|
403
|
+
def inline_body(loaded, inline, fields)
|
|
404
|
+
text, format = inline
|
|
405
|
+
body = { "title" => fields.fetch("title") { Document.stem(loaded.filename) }, "text" => text }
|
|
406
|
+
body["format"] = format unless format.nil?
|
|
407
|
+
body.merge(fields.except("title"))
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def missing_title
|
|
411
|
+
Checks.entry("missing", %w[body title], "Field required", nil)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# The 422 entries of a document's fields, in the order given: the title
|
|
415
|
+
# (1 to 200 characters once trimmed), `pinned` (true or false) and
|
|
416
|
+
# `pin_rank` (an Integer from 0 to 1,000), and the reference, the label and
|
|
417
|
+
# the metadata as #enroll checks them.
|
|
418
|
+
def document_entries(fields)
|
|
419
|
+
entries = []
|
|
420
|
+
fields.each do |name, value|
|
|
421
|
+
case name
|
|
422
|
+
when "title"
|
|
423
|
+
next if value.is_a?(String) && value.strip.length.between?(1, TITLE_MAX)
|
|
424
|
+
|
|
425
|
+
entries << Checks.entry("string_too_long", %w[body title], "A title is 1 to #{TITLE_MAX} characters", echoed(value))
|
|
426
|
+
when "reference"
|
|
427
|
+
entries.concat(string_entries(%w[body reference], value, 1, REFERENCE_MAX, REFERENCE_RULE))
|
|
428
|
+
when "pinned"
|
|
429
|
+
next if [true, false].include?(value)
|
|
430
|
+
|
|
431
|
+
entries << Checks.entry("bool_type", %w[body pinned], "Input should be a valid boolean", echoed(value))
|
|
432
|
+
when "pin_rank"
|
|
433
|
+
next if value.is_a?(Integer) && value.between?(0, PIN_RANK_MAX)
|
|
434
|
+
|
|
435
|
+
entries << Checks.entry("int_range", %w[body pin_rank], "Input should be an integer from 0 to #{PIN_RANK_MAX}",
|
|
436
|
+
echoed(value))
|
|
437
|
+
when "label"
|
|
438
|
+
entries.concat(string_entries(%w[body label], value, 1, LABEL_MAX)) unless value.nil?
|
|
439
|
+
when "metadata"
|
|
440
|
+
entries.concat(metadata_entries(value))
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
entries
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def segment(id)
|
|
447
|
+
raise ArgumentError, "an id is required" if id.nil? || id.to_s.empty?
|
|
448
|
+
|
|
449
|
+
URI.encode_uri_component(id.to_s)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# An item's metadata's 422 entries, as the API answers them: not a Hash,
|
|
453
|
+
# more than 20 keys (that alone, before any key is read), a key not 1 to 40
|
|
454
|
+
# of a-z, 0-9 and _, a text over 500 characters, a value that is not a
|
|
455
|
+
# text, a number, true or false; in key order. A text's control or
|
|
456
|
+
# invisible characters, a number's range and the 4 KB limit are the API's.
|
|
457
|
+
def metadata_entries(metadata)
|
|
458
|
+
loc = %w[body metadata]
|
|
459
|
+
return [Checks.entry("value_error", loc, "Metadata is an object of keys and values.", echoed(metadata))] unless metadata.is_a?(Hash)
|
|
460
|
+
if metadata.size > METADATA_KEYS
|
|
461
|
+
return [Checks.entry("value_error", loc, "Metadata holds at most #{METADATA_KEYS} keys.", nil)]
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
metadata.each_with_object([]) do |(key, value), out|
|
|
465
|
+
key = key.to_s if key.is_a?(Symbol)
|
|
466
|
+
if !key.is_a?(String) || !key.match?(METADATA_KEY_RULE)
|
|
467
|
+
out << Checks.entry("value_error", loc, "A metadata key is 1 to #{METADATA_KEY_CHARS} characters of a-z, 0-9 and _: \"#{shown_key(key.to_s)}\".", nil)
|
|
468
|
+
elsif value.is_a?(String)
|
|
469
|
+
if value.length > METADATA_TEXT_CHARS
|
|
470
|
+
out << Checks.entry("value_error", loc + [key], "A metadata text is at most #{METADATA_TEXT_CHARS} characters.", echoed(value))
|
|
471
|
+
end
|
|
472
|
+
elsif !(value.is_a?(Integer) || value.is_a?(Float) || value == true || value == false)
|
|
473
|
+
out << Checks.entry("value_error", loc + [key], "A metadata value is a text, a number or true or false.", echoed(value))
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# A metadata key as the API's message shows it: its first 40 characters,
|
|
479
|
+
# each control character as ?, then "..." when it was longer.
|
|
480
|
+
def shown_key(key)
|
|
481
|
+
shown = key.each_char.first(METADATA_KEY_CHARS).map { |c| c.match?(/\p{Cc}/) ? "?" : c }.join
|
|
482
|
+
key.length > METADATA_KEY_CHARS ? "#{shown}..." : shown
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# A `reference` or `reference_prefix` of the items list, checked as the
|
|
486
|
+
# API checks it.
|
|
487
|
+
def reference_query!(name, value)
|
|
488
|
+
return if value.is_a?(String) && value.match?(REFERENCE_RULE)
|
|
489
|
+
|
|
490
|
+
Checks.usage("Query parameter #{name} must be 1 to 64 characters of A-Z, a-z, 0-9, _, ., : and -, " \
|
|
491
|
+
"got #{printable(value.to_s, REFERENCE_MAX)}.")
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# A caller's value as an API message echoes it: its first `most`
|
|
495
|
+
# characters, any but printable ASCII as \u{hex}, then "..." when longer.
|
|
496
|
+
def printable(value, most)
|
|
497
|
+
shown = value.each_char.first(most).map { |c| c.ord.between?(0x20, 0x7E) ? c : "\\u{#{c.ord.to_s(16)}}" }.join
|
|
498
|
+
value.length > most ? "#{shown}..." : shown
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# How a collections 422 entry echoes an input, as the API does: a String
|
|
502
|
+
# cut to its first 64 characters, with "..." only when it was longer; an
|
|
503
|
+
# object or an array never (nil); anything else as it is.
|
|
504
|
+
def echoed(value)
|
|
505
|
+
case value
|
|
506
|
+
when String then value.length > ECHO_CHARS ? "#{value[0, ECHO_CHARS]}..." : value
|
|
507
|
+
when Hash, Array then nil
|
|
508
|
+
else value
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# A string field's 422 entry, as the API answers it: missing, not a
|
|
513
|
+
# String, too short, too long, or (with a pattern) not matching it. The
|
|
514
|
+
# length is checked before the pattern, so an empty value is too short.
|
|
515
|
+
def string_entries(loc, value, least, most, rule = nil, required: least.positive?)
|
|
516
|
+
return required ? [Checks.entry("missing", loc, "Field required", nil)] : [] if value.nil?
|
|
517
|
+
return [Checks.entry("string_type", loc, "Input should be a valid string", echoed(value))] unless value.is_a?(String)
|
|
518
|
+
|
|
519
|
+
if value.length < least
|
|
520
|
+
chars = least == 1 ? "character" : "characters"
|
|
521
|
+
return [Checks.entry("string_too_short", loc, "String should have at least #{least} #{chars}", value,
|
|
522
|
+
{ "min_length" => least })]
|
|
523
|
+
end
|
|
524
|
+
if value.length > most
|
|
525
|
+
return [Checks.entry("string_too_long", loc, "String should have at most #{most} characters", echoed(value),
|
|
526
|
+
{ "max_length" => most })]
|
|
527
|
+
end
|
|
528
|
+
if rule && !value.match?(rule)
|
|
529
|
+
return [Checks.entry("string_pattern_mismatch", loc, "String should match pattern '#{REFERENCE_PATTERN}'", value,
|
|
530
|
+
{ "pattern" => REFERENCE_PATTERN })]
|
|
531
|
+
end
|
|
532
|
+
[]
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
end
|