truffler 0.1.3 → 0.1.5
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 +31 -0
- data/README.md +77 -10
- data/bench/cassettes/1037e0b63ba790c02d14d0ddfc18b68151c11d03ce88d764658044b6bab2e55b.json +1 -0
- data/bench/cassettes/19bba849a846d30a69dc385a337d67704fbe5194506fd3c914d3270d86495fde.json +1 -0
- data/bench/cassettes/3768847dc57df8bf2328771e7763c0b8bd013a167244e7849967dac144420601.json +1 -0
- data/lib/generators/truffler/install/templates/migration.rb.tt +6 -3
- data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +25 -0
- data/lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt +32 -0
- data/lib/generators/truffler/upgrade/upgrade_generator.rb +49 -3
- data/lib/tasks/truffler.rake +38 -13
- data/lib/truffler/benchmark/runner.rb +1 -1
- data/lib/truffler/clients/evaluator.rb +44 -0
- data/lib/truffler/configuration.rb +12 -0
- data/lib/truffler/definition.rb +52 -0
- data/lib/truffler/embeddings/backfill.rb +40 -11
- data/lib/truffler/embeddings/label_vector.rb +1 -1
- data/lib/truffler/embeddings/neighbor_store.rb +33 -7
- data/lib/truffler/embeddings/vector_store.rb +9 -3
- data/lib/truffler/jobs/backfill_job.rb +14 -5
- data/lib/truffler/jobs/embed_job.rb +2 -0
- data/lib/truffler/jobs/label_flush_job.rb +4 -2
- data/lib/truffler/jobs/resume_job.rb +22 -9
- data/lib/truffler/label_definition.rb +55 -12
- data/lib/truffler/labeling/backfill.rb +100 -37
- data/lib/truffler/labeling/labeler.rb +20 -5
- data/lib/truffler/labeling/queue.rb +14 -9
- data/lib/truffler/labeling/supplied.rb +16 -5
- data/lib/truffler/lenses/backfill.rb +28 -10
- data/lib/truffler/lenses/lens_label.rb +7 -0
- data/lib/truffler/model.rb +5 -0
- data/lib/truffler/providers/backup.rb +1 -1
- data/lib/truffler/query_encoding/encoder.rb +56 -39
- data/lib/truffler/records/backfill_spend.rb +30 -6
- data/lib/truffler/redaction.rb +1 -1
- data/lib/truffler/search/encoding.rb +50 -12
- data/lib/truffler/search/encoding_cache.rb +4 -3
- data/lib/truffler/search/filler.rb +73 -0
- data/lib/truffler/search/keystroke.rb +29 -15
- data/lib/truffler/search/result.rb +11 -1
- data/lib/truffler/search/sql.rb +93 -15
- data/lib/truffler/smart_search/dispatcher.rb +1 -1
- data/lib/truffler/smart_search/starter.rb +1 -1
- data/lib/truffler/version.rb +1 -1
- data/lib/truffler/vocabulary.rb +13 -3
- metadata +8 -1
|
@@ -16,7 +16,7 @@ module Truffler
|
|
|
16
16
|
# label the query applies (its key, a word of its key, the chosen option,
|
|
17
17
|
# or a word of that option's display name, ignoring case and plurals, or
|
|
18
18
|
# sharing its first three letters) becomes a label term, and a common
|
|
19
|
-
# stopword becomes filler.
|
|
19
|
+
# stopword or `config.filler_words` noun becomes filler.
|
|
20
20
|
#
|
|
21
21
|
# Answers become a `Search::Encoding` with the KTD20 intent vector: boost
|
|
22
22
|
# gives the declared boost, filter narrows and adds `filter_weight`
|
|
@@ -35,10 +35,7 @@ module Truffler
|
|
|
35
35
|
"filler" => "A word that carries no meaning for the search"
|
|
36
36
|
}.freeze
|
|
37
37
|
NO_OPTION = Truffler::NO_OPTION
|
|
38
|
-
STOPWORDS =
|
|
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
|
|
38
|
+
STOPWORDS = Search::Filler::STOPWORDS
|
|
42
39
|
|
|
43
40
|
Request = Data.define(:state, :questions, :token_ids, :exact_tokens, :unasked_tokens)
|
|
44
41
|
|
|
@@ -120,8 +117,7 @@ module Truffler
|
|
|
120
117
|
filters = {}
|
|
121
118
|
boosts = {}
|
|
122
119
|
intent = {}
|
|
123
|
-
|
|
124
|
-
terms = []
|
|
120
|
+
names = {}
|
|
125
121
|
labels(model, tenant_key, user_key).each_value do |label|
|
|
126
122
|
key = storage_key(label, answers, tenant_key)
|
|
127
123
|
next unless key
|
|
@@ -134,16 +130,18 @@ module Truffler
|
|
|
134
130
|
boosts[key] = intent[key] = label.boost || DEFAULT_BOOST
|
|
135
131
|
else next
|
|
136
132
|
end
|
|
137
|
-
|
|
138
|
-
terms.concat(name_terms(option_name(label, key, tenant_key)))
|
|
133
|
+
names[key] = [ label_terms(key), name_terms(option_name(label, key, tenant_key)) ]
|
|
139
134
|
end
|
|
140
135
|
|
|
141
136
|
query = Search::Query.new(request.state["query"])
|
|
142
|
-
roles = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) },
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent,
|
|
146
|
-
|
|
137
|
+
roles, sources, soft = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, names,
|
|
138
|
+
Search::Filler.label_words(model.truffler_definition, tenant_key))
|
|
139
|
+
tokens = ->(positions) { positions.map { |position| query.tokens[position] } }
|
|
140
|
+
Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent,
|
|
141
|
+
keyword_tokens: tokens.call(roles.keys.select { |position| roles[position] == "keyword" }),
|
|
142
|
+
label_term_tokens: tokens.call(sources.keys), soft_keyword_tokens: tokens.call(soft).uniq,
|
|
143
|
+
filler_tokens: tokens.call(roles.keys.select { |position| roles[position] == "filler" }).uniq,
|
|
144
|
+
label_term_sources: sources.group_by { |position, _| query.tokens[position] }.transform_values { |pairs| pairs.flat_map(&:last).uniq })
|
|
147
145
|
end
|
|
148
146
|
|
|
149
147
|
private
|
|
@@ -194,35 +192,46 @@ module Truffler
|
|
|
194
192
|
labels.transform_values do |label|
|
|
195
193
|
entry = { "description" => label.description }
|
|
196
194
|
if label.type == :choice
|
|
197
|
-
options = label.options(tenant_key)
|
|
198
|
-
|
|
199
|
-
names = options.compact
|
|
195
|
+
entry["options"] = label.options(tenant_key).keys
|
|
196
|
+
names = label.option_names(tenant_key)
|
|
200
197
|
entry["option_names"] = names if names.any?
|
|
201
198
|
end
|
|
202
199
|
entry
|
|
203
200
|
end
|
|
204
201
|
end
|
|
205
202
|
|
|
206
|
-
# {position => role} for every token
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
|
|
203
|
+
# Returns `[roles, sources, soft]`: {position => role} for every token,
|
|
204
|
+
# {label-term position => applied storage keys it names}, and the
|
|
205
|
+
# label-term positions that named a key only by prefix. Time phrase
|
|
206
|
+
# words are "time"; exact tokens and unasked words are keywords; a
|
|
207
|
+
# keyword naming an applied label becomes a label term; stopwords and
|
|
208
|
+
# filler words become filler unless they are all that would be left of
|
|
209
|
+
# an encoding that applies no label and no time range (Search::Filler).
|
|
210
|
+
# A word naming any declared label (`keep`, see Filler.label_words) is
|
|
211
|
+
# never filler, even when Jev calls it that. A word Jev called a label
|
|
212
|
+
# term that names no applied label locally is sourced to every applied
|
|
213
|
+
# label.
|
|
214
|
+
def reconcile(query, answered, names, keep)
|
|
211
215
|
roles = query.tokens.each_index.to_h do |position|
|
|
212
|
-
|
|
216
|
+
role = query.time_position?(position) ? "time" : answered.fetch(position, "keyword")
|
|
217
|
+
[ position, role == "filler" && keep.include?(query.tokens[position].singularize) ? "keyword" : role ]
|
|
213
218
|
end
|
|
219
|
+
matches = roles.keys.to_h { |position| [ position, roles[position] == "time" ? {} : label_matches(query.tokens[position], names) ] }
|
|
214
220
|
words = roles.keys.select { |position| roles[position] == "keyword" && !query.exact_tokens.include?(query.tokens[position]) }
|
|
215
|
-
words.each { |position| roles[position] = "label_term" if
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
roles
|
|
221
|
+
words.each { |position| roles[position] = "label_term" if matches[position].any? }
|
|
222
|
+
filler = words.select { |position| roles[position] == "keyword" && Search::Filler.word?(query.tokens[position], keep: keep) }
|
|
223
|
+
Search::Filler.drop(filler, keyword_count: roles.values.count("keyword"), anchored: names.any? || !query.time_phrase.nil?,
|
|
224
|
+
stopword: ->(position) { Search::Filler.stopword?(query.tokens[position]) })
|
|
225
|
+
.each { |position| roles[position] = "filler" }
|
|
226
|
+
label_terms = roles.keys.select { |position| roles[position] == "label_term" }
|
|
227
|
+
sources = label_terms.to_h { |position| [ position, matches[position].keys.presence || names.keys ] }
|
|
228
|
+
soft = (words & label_terms).select { |position| matches[position].values.all?(:prefix) }
|
|
229
|
+
[ roles, sources, soft ]
|
|
221
230
|
end
|
|
222
231
|
|
|
223
232
|
# "category:billing" names "category" and "billing"; "needs_action"
|
|
224
233
|
# names "needs_action", "needs", and "action". These key terms also
|
|
225
|
-
# match by shared stem (see
|
|
234
|
+
# match by shared stem (see label_matches).
|
|
226
235
|
STEM = 3
|
|
227
236
|
|
|
228
237
|
def label_terms(storage_key)
|
|
@@ -231,8 +240,9 @@ module Truffler
|
|
|
231
240
|
.reject(&:empty?).map(&:singularize)
|
|
232
241
|
end
|
|
233
242
|
|
|
234
|
-
# An option's display name
|
|
235
|
-
#
|
|
243
|
+
# An option's display name (its search text, else its description) adds
|
|
244
|
+
# its words minus stopwords: "p_17" shown as "Spiral writing tool" names
|
|
245
|
+
# "spiral", "writing", and "tool". These
|
|
236
246
|
# match exactly only: descriptions are prose, and a stem match on them
|
|
237
247
|
# would swallow ordinary search words ("chat" against "charge").
|
|
238
248
|
def name_terms(option_name)
|
|
@@ -242,16 +252,23 @@ module Truffler
|
|
|
242
252
|
def option_name(label, storage_key, tenant_key)
|
|
243
253
|
return unless label.type == :choice
|
|
244
254
|
|
|
245
|
-
label.
|
|
255
|
+
label.option_names(tenant_key)[Search::Encoding.split_key(storage_key).last]
|
|
246
256
|
end
|
|
247
257
|
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
|
|
258
|
+
# {storage key => :exact or :prefix} for the applied labels `word`
|
|
259
|
+
# names. "angry" names "anger": the same word ignoring plurals is
|
|
260
|
+
# :exact; against a label or option key, two words of four letters or
|
|
261
|
+
# more that share their first three letters are :prefix. Display-name
|
|
262
|
+
# words match exactly only.
|
|
263
|
+
def label_matches(word, names)
|
|
252
264
|
word = word.singularize
|
|
253
|
-
|
|
254
|
-
|
|
265
|
+
names.each_with_object({}) do |(key, (stems, terms)), matches|
|
|
266
|
+
if terms.include?(word) || stems.include?(word)
|
|
267
|
+
matches[key] = :exact
|
|
268
|
+
elsif word.length > STEM && stems.any? { |term| term.length > STEM && word[0, STEM] == term[0, STEM] }
|
|
269
|
+
matches[key] = :prefix
|
|
270
|
+
end
|
|
271
|
+
end
|
|
255
272
|
end
|
|
256
273
|
|
|
257
274
|
def intent_instructions(label)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
module Truffler
|
|
2
2
|
module Records
|
|
3
|
-
# The backfill spend ledger: one row per model
|
|
4
|
-
# version, so a spend cap holds across
|
|
5
|
-
# BackfillJob chains. Spend is reserved and
|
|
6
|
-
# added to, and written back.
|
|
3
|
+
# The backfill spend ledger: one row per model, tenant (nil for the
|
|
4
|
+
# app-wide ledger), and vocabulary version, so a spend cap holds across
|
|
5
|
+
# runs, reruns, and overlapping BackfillJob chains. Spend is reserved and
|
|
6
|
+
# settled in SQL, never read, added to, and written back.
|
|
7
7
|
class BackfillSpend < ActiveRecord::Base
|
|
8
8
|
self.table_name = "truffler_backfill_spends"
|
|
9
9
|
|
|
@@ -18,10 +18,34 @@ module Truffler
|
|
|
18
18
|
false
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def self.
|
|
22
|
-
|
|
21
|
+
def self.for_ledger(model, tenant_key)
|
|
22
|
+
tenant_ledgers? ? for_model(model).where(tenant_key: tenant_key) : for_model(model)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Before `rails g truffler:upgrade` adds tenant_key, every tenant
|
|
26
|
+
# shares the app-wide row.
|
|
27
|
+
def self.ledger(model, version, tenant_key: nil)
|
|
28
|
+
attributes = { record_type: model.polymorphic_name, vocabulary_version: version }
|
|
29
|
+
attributes[:tenant_key] = tenant_key if tenant_ledgers?
|
|
30
|
+
create_or_find_by!(attributes)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.tenant_ledgers?
|
|
34
|
+
return true if column_names.include?("tenant_key")
|
|
35
|
+
|
|
36
|
+
warn_missing_tenant_key
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.warn_missing_tenant_key
|
|
41
|
+
return if @missing_tenant_warned
|
|
42
|
+
|
|
43
|
+
@missing_tenant_warned = true
|
|
44
|
+
Truffler.config.logger.warn("[truffler] #{table_name}.tenant_key is missing, so backfill spend caps are app-wide. " \
|
|
45
|
+
"Run `bin/rails g truffler:upgrade && bin/rails db:migrate`.")
|
|
46
|
+
end
|
|
47
|
+
private_class_method :warn_missing_tenant_key
|
|
48
|
+
|
|
25
49
|
def self.warn_missing
|
|
26
50
|
return if @missing_warned
|
|
27
51
|
|
data/lib/truffler/redaction.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Truffler
|
|
|
6
6
|
module Redaction
|
|
7
7
|
KEYS = %i[
|
|
8
8
|
priority model cost input_tokens tokens_estimated latency_ms error_class status outcome reason
|
|
9
|
-
record_type tenant_key user_key surface section sources vocabulary_version label_key
|
|
9
|
+
record_type tenant_key user_key surface section sources vocabulary_version label_key permanent
|
|
10
10
|
].to_set.freeze
|
|
11
11
|
SUFFIXES = %w[_id _ids _count _digest _ms].freeze
|
|
12
12
|
|
|
@@ -10,11 +10,20 @@ module Truffler
|
|
|
10
10
|
# for the label term SUM(weight * value). Defaults to `boosts`; the
|
|
11
11
|
# encoder decides whether filtered labels also carry weight here.
|
|
12
12
|
# - `keyword_tokens`: query tokens for the keyword source; nil means
|
|
13
|
-
# every token that is not a label term.
|
|
13
|
+
# every token that is not a label term or filler.
|
|
14
14
|
# - `label_term_tokens`: tokens that named a label rather than a keyword.
|
|
15
|
+
# - `label_term_sources`: label-term token => the applied storage keys it
|
|
16
|
+
# named. Once the searcher removes every one of them, the token is a
|
|
17
|
+
# keyword again.
|
|
18
|
+
# - `soft_keyword_tokens`: label terms that named a label or option key
|
|
19
|
+
# only by shared prefix ("urgently" for urgent). They add a small
|
|
20
|
+
# keyword score and are never required.
|
|
21
|
+
# - `filler_tokens`: stopwords and filler words the encoder dropped.
|
|
22
|
+
# Removing the chips that justified the drop brings filler nouns back.
|
|
15
23
|
# - `time`: the query's `TimeRange`, resolved at search time and never
|
|
16
24
|
# cached, since "today" moves.
|
|
17
|
-
Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens, :
|
|
25
|
+
Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens, :label_term_sources,
|
|
26
|
+
:soft_keyword_tokens, :filler_tokens, :time) do
|
|
18
27
|
def self.load(value, query)
|
|
19
28
|
return value if value.is_a?(self)
|
|
20
29
|
return if value.nil?
|
|
@@ -22,15 +31,22 @@ module Truffler
|
|
|
22
31
|
tokens = query.tokens
|
|
23
32
|
new(filters: value["filters"], boosts: value["boosts"], intent_vector: value["intent_vector"],
|
|
24
33
|
keyword_tokens: value["keyword_positions"]&.map { |position| tokens[position] }&.compact,
|
|
25
|
-
label_term_tokens: Array(value["label_term_positions"]).filter_map { |position| tokens[position] }
|
|
34
|
+
label_term_tokens: Array(value["label_term_positions"]).filter_map { |position| tokens[position] },
|
|
35
|
+
label_term_sources: Array(value["label_term_sources"]).filter_map { |position, keys| [ tokens[position], keys ] if tokens[position] },
|
|
36
|
+
soft_keyword_tokens: Array(value["soft_keyword_positions"]).filter_map { |position| tokens[position] },
|
|
37
|
+
filler_tokens: Array(value["filler_positions"]).filter_map { |position| tokens[position] })
|
|
26
38
|
end
|
|
27
39
|
|
|
28
|
-
def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [],
|
|
40
|
+
def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [], label_term_sources: {},
|
|
41
|
+
soft_keyword_tokens: [], filler_tokens: [], time: nil)
|
|
29
42
|
filters = weights(filters)
|
|
30
43
|
boosts = weights(boosts)
|
|
31
44
|
intent_vector = weights(intent_vector || boosts).reject { |_, weight| weight.zero? }
|
|
45
|
+
sources = label_term_sources.to_h { |token, keys| [ token.to_s, Array(keys).map(&:to_s).freeze ] }
|
|
32
46
|
super(filters: filters.freeze, boosts: boosts.freeze, intent_vector: intent_vector.freeze,
|
|
33
|
-
keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze,
|
|
47
|
+
keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze,
|
|
48
|
+
label_term_sources: sources.freeze, soft_keyword_tokens: Array(soft_keyword_tokens).map(&:to_s).freeze,
|
|
49
|
+
filler_tokens: Array(filler_tokens).map(&:to_s).freeze, time: time)
|
|
34
50
|
end
|
|
35
51
|
|
|
36
52
|
def empty?
|
|
@@ -38,14 +54,26 @@ module Truffler
|
|
|
38
54
|
end
|
|
39
55
|
|
|
40
56
|
# The encoding minus the chips the searcher removed (R20), matched by
|
|
41
|
-
# storage key or by label key; "time" removes the time range.
|
|
42
|
-
|
|
57
|
+
# storage key or by label key; "time" removes the time range. A label
|
|
58
|
+
# term whose every source label is gone becomes a keyword again. `keep_words`
|
|
59
|
+
# (a set, or a callable returning one) holds the words that are never
|
|
60
|
+
# filler, as in `keywords`.
|
|
61
|
+
def without(suppressed, keep_words: nil)
|
|
43
62
|
suppressed = Array(suppressed).map(&:to_s).to_set
|
|
44
63
|
return self if suppressed.empty?
|
|
45
64
|
|
|
46
65
|
keep = ->(key, _) { !suppressed.include?(key) && !suppressed.include?(self.class.split_key(key).first) }
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
kept = { filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep) }
|
|
67
|
+
applied = kept.values.flat_map(&:keys).to_set
|
|
68
|
+
freed = label_term_sources.select { |_, keys| keys.any? && keys.none? { |key| applied.include?(key) } }.keys
|
|
69
|
+
kept_time = (time unless suppressed.include?(TimeRange.key))
|
|
70
|
+
keywords = keyword_tokens && (keyword_tokens + freed).uniq
|
|
71
|
+
if keywords && applied.empty? && kept_time.nil?
|
|
72
|
+
keywords = Filler.keywords(keywords + filler_tokens, anchored: false, keep: keep_words.respond_to?(:call) ? keep_words.call : keep_words)
|
|
73
|
+
end
|
|
74
|
+
with(**kept, time: kept_time, label_term_tokens: label_term_tokens - freed,
|
|
75
|
+
label_term_sources: label_term_sources.except(*freed), soft_keyword_tokens: soft_keyword_tokens - freed,
|
|
76
|
+
keyword_tokens: keywords)
|
|
49
77
|
end
|
|
50
78
|
|
|
51
79
|
# Splits a storage key into its label key and choice option. Lens keys
|
|
@@ -60,8 +88,13 @@ module Truffler
|
|
|
60
88
|
end
|
|
61
89
|
end
|
|
62
90
|
|
|
63
|
-
|
|
64
|
-
|
|
91
|
+
# Without encoder decisions (a cold cache), every search token that is
|
|
92
|
+
# not a label term, minus filler words (see Filler). `keep` is called
|
|
93
|
+
# only then, for the words that are never filler.
|
|
94
|
+
def keywords(query, keep: nil)
|
|
95
|
+
keyword_tokens ||
|
|
96
|
+
Filler.keywords(query.search_tokens - label_term_tokens, anchored: !empty? || !time.nil?, exact: query.exact_tokens,
|
|
97
|
+
keep: keep&.call)
|
|
65
98
|
end
|
|
66
99
|
|
|
67
100
|
# The cache form: decisions plus token positions in the normalized
|
|
@@ -70,7 +103,12 @@ module Truffler
|
|
|
70
103
|
def dump(query)
|
|
71
104
|
{ "filters" => filters, "boosts" => boosts, "intent_vector" => intent_vector,
|
|
72
105
|
"keyword_positions" => keyword_tokens && positions(query, keyword_tokens),
|
|
73
|
-
"label_term_positions" => positions(query, label_term_tokens)
|
|
106
|
+
"label_term_positions" => positions(query, label_term_tokens),
|
|
107
|
+
"label_term_sources" => query.tokens.each_index.filter_map do |position|
|
|
108
|
+
[ position, label_term_sources[query.tokens[position]] ] if label_term_sources.key?(query.tokens[position])
|
|
109
|
+
end,
|
|
110
|
+
"soft_keyword_positions" => positions(query, soft_keyword_tokens),
|
|
111
|
+
"filler_positions" => positions(query, filler_tokens) }
|
|
74
112
|
end
|
|
75
113
|
|
|
76
114
|
private
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
module Truffler
|
|
2
2
|
module Search
|
|
3
3
|
# Where keystroke search finds query encodings and query vectors (R12,
|
|
4
|
-
# R15). Keys digest the model, the normalized query, and the vocabulary
|
|
5
|
-
# version
|
|
4
|
+
# R15). Keys digest the model, the normalized query, and the vocabulary's
|
|
5
|
+
# encoding version (labeling fingerprints plus descriptions and option
|
|
6
|
+
# search texts), plus the tenant when the vocabulary has per-tenant choices.
|
|
6
7
|
# Values hold decisions and floats, never query text. Encoding keys use
|
|
7
8
|
# the searcher's vocabulary version, which holds the lenses that searcher
|
|
8
9
|
# can see (KTD21), so a personal lens's encoding is never shared; query
|
|
@@ -60,7 +61,7 @@ module Truffler
|
|
|
60
61
|
def digest(model, query, tenant_key, user_key)
|
|
61
62
|
definition = model.truffler_definition
|
|
62
63
|
tenant = tenant_key&.to_s if definition.per_tenant_vocabulary?
|
|
63
|
-
version = definition.vocabulary.
|
|
64
|
+
version = definition.vocabulary.encoding_version(tenant_key: tenant_key&.to_s, user_key: user_key)
|
|
64
65
|
Canonical.digest(record_type: model.polymorphic_name, query: query.normalized, vocabulary_version: version, tenant_key: tenant)
|
|
65
66
|
end
|
|
66
67
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Search
|
|
3
|
+
# Words that carry no search meaning on their own: common stopwords and
|
|
4
|
+
# `config.filler_words` (generic nouns such as "customers" or "items",
|
|
5
|
+
# matched ignoring plurals). One rule serves the encoder's reconcile and
|
|
6
|
+
# the cold-cache keywords: filler is dropped as a keyword unless dropping
|
|
7
|
+
# it would leave the search with no keyword, no applied label, and no
|
|
8
|
+
# time range, so a lone "customers" still searches text.
|
|
9
|
+
module Filler
|
|
10
|
+
STOPWORDS = %w[
|
|
11
|
+
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
|
|
12
|
+
them there they this to up us was we what when where which who why with you your
|
|
13
|
+
].to_set.freeze
|
|
14
|
+
|
|
15
|
+
DEFAULT_WORDS = %w[customer customers people person user users message messages item items stuff thing things].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def stopword?(word)
|
|
20
|
+
STOPWORDS.include?(word.to_s.downcase)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# `keep` holds singular words that are never filler (see label_words).
|
|
24
|
+
def word?(word, filler_words: Truffler.config.filler_words, keep: nil)
|
|
25
|
+
word = word.to_s.downcase
|
|
26
|
+
return false if keep&.include?(word.singularize)
|
|
27
|
+
|
|
28
|
+
stopword?(word) || Array(filler_words).any? { |filler| filler.to_s.downcase.singularize == word.singularize }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Singular words that name one of the model's declared labels for this
|
|
32
|
+
# tenant, applied or not: words of a label key, of a choice option key,
|
|
33
|
+
# and of an option's search text (not its description, which is prose).
|
|
34
|
+
# Such a word is never filler, so "text messages" still searches
|
|
35
|
+
# "messages" when an option's search text is "text message".
|
|
36
|
+
def label_words(definition, tenant_key = nil)
|
|
37
|
+
definition.labels.each_value.with_object(Set.new) do |label, words|
|
|
38
|
+
next unless label.available?(tenant_key)
|
|
39
|
+
|
|
40
|
+
names = [ label.key ]
|
|
41
|
+
if label.type == :choice
|
|
42
|
+
options = label.encoding_wording(tenant_key)[:options]
|
|
43
|
+
names.concat(options.keys, options.values.filter_map { |entry| entry[:search] })
|
|
44
|
+
end
|
|
45
|
+
names.each do |name|
|
|
46
|
+
name.to_s.downcase.split(/[^\p{Alnum}]+/).each { |word| words << word.singularize unless word.empty? || stopword?(word) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The subset of `candidates` (droppable keywords) to drop, given how many
|
|
52
|
+
# keywords there are and whether a label or time range is applied. When
|
|
53
|
+
# nothing anchors the search and only droppable words are left, the
|
|
54
|
+
# filler nouns stay as keywords and only pure stopwords go ("customers
|
|
55
|
+
# in the" searches "customers"); if every word is a stopword, all stay.
|
|
56
|
+
def drop(candidates, keyword_count:, anchored:, stopword: ->(candidate) { stopword?(candidate) })
|
|
57
|
+
return candidates if anchored || candidates.size < keyword_count
|
|
58
|
+
|
|
59
|
+
stopwords = candidates.select(&stopword)
|
|
60
|
+
stopwords.size == candidates.size ? [] : stopwords
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# `tokens` minus filler words, or only minus stopwords when nothing else
|
|
64
|
+
# would anchor the search. Exact tokens (a quoted "the") and `keep`
|
|
65
|
+
# words are never filler.
|
|
66
|
+
def keywords(tokens, anchored:, exact: [], keep: nil)
|
|
67
|
+
dropped = drop(tokens.select { |token| !exact.include?(token) && word?(token, keep: keep) }, keyword_count: tokens.size,
|
|
68
|
+
anchored: anchored)
|
|
69
|
+
tokens - dropped
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -41,12 +41,12 @@ module Truffler
|
|
|
41
41
|
explicit_action = surface_action
|
|
42
42
|
cached = read_encoding
|
|
43
43
|
status = encoding_status(cached)
|
|
44
|
-
encoding = with_time(
|
|
44
|
+
encoding = visible_lenses_only(with_time(cached)&.without(suppressed, keep_words: label_words), record_usage: true)
|
|
45
45
|
sql = sql(encoding)
|
|
46
46
|
records = sql.relation(scope, limit: limit).to_a
|
|
47
47
|
result = Result.new(records: records, query: query, encoding: encoding, encoding_status: status, watermark: watermark,
|
|
48
|
-
explicit_action: explicit_action, sources: sql.sources, invite_row: invite_row(records, cached),
|
|
49
|
-
weights: @weights, recount: ->(since) { count(since: since) })
|
|
48
|
+
explicit_action: explicit_action, sources: sql.sources, invite_row: invite_row(records, cached, status),
|
|
49
|
+
local_weak: local_weak?(records, cached), weights: @weights, recount: ->(since) { count(since: since) })
|
|
50
50
|
instrument(result, started)
|
|
51
51
|
result
|
|
52
52
|
end
|
|
@@ -54,7 +54,7 @@ module Truffler
|
|
|
54
54
|
# How many records the same search would return that arrived after
|
|
55
55
|
# `since` (R25). Reads the cache only and never prefetches.
|
|
56
56
|
def count(since:)
|
|
57
|
-
sql(with_time(
|
|
57
|
+
sql(visible_lenses_only(with_time(read_encoding)&.without(suppressed, keep_words: label_words))).candidates(scope)
|
|
58
58
|
.where(model.arel_table[@definition.arrived_at_column].gt(since)).count
|
|
59
59
|
end
|
|
60
60
|
|
|
@@ -83,6 +83,10 @@ module Truffler
|
|
|
83
83
|
|
|
84
84
|
# Drops lens keys this searcher cannot see (another user's personal
|
|
85
85
|
# lens, an expired lens) and counts a use of the rest (R42, R43).
|
|
86
|
+
def label_words
|
|
87
|
+
-> { Filler.label_words(@definition, tenant_key) }
|
|
88
|
+
end
|
|
89
|
+
|
|
86
90
|
def visible_lenses_only(encoding, record_usage: false)
|
|
87
91
|
lens_keys = encoding ? (encoding.intent_vector.keys | encoding.filters.keys | encoding.boosts.keys).select { |key| lens_id(key) } : []
|
|
88
92
|
return encoding if lens_keys.empty?
|
|
@@ -90,11 +94,12 @@ module Truffler
|
|
|
90
94
|
visible = Lenses.labels(model, tenant_key: tenant_key, user_key: user_key).values.map(&:lens_id).uniq
|
|
91
95
|
hidden, shown = lens_keys.partition { |key| !visible.include?(lens_id(key)) }
|
|
92
96
|
Lenses.record_usage(shown.map { |key| lens_id(key) }.uniq) if record_usage
|
|
93
|
-
encoding.without(hidden)
|
|
97
|
+
encoding.without(hidden, keep_words: label_words)
|
|
94
98
|
end
|
|
95
99
|
|
|
96
100
|
# The query's time phrase, resolved on this search's clock, unless the
|
|
97
|
-
# searcher removed its chip.
|
|
101
|
+
# searcher removed its chip. Attached before `without`, which keeps
|
|
102
|
+
# filler dropped while a time range still anchors the search.
|
|
98
103
|
def with_time(encoding)
|
|
99
104
|
phrase = query.time_phrase
|
|
100
105
|
return encoding if phrase.nil? || suppressed.include?(TimeRange.key)
|
|
@@ -133,20 +138,29 @@ module Truffler
|
|
|
133
138
|
Sql.new(model, tenant_key: tenant_key, query: query, encoding: encoding, vector: read_vector, weights: @weights)
|
|
134
139
|
end
|
|
135
140
|
|
|
136
|
-
# R21: the Smart search row. A
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
|
|
141
|
+
# R21: the Smart search row. A query whose encoding is not cached yet
|
|
142
|
+
# invites the action even when a blind index or keyword matched,
|
|
143
|
+
# because a first-time intent query resolves on the action (AE10): on a
|
|
144
|
+
# model with no local text search always, and with a `keyword` source
|
|
145
|
+
# while the encoding is in flight unless `invite_on_pending_encoding false`.
|
|
146
|
+
def invite_row(records, cached, status)
|
|
140
147
|
return if query.blank?
|
|
141
148
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
elsif records.empty? then :empty
|
|
145
|
-
elsif records.size < @definition.weak_below then :weak
|
|
146
|
-
end
|
|
149
|
+
pending = cached.nil? && (@definition.keyword.blank? || (@definition.invite_on_pending_encoding && status == :pending))
|
|
150
|
+
reason = pending ? :encoding_pending : weak_reason(records)
|
|
147
151
|
{ query: query.raw.strip, reason: reason } if reason
|
|
148
152
|
end
|
|
149
153
|
|
|
154
|
+
def weak_reason(records)
|
|
155
|
+
if records.empty? then :empty
|
|
156
|
+
elsif records.size < @definition.weak_below then :weak
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def local_weak?(records, cached)
|
|
161
|
+
!query.blank? && (weak_reason(records).present? || (@definition.keyword.blank? && cached.nil?))
|
|
162
|
+
end
|
|
163
|
+
|
|
150
164
|
def instrument(result, started)
|
|
151
165
|
payload = { record_type: model.polymorphic_name, tenant_key: tenant_key, surface: surface, outcome: result.encoding_status,
|
|
152
166
|
result_count: result.records.size, filter_count: result.encoding&.filters&.size.to_i,
|
|
@@ -12,7 +12,9 @@ module Truffler
|
|
|
12
12
|
|
|
13
13
|
attr_reader :records, :query, :encoding, :encoding_status, :watermark, :explicit_action, :sources, :invite_row
|
|
14
14
|
|
|
15
|
-
def initialize(records:, query:, encoding:, encoding_status:, watermark:, explicit_action:, sources:, invite_row:, weights:, recount
|
|
15
|
+
def initialize(records:, query:, encoding:, encoding_status:, watermark:, explicit_action:, sources:, invite_row:, weights:, recount:,
|
|
16
|
+
local_weak: nil)
|
|
17
|
+
@local_weak = local_weak
|
|
16
18
|
@records = records
|
|
17
19
|
@query = query
|
|
18
20
|
@encoding = encoding
|
|
@@ -29,6 +31,14 @@ module Truffler
|
|
|
29
31
|
records.map(&:id)
|
|
30
32
|
end
|
|
31
33
|
|
|
34
|
+
# Whether the local list is too weak to stand alone, which starts the
|
|
35
|
+
# backup provider on the explicit action. An `:encoding_pending` row on
|
|
36
|
+
# a model with a `keyword` source invites Smart search without making
|
|
37
|
+
# the list weak.
|
|
38
|
+
def local_weak?
|
|
39
|
+
@local_weak.nil? ? invite_row.present? : @local_weak
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
# Applied filters, then boosts, then the time range, as `{key:, label:, kind:, name:}`.
|
|
33
43
|
def chips
|
|
34
44
|
return [] unless encoding
|