truffler 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +38 -8
- data/lib/generators/truffler/install/templates/migration.rb.tt +10 -0
- data/lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt +19 -0
- data/lib/generators/truffler/upgrade/upgrade_generator.rb +24 -0
- data/lib/tasks/truffler.rake +42 -4
- data/lib/truffler/budget.rb +23 -8
- data/lib/truffler/clients/fake.rb +8 -2
- data/lib/truffler/configuration.rb +2 -1
- data/lib/truffler/definition.rb +35 -2
- data/lib/truffler/errors.rb +11 -1
- data/lib/truffler/jobs/backfill_job.rb +13 -6
- data/lib/truffler/label_definition.rb +14 -6
- data/lib/truffler/labeling/backfill.rb +148 -33
- data/lib/truffler/labeling/labeler.rb +2 -1
- data/lib/truffler/labeling/queue.rb +1 -0
- data/lib/truffler/labeling/supplied.rb +1 -0
- data/lib/truffler/lenses/drafter.rb +2 -1
- data/lib/truffler/model.rb +6 -5
- data/lib/truffler/query_encoding/encoder.rb +89 -14
- data/lib/truffler/records/backfill_spend.rb +51 -0
- data/lib/truffler/search/encoding.rb +9 -6
- data/lib/truffler/search/keystroke.rb +14 -3
- data/lib/truffler/search/query.rb +18 -6
- data/lib/truffler/search/result.rb +4 -2
- data/lib/truffler/search/sql.rb +11 -3
- data/lib/truffler/search/time_phrase.rb +87 -0
- data/lib/truffler/search/time_range.rb +10 -0
- data/lib/truffler/smart_search/starter.rb +3 -3
- data/lib/truffler/version.rb +1 -1
- data/lib/truffler/vocabulary.rb +5 -3
- data/lib/truffler.rb +4 -0
- metadata +6 -1
|
@@ -11,39 +11,84 @@ module Truffler
|
|
|
11
11
|
# done, and records already current are skipped, so a rerun never asks
|
|
12
12
|
# Jev about them again. A spend cap stops the run before a request would
|
|
13
13
|
# exceed it; host-supplied labels cost nothing, so they are still written
|
|
14
|
-
# once the cap is reached.
|
|
14
|
+
# once the cap is reached. The cap counts everything spent under the
|
|
15
|
+
# model's current app-wide vocabulary version, kept in the
|
|
16
|
+
# truffler_backfill_spends ledger, so reruns and overlapping jobs share
|
|
17
|
+
# it; without that table it falls back to this run plus `spent:`. A Jev error releases the claimed rows and ends
|
|
15
18
|
# the run with `:client_error`, so the caller keeps the spend metered so far.
|
|
19
|
+
#
|
|
20
|
+
# A budget denial ends the run with `:budget_denied`, unless the run
|
|
21
|
+
# waits: then it backs off (see .backoff) and retries from the same
|
|
22
|
+
# cursor until it completes, reaches the spend cap, or runs out of
|
|
23
|
+
# `max_duration` seconds, which pauses it with the cursor to resume from.
|
|
16
24
|
class Backfill
|
|
17
|
-
Result = Data.define(:status, :labeled, :requests, :cost, :cursor)
|
|
25
|
+
Result = Data.define(:status, :labeled, :requests, :cost, :cursor, :retry_after)
|
|
26
|
+
|
|
27
|
+
INITIAL_BACKOFF = 1.0
|
|
28
|
+
MAX_BACKOFF = 30.0
|
|
29
|
+
|
|
30
|
+
class_attribute :sleeper, default: ->(seconds) { sleep(seconds) }
|
|
31
|
+
class_attribute :clock, default: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
18
32
|
|
|
19
33
|
STATES = Records::RecordState.table_name
|
|
20
34
|
|
|
21
35
|
class SpendCapReached < StandardError; end
|
|
22
36
|
|
|
23
37
|
# Wraps the client to meter spend per request and refuse a request whose
|
|
24
|
-
# estimated cost would push spend past the cap.
|
|
38
|
+
# estimated cost would push spend past the cap. With a ledger the
|
|
39
|
+
# estimate is reserved in SQL before the request and settled to the
|
|
40
|
+
# reported cost after it, so concurrent meters on one ledger never
|
|
41
|
+
# both take the last of the cap.
|
|
25
42
|
class SpendMeter
|
|
26
|
-
attr_reader :
|
|
43
|
+
attr_reader :requests, :cost
|
|
27
44
|
|
|
28
|
-
def initialize(client, cap:, spent:, config: Truffler.config)
|
|
45
|
+
def initialize(client, cap:, spent:, ledger: nil, config: Truffler.config)
|
|
29
46
|
@client = client
|
|
30
47
|
@cap = cap
|
|
31
48
|
@spent = spent.to_f
|
|
49
|
+
@ledger = ledger
|
|
32
50
|
@requests = 0
|
|
51
|
+
@cost = 0.0
|
|
33
52
|
@config = config
|
|
34
53
|
end
|
|
35
54
|
|
|
36
|
-
def
|
|
37
|
-
|
|
55
|
+
def spent
|
|
56
|
+
@ledger ? @ledger.total : @spent
|
|
57
|
+
end
|
|
38
58
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
def ask(state:, questions:, **options)
|
|
60
|
+
estimate = estimate(state, questions)
|
|
61
|
+
reserve(estimate)
|
|
62
|
+
answers = nil
|
|
63
|
+
begin
|
|
64
|
+
answers = @client.ask(state: state, questions: questions, **options)
|
|
65
|
+
ensure
|
|
66
|
+
@ledger&.settle(-estimate, requests: 0) unless answers
|
|
67
|
+
end
|
|
68
|
+
record(answers.usage&.cost.to_f, estimate)
|
|
42
69
|
answers
|
|
43
70
|
end
|
|
44
71
|
|
|
45
72
|
private
|
|
46
73
|
|
|
74
|
+
def record(cost, estimate)
|
|
75
|
+
@requests += 1
|
|
76
|
+
@cost += cost
|
|
77
|
+
if @ledger
|
|
78
|
+
@ledger.settle(cost - estimate)
|
|
79
|
+
else
|
|
80
|
+
@spent += cost
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def reserve(estimate)
|
|
85
|
+
if @ledger
|
|
86
|
+
raise SpendCapReached unless @ledger.reserve(estimate, @cap)
|
|
87
|
+
elsif @cap && @spent + estimate > @cap
|
|
88
|
+
raise SpendCapReached
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
47
92
|
def estimate(state, questions)
|
|
48
93
|
@config.cost_for(Tokens.estimate({ state: state, questions: questions }))
|
|
49
94
|
end
|
|
@@ -53,40 +98,72 @@ module Truffler
|
|
|
53
98
|
new(model).status
|
|
54
99
|
end
|
|
55
100
|
|
|
101
|
+
# The ledger row for the model's current vocabulary version, or nil
|
|
102
|
+
# when nothing was spent yet or the ledger table is missing.
|
|
103
|
+
def self.spend(model)
|
|
104
|
+
return unless Records::BackfillSpend.available?
|
|
105
|
+
|
|
106
|
+
Records::BackfillSpend.for_model(model).find_by(vocabulary_version: ledger_version(model))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Zeroes the current vocabulary version's ledger in place, so a chain
|
|
110
|
+
# still running keeps its row and continues against the fresh total.
|
|
111
|
+
def self.reset_spend!(model)
|
|
112
|
+
return unless Records::BackfillSpend.available?
|
|
113
|
+
|
|
114
|
+
Records::BackfillSpend.for_model(model).where(vocabulary_version: ledger_version(model))
|
|
115
|
+
.update_all(spent_usd: 0.0, requests: 0, updated_at: Time.current)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.ledger_version(model)
|
|
119
|
+
model.truffler_definition.vocabulary.version(all_users: true)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Seconds to wait after `denials` consecutive budget denials with no
|
|
123
|
+
# work in between: 1, 2, 4, ... capped at MAX_BACKOFF, or the budget's
|
|
124
|
+
# retry hint when that is longer.
|
|
125
|
+
def self.backoff(denials, retry_after = nil)
|
|
126
|
+
[ [ INITIAL_BACKOFF * (2**denials), MAX_BACKOFF ].min, retry_after.to_f ].max
|
|
127
|
+
end
|
|
128
|
+
|
|
56
129
|
attr_reader :model, :batch_size, :page_size
|
|
57
130
|
|
|
58
131
|
def initialize(model, spend_cap: Truffler.config.backfill_spend_cap, batch_size: Truffler.config.batch_size,
|
|
59
132
|
page_size: nil, cursor: nil, spent: 0.0, client: Truffler.config.client, budget: Budget.new)
|
|
60
133
|
@model = model
|
|
134
|
+
model.truffler_definition.validate_columns!
|
|
61
135
|
@batch_size = batch_size
|
|
62
136
|
@page_size = page_size || batch_size * 5
|
|
63
137
|
@cursor = cursor
|
|
64
|
-
@
|
|
138
|
+
@spend_cap = spend_cap
|
|
139
|
+
@spent = spent
|
|
140
|
+
@client = client
|
|
65
141
|
@budget = budget
|
|
66
142
|
@versions = {}
|
|
67
143
|
end
|
|
68
144
|
|
|
69
|
-
|
|
145
|
+
# `progress` is called with the result so far and the delay before each
|
|
146
|
+
# wait; it carries counts, cost, and the cursor, never record text.
|
|
147
|
+
def run(max_pages: nil, wait: false, max_duration: nil, sleeper: self.class.sleeper, clock: self.class.clock,
|
|
148
|
+
progress: nil)
|
|
70
149
|
@labeled = 0
|
|
71
|
-
@
|
|
72
|
-
|
|
73
|
-
|
|
150
|
+
@started_cost = meter.cost
|
|
151
|
+
@pages = 0
|
|
152
|
+
deadline = max_duration && clock.call + max_duration
|
|
153
|
+
denials = 0
|
|
74
154
|
|
|
75
155
|
loop do
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return result(
|
|
156
|
+
before = [ @labeled, meter.requests ]
|
|
157
|
+
status = sweep(max_pages, deadline, clock)
|
|
158
|
+
return result(status) unless wait && status == :budget_denied
|
|
79
159
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return result(stop, cursor) if stop
|
|
84
|
-
end
|
|
85
|
-
end
|
|
160
|
+
denials = 0 unless before == [ @labeled, meter.requests ]
|
|
161
|
+
delay = self.class.backoff(denials, @retry_after)
|
|
162
|
+
return result(:paused) if deadline && clock.call + delay > deadline
|
|
86
163
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
164
|
+
progress&.call(result(status), delay)
|
|
165
|
+
sleeper.call(delay)
|
|
166
|
+
denials += 1
|
|
90
167
|
end
|
|
91
168
|
end
|
|
92
169
|
|
|
@@ -117,13 +194,47 @@ module Truffler
|
|
|
117
194
|
@queue ||= Queue.new(model)
|
|
118
195
|
end
|
|
119
196
|
|
|
197
|
+
# Spend carried in with `spent:` is ignored when the ledger holds it.
|
|
198
|
+
def meter
|
|
199
|
+
@meter ||= begin
|
|
200
|
+
ledger = Records::BackfillSpend.ledger(model, version_for(nil)) if Records::BackfillSpend.available?
|
|
201
|
+
SpendMeter.new(@client, cap: @spend_cap, spent: ledger ? 0.0 : @spent, ledger: ledger)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
120
205
|
def version_for(tenant_key)
|
|
121
206
|
@versions[tenant_key] ||= definition.vocabulary.version(tenant_key: tenant_key, all_users: true)
|
|
122
207
|
end
|
|
123
208
|
|
|
124
|
-
def result(status
|
|
125
|
-
Result.new(status: status, labeled: @labeled, requests:
|
|
126
|
-
cursor: cursor)
|
|
209
|
+
def result(status)
|
|
210
|
+
Result.new(status: status, labeled: @labeled, requests: meter.requests, cost: meter.cost - @started_cost,
|
|
211
|
+
cursor: @cursor, retry_after: (@retry_after if status == :budget_denied))
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Walks pages below @cursor until done or stopped, returning the status.
|
|
215
|
+
def sweep(max_pages, deadline, clock)
|
|
216
|
+
@retry_after = nil
|
|
217
|
+
loop do
|
|
218
|
+
scanned, rows = page(@cursor)
|
|
219
|
+
return complete if scanned.empty?
|
|
220
|
+
return :paused if (max_pages && @pages >= max_pages) || (deadline && clock.call >= deadline)
|
|
221
|
+
|
|
222
|
+
rows.group_by(&:last).each do |tenant_key, tenant_rows|
|
|
223
|
+
tenant_rows.map(&:first).each_slice(batch_size) do |ids|
|
|
224
|
+
stop = label(ids, tenant_key)
|
|
225
|
+
return stop if stop
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
@cursor = scanned.last
|
|
230
|
+
@pages += 1
|
|
231
|
+
return complete if scanned.size < page_size
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def complete
|
|
236
|
+
@cursor = nil
|
|
237
|
+
:complete
|
|
127
238
|
end
|
|
128
239
|
|
|
129
240
|
# Returns the scanned ids (for the cursor) and the [id, tenant_key] rows
|
|
@@ -172,10 +283,14 @@ module Truffler
|
|
|
172
283
|
return if claimed.empty?
|
|
173
284
|
|
|
174
285
|
begin
|
|
175
|
-
Labeler.new(model, client:
|
|
176
|
-
rescue
|
|
286
|
+
Labeler.new(model, client: meter, budget: @budget).label(claimed, priority: :backfill)
|
|
287
|
+
rescue SpendCapReached
|
|
288
|
+
queue.demote(claimed)
|
|
289
|
+
return :spend_cap_reached
|
|
290
|
+
rescue BudgetExhausted => error
|
|
177
291
|
queue.demote(claimed)
|
|
178
|
-
|
|
292
|
+
@retry_after = error.retry_after
|
|
293
|
+
return :budget_denied
|
|
179
294
|
rescue ClientError, IncompleteAnswers => error
|
|
180
295
|
queue.release(claimed, error)
|
|
181
296
|
return :client_error
|
|
@@ -14,6 +14,7 @@ module Truffler
|
|
|
14
14
|
|
|
15
15
|
def initialize(model, client: Truffler.config.client, budget: Budget.new)
|
|
16
16
|
@model = model
|
|
17
|
+
model.truffler_definition.validate_columns!
|
|
17
18
|
@client = client
|
|
18
19
|
@budget = budget
|
|
19
20
|
end
|
|
@@ -49,7 +50,7 @@ module Truffler
|
|
|
49
50
|
retry_failed_supplied(supplier.failed_ids, states_by_id)
|
|
50
51
|
return Result.new(labeled: current.size, requests: index, cost: cost, demoted: true)
|
|
51
52
|
end
|
|
52
|
-
raise BudgetExhausted
|
|
53
|
+
raise BudgetExhausted.new("no Jev budget for #{priority} labeling", retry_after: decision.retry_after) if decision.denied?
|
|
53
54
|
|
|
54
55
|
answers = client.ask(state: request.state, questions: request.questions, priority: decision.priority)
|
|
55
56
|
cost += answers.usage&.cost.to_f
|
|
@@ -84,7 +84,8 @@ module Truffler
|
|
|
84
84
|
# regenerated.
|
|
85
85
|
def vocabulary_for(model, scope, lens)
|
|
86
86
|
definition = model.truffler_definition
|
|
87
|
-
declared = definition.labels.
|
|
87
|
+
declared = definition.labels.select { |_, label| label.available?(scope.tenant_key) }
|
|
88
|
+
.to_h { |key, label| [ key, label.question(scope.tenant_key) ] }
|
|
88
89
|
visible = Lenses.visible_lenses(model, tenant_key: scope.tenant_key, user_digest: scope.user_digest)
|
|
89
90
|
visible = visible.reject { |other| other.id == lens&.id }
|
|
90
91
|
declared.merge(visible.each_with_object({}) { |other, all| all.merge!(other.storage_questions) })
|
data/lib/truffler/model.rb
CHANGED
|
@@ -62,8 +62,9 @@ module Truffler
|
|
|
62
62
|
# outside the watched columns, e.g. from the job that classified it.
|
|
63
63
|
def truffler_refresh_labels!
|
|
64
64
|
definition = self.class.truffler_definition
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
tenant_key = definition.tenant_key_for(self)
|
|
66
|
+
keys = definition.supplied_labels.select { |label| label.available?(tenant_key) }.map(&:key)
|
|
67
|
+
Labeling::Supplied.new(self.class).write([ [ self, keys ] ], tenant_key: tenant_key) if keys.any?
|
|
67
68
|
self
|
|
68
69
|
end
|
|
69
70
|
|
|
@@ -77,10 +78,10 @@ module Truffler
|
|
|
77
78
|
end
|
|
78
79
|
|
|
79
80
|
changed = saved_changes.keys
|
|
80
|
-
if changed.intersect?(
|
|
81
|
+
if changed.intersect?(definition.relabel_columns)
|
|
81
82
|
truffler_expire_labels
|
|
82
83
|
else
|
|
83
|
-
watched = definition.
|
|
84
|
+
watched = definition.labels.values.select { |label| changed.intersect?(label.watch) }
|
|
84
85
|
return if watched.empty?
|
|
85
86
|
|
|
86
87
|
truffler_expire_labels(watched.map(&:key))
|
|
@@ -103,7 +104,7 @@ module Truffler
|
|
|
103
104
|
def truffler_enqueue_embedding
|
|
104
105
|
definition = self.class.truffler_definition
|
|
105
106
|
return unless Embeddings.managed?(definition)
|
|
106
|
-
return unless previously_new_record? || saved_changes.keys.intersect?(
|
|
107
|
+
return unless previously_new_record? || saved_changes.keys.intersect?(definition.relabel_columns)
|
|
107
108
|
|
|
108
109
|
Jobs::EmbedJob.perform_later(self.class.polymorphic_name, id)
|
|
109
110
|
end
|
|
@@ -2,12 +2,21 @@ module Truffler
|
|
|
2
2
|
module QueryEncoding
|
|
3
3
|
# Encodes one pending query (KTD9). It asks a fixed question set: each
|
|
4
4
|
# label gets `filter | boost | ignore`, each choice label also gets its
|
|
5
|
-
# options plus `
|
|
5
|
+
# options plus `Truffler::NO_OPTION`, and each of the first 12 word tokens gets
|
|
6
6
|
# `keyword | label_term | filler`. Exact-text tokens (digits, dates,
|
|
7
7
|
# quoted phrases, emails, identifiers) are keywords decided locally and
|
|
8
8
|
# never asked (R18). Query text travels only in `state` ("query" and
|
|
9
9
|
# "tokens"); a token question names its word by position, `tokens[n]`,
|
|
10
|
-
# so searcher text never lands in an instruction (R8).
|
|
10
|
+
# so searcher text never lands in an instruction (R8). The label
|
|
11
|
+
# vocabulary, with choice option display names, rides along in
|
|
12
|
+
# `state["labels"]` so a token question can tell a word that names a
|
|
13
|
+
# label from text to match.
|
|
14
|
+
#
|
|
15
|
+
# Jev's word roles are then reconciled locally: a keyword that names a
|
|
16
|
+
# label the query applies (its key, a word of its key, the chosen option,
|
|
17
|
+
# or a word of that option's display name, ignoring case and plurals, or
|
|
18
|
+
# sharing its first three letters) becomes a label term, and a common
|
|
19
|
+
# stopword becomes filler.
|
|
11
20
|
#
|
|
12
21
|
# Answers become a `Search::Encoding` with the KTD20 intent vector: boost
|
|
13
22
|
# gives the declared boost, filter narrows and adds `filter_weight`
|
|
@@ -22,10 +31,14 @@ module Truffler
|
|
|
22
31
|
}.freeze
|
|
23
32
|
TOKEN_ROLES = {
|
|
24
33
|
"keyword" => "A word to match in the record text",
|
|
25
|
-
"label_term" => "A word that names one of the labels
|
|
34
|
+
"label_term" => "A word that names one of the labels in `labels`, or one of its options, rather than text to match",
|
|
26
35
|
"filler" => "A word that carries no meaning for the search"
|
|
27
36
|
}.freeze
|
|
28
|
-
NO_OPTION =
|
|
37
|
+
NO_OPTION = Truffler::NO_OPTION
|
|
38
|
+
STOPWORDS = %w[
|
|
39
|
+
a about all an and any are at be by for from have i in is it me my now of on or our please so some that the their
|
|
40
|
+
them there they this to up us was we what when where which who why with you your
|
|
41
|
+
].to_set.freeze
|
|
29
42
|
|
|
30
43
|
Request = Data.define(:state, :questions, :token_ids, :exact_tokens, :unasked_tokens)
|
|
31
44
|
|
|
@@ -54,16 +67,20 @@ module Truffler
|
|
|
54
67
|
criteria: options)
|
|
55
68
|
end
|
|
56
69
|
|
|
57
|
-
words = query.tokens.each_with_index.reject
|
|
70
|
+
words = query.tokens.each_with_index.reject do |token, position|
|
|
71
|
+
query.exact_tokens.include?(token) || query.time_position?(position)
|
|
72
|
+
end
|
|
58
73
|
asked = words.first(MAX_TOKEN_QUESTIONS)
|
|
59
74
|
token_ids = asked.to_h do |_token, position|
|
|
60
75
|
id = :"token__#{position}"
|
|
61
|
-
questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]?
|
|
76
|
+
questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]? The labels it may name, ) +
|
|
77
|
+
%(with their options, are in `labels`.), criteria: TOKEN_ROLES)
|
|
62
78
|
[ position, id.to_s ]
|
|
63
79
|
end
|
|
64
80
|
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
state = { "query" => query.normalized, "tokens" => query.tokens, "labels" => vocabulary_state(labels, tenant_key) }
|
|
82
|
+
Request.new(state: state, questions: questions.to_h, token_ids: token_ids, exact_tokens: query.exact_tokens,
|
|
83
|
+
unasked_tokens: words.drop(MAX_TOKEN_QUESTIONS).map(&:first))
|
|
67
84
|
end
|
|
68
85
|
|
|
69
86
|
# Encodes the query pending under `cache_key`. Returns the encoding, or
|
|
@@ -103,6 +120,7 @@ module Truffler
|
|
|
103
120
|
filters = {}
|
|
104
121
|
boosts = {}
|
|
105
122
|
intent = {}
|
|
123
|
+
terms = []
|
|
106
124
|
labels(model, tenant_key, user_key).each_value do |label|
|
|
107
125
|
key = storage_key(label, answers, tenant_key)
|
|
108
126
|
next unless key
|
|
@@ -113,15 +131,15 @@ module Truffler
|
|
|
113
131
|
intent[key] = label.filter_weight
|
|
114
132
|
when "boost"
|
|
115
133
|
boosts[key] = intent[key] = label.boost || DEFAULT_BOOST
|
|
134
|
+
else next
|
|
116
135
|
end
|
|
136
|
+
terms.concat(label_terms(key, option_name(label, key, tenant_key)))
|
|
117
137
|
end
|
|
118
138
|
|
|
119
|
-
roles = request.token_ids.transform_values { |id| answers.choice(id) }
|
|
120
139
|
query = Search::Query.new(request.state["query"])
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
label_term_tokens = roles.filter_map { |position, role| query.tokens[position] if role == "label_term" }
|
|
140
|
+
roles = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, terms.uniq)
|
|
141
|
+
keyword_tokens = query.tokens.each_index.filter_map { |position| query.tokens[position] if roles[position] == "keyword" }
|
|
142
|
+
label_term_tokens = query.tokens.each_index.filter_map { |position| query.tokens[position] if roles[position] == "label_term" }
|
|
125
143
|
Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent, keyword_tokens: keyword_tokens,
|
|
126
144
|
label_term_tokens: label_term_tokens)
|
|
127
145
|
end
|
|
@@ -162,7 +180,7 @@ module Truffler
|
|
|
162
180
|
end
|
|
163
181
|
|
|
164
182
|
# The label's storage key the query names, or nil for a choice label
|
|
165
|
-
# whose option answer is
|
|
183
|
+
# whose option answer is NO_OPTION.
|
|
166
184
|
def storage_key(label, answers, tenant_key)
|
|
167
185
|
return label.key unless label.type == :choice
|
|
168
186
|
|
|
@@ -170,6 +188,63 @@ module Truffler
|
|
|
170
188
|
"#{label.key}:#{option}" if option != NO_OPTION && label.options(tenant_key).key?(option)
|
|
171
189
|
end
|
|
172
190
|
|
|
191
|
+
def vocabulary_state(labels, tenant_key)
|
|
192
|
+
labels.transform_values do |label|
|
|
193
|
+
entry = { "description" => label.description }
|
|
194
|
+
if label.type == :choice
|
|
195
|
+
options = label.options(tenant_key)
|
|
196
|
+
entry["options"] = options.keys
|
|
197
|
+
names = options.compact
|
|
198
|
+
entry["option_names"] = names if names.any?
|
|
199
|
+
end
|
|
200
|
+
entry
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# {position => role} for every token. Time phrase words are "time";
|
|
205
|
+
# exact tokens and unasked words are keywords; a keyword naming an
|
|
206
|
+
# applied label becomes a label term; stopwords become filler unless
|
|
207
|
+
# they are all that would be left of an encoding that applies nothing.
|
|
208
|
+
def reconcile(query, answered, terms)
|
|
209
|
+
roles = query.tokens.each_index.to_h do |position|
|
|
210
|
+
[ position, query.time_position?(position) ? "time" : answered.fetch(position, "keyword") ]
|
|
211
|
+
end
|
|
212
|
+
words = roles.keys.select { |position| roles[position] == "keyword" && !query.exact_tokens.include?(query.tokens[position]) }
|
|
213
|
+
words.each { |position| roles[position] = "label_term" if names_label?(query.tokens[position], terms) }
|
|
214
|
+
stopwords = words.select { |position| roles[position] == "keyword" && STOPWORDS.include?(query.tokens[position]) }
|
|
215
|
+
return roles if terms.empty? && roles.values.count("keyword") == stopwords.size
|
|
216
|
+
|
|
217
|
+
stopwords.each { |position| roles[position] = "filler" }
|
|
218
|
+
roles
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# "category:billing" names "category" and "billing"; "needs_action"
|
|
222
|
+
# names "needs_action", "needs", and "action". An option's display
|
|
223
|
+
# name adds its words minus stopwords: "p_17" shown as "Spiral writing
|
|
224
|
+
# tool" also names "spiral", "writing", and "tool".
|
|
225
|
+
STEM = 3
|
|
226
|
+
|
|
227
|
+
def label_terms(storage_key, option_name = nil)
|
|
228
|
+
label, option = Search::Encoding.split_key(storage_key)
|
|
229
|
+
keys = [ label.split(":").last, option ].compact.flat_map { |name| [ name.downcase, *name.downcase.split(/[^\p{Alnum}]+/) ] }
|
|
230
|
+
words = option_name.to_s.downcase.split(/[^\p{Alnum}]+/).reject { |word| STOPWORDS.include?(word) }
|
|
231
|
+
(keys + words).reject(&:empty?).map(&:singularize)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def option_name(label, storage_key, tenant_key)
|
|
235
|
+
return unless label.type == :choice
|
|
236
|
+
|
|
237
|
+
label.options(tenant_key)[Search::Encoding.split_key(storage_key).last]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# "angry" names "anger": the same word ignoring plurals, or two words of
|
|
241
|
+
# four letters or more that share their first three letters. Only the
|
|
242
|
+
# applied labels' terms are compared, so the loose match stays safe.
|
|
243
|
+
def names_label?(word, terms)
|
|
244
|
+
word = word.singularize
|
|
245
|
+
terms.any? { |term| term == word || (word.length > STEM && term.length > STEM && word[0, STEM] == term[0, STEM]) }
|
|
246
|
+
end
|
|
247
|
+
|
|
173
248
|
def intent_instructions(label)
|
|
174
249
|
%(How does the search query use the label "#{label.key}" (#{label.description})?)
|
|
175
250
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Records
|
|
3
|
+
# The backfill spend ledger: one row per model and app-wide vocabulary
|
|
4
|
+
# version, so a spend cap holds across runs, reruns, and overlapping
|
|
5
|
+
# BackfillJob chains. Spend is reserved and settled in SQL, never read,
|
|
6
|
+
# added to, and written back.
|
|
7
|
+
class BackfillSpend < ActiveRecord::Base
|
|
8
|
+
self.table_name = "truffler_backfill_spends"
|
|
9
|
+
|
|
10
|
+
scope :for_model, ->(model) { where(record_type: model.polymorphic_name) }
|
|
11
|
+
|
|
12
|
+
# False on apps that upgraded the gem without running
|
|
13
|
+
# `rails g truffler:upgrade`; warns once per process.
|
|
14
|
+
def self.available?
|
|
15
|
+
return true if connection.data_source_exists?(table_name)
|
|
16
|
+
|
|
17
|
+
warn_missing
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.ledger(model, version)
|
|
22
|
+
create_or_find_by!(record_type: model.polymorphic_name, vocabulary_version: version)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.warn_missing
|
|
26
|
+
return if @missing_warned
|
|
27
|
+
|
|
28
|
+
@missing_warned = true
|
|
29
|
+
Truffler.config.logger.warn("[truffler] #{table_name} is missing, so backfill spend caps apply per run only. " \
|
|
30
|
+
"Run `bin/rails g truffler:upgrade && bin/rails db:migrate`.")
|
|
31
|
+
end
|
|
32
|
+
private_class_method :warn_missing
|
|
33
|
+
|
|
34
|
+
# Adds `amount` unless that would pass `cap`; true when reserved.
|
|
35
|
+
def reserve(amount, cap)
|
|
36
|
+
scope = self.class.where(id: id)
|
|
37
|
+
scope = scope.where("spent_usd + ? <= ?", amount, cap) if cap
|
|
38
|
+
scope.update_all([ "spent_usd = spent_usd + ?, updated_at = ?", amount, Time.current ]) == 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def settle(amount, requests: 1)
|
|
42
|
+
self.class.where(id: id)
|
|
43
|
+
.update_all([ "spent_usd = spent_usd + ?, requests = requests + ?, updated_at = ?", amount, requests, Time.current ])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def total
|
|
47
|
+
self.class.where(id: id).pick(:spent_usd).to_f
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -12,7 +12,9 @@ module Truffler
|
|
|
12
12
|
# - `keyword_tokens`: query tokens for the keyword source; nil means
|
|
13
13
|
# every token that is not a label term.
|
|
14
14
|
# - `label_term_tokens`: tokens that named a label rather than a keyword.
|
|
15
|
-
|
|
15
|
+
# - `time`: the query's `TimeRange`, resolved at search time and never
|
|
16
|
+
# cached, since "today" moves.
|
|
17
|
+
Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens, :time) do
|
|
16
18
|
def self.load(value, query)
|
|
17
19
|
return value if value.is_a?(self)
|
|
18
20
|
return if value.nil?
|
|
@@ -23,12 +25,12 @@ module Truffler
|
|
|
23
25
|
label_term_tokens: Array(value["label_term_positions"]).filter_map { |position| tokens[position] })
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [])
|
|
28
|
+
def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [], time: nil)
|
|
27
29
|
filters = weights(filters)
|
|
28
30
|
boosts = weights(boosts)
|
|
29
31
|
intent_vector = weights(intent_vector || boosts).reject { |_, weight| weight.zero? }
|
|
30
32
|
super(filters: filters.freeze, boosts: boosts.freeze, intent_vector: intent_vector.freeze,
|
|
31
|
-
keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze)
|
|
33
|
+
keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze, time: time)
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def empty?
|
|
@@ -36,13 +38,14 @@ module Truffler
|
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# The encoding minus the chips the searcher removed (R20), matched by
|
|
39
|
-
# storage key or by label key.
|
|
41
|
+
# storage key or by label key; "time" removes the time range.
|
|
40
42
|
def without(suppressed)
|
|
41
43
|
suppressed = Array(suppressed).map(&:to_s).to_set
|
|
42
44
|
return self if suppressed.empty?
|
|
43
45
|
|
|
44
46
|
keep = ->(key, _) { !suppressed.include?(key) && !suppressed.include?(self.class.split_key(key).first) }
|
|
45
|
-
with(filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep)
|
|
47
|
+
with(filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep),
|
|
48
|
+
time: (time unless suppressed.include?(TimeRange.key)))
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
# Splits a storage key into its label key and choice option. Lens keys
|
|
@@ -58,7 +61,7 @@ module Truffler
|
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def keywords(query)
|
|
61
|
-
keyword_tokens || (query.
|
|
64
|
+
keyword_tokens || (query.search_tokens - label_term_tokens)
|
|
62
65
|
end
|
|
63
66
|
|
|
64
67
|
# The cache form: decisions plus token positions in the normalized
|
|
@@ -10,9 +10,10 @@ module Truffler
|
|
|
10
10
|
attr_reader :model, :query, :tenant_key, :scope, :user_key, :suppressed, :surface, :limit
|
|
11
11
|
|
|
12
12
|
def initialize(model, query, tenant:, scope:, user: nil, suppressed: [], surface: nil, limit: DEFAULT_LIMIT, weights: {},
|
|
13
|
-
cache: EncodingCache.new)
|
|
13
|
+
cache: EncodingCache.new, clock: -> { Time.current })
|
|
14
14
|
@model = model
|
|
15
15
|
@definition = model.try(:truffler_definition) || raise(DefinitionError, "#{model.name} has no truffler declaration")
|
|
16
|
+
@definition.validate_columns!
|
|
16
17
|
@query = Query.wrap(query)
|
|
17
18
|
@tenant_key = tenant&.to_s
|
|
18
19
|
@scope = scope.nil? && !@definition.scoped? ? model.all : scope
|
|
@@ -22,6 +23,7 @@ module Truffler
|
|
|
22
23
|
@limit = limit
|
|
23
24
|
@weights = @definition.ranking.merge(weights.to_h { |key, weight| [ key.to_sym, Float(weight) ] })
|
|
24
25
|
@cache = cache
|
|
26
|
+
@clock = clock
|
|
25
27
|
check_scope!
|
|
26
28
|
end
|
|
27
29
|
|
|
@@ -39,7 +41,7 @@ module Truffler
|
|
|
39
41
|
explicit_action = surface_action
|
|
40
42
|
cached = read_encoding
|
|
41
43
|
status = encoding_status(cached)
|
|
42
|
-
encoding = visible_lenses_only(cached&.without(suppressed), record_usage: true)
|
|
44
|
+
encoding = with_time(visible_lenses_only(cached&.without(suppressed), record_usage: true))
|
|
43
45
|
sql = sql(encoding)
|
|
44
46
|
records = sql.relation(scope, limit: limit).to_a
|
|
45
47
|
result = Result.new(records: records, query: query, encoding: encoding, encoding_status: status, watermark: watermark,
|
|
@@ -52,7 +54,7 @@ module Truffler
|
|
|
52
54
|
# How many records the same search would return that arrived after
|
|
53
55
|
# `since` (R25). Reads the cache only and never prefetches.
|
|
54
56
|
def count(since:)
|
|
55
|
-
sql(visible_lenses_only(read_encoding&.without(suppressed))).candidates(scope)
|
|
57
|
+
sql(with_time(visible_lenses_only(read_encoding&.without(suppressed)))).candidates(scope)
|
|
56
58
|
.where(model.arel_table[@definition.arrived_at_column].gt(since)).count
|
|
57
59
|
end
|
|
58
60
|
|
|
@@ -91,6 +93,15 @@ module Truffler
|
|
|
91
93
|
encoding.without(hidden)
|
|
92
94
|
end
|
|
93
95
|
|
|
96
|
+
# The query's time phrase, resolved on this search's clock, unless the
|
|
97
|
+
# searcher removed its chip.
|
|
98
|
+
def with_time(encoding)
|
|
99
|
+
phrase = query.time_phrase
|
|
100
|
+
return encoding if phrase.nil? || suppressed.include?(TimeRange.key)
|
|
101
|
+
|
|
102
|
+
(encoding || Encoding.new).with(time: phrase.range(@clock.call))
|
|
103
|
+
end
|
|
104
|
+
|
|
94
105
|
def lens_id(key)
|
|
95
106
|
prefix, id = key.split(":", 3)
|
|
96
107
|
Integer(id, exception: false) if prefix == Lenses::KEY_PREFIX
|