truffler 0.1.4 → 0.1.6

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +79 -12
  4. data/lib/generators/truffler/install/templates/migration.rb.tt +14 -11
  5. data/lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt +1 -1
  6. data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +25 -0
  7. data/lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt +32 -0
  8. data/lib/generators/truffler/upgrade/upgrade_generator.rb +49 -3
  9. data/lib/tasks/truffler.rake +28 -13
  10. data/lib/truffler/benchmark/runner.rb +1 -1
  11. data/lib/truffler/clients/evaluator.rb +44 -0
  12. data/lib/truffler/configuration.rb +12 -0
  13. data/lib/truffler/current.rb +30 -0
  14. data/lib/truffler/definition.rb +52 -0
  15. data/lib/truffler/embeddings/backfill.rb +40 -11
  16. data/lib/truffler/embeddings/label_vector.rb +1 -1
  17. data/lib/truffler/embeddings/neighbor_store.rb +33 -7
  18. data/lib/truffler/embeddings/vector_store.rb +9 -3
  19. data/lib/truffler/jobs/backfill_job.rb +14 -5
  20. data/lib/truffler/jobs/embed_job.rb +2 -0
  21. data/lib/truffler/jobs/label_flush_job.rb +4 -2
  22. data/lib/truffler/jobs/resume_job.rb +22 -9
  23. data/lib/truffler/label_definition.rb +17 -3
  24. data/lib/truffler/labeling/backfill.rb +115 -37
  25. data/lib/truffler/labeling/labeler.rb +58 -41
  26. data/lib/truffler/labeling/queue.rb +14 -9
  27. data/lib/truffler/labeling/supplied.rb +16 -5
  28. data/lib/truffler/lenses/backfill.rb +28 -10
  29. data/lib/truffler/model.rb +5 -0
  30. data/lib/truffler/providers/backup.rb +1 -1
  31. data/lib/truffler/query_encoding/encoder.rb +75 -49
  32. data/lib/truffler/query_encoding/present_options.rb +54 -0
  33. data/lib/truffler/records/backfill_spend.rb +60 -6
  34. data/lib/truffler/redaction.rb +1 -1
  35. data/lib/truffler/search/encoding.rb +41 -10
  36. data/lib/truffler/search/encoding_cache.rb +12 -1
  37. data/lib/truffler/search/filler.rb +31 -6
  38. data/lib/truffler/search/keystroke.rb +60 -29
  39. data/lib/truffler/search/relaxation.rb +95 -0
  40. data/lib/truffler/search/result.rb +21 -5
  41. data/lib/truffler/search/sql.rb +81 -10
  42. data/lib/truffler/smart_search/dispatcher.rb +36 -24
  43. data/lib/truffler/smart_search/reranker.rb +7 -5
  44. data/lib/truffler/smart_search/run.rb +10 -3
  45. data/lib/truffler/smart_search/starter.rb +13 -11
  46. data/lib/truffler/version.rb +1 -1
  47. data/lib/truffler/vocabulary.rb +10 -0
  48. metadata +7 -1
@@ -51,50 +51,55 @@ module Truffler
51
51
  end
52
52
 
53
53
  def request(model, query, tenant_key:, user_key: nil)
54
- labels = labels(model, tenant_key, user_key)
55
- questions = Questions.new
56
- labels.each_value do |label|
57
- questions.choice(:"intent__#{label.question_key}", instructions: intent_instructions(label), criteria: INTENTS)
58
- end
59
- labels.each_value do |label|
60
- next unless label.type == :choice
54
+ Current.scope do
55
+ present = present_options(model, tenant_key, user_key)
56
+ labels = labels(model, tenant_key, user_key, present)
57
+ questions = Questions.new
58
+ labels.each_value do |label|
59
+ questions.choice(:"intent__#{label.question_key}", instructions: intent_instructions(label), criteria: INTENTS)
60
+ end
61
+ labels.each_value do |label|
62
+ next unless label.type == :choice
61
63
 
62
- options = label.options(tenant_key).merge(NO_OPTION => "The query names none of these")
63
- questions.choice(:"option__#{label.question_key}", instructions: %(Which "#{label.key}" option does the search query ask about?),
64
- criteria: options)
65
- end
64
+ options = PresentOptions.options(label, tenant_key, present).merge(NO_OPTION => "The query names none of these")
65
+ questions.choice(:"option__#{label.question_key}", instructions: %(Which "#{label.key}" option does the search query ask about?),
66
+ criteria: options)
67
+ end
66
68
 
67
- words = query.tokens.each_with_index.reject do |token, position|
68
- query.exact_tokens.include?(token) || query.time_position?(position)
69
- end
70
- asked = words.first(MAX_TOKEN_QUESTIONS)
71
- token_ids = asked.to_h do |_token, position|
72
- id = :"token__#{position}"
73
- questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]? The labels it may name, ) +
74
- %(with their options, are in `labels`.), criteria: TOKEN_ROLES)
75
- [ position, id.to_s ]
76
- end
69
+ words = query.tokens.each_with_index.reject do |token, position|
70
+ query.exact_tokens.include?(token) || query.time_position?(position)
71
+ end
72
+ asked = words.first(MAX_TOKEN_QUESTIONS)
73
+ token_ids = asked.to_h do |_token, position|
74
+ id = :"token__#{position}"
75
+ questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]? The labels it may name, ) +
76
+ %(with their options, are in `labels`.), criteria: TOKEN_ROLES)
77
+ [ position, id.to_s ]
78
+ end
77
79
 
78
- state = { "query" => query.normalized, "tokens" => query.tokens, "labels" => vocabulary_state(labels, tenant_key) }
79
- Request.new(state: state, questions: questions.to_h, token_ids: token_ids, exact_tokens: query.exact_tokens,
80
- unasked_tokens: words.drop(MAX_TOKEN_QUESTIONS).map(&:first))
80
+ state = { "query" => query.normalized, "tokens" => query.tokens, "labels" => vocabulary_state(labels, tenant_key, present) }
81
+ Request.new(state: state, questions: questions.to_h, token_ids: token_ids, exact_tokens: query.exact_tokens,
82
+ unasked_tokens: words.drop(MAX_TOKEN_QUESTIONS).map(&:first))
83
+ end
81
84
  end
82
85
 
83
86
  # Encodes the query pending under `cache_key`. Returns the encoding, or
84
87
  # nil when the payload expired, the vocabulary moved on, or the encode
85
88
  # budget was denied (a silent skip). Always releases the in-flight marker.
86
89
  def encode(cache_key)
87
- pending = cache.read_payload(cache_key)
88
- return unless pending
89
-
90
- model, query, tenant_key, user_key = pending.values_at(:model, :query, :tenant_key, :user_key)
91
- return unless cache.key(model, query, tenant_key: tenant_key, user_key: user_key) == cache_key
92
-
93
- encoding = cache.encoded?(cache_key) ? cache.read_encoding(cache_key, query) : encode_labels(model, query, tenant_key, user_key)
94
- embed_query(model, query, tenant_key)
95
- encoding
96
- ensure
97
- cache.release(cache_key)
90
+ Current.scope do
91
+ pending = cache.read_payload(cache_key)
92
+ return unless pending
93
+
94
+ model, query, tenant_key, user_key = pending.values_at(:model, :query, :tenant_key, :user_key)
95
+ return unless cache.key(model, query, tenant_key: tenant_key, user_key: user_key) == cache_key
96
+
97
+ encoding = cache.encoded?(cache_key) ? cache.read_encoding(cache_key, query) : encode_labels(model, query, tenant_key, user_key)
98
+ embed_query(model, query, tenant_key)
99
+ encoding
100
+ ensure
101
+ cache.release(cache_key)
102
+ end
98
103
  end
99
104
 
100
105
  # Polls the cache until the encoding lands or the deadline (seconds
@@ -118,8 +123,9 @@ module Truffler
118
123
  boosts = {}
119
124
  intent = {}
120
125
  names = {}
121
- labels(model, tenant_key, user_key).each_value do |label|
122
- key = storage_key(label, answers, tenant_key)
126
+ present = present_options(model, tenant_key, user_key)
127
+ labels(model, tenant_key, user_key, present).each_value do |label|
128
+ key = storage_key(label, answers, tenant_key, present)
123
129
  next unless key
124
130
 
125
131
  case answers.choice("intent__#{label.question_key}")
@@ -134,7 +140,8 @@ module Truffler
134
140
  end
135
141
 
136
142
  query = Search::Query.new(request.state["query"])
137
- roles, sources, soft = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, names)
143
+ roles, sources, soft = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, names,
144
+ Search::Filler.label_words(model.truffler_definition, tenant_key))
138
145
  tokens = ->(positions) { positions.map { |position| query.tokens[position] } }
139
146
  Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent,
140
147
  keyword_tokens: tokens.call(roles.keys.select { |position| roles[position] == "keyword" }),
@@ -174,25 +181,41 @@ module Truffler
174
181
  cache.write_vector(model, query, vector, tenant_key: tenant_key)
175
182
  end
176
183
 
177
- def labels(model, tenant_key, user_key)
184
+ # The searcher's vocabulary; with skip_empty_options, minus choice
185
+ # labels the tenant has no option rows for (see PresentOptions).
186
+ def labels(model, tenant_key, user_key, present = present_options(model, tenant_key, user_key))
187
+ labels = vocabulary_labels(model, tenant_key, user_key)
188
+ return labels unless present
189
+
190
+ labels.select { |_, label| label.type != :choice || PresentOptions.options(label, tenant_key, present).any? }
191
+ end
192
+
193
+ def vocabulary_labels(model, tenant_key, user_key)
178
194
  model.truffler_definition.vocabulary.labels_for(tenant_key: tenant_key, user_key: user_key)
179
195
  end
180
196
 
197
+ def present_options(model, tenant_key, user_key)
198
+ return unless PresentOptions.enabled?
199
+
200
+ PresentOptions.new.keys(model, vocabulary_labels(model, tenant_key, user_key), tenant_key: tenant_key)
201
+ end
202
+
181
203
  # The label's storage key the query names, or nil for a choice label
182
204
  # whose option answer is NO_OPTION.
183
- def storage_key(label, answers, tenant_key)
205
+ def storage_key(label, answers, tenant_key, present)
184
206
  return label.key unless label.type == :choice
185
207
 
186
208
  option = answers.choice("option__#{label.question_key}")
187
- "#{label.key}:#{option}" if option != NO_OPTION && label.options(tenant_key).key?(option)
209
+ "#{label.key}:#{option}" if option != NO_OPTION && PresentOptions.options(label, tenant_key, present).key?(option)
188
210
  end
189
211
 
190
- def vocabulary_state(labels, tenant_key)
212
+ def vocabulary_state(labels, tenant_key, present)
191
213
  labels.transform_values do |label|
192
214
  entry = { "description" => label.description }
193
215
  if label.type == :choice
194
- entry["options"] = label.options(tenant_key).keys
195
- names = label.option_names(tenant_key)
216
+ options = PresentOptions.options(label, tenant_key, present).keys
217
+ entry["options"] = options
218
+ names = label.option_names(tenant_key).slice(*options)
196
219
  entry["option_names"] = names if names.any?
197
220
  end
198
221
  entry
@@ -206,16 +229,19 @@ module Truffler
206
229
  # keyword naming an applied label becomes a label term; stopwords and
207
230
  # filler words become filler unless they are all that would be left of
208
231
  # an encoding that applies no label and no time range (Search::Filler).
209
- # A word Jev called a label term that names no applied label locally
210
- # is sourced to every applied label.
211
- def reconcile(query, answered, names)
232
+ # A word naming any declared label (`keep`, see Filler.label_words) is
233
+ # never filler, even when Jev calls it that. A word Jev called a label
234
+ # term that names no applied label locally is sourced to every applied
235
+ # label.
236
+ def reconcile(query, answered, names, keep)
212
237
  roles = query.tokens.each_index.to_h do |position|
213
- [ position, query.time_position?(position) ? "time" : answered.fetch(position, "keyword") ]
238
+ role = query.time_position?(position) ? "time" : answered.fetch(position, "keyword")
239
+ [ position, role == "filler" && keep.include?(query.tokens[position].singularize) ? "keyword" : role ]
214
240
  end
215
241
  matches = roles.keys.to_h { |position| [ position, roles[position] == "time" ? {} : label_matches(query.tokens[position], names) ] }
216
242
  words = roles.keys.select { |position| roles[position] == "keyword" && !query.exact_tokens.include?(query.tokens[position]) }
217
243
  words.each { |position| roles[position] = "label_term" if matches[position].any? }
218
- filler = words.select { |position| roles[position] == "keyword" && Search::Filler.word?(query.tokens[position]) }
244
+ filler = words.select { |position| roles[position] == "keyword" && Search::Filler.word?(query.tokens[position], keep: keep) }
219
245
  Search::Filler.drop(filler, keyword_count: roles.values.count("keyword"), anchored: names.any? || !query.time_phrase.nil?,
220
246
  stopword: ->(position) { Search::Filler.stopword?(query.tokens[position]) })
221
247
  .each { |position| roles[position] = "filler" }
@@ -0,0 +1,54 @@
1
+ module Truffler
2
+ module QueryEncoding
3
+ # With `config.skip_empty_options`, the choice options a tenant actually
4
+ # has: storage keys with at least one label row at or above
5
+ # `config.choice_min_probability` (above 0.0 when that is nil). Query
6
+ # encoding offers Jev only these, so it cannot pick "Source: email" in a
7
+ # tenant with no email sources.
8
+ #
9
+ # The set is digested into the encoding cache key, so encodings refresh
10
+ # when an option appears. It is read through the cache store for TTL, so
11
+ # a keystroke stays one SELECT; a new option reaches query encoding (and
12
+ # the cache key) within TTL.
13
+ class PresentOptions
14
+ TTL = 5.minutes
15
+
16
+ def initialize(store: Truffler.config.cache_store)
17
+ @store = store
18
+ end
19
+
20
+ def self.enabled?
21
+ Truffler.config.skip_empty_options == true
22
+ end
23
+
24
+ # The present storage keys among `labels`' choice options, or nil when
25
+ # skip_empty_options is off.
26
+ def keys(model, labels, tenant_key:)
27
+ return unless self.class.enabled?
28
+
29
+ candidates = labels.values.select { |label| label.type == :choice }.flat_map { |label| label.storage_keys(tenant_key) }.sort
30
+ return Set.new if candidates.empty?
31
+
32
+ min = Truffler.config.choice_min_probability
33
+ cache_key = "truffler/present_options/#{Canonical.digest(record_type: model.polymorphic_name, tenant_key: tenant_key&.to_s,
34
+ keys: candidates, min: min)}"
35
+ Set.new(@store.fetch(cache_key, expires_in: TTL) { present(model, candidates, tenant_key, min) })
36
+ end
37
+
38
+ # A label's options narrowed to `present` (all of them when nil).
39
+ def self.options(label, tenant_key, present)
40
+ options = label.options(tenant_key)
41
+ present ? options.select { |option, _| present.include?("#{label.key}:#{option}") } : options
42
+ end
43
+
44
+ private
45
+
46
+ def present(model, candidates, tenant_key, min)
47
+ rows = Records::Label.where(record_type: model.polymorphic_name, label_key: candidates)
48
+ rows = rows.where(tenant_key: tenant_key.to_s) if model.truffler_definition.scoped?
49
+ rows = min ? rows.where(value: min..) : rows.where(Records::Label.arel_table[:value].gt(0.0))
50
+ rows.distinct.pluck(:label_key).sort
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,11 +1,12 @@
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
+ TENANT_KEY_RECHECK = 1.minute
9
10
 
10
11
  scope :for_model, ->(model) { where(record_type: model.polymorphic_name) }
11
12
 
@@ -18,10 +19,63 @@ module Truffler
18
19
  false
19
20
  end
20
21
 
21
- def self.ledger(model, version)
22
- create_or_find_by!(record_type: model.polymorphic_name, vocabulary_version: version)
22
+ def self.for_ledger(model, tenant_key)
23
+ tenant_ledgers? ? for_model(model).where(tenant_key: tenant_key) : for_model(model)
23
24
  end
24
25
 
26
+ # Before `rails g truffler:upgrade` adds tenant_key, every tenant
27
+ # shares the app-wide row. Before 0.1.6 rows were keyed by the whole
28
+ # vocabulary version; pass it as `legacy_version:` and the first lookup
29
+ # takes that row over instead of starting from zero.
30
+ def self.ledger(model, version, tenant_key: nil, legacy_version: nil)
31
+ attributes = { record_type: model.polymorphic_name, vocabulary_version: version }
32
+ attributes[:tenant_key] = tenant_key if tenant_ledgers?
33
+ adopt(attributes, legacy_version) if legacy_version && legacy_version != version
34
+ create_or_find_by!(attributes)
35
+ end
36
+
37
+ def self.adopt(attributes, legacy_version)
38
+ return if exists?(attributes)
39
+
40
+ where(attributes.merge(vocabulary_version: legacy_version)).update_all(vocabulary_version: attributes[:vocabulary_version])
41
+ rescue ActiveRecord::RecordNotUnique
42
+ nil
43
+ end
44
+ private_class_method :adopt
45
+
46
+ # A worker booted before `db:migrate` added tenant_key has the old
47
+ # columns cached, so a miss reloads them at most once per
48
+ # TENANT_KEY_RECHECK and the worker moves to tenant ledgers without a
49
+ # restart.
50
+ def self.tenant_ledgers?
51
+ return true if column_names.include?("tenant_key")
52
+
53
+ if recheck_tenant_key?
54
+ reset_column_information
55
+ return true if column_names.include?("tenant_key")
56
+ end
57
+ warn_missing_tenant_key
58
+ false
59
+ end
60
+
61
+ def self.recheck_tenant_key?
62
+ now = Time.current
63
+ return false if @tenant_key_checked_at && now - @tenant_key_checked_at < TENANT_KEY_RECHECK
64
+
65
+ @tenant_key_checked_at = now
66
+ true
67
+ end
68
+ private_class_method :recheck_tenant_key?
69
+
70
+ def self.warn_missing_tenant_key
71
+ return if @missing_tenant_warned
72
+
73
+ @missing_tenant_warned = true
74
+ Truffler.config.logger.warn("[truffler] #{table_name}.tenant_key is missing, so backfill spend caps are app-wide. " \
75
+ "Run `bin/rails g truffler:upgrade && bin/rails db:migrate`.")
76
+ end
77
+ private_class_method :warn_missing_tenant_key
78
+
25
79
  def self.warn_missing
26
80
  return if @missing_warned
27
81
 
@@ -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
 
@@ -55,21 +55,40 @@ module Truffler
55
55
 
56
56
  # The encoding minus the chips the searcher removed (R20), matched by
57
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.
59
- def without(suppressed)
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)
60
62
  suppressed = Array(suppressed).map(&:to_s).to_set
61
63
  return self if suppressed.empty?
62
64
 
63
65
  keep = ->(key, _) { !suppressed.include?(key) && !suppressed.include?(self.class.split_key(key).first) }
64
66
  kept = { filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep) }
65
67
  applied = kept.values.flat_map(&:keys).to_set
66
- freed = label_term_sources.select { |_, keys| keys.any? && keys.none? { |key| applied.include?(key) } }.keys
68
+ freed = freed_words(applied)
67
69
  kept_time = (time unless suppressed.include?(TimeRange.key))
68
70
  keywords = keyword_tokens && (keyword_tokens + freed).uniq
69
- keywords = Filler.keywords(keywords + filler_tokens, anchored: false) if keywords && applied.empty? && kept_time.nil?
70
- with(**kept, time: kept_time, label_term_tokens: label_term_tokens - freed,
71
- label_term_sources: label_term_sources.except(*freed), soft_keyword_tokens: soft_keyword_tokens - freed,
72
- keyword_tokens: keywords)
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, **free(freed, keywords))
75
+ end
76
+
77
+ # The encoding with the filters `keys` demoted to soft boosts (zero-result
78
+ # relaxation, see Relaxation): they stay in the intent vector, at their
79
+ # intent weight or DEFAULT_BOOST when filtering gave them none, so records
80
+ # that match still rank first, but nothing requires them. A label term
81
+ # whose every applied source was relaxed becomes a keyword again, as in
82
+ # `without`.
83
+ def relax(keys)
84
+ relaxed = filters.slice(*Array(keys).map(&:to_s))
85
+ return self if relaxed.empty?
86
+
87
+ soft = relaxed.to_h { |key, _| [ key, intent_vector[key] || boosts[key] || QueryEncoding::DEFAULT_BOOST ] }
88
+ kept_filters = filters.except(*relaxed.keys)
89
+ freed = freed_words((kept_filters.keys + boosts.keys + intent_vector.keys).to_set - relaxed.keys)
90
+ with(filters: kept_filters, boosts: boosts.merge(soft), intent_vector: intent_vector.merge(soft),
91
+ **free(freed, keyword_tokens && (keyword_tokens + freed).uniq))
73
92
  end
74
93
 
75
94
  # Splits a storage key into its label key and choice option. Lens keys
@@ -85,10 +104,12 @@ module Truffler
85
104
  end
86
105
 
87
106
  # Without encoder decisions (a cold cache), every search token that is
88
- # not a label term, minus filler words (see Filler).
89
- def keywords(query)
107
+ # not a label term, minus filler words (see Filler). `keep` is called
108
+ # only then, for the words that are never filler.
109
+ def keywords(query, keep: nil)
90
110
  keyword_tokens ||
91
- Filler.keywords(query.search_tokens - label_term_tokens, anchored: !empty? || !time.nil?, exact: query.exact_tokens)
111
+ Filler.keywords(query.search_tokens - label_term_tokens, anchored: !empty? || !time.nil?, exact: query.exact_tokens,
112
+ keep: keep&.call)
92
113
  end
93
114
 
94
115
  # The cache form: decisions plus token positions in the normalized
@@ -107,6 +128,16 @@ module Truffler
107
128
 
108
129
  private
109
130
 
131
+ # Label terms none of whose source keys is still applied.
132
+ def freed_words(applied)
133
+ label_term_sources.select { |_, keys| keys.any? && keys.none? { |key| applied.include?(key) } }.keys
134
+ end
135
+
136
+ def free(freed, keywords)
137
+ { label_term_tokens: label_term_tokens - freed, label_term_sources: label_term_sources.except(*freed),
138
+ soft_keyword_tokens: soft_keyword_tokens - freed, keyword_tokens: keywords }
139
+ end
140
+
110
141
  def weights(hash)
111
142
  hash.to_h.to_h { |key, weight| [ key.to_s, Float(weight) ] }
112
143
  end
@@ -62,7 +62,18 @@ module Truffler
62
62
  definition = model.truffler_definition
63
63
  tenant = tenant_key&.to_s if definition.per_tenant_vocabulary?
64
64
  version = definition.vocabulary.encoding_version(tenant_key: tenant_key&.to_s, user_key: user_key)
65
- Canonical.digest(record_type: model.polymorphic_name, query: query.normalized, vocabulary_version: version, tenant_key: tenant)
65
+ parts = { record_type: model.polymorphic_name, query: query.normalized, vocabulary_version: version, tenant_key: tenant }
66
+ present = present_options(model, tenant_key, user_key)
67
+ Canonical.digest(present ? parts.merge(present_options: Canonical.digest(present.to_a.sort)) : parts)
68
+ end
69
+
70
+ # With skip_empty_options, the tenant's present choice options (see
71
+ # QueryEncoding::PresentOptions), so an encoding refreshes when one appears.
72
+ def present_options(model, tenant_key, user_key)
73
+ return unless QueryEncoding::PresentOptions.enabled?
74
+
75
+ labels = model.truffler_definition.vocabulary.labels_for(tenant_key: tenant_key&.to_s, user_key: user_key)
76
+ QueryEncoding::PresentOptions.new.keys(model, labels, tenant_key: tenant_key&.to_s)
66
77
  end
67
78
  end
68
79
  end
@@ -1,7 +1,7 @@
1
1
  module Truffler
2
2
  module Search
3
3
  # Words that carry no search meaning on their own: common stopwords and
4
- # `config.filler_words` (generic nouns such as "customers" or "emails",
4
+ # `config.filler_words` (generic nouns such as "customers" or "items",
5
5
  # matched ignoring plurals). One rule serves the encoder's reconcile and
6
6
  # the cold-cache keywords: filler is dropped as a keyword unless dropping
7
7
  # it would leave the search with no keyword, no applied label, and no
@@ -12,7 +12,7 @@ module Truffler
12
12
  them there they this to up us was we what when where which who why with you your
13
13
  ].to_set.freeze
14
14
 
15
- DEFAULT_WORDS = %w[customer customers people person user users message messages email emails item items stuff thing things].freeze
15
+ DEFAULT_WORDS = %w[customer customers people person user users message messages item items stuff thing things].freeze
16
16
 
17
17
  module_function
18
18
 
@@ -20,11 +20,34 @@ module Truffler
20
20
  STOPWORDS.include?(word.to_s.downcase)
21
21
  end
22
22
 
23
- def word?(word, filler_words: Truffler.config.filler_words)
23
+ # `keep` holds singular words that are never filler (see label_words).
24
+ def word?(word, filler_words: Truffler.config.filler_words, keep: nil)
24
25
  word = word.to_s.downcase
26
+ return false if keep&.include?(word.singularize)
27
+
25
28
  stopword?(word) || Array(filler_words).any? { |filler| filler.to_s.downcase.singularize == word.singularize }
26
29
  end
27
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
+
28
51
  # The subset of `candidates` (droppable keywords) to drop, given how many
29
52
  # keywords there are and whether a label or time range is applied. When
30
53
  # nothing anchors the search and only droppable words are left, the
@@ -38,9 +61,11 @@ module Truffler
38
61
  end
39
62
 
40
63
  # `tokens` minus filler words, or only minus stopwords when nothing else
41
- # would anchor the search. Exact tokens (a quoted "the") are never filler.
42
- def keywords(tokens, anchored:, exact: [])
43
- dropped = drop(tokens.select { |token| !exact.include?(token) && word?(token) }, keyword_count: tokens.size, anchored: anchored)
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)
44
69
  tokens - dropped
45
70
  end
46
71
  end
@@ -36,26 +36,36 @@ module Truffler
36
36
  end
37
37
 
38
38
  def call
39
- started = Instrumentation.monotonic_ms
40
- watermark = Time.current
41
- explicit_action = surface_action
42
- cached = read_encoding
43
- status = encoding_status(cached)
44
- encoding = with_time(visible_lenses_only(cached&.without(suppressed), record_usage: true))
45
- sql = sql(encoding)
46
- records = sql.relation(scope, limit: limit).to_a
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) })
50
- instrument(result, started)
51
- result
39
+ Current.scope do
40
+ started = Instrumentation.monotonic_ms
41
+ watermark = Time.current
42
+ explicit_action = surface_action
43
+ cached = read_encoding
44
+ status = encoding_status(cached)
45
+ encoding = visible_lenses_only(with_time(cached)&.without(suppressed, keep_words: label_words), record_usage: true)
46
+ sql = sql(encoding)
47
+ records = sql.relation(scope, limit: limit).to_a
48
+ relaxed = relax(encoding, records)
49
+ records, encoding, sql = relaxed.records, relaxed.encoding, sql(relaxed.encoding) if relaxed
50
+ relaxed_labels = relaxed&.relaxed_labels.to_a
51
+ result = Result.new(records: records, query: query, encoding: encoding, encoding_status: status, watermark: watermark,
52
+ explicit_action: explicit_action, sources: sql.sources, invite_row: invite_row(records, cached, status),
53
+ local_weak: local_weak?(records, cached), weights: @weights, relaxed_labels: relaxed_labels,
54
+ recount: ->(since) { count(since: since, relaxed: relaxed_labels) })
55
+ instrument(result, started)
56
+ result
57
+ end
52
58
  end
53
59
 
54
60
  # How many records the same search would return that arrived after
55
- # `since` (R25). Reads the cache only and never prefetches.
56
- def count(since:)
57
- sql(with_time(visible_lenses_only(read_encoding&.without(suppressed)))).candidates(scope)
58
- .where(model.arel_table[@definition.arrived_at_column].gt(since)).count
61
+ # `since` (R25), with the filters `relaxed` demoted as the search did.
62
+ # Reads the cache only and never prefetches.
63
+ def count(since:, relaxed: [])
64
+ Current.scope do
65
+ encoding = visible_lenses_only(with_time(read_encoding)&.without(suppressed, keep_words: label_words))
66
+ sql(relaxed.any? ? encoding&.relax(relaxed) : encoding).candidates(scope)
67
+ .where(model.arel_table[@definition.arrived_at_column].gt(since)).count
68
+ end
59
69
  end
60
70
 
61
71
  private
@@ -83,6 +93,10 @@ module Truffler
83
93
 
84
94
  # Drops lens keys this searcher cannot see (another user's personal
85
95
  # lens, an expired lens) and counts a use of the rest (R42, R43).
96
+ def label_words
97
+ -> { Filler.label_words(@definition, tenant_key) }
98
+ end
99
+
86
100
  def visible_lenses_only(encoding, record_usage: false)
87
101
  lens_keys = encoding ? (encoding.intent_vector.keys | encoding.filters.keys | encoding.boosts.keys).select { |key| lens_id(key) } : []
88
102
  return encoding if lens_keys.empty?
@@ -90,11 +104,12 @@ module Truffler
90
104
  visible = Lenses.labels(model, tenant_key: tenant_key, user_key: user_key).values.map(&:lens_id).uniq
91
105
  hidden, shown = lens_keys.partition { |key| !visible.include?(lens_id(key)) }
92
106
  Lenses.record_usage(shown.map { |key| lens_id(key) }.uniq) if record_usage
93
- encoding.without(hidden)
107
+ encoding.without(hidden, keep_words: label_words)
94
108
  end
95
109
 
96
110
  # The query's time phrase, resolved on this search's clock, unless the
97
- # searcher removed its chip.
111
+ # searcher removed its chip. Attached before `without`, which keeps
112
+ # filler dropped while a time range still anchors the search.
98
113
  def with_time(encoding)
99
114
  phrase = query.time_phrase
100
115
  return encoding if phrase.nil? || suppressed.include?(TimeRange.key)
@@ -133,23 +148,39 @@ module Truffler
133
148
  Sql.new(model, tenant_key: tenant_key, query: query, encoding: encoding, vector: read_vector, weights: @weights)
134
149
  end
135
150
 
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)
151
+ # Only an empty result under filters pays for relaxation.
152
+ def relax(encoding, records)
153
+ return unless records.empty? && encoding&.filters&.any?
154
+
155
+ Relaxation.new(model, encoding, sql: method(:sql)).call(scope, limit: limit)
156
+ end
157
+
158
+ # R21: the Smart search row. A query whose encoding is not cached yet
159
+ # invites the action even when a blind index or keyword matched,
160
+ # because a first-time intent query resolves on the action (AE10): on a
161
+ # model with no local text search always, and with a `keyword` source
162
+ # while the encoding is in flight unless `invite_on_pending_encoding false`.
163
+ def invite_row(records, cached, status)
140
164
  return if query.blank?
141
165
 
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
166
+ pending = cached.nil? && (@definition.keyword.blank? || (@definition.invite_on_pending_encoding && status == :pending))
167
+ reason = pending ? :encoding_pending : weak_reason(records)
147
168
  { query: query.raw.strip, reason: reason } if reason
148
169
  end
149
170
 
171
+ def weak_reason(records)
172
+ if records.empty? then :empty
173
+ elsif records.size < @definition.weak_below then :weak
174
+ end
175
+ end
176
+
177
+ def local_weak?(records, cached)
178
+ !query.blank? && (weak_reason(records).present? || (@definition.keyword.blank? && cached.nil?))
179
+ end
180
+
150
181
  def instrument(result, started)
151
182
  payload = { record_type: model.polymorphic_name, tenant_key: tenant_key, surface: surface, outcome: result.encoding_status,
152
- result_count: result.records.size, filter_count: result.encoding&.filters&.size.to_i,
183
+ result_count: result.records.size, filter_count: result.encoding&.filters&.size.to_i, relaxed_count: result.relaxed_labels.size,
153
184
  boost_count: result.encoding&.intent_vector&.size.to_i, sources: result.sources.map(&:to_s),
154
185
  reason: result.invite_row&.dig(:reason), latency_ms: Instrumentation.elapsed_ms(started) }
155
186
  payload[:query_digest] = Misses.digest(:query, query.normalized) if Misses.encrypted_model?(model)