truffler 0.1.2 → 0.1.3
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 +4 -0
- data/README.md +1 -1
- data/lib/truffler/query_encoding/encoder.rb +26 -17
- data/lib/truffler/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc0514a875fdc157249448de1514bb62560348ab4dfba22c75e3d0b0137b9072
|
|
4
|
+
data.tar.gz: 93b65a47fb89826536cdf03bd68efd824193488d20675789776400de2f19fb7a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4cbcba6f0deaee8afecbbe3e72988a175422b6ce42ba0f3d42b6d30b77f4736f28f485c21e15aac956fb31c4853724c30402872c52f8e5aff44acec36c0850a1
|
|
7
|
+
data.tar.gz: df78717442f56984544151b2962f3b78d724e001b6111d2ea92c9fd41903cb53c57117f85bac698bac4726b66000608b6a5132352eaa3c0fa30c688c893c1d04
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.3]
|
|
4
|
+
|
|
5
|
+
- Display-name and description words of an applied choice option now match query words exactly (ignoring case and plurals) instead of by shared three-letter prefix. Under a filter, ordinary search words such as "email", "inbox" and "summary" (with Cora applied) or "chat" and "change" (with billing applied) no longer become label terms and keep ranking results. Label keys and option keys still match by shared prefix, so "angry" names `anger`.
|
|
6
|
+
|
|
3
7
|
## [0.1.2]
|
|
4
8
|
|
|
5
9
|
Fixes from happyhappy's production `churn_risk` backfill and its 0.1.1 upgrade.
|
data/README.md
CHANGED
|
@@ -183,7 +183,7 @@ result = Email.truffler(params[:q], tenant: Current.account.id, scope: Current.a
|
|
|
183
183
|
|
|
184
184
|
A keystroke search makes no network call. It reads the query encoding and query vector from the cache. When the cache misses, it enqueues `EncodeQueryJob`, and the next keystroke or reload picks up the result.
|
|
185
185
|
|
|
186
|
-
Query encoding sends Jev the label vocabulary (each label's description and a choice label's option names) next to the query, and asks for each label whether the query filters on it, prefers it, or ignores it, plus the role of each word. A choice label's option question also offers `Truffler::NO_OPTION` (`"truffler:none"`), meaning the query names none of its options, so a host option literally called `none` stays filterable. Word roles are then checked locally: a word that names a label the query applies (its key, a word of its key, the chosen option
|
|
186
|
+
Query encoding sends Jev the label vocabulary (each label's description and a choice label's option names) next to the query, and asks for each label whether the query filters on it, prefers it, or ignores it, plus the role of each word. A choice label's option question also offers `Truffler::NO_OPTION` (`"truffler:none"`), meaning the query names none of its options, so a host option literally called `none` stays filterable. Word roles are then checked locally: a word that names a label the query applies (its key, a word of its key, or the chosen option key, ignoring case and plurals or sharing their first three letters when both words have four letters or more, so "angry" names `anger`; or, matched exactly, a word of that option's display name or description) counts as naming the label, and common stopwords are dropped. When the encoding applies a label filter, the filter decides which records match and keyword hits only rank them; without one, the remaining keywords must match.
|
|
187
187
|
|
|
188
188
|
Time phrases are handled in Ruby and never asked of Jev: `today`, `yesterday`, `this week`, `last week`, `this month`, `last month`, `past|last N hours|days|weeks|months`, `last hour`, `past hour`, `past week`, `past month`, and `since monday` through `since sunday`. "Past/last N units" is a rolling window ending now; "this/last week" and "this/last month" are calendar windows. The first one in a query limits results to records whose `arrived_at` column falls in that window, its words are not keywords, and it shows as a chip `{key: "time", label: "time", kind: :time, name: "This week"}`. Pass `"time"` in `suppressed:` to drop it. Weeks start on `Date.beginning_of_week`, and the window is computed from `Time.current` (or a `clock:` callable passed to the search, for tests).
|
|
189
189
|
|
|
@@ -120,6 +120,7 @@ module Truffler
|
|
|
120
120
|
filters = {}
|
|
121
121
|
boosts = {}
|
|
122
122
|
intent = {}
|
|
123
|
+
stems = []
|
|
123
124
|
terms = []
|
|
124
125
|
labels(model, tenant_key, user_key).each_value do |label|
|
|
125
126
|
key = storage_key(label, answers, tenant_key)
|
|
@@ -133,11 +134,12 @@ module Truffler
|
|
|
133
134
|
boosts[key] = intent[key] = label.boost || DEFAULT_BOOST
|
|
134
135
|
else next
|
|
135
136
|
end
|
|
136
|
-
|
|
137
|
+
stems.concat(label_terms(key))
|
|
138
|
+
terms.concat(name_terms(option_name(label, key, tenant_key)))
|
|
137
139
|
end
|
|
138
140
|
|
|
139
141
|
query = Search::Query.new(request.state["query"])
|
|
140
|
-
roles = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, terms.uniq)
|
|
142
|
+
roles = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, stems.uniq, terms.uniq)
|
|
141
143
|
keyword_tokens = query.tokens.each_index.filter_map { |position| query.tokens[position] if roles[position] == "keyword" }
|
|
142
144
|
label_term_tokens = query.tokens.each_index.filter_map { |position| query.tokens[position] if roles[position] == "label_term" }
|
|
143
145
|
Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent, keyword_tokens: keyword_tokens,
|
|
@@ -205,30 +207,36 @@ module Truffler
|
|
|
205
207
|
# exact tokens and unasked words are keywords; a keyword naming an
|
|
206
208
|
# applied label becomes a label term; stopwords become filler unless
|
|
207
209
|
# they are all that would be left of an encoding that applies nothing.
|
|
208
|
-
def reconcile(query, answered, terms)
|
|
210
|
+
def reconcile(query, answered, stems, terms)
|
|
209
211
|
roles = query.tokens.each_index.to_h do |position|
|
|
210
212
|
[ position, query.time_position?(position) ? "time" : answered.fetch(position, "keyword") ]
|
|
211
213
|
end
|
|
212
214
|
words = roles.keys.select { |position| roles[position] == "keyword" && !query.exact_tokens.include?(query.tokens[position]) }
|
|
213
|
-
words.each { |position| roles[position] = "label_term" if names_label?(query.tokens[position], terms) }
|
|
215
|
+
words.each { |position| roles[position] = "label_term" if names_label?(query.tokens[position], stems, terms) }
|
|
214
216
|
stopwords = words.select { |position| roles[position] == "keyword" && STOPWORDS.include?(query.tokens[position]) }
|
|
215
|
-
return roles if
|
|
217
|
+
return roles if stems.empty? && roles.values.count("keyword") == stopwords.size
|
|
216
218
|
|
|
217
219
|
stopwords.each { |position| roles[position] = "filler" }
|
|
218
220
|
roles
|
|
219
221
|
end
|
|
220
222
|
|
|
221
223
|
# "category:billing" names "category" and "billing"; "needs_action"
|
|
222
|
-
# names "needs_action", "needs", and "action".
|
|
223
|
-
#
|
|
224
|
-
# tool" also names "spiral", "writing", and "tool".
|
|
224
|
+
# names "needs_action", "needs", and "action". These key terms also
|
|
225
|
+
# match by shared stem (see names_label?).
|
|
225
226
|
STEM = 3
|
|
226
227
|
|
|
227
|
-
def label_terms(storage_key
|
|
228
|
+
def label_terms(storage_key)
|
|
228
229
|
label, option = Search::Encoding.split_key(storage_key)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
230
|
+
[ label.split(":").last, option ].compact.flat_map { |name| [ name.downcase, *name.downcase.split(/[^\p{Alnum}]+/) ] }
|
|
231
|
+
.reject(&:empty?).map(&:singularize)
|
|
232
|
+
end
|
|
233
|
+
|
|
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
|
|
236
|
+
# match exactly only: descriptions are prose, and a stem match on them
|
|
237
|
+
# would swallow ordinary search words ("chat" against "charge").
|
|
238
|
+
def name_terms(option_name)
|
|
239
|
+
option_name.to_s.downcase.split(/[^\p{Alnum}]+/).reject { |word| word.empty? || STOPWORDS.include?(word) }.map(&:singularize)
|
|
232
240
|
end
|
|
233
241
|
|
|
234
242
|
def option_name(label, storage_key, tenant_key)
|
|
@@ -237,12 +245,13 @@ module Truffler
|
|
|
237
245
|
label.options(tenant_key)[Search::Encoding.split_key(storage_key).last]
|
|
238
246
|
end
|
|
239
247
|
|
|
240
|
-
# "angry" names "anger": the same word ignoring plurals, or
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
def names_label?(word, terms)
|
|
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)
|
|
244
252
|
word = word.singularize
|
|
245
|
-
terms.
|
|
253
|
+
terms.include?(word) ||
|
|
254
|
+
stems.any? { |term| term == word || (word.length > STEM && term.length > STEM && word[0, STEM] == term[0, STEM]) }
|
|
246
255
|
end
|
|
247
256
|
|
|
248
257
|
def intent_instructions(label)
|
data/lib/truffler/version.rb
CHANGED