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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +77 -10
  4. data/bench/cassettes/1037e0b63ba790c02d14d0ddfc18b68151c11d03ce88d764658044b6bab2e55b.json +1 -0
  5. data/bench/cassettes/19bba849a846d30a69dc385a337d67704fbe5194506fd3c914d3270d86495fde.json +1 -0
  6. data/bench/cassettes/3768847dc57df8bf2328771e7763c0b8bd013a167244e7849967dac144420601.json +1 -0
  7. data/lib/generators/truffler/install/templates/migration.rb.tt +6 -3
  8. data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +25 -0
  9. data/lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt +32 -0
  10. data/lib/generators/truffler/upgrade/upgrade_generator.rb +49 -3
  11. data/lib/tasks/truffler.rake +38 -13
  12. data/lib/truffler/benchmark/runner.rb +1 -1
  13. data/lib/truffler/clients/evaluator.rb +44 -0
  14. data/lib/truffler/configuration.rb +12 -0
  15. data/lib/truffler/definition.rb +52 -0
  16. data/lib/truffler/embeddings/backfill.rb +40 -11
  17. data/lib/truffler/embeddings/label_vector.rb +1 -1
  18. data/lib/truffler/embeddings/neighbor_store.rb +33 -7
  19. data/lib/truffler/embeddings/vector_store.rb +9 -3
  20. data/lib/truffler/jobs/backfill_job.rb +14 -5
  21. data/lib/truffler/jobs/embed_job.rb +2 -0
  22. data/lib/truffler/jobs/label_flush_job.rb +4 -2
  23. data/lib/truffler/jobs/resume_job.rb +22 -9
  24. data/lib/truffler/label_definition.rb +55 -12
  25. data/lib/truffler/labeling/backfill.rb +100 -37
  26. data/lib/truffler/labeling/labeler.rb +20 -5
  27. data/lib/truffler/labeling/queue.rb +14 -9
  28. data/lib/truffler/labeling/supplied.rb +16 -5
  29. data/lib/truffler/lenses/backfill.rb +28 -10
  30. data/lib/truffler/lenses/lens_label.rb +7 -0
  31. data/lib/truffler/model.rb +5 -0
  32. data/lib/truffler/providers/backup.rb +1 -1
  33. data/lib/truffler/query_encoding/encoder.rb +56 -39
  34. data/lib/truffler/records/backfill_spend.rb +30 -6
  35. data/lib/truffler/redaction.rb +1 -1
  36. data/lib/truffler/search/encoding.rb +50 -12
  37. data/lib/truffler/search/encoding_cache.rb +4 -3
  38. data/lib/truffler/search/filler.rb +73 -0
  39. data/lib/truffler/search/keystroke.rb +29 -15
  40. data/lib/truffler/search/result.rb +11 -1
  41. data/lib/truffler/search/sql.rb +93 -15
  42. data/lib/truffler/smart_search/dispatcher.rb +1 -1
  43. data/lib/truffler/smart_search/starter.rb +1 -1
  44. data/lib/truffler/version.rb +1 -1
  45. data/lib/truffler/vocabulary.rb +13 -3
  46. 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 = %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
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
- stems = []
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
- stems.concat(label_terms(key))
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) }, stems.uniq, terms.uniq)
143
- keyword_tokens = query.tokens.each_index.filter_map { |position| query.tokens[position] if roles[position] == "keyword" }
144
- label_term_tokens = query.tokens.each_index.filter_map { |position| query.tokens[position] if roles[position] == "label_term" }
145
- Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent, keyword_tokens: keyword_tokens,
146
- label_term_tokens: label_term_tokens)
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
- entry["options"] = options.keys
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. Time phrase words are "time";
207
- # exact tokens and unasked words are keywords; a keyword naming an
208
- # applied label becomes a label term; stopwords become filler unless
209
- # they are all that would be left of an encoding that applies nothing.
210
- def reconcile(query, answered, stems, terms)
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
- [ position, query.time_position?(position) ? "time" : answered.fetch(position, "keyword") ]
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 names_label?(query.tokens[position], stems, terms) }
216
- stopwords = words.select { |position| roles[position] == "keyword" && STOPWORDS.include?(query.tokens[position]) }
217
- return roles if stems.empty? && roles.values.count("keyword") == stopwords.size
218
-
219
- stopwords.each { |position| roles[position] = "filler" }
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 names_label?).
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 adds its words minus stopwords: "p_17" shown
235
- # as "Spiral writing tool" names "spiral", "writing", and "tool". These
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.options(tenant_key)[Search::Encoding.split_key(storage_key).last]
255
+ label.option_names(tenant_key)[Search::Encoding.split_key(storage_key).last]
246
256
  end
247
257
 
248
- # "angry" names "anger": the same word ignoring plurals, or, against a
249
- # label or option key, two words of four letters or more that share
250
- # their first three letters. Display-name words match exactly.
251
- def names_label?(word, stems, terms)
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
- terms.include?(word) ||
254
- stems.any? { |term| term == word || (word.length > STEM && term.length > STEM && word[0, STEM] == term[0, STEM]) }
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 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.
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.ledger(model, version)
22
- create_or_find_by!(record_type: model.polymorphic_name, vocabulary_version: version)
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
 
@@ -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, :time) do
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: [], time: nil)
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, time: time)
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
- def without(suppressed)
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
- with(filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep),
48
- time: (time unless suppressed.include?(TimeRange.key)))
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
- def keywords(query)
64
- keyword_tokens || (query.search_tokens - label_term_tokens)
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, plus the tenant when the vocabulary has per-tenant choices.
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.version(tenant_key: tenant_key&.to_s, user_key: user_key)
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(visible_lenses_only(cached&.without(suppressed), record_usage: true))
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(visible_lenses_only(read_encoding&.without(suppressed)))).candidates(scope)
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 model with no local text search whose
137
- # encoding is not cached yet invites the action even when a blind index
138
- # matched, because intent queries resolve on the action there (AE10).
139
- def invite_row(records, cached)
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
- reason =
143
- if @definition.keyword.blank? && cached.nil? then :encoding_pending
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