truffler 0.1.0 → 0.1.1
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 +13 -0
- data/README.md +13 -6
- data/lib/tasks/truffler.rake +10 -2
- data/lib/truffler/clients/fake.rb +8 -2
- data/lib/truffler/configuration.rb +2 -1
- data/lib/truffler/definition.rb +35 -2
- data/lib/truffler/label_definition.rb +14 -6
- data/lib/truffler/labeling/backfill.rb +1 -0
- data/lib/truffler/labeling/labeler.rb +1 -0
- data/lib/truffler/labeling/queue.rb +1 -0
- data/lib/truffler/labeling/supplied.rb +1 -0
- data/lib/truffler/lenses/drafter.rb +2 -1
- data/lib/truffler/model.rb +6 -5
- data/lib/truffler/query_encoding/encoder.rb +73 -14
- data/lib/truffler/search/encoding.rb +9 -6
- data/lib/truffler/search/keystroke.rb +14 -3
- data/lib/truffler/search/query.rb +18 -6
- data/lib/truffler/search/result.rb +4 -2
- data/lib/truffler/search/sql.rb +11 -3
- data/lib/truffler/search/time_phrase.rb +70 -0
- data/lib/truffler/search/time_range.rb +10 -0
- data/lib/truffler/smart_search/starter.rb +3 -3
- data/lib/truffler/version.rb +1 -1
- data/lib/truffler/vocabulary.rb +5 -3
- data/lib/truffler.rb +4 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 78736a9bfd83f38c6623b52904121d19a15c33f9d53bad271c1021968749c11d
|
|
4
|
+
data.tar.gz: 68f40b4143278a1ab48102aaa5d0146fc00c71885f332180dacdd2495e6e0a7d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2b98ccc3b180c487994c17eb6ed194e300ecace2948b549952a7b731e9bd5364c74a59656e93d8235bd6499aa41cb0926ea6ccf80acb9eed69e4946f5219b133
|
|
7
|
+
data.tar.gz: 860b3932fa42544edd3bee2ecf65ec3e90ae0aa8d9d9c6d8c1ea073dd9e5a0b7d7e4af78ab28dcd469aced26a47aa73e91b148878b98ea999fe8cf7cee5b3e5a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.1]
|
|
4
|
+
|
|
5
|
+
Fixes from the first host integration (happyhappy).
|
|
6
|
+
|
|
7
|
+
- Live queries no longer return zero results next to a label filter. Query encoding sends the label vocabulary (descriptions and choice option names) in the request state; a keyword that names an applied label or its option (including a shared first-three-letter stem, so "angry" names `anger`) becomes a label term and common stopwords become filler; and when a label filter applies, keyword hits only add to the score instead of being required.
|
|
8
|
+
- Declaring `truffler` on a model whose table does not exist yet (a fresh database during `db:prepare`) no longer raises. Column checks run on the first labeling or search instead and raise `DefinitionError` then if columns are still unknown.
|
|
9
|
+
- Query encoding's "no option" answer is the reserved `Truffler::NO_OPTION` (`"truffler:none"`), so a host choice option named `none` can be filtered. Host options may not use the reserved name.
|
|
10
|
+
- Time phrases (`today`, `yesterday`, `this/last week`, `this/last month`, `past/last N days/weeks`, `since <weekday>`) are parsed locally, never asked of Jev or matched as keywords, and limit results on the `arrived_at` column. They show as a removable `kind: :time` chip (suppress `"time"`); keystroke search accepts a `clock:` for tests.
|
|
11
|
+
- `watch:` works on asked labels, not only on `from:` labels, and a model-level `watch :column, ...` relabels every label when those columns change, for `reads` fields backed by methods.
|
|
12
|
+
- `Clients::Fake` answers unscripted choices with their neutral option (`ignore`, `Truffler::NO_OPTION`, `keyword`) when present, so unscripted host tests no longer filter on every label.
|
|
13
|
+
- `config.backfill_spend_cap` defaults to 5.0 USD for `BackfillJob`, `ResumeJob` backfills, and `rake truffler:backfill`. Set it to `nil`, or pass `SPEND_CAP=none`, to disable it; an unparseable `SPEND_CAP` aborts.
|
|
14
|
+
- A per-tenant choice whose options callable returns `{}` or nil for a tenant is left out of that tenant's vocabulary (not asked, not encoded) instead of raising.
|
|
15
|
+
|
|
3
16
|
## 0.1.0
|
|
4
17
|
|
|
5
18
|
- `truffler do ... end` declarations on Active Record models: tenant, fields, noul/choice/score labels, keyword and exact sources, embeddings, a provider, surfaces, ranking weights, and `weak_below`.
|
data/README.md
CHANGED
|
@@ -80,7 +80,8 @@ end
|
|
|
80
80
|
Here is what each option does:
|
|
81
81
|
|
|
82
82
|
- `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.
|
|
83
|
-
- Choice `options:` may be a callable of the tenant key, which gives each tenant its own vocabulary.
|
|
83
|
+
- 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).
|
|
84
|
+
- `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`.
|
|
84
85
|
- `keyword` also accepts a single callable, `->(scope, tokens) { relation }`.
|
|
85
86
|
- `embeddings column: :my_vector` searches a vector column you maintain yourself. Truffler never writes it.
|
|
86
87
|
- `embeddings` refuses to send encrypted fields to the embedding provider unless you pass `allow_encrypted: true`.
|
|
@@ -96,12 +97,12 @@ Here is what each option does:
|
|
|
96
97
|
| `filter_at:`, `boost:`, `filter_weight:` | all | Filter threshold, boost weight, and the intent weight a filter adds. |
|
|
97
98
|
| `description:` | all | The label's wording in query encoding. Defaults to the question, then to the key. |
|
|
98
99
|
| `from:` | all | `->(record) { answer }`. Makes the label host-supplied; Jev is never asked. See [Labels you already compute](#labels-you-already-compute). |
|
|
99
|
-
| `watch:` |
|
|
100
|
+
| `watch:` | all | Extra columns whose change refreshes (or, for an asked label, re-asks) just this label, in addition to `reads`. |
|
|
100
101
|
| `version:` | with `from:` | Any value; changing it rewrites the label for every record on the next backfill. |
|
|
101
102
|
|
|
102
103
|
Keys must be lowercase snake case without a double underscore, and `lens` is reserved.
|
|
103
104
|
|
|
104
|
-
Saving a record enqueues labeling (and embedding, when enabled) after commit, but only when a field in `reads`, the tenant column, or a
|
|
105
|
+
Saving a record enqueues labeling (and embedding, when enabled) after commit, but only when a column-backed field in `reads`, the tenant column, a model-level `watch` column, or a label's `watch:` column changed. Destroying a record removes its labels, state, and embeddings.
|
|
105
106
|
|
|
106
107
|
### Labels you already compute
|
|
107
108
|
|
|
@@ -171,10 +172,14 @@ result = Email.truffler(params[:q], tenant: Current.account.id, scope: Current.a
|
|
|
171
172
|
|
|
172
173
|
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.
|
|
173
174
|
|
|
175
|
+
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, ignoring case and plurals, or a word of four letters or more sharing its first three letters, so "angry" names `anger`) 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.
|
|
176
|
+
|
|
177
|
+
Time phrases are handled in Ruby and never asked of Jev: `today`, `yesterday`, `this week`, `last week`, `this month`, `last month`, `past|last N days|weeks`, and `since monday` through `since sunday`. 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).
|
|
178
|
+
|
|
174
179
|
The returned `Truffler::Search::Result` exposes:
|
|
175
180
|
|
|
176
181
|
- `records` and `ids`.
|
|
177
|
-
- `chips`: `[{key:, label:, kind: :filter | :boost, name:}]`.
|
|
182
|
+
- `chips`: `[{key:, label:, kind: :filter | :boost | :time, name:}]`.
|
|
178
183
|
- `invite_row`: `{query:, reason: :weak | :empty | :encoding_pending}` or nil.
|
|
179
184
|
- `encoding_status`: `:cached`, `:pending`, or `:none`.
|
|
180
185
|
- `watermark` and `new_matches_count`.
|
|
@@ -322,7 +327,7 @@ Set these in `Truffler.configure do |config| ... end`.
|
|
|
322
327
|
| `max_field_chars`, `request_token_budget`, `max_questions_per_request` | 4,000, 48,000, 200 | Request packing limits. |
|
|
323
328
|
| `queue_name` | `:default` | Queue for every Truffler job. |
|
|
324
329
|
| `cost_per_million_tokens` | 0.042 | Jev input price, used in usage events and estimates. |
|
|
325
|
-
| `backfill_spend_cap` |
|
|
330
|
+
| `backfill_spend_cap` | 5.0 | Dollar cap per backfill run (`BackfillJob` chains, `ResumeJob` backfills, and `truffler:backfill`). `nil` disables it; for the rake task, `SPEND_CAP=none` does. Supplied labels cost nothing and are still written once it is reached. |
|
|
326
331
|
| `resume_pending_after` | 5 minutes | How long before `ResumeJob` treats work as stuck. |
|
|
327
332
|
| `embedder` | `Embeddings::RubyLLMEmbedder.new` | Any `Embeddings::Embedder` subclass. The default calls `RubyLLM.embed`, which works on ruby_llm 1.x and 2. |
|
|
328
333
|
| `embedding_cost_per_million_tokens` | 0.02 | Embedding price. |
|
|
@@ -348,7 +353,7 @@ Truffler enqueues most of its own jobs. Run a worker for `config.queue_name` and
|
|
|
348
353
|
| `Truffler::Jobs::ResumeJob` | Every few minutes. Requeues failed and stuck labeling after an outage or a crashed worker, and enqueues up to 1,000 missing or stale embeddings per model per hour. |
|
|
349
354
|
| `Truffler::Jobs::PruneQueryMissesJob` | Daily. Enforces `miss_retention`. |
|
|
350
355
|
| `Truffler::Jobs::ExpireLensesJob` | Daily. Expires lenses unused for `lenses.expire_after`. |
|
|
351
|
-
| `bin/rails "truffler:backfill[Email]"` (`SPEND_CAP=
|
|
356
|
+
| `bin/rails "truffler:backfill[Email]"` (`SPEND_CAP=20`, or `none`; default `backfill_spend_cap`) or `Truffler::Jobs::BackfillJob.perform_later("Email")` | After adopting Truffler, changing a declaration, or changing the model pin. |
|
|
352
357
|
| `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. |
|
|
353
358
|
|
|
354
359
|
`bin/rails "truffler:status[Email]"` prints labeling counts. `bin/rails "truffler:suggestions[Email]"` prints candidate questions drawn from logged query misses.
|
|
@@ -423,3 +428,5 @@ bundle exec rubocop
|
|
|
423
428
|
```
|
|
424
429
|
|
|
425
430
|
Tests run on in-memory SQLite with fake clients. Any live Jev call raises `Truffler::LiveCallInTest`.
|
|
431
|
+
|
|
432
|
+
In your own tests, `Truffler::Clients::Fake` answers unscripted questions neutrally: nouls no, scores the lowest level, and choices their neutral option when they have one (`ignore` for a query's label intent, `Truffler::NO_OPTION` for an option question, `keyword` for a word role), otherwise the first option. An unscripted query encoding therefore applies no labels; script the ones a test needs, e.g. `fake.answer("intent__needs_action", "filter")`.
|
data/lib/tasks/truffler.rake
CHANGED
|
@@ -9,10 +9,18 @@ namespace :truffler do
|
|
|
9
9
|
model
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
resolve_spend_cap = lambda do |value|
|
|
13
|
+
case value.to_s.strip.downcase
|
|
14
|
+
when "" then Truffler.config.backfill_spend_cap
|
|
15
|
+
when "none" then nil
|
|
16
|
+
else Float(value, exception: false) || abort("SPEND_CAP must be a dollar amount or none, got #{value.inspect}")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "Backfill stale, missing, and failed labels for a model (SPEND_CAP=dollars or none; default config.backfill_spend_cap)"
|
|
13
21
|
task :backfill, [ :model ] => :setup do |_, args|
|
|
14
22
|
model = resolve_model.call(args[:model])
|
|
15
|
-
spend_cap = ENV
|
|
23
|
+
spend_cap = resolve_spend_cap.call(ENV.fetch("SPEND_CAP", nil))
|
|
16
24
|
result = Truffler::Labeling::Backfill.new(model, spend_cap: spend_cap).run
|
|
17
25
|
puts "#{model.name}: #{result.status}, #{result.labeled} labeled in #{result.requests} requests, $#{format('%.6f', result.cost)}"
|
|
18
26
|
end
|
|
@@ -9,8 +9,14 @@ module Truffler
|
|
|
9
9
|
# fake.answer(:urgency, 2) # score level
|
|
10
10
|
# fake.answer(:spam) { |tag, state| state.dig("records", tag, "body").include?("$$$") ? 0.9 : 0.1 }
|
|
11
11
|
#
|
|
12
|
-
# Unscripted questions answer no, the
|
|
12
|
+
# Unscripted questions answer no, the lowest level, or for a choice its
|
|
13
|
+
# neutral option when it has one (`ignore` for a query-encoding intent,
|
|
14
|
+
# `Truffler::NO_OPTION` for an option question, `keyword` for a word
|
|
15
|
+
# role), else the first option. So an unscripted query encoding applies
|
|
16
|
+
# no label.
|
|
13
17
|
class Fake < Base
|
|
18
|
+
NEUTRAL_OPTIONS = [ "ignore", NO_OPTION, "keyword" ].freeze
|
|
19
|
+
|
|
14
20
|
attr_reader :calls
|
|
15
21
|
|
|
16
22
|
def initialize(model: nil, &default)
|
|
@@ -67,7 +73,7 @@ module Truffler
|
|
|
67
73
|
probabilities = options.to_h { |option| [ option, 0.0 ] }
|
|
68
74
|
case value
|
|
69
75
|
when Hash then probabilities.merge!(value.transform_keys(&:to_s).transform_values(&:to_f))
|
|
70
|
-
when nil then probabilities[options.first] = 1.0
|
|
76
|
+
when nil then probabilities[(NEUTRAL_OPTIONS & options).first || options.first] = 1.0
|
|
71
77
|
else probabilities[value.to_s] = 1.0
|
|
72
78
|
end
|
|
73
79
|
pick, confidence = probabilities.max_by { |_, probability| probability }
|
|
@@ -3,6 +3,7 @@ module Truffler
|
|
|
3
3
|
DEFAULT_REQUESTS_PER_MINUTE = 1_200
|
|
4
4
|
DEFAULT_PRIORITY_CEILINGS = { live: 1.0, encode: 0.9, rerank: 0.75, backfill: 0.5 }.freeze
|
|
5
5
|
DEFAULT_USER_CAPS = { encode: 30, rerank: 10 }.freeze
|
|
6
|
+
DEFAULT_BACKFILL_SPEND_CAP = 5.0
|
|
6
7
|
|
|
7
8
|
attr_accessor :model, :cost_per_million_tokens, :requests_per_minute, :headroom, :priority_ceilings,
|
|
8
9
|
:user_caps, :tenant_live_cap, :max_wait, :batch_size, :grouping_window, :max_attempts,
|
|
@@ -35,7 +36,7 @@ module Truffler
|
|
|
35
36
|
@queue_name = :default
|
|
36
37
|
@miss_retention = 30.days
|
|
37
38
|
@miss_min_distinct_users = 5
|
|
38
|
-
@backfill_spend_cap =
|
|
39
|
+
@backfill_spend_cap = DEFAULT_BACKFILL_SPEND_CAP
|
|
39
40
|
@resume_pending_after = 5.minutes
|
|
40
41
|
@vector_store = :auto
|
|
41
42
|
@embedding_cost_per_million_tokens = 0.02
|
data/lib/truffler/definition.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Truffler
|
|
|
4
4
|
EXPLICIT_ACTIONS = %i[enter key row].freeze
|
|
5
5
|
DEFAULT_EMBEDDINGS = { model: "text-embedding-3-small", dimensions: 256 }.freeze
|
|
6
6
|
|
|
7
|
-
attr_reader :model, :fields, :labels, :exact_sources, :providers, :surfaces
|
|
7
|
+
attr_reader :model, :fields, :labels, :exact_sources, :providers, :surfaces, :watch_columns
|
|
8
8
|
attr_accessor :tenant_column, :keyword, :embeddings, :order, :arrived_at_column
|
|
9
9
|
|
|
10
10
|
def initialize(model)
|
|
@@ -14,6 +14,7 @@ module Truffler
|
|
|
14
14
|
@exact_sources = {}
|
|
15
15
|
@providers = {}
|
|
16
16
|
@surfaces = {}
|
|
17
|
+
@watch_columns = []
|
|
17
18
|
@arrived_at_column = "created_at"
|
|
18
19
|
end
|
|
19
20
|
|
|
@@ -39,6 +40,13 @@ module Truffler
|
|
|
39
40
|
labels.values.select(&:supplied?)
|
|
40
41
|
end
|
|
41
42
|
|
|
43
|
+
# Columns whose change relabels every label: the column-backed fields,
|
|
44
|
+
# the tenant column, and the model-level `watch` columns. Method-backed
|
|
45
|
+
# fields never show up in saved_changes, so they need `watch`.
|
|
46
|
+
def relabel_columns
|
|
47
|
+
[ *fields, tenant_column, *watch_columns ].compact
|
|
48
|
+
end
|
|
49
|
+
|
|
42
50
|
def per_tenant_vocabulary?
|
|
43
51
|
labels.each_value.any?(&:per_tenant?)
|
|
44
52
|
end
|
|
@@ -71,13 +79,28 @@ module Truffler
|
|
|
71
79
|
Array(model.try(:encrypted_attributes)).map(&:to_s) & fields
|
|
72
80
|
end
|
|
73
81
|
|
|
82
|
+
# Column checks wait for the table: a model declared at boot on a fresh
|
|
83
|
+
# database is checked on its first labeling or search instead.
|
|
74
84
|
def validate!
|
|
75
85
|
raise DefinitionError, "#{model.name}: declare the fields Jev reads with `reads`" if fields.empty?
|
|
76
86
|
|
|
77
|
-
|
|
87
|
+
@columns_deferred = !table_available?
|
|
88
|
+
validate_columns! unless @columns_deferred
|
|
78
89
|
check_embeddings if embeddings
|
|
79
90
|
end
|
|
80
91
|
|
|
92
|
+
def validate_columns!
|
|
93
|
+
return if @columns_checked
|
|
94
|
+
|
|
95
|
+
if @columns_deferred
|
|
96
|
+
return unless table_available?
|
|
97
|
+
|
|
98
|
+
model.reset_column_information
|
|
99
|
+
end
|
|
100
|
+
check_columns([ tenant_column, *fields, *Array(keyword).grep(String), *watch_columns, *labels.values.flat_map(&:watch) ].compact.uniq)
|
|
101
|
+
@columns_checked = true
|
|
102
|
+
end
|
|
103
|
+
|
|
81
104
|
DEFAULT_RANKING = { label: 1.0, text: 1.0, keyword: 0.5, exact: 1.0, min_similarity: 0.0 }.freeze
|
|
82
105
|
DEFAULT_WEAK_BELOW = 3
|
|
83
106
|
|
|
@@ -95,6 +118,12 @@ module Truffler
|
|
|
95
118
|
|
|
96
119
|
private
|
|
97
120
|
|
|
121
|
+
def table_available?
|
|
122
|
+
model.connection.data_source_exists?(model.table_name)
|
|
123
|
+
rescue ActiveRecord::ActiveRecordError
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
98
127
|
def check_columns(names)
|
|
99
128
|
columns = model.attribute_names
|
|
100
129
|
missing = names.reject { |name| columns.include?(name) || model.method_defined?(name) }
|
|
@@ -124,6 +153,10 @@ module Truffler
|
|
|
124
153
|
@definition.fields.concat(fields.map(&:to_s))
|
|
125
154
|
end
|
|
126
155
|
|
|
156
|
+
def watch(*columns)
|
|
157
|
+
@definition.watch_columns.concat(columns.map(&:to_s))
|
|
158
|
+
end
|
|
159
|
+
|
|
127
160
|
def label(key, type, **options)
|
|
128
161
|
@definition.add_label(LabelDefinition.new(key, type, **options))
|
|
129
162
|
end
|
|
@@ -2,12 +2,14 @@ module Truffler
|
|
|
2
2
|
# One typed label question. Nouls store their probability, scores their
|
|
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
|
-
# key, which makes the vocabulary per-tenant
|
|
5
|
+
# key, which makes the vocabulary per-tenant; a tenant it gives no options
|
|
6
|
+
# (`{}` or nil) simply does not have the label.
|
|
6
7
|
#
|
|
7
8
|
# A label with `from:` is supplied by the host: its answer is read from the
|
|
8
9
|
# record in the shape Jev answers normalize to and Jev is never asked. Its
|
|
9
|
-
# question is optional
|
|
10
|
-
# `
|
|
10
|
+
# question is optional and `version:` forces a refresh when its logic
|
|
11
|
+
# changes. On any label, `watch:` names extra columns whose change
|
|
12
|
+
# refreshes (or re-asks) just that label.
|
|
11
13
|
class LabelDefinition
|
|
12
14
|
TYPES = %i[noul choice score].freeze
|
|
13
15
|
KEY = /\A[a-z][a-z0-9_]*\z/
|
|
@@ -50,9 +52,15 @@ module Truffler
|
|
|
50
52
|
def options(tenant_key = nil)
|
|
51
53
|
options = per_tenant? ? @options.call(tenant_key) : @options
|
|
52
54
|
options = Array(options).to_h { |option| [ option, nil ] } unless options.is_a?(Hash)
|
|
53
|
-
|
|
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)
|
|
54
57
|
|
|
55
|
-
options
|
|
58
|
+
options
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# False for a per-tenant choice with no options for this tenant.
|
|
62
|
+
def available?(tenant_key = nil)
|
|
63
|
+
type != :choice || !per_tenant? || options(tenant_key).any?
|
|
56
64
|
end
|
|
57
65
|
|
|
58
66
|
def question(tenant_key = nil)
|
|
@@ -139,7 +147,7 @@ module Truffler
|
|
|
139
147
|
def validate_supplied!
|
|
140
148
|
unless supplied?
|
|
141
149
|
raise DefinitionError, "#{key}: a question is required" if instructions.blank?
|
|
142
|
-
raise DefinitionError, "#{key}:
|
|
150
|
+
raise DefinitionError, "#{key}: version: needs from:" unless @version.nil?
|
|
143
151
|
|
|
144
152
|
return
|
|
145
153
|
end
|
|
@@ -58,6 +58,7 @@ module Truffler
|
|
|
58
58
|
def initialize(model, spend_cap: Truffler.config.backfill_spend_cap, batch_size: Truffler.config.batch_size,
|
|
59
59
|
page_size: nil, cursor: nil, spent: 0.0, client: Truffler.config.client, budget: Budget.new)
|
|
60
60
|
@model = model
|
|
61
|
+
model.truffler_definition.validate_columns!
|
|
61
62
|
@batch_size = batch_size
|
|
62
63
|
@page_size = page_size || batch_size * 5
|
|
63
64
|
@cursor = cursor
|
|
@@ -84,7 +84,8 @@ module Truffler
|
|
|
84
84
|
# regenerated.
|
|
85
85
|
def vocabulary_for(model, scope, lens)
|
|
86
86
|
definition = model.truffler_definition
|
|
87
|
-
declared = definition.labels.
|
|
87
|
+
declared = definition.labels.select { |_, label| label.available?(scope.tenant_key) }
|
|
88
|
+
.to_h { |key, label| [ key, label.question(scope.tenant_key) ] }
|
|
88
89
|
visible = Lenses.visible_lenses(model, tenant_key: scope.tenant_key, user_digest: scope.user_digest)
|
|
89
90
|
visible = visible.reject { |other| other.id == lens&.id }
|
|
90
91
|
declared.merge(visible.each_with_object({}) { |other, all| all.merge!(other.storage_questions) })
|
data/lib/truffler/model.rb
CHANGED
|
@@ -62,8 +62,9 @@ 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
|
-
|
|
66
|
-
|
|
65
|
+
tenant_key = definition.tenant_key_for(self)
|
|
66
|
+
keys = definition.supplied_labels.select { |label| label.available?(tenant_key) }.map(&:key)
|
|
67
|
+
Labeling::Supplied.new(self.class).write([ [ self, keys ] ], tenant_key: tenant_key) if keys.any?
|
|
67
68
|
self
|
|
68
69
|
end
|
|
69
70
|
|
|
@@ -77,10 +78,10 @@ module Truffler
|
|
|
77
78
|
end
|
|
78
79
|
|
|
79
80
|
changed = saved_changes.keys
|
|
80
|
-
if changed.intersect?(
|
|
81
|
+
if changed.intersect?(definition.relabel_columns)
|
|
81
82
|
truffler_expire_labels
|
|
82
83
|
else
|
|
83
|
-
watched = definition.
|
|
84
|
+
watched = definition.labels.values.select { |label| changed.intersect?(label.watch) }
|
|
84
85
|
return if watched.empty?
|
|
85
86
|
|
|
86
87
|
truffler_expire_labels(watched.map(&:key))
|
|
@@ -103,7 +104,7 @@ module Truffler
|
|
|
103
104
|
def truffler_enqueue_embedding
|
|
104
105
|
definition = self.class.truffler_definition
|
|
105
106
|
return unless Embeddings.managed?(definition)
|
|
106
|
-
return unless previously_new_record? || saved_changes.keys.intersect?(
|
|
107
|
+
return unless previously_new_record? || saved_changes.keys.intersect?(definition.relabel_columns)
|
|
107
108
|
|
|
108
109
|
Jobs::EmbedJob.perform_later(self.class.polymorphic_name, id)
|
|
109
110
|
end
|
|
@@ -2,12 +2,19 @@ module Truffler
|
|
|
2
2
|
module QueryEncoding
|
|
3
3
|
# Encodes one pending query (KTD9). It asks a fixed question set: each
|
|
4
4
|
# label gets `filter | boost | ignore`, each choice label also gets its
|
|
5
|
-
# options plus `
|
|
5
|
+
# options plus `Truffler::NO_OPTION`, and each of the first 12 word tokens gets
|
|
6
6
|
# `keyword | label_term | filler`. Exact-text tokens (digits, dates,
|
|
7
7
|
# quoted phrases, emails, identifiers) are keywords decided locally and
|
|
8
8
|
# never asked (R18). Query text travels only in `state` ("query" and
|
|
9
9
|
# "tokens"); a token question names its word by position, `tokens[n]`,
|
|
10
|
-
# so searcher text never lands in an instruction (R8).
|
|
10
|
+
# so searcher text never lands in an instruction (R8). The label
|
|
11
|
+
# vocabulary rides along in `state["labels"]` so a token question can
|
|
12
|
+
# tell a word that names a label from text to match.
|
|
13
|
+
#
|
|
14
|
+
# Jev's word roles are then reconciled locally: a keyword that names a
|
|
15
|
+
# label the query applies (its key, a word of its key, or the chosen
|
|
16
|
+
# option, ignoring case and plurals, or sharing its first three letters) becomes a label term, and a common
|
|
17
|
+
# stopword becomes filler.
|
|
11
18
|
#
|
|
12
19
|
# Answers become a `Search::Encoding` with the KTD20 intent vector: boost
|
|
13
20
|
# gives the declared boost, filter narrows and adds `filter_weight`
|
|
@@ -22,10 +29,14 @@ module Truffler
|
|
|
22
29
|
}.freeze
|
|
23
30
|
TOKEN_ROLES = {
|
|
24
31
|
"keyword" => "A word to match in the record text",
|
|
25
|
-
"label_term" => "A word that names one of the labels
|
|
32
|
+
"label_term" => "A word that names one of the labels in `labels`, or one of its options, rather than text to match",
|
|
26
33
|
"filler" => "A word that carries no meaning for the search"
|
|
27
34
|
}.freeze
|
|
28
|
-
NO_OPTION =
|
|
35
|
+
NO_OPTION = Truffler::NO_OPTION
|
|
36
|
+
STOPWORDS = %w[
|
|
37
|
+
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
|
|
38
|
+
them there they this to up us was we what when where which who why with you your
|
|
39
|
+
].to_set.freeze
|
|
29
40
|
|
|
30
41
|
Request = Data.define(:state, :questions, :token_ids, :exact_tokens, :unasked_tokens)
|
|
31
42
|
|
|
@@ -54,16 +65,20 @@ module Truffler
|
|
|
54
65
|
criteria: options)
|
|
55
66
|
end
|
|
56
67
|
|
|
57
|
-
words = query.tokens.each_with_index.reject
|
|
68
|
+
words = query.tokens.each_with_index.reject do |token, position|
|
|
69
|
+
query.exact_tokens.include?(token) || query.time_position?(position)
|
|
70
|
+
end
|
|
58
71
|
asked = words.first(MAX_TOKEN_QUESTIONS)
|
|
59
72
|
token_ids = asked.to_h do |_token, position|
|
|
60
73
|
id = :"token__#{position}"
|
|
61
|
-
questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]?
|
|
74
|
+
questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]? The labels it may name, ) +
|
|
75
|
+
%(with their options, are in `labels`.), criteria: TOKEN_ROLES)
|
|
62
76
|
[ position, id.to_s ]
|
|
63
77
|
end
|
|
64
78
|
|
|
65
|
-
|
|
66
|
-
|
|
79
|
+
state = { "query" => query.normalized, "tokens" => query.tokens, "labels" => vocabulary_state(labels, tenant_key) }
|
|
80
|
+
Request.new(state: state, questions: questions.to_h, token_ids: token_ids, exact_tokens: query.exact_tokens,
|
|
81
|
+
unasked_tokens: words.drop(MAX_TOKEN_QUESTIONS).map(&:first))
|
|
67
82
|
end
|
|
68
83
|
|
|
69
84
|
# Encodes the query pending under `cache_key`. Returns the encoding, or
|
|
@@ -103,6 +118,7 @@ module Truffler
|
|
|
103
118
|
filters = {}
|
|
104
119
|
boosts = {}
|
|
105
120
|
intent = {}
|
|
121
|
+
terms = []
|
|
106
122
|
labels(model, tenant_key, user_key).each_value do |label|
|
|
107
123
|
key = storage_key(label, answers, tenant_key)
|
|
108
124
|
next unless key
|
|
@@ -113,15 +129,15 @@ module Truffler
|
|
|
113
129
|
intent[key] = label.filter_weight
|
|
114
130
|
when "boost"
|
|
115
131
|
boosts[key] = intent[key] = label.boost || DEFAULT_BOOST
|
|
132
|
+
else next
|
|
116
133
|
end
|
|
134
|
+
terms.concat(label_terms(key))
|
|
117
135
|
end
|
|
118
136
|
|
|
119
|
-
roles = request.token_ids.transform_values { |id| answers.choice(id) }
|
|
120
137
|
query = Search::Query.new(request.state["query"])
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
label_term_tokens = roles.filter_map { |position, role| query.tokens[position] if role == "label_term" }
|
|
138
|
+
roles = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, terms.uniq)
|
|
139
|
+
keyword_tokens = query.tokens.each_index.filter_map { |position| query.tokens[position] if roles[position] == "keyword" }
|
|
140
|
+
label_term_tokens = query.tokens.each_index.filter_map { |position| query.tokens[position] if roles[position] == "label_term" }
|
|
125
141
|
Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent, keyword_tokens: keyword_tokens,
|
|
126
142
|
label_term_tokens: label_term_tokens)
|
|
127
143
|
end
|
|
@@ -162,7 +178,7 @@ module Truffler
|
|
|
162
178
|
end
|
|
163
179
|
|
|
164
180
|
# The label's storage key the query names, or nil for a choice label
|
|
165
|
-
# whose option answer is
|
|
181
|
+
# whose option answer is NO_OPTION.
|
|
166
182
|
def storage_key(label, answers, tenant_key)
|
|
167
183
|
return label.key unless label.type == :choice
|
|
168
184
|
|
|
@@ -170,6 +186,49 @@ module Truffler
|
|
|
170
186
|
"#{label.key}:#{option}" if option != NO_OPTION && label.options(tenant_key).key?(option)
|
|
171
187
|
end
|
|
172
188
|
|
|
189
|
+
def vocabulary_state(labels, tenant_key)
|
|
190
|
+
labels.transform_values do |label|
|
|
191
|
+
entry = { "description" => label.description }
|
|
192
|
+
entry["options"] = label.options(tenant_key).keys if label.type == :choice
|
|
193
|
+
entry
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# {position => role} for every token. Time phrase words are "time";
|
|
198
|
+
# exact tokens and unasked words are keywords; a keyword naming an
|
|
199
|
+
# applied label becomes a label term; stopwords become filler unless
|
|
200
|
+
# they are all that would be left of an encoding that applies nothing.
|
|
201
|
+
def reconcile(query, answered, terms)
|
|
202
|
+
roles = query.tokens.each_index.to_h do |position|
|
|
203
|
+
[ position, query.time_position?(position) ? "time" : answered.fetch(position, "keyword") ]
|
|
204
|
+
end
|
|
205
|
+
words = roles.keys.select { |position| roles[position] == "keyword" && !query.exact_tokens.include?(query.tokens[position]) }
|
|
206
|
+
words.each { |position| roles[position] = "label_term" if names_label?(query.tokens[position], terms) }
|
|
207
|
+
stopwords = words.select { |position| roles[position] == "keyword" && STOPWORDS.include?(query.tokens[position]) }
|
|
208
|
+
return roles if terms.empty? && roles.values.count("keyword") == stopwords.size
|
|
209
|
+
|
|
210
|
+
stopwords.each { |position| roles[position] = "filler" }
|
|
211
|
+
roles
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# "category:billing" names "category" and "billing"; "needs_action"
|
|
215
|
+
# names "needs_action", "needs", and "action".
|
|
216
|
+
STEM = 3
|
|
217
|
+
|
|
218
|
+
def label_terms(storage_key)
|
|
219
|
+
label, option = Search::Encoding.split_key(storage_key)
|
|
220
|
+
[ label.split(":").last, option ].compact.flat_map { |name| [ name.downcase, *name.downcase.split(/[^\p{Alnum}]+/) ] }
|
|
221
|
+
.reject(&:empty?).map(&:singularize)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# "angry" names "anger": the same word ignoring plurals, or two words of
|
|
225
|
+
# four letters or more that share their first three letters. Only the
|
|
226
|
+
# applied labels' terms are compared, so the loose match stays safe.
|
|
227
|
+
def names_label?(word, terms)
|
|
228
|
+
word = word.singularize
|
|
229
|
+
terms.any? { |term| term == word || (word.length > STEM && term.length > STEM && word[0, STEM] == term[0, STEM]) }
|
|
230
|
+
end
|
|
231
|
+
|
|
173
232
|
def intent_instructions(label)
|
|
174
233
|
%(How does the search query use the label "#{label.key}" (#{label.description})?)
|
|
175
234
|
end
|
|
@@ -12,7 +12,9 @@ module Truffler
|
|
|
12
12
|
# - `keyword_tokens`: query tokens for the keyword source; nil means
|
|
13
13
|
# every token that is not a label term.
|
|
14
14
|
# - `label_term_tokens`: tokens that named a label rather than a keyword.
|
|
15
|
-
|
|
15
|
+
# - `time`: the query's `TimeRange`, resolved at search time and never
|
|
16
|
+
# cached, since "today" moves.
|
|
17
|
+
Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens, :time) do
|
|
16
18
|
def self.load(value, query)
|
|
17
19
|
return value if value.is_a?(self)
|
|
18
20
|
return if value.nil?
|
|
@@ -23,12 +25,12 @@ module Truffler
|
|
|
23
25
|
label_term_tokens: Array(value["label_term_positions"]).filter_map { |position| tokens[position] })
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [])
|
|
28
|
+
def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [], time: nil)
|
|
27
29
|
filters = weights(filters)
|
|
28
30
|
boosts = weights(boosts)
|
|
29
31
|
intent_vector = weights(intent_vector || boosts).reject { |_, weight| weight.zero? }
|
|
30
32
|
super(filters: filters.freeze, boosts: boosts.freeze, intent_vector: intent_vector.freeze,
|
|
31
|
-
keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze)
|
|
33
|
+
keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze, time: time)
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def empty?
|
|
@@ -36,13 +38,14 @@ module Truffler
|
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# The encoding minus the chips the searcher removed (R20), matched by
|
|
39
|
-
# storage key or by label key.
|
|
41
|
+
# storage key or by label key; "time" removes the time range.
|
|
40
42
|
def without(suppressed)
|
|
41
43
|
suppressed = Array(suppressed).map(&:to_s).to_set
|
|
42
44
|
return self if suppressed.empty?
|
|
43
45
|
|
|
44
46
|
keep = ->(key, _) { !suppressed.include?(key) && !suppressed.include?(self.class.split_key(key).first) }
|
|
45
|
-
with(filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep)
|
|
47
|
+
with(filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep),
|
|
48
|
+
time: (time unless suppressed.include?(TimeRange.key)))
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
# Splits a storage key into its label key and choice option. Lens keys
|
|
@@ -58,7 +61,7 @@ module Truffler
|
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def keywords(query)
|
|
61
|
-
keyword_tokens || (query.
|
|
64
|
+
keyword_tokens || (query.search_tokens - label_term_tokens)
|
|
62
65
|
end
|
|
63
66
|
|
|
64
67
|
# The cache form: decisions plus token positions in the normalized
|
|
@@ -10,9 +10,10 @@ module Truffler
|
|
|
10
10
|
attr_reader :model, :query, :tenant_key, :scope, :user_key, :suppressed, :surface, :limit
|
|
11
11
|
|
|
12
12
|
def initialize(model, query, tenant:, scope:, user: nil, suppressed: [], surface: nil, limit: DEFAULT_LIMIT, weights: {},
|
|
13
|
-
cache: EncodingCache.new)
|
|
13
|
+
cache: EncodingCache.new, clock: -> { Time.current })
|
|
14
14
|
@model = model
|
|
15
15
|
@definition = model.try(:truffler_definition) || raise(DefinitionError, "#{model.name} has no truffler declaration")
|
|
16
|
+
@definition.validate_columns!
|
|
16
17
|
@query = Query.wrap(query)
|
|
17
18
|
@tenant_key = tenant&.to_s
|
|
18
19
|
@scope = scope.nil? && !@definition.scoped? ? model.all : scope
|
|
@@ -22,6 +23,7 @@ module Truffler
|
|
|
22
23
|
@limit = limit
|
|
23
24
|
@weights = @definition.ranking.merge(weights.to_h { |key, weight| [ key.to_sym, Float(weight) ] })
|
|
24
25
|
@cache = cache
|
|
26
|
+
@clock = clock
|
|
25
27
|
check_scope!
|
|
26
28
|
end
|
|
27
29
|
|
|
@@ -39,7 +41,7 @@ module Truffler
|
|
|
39
41
|
explicit_action = surface_action
|
|
40
42
|
cached = read_encoding
|
|
41
43
|
status = encoding_status(cached)
|
|
42
|
-
encoding = visible_lenses_only(cached&.without(suppressed), record_usage: true)
|
|
44
|
+
encoding = with_time(visible_lenses_only(cached&.without(suppressed), record_usage: true))
|
|
43
45
|
sql = sql(encoding)
|
|
44
46
|
records = sql.relation(scope, limit: limit).to_a
|
|
45
47
|
result = Result.new(records: records, query: query, encoding: encoding, encoding_status: status, watermark: watermark,
|
|
@@ -52,7 +54,7 @@ module Truffler
|
|
|
52
54
|
# How many records the same search would return that arrived after
|
|
53
55
|
# `since` (R25). Reads the cache only and never prefetches.
|
|
54
56
|
def count(since:)
|
|
55
|
-
sql(visible_lenses_only(read_encoding&.without(suppressed))).candidates(scope)
|
|
57
|
+
sql(with_time(visible_lenses_only(read_encoding&.without(suppressed)))).candidates(scope)
|
|
56
58
|
.where(model.arel_table[@definition.arrived_at_column].gt(since)).count
|
|
57
59
|
end
|
|
58
60
|
|
|
@@ -91,6 +93,15 @@ module Truffler
|
|
|
91
93
|
encoding.without(hidden)
|
|
92
94
|
end
|
|
93
95
|
|
|
96
|
+
# The query's time phrase, resolved on this search's clock, unless the
|
|
97
|
+
# searcher removed its chip.
|
|
98
|
+
def with_time(encoding)
|
|
99
|
+
phrase = query.time_phrase
|
|
100
|
+
return encoding if phrase.nil? || suppressed.include?(TimeRange.key)
|
|
101
|
+
|
|
102
|
+
(encoding || Encoding.new).with(time: phrase.range(@clock.call))
|
|
103
|
+
end
|
|
104
|
+
|
|
94
105
|
def lens_id(key)
|
|
95
106
|
prefix, id = key.split(":", 3)
|
|
96
107
|
Integer(id, exception: false) if prefix == Lenses::KEY_PREFIX
|
|
@@ -3,14 +3,15 @@ module Truffler
|
|
|
3
3
|
# A normalized, tokenized search query. Quoted phrases stay one token.
|
|
4
4
|
# Exact-text signals (quoted phrases, digit-bearing tokens, emails, and
|
|
5
5
|
# identifier shapes such as `INV-4471` or `order_id`) are detected
|
|
6
|
-
# locally and never asked of Jev (R18)
|
|
6
|
+
# locally and never asked of Jev (R18), and so is a time phrase ("this
|
|
7
|
+
# week"), whose words are neither exact tokens nor keywords.
|
|
7
8
|
class Query
|
|
8
9
|
QUOTED = /"([^"]*)"/
|
|
9
10
|
EMAIL = /\A[^@\s]+@[^@\s]+\.[a-z]{2,}\z/
|
|
10
11
|
IDENTIFIER = /\A[a-z0-9]+(?:[-_.][a-z0-9]+)+\z/
|
|
11
12
|
EDGE_PUNCTUATION = /\A[^\p{Alnum}@]+|[^\p{Alnum}]+\z/
|
|
12
13
|
|
|
13
|
-
attr_reader :raw, :normalized, :tokens, :exact_tokens
|
|
14
|
+
attr_reader :raw, :normalized, :tokens, :exact_tokens, :time_phrase
|
|
14
15
|
|
|
15
16
|
def self.wrap(query)
|
|
16
17
|
query.is_a?(self) ? query : new(query)
|
|
@@ -23,7 +24,18 @@ module Truffler
|
|
|
23
24
|
def initialize(raw)
|
|
24
25
|
@raw = raw.to_s
|
|
25
26
|
@normalized = self.class.normalize(raw)
|
|
26
|
-
@tokens,
|
|
27
|
+
@tokens, exact = tokenize(normalized)
|
|
28
|
+
@time_phrase = TimePhrase.find(@tokens)
|
|
29
|
+
@exact_tokens = exact.reject { |position| time_position?(position) }.map { |position| @tokens[position] }.freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def time_position?(position)
|
|
33
|
+
time_phrase&.positions&.include?(position) || false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The tokens a keyword source may match: all but the time phrase.
|
|
37
|
+
def search_tokens
|
|
38
|
+
tokens.each_index.reject { |position| time_position?(position) }.map { |position| tokens[position] }
|
|
27
39
|
end
|
|
28
40
|
|
|
29
41
|
def blank?
|
|
@@ -44,19 +56,19 @@ module Truffler
|
|
|
44
56
|
phrase = part.squish
|
|
45
57
|
next if phrase.empty?
|
|
46
58
|
|
|
59
|
+
exact << tokens.size
|
|
47
60
|
tokens << phrase
|
|
48
|
-
exact << phrase
|
|
49
61
|
else
|
|
50
62
|
part.split.each do |word|
|
|
51
63
|
word = word.gsub(EDGE_PUNCTUATION, "")
|
|
52
64
|
next if word.empty?
|
|
53
65
|
|
|
66
|
+
exact << tokens.size if exact?(word)
|
|
54
67
|
tokens << word
|
|
55
|
-
exact << word if exact?(word)
|
|
56
68
|
end
|
|
57
69
|
end
|
|
58
70
|
end
|
|
59
|
-
[ tokens.freeze, exact
|
|
71
|
+
[ tokens.freeze, exact ]
|
|
60
72
|
end
|
|
61
73
|
|
|
62
74
|
def exact?(word)
|
|
@@ -29,12 +29,14 @@ module Truffler
|
|
|
29
29
|
records.map(&:id)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# Applied filters, then boosts, as `{key:, label:, kind:, name:}`.
|
|
32
|
+
# Applied filters, then boosts, then the time range, as `{key:, label:, kind:, name:}`.
|
|
33
33
|
def chips
|
|
34
34
|
return [] unless encoding
|
|
35
35
|
|
|
36
36
|
filters = encoding.filters.keys.map { |key| chip(key, :filter) }
|
|
37
|
-
filters + (encoding.boosts.keys - encoding.filters.keys).map { |key| chip(key, :boost) }
|
|
37
|
+
chips = filters + (encoding.boosts.keys - encoding.filters.keys).map { |key| chip(key, :boost) }
|
|
38
|
+
time = encoding.time
|
|
39
|
+
time ? chips + [ { key: TimeRange.key, label: TimeRange.key, kind: :time, name: time.name } ] : chips
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
def score(record)
|
data/lib/truffler/search/sql.rb
CHANGED
|
@@ -24,16 +24,18 @@ module Truffler
|
|
|
24
24
|
@store = store
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# The caller's relation, ANDed with the tenant and the hard filters.
|
|
27
|
+
# The caller's relation, ANDed with the tenant, the time range, and the hard filters.
|
|
28
28
|
def base(scope)
|
|
29
29
|
scope = scope.where(definition.tenant_column => tenant_key) if definition.scoped?
|
|
30
|
+
scope = within_time(scope, encoding.time) if encoding.time
|
|
30
31
|
encoding.filters.reduce(scope) { |relation, (key, threshold)| relation.where(Arel.sql(label_filter_sql(key, threshold))) }
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
# Every record the query can return, before ranking.
|
|
34
|
+
# Every record the query can return, before ranking. Under a label
|
|
35
|
+
# filter the filter decides membership and text matches only rank.
|
|
34
36
|
def candidates(scope)
|
|
35
37
|
base = base(scope)
|
|
36
|
-
return base if label_only?
|
|
38
|
+
return base if label_only? || encoding.filters.any?
|
|
37
39
|
|
|
38
40
|
conditions = [ keyword_sql, exact_sql, (text_candidate_sql if text_score_sql) ].compact
|
|
39
41
|
base.where(Arel.sql(conditions.any? ? conditions.map { |condition| "(#{condition})" }.join(" OR ") : "1 = 0"))
|
|
@@ -104,6 +106,12 @@ module Truffler
|
|
|
104
106
|
"#{label_column('record_type')} = #{quote(model.polymorphic_name)}#{tenant} AND #{label_column('record_id')} = #{primary_key}"
|
|
105
107
|
end
|
|
106
108
|
|
|
109
|
+
def within_time(scope, range)
|
|
110
|
+
arrived_at = model.arel_table[definition.arrived_at_column]
|
|
111
|
+
scope = scope.where(arrived_at.gteq(range.from))
|
|
112
|
+
range.to ? scope.where(arrived_at.lt(range.to)) : scope
|
|
113
|
+
end
|
|
114
|
+
|
|
107
115
|
def label_filter_sql(key, threshold)
|
|
108
116
|
"EXISTS (SELECT 1 FROM #{quoted_labels} WHERE #{label_scope_sql} AND #{label_column('label_key')} = #{quote(key)} " \
|
|
109
117
|
"AND #{label_column('value')} >= #{Float(threshold)})"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Search
|
|
3
|
+
# A time window named in a search query (R16, R18): computed here from
|
|
4
|
+
# the query tokens and a clock, never asked of Jev. Matching needs no
|
|
5
|
+
# clock, so the phrase is part of the query; `window(now)` resolves it
|
|
6
|
+
# to `[from, to]`, where `to` is nil for "until now".
|
|
7
|
+
#
|
|
8
|
+
# today, yesterday, this week, last week, this month, last month,
|
|
9
|
+
# past|last N day(s)|week(s), since monday..sunday
|
|
10
|
+
#
|
|
11
|
+
# Weeks start on `Date.beginning_of_week` (Monday by default). Only the
|
|
12
|
+
# first phrase in a query counts; a quoted phrase is exact text.
|
|
13
|
+
TimePhrase = Data.define(:name, :positions, :resolver) do
|
|
14
|
+
def self.find(tokens)
|
|
15
|
+
tokens.each_index do |start|
|
|
16
|
+
PATTERNS.each do |pattern|
|
|
17
|
+
phrase = pattern.call(tokens, start)
|
|
18
|
+
return phrase if phrase
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def window(now)
|
|
25
|
+
resolver.call(now)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def range(now)
|
|
29
|
+
from, to = window(now)
|
|
30
|
+
TimeRange.new(name: name, from: from, to: to)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.fixed(words, name, &resolver)
|
|
34
|
+
lambda do |tokens, start|
|
|
35
|
+
new(name: name, positions: (start...start + words.size).to_a, resolver: resolver) if tokens[start, words.size] == words
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.rolling(tokens, start)
|
|
40
|
+
return unless %w[past last].include?(tokens[start]) && tokens[start + 1].to_s.match?(/\A\d{1,3}\z/)
|
|
41
|
+
|
|
42
|
+
count = Integer(tokens[start + 1], 10)
|
|
43
|
+
unit = { "day" => :days, "days" => :days, "week" => :weeks, "weeks" => :weeks }[tokens[start + 2]]
|
|
44
|
+
return unless unit && count.positive?
|
|
45
|
+
|
|
46
|
+
name = "Last #{count} #{count == 1 ? unit.to_s.singularize : unit}"
|
|
47
|
+
new(name: name, positions: [ start, start + 1, start + 2 ], resolver: ->(now) { [ now - count.public_send(unit), nil ] })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.since(tokens, start)
|
|
51
|
+
wday = Date::DAYNAMES.map(&:downcase).index(tokens[start + 1]) if tokens[start] == "since"
|
|
52
|
+
return unless wday
|
|
53
|
+
|
|
54
|
+
new(name: "Since #{Date::DAYNAMES[wday]}", positions: [ start, start + 1 ],
|
|
55
|
+
resolver: ->(now) { [ (now - ((now.wday - wday) % 7).days).beginning_of_day, nil ] })
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
PATTERNS = [
|
|
59
|
+
method(:rolling),
|
|
60
|
+
method(:since),
|
|
61
|
+
fixed(%w[today], "Today") { |now| [ now.beginning_of_day, nil ] },
|
|
62
|
+
fixed(%w[yesterday], "Yesterday") { |now| [ now.yesterday.beginning_of_day, now.beginning_of_day ] },
|
|
63
|
+
fixed(%w[this week], "This week") { |now| [ now.beginning_of_week, nil ] },
|
|
64
|
+
fixed(%w[last week], "Last week") { |now| [ now.prev_week.beginning_of_week, now.beginning_of_week ] },
|
|
65
|
+
fixed(%w[this month], "This month") { |now| [ now.beginning_of_month, nil ] },
|
|
66
|
+
fixed(%w[last month], "Last month") { |now| [ now.prev_month.beginning_of_month, now.beginning_of_month ] }
|
|
67
|
+
].freeze
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Search
|
|
3
|
+
# A resolved time phrase: records whose `arrived_at` column is at or
|
|
4
|
+
# after `from` and, when `to` is set, before it. Shown as the chip
|
|
5
|
+
# `{key: "time", kind: :time}` and dropped by suppressing "time".
|
|
6
|
+
TimeRange = Data.define(:name, :from, :to) do
|
|
7
|
+
def self.key = "time"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -55,13 +55,13 @@ module Truffler
|
|
|
55
55
|
pool = local_ids.first(depth)
|
|
56
56
|
return pool if result.encoding_status == :cached || definition.labels.empty? || pool.size >= depth
|
|
57
57
|
|
|
58
|
-
pool + newest_in_scope(exclude: pool, limit: [ @config.smart_candidate_pool - pool.size, 0 ].max)
|
|
58
|
+
pool + newest_in_scope(exclude: pool, limit: [ @config.smart_candidate_pool - pool.size, 0 ].max, time: result.encoding&.time)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
def newest_in_scope(exclude:, limit:)
|
|
61
|
+
def newest_in_scope(exclude:, limit:, time:)
|
|
62
62
|
return [] if limit.zero?
|
|
63
63
|
|
|
64
|
-
sql = Search::Sql.new(@model, tenant_key: keystroke.tenant_key, query: @query)
|
|
64
|
+
sql = Search::Sql.new(@model, tenant_key: keystroke.tenant_key, query: @query, encoding: Search::Encoding.new(time: time))
|
|
65
65
|
relation = sql.base(keystroke.scope).where.not(@model.primary_key => exclude)
|
|
66
66
|
column, direction = definition.order
|
|
67
67
|
relation = column ? relation.reorder(column => direction) : relation.unscope(:order)
|
data/lib/truffler/version.rb
CHANGED
data/lib/truffler/vocabulary.rb
CHANGED
|
@@ -17,11 +17,13 @@ module Truffler
|
|
|
17
17
|
@model = model
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
# {key => LabelDefinition or Lenses::LensLabel}: the declared labels
|
|
21
|
-
#
|
|
20
|
+
# {key => LabelDefinition or Lenses::LensLabel}: the declared labels
|
|
21
|
+
# the tenant has (a per-tenant choice with no options for it is left
|
|
22
|
+
# out), then the lens labels keyed "lens:<id>:<label>".
|
|
22
23
|
def labels_for(tenant_key: nil, user_key: nil, all_users: false)
|
|
24
|
+
declared = definition.per_tenant_vocabulary? ? definition.labels.select { |_, label| label.available?(tenant_key) } : definition.labels
|
|
23
25
|
lenses = Lenses.labels(definition.model, tenant_key: tenant_key, user_key: user_key, all_users: all_users)
|
|
24
|
-
lenses.empty? ?
|
|
26
|
+
lenses.empty? ? declared : declared.merge(lenses)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def fingerprint(label_key, tenant_key: nil)
|
data/lib/truffler.rb
CHANGED
|
@@ -19,6 +19,10 @@ loader.do_not_eager_load("#{__dir__}/truffler/lenses/ruby_llm_generator.rb")
|
|
|
19
19
|
loader.setup
|
|
20
20
|
|
|
21
21
|
module Truffler
|
|
22
|
+
# The answer to a choice question in query encoding that means "no
|
|
23
|
+
# option". The colon keeps it out of host option names, which reject it.
|
|
24
|
+
NO_OPTION = "truffler:none".freeze
|
|
25
|
+
|
|
22
26
|
class << self
|
|
23
27
|
def config
|
|
24
28
|
@config ||= Configuration.new
|
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.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kieran Klaassen
|
|
@@ -295,6 +295,8 @@ files:
|
|
|
295
295
|
- lib/truffler/search/query.rb
|
|
296
296
|
- lib/truffler/search/result.rb
|
|
297
297
|
- lib/truffler/search/sql.rb
|
|
298
|
+
- lib/truffler/search/time_phrase.rb
|
|
299
|
+
- lib/truffler/search/time_range.rb
|
|
298
300
|
- lib/truffler/smart_search.rb
|
|
299
301
|
- lib/truffler/smart_search/dispatcher.rb
|
|
300
302
|
- lib/truffler/smart_search/reranker.rb
|