truffler 0.1.3 → 0.1.4
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 +11 -0
- data/README.md +8 -4
- data/bench/cassettes/1037e0b63ba790c02d14d0ddfc18b68151c11d03ce88d764658044b6bab2e55b.json +1 -0
- data/bench/cassettes/19bba849a846d30a69dc385a337d67704fbe5194506fd3c914d3270d86495fde.json +1 -0
- data/bench/cassettes/3768847dc57df8bf2328771e7763c0b8bd013a167244e7849967dac144420601.json +1 -0
- data/lib/tasks/truffler.rake +13 -3
- data/lib/truffler/configuration.rb +3 -0
- data/lib/truffler/label_definition.rb +42 -10
- data/lib/truffler/lenses/lens_label.rb +7 -0
- data/lib/truffler/query_encoding/encoder.rb +51 -38
- data/lib/truffler/search/encoding.rb +42 -10
- data/lib/truffler/search/encoding_cache.rb +4 -3
- data/lib/truffler/search/filler.rb +48 -0
- data/lib/truffler/search/sql.rb +23 -5
- data/lib/truffler/version.rb +1 -1
- data/lib/truffler/vocabulary.rb +13 -3
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3be74707295fff06a13ad0b12bbfb2d876f851893f0fff5f3f49b14c368bdb9b
|
|
4
|
+
data.tar.gz: 59a2e7d7e57669e8ffa35eadf656cc4e52fbae6cc47798f9ae4d01a43f870109
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e71fd94ea16d17b9ca2e2f219591b51dfc94ab82f749bd21d5d277389039e6fa30f3c29130dd07c2b8aecd588abe0e1d91e8c6e70b66d199b485a77a90cc0a59
|
|
7
|
+
data.tar.gz: b3ea709b2d26f377b3c7731e7cc020d5249780bfbc756ad1f01dde3506a542f80b34bc7e8846387527d9311cbbe0015c826ea90916fdd251fdaa596efef123c6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.4]
|
|
4
|
+
|
|
5
|
+
Fixes from happyhappy production.
|
|
6
|
+
|
|
7
|
+
- Generic filler nouns are no longer required keywords. "customers in the last 3 hours" returned nothing while "in the last 3 hours" returned the records. New `config.filler_words` (default: customer, customers, people, person, user, users, message, messages, email, emails, item, items, stuff, thing, things; replace or extend it, matched ignoring plurals). One rule now serves both the encoder's word reconciliation and the cold-cache keywords: stopwords and filler words are dropped unless that would leave the search with no keyword, no applied label, and no time phrase, so a lone "customers" still searches text and "customers refund this week" requires only "refund".
|
|
8
|
+
- `rake truffler:status` with no model prints the status of every registered Truffler model instead of " is not a Truffler model". An unknown model name still aborts with a message.
|
|
9
|
+
- A choice option may be `{ description:, search: }`: the long description stays what Jev labels with, and the short search text drives query-word matching and the request state's `option_names`. Plain descriptions and per-tenant callables keep working, and callables may return the new shape. Labeling fingerprints now cover only what Jev labels with: asked labels are unchanged (search texts are not part of the question), and a supplied label's fingerprint digests its type, option keys, legend, and `version:` but no longer its descriptions. Descriptions and search texts go into a separate encoding version that keys the query-encoding cache, so rewording re-encodes queries without staling labels.
|
|
10
|
+
- Removing a chip gives its words back to the keywords. The cached encoding records which applied labels each label-term word named (by token position, no query text); once every one of them is removed, the word is a keyword again, so "urgent refunds" without the urgent chip matches both words. A word that named a label or option key only by shared prefix adds a soft keyword score (a quarter of the keyword weight) and is never required.
|
|
11
|
+
- Upgrading from 0.1.3: supplied (`from:`) labels get a new fingerprint, so their stored rows and record states read stale once. `rake truffler:backfill` (or the next watched change) rewrites them at no Jev cost; the new vocabulary version also starts a fresh backfill spend ledger. Query encodings cached by 0.1.3 miss once and are re-encoded.
|
|
12
|
+
- Removing the chip that justified dropping a filler word (the time range, or the last applied label) brings the filler noun back as a keyword, cached or cold. When nothing anchors a search and only droppable words are left, filler nouns stay keywords and only pure stopwords are dropped ("the customers" searches "customers").
|
|
13
|
+
|
|
3
14
|
## [0.1.3]
|
|
4
15
|
|
|
5
16
|
- 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`.
|
data/README.md
CHANGED
|
@@ -92,6 +92,7 @@ Here is what each option does:
|
|
|
92
92
|
|
|
93
93
|
- `filter_at` makes a label a hard filter at that probability when the query asks for it. `boost` is the label's weight when the query prefers it. `filter_weight:` (default 0) is how much intent weight a filter adds on top.
|
|
94
94
|
- Choice `options:` may be a callable of the tenant key, which gives each tenant its own vocabulary. A tenant it gives no options (`{}` or nil) simply lacks the label: it is neither asked nor encoded there. The option name `truffler:none` is reserved (see `Truffler::NO_OPTION` below).
|
|
95
|
+
- An option's value may be `{ description: "...", search: "..." }` instead of a plain description, e.g. `options: { "prod-a1" => { description: "Cora, the AI email assistant that drafts replies...", search: "Cora email assistant" } }`. Jev labels records with the description; query words are matched against the short search text (else the description), and the request state's `option_names` carries it. A per-tenant callable may return the same shape. Editing a search text never stales labels or triggers a backfill; it only changes the query-encoding cache key, so queries are re-encoded.
|
|
95
96
|
- `watch :column, ...` relabels every label when one of those columns changes. Saving a record only relabels on columns that changed, so a `reads` field backed by a method (a conversation built from messages, say) needs the columns it is built from in `watch`.
|
|
96
97
|
- `keyword` also accepts a single callable, `->(scope, tokens) { relation }`.
|
|
97
98
|
- `embeddings column: :my_vector` searches a vector column you maintain yourself. Truffler never writes it.
|
|
@@ -103,7 +104,7 @@ Here is what each option does:
|
|
|
103
104
|
|---|---|---|
|
|
104
105
|
| `question:` | asked labels (required); optional with `from:` | What Jev is asked about each record. |
|
|
105
106
|
| `criteria:` | `:noul` | `{ true => "...", false => "..." }` guidance for Jev. |
|
|
106
|
-
| `options:` | `:choice` (required) | Option names, `{ option => description }`, or a callable of the tenant key. |
|
|
107
|
+
| `options:` | `:choice` (required) | Option names, `{ option => description }`, `{ option => { description:, search: } }`, or a callable of the tenant key returning any of these. |
|
|
107
108
|
| `legend:` | `:score` (required) | Two or more ordered levels, as an array or `{ index => description }`. |
|
|
108
109
|
| `filter_at:`, `boost:`, `filter_weight:` | all | Filter threshold, boost weight, and the intent weight a filter adds. |
|
|
109
110
|
| `description:` | all | The label's wording in query encoding. Defaults to the question, then to the key. |
|
|
@@ -158,7 +159,7 @@ To keep them fresh:
|
|
|
158
159
|
- Call `record.truffler_refresh_labels!` when the answers change somewhere truffler cannot see, such as the job that runs your classifier after insert. It rewrites the record's supplied labels immediately, with no Jev call.
|
|
159
160
|
- Bump `version:` when the logic behind `from:` changes. That changes the vocabulary version, so `rake truffler:backfill` rewrites the label for every record, again at no cost.
|
|
160
161
|
|
|
161
|
-
A supplied label's fingerprint digests its type,
|
|
162
|
+
A supplied label's fingerprint digests its type, option keys, legend, and `version:`, never the Jev model or its wording, so changing `config.model`, `description:`, or an option's description or search text does not rewrite supplied labels. Wording changes only re-key the query-encoding cache.
|
|
162
163
|
|
|
163
164
|
## Clients
|
|
164
165
|
|
|
@@ -183,7 +184,9 @@ result = Email.truffler(params[:q], tenant: Current.account.id, scope: Current.a
|
|
|
183
184
|
|
|
184
185
|
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
186
|
|
|
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
|
+
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 and `config.filler_words` (generic nouns such as "customers", "users", "emails") are dropped. They are kept only when dropping them would leave no keyword, no applied label, and no time phrase, so "customers in the last 3 hours" lists the window's records while a lone "customers" still searches text; the same rule applies before the encoding is cached. 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.
|
|
188
|
+
|
|
189
|
+
When the searcher removes a chip, the words that named only that label (or only labels that are now all removed) become keywords again, so removing the `urgent` chip from "urgent refunds" searches for both words. A word that named a label or option key only by shared prefix ("urgently" for `urgent`) also adds a small keyword score (a quarter of the keyword weight) while its label applies, ranking records whose text contains it higher without requiring it.
|
|
187
190
|
|
|
188
191
|
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
192
|
|
|
@@ -343,6 +346,7 @@ Set these in `Truffler.configure do |config| ... end`.
|
|
|
343
346
|
| `embedder` | `Embeddings::RubyLLMEmbedder.new` | Any `Embeddings::Embedder` subclass. The default calls `RubyLLM.embed`, which works on ruby_llm 1.x and 2. |
|
|
344
347
|
| `embedding_cost_per_million_tokens` | 0.02 | Embedding price. |
|
|
345
348
|
| `vector_store` | `:auto` | `:neighbor` (pgvector `<=>`, or sqlite-vec `vec_distance_cosine` when you load the extension), `:ruby` (exact cosine in Ruby), or `:auto` (neighbor when available, otherwise Ruby). A model declaring `embeddings column:` always reads its own column. |
|
|
349
|
+
| `filler_words` | `customer(s) people person user(s) message(s) email(s) item(s) stuff thing(s)` | Generic nouns never required as keywords on their own (matched ignoring plurals). Replace the list or extend it (`config.filler_words += %w[ticket]`). |
|
|
346
350
|
| `encoding_prefetch` | `QueryEncoding::Prefetch.new` | Cache-miss hook, called as `call(model, query, cache_key:, tenant_key:, user_key:)`. |
|
|
347
351
|
| `encoding_deadline` | 1.0 | Seconds a Smart run waits for an in-flight query encoding. |
|
|
348
352
|
| `rerank_depth`, `rerank_chunk_size`, `rerank_max_field_chars` | 30, 10, 1,200 | Candidates reranked, candidates per Jev request, characters per field sent. |
|
|
@@ -367,7 +371,7 @@ Truffler enqueues most of its own jobs. Run a worker for `config.queue_name` and
|
|
|
367
371
|
| `bin/rails "truffler:backfill[Email]"` (see Backfill) or `Truffler::Jobs::BackfillJob.perform_later("Email")` | After adopting Truffler, changing a declaration, or changing the model pin. |
|
|
368
372
|
| `Truffler::Embeddings::Backfill.new(Email).enqueue` | After enabling embeddings or changing the embedding model, width, or fields, to re-embed everything now instead of through the `ResumeJob` sweep. It enqueues 1,000 jobs at a time; pass `limit:` to cap the total. |
|
|
369
373
|
|
|
370
|
-
`bin/rails "truffler:status[Email]"` prints labeling counts and the backfill spend for the current vocabulary version. `bin/rails "truffler:suggestions[Email]"` prints candidate questions drawn from logged query misses.
|
|
374
|
+
`bin/rails "truffler:status[Email]"` prints labeling counts and the backfill spend for the current vocabulary version; `bin/rails truffler:status` prints them for every registered Truffler model. `bin/rails "truffler:suggestions[Email]"` prints candidate questions drawn from logged query misses.
|
|
371
375
|
|
|
372
376
|
With Solid Queue, for example:
|
|
373
377
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"answers":{"c001__relevance":{"noul":0.0,"type":"noul"},"c002__relevance":{"noul":0.0,"type":"noul"},"c003__relevance":{"noul":0.0,"type":"noul"},"c004__relevance":{"noul":0.0,"type":"noul"},"c005__relevance":{"noul":0.0,"type":"noul"}},"model":"synthetic-jev","request_hash":"1037e0b63ba790c02d14d0ddfc18b68151c11d03ce88d764658044b6bab2e55b","usage":{"input_tokens":873}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"answers":{"c001__relevance":{"noul":0.0,"type":"noul"},"c002__relevance":{"noul":0.0,"type":"noul"},"c003__relevance":{"noul":0.0,"type":"noul"},"c004__relevance":{"noul":0.0,"type":"noul"},"c005__relevance":{"noul":0.0,"type":"noul"},"c006__relevance":{"noul":0.0,"type":"noul"},"c007__relevance":{"noul":0.0,"type":"noul"}},"model":"synthetic-jev","request_hash":"19bba849a846d30a69dc385a337d67704fbe5194506fd3c914d3270d86495fde","usage":{"input_tokens":1164}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"answers":{"c001__relevance":{"noul":0.0,"type":"noul"},"c002__relevance":{"noul":0.0,"type":"noul"}},"model":"synthetic-jev","request_hash":"3768847dc57df8bf2328771e7763c0b8bd013a167244e7849967dac144420601","usage":{"input_tokens":417}}
|
data/lib/tasks/truffler.rake
CHANGED
|
@@ -47,9 +47,7 @@ namespace :truffler do
|
|
|
47
47
|
puts summary
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
task :status, [ :model ] => :setup do |_, args|
|
|
52
|
-
model = resolve_model.call(args[:model])
|
|
50
|
+
print_status = lambda do |model|
|
|
53
51
|
puts model.name
|
|
54
52
|
Truffler::Labeling::Backfill.status(model).each { |key, count| puts format(" %-9s %d", key, count) }
|
|
55
53
|
if Truffler::Records::BackfillSpend.available?
|
|
@@ -61,4 +59,16 @@ namespace :truffler do
|
|
|
61
59
|
puts format(" %-9s %s", "spent", "not tracked across runs; run bin/rails g truffler:upgrade && bin/rails db:migrate")
|
|
62
60
|
end
|
|
63
61
|
end
|
|
62
|
+
|
|
63
|
+
desc "Print a model's labeling counts by status and staleness (every registered Truffler model when none is named)"
|
|
64
|
+
task :status, [ :model ] => :setup do |_, args|
|
|
65
|
+
if args[:model].to_s.strip.empty?
|
|
66
|
+
Rails.application.eager_load! if defined?(Rails.application) && Rails.application
|
|
67
|
+
models = Truffler.registry.models.select { |model| model.try(:truffler_definition) }.sort_by(&:name)
|
|
68
|
+
abort "No Truffler models are registered" if models.empty?
|
|
69
|
+
models.each(&print_status)
|
|
70
|
+
else
|
|
71
|
+
print_status.call(resolve_model.call(args[:model]))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
64
74
|
end
|
|
@@ -17,6 +17,8 @@ module Truffler
|
|
|
17
17
|
attr_reader :lenses
|
|
18
18
|
attr_accessor :encoding_deadline, :rerank_depth, :rerank_chunk_size, :rerank_max_field_chars, :smart_thresholds,
|
|
19
19
|
:smart_run_ttl, :smart_candidate_pool, :broadcaster
|
|
20
|
+
# Generic nouns that are never required keywords on their own (Search::Filler).
|
|
21
|
+
attr_accessor :filler_words
|
|
20
22
|
|
|
21
23
|
def initialize(env: ENV)
|
|
22
24
|
@model = "jev-latest"
|
|
@@ -50,6 +52,7 @@ module Truffler
|
|
|
50
52
|
@smart_run_ttl = 15.minutes
|
|
51
53
|
@smart_candidate_pool = 200
|
|
52
54
|
@broadcaster = nil
|
|
55
|
+
@filler_words = Search::Filler::DEFAULT_WORDS.dup
|
|
53
56
|
end
|
|
54
57
|
|
|
55
58
|
def client
|
|
@@ -3,7 +3,9 @@ module Truffler
|
|
|
3
3
|
# normalized position, and choices one row per option ("label:option") with
|
|
4
4
|
# that option's probability. Choice options may be a callable of the tenant
|
|
5
5
|
# key, which makes the vocabulary per-tenant; a tenant it gives no options
|
|
6
|
-
# (`{}` or nil) simply does not have the label.
|
|
6
|
+
# (`{}` or nil) simply does not have the label. An option's value is its
|
|
7
|
+
# description, or `{ description:, search: }` to give query encoding a
|
|
8
|
+
# short search text apart from the classifier description.
|
|
7
9
|
#
|
|
8
10
|
# A label with `from:` is supplied by the host: its answer is read from the
|
|
9
11
|
# record in the shape Jev answers normalize to and Jev is never asked. Its
|
|
@@ -13,6 +15,7 @@ module Truffler
|
|
|
13
15
|
class LabelDefinition
|
|
14
16
|
TYPES = %i[noul choice score].freeze
|
|
15
17
|
KEY = /\A[a-z][a-z0-9_]*\z/
|
|
18
|
+
OPTION_FIELDS = %i[description search].freeze
|
|
16
19
|
|
|
17
20
|
attr_reader :key, :type, :instructions, :filter_at, :boost, :watch, :version
|
|
18
21
|
# Intent weight a filter decision adds to the KTD20 query vector (default 0).
|
|
@@ -49,13 +52,23 @@ module Truffler
|
|
|
49
52
|
@options.respond_to?(:call)
|
|
50
53
|
end
|
|
51
54
|
|
|
55
|
+
# {option => description}: the classifier wording Jev labels with. An
|
|
56
|
+
# option given as `{ description:, search: }` contributes its description.
|
|
52
57
|
def options(tenant_key = nil)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
options = options.to_h { |option, description| [ option.to_s, description ] }
|
|
56
|
-
raise DefinitionError, "#{key}: the option name #{NO_OPTION} is reserved" if options.key?(NO_OPTION)
|
|
58
|
+
option_entries(tenant_key).transform_values { |entry| entry[:description] }
|
|
59
|
+
end
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
# {option => name} for query words: the option's short search text, else
|
|
62
|
+
# its description; options with neither are left out.
|
|
63
|
+
def option_names(tenant_key = nil)
|
|
64
|
+
option_entries(tenant_key).transform_values { |entry| entry[:search].presence || entry[:description] }.compact
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# What query encoding reads beyond the labeling fingerprint: descriptions
|
|
68
|
+
# and search texts. Digested into the encoding cache key only, so
|
|
69
|
+
# rewording never stales stored labels.
|
|
70
|
+
def encoding_wording(tenant_key = nil)
|
|
71
|
+
{ description: description, options: (option_entries(tenant_key) if type == :choice) }
|
|
59
72
|
end
|
|
60
73
|
|
|
61
74
|
# False for a per-tenant choice with no options for this tenant.
|
|
@@ -75,11 +88,11 @@ module Truffler
|
|
|
75
88
|
end
|
|
76
89
|
|
|
77
90
|
# Stored supplied values change with their shape and version, never with
|
|
78
|
-
# the Jev model
|
|
79
|
-
#
|
|
91
|
+
# the Jev model or the wording: descriptions and search texts reach only
|
|
92
|
+
# query encoding (see encoding_wording).
|
|
80
93
|
def supplied_fingerprint(tenant_key = nil)
|
|
81
|
-
Canonical.digest(supplied: true, type: type, options: (options(tenant_key) if type == :choice),
|
|
82
|
-
levels: (levels if type == :score),
|
|
94
|
+
Canonical.digest(supplied: true, type: type, options: (options(tenant_key).keys if type == :choice),
|
|
95
|
+
levels: (levels if type == :score), version: version)
|
|
83
96
|
end
|
|
84
97
|
|
|
85
98
|
# {storage_key => value} read from the record, or nil when the host has
|
|
@@ -106,6 +119,25 @@ module Truffler
|
|
|
106
119
|
|
|
107
120
|
private
|
|
108
121
|
|
|
122
|
+
def option_entries(tenant_key)
|
|
123
|
+
options = per_tenant? ? @options.call(tenant_key) : @options
|
|
124
|
+
options = Array(options).to_h { |option| [ option, nil ] } unless options.is_a?(Hash)
|
|
125
|
+
options = options.to_h { |option, value| [ option.to_s, option_entry(option, value) ] }
|
|
126
|
+
raise DefinitionError, "#{key}: the option name #{NO_OPTION} is reserved" if options.key?(NO_OPTION)
|
|
127
|
+
|
|
128
|
+
options
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def option_entry(option, value)
|
|
132
|
+
return { description: value, search: nil } unless value.is_a?(Hash)
|
|
133
|
+
|
|
134
|
+
entry = value.to_h.symbolize_keys
|
|
135
|
+
unknown = entry.keys - OPTION_FIELDS
|
|
136
|
+
raise DefinitionError, "#{key}: option #{option} takes description: and search:, not #{unknown.join(', ')}" if unknown.any?
|
|
137
|
+
|
|
138
|
+
{ description: entry[:description], search: entry[:search] }
|
|
139
|
+
end
|
|
140
|
+
|
|
109
141
|
def levels
|
|
110
142
|
Array(@legend).size
|
|
111
143
|
end
|
|
@@ -29,6 +29,13 @@ module Truffler
|
|
|
29
29
|
@question["criteria"].to_h.transform_keys(&:to_s)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def option_names(tenant_key = nil)
|
|
33
|
+
options(tenant_key).compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The lens question, wording included, is already in its fingerprint.
|
|
37
|
+
def encoding_wording(_tenant_key = nil) = nil
|
|
38
|
+
|
|
32
39
|
def storage_keys(tenant_key = nil)
|
|
33
40
|
type == :choice ? options(tenant_key).keys.map { |option| "#{key}:#{option}" } : [ key ]
|
|
34
41
|
end
|
|
@@ -16,7 +16,7 @@ module Truffler
|
|
|
16
16
|
# label the query applies (its key, a word of its key, the chosen option,
|
|
17
17
|
# or a word of that option's display name, ignoring case and plurals, or
|
|
18
18
|
# sharing its first three letters) becomes a label term, and a common
|
|
19
|
-
# stopword becomes filler.
|
|
19
|
+
# stopword or `config.filler_words` noun becomes filler.
|
|
20
20
|
#
|
|
21
21
|
# Answers become a `Search::Encoding` with the KTD20 intent vector: boost
|
|
22
22
|
# gives the declared boost, filter narrows and adds `filter_weight`
|
|
@@ -35,10 +35,7 @@ module Truffler
|
|
|
35
35
|
"filler" => "A word that carries no meaning for the search"
|
|
36
36
|
}.freeze
|
|
37
37
|
NO_OPTION = Truffler::NO_OPTION
|
|
38
|
-
STOPWORDS =
|
|
39
|
-
a about all an and any are at be by for from have i in is it me my now of on or our please so some that the their
|
|
40
|
-
them there they this to up us was we what when where which who why with you your
|
|
41
|
-
].to_set.freeze
|
|
38
|
+
STOPWORDS = Search::Filler::STOPWORDS
|
|
42
39
|
|
|
43
40
|
Request = Data.define(:state, :questions, :token_ids, :exact_tokens, :unasked_tokens)
|
|
44
41
|
|
|
@@ -120,8 +117,7 @@ module Truffler
|
|
|
120
117
|
filters = {}
|
|
121
118
|
boosts = {}
|
|
122
119
|
intent = {}
|
|
123
|
-
|
|
124
|
-
terms = []
|
|
120
|
+
names = {}
|
|
125
121
|
labels(model, tenant_key, user_key).each_value do |label|
|
|
126
122
|
key = storage_key(label, answers, tenant_key)
|
|
127
123
|
next unless key
|
|
@@ -134,16 +130,17 @@ module Truffler
|
|
|
134
130
|
boosts[key] = intent[key] = label.boost || DEFAULT_BOOST
|
|
135
131
|
else next
|
|
136
132
|
end
|
|
137
|
-
|
|
138
|
-
terms.concat(name_terms(option_name(label, key, tenant_key)))
|
|
133
|
+
names[key] = [ label_terms(key), name_terms(option_name(label, key, tenant_key)) ]
|
|
139
134
|
end
|
|
140
135
|
|
|
141
136
|
query = Search::Query.new(request.state["query"])
|
|
142
|
-
roles = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) },
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
label_term_tokens:
|
|
137
|
+
roles, sources, soft = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, names)
|
|
138
|
+
tokens = ->(positions) { positions.map { |position| query.tokens[position] } }
|
|
139
|
+
Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent,
|
|
140
|
+
keyword_tokens: tokens.call(roles.keys.select { |position| roles[position] == "keyword" }),
|
|
141
|
+
label_term_tokens: tokens.call(sources.keys), soft_keyword_tokens: tokens.call(soft).uniq,
|
|
142
|
+
filler_tokens: tokens.call(roles.keys.select { |position| roles[position] == "filler" }).uniq,
|
|
143
|
+
label_term_sources: sources.group_by { |position, _| query.tokens[position] }.transform_values { |pairs| pairs.flat_map(&:last).uniq })
|
|
147
144
|
end
|
|
148
145
|
|
|
149
146
|
private
|
|
@@ -194,35 +191,43 @@ module Truffler
|
|
|
194
191
|
labels.transform_values do |label|
|
|
195
192
|
entry = { "description" => label.description }
|
|
196
193
|
if label.type == :choice
|
|
197
|
-
options = label.options(tenant_key)
|
|
198
|
-
|
|
199
|
-
names = options.compact
|
|
194
|
+
entry["options"] = label.options(tenant_key).keys
|
|
195
|
+
names = label.option_names(tenant_key)
|
|
200
196
|
entry["option_names"] = names if names.any?
|
|
201
197
|
end
|
|
202
198
|
entry
|
|
203
199
|
end
|
|
204
200
|
end
|
|
205
201
|
|
|
206
|
-
# {position => role} for every token
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
|
|
202
|
+
# Returns `[roles, sources, soft]`: {position => role} for every token,
|
|
203
|
+
# {label-term position => applied storage keys it names}, and the
|
|
204
|
+
# label-term positions that named a key only by prefix. Time phrase
|
|
205
|
+
# words are "time"; exact tokens and unasked words are keywords; a
|
|
206
|
+
# keyword naming an applied label becomes a label term; stopwords and
|
|
207
|
+
# filler words become filler unless they are all that would be left of
|
|
208
|
+
# 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)
|
|
211
212
|
roles = query.tokens.each_index.to_h do |position|
|
|
212
213
|
[ position, query.time_position?(position) ? "time" : answered.fetch(position, "keyword") ]
|
|
213
214
|
end
|
|
215
|
+
matches = roles.keys.to_h { |position| [ position, roles[position] == "time" ? {} : label_matches(query.tokens[position], names) ] }
|
|
214
216
|
words = roles.keys.select { |position| roles[position] == "keyword" && !query.exact_tokens.include?(query.tokens[position]) }
|
|
215
|
-
words.each { |position| roles[position] = "label_term" if
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
roles
|
|
217
|
+
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]) }
|
|
219
|
+
Search::Filler.drop(filler, keyword_count: roles.values.count("keyword"), anchored: names.any? || !query.time_phrase.nil?,
|
|
220
|
+
stopword: ->(position) { Search::Filler.stopword?(query.tokens[position]) })
|
|
221
|
+
.each { |position| roles[position] = "filler" }
|
|
222
|
+
label_terms = roles.keys.select { |position| roles[position] == "label_term" }
|
|
223
|
+
sources = label_terms.to_h { |position| [ position, matches[position].keys.presence || names.keys ] }
|
|
224
|
+
soft = (words & label_terms).select { |position| matches[position].values.all?(:prefix) }
|
|
225
|
+
[ roles, sources, soft ]
|
|
221
226
|
end
|
|
222
227
|
|
|
223
228
|
# "category:billing" names "category" and "billing"; "needs_action"
|
|
224
229
|
# names "needs_action", "needs", and "action". These key terms also
|
|
225
|
-
# match by shared stem (see
|
|
230
|
+
# match by shared stem (see label_matches).
|
|
226
231
|
STEM = 3
|
|
227
232
|
|
|
228
233
|
def label_terms(storage_key)
|
|
@@ -231,8 +236,9 @@ module Truffler
|
|
|
231
236
|
.reject(&:empty?).map(&:singularize)
|
|
232
237
|
end
|
|
233
238
|
|
|
234
|
-
# An option's display name
|
|
235
|
-
#
|
|
239
|
+
# An option's display name (its search text, else its description) adds
|
|
240
|
+
# its words minus stopwords: "p_17" shown as "Spiral writing tool" names
|
|
241
|
+
# "spiral", "writing", and "tool". These
|
|
236
242
|
# match exactly only: descriptions are prose, and a stem match on them
|
|
237
243
|
# would swallow ordinary search words ("chat" against "charge").
|
|
238
244
|
def name_terms(option_name)
|
|
@@ -242,16 +248,23 @@ module Truffler
|
|
|
242
248
|
def option_name(label, storage_key, tenant_key)
|
|
243
249
|
return unless label.type == :choice
|
|
244
250
|
|
|
245
|
-
label.
|
|
251
|
+
label.option_names(tenant_key)[Search::Encoding.split_key(storage_key).last]
|
|
246
252
|
end
|
|
247
253
|
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
|
|
254
|
+
# {storage key => :exact or :prefix} for the applied labels `word`
|
|
255
|
+
# names. "angry" names "anger": the same word ignoring plurals is
|
|
256
|
+
# :exact; against a label or option key, two words of four letters or
|
|
257
|
+
# more that share their first three letters are :prefix. Display-name
|
|
258
|
+
# words match exactly only.
|
|
259
|
+
def label_matches(word, names)
|
|
252
260
|
word = word.singularize
|
|
253
|
-
|
|
254
|
-
|
|
261
|
+
names.each_with_object({}) do |(key, (stems, terms)), matches|
|
|
262
|
+
if terms.include?(word) || stems.include?(word)
|
|
263
|
+
matches[key] = :exact
|
|
264
|
+
elsif word.length > STEM && stems.any? { |term| term.length > STEM && word[0, STEM] == term[0, STEM] }
|
|
265
|
+
matches[key] = :prefix
|
|
266
|
+
end
|
|
267
|
+
end
|
|
255
268
|
end
|
|
256
269
|
|
|
257
270
|
def intent_instructions(label)
|
|
@@ -10,11 +10,20 @@ module Truffler
|
|
|
10
10
|
# for the label term SUM(weight * value). Defaults to `boosts`; the
|
|
11
11
|
# encoder decides whether filtered labels also carry weight here.
|
|
12
12
|
# - `keyword_tokens`: query tokens for the keyword source; nil means
|
|
13
|
-
# every token that is not a label term.
|
|
13
|
+
# every token that is not a label term or filler.
|
|
14
14
|
# - `label_term_tokens`: tokens that named a label rather than a keyword.
|
|
15
|
+
# - `label_term_sources`: label-term token => the applied storage keys it
|
|
16
|
+
# named. Once the searcher removes every one of them, the token is a
|
|
17
|
+
# keyword again.
|
|
18
|
+
# - `soft_keyword_tokens`: label terms that named a label or option key
|
|
19
|
+
# only by shared prefix ("urgently" for urgent). They add a small
|
|
20
|
+
# keyword score and are never required.
|
|
21
|
+
# - `filler_tokens`: stopwords and filler words the encoder dropped.
|
|
22
|
+
# Removing the chips that justified the drop brings filler nouns back.
|
|
15
23
|
# - `time`: the query's `TimeRange`, resolved at search time and never
|
|
16
24
|
# cached, since "today" moves.
|
|
17
|
-
Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens, :
|
|
25
|
+
Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens, :label_term_sources,
|
|
26
|
+
:soft_keyword_tokens, :filler_tokens, :time) do
|
|
18
27
|
def self.load(value, query)
|
|
19
28
|
return value if value.is_a?(self)
|
|
20
29
|
return if value.nil?
|
|
@@ -22,15 +31,22 @@ module Truffler
|
|
|
22
31
|
tokens = query.tokens
|
|
23
32
|
new(filters: value["filters"], boosts: value["boosts"], intent_vector: value["intent_vector"],
|
|
24
33
|
keyword_tokens: value["keyword_positions"]&.map { |position| tokens[position] }&.compact,
|
|
25
|
-
label_term_tokens: Array(value["label_term_positions"]).filter_map { |position| tokens[position] }
|
|
34
|
+
label_term_tokens: Array(value["label_term_positions"]).filter_map { |position| tokens[position] },
|
|
35
|
+
label_term_sources: Array(value["label_term_sources"]).filter_map { |position, keys| [ tokens[position], keys ] if tokens[position] },
|
|
36
|
+
soft_keyword_tokens: Array(value["soft_keyword_positions"]).filter_map { |position| tokens[position] },
|
|
37
|
+
filler_tokens: Array(value["filler_positions"]).filter_map { |position| tokens[position] })
|
|
26
38
|
end
|
|
27
39
|
|
|
28
|
-
def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [],
|
|
40
|
+
def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [], label_term_sources: {},
|
|
41
|
+
soft_keyword_tokens: [], filler_tokens: [], time: nil)
|
|
29
42
|
filters = weights(filters)
|
|
30
43
|
boosts = weights(boosts)
|
|
31
44
|
intent_vector = weights(intent_vector || boosts).reject { |_, weight| weight.zero? }
|
|
45
|
+
sources = label_term_sources.to_h { |token, keys| [ token.to_s, Array(keys).map(&:to_s).freeze ] }
|
|
32
46
|
super(filters: filters.freeze, boosts: boosts.freeze, intent_vector: intent_vector.freeze,
|
|
33
|
-
keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze,
|
|
47
|
+
keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze,
|
|
48
|
+
label_term_sources: sources.freeze, soft_keyword_tokens: Array(soft_keyword_tokens).map(&:to_s).freeze,
|
|
49
|
+
filler_tokens: Array(filler_tokens).map(&:to_s).freeze, time: time)
|
|
34
50
|
end
|
|
35
51
|
|
|
36
52
|
def empty?
|
|
@@ -38,14 +54,22 @@ 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.
|
|
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.
|
|
42
59
|
def without(suppressed)
|
|
43
60
|
suppressed = Array(suppressed).map(&:to_s).to_set
|
|
44
61
|
return self if suppressed.empty?
|
|
45
62
|
|
|
46
63
|
keep = ->(key, _) { !suppressed.include?(key) && !suppressed.include?(self.class.split_key(key).first) }
|
|
47
|
-
|
|
48
|
-
|
|
64
|
+
kept = { filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep) }
|
|
65
|
+
applied = kept.values.flat_map(&:keys).to_set
|
|
66
|
+
freed = label_term_sources.select { |_, keys| keys.any? && keys.none? { |key| applied.include?(key) } }.keys
|
|
67
|
+
kept_time = (time unless suppressed.include?(TimeRange.key))
|
|
68
|
+
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)
|
|
49
73
|
end
|
|
50
74
|
|
|
51
75
|
# Splits a storage key into its label key and choice option. Lens keys
|
|
@@ -60,8 +84,11 @@ module Truffler
|
|
|
60
84
|
end
|
|
61
85
|
end
|
|
62
86
|
|
|
87
|
+
# Without encoder decisions (a cold cache), every search token that is
|
|
88
|
+
# not a label term, minus filler words (see Filler).
|
|
63
89
|
def keywords(query)
|
|
64
|
-
keyword_tokens ||
|
|
90
|
+
keyword_tokens ||
|
|
91
|
+
Filler.keywords(query.search_tokens - label_term_tokens, anchored: !empty? || !time.nil?, exact: query.exact_tokens)
|
|
65
92
|
end
|
|
66
93
|
|
|
67
94
|
# The cache form: decisions plus token positions in the normalized
|
|
@@ -70,7 +97,12 @@ module Truffler
|
|
|
70
97
|
def dump(query)
|
|
71
98
|
{ "filters" => filters, "boosts" => boosts, "intent_vector" => intent_vector,
|
|
72
99
|
"keyword_positions" => keyword_tokens && positions(query, keyword_tokens),
|
|
73
|
-
"label_term_positions" => positions(query, label_term_tokens)
|
|
100
|
+
"label_term_positions" => positions(query, label_term_tokens),
|
|
101
|
+
"label_term_sources" => query.tokens.each_index.filter_map do |position|
|
|
102
|
+
[ position, label_term_sources[query.tokens[position]] ] if label_term_sources.key?(query.tokens[position])
|
|
103
|
+
end,
|
|
104
|
+
"soft_keyword_positions" => positions(query, soft_keyword_tokens),
|
|
105
|
+
"filler_positions" => positions(query, filler_tokens) }
|
|
74
106
|
end
|
|
75
107
|
|
|
76
108
|
private
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
module Truffler
|
|
2
2
|
module Search
|
|
3
3
|
# Where keystroke search finds query encodings and query vectors (R12,
|
|
4
|
-
# R15). Keys digest the model, the normalized query, and the vocabulary
|
|
5
|
-
# version
|
|
4
|
+
# R15). Keys digest the model, the normalized query, and the vocabulary's
|
|
5
|
+
# encoding version (labeling fingerprints plus descriptions and option
|
|
6
|
+
# search texts), plus the tenant when the vocabulary has per-tenant choices.
|
|
6
7
|
# Values hold decisions and floats, never query text. Encoding keys use
|
|
7
8
|
# the searcher's vocabulary version, which holds the lenses that searcher
|
|
8
9
|
# can see (KTD21), so a personal lens's encoding is never shared; query
|
|
@@ -60,7 +61,7 @@ module Truffler
|
|
|
60
61
|
def digest(model, query, tenant_key, user_key)
|
|
61
62
|
definition = model.truffler_definition
|
|
62
63
|
tenant = tenant_key&.to_s if definition.per_tenant_vocabulary?
|
|
63
|
-
version = definition.vocabulary.
|
|
64
|
+
version = definition.vocabulary.encoding_version(tenant_key: tenant_key&.to_s, user_key: user_key)
|
|
64
65
|
Canonical.digest(record_type: model.polymorphic_name, query: query.normalized, vocabulary_version: version, tenant_key: tenant)
|
|
65
66
|
end
|
|
66
67
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
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 "emails",
|
|
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 email emails 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
|
+
def word?(word, filler_words: Truffler.config.filler_words)
|
|
24
|
+
word = word.to_s.downcase
|
|
25
|
+
stopword?(word) || Array(filler_words).any? { |filler| filler.to_s.downcase.singularize == word.singularize }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The subset of `candidates` (droppable keywords) to drop, given how many
|
|
29
|
+
# keywords there are and whether a label or time range is applied. When
|
|
30
|
+
# nothing anchors the search and only droppable words are left, the
|
|
31
|
+
# filler nouns stay as keywords and only pure stopwords go ("customers
|
|
32
|
+
# in the" searches "customers"); if every word is a stopword, all stay.
|
|
33
|
+
def drop(candidates, keyword_count:, anchored:, stopword: ->(candidate) { stopword?(candidate) })
|
|
34
|
+
return candidates if anchored || candidates.size < keyword_count
|
|
35
|
+
|
|
36
|
+
stopwords = candidates.select(&stopword)
|
|
37
|
+
stopwords.size == candidates.size ? [] : stopwords
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# `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)
|
|
44
|
+
tokens - dropped
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/truffler/search/sql.rb
CHANGED
|
@@ -5,12 +5,15 @@ module Truffler
|
|
|
5
5
|
# w_label * SUM(weight * value) over the intent's nonzero label keys
|
|
6
6
|
# + w_text * text similarity (inline SQL, or the store's top-K CASE)
|
|
7
7
|
# + w_keyword * keyword hit + w_exact * exact-source hit
|
|
8
|
+
# + SOFT_KEYWORD * w_keyword * soft keyword hit
|
|
8
9
|
#
|
|
9
10
|
# A weighted dot product, not cosine: cosine would divide out magnitude
|
|
10
11
|
# and let a record high on unrelated labels outrank the one the query
|
|
11
12
|
# asked for. Hard filters are EXISTS subqueries that run before scoring.
|
|
12
13
|
class Sql
|
|
13
14
|
LABELS = "truffler_labels".freeze
|
|
15
|
+
# Share of the keyword weight a soft keyword hit adds (see Encoding).
|
|
16
|
+
SOFT_KEYWORD = 0.25
|
|
14
17
|
|
|
15
18
|
attr_reader :model, :tenant_key, :query, :encoding, :vector, :weights
|
|
16
19
|
|
|
@@ -130,20 +133,28 @@ module Truffler
|
|
|
130
133
|
def keyword_sql
|
|
131
134
|
return @keyword_sql if defined?(@keyword_sql)
|
|
132
135
|
|
|
133
|
-
@keyword_sql = keywords.empty? ? nil : keyword_condition(definition.keyword)
|
|
136
|
+
@keyword_sql = keywords.empty? ? nil : keyword_condition(definition.keyword, keywords)
|
|
134
137
|
end
|
|
135
138
|
|
|
136
|
-
|
|
139
|
+
# Any soft keyword hit; soft keywords rank and never narrow.
|
|
140
|
+
def soft_keyword_sql
|
|
141
|
+
return @soft_keyword_sql if defined?(@soft_keyword_sql)
|
|
142
|
+
|
|
143
|
+
conditions = (encoding.soft_keyword_tokens - keywords).uniq.filter_map { |token| keyword_condition(definition.keyword, [ token ]) }
|
|
144
|
+
@soft_keyword_sql = conditions.empty? ? nil : conditions.map { |condition| "(#{condition})" }.join(" OR ")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def keyword_condition(source, tokens)
|
|
137
148
|
case source
|
|
138
149
|
when Array
|
|
139
150
|
return if source.empty?
|
|
140
151
|
|
|
141
|
-
|
|
152
|
+
tokens.map do |token|
|
|
142
153
|
pattern = quote("%#{model.sanitize_sql_like(token)}%")
|
|
143
154
|
"(#{source.map { |name| "LOWER(#{column(name)}) LIKE #{pattern} ESCAPE '\\'" }.join(' OR ')})"
|
|
144
155
|
end.join(" AND ")
|
|
145
156
|
when nil then nil
|
|
146
|
-
else membership_sql(source.call(tenant_scope,
|
|
157
|
+
else membership_sql(source.call(tenant_scope, tokens))
|
|
147
158
|
end
|
|
148
159
|
end
|
|
149
160
|
|
|
@@ -189,12 +200,19 @@ module Truffler
|
|
|
189
200
|
terms = {
|
|
190
201
|
truffler_label_score: (label_score_sql && "#{Float(weights[:label])} * #{label_score_sql}"),
|
|
191
202
|
truffler_text_score: (text_score_sql && "#{Float(weights[:text])} * #{text_score_sql}"),
|
|
192
|
-
truffler_keyword_score:
|
|
203
|
+
truffler_keyword_score: keyword_score_sql,
|
|
193
204
|
truffler_exact_score: (exact_sql && "#{Float(weights[:exact])} * (CASE WHEN #{exact_sql} THEN 1.0 ELSE 0.0 END)")
|
|
194
205
|
}.compact
|
|
195
206
|
{ truffler_score: terms.any? ? terms.values.map { |term| "(#{term})" }.join(" + ") : "0.0", **terms }
|
|
196
207
|
end
|
|
197
208
|
|
|
209
|
+
def keyword_score_sql
|
|
210
|
+
weight = Float(weights[:keyword])
|
|
211
|
+
terms = [ (keyword_sql && "#{weight} * (CASE WHEN #{keyword_sql} THEN 1.0 ELSE 0.0 END)"),
|
|
212
|
+
(soft_keyword_sql && "#{weight * SOFT_KEYWORD} * (CASE WHEN #{soft_keyword_sql} THEN 1.0 ELSE 0.0 END)") ].compact
|
|
213
|
+
terms.map { |term| "(#{term})" }.join(" + ") if terms.any?
|
|
214
|
+
end
|
|
215
|
+
|
|
198
216
|
def ordering
|
|
199
217
|
order_column, direction = definition.order
|
|
200
218
|
[ Arel.sql("truffler_score DESC"), (Arel.sql("#{column(order_column)} #{direction == :asc ? 'ASC' : 'DESC'}") if order_column),
|
data/lib/truffler/version.rb
CHANGED
data/lib/truffler/vocabulary.rb
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
module Truffler
|
|
2
2
|
# A label's fingerprint is the SHA-256 of its canonical question plus the
|
|
3
3
|
# pinned Jev model; a host-supplied label's is its supplied fingerprint
|
|
4
|
-
# (type,
|
|
5
|
-
# vocabulary version digests every fingerprint. A stored
|
|
6
|
-
# when its fingerprint differs from the current one.
|
|
4
|
+
# (type, option keys, version), which no Jev model change or rewording
|
|
5
|
+
# touches. The vocabulary version digests every fingerprint. A stored
|
|
6
|
+
# label is stale when its fingerprint differs from the current one.
|
|
7
7
|
#
|
|
8
8
|
# A scope's vocabulary also holds its active lens labels (KTD21), so a lens
|
|
9
9
|
# changes the version only where it applies. user_key is the searcher key
|
|
@@ -39,6 +39,16 @@ module Truffler
|
|
|
39
39
|
Canonical.digest(fingerprints(tenant_key: tenant_key, user_key: user_key, all_users: all_users))
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# The version query encodings are cached under: the labeling version plus
|
|
43
|
+
# a digest of the wording only query encoding reads (label descriptions,
|
|
44
|
+
# option descriptions and search texts), which labeling never sees for
|
|
45
|
+
# supplied labels and never sees for search texts.
|
|
46
|
+
def encoding_version(tenant_key: nil, user_key: nil)
|
|
47
|
+
labels = labels_for(tenant_key: tenant_key, user_key: user_key)
|
|
48
|
+
Canonical.digest(version: Canonical.digest(labels.transform_values { |label| fingerprint_of(label, tenant_key) }),
|
|
49
|
+
wording: labels.transform_values { |label| label.encoding_wording(tenant_key) }.compact)
|
|
50
|
+
end
|
|
51
|
+
|
|
42
52
|
private
|
|
43
53
|
|
|
44
54
|
def fingerprint_of(label, tenant_key)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: truffler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kieran Klaassen
|
|
@@ -102,12 +102,14 @@ files:
|
|
|
102
102
|
- bench/cassettes/0858c5006c0bfac3e970ddaff3c139f6c110dbe3d8539fd95263706c637cdc8e.json
|
|
103
103
|
- bench/cassettes/0c0eb7c27d76bd0b9263690a63cd92d6b68c305826318555fd5b084a09970b78.json
|
|
104
104
|
- bench/cassettes/0fada0c0a103882e2895f7f455d73d2cbe2b4a81bf743bb19b2b4f6e0130126b.json
|
|
105
|
+
- bench/cassettes/1037e0b63ba790c02d14d0ddfc18b68151c11d03ce88d764658044b6bab2e55b.json
|
|
105
106
|
- bench/cassettes/1117d79ac7c43b21beaf75e510461f91cd4ecc3cdbbe461f1794644af4aaf8f3.json
|
|
106
107
|
- bench/cassettes/11b7444a074cca4d0ef1535db0cf0edefe4c34eac4be3c593c9c2f33d3b038b8.json
|
|
107
108
|
- bench/cassettes/11d00371d1b993675be00463159164536f1f8ed90a48652b0935eb9c36f8b684.json
|
|
108
109
|
- bench/cassettes/1456ebe12616ce961d68a14eada62778a7271ebd3d1b4cb6eefd6857651c7afc.json
|
|
109
110
|
- bench/cassettes/14c3693f64b0e9ea7e470277fccae6ab501d404185eec5bb9074ef4b75c572c0.json
|
|
110
111
|
- bench/cassettes/14fe9f6d8055d41f5d970dc32ab640c213ea8782d365c82d32b9884ba4f28c67.json
|
|
112
|
+
- bench/cassettes/19bba849a846d30a69dc385a337d67704fbe5194506fd3c914d3270d86495fde.json
|
|
111
113
|
- bench/cassettes/1a1d8557e4698426c2467aa573a240357715d3d9e3fa77bf066672a8b6bf9a86.json
|
|
112
114
|
- bench/cassettes/1b5a8aaaf98305560588246cd51f53c6c185a951f8018c26bf588954d9de1cea.json
|
|
113
115
|
- bench/cassettes/1fe596be747e33e9c7e4d7c50de32613957ac66bee43679b4bed96c551b1fd0a.json
|
|
@@ -116,6 +118,7 @@ files:
|
|
|
116
118
|
- bench/cassettes/2f6576325dfab696cec8d8b35fa89c29960bee15d10dab31c91cef507f4842be.json
|
|
117
119
|
- bench/cassettes/33ec27d22c0f63dbbe93008f50af07fa8667a5c806a9fbfc7897683915412b40.json
|
|
118
120
|
- bench/cassettes/351f745b0c47e0cf3272b0fea7fc4941b6aa733f013c55a9a19707b542543200.json
|
|
121
|
+
- bench/cassettes/3768847dc57df8bf2328771e7763c0b8bd013a167244e7849967dac144420601.json
|
|
119
122
|
- bench/cassettes/37d974fdc0c1876413111c244d363c8d2306e3229fec668c158b5c924ffdb983.json
|
|
120
123
|
- bench/cassettes/39524467463dd612cbf03e314b9cfd8cbe1c5f06969da2289359cf7cf83de5da.json
|
|
121
124
|
- bench/cassettes/3f2c758c111ab7f1163ba579f2329718afe15620507d356d3ebe2a053df237ea.json
|
|
@@ -294,6 +297,7 @@ files:
|
|
|
294
297
|
- lib/truffler/registry.rb
|
|
295
298
|
- lib/truffler/search/encoding.rb
|
|
296
299
|
- lib/truffler/search/encoding_cache.rb
|
|
300
|
+
- lib/truffler/search/filler.rb
|
|
297
301
|
- lib/truffler/search/keystroke.rb
|
|
298
302
|
- lib/truffler/search/query.rb
|
|
299
303
|
- lib/truffler/search/result.rb
|