truffler 0.1.4 → 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 +20 -0
- data/README.md +71 -8
- 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 +28 -13
- data/lib/truffler/benchmark/runner.rb +1 -1
- data/lib/truffler/clients/evaluator.rb +44 -0
- data/lib/truffler/configuration.rb +9 -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 +13 -2
- 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/model.rb +5 -0
- data/lib/truffler/providers/backup.rb +1 -1
- data/lib/truffler/query_encoding/encoder.rb +10 -6
- data/lib/truffler/records/backfill_spend.rb +30 -6
- data/lib/truffler/redaction.rb +1 -1
- data/lib/truffler/search/encoding.rb +12 -6
- data/lib/truffler/search/filler.rb +31 -6
- data/lib/truffler/search/keystroke.rb +29 -15
- data/lib/truffler/search/result.rb +11 -1
- data/lib/truffler/search/sql.rb +70 -10
- 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
- metadata +4 -1
|
@@ -23,7 +23,7 @@ module Truffler
|
|
|
23
23
|
return Result.new(labeled: 0, requests: 0, cost: 0.0, demoted: false) if states.empty?
|
|
24
24
|
|
|
25
25
|
tenant_key = tenant_key_of(states)
|
|
26
|
-
records = load_records(states)
|
|
26
|
+
records = load_records(states, tenant_key)
|
|
27
27
|
vocabulary = definition.vocabulary
|
|
28
28
|
@labels = vocabulary.labels_for(tenant_key: tenant_key, all_users: true)
|
|
29
29
|
fingerprints = vocabulary.fingerprints(tenant_key: tenant_key, all_users: true)
|
|
@@ -87,17 +87,31 @@ module Truffler
|
|
|
87
87
|
tenants.first
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
# Records deleted or outside index_scope / index_if lose their state
|
|
91
|
+
# rows instead of being labeled. A disabled tenant keeps its state rows:
|
|
92
|
+
# they go back to pending at backfill priority, so re-enabling it
|
|
93
|
+
# relabels only what is stale instead of every record.
|
|
94
|
+
def load_records(states, tenant_key)
|
|
95
|
+
unless definition.tenant_enabled?(tenant_key)
|
|
96
|
+
Records::RecordState.where(id: states.map(&:id))
|
|
97
|
+
.update_all(status: "pending", priority: "backfill", claimed_at: nil, updated_at: Time.current)
|
|
98
|
+
return []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
records = definition.index_relation(model.where(model.primary_key => states.map(&:record_id))).to_a
|
|
102
|
+
.select { |record| definition.index_if.nil? || definition.index_if.call(record) }
|
|
92
103
|
found = records.map { |record| record.id.to_s }
|
|
93
104
|
gone = states.reject { |state| found.include?(state.record_id.to_s) }
|
|
94
105
|
Records::RecordState.where(id: gone.map(&:id)).delete_all if gone.any?
|
|
95
106
|
records
|
|
96
107
|
end
|
|
97
108
|
|
|
109
|
+
# A label is current when it has stored rows and all carry its current
|
|
110
|
+
# fingerprint; choice labels store only some options (see sparse_choice).
|
|
98
111
|
def stale_keys(stored, fingerprints, tenant_key, askable)
|
|
99
112
|
askable.reject do |label|
|
|
100
|
-
label.storage_keys(tenant_key).
|
|
113
|
+
present = label.storage_keys(tenant_key).select { |key| stored.key?(key) }
|
|
114
|
+
present.any? && present.all? { |key| stored[key] == fingerprints[label.key] }
|
|
101
115
|
end.map(&:key)
|
|
102
116
|
end
|
|
103
117
|
|
|
@@ -154,7 +168,8 @@ module Truffler
|
|
|
154
168
|
case label.type
|
|
155
169
|
when :noul then [ [ label.key, answers.noul(id) ] ]
|
|
156
170
|
when :score then [ [ label.key, answers.score(id) ] ]
|
|
157
|
-
when :choice
|
|
171
|
+
when :choice
|
|
172
|
+
LabelDefinition.sparse_choice(label.options(tenant_key).keys.to_h { |option| [ "#{label.key}:#{option}", answers.probability(id, option) ] }).to_a
|
|
158
173
|
end
|
|
159
174
|
end
|
|
160
175
|
end
|
|
@@ -3,6 +3,8 @@ module Truffler
|
|
|
3
3
|
# State transitions on truffler_record_states for one model, plus the
|
|
4
4
|
# deduplicated scheduling of LabelFlushJob per tenant and of BackfillJob
|
|
5
5
|
# for demoted rows. Jobs carry only the record type and tenant key.
|
|
6
|
+
# Records the definition does not index (a disabled tenant, `index_if`)
|
|
7
|
+
# are never enqueued.
|
|
6
8
|
class Queue
|
|
7
9
|
BACKFILL_WAIT = 1.minute
|
|
8
10
|
|
|
@@ -15,13 +17,15 @@ module Truffler
|
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
def enqueue(record, priority: :live)
|
|
20
|
+
return unless definition.indexable?(record)
|
|
21
|
+
|
|
18
22
|
tenant_key = definition.tenant_key_for(record)
|
|
19
23
|
now = Time.current
|
|
20
24
|
Records::RecordState.upsert(
|
|
21
25
|
{ record_type: record_type, record_id: record.id, tenant_key: tenant_key, status: "pending",
|
|
22
26
|
priority: priority.to_s, attempts: 0, created_at: now, updated_at: now },
|
|
23
27
|
unique_by: %i[record_type record_id],
|
|
24
|
-
update_only: %i[tenant_key status priority attempts
|
|
28
|
+
update_only: %i[tenant_key status priority attempts]
|
|
25
29
|
)
|
|
26
30
|
schedule(tenant_key)
|
|
27
31
|
end
|
|
@@ -41,13 +45,14 @@ module Truffler
|
|
|
41
45
|
job.perform_later(record_type, tenant_key)
|
|
42
46
|
end
|
|
43
47
|
|
|
44
|
-
# Starts a backfill shortly after live rows are
|
|
45
|
-
# cap. One per
|
|
46
|
-
# dropped job leaves behind.
|
|
47
|
-
def schedule_backfill
|
|
48
|
-
return unless
|
|
48
|
+
# Starts a backfill of the tenant shortly after its live rows are
|
|
49
|
+
# demoted over the tenant cap. One per tenant per marker lifetime;
|
|
50
|
+
# ResumeJob still catches rows a dropped job leaves behind.
|
|
51
|
+
def schedule_backfill(tenant_key = nil)
|
|
52
|
+
return unless definition.tenant_enabled?(tenant_key)
|
|
53
|
+
return unless config.cache_store.write(backfill_marker(tenant_key), true, unless_exist: true, expires_in: BACKFILL_WAIT + 300)
|
|
49
54
|
|
|
50
|
-
Jobs::BackfillJob.set(wait: BACKFILL_WAIT).perform_later(record_type)
|
|
55
|
+
Jobs::BackfillJob.set(wait: BACKFILL_WAIT).perform_later(record_type, **Jobs::BackfillJob.tenant_argument(model, tenant_key))
|
|
51
56
|
end
|
|
52
57
|
|
|
53
58
|
def clear_marker(tenant_key)
|
|
@@ -118,8 +123,8 @@ module Truffler
|
|
|
118
123
|
"truffler/flush/#{record_type}/#{tenant_key}"
|
|
119
124
|
end
|
|
120
125
|
|
|
121
|
-
def backfill_marker
|
|
122
|
-
"truffler/backfill/#{record_type}"
|
|
126
|
+
def backfill_marker(tenant_key)
|
|
127
|
+
"truffler/backfill/#{record_type}/#{tenant_key}"
|
|
123
128
|
end
|
|
124
129
|
end
|
|
125
130
|
end
|
|
@@ -4,9 +4,13 @@ module Truffler
|
|
|
4
4
|
# of one tenant into truffler_labels, under the same storage keys as asked
|
|
5
5
|
# labels, and rewrites those records' label vectors. No Jev request, no
|
|
6
6
|
# budget slot, no spend. A nil answer stores nothing, so the label reads
|
|
7
|
-
# as missing rather than 0.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# as missing rather than 0. Both failures are instrumented as
|
|
8
|
+
# `supplied_label_failed`. An answer out of shape (InvalidSuppliedAnswer)
|
|
9
|
+
# is permanent (`permanent: true`): it stores nothing for that label,
|
|
10
|
+
# like a nil answer, and the record settles, since retrying the same
|
|
11
|
+
# answer cannot succeed. A `from` that raises anything else is transient:
|
|
12
|
+
# the label is skipped and listed in `failed_ids` so the caller retries
|
|
13
|
+
# it (failed after max_attempts); its stored rows serve meanwhile.
|
|
10
14
|
class Supplied
|
|
11
15
|
attr_reader :model, :failed_ids
|
|
12
16
|
|
|
@@ -61,11 +65,18 @@ module Truffler
|
|
|
61
65
|
|
|
62
66
|
def answer(label, record, tenant_key)
|
|
63
67
|
label.supplied_values(record, tenant_key)
|
|
68
|
+
rescue InvalidSuppliedAnswer => error
|
|
69
|
+
instrument_failure(label, record, tenant_key, error, permanent: true)
|
|
70
|
+
nil
|
|
64
71
|
rescue StandardError => error
|
|
65
|
-
|
|
66
|
-
label_key: label.key, error_class: error.class.name)
|
|
72
|
+
instrument_failure(label, record, tenant_key, error, permanent: false)
|
|
67
73
|
:failed
|
|
68
74
|
end
|
|
75
|
+
|
|
76
|
+
def instrument_failure(label, record, tenant_key, error, permanent:)
|
|
77
|
+
Instrumentation.instrument(:supplied_label_failed, record_type: record_type, tenant_key: tenant_key, record_id: record.id,
|
|
78
|
+
label_key: label.key, error_class: error.class.name, permanent: permanent)
|
|
79
|
+
end
|
|
69
80
|
end
|
|
70
81
|
end
|
|
71
82
|
end
|
|
@@ -37,6 +37,8 @@ module Truffler
|
|
|
37
37
|
|
|
38
38
|
attempted.concat(records.map(&:id))
|
|
39
39
|
records.group_by { |record| definition.tenant_key_for(record) }.each do |tenant_key, slice|
|
|
40
|
+
next unless definition.tenant_enabled?(tenant_key)
|
|
41
|
+
|
|
40
42
|
stop = label(slice, tenant_key)
|
|
41
43
|
return result(stop) if stop
|
|
42
44
|
end
|
|
@@ -70,22 +72,38 @@ module Truffler
|
|
|
70
72
|
# lens row.
|
|
71
73
|
def page(attempted)
|
|
72
74
|
pk = model.primary_key
|
|
73
|
-
scope = model.all
|
|
74
|
-
|
|
75
|
+
scope = definition.index_relation(model.all)
|
|
76
|
+
if definition.scoped? && lens.tenant_key
|
|
77
|
+
return [] unless definition.tenant_enabled?(lens.tenant_key)
|
|
78
|
+
|
|
79
|
+
scope = scope.where(definition.tenant_column => lens.tenant_key)
|
|
80
|
+
elsif definition.scoped? && Truffler.config.tenant_enabled
|
|
81
|
+
scope = scope.where(definition.tenant_column => enabled_tenants)
|
|
82
|
+
end
|
|
75
83
|
scope = scope.where.not(pk => attempted) if attempted.any?
|
|
76
84
|
scope.where(Arel.sql(stale_sql)).reorder(definition.arrival_order).limit(batch_size).to_a
|
|
77
85
|
end
|
|
78
86
|
|
|
87
|
+
# Tenants in index_scope that config.tenant_enabled allows, looked up
|
|
88
|
+
# once per run, so disabled tenants are never paged (and never refetched
|
|
89
|
+
# by the next LensBackfillJob).
|
|
90
|
+
def enabled_tenants
|
|
91
|
+
@enabled_tenants ||= definition.index_relation(model.all).reorder(nil).distinct.pluck(definition.tenant_column)
|
|
92
|
+
.select { |tenant_key| definition.tenant_enabled?(tenant_key.to_s) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Stale unless every lens label has a row under its current fingerprint;
|
|
96
|
+
# a choice label stores only some of its options.
|
|
79
97
|
def stale_sql
|
|
80
|
-
expected = lens.labels.values.flat_map do |label|
|
|
81
|
-
print = Lenses.fingerprint(label.question)
|
|
82
|
-
label.storage_keys.map { |key| ActiveRecord::Base.sanitize_sql_array([ "(label_key = ? AND fingerprint = ?)", key, print ]) }
|
|
83
|
-
end
|
|
84
98
|
pk = "#{model.quoted_table_name}.#{model.connection.quote_column_name(model.primary_key)}"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
99
|
+
current = lens.labels.values.map do |label|
|
|
100
|
+
ActiveRecord::Base.sanitize_sql_array([
|
|
101
|
+
"EXISTS (SELECT 1 FROM #{LABELS} WHERE #{LABELS}.record_type = ? AND #{LABELS}.record_id = #{pk} " \
|
|
102
|
+
"AND #{LABELS}.label_key IN (?) AND #{LABELS}.fingerprint = ?)",
|
|
103
|
+
model.polymorphic_name, label.storage_keys, Lenses.fingerprint(label.question)
|
|
104
|
+
])
|
|
105
|
+
end
|
|
106
|
+
"NOT (#{current.join(' AND ')})"
|
|
89
107
|
end
|
|
90
108
|
|
|
91
109
|
# Labels one tenant's records through the labeler, which asks only
|
data/lib/truffler/model.rb
CHANGED
|
@@ -62,6 +62,8 @@ module Truffler
|
|
|
62
62
|
# outside the watched columns, e.g. from the job that classified it.
|
|
63
63
|
def truffler_refresh_labels!
|
|
64
64
|
definition = self.class.truffler_definition
|
|
65
|
+
return self unless definition.indexable?(self)
|
|
66
|
+
|
|
65
67
|
tenant_key = definition.tenant_key_for(self)
|
|
66
68
|
keys = definition.supplied_labels.select { |label| label.available?(tenant_key) }.map(&:key)
|
|
67
69
|
Labeling::Supplied.new(self.class).write([ [ self, keys ] ], tenant_key: tenant_key) if keys.any?
|
|
@@ -72,6 +74,8 @@ module Truffler
|
|
|
72
74
|
|
|
73
75
|
def truffler_enqueue_labeling
|
|
74
76
|
definition = self.class.truffler_definition
|
|
77
|
+
return unless definition.indexable?(self)
|
|
78
|
+
|
|
75
79
|
if previously_new_record?
|
|
76
80
|
Labeling::Queue.new(self.class).enqueue(self)
|
|
77
81
|
return
|
|
@@ -105,6 +109,7 @@ module Truffler
|
|
|
105
109
|
definition = self.class.truffler_definition
|
|
106
110
|
return unless Embeddings.managed?(definition)
|
|
107
111
|
return unless previously_new_record? || saved_changes.keys.intersect?(definition.relabel_columns)
|
|
112
|
+
return unless definition.indexable?(self)
|
|
108
113
|
|
|
109
114
|
Jobs::EmbedJob.perform_later(self.class.polymorphic_name, id)
|
|
110
115
|
end
|
|
@@ -39,7 +39,7 @@ module Truffler
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def weak_local?
|
|
42
|
-
return @local_result.
|
|
42
|
+
return @local_result.local_weak? if @local_result
|
|
43
43
|
return @run.local_weak? if @run.respond_to?(:local_weak?)
|
|
44
44
|
unless @run.respond_to?(:candidate_ids)
|
|
45
45
|
raise ArgumentError, "Providers.start needs local_result: or a run that responds to local_weak? or candidate_ids"
|
|
@@ -134,7 +134,8 @@ module Truffler
|
|
|
134
134
|
end
|
|
135
135
|
|
|
136
136
|
query = Search::Query.new(request.state["query"])
|
|
137
|
-
roles, sources, soft = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, names
|
|
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))
|
|
138
139
|
tokens = ->(positions) { positions.map { |position| query.tokens[position] } }
|
|
139
140
|
Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent,
|
|
140
141
|
keyword_tokens: tokens.call(roles.keys.select { |position| roles[position] == "keyword" }),
|
|
@@ -206,16 +207,19 @@ module Truffler
|
|
|
206
207
|
# keyword naming an applied label becomes a label term; stopwords and
|
|
207
208
|
# filler words become filler unless they are all that would be left of
|
|
208
209
|
# an encoding that applies no label and no time range (Search::Filler).
|
|
209
|
-
# A word
|
|
210
|
-
#
|
|
211
|
-
|
|
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)
|
|
212
215
|
roles = query.tokens.each_index.to_h do |position|
|
|
213
|
-
|
|
216
|
+
role = query.time_position?(position) ? "time" : answered.fetch(position, "keyword")
|
|
217
|
+
[ position, role == "filler" && keep.include?(query.tokens[position].singularize) ? "keyword" : role ]
|
|
214
218
|
end
|
|
215
219
|
matches = roles.keys.to_h { |position| [ position, roles[position] == "time" ? {} : label_matches(query.tokens[position], names) ] }
|
|
216
220
|
words = roles.keys.select { |position| roles[position] == "keyword" && !query.exact_tokens.include?(query.tokens[position]) }
|
|
217
221
|
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]) }
|
|
222
|
+
filler = words.select { |position| roles[position] == "keyword" && Search::Filler.word?(query.tokens[position], keep: keep) }
|
|
219
223
|
Search::Filler.drop(filler, keyword_count: roles.values.count("keyword"), anchored: names.any? || !query.time_phrase.nil?,
|
|
220
224
|
stopword: ->(position) { Search::Filler.stopword?(query.tokens[position]) })
|
|
221
225
|
.each { |position| roles[position] = "filler" }
|
|
@@ -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
|
|
|
@@ -55,8 +55,10 @@ 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
|
-
|
|
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
|
|
|
@@ -66,7 +68,9 @@ module Truffler
|
|
|
66
68
|
freed = label_term_sources.select { |_, keys| keys.any? && keys.none? { |key| applied.include?(key) } }.keys
|
|
67
69
|
kept_time = (time unless suppressed.include?(TimeRange.key))
|
|
68
70
|
keywords = keyword_tokens && (keyword_tokens + freed).uniq
|
|
69
|
-
|
|
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
|
|
70
74
|
with(**kept, time: kept_time, label_term_tokens: label_term_tokens - freed,
|
|
71
75
|
label_term_sources: label_term_sources.except(*freed), soft_keyword_tokens: soft_keyword_tokens - freed,
|
|
72
76
|
keyword_tokens: keywords)
|
|
@@ -85,10 +89,12 @@ module Truffler
|
|
|
85
89
|
end
|
|
86
90
|
|
|
87
91
|
# Without encoder decisions (a cold cache), every search token that is
|
|
88
|
-
# not a label term, minus filler words (see Filler).
|
|
89
|
-
|
|
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)
|
|
90
95
|
keyword_tokens ||
|
|
91
|
-
Filler.keywords(query.search_tokens - label_term_tokens, anchored: !empty? || !time.nil?, exact: query.exact_tokens
|
|
96
|
+
Filler.keywords(query.search_tokens - label_term_tokens, anchored: !empty? || !time.nil?, exact: query.exact_tokens,
|
|
97
|
+
keep: keep&.call)
|
|
92
98
|
end
|
|
93
99
|
|
|
94
100
|
# The cache form: decisions plus token positions in the normalized
|
|
@@ -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 "
|
|
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
|
|
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
|
-
|
|
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")
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
@@ -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
|