thinkthen 0.1.1-aarch64-linux
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/lib/thinkthen/thinkthen.so +0 -0
- data/lib/thinkthen/version.rb +3 -0
- data/lib/thinkthen.rb +725 -0
- metadata +47 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ca0f4c3ddb7df44e7f2a96b6d278aec9cceb4c381d3e6e266c9f3d5761c6b2ec
|
|
4
|
+
data.tar.gz: 48e6da8f17c1349d04c6ed791410aa8ff9ecc26c31070624c64c72ae2a282ff4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 69f0cdd1e48936516f66eaa05cfbd84ee355e6f00a018084c4fa4cd1ae40805ec471a47b4f3126ba0591acf9302163459ae7d3b7167188a0862010f5eadba53c
|
|
7
|
+
data.tar.gz: e0a6b8197ee4ee1ea2c68242aaf0beab7e734bd946df3deed7183fe1671b81f753946e19bb97e8ce596ce52fb8c9903b31ed58c0699e28feb3c3128faf1c9016
|
|
Binary file
|
data/lib/thinkthen.rb
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The Ruby surface over the thinkthen public Rust API.
|
|
4
|
+
#
|
|
5
|
+
# The asking verbs are module methods on ThinkThen, beside question, set,
|
|
6
|
+
# usage, and with_tick. The same methods live on a
|
|
7
|
+
# ThinkThen::Engine value, which carries the engine settings. The module
|
|
8
|
+
# methods call one lazy engine built from the environment.
|
|
9
|
+
#
|
|
10
|
+
# A question is built with ThinkThen.question(decide: "...", threshold:
|
|
11
|
+
# 0.2..0.8), and a verb that takes a question also takes the bare question
|
|
12
|
+
# text. A Range threshold is the band. nil is unsure.
|
|
13
|
+
#
|
|
14
|
+
# Every failure raises a ThinkThen::Error. The six kind classes inherit it,
|
|
15
|
+
# and the binding's own refusals raise UsageError. A record that is not a
|
|
16
|
+
# String crosses as its JSON text, never as Ruby's to_s form.
|
|
17
|
+
|
|
18
|
+
require "json"
|
|
19
|
+
require "timeout"
|
|
20
|
+
require_relative "thinkthen/version"
|
|
21
|
+
|
|
22
|
+
module ThinkThen
|
|
23
|
+
# The one base class. `kind` is the kind's word and `retryable` the
|
|
24
|
+
# engine's retry signal.
|
|
25
|
+
class Error < StandardError
|
|
26
|
+
# Each kind's code in the C door, 1 through 6.
|
|
27
|
+
CODES = { "usage" => 1, "backend" => 2, "deadline" => 3, "local" => 4, "cancelled" => 5, "defect" => 6 }.freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :kind, :retryable, :facts, :details, :completion
|
|
30
|
+
|
|
31
|
+
def initialize(message = nil, kind = nil, retryable = false)
|
|
32
|
+
super(message)
|
|
33
|
+
@kind = kind
|
|
34
|
+
@retryable = retryable
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def code = CODES.fetch(kind, 6)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A decide answer's code in the C door. ThinkThen.outcome maps an answer to it.
|
|
41
|
+
YES = 1
|
|
42
|
+
NO = 0
|
|
43
|
+
UNSURE = 2
|
|
44
|
+
|
|
45
|
+
class UsageError < Error; end
|
|
46
|
+
class BackendError < Error; end
|
|
47
|
+
class LocalError < Error; end
|
|
48
|
+
class CancelledError < Error; end
|
|
49
|
+
class DeadlineError < Error; end
|
|
50
|
+
class DefectError < Error; end
|
|
51
|
+
|
|
52
|
+
# The old Ruby value plus one completed call's immutable account.
|
|
53
|
+
class Call
|
|
54
|
+
attr_reader :value, :facts, :details
|
|
55
|
+
|
|
56
|
+
def initialize(value, facts, details)
|
|
57
|
+
@value = value
|
|
58
|
+
@facts = ThinkThen.__send__(:deep_freeze, facts)
|
|
59
|
+
@details = ThinkThen.__send__(:deep_freeze, details)
|
|
60
|
+
freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def map
|
|
64
|
+
self.class.new(yield(value), facts, details)
|
|
65
|
+
rescue Exception => error
|
|
66
|
+
ThinkThen.__send__(:attach_account, error, facts, details)
|
|
67
|
+
raise
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def inspect
|
|
71
|
+
"#<ThinkThen::Call value=<withheld> records=#{facts[:records]}>"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A prompt stop retains the original worker's eventual final account.
|
|
76
|
+
class Completion
|
|
77
|
+
def initialize(native)
|
|
78
|
+
@native = native
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def done? = @native.done?
|
|
82
|
+
alias done done?
|
|
83
|
+
|
|
84
|
+
def result(timeout: nil)
|
|
85
|
+
raise UsageError.new("completion timeout takes a nonnegative number", "usage") unless timeout.nil? ||
|
|
86
|
+
(timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout >= 0)
|
|
87
|
+
|
|
88
|
+
raw = @native.result(timeout)
|
|
89
|
+
raise Timeout::Error, "the call has not completed" if raw.nil?
|
|
90
|
+
|
|
91
|
+
@result ||= ThinkThen.__send__(:deep_freeze, raw)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
require_relative "thinkthen/thinkthen"
|
|
97
|
+
|
|
98
|
+
module ThinkThen
|
|
99
|
+
# The print form of every result value. A field holding the caller's text
|
|
100
|
+
# prints as its byte count, as the Rust Debug and Python repr forms do. A
|
|
101
|
+
# list prints as its length. Places, probabilities, and rule names print
|
|
102
|
+
# in clear.
|
|
103
|
+
module Withheld
|
|
104
|
+
TEXT = %i[name kind text record unit].freeze
|
|
105
|
+
|
|
106
|
+
def inspect
|
|
107
|
+
fields = each_pair.map do |name, value|
|
|
108
|
+
shown = if value.nil? then "nil"
|
|
109
|
+
elsif TEXT.include?(name) then "<#{value.to_s.bytesize} bytes withheld>"
|
|
110
|
+
elsif value.is_a?(Array) then value.size.to_s
|
|
111
|
+
else value.inspect
|
|
112
|
+
end
|
|
113
|
+
"#{name}=#{shown}"
|
|
114
|
+
end
|
|
115
|
+
"#<struct #{self.class.name} #{fields.join(", ")}>"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
alias to_s inspect
|
|
119
|
+
|
|
120
|
+
def pretty_print(printer)
|
|
121
|
+
printer.text(inspect)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A name and its kind, as relate reads and returns them.
|
|
126
|
+
Entity = Struct.new(:name, :kind) { include Withheld }
|
|
127
|
+
|
|
128
|
+
# One name recognize found. `start`, `end`, and `length` count characters,
|
|
129
|
+
# so `text[start...end]` is the name. `kind` is "ENTITY" when the call
|
|
130
|
+
# named no kind.
|
|
131
|
+
RecognizedEntity = Struct.new(:text, :start, :end, :length, :kind, :strength) { include Withheld }
|
|
132
|
+
|
|
133
|
+
# One relation between two recognized names.
|
|
134
|
+
# `either` is true when the relation holds both ways.
|
|
135
|
+
Relation = Struct.new(:relation, :source, :target, :probability, :either) { include Withheld }
|
|
136
|
+
|
|
137
|
+
# What recognize returned. `relations` is nil when no rule was given.
|
|
138
|
+
Recognized = Struct.new(:entities, :relations) { include Withheld }
|
|
139
|
+
|
|
140
|
+
# One edge relate found, with Entity ends. `either` is true when the edge
|
|
141
|
+
# holds both ways; its ends are then in input order.
|
|
142
|
+
Edge = Struct.new(:relation, :source, :target, :probability, :either) { include Withheld }
|
|
143
|
+
|
|
144
|
+
# The record's place in the input, the record, and its probability.
|
|
145
|
+
# Most likely yes first. Ties keep input order. `to_s` is the record.
|
|
146
|
+
Ranked = Struct.new(:index, :record, :probability) do
|
|
147
|
+
include Withheld
|
|
148
|
+
|
|
149
|
+
def to_s
|
|
150
|
+
record.to_s
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The selected unit's place, the unit, and its probability. Every field
|
|
155
|
+
# is nil when nothing is selected.
|
|
156
|
+
Found = Struct.new(:index, :unit, :probability) { include Withheld }
|
|
157
|
+
|
|
158
|
+
# The watchdog's cadence and one in-flight call's row.
|
|
159
|
+
WATCHDOG_INTERVAL = 0.1
|
|
160
|
+
Row = Struct.new(:tick, :token, :error)
|
|
161
|
+
private_constant :WATCHDOG_INTERVAL, :Row, :Native, :Withheld
|
|
162
|
+
|
|
163
|
+
@rows = {}
|
|
164
|
+
@rows_mutex = Mutex.new
|
|
165
|
+
@default_mutex = Mutex.new
|
|
166
|
+
|
|
167
|
+
# An engine and its settings. Omitted settings come from the environment,
|
|
168
|
+
# as the module methods' engine does. The throttle is the most requests
|
|
169
|
+
# in flight at once, per loaded copy of the library.
|
|
170
|
+
class Engine
|
|
171
|
+
def initialize(base_url: nil, model: nil, throttle: nil, max_requests: nil, max_request_bytes: nil, cache: nil,
|
|
172
|
+
timeout: nil, max_retries: nil, record: nil, replay: nil, profile: nil, batch: nil, max_requests_total: nil)
|
|
173
|
+
ThinkThen.__send__(:text_setting, :base_url, base_url)
|
|
174
|
+
ThinkThen.__send__(:text_setting, :model, model)
|
|
175
|
+
ThinkThen.__send__(:whole_setting, :throttle, throttle)
|
|
176
|
+
ThinkThen.__send__(:whole_setting, :max_requests, max_requests)
|
|
177
|
+
ThinkThen.__send__(:whole_setting, :max_request_bytes, max_request_bytes)
|
|
178
|
+
ThinkThen.__send__(:whole_setting, :timeout, timeout)
|
|
179
|
+
ThinkThen.__send__(:whole_setting, :max_retries, max_retries)
|
|
180
|
+
ThinkThen.__send__(:whole_setting, :max_requests_total, max_requests_total)
|
|
181
|
+
{ record: record, replay: replay, profile: profile }.each do |name, value|
|
|
182
|
+
ThinkThen.__send__(:text_setting, name, value)
|
|
183
|
+
end
|
|
184
|
+
unless cache.nil? || cache == false || cache.is_a?(String)
|
|
185
|
+
raise UsageError.new("cache is a folder path, false for none, or nil for the default", "usage")
|
|
186
|
+
end
|
|
187
|
+
batch = ThinkThen.__send__(:batch_of, batch)
|
|
188
|
+
@native = Native.engine({ base_url: base_url, model: model, throttle: throttle,
|
|
189
|
+
max_requests: max_requests, max_request_bytes: max_request_bytes,
|
|
190
|
+
cache_at: cache || nil, no_cache: cache == false,
|
|
191
|
+
timeout: timeout, max_retries: max_retries, record: record, replay: replay, profile: profile, batch: batch,
|
|
192
|
+
max_requests_total: max_requests_total })
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def self.from_native(native)
|
|
196
|
+
engine = allocate
|
|
197
|
+
engine.instance_variable_set(:@native, native)
|
|
198
|
+
engine
|
|
199
|
+
end
|
|
200
|
+
private_class_method :from_native
|
|
201
|
+
|
|
202
|
+
def inspect
|
|
203
|
+
"#<ThinkThen::Engine>"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def decide(question, evidence, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
207
|
+
crossing("decide", ThinkThen.__send__(:built, question), ThinkThen.__send__(:text_of, evidence), cancel, deadline_ms,
|
|
208
|
+
batch: batch, context: context)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def decide_many(question, records, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
212
|
+
decide_many_with_probabilities(question, records, cancel: cancel, deadline_ms: deadline_ms, batch: batch,
|
|
213
|
+
context: context).map { |rows| rows.map { |row| row[:answer] } }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Each answer with its probability, from the same requests.
|
|
217
|
+
def decide_many_with_probabilities(question, records, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
218
|
+
crossing("decide_many", ThinkThen.__send__(:built, question), ThinkThen.__send__(:texts, records.to_a), cancel, deadline_ms,
|
|
219
|
+
batch: batch, context: context, batch_ok: true, context_ok: true)
|
|
220
|
+
.map { |rows| rows.map { |answer, probability| { answer: answer, probability: probability } } }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def filter(question, records, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
224
|
+
list = records.to_a
|
|
225
|
+
crossing("filter", ThinkThen.__send__(:built, question), ThinkThen.__send__(:texts, list), cancel, deadline_ms,
|
|
226
|
+
batch: batch, context: context, batch_ok: true, context_ok: true)
|
|
227
|
+
.map { |places| places.map { |place| list[place] } }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def rank(question, records, top: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
231
|
+
list = records.to_a
|
|
232
|
+
crossing("rank", ThinkThen.__send__(:question_text, "rank", question), ThinkThen.__send__(:texts, list), cancel, deadline_ms,
|
|
233
|
+
batch: batch, context: context, batch_ok: true, context_ok: true)
|
|
234
|
+
.map do |placed|
|
|
235
|
+
ranked = placed.map { |place, probability| Ranked.new(place, list[place], probability) }
|
|
236
|
+
top ? ranked.first(top) : ranked
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# none: true offers a none candidate, as find --none does, so nothing may fit.
|
|
241
|
+
def find(question, units, none: false, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
242
|
+
raise UsageError.new("none is true or false", "usage") unless [true, false].include?(none)
|
|
243
|
+
|
|
244
|
+
list = units.to_a
|
|
245
|
+
crossing(none ? "find_none" : "find", ThinkThen.__send__(:question_text, "find", question), ThinkThen.__send__(:texts, list), cancel, deadline_ms,
|
|
246
|
+
batch: batch, context: context).map do |place, probability|
|
|
247
|
+
Found.new(place, place.nil? ? nil : list[place], probability)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def choose(question, evidence, options: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
252
|
+
question = ThinkThen.__send__(:keyed, "choose", question, :options, options)
|
|
253
|
+
crossing("details", question, ThinkThen.__send__(:text_of, evidence), cancel, deadline_ms,
|
|
254
|
+
batch: batch, context: context).map { |json| JSON.parse(json)["value"] }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def score(question, evidence, levels: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
258
|
+
question = ThinkThen.__send__(:keyed, "score", question, :levels, levels)
|
|
259
|
+
crossing("score", question, ThinkThen.__send__(:text_of, evidence), cancel, deadline_ms,
|
|
260
|
+
batch: batch, context: context)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The position and its nearest level, from one call.
|
|
264
|
+
def score_with_level(question, evidence, levels: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
265
|
+
question = ThinkThen.__send__(:keyed, "score", question, :levels, levels)
|
|
266
|
+
crossing("details", question, ThinkThen.__send__(:text_of, evidence), cancel, deadline_ms,
|
|
267
|
+
batch: batch, context: context).map { |json| JSON.parse(json).then { |doc| [doc["value"], doc.dig("answer", "level")] } }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def tag(question, evidence, labels: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
271
|
+
question = ThinkThen.__send__(:keyed, "tag", question, :labels, labels)
|
|
272
|
+
crossing("details", question, ThinkThen.__send__(:text_of, evidence), cancel, deadline_ms,
|
|
273
|
+
batch: batch, context: context).map { |json| JSON.parse(json)["value"] }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The command's --details document for one text.
|
|
277
|
+
def details(question, evidence, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
278
|
+
crossing("details", ThinkThen.__send__(:built, question), ThinkThen.__send__(:text_of, evidence), cancel, deadline_ms,
|
|
279
|
+
batch: batch, context: context).map { |json| JSON.parse(json) }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def choose_many(question, records, options: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
283
|
+
many("choose", question, :options, options, records, cancel, deadline_ms, batch, context)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def score_many(question, records, levels: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
287
|
+
many("score", question, :levels, levels, records, cancel, deadline_ms, batch, context)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def tag_many(question, records, labels: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
291
|
+
many("tag", question, :labels, labels, records, cancel, deadline_ms, batch, context)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# One Hash per record, named by the set's questions. A set member whose
|
|
295
|
+
# `on` names a part reads it from each record as JSON text. With `on:`, each
|
|
296
|
+
# record is a Hash, its `on` value is the evidence, and the answers
|
|
297
|
+
# join its own keys. A question landing on any record's key refuses
|
|
298
|
+
# before any request.
|
|
299
|
+
def annotate(set, records, on: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
300
|
+
set = ThinkThen.set(set) if set.is_a?(String)
|
|
301
|
+
list = records.to_a
|
|
302
|
+
if on
|
|
303
|
+
ThinkThen.__send__(:refuse_clashes, set, list)
|
|
304
|
+
evidence = list.each_with_index.map { |record, place| ThinkThen.__send__(:text_of, record[on], "record #{place}") }
|
|
305
|
+
else
|
|
306
|
+
evidence = ThinkThen.__send__(:texts, list)
|
|
307
|
+
end
|
|
308
|
+
crossing("annotate", set, evidence, cancel, deadline_ms, batch: batch, context: context, batch_ok: true)
|
|
309
|
+
.map do |rows|
|
|
310
|
+
rows.each_with_index.map do |json, place|
|
|
311
|
+
answers = JSON.parse(json).transform_keys(&:to_sym)
|
|
312
|
+
on ? list[place].transform_keys(&:to_sym).merge(answers) : answers
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Find every name in a text and say what kind it is.
|
|
318
|
+
#
|
|
319
|
+
# found = ThinkThen.recognize(text, kinds: %w[person organization place],
|
|
320
|
+
# relations: { works_for: %w[person organization] })
|
|
321
|
+
# found.entities.first.kind # "person"
|
|
322
|
+
def recognize(text, kinds: nil, relations: nil, threshold: nil, relation_threshold: nil, file: nil, cancel: nil, deadline_ms: nil,
|
|
323
|
+
batch: nil, context: nil)
|
|
324
|
+
unless file.nil?
|
|
325
|
+
ThinkThen.__send__(:refuse, "recognize file takes no inline plan options") unless [kinds, relations, threshold, relation_threshold].all?(&:nil?)
|
|
326
|
+
spec = Native.plan_file(ThinkThen.__send__(:file_path, file, "plan"), "recognize")
|
|
327
|
+
else
|
|
328
|
+
spec = JSON.generate(ThinkThen.__send__(:recognize_spec, kinds, relations, threshold, relation_threshold))
|
|
329
|
+
end
|
|
330
|
+
crossing("recognize", spec, ThinkThen.__send__(:text_of, text), cancel, deadline_ms,
|
|
331
|
+
batch: batch, context: context).map { |json| ThinkThen.__send__(:recognized, JSON.parse(json)) }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Say how named entities relate. An entity is a [name, kind] pair, a
|
|
335
|
+
# Hash with name and kind, an Entity, or a RecognizedEntity. A found name,
|
|
336
|
+
# or a Hash with text and no name, is named by its text.
|
|
337
|
+
#
|
|
338
|
+
# edges = ThinkThen.relate([["Ana", "person"], ["Acme", "organization"]],
|
|
339
|
+
# relations: { works_for: %w[person organization] })
|
|
340
|
+
def relate(entities, relations: nil, either: nil, threshold: nil, file: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
|
|
341
|
+
unless file.nil?
|
|
342
|
+
ThinkThen.__send__(:refuse, "relate file takes no inline plan options") unless [relations, either, threshold].all?(&:nil?)
|
|
343
|
+
spec = Native.plan_file(ThinkThen.__send__(:file_path, file, "plan"), "relate")
|
|
344
|
+
else
|
|
345
|
+
ThinkThen.__send__(:refuse, "relate needs at least one relation rule") if relations.nil?
|
|
346
|
+
spec = JSON.generate(ThinkThen.__send__(:relate_spec, relations, either, threshold))
|
|
347
|
+
end
|
|
348
|
+
pairs = entities.to_a.each_with_index.map { |entity, place| ThinkThen.__send__(:pair_of, entity, place) }
|
|
349
|
+
crossing("relate", spec, pairs, cancel, deadline_ms, batch: batch, context: context)
|
|
350
|
+
.map do |rows|
|
|
351
|
+
rows.map do |json|
|
|
352
|
+
edge = JSON.parse(json)
|
|
353
|
+
Edge.new(edge["relation"], ThinkThen.__send__(:entity, edge["source"]), ThinkThen.__send__(:entity, edge["target"]), edge["probability"], edge["either"] == true)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Preview a decide, choose, score, or tag call without asking it: the
|
|
359
|
+
# records, the requests, the prepared bytes, the input-token band, and the
|
|
360
|
+
# first request body, as the result schema's plan object. It reads no key
|
|
361
|
+
# and no cache and sends nothing.
|
|
362
|
+
def plan(question, records, batch: nil, context: nil)
|
|
363
|
+
@native.plan(ThinkThen.__send__(:built, question), ThinkThen.__send__(:texts, records.to_a),
|
|
364
|
+
ThinkThen.__send__(:batch_of, batch), ThinkThen.__send__(:context_of, context))
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# The engine's counters since the process started.
|
|
368
|
+
def usage
|
|
369
|
+
@native.usage
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Set this thread's tick. The watchdog runs it about ten times a second
|
|
373
|
+
# while a call from this thread is in flight. A raise inside it stops the
|
|
374
|
+
# call at once and surfaces from the call. A positional tick with a block
|
|
375
|
+
# scopes the tick to the block's calls.
|
|
376
|
+
def with_tick(tick = nil, &block)
|
|
377
|
+
held = tick || block
|
|
378
|
+
Thread.current[:thinkthen_tick] = held
|
|
379
|
+
return self unless block && tick
|
|
380
|
+
|
|
381
|
+
begin
|
|
382
|
+
yield
|
|
383
|
+
ensure
|
|
384
|
+
Thread.current[:thinkthen_tick] = nil
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
private
|
|
389
|
+
|
|
390
|
+
def many(verb, question, key, members, records, cancel, deadline_ms, batch, context)
|
|
391
|
+
question = ThinkThen.__send__(:keyed, verb, question, key, members)
|
|
392
|
+
crossing("many", question, ThinkThen.__send__(:texts, records.to_a), cancel, deadline_ms,
|
|
393
|
+
batch: batch, context: context, batch_ok: true, context_ok: true)
|
|
394
|
+
.map { |rows| rows.map { |json| JSON.parse(json)["value"] } }
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def crossing(verb, subject, input, cancel, deadline_ms, batch: nil, context: nil, batch_ok: false, context_ok: false)
|
|
398
|
+
unless cancel.nil? || cancel.is_a?(Cancel)
|
|
399
|
+
raise UsageError.new("cancel is a ThinkThen::Cancel or nil", "usage")
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
raise UsageError.new("#{verb} does not take batch", "usage") if !batch.nil? && !batch_ok
|
|
403
|
+
raise UsageError.new("#{verb} does not take context", "usage") if !context.nil? && !context_ok
|
|
404
|
+
batch = ThinkThen.__send__(:batch_of, batch)
|
|
405
|
+
context = ThinkThen.__send__(:context_of, context)
|
|
406
|
+
|
|
407
|
+
deadline_ms = ThinkThen.__send__(:deadline_ms_of, deadline_ms)
|
|
408
|
+
own = Cancel.new
|
|
409
|
+
tick = Thread.current[:thinkthen_tick]
|
|
410
|
+
row = tick && ThinkThen.__send__(:watch, Row.new(tick, own, nil))
|
|
411
|
+
pending = nil
|
|
412
|
+
begin
|
|
413
|
+
raw, facts, details = @native.call(verb, subject, input, own, cancel, deadline_ms, batch, context)
|
|
414
|
+
Call.new(raw, facts, details)
|
|
415
|
+
rescue Interrupt => error
|
|
416
|
+
pending = error
|
|
417
|
+
stopped = CancelledError.new("the call was cancelled", "cancelled")
|
|
418
|
+
ThinkThen.__send__(:attach_receipt, stopped, error)
|
|
419
|
+
raise stopped
|
|
420
|
+
rescue Exception => error
|
|
421
|
+
pending = error
|
|
422
|
+
ThinkThen.__send__(:attach_receipt, error, error)
|
|
423
|
+
raise
|
|
424
|
+
ensure
|
|
425
|
+
if row
|
|
426
|
+
ThinkThen.__send__(:unwatch, row)
|
|
427
|
+
if row.error
|
|
428
|
+
ThinkThen.__send__(:attach_receipt, row.error, pending) if pending
|
|
429
|
+
raise row.error
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
class << self
|
|
437
|
+
# Build a question from the question file's keys: one verb key
|
|
438
|
+
# (decide, choose, score, or tag) and its text, and threshold, options,
|
|
439
|
+
# levels, labels, or meanings. A Range threshold is the band.
|
|
440
|
+
def question(**keywords)
|
|
441
|
+
if keywords.key?(:file)
|
|
442
|
+
raise UsageError.new("question file takes no other question keys", "usage") unless keywords.size == 1
|
|
443
|
+
|
|
444
|
+
return Native.question_file(file_path(keywords[:file], "question"))
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
body = keywords.transform_keys(&:to_s)
|
|
448
|
+
threshold = body["threshold"]
|
|
449
|
+
body["threshold"] = "#{threshold.begin}:#{threshold.end}" if threshold.is_a?(Range)
|
|
450
|
+
Native.question(JSON.generate(body))
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# A question set: a path to a set file, or questions by name.
|
|
454
|
+
def set(path = nil, **questions)
|
|
455
|
+
return Native.set_file(path.to_s) if path
|
|
456
|
+
|
|
457
|
+
body = { "version" => 1, "questions" => {} }
|
|
458
|
+
questions.each do |name, spec|
|
|
459
|
+
body["questions"][name.to_s] = spec.is_a?(Question) ? JSON.parse(spec.json) : spec
|
|
460
|
+
end
|
|
461
|
+
Native.set_json(JSON.generate(body))
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
%i[decide decide_many decide_many_with_probabilities filter rank find choose choose_many score score_many score_with_level
|
|
465
|
+
tag tag_many details annotate recognize relate plan usage].each do |name|
|
|
466
|
+
define_method(name) { |*args, **keywords, &block| default_engine.public_send(name, *args, **keywords, &block) }
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def with_tick(tick = nil, &block)
|
|
470
|
+
default_engine.with_tick(tick, &block)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# YES, NO, or UNSURE for a decide answer: true, false, or nil.
|
|
474
|
+
def outcome(answer)
|
|
475
|
+
{ true => YES, false => NO, nil => UNSURE }.fetch(answer) { refuse("an outcome reads true, false, or nil") }
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# The failure of one annotate answer, `{ "kind" => ..., "cause" => ... }`,
|
|
479
|
+
# or nil for any value. nil is unresolved, never a failure.
|
|
480
|
+
def failed(member)
|
|
481
|
+
return unless member.is_a?(Hash) && member.size == 1
|
|
482
|
+
|
|
483
|
+
held = member["failed"] || member[:failed]
|
|
484
|
+
held if held.is_a?(Hash)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
private
|
|
488
|
+
|
|
489
|
+
def deep_freeze(value)
|
|
490
|
+
case value
|
|
491
|
+
when Hash
|
|
492
|
+
value.each { |key, held| deep_freeze(key); deep_freeze(held) }
|
|
493
|
+
when Array
|
|
494
|
+
value.each { |held| deep_freeze(held) }
|
|
495
|
+
end
|
|
496
|
+
value.freeze
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def batch_of(value)
|
|
500
|
+
return nil if value.nil?
|
|
501
|
+
return value if value == "max" || (value.is_a?(Integer) && value.positive?)
|
|
502
|
+
|
|
503
|
+
refuse("batch takes max or a whole number of at least 1")
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def context_of(value)
|
|
507
|
+
return nil if value.nil?
|
|
508
|
+
refuse("context is nonblank text") unless value.is_a?(String)
|
|
509
|
+
text = text_of(value, "context")
|
|
510
|
+
refuse("context is nonblank text") if text.strip.empty?
|
|
511
|
+
|
|
512
|
+
text
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def attach_receipt(error, source)
|
|
516
|
+
native = source.instance_variable_get(:@thinkthen_completion)
|
|
517
|
+
receipt = native ? Completion.new(native) : source.respond_to?(:completion) ? source.completion : nil
|
|
518
|
+
error.instance_variable_set(:@completion, receipt) if receipt
|
|
519
|
+
unless error.respond_to?(:completion)
|
|
520
|
+
error.define_singleton_method(:completion) { @completion }
|
|
521
|
+
end
|
|
522
|
+
if error.is_a?(Error)
|
|
523
|
+
deep_freeze(error.facts) if error.facts
|
|
524
|
+
deep_freeze(error.details) if error.details
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def attach_account(error, facts, details)
|
|
529
|
+
error.instance_variable_set(:@facts, facts)
|
|
530
|
+
error.instance_variable_set(:@details, details)
|
|
531
|
+
error.define_singleton_method(:facts) { @facts } unless error.respond_to?(:facts)
|
|
532
|
+
error.define_singleton_method(:details) { @details } unless error.respond_to?(:details)
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def default_engine
|
|
536
|
+
@default_mutex.synchronize do
|
|
537
|
+
@default ||= Engine.__send__(:from_native, Native.default_engine)
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def refuse(message)
|
|
542
|
+
raise UsageError.new(message, "usage")
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def file_path(path, role)
|
|
546
|
+
refuse("#{role} file path is valid text") unless path.is_a?(String)
|
|
547
|
+
path = path.dup.force_encoding(Encoding::UTF_8) unless path.encoding == Encoding::UTF_8
|
|
548
|
+
refuse("#{role} file path is valid text") unless path.valid_encoding? && !path.include?("\0") && !path.empty?
|
|
549
|
+
|
|
550
|
+
path
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def text_setting(name, value)
|
|
554
|
+
refuse("#{name} is text or nil") unless value.nil? || value.is_a?(String)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def whole_setting(name, value)
|
|
558
|
+
refuse("#{name} is a whole number or nil") unless value.nil? || value.is_a?(Integer)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# A deadline is whole milliseconds: nil or -1 for none, 0 for spent. The
|
|
562
|
+
# engine rules on the value; this only keeps it a 64-bit whole number.
|
|
563
|
+
def deadline_ms_of(value)
|
|
564
|
+
return nil if value.nil?
|
|
565
|
+
unless value.is_a?(Integer) && value.bit_length < 64
|
|
566
|
+
refuse("deadline_ms is a whole number of milliseconds, or -1 or nil for no deadline")
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
value
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# The text one record crosses as: a String unchanged, anything else its
|
|
573
|
+
# JSON text. nil, invalid UTF-8, and a NUL byte refuse.
|
|
574
|
+
def text_of(record, where = "the evidence")
|
|
575
|
+
refuse("#{where} is nil; a record is text or a JSON value") if record.nil?
|
|
576
|
+
text = record.is_a?(String) ? record : JSON.generate(record)
|
|
577
|
+
text = text.dup.force_encoding(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
|
|
578
|
+
refuse("#{where} is not valid UTF-8") unless text.valid_encoding?
|
|
579
|
+
refuse("#{where} holds a NUL byte") if text.include?("\0")
|
|
580
|
+
text
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def texts(list)
|
|
584
|
+
list.each_with_index.map { |record, place| text_of(record, "record #{place}") }
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# A built question, or a question's own text with its default cut.
|
|
588
|
+
def built(value)
|
|
589
|
+
return value if value.is_a?(Question)
|
|
590
|
+
return Native.question(JSON.generate({ "decide" => value })) if value.is_a?(String)
|
|
591
|
+
|
|
592
|
+
refuse("a question is a built question or its text")
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# rank and find read one question text and no rule.
|
|
596
|
+
def question_text(verb, value)
|
|
597
|
+
return value if value.is_a?(String)
|
|
598
|
+
if value.is_a?(Question)
|
|
599
|
+
spec = JSON.parse(value.json)
|
|
600
|
+
refuse("#{verb} takes a decide question") unless spec.key?("decide")
|
|
601
|
+
extra = spec.keys.find { |key| key != "decide" }
|
|
602
|
+
refuse("#{verb} takes a decide question with no #{extra}") if extra
|
|
603
|
+
return spec.fetch("decide")
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
refuse("a question is a built question or its text")
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# A choose, score, or tag question from its text and members, or a
|
|
610
|
+
# built question that carries its own.
|
|
611
|
+
def keyed(verb, question, key, members)
|
|
612
|
+
if question.is_a?(Question)
|
|
613
|
+
refuse("#{verb}: a built question carries its own #{key}; pass them on the question") if members
|
|
614
|
+
|
|
615
|
+
return question
|
|
616
|
+
end
|
|
617
|
+
body = { verb => question }
|
|
618
|
+
body[key.to_s] = members if members
|
|
619
|
+
Native.question(JSON.generate(body))
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def refuse_clashes(set, list)
|
|
623
|
+
existing = list.flat_map { |record| record.keys.map(&:to_sym) }.uniq
|
|
624
|
+
clash = (set.names.map(&:to_sym) & existing).first
|
|
625
|
+
return unless clash
|
|
626
|
+
|
|
627
|
+
refuse("annotate cannot add a question named '#{clash}': the input already has a key by that name; rename one")
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def recognize_spec(kinds, relations, threshold, relation_threshold)
|
|
631
|
+
kinds = [] if kinds.nil?
|
|
632
|
+
kinds = kinds.to_h { |name| [name.to_s, nil] } if kinds.is_a?(Array)
|
|
633
|
+
body = { "kinds" => kinds.to_h { |name, description| [name.to_s, description] } }
|
|
634
|
+
body["relations"] = relation_rules(relations) if relations
|
|
635
|
+
spec = { "version" => 1, "recognize" => body }
|
|
636
|
+
spec["threshold"] = threshold unless threshold.nil?
|
|
637
|
+
spec["relation_threshold"] = relation_threshold unless relation_threshold.nil?
|
|
638
|
+
spec
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# relations: names (any kind to any kind), a Hash of name to [source,
|
|
642
|
+
# target], or the file's rule Hashes. either: the names that read the
|
|
643
|
+
# same both ways.
|
|
644
|
+
def relate_spec(relations, either, threshold)
|
|
645
|
+
rules = relation_rules(relations)
|
|
646
|
+
both = Array(either).map(&:to_s)
|
|
647
|
+
rules.each { |rule| rule["either"] = true if both.include?(rule["name"]) }
|
|
648
|
+
spec = { "version" => 1, "relate" => { "relations" => rules } }
|
|
649
|
+
spec["threshold"] = threshold unless threshold.nil?
|
|
650
|
+
spec
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def relation_rules(relations)
|
|
654
|
+
relations.map do |name, ends|
|
|
655
|
+
next name.transform_keys(&:to_s) if name.is_a?(Hash)
|
|
656
|
+
next { "name" => name.to_s, "source" => "*", "target" => "*" } if ends.nil?
|
|
657
|
+
|
|
658
|
+
source, target = ends
|
|
659
|
+
{ "name" => name.to_s, "source" => source.to_s, "target" => target.to_s }
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def pair_of(entity, place)
|
|
664
|
+
name, kind = case entity
|
|
665
|
+
when Entity then [entity.name, entity.kind]
|
|
666
|
+
when RecognizedEntity then [entity.text, entity.kind]
|
|
667
|
+
when Hash then [first_of(entity, :name) || first_of(entity, :text), first_of(entity, :kind)]
|
|
668
|
+
when Array then entity
|
|
669
|
+
else refuse("entity #{place} is a [name, kind] pair, a Hash, or an Entity")
|
|
670
|
+
end
|
|
671
|
+
refuse("entity #{place} needs a name and a kind as text") unless name.is_a?(String) && kind.is_a?(String)
|
|
672
|
+
|
|
673
|
+
[text_of(name, "entity #{place}"), text_of(kind, "entity #{place}")]
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def first_of(hash, key)
|
|
677
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def entity(held)
|
|
681
|
+
Entity.new(held["name"], held["kind"])
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def recognized_entity(held)
|
|
685
|
+
RecognizedEntity.new(*held.values_at("text", "start", "end", "length", "kind", "strength"))
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def recognized(held)
|
|
689
|
+
relations = held["relations"]&.map do |one|
|
|
690
|
+
Relation.new(one["relation"], recognized_entity(one["source"]), recognized_entity(one["target"]), one["probability"], one["either"] == true)
|
|
691
|
+
end
|
|
692
|
+
Recognized.new(held.fetch("entities").map { |one| recognized_entity(one) }, relations)
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def watch(row)
|
|
696
|
+
@rows_mutex.synchronize { @rows[row.object_id] = row }
|
|
697
|
+
watchdog
|
|
698
|
+
row
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def unwatch(row)
|
|
702
|
+
@rows_mutex.synchronize { @rows.delete(row.object_id) }
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# One thread runs every in-flight call's tick. A tick that raises keeps
|
|
706
|
+
# its error in the row and fires the call's own token, and the call's
|
|
707
|
+
# next wait slice stops it. The thread starts again after a fork.
|
|
708
|
+
def watchdog
|
|
709
|
+
return if @watchdog&.alive?
|
|
710
|
+
|
|
711
|
+
@watchdog = Thread.new do
|
|
712
|
+
Thread.current.name = "thinkthen-watchdog"
|
|
713
|
+
loop do
|
|
714
|
+
sleep WATCHDOG_INTERVAL
|
|
715
|
+
@rows_mutex.synchronize { @rows.values }.each do |row|
|
|
716
|
+
row.tick.call
|
|
717
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
718
|
+
row.error ||= e
|
|
719
|
+
row.token.cancel
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: thinkthen
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: aarch64-linux
|
|
6
|
+
authors:
|
|
7
|
+
- Ian Maurer
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: The ten thinkthen verbs over the thinkthen engine.
|
|
13
|
+
executables: []
|
|
14
|
+
extensions: []
|
|
15
|
+
extra_rdoc_files: []
|
|
16
|
+
files:
|
|
17
|
+
- lib/thinkthen.rb
|
|
18
|
+
- lib/thinkthen/thinkthen.so
|
|
19
|
+
- lib/thinkthen/version.rb
|
|
20
|
+
homepage: https://thinkthen.dev
|
|
21
|
+
licenses:
|
|
22
|
+
- MIT
|
|
23
|
+
metadata:
|
|
24
|
+
source_code_uri: https://github.com/botassembly/thinkthen
|
|
25
|
+
changelog_uri: https://github.com/botassembly/thinkthen/blob/main/CHANGELOG.md
|
|
26
|
+
bug_tracker_uri: https://github.com/botassembly/thinkthen/issues
|
|
27
|
+
rdoc_options: []
|
|
28
|
+
require_paths:
|
|
29
|
+
- lib
|
|
30
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
31
|
+
requirements:
|
|
32
|
+
- - ">="
|
|
33
|
+
- !ruby/object:Gem::Version
|
|
34
|
+
version: '3.4'
|
|
35
|
+
- - "<"
|
|
36
|
+
- !ruby/object:Gem::Version
|
|
37
|
+
version: '4'
|
|
38
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '0'
|
|
43
|
+
requirements: []
|
|
44
|
+
rubygems_version: 3.6.9
|
|
45
|
+
specification_version: 4
|
|
46
|
+
summary: The thinkthen surface for Ruby
|
|
47
|
+
test_files: []
|