truffler 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +9 -5
- data/lib/generators/truffler/install/templates/migration.rb.tt +11 -11
- data/lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt +1 -1
- data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +3 -3
- data/lib/truffler/configuration.rb +3 -0
- data/lib/truffler/current.rb +30 -0
- data/lib/truffler/label_definition.rb +4 -1
- data/lib/truffler/labeling/backfill.rb +23 -8
- data/lib/truffler/labeling/labeler.rb +39 -37
- data/lib/truffler/query_encoding/encoder.rb +65 -43
- data/lib/truffler/query_encoding/present_options.rb +54 -0
- data/lib/truffler/records/backfill_spend.rb +32 -2
- data/lib/truffler/search/encoding.rb +29 -4
- data/lib/truffler/search/encoding_cache.rb +12 -1
- data/lib/truffler/search/keystroke.rb +35 -18
- data/lib/truffler/search/relaxation.rb +95 -0
- data/lib/truffler/search/result.rb +11 -5
- data/lib/truffler/search/sql.rb +16 -5
- data/lib/truffler/smart_search/dispatcher.rb +35 -23
- data/lib/truffler/smart_search/reranker.rb +7 -5
- data/lib/truffler/smart_search/run.rb +10 -3
- data/lib/truffler/smart_search/starter.rb +13 -11
- data/lib/truffler/version.rb +1 -1
- data/lib/truffler/vocabulary.rb +10 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1e678dd275fc608a97a93cc9eb7a881ae686c4cc428bfe2a823310f2d5a4b559
|
|
4
|
+
data.tar.gz: 3145a1087183f63dae1baf3bd38237761760f368b79828860af802c81caea690
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ebe167d14a66fc74e219e1a5f9206ba158018418b87437b212e51eb6e356c27dd35a5312e443f6cd108a21f57c223e7219c39f5ed45d44ac643aae1feb08a103
|
|
7
|
+
data.tar.gz: 2352317c03e9c9e8b91597efdf3aab02ea09ca54bb9ff3bb9bcf1fc0a214d1b23d62f1542ef3c7f6866e720f7e7624aa9eab23bea04fe6fe86c649a20e5faaaf
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.6]
|
|
4
|
+
|
|
5
|
+
A fix from happyhappy production and follow-ups from 0.1.5.
|
|
6
|
+
|
|
7
|
+
- Zero-result relaxation. With a Cora product filter applied, the word "email" became a `Source: email` filter chip and results dropped from 50 to 0 because the tenant has no email sources. When a keystroke search under the encoding's filters returns nothing but would match without them, those filters now become soft boosts: they stay in the intent vector so matching records rank first, but nothing requires them. The words they consumed become keywords again. Truffler first relaxes only the filters no record in the tenant carries at their threshold and keeps the rest, then every filter if that still finds nothing. It never relaxes into "every record in the tenant" when no word, exact match, or vector match remains. The fallback costs one extra query, only on an empty result; non-empty keystrokes stay one SELECT. `Result#relaxed_labels` lists the relaxed storage keys, and their chips carry `relaxed: true`. Chips the searcher removed stay removed. Smart search candidate filtering falls back the same way instead of reranking an empty set, and records `relaxed_labels` on the run (`run.to_h`).
|
|
8
|
+
- New `config.skip_empty_options` (default false). When true, query encoding offers Jev only the choice options the tenant has label rows for (at or above `choice_min_probability`) and leaves out choice labels with none. The present-option set is digested into the encoding cache key and cached for 5 minutes, so a new option reaches encoding within that TTL and keystrokes stay one SELECT.
|
|
9
|
+
- `truffler:install` and `truffler:upgrade` migration templates pass rubocop-rails-omakase (`[ :a, :b ]` array brackets). A test renders every template and runs RuboCop with the omakase config on the output.
|
|
10
|
+
- `Records::BackfillSpend` no longer caches a missing `truffler_backfill_spends.tenant_key` for the life of the process. On a miss it reloads the column information at most once a minute, so workers started before `db:migrate` move to tenant ledgers without a restart.
|
|
11
|
+
- `Backfill.status` works with an `index_scope` that orders. It plucked distinct tenants over `index_relation` without `reorder(nil)`, which raised on Postgres (`for SELECT DISTINCT, ORDER BY expressions must appear in select list`).
|
|
12
|
+
- A per-tenant choice `options:` callable runs once per label and tenant for each keystroke search, query encoding, labeler batch, and Smart run step, instead of at every vocabulary, fingerprint, and wording read. In happyhappy that was about 8 times per keystroke, and 14 per encoding and 21 per labeler batch in the test model. The new `Truffler::Current.scope` memoizes for one unit of work and clears when it ends, so nothing is shared between searches, jobs, or tenants.
|
|
13
|
+
- Backfill spend ledgers key on the Jev-asked labels only (`Vocabulary#ledger_version`). Changing a supplied (`from:`) label, such as adding an option to a supplied product choice, no longer starts a fresh ledger; the backfill rewrites only that label with no Jev call and no charge. Changing an asked label still starts a new ledger. Existing ledgers keep counting: a model without supplied labels keeps the same key, and one with supplied labels has its pre-0.1.6 row taken over by the first backfill.
|
|
14
|
+
|
|
3
15
|
## [0.1.5]
|
|
4
16
|
|
|
5
17
|
Fixes from the Cora integration at Postgres scale, and two from happyhappy's 0.1.4 upgrade.
|
data/README.md
CHANGED
|
@@ -51,7 +51,7 @@ bin/rails db:migrate
|
|
|
51
51
|
|
|
52
52
|
The migration creates only that table and skips it if it already exists. Until you run it, backfills log one warning and cap spend per run, as 0.1.1 did.
|
|
53
53
|
|
|
54
|
-
0.1.5 adds `truffler_backfill_spends.tenant_key` for per-tenant spend ledgers. The same `truffler:upgrade` command writes that migration. Until you run it, every tenant shares the model's app-wide ledger and a warning is logged once.
|
|
54
|
+
0.1.5 adds `truffler_backfill_spends.tenant_key` for per-tenant spend ledgers. The same `truffler:upgrade` command writes that migration. Until you run it, every tenant shares the model's app-wide ledger and a warning is logged once. Workers already running when you migrate pick up the column within a minute, without a restart.
|
|
55
55
|
|
|
56
56
|
`truffler:upgrade` is safe to rerun. It skips any migration already in `db/migrate` or already applied to the database, and writes only the missing ones.
|
|
57
57
|
|
|
@@ -96,7 +96,7 @@ end
|
|
|
96
96
|
Here is what each option does:
|
|
97
97
|
|
|
98
98
|
- `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.
|
|
99
|
-
- 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).
|
|
99
|
+
- 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 callable runs once per label and tenant for each keystroke search, query encoding, labeler batch, and Smart run step (`Truffler::Current.scope`), never shared between them. The option name `truffler:none` is reserved (see `Truffler::NO_OPTION` below).
|
|
100
100
|
- 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.
|
|
101
101
|
- `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`.
|
|
102
102
|
- `keyword` also accepts a single callable, `->(scope, tokens) { relation }`. It, and each `exact` callable, may return an Array of ids instead of a relation, which is far faster on a large tenant (see [Keystroke search at scale](#keystroke-search-at-scale)).
|
|
@@ -203,12 +203,15 @@ Query encoding sends Jev the label vocabulary (each label's description and a ch
|
|
|
203
203
|
|
|
204
204
|
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.
|
|
205
205
|
|
|
206
|
+
When a search under the encoding's filters returns nothing but would match without them, the filters relax instead of leaving an empty list. With a product filter applied, "email" can become a `Source: email` filter in a tenant with no email sources. Truffler first relaxes only the filters no record in the tenant carries at their threshold, keeping the rest (the product filter stays). If that still finds nothing, it relaxes every filter. A relaxed filter becomes a soft boost, so records that do match it still rank first, and the words it consumed become keywords again. A filter is never relaxed into "every record in the tenant": if no word comes back and there is no exact or vector match, the list stays empty. Relaxation runs one extra query, only when the result is empty. `result.relaxed_labels` lists the relaxed storage keys, and their chips keep `kind: :filter` with `relaxed: true`, so the UI can say "No email sources; showing keyword matches". Chips the searcher removed stay removed.
|
|
207
|
+
|
|
206
208
|
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).
|
|
207
209
|
|
|
208
210
|
The returned `Truffler::Search::Result` exposes:
|
|
209
211
|
|
|
210
212
|
- `records` and `ids`.
|
|
211
|
-
- `chips`: `[{key:, label:, kind: :filter | :boost | :time, name:}]
|
|
213
|
+
- `chips`: `[{key:, label:, kind: :filter | :boost | :time, name:}]`, plus `relaxed: true` on a filter chip that was relaxed.
|
|
214
|
+
- `relaxed_labels`: storage keys of filters relaxed because they left nothing to show (empty otherwise).
|
|
212
215
|
- `invite_row`: `{query:, reason: :weak | :empty | :encoding_pending}` or nil.
|
|
213
216
|
- `local_weak?`: whether the list is too weak to stand alone (starts the backup provider). A pending encoding on a model with a `keyword` source invites Smart search without making the list weak.
|
|
214
217
|
- `encoding_status`: `:cached`, `:pending`, or `:none`.
|
|
@@ -260,7 +263,7 @@ The keystroke list is left untouched. The job then does the following:
|
|
|
260
263
|
|
|
261
264
|
1. Starts the provider backup, if one is declared.
|
|
262
265
|
2. Takes a rerank slot under the per-user cap. If no slot is available, the run pauses.
|
|
263
|
-
3. Waits up to `encoding_deadline` for an in-flight query encoding and applies its filters.
|
|
266
|
+
3. Waits up to `encoding_deadline` for an in-flight query encoding and applies its filters. If they leave no candidate, they relax as on the keystroke instead of reranking an empty set. `run.relaxed_labels` and `run.to_h[:relaxed_labels]` list them.
|
|
264
267
|
4. Fans out one `RerankChunkJob` per `rerank_chunk_size` candidates.
|
|
265
268
|
|
|
266
269
|
Each chunk appends to the buckets and pings. When the searcher edits the query or accepts or removes a chip, call `Email.jev_cancel_smart_search(tenant:, user:, surface:)`.
|
|
@@ -395,6 +398,7 @@ Set these in `Truffler.configure do |config| ... end`.
|
|
|
395
398
|
| `tenant_enabled` | nil (every tenant) | `->(model, tenant_key) { ... }`, asked for tenant-scoped models only. A disabled tenant is never labeled, embedded, or backfilled (see Indexing only some tenants). |
|
|
396
399
|
| `backfill_spend_cap_scope` | `:tenant` | `:tenant` keeps a spend ledger per tenant for tenant-scoped models, so `backfill_spend_cap` applies to each tenant. `:app` keeps one ledger per model. Unscoped models always use one. |
|
|
397
400
|
| `choice_min_probability` | 0.05 | Choice labels store a row only for options at or above this probability, plus the most likely option. A missing option reads as 0.0 in filters, boosts, label vectors, and contributions. `nil` stores every option. No migration is needed: rows written earlier stay until their record is relabeled. |
|
|
401
|
+
| `skip_empty_options` | false | When true, query encoding offers Jev only the choice options the tenant has label rows for (at or above `choice_min_probability`, or above 0.0 when that is `nil`), and leaves out choice labels with none, so a query cannot filter on an option no record carries. The present-option set is part of the encoding cache key and is read through the cache store for 5 minutes (`Truffler::QueryEncoding::PresentOptions::TTL`), so keystrokes stay one query and a new option reaches encoding within 5 minutes. |
|
|
398
402
|
|
|
399
403
|
## Jobs to schedule
|
|
400
404
|
|
|
@@ -436,7 +440,7 @@ truffler_expire_lenses:
|
|
|
436
440
|
| `RESET_SPEND=1` | Zeroes the spend recorded for the current vocabulary version before starting (every tenant's ledger when no `TENANT` is given). |
|
|
437
441
|
| `TENANT=42` | Backfills only that tenant, against that tenant's ledger. `truffler:status` takes it too, to print that tenant's spend. |
|
|
438
442
|
|
|
439
|
-
The spend cap holds across runs. Truffler records backfill spend per model and
|
|
443
|
+
The spend cap holds across runs. Truffler records backfill spend per model and ledger version in `truffler_backfill_spends`, and reserves each request's estimate against the cap in SQL. A rerun, a `ResumeJob` backfill, and overlapping `BackfillJob` chains all draw on the same total, so together they stop at `backfill_spend_cap`. Changing what Jev is asked (a reworded question, a new asked label, or an activated lens) starts a new total. Supplied (`from:`) labels cost nothing and never do: adding an option to a supplied choice keeps the ledger, and the next backfill rewrites only that label with no Jev call. Ledgers written before 0.1.6 keep counting. A model without supplied labels keeps the same key, and one with supplied labels has its old row taken over on the first backfill, so no spend is reset. Lens backfills are capped separately by each lens's `spend_cap_usd`. `BackfillJob` reschedules itself after a denial with the same backoff. In code, `Truffler::Labeling::Backfill.new(Email).run(wait: true, max_duration: 600)` does what the task does.
|
|
440
444
|
|
|
441
445
|
For tenant-scoped models the ledger is per tenant (`backfill_spend_cap_scope :tenant`, the default), so the cap applies to each tenant. A whole-model run skips a tenant once its ledger reaches the cap, keeps labeling the others, and ends with `spend_cap_reached`. A tenant run (`TENANT=`, or `BackfillJob.perform_later("Email", tenant_key: "42")`) stops at that tenant's cap. `ResumeJob` and the flush job's over-cap demotion enqueue one `BackfillJob` per tenant.
|
|
442
446
|
|
|
@@ -9,9 +9,9 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
9
9
|
t.string :fingerprint, null: false
|
|
10
10
|
t.datetime :labeled_at, null: false
|
|
11
11
|
end
|
|
12
|
-
add_index :truffler_labels, [:record_type, :record_id, :label_key], unique: true,
|
|
12
|
+
add_index :truffler_labels, [ :record_type, :record_id, :label_key ], unique: true,
|
|
13
13
|
name: "index_truffler_labels_on_record_and_label"
|
|
14
|
-
add_index :truffler_labels, [:record_type, :tenant_key, :label_key, :value], include: [:record_id],
|
|
14
|
+
add_index :truffler_labels, [ :record_type, :tenant_key, :label_key, :value ], include: [ :record_id ],
|
|
15
15
|
name: "index_truffler_labels_for_search"
|
|
16
16
|
|
|
17
17
|
create_table :truffler_record_states do |t|
|
|
@@ -29,9 +29,9 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
29
29
|
t.datetime :embedded_at
|
|
30
30
|
t.timestamps
|
|
31
31
|
end
|
|
32
|
-
add_index :truffler_record_states, [:record_type, :record_id], unique: true,
|
|
32
|
+
add_index :truffler_record_states, [ :record_type, :record_id ], unique: true,
|
|
33
33
|
name: "index_truffler_record_states_on_record"
|
|
34
|
-
add_index :truffler_record_states, [:record_type, :tenant_key, :status, :priority],
|
|
34
|
+
add_index :truffler_record_states, [ :record_type, :tenant_key, :status, :priority ],
|
|
35
35
|
name: "index_truffler_record_states_for_claims"
|
|
36
36
|
|
|
37
37
|
create_table :truffler_embeddings do |t|
|
|
@@ -49,9 +49,9 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
49
49
|
t.string :label_vocabulary_version
|
|
50
50
|
t.timestamps
|
|
51
51
|
end
|
|
52
|
-
add_index :truffler_embeddings, [:record_type, :record_id], unique: true,
|
|
52
|
+
add_index :truffler_embeddings, [ :record_type, :record_id ], unique: true,
|
|
53
53
|
name: "index_truffler_embeddings_on_record"
|
|
54
|
-
add_index :truffler_embeddings, [:record_type, :tenant_key], name: "index_truffler_embeddings_on_tenant"
|
|
54
|
+
add_index :truffler_embeddings, [ :record_type, :tenant_key ], name: "index_truffler_embeddings_on_tenant"
|
|
55
55
|
|
|
56
56
|
create_table :truffler_query_misses do |t|
|
|
57
57
|
t.string :record_type, null: false
|
|
@@ -61,7 +61,7 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
61
61
|
t.text :query_text
|
|
62
62
|
t.datetime :created_at, null: false
|
|
63
63
|
end
|
|
64
|
-
add_index :truffler_query_misses, [:record_type, :tenant_key, :created_at],
|
|
64
|
+
add_index :truffler_query_misses, [ :record_type, :tenant_key, :created_at ],
|
|
65
65
|
name: "index_truffler_query_misses_on_tenant"
|
|
66
66
|
add_index :truffler_query_misses, :created_at
|
|
67
67
|
|
|
@@ -86,7 +86,7 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
86
86
|
t.datetime :last_used_at
|
|
87
87
|
t.timestamps
|
|
88
88
|
end
|
|
89
|
-
add_index :truffler_lenses, [:record_type, :status, :scope_type, :tenant_key],
|
|
89
|
+
add_index :truffler_lenses, [ :record_type, :status, :scope_type, :tenant_key ],
|
|
90
90
|
name: "index_truffler_lenses_for_visibility"
|
|
91
91
|
|
|
92
92
|
create_table :truffler_lens_versions do |t|
|
|
@@ -103,7 +103,7 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
103
103
|
t.datetime :activated_at
|
|
104
104
|
t.datetime :created_at, null: false
|
|
105
105
|
end
|
|
106
|
-
add_index :truffler_lens_versions, [:lens_id, :number], unique: true,
|
|
106
|
+
add_index :truffler_lens_versions, [ :lens_id, :number ], unique: true,
|
|
107
107
|
name: "index_truffler_lens_versions_on_lens_and_number"
|
|
108
108
|
|
|
109
109
|
create_table :truffler_backfill_spends do |t|
|
|
@@ -114,9 +114,9 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
114
114
|
t.integer :requests, null: false, default: 0
|
|
115
115
|
t.timestamps
|
|
116
116
|
end
|
|
117
|
-
add_index :truffler_backfill_spends, [:record_type, :tenant_key, :vocabulary_version], unique: true,
|
|
117
|
+
add_index :truffler_backfill_spends, [ :record_type, :tenant_key, :vocabulary_version ], unique: true,
|
|
118
118
|
name: "index_truffler_backfill_spends_on_tenant_ledger"
|
|
119
|
-
add_index :truffler_backfill_spends, [:record_type, :vocabulary_version], unique: true, where: "tenant_key IS NULL",
|
|
119
|
+
add_index :truffler_backfill_spends, [ :record_type, :vocabulary_version ], unique: true, where: "tenant_key IS NULL",
|
|
120
120
|
name: "index_truffler_backfill_spends_on_app_ledger"
|
|
121
121
|
end
|
|
122
122
|
end
|
|
@@ -9,7 +9,7 @@ class CreateTrufflerBackfillSpends < ActiveRecord::Migration<%= migration_versio
|
|
|
9
9
|
t.integer :requests, null: false, default: 0
|
|
10
10
|
t.timestamps
|
|
11
11
|
end
|
|
12
|
-
add_index :truffler_backfill_spends, [:record_type, :vocabulary_version], unique: true,
|
|
12
|
+
add_index :truffler_backfill_spends, [ :record_type, :vocabulary_version ], unique: true,
|
|
13
13
|
name: "index_truffler_backfill_spends_on_ledger"
|
|
14
14
|
end
|
|
15
15
|
|
|
@@ -5,11 +5,11 @@ class AddTenantKeyToTrufflerBackfillSpends < ActiveRecord::Migration<%= migratio
|
|
|
5
5
|
remove_index :truffler_backfill_spends, name: "index_truffler_backfill_spends_on_ledger"
|
|
6
6
|
end
|
|
7
7
|
unless index_name_exists?(:truffler_backfill_spends, "index_truffler_backfill_spends_on_tenant_ledger")
|
|
8
|
-
add_index :truffler_backfill_spends, [:record_type, :tenant_key, :vocabulary_version], unique: true,
|
|
8
|
+
add_index :truffler_backfill_spends, [ :record_type, :tenant_key, :vocabulary_version ], unique: true,
|
|
9
9
|
name: "index_truffler_backfill_spends_on_tenant_ledger"
|
|
10
10
|
end
|
|
11
11
|
unless index_name_exists?(:truffler_backfill_spends, "index_truffler_backfill_spends_on_app_ledger")
|
|
12
|
-
add_index :truffler_backfill_spends, [:record_type, :vocabulary_version], unique: true, where: "tenant_key IS NULL",
|
|
12
|
+
add_index :truffler_backfill_spends, [ :record_type, :vocabulary_version ], unique: true, where: "tenant_key IS NULL",
|
|
13
13
|
name: "index_truffler_backfill_spends_on_app_ledger"
|
|
14
14
|
end
|
|
15
15
|
end
|
|
@@ -19,7 +19,7 @@ class AddTenantKeyToTrufflerBackfillSpends < ActiveRecord::Migration<%= migratio
|
|
|
19
19
|
remove_index :truffler_backfill_spends, name: "index_truffler_backfill_spends_on_tenant_ledger", if_exists: true
|
|
20
20
|
remove_index :truffler_backfill_spends, name: "index_truffler_backfill_spends_on_app_ledger", if_exists: true
|
|
21
21
|
remove_column :truffler_backfill_spends, :tenant_key, if_exists: true
|
|
22
|
-
add_index :truffler_backfill_spends, [:record_type, :vocabulary_version], unique: true,
|
|
22
|
+
add_index :truffler_backfill_spends, [ :record_type, :vocabulary_version ], unique: true,
|
|
23
23
|
name: "index_truffler_backfill_spends_on_ledger", if_not_exists: true
|
|
24
24
|
end
|
|
25
25
|
end
|
|
@@ -25,6 +25,8 @@ module Truffler
|
|
|
25
25
|
attr_accessor :backfill_spend_cap_scope
|
|
26
26
|
# Choice options below this probability store no row and read as 0.0 (nil stores every option).
|
|
27
27
|
attr_accessor :choice_min_probability
|
|
28
|
+
# Query encoding omits choice options the tenant has no label row for (QueryEncoding::PresentOptions).
|
|
29
|
+
attr_accessor :skip_empty_options
|
|
28
30
|
|
|
29
31
|
def initialize(env: ENV)
|
|
30
32
|
@model = "jev-latest"
|
|
@@ -62,6 +64,7 @@ module Truffler
|
|
|
62
64
|
@tenant_enabled = nil
|
|
63
65
|
@backfill_spend_cap_scope = :tenant
|
|
64
66
|
@choice_min_probability = 0.05
|
|
67
|
+
@skip_empty_options = false
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
def client
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# State scoped to one unit of work: a keystroke search, a query encoding,
|
|
3
|
+
# a labeler batch, a Smart run step. Inside `scope`, a per-tenant choice
|
|
4
|
+
# `options:` callable is resolved once per (label, tenant) instead of at
|
|
5
|
+
# every vocabulary, fingerprint, and wording read. Nested scopes share the
|
|
6
|
+
# outermost one, which clears everything when it ends, so nothing carries
|
|
7
|
+
# over to the next search or job. Outside a scope nothing is memoized.
|
|
8
|
+
module Current
|
|
9
|
+
KEY = :truffler_current_options
|
|
10
|
+
|
|
11
|
+
def self.scope
|
|
12
|
+
return yield if ActiveSupport::IsolatedExecutionState.key?(KEY)
|
|
13
|
+
|
|
14
|
+
ActiveSupport::IsolatedExecutionState[KEY] = {}
|
|
15
|
+
begin
|
|
16
|
+
yield
|
|
17
|
+
ensure
|
|
18
|
+
ActiveSupport::IsolatedExecutionState.delete(KEY)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.options(label, tenant_key)
|
|
23
|
+
memo = ActiveSupport::IsolatedExecutionState[KEY]
|
|
24
|
+
return yield unless memo
|
|
25
|
+
|
|
26
|
+
key = [ label, tenant_key&.to_s ]
|
|
27
|
+
memo.fetch(key) { memo[key] = yield }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -131,7 +131,10 @@ module Truffler
|
|
|
131
131
|
private
|
|
132
132
|
|
|
133
133
|
def option_entries(tenant_key)
|
|
134
|
-
|
|
134
|
+
per_tenant? ? Current.options(self, tenant_key) { build_option_entries(@options.call(tenant_key)).freeze } : build_option_entries(@options)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_option_entries(options)
|
|
135
138
|
options = Array(options).to_h { |option| [ option, nil ] } unless options.is_a?(Hash)
|
|
136
139
|
options = options.to_h { |option, value| [ option.to_s, option_entry(option, value) ] }
|
|
137
140
|
raise DefinitionError, "#{key}: the option name #{NO_OPTION} is reserved" if options.key?(NO_OPTION)
|
|
@@ -14,7 +14,8 @@ module Truffler
|
|
|
14
14
|
# Jev about them again. A spend cap stops the run before a request would
|
|
15
15
|
# exceed it; host-supplied labels cost nothing, so they are still written
|
|
16
16
|
# once the cap is reached. The cap counts everything spent under the
|
|
17
|
-
# current
|
|
17
|
+
# current ledger version (the asked labels' fingerprints, so supplied
|
|
18
|
+
# labels never reset it) in the truffler_backfill_spends ledger, so
|
|
18
19
|
# reruns and overlapping jobs share it. Tenant-scoped models keep one
|
|
19
20
|
# ledger per tenant (backfill_spend_cap_scope :tenant, the default), so
|
|
20
21
|
# the cap applies to each tenant: a whole-model run skips a tenant at its
|
|
@@ -105,13 +106,16 @@ module Truffler
|
|
|
105
106
|
new(model, tenant_key: tenant_key).status
|
|
106
107
|
end
|
|
107
108
|
|
|
108
|
-
# The ledger row for the current
|
|
109
|
-
#
|
|
110
|
-
#
|
|
109
|
+
# The ledger row for the current ledger version (the tenant's, for a
|
|
110
|
+
# tenant ledger), or nil when nothing was spent yet or the ledger table
|
|
111
|
+
# is missing. A row still keyed by the pre-0.1.6 vocabulary version
|
|
112
|
+
# counts until a backfill takes it over.
|
|
111
113
|
def self.spend(model, tenant_key: nil)
|
|
112
114
|
return unless Records::BackfillSpend.available?
|
|
113
115
|
|
|
114
|
-
Records::BackfillSpend.for_ledger(model, tenant_key)
|
|
116
|
+
ledgers = Records::BackfillSpend.for_ledger(model, tenant_key)
|
|
117
|
+
ledgers.find_by(vocabulary_version: ledger_version(model, tenant_key)) ||
|
|
118
|
+
ledgers.find_by(vocabulary_version: legacy_ledger_version(model, tenant_key))
|
|
115
119
|
end
|
|
116
120
|
|
|
117
121
|
# Zeroes the current vocabulary version's ledger in place, so a chain
|
|
@@ -123,12 +127,20 @@ module Truffler
|
|
|
123
127
|
ledgers = if all_tenants && Records::BackfillSpend.tenant_ledgers?
|
|
124
128
|
Records::BackfillSpend.for_model(model).where.not(tenant_key: nil)
|
|
125
129
|
else
|
|
126
|
-
Records::BackfillSpend.for_ledger(model, tenant_key)
|
|
130
|
+
Records::BackfillSpend.for_ledger(model, tenant_key)
|
|
131
|
+
.where(vocabulary_version: [ ledger_version(model, tenant_key), legacy_ledger_version(model, tenant_key) ])
|
|
127
132
|
end
|
|
128
133
|
ledgers.update_all(spent_usd: 0.0, requests: 0, updated_at: Time.current)
|
|
129
134
|
end
|
|
130
135
|
|
|
136
|
+
# Spend ledgers key on the asked labels only (Vocabulary#ledger_version),
|
|
137
|
+
# so a supplied-label change never resets the cap.
|
|
131
138
|
def self.ledger_version(model, tenant_key = nil)
|
|
139
|
+
model.truffler_definition.vocabulary.ledger_version(tenant_key: tenant_key, all_users: true)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# What ledgers were keyed by before 0.1.6: the whole vocabulary version.
|
|
143
|
+
def self.legacy_ledger_version(model, tenant_key = nil)
|
|
132
144
|
model.truffler_definition.vocabulary.version(tenant_key: tenant_key, all_users: true)
|
|
133
145
|
end
|
|
134
146
|
|
|
@@ -220,7 +232,7 @@ module Truffler
|
|
|
220
232
|
return [ scope.where(definition.tenant_column => @tenant_key), [ @tenant_key ] ] if @tenant_key
|
|
221
233
|
return [ scope, nil ] unless Truffler.config.tenant_enabled
|
|
222
234
|
|
|
223
|
-
tenants = scope.distinct.pluck(definition.tenant_column).map(&:to_s).select { |tenant_key| enabled?(tenant_key) }
|
|
235
|
+
tenants = scope.reorder(nil).distinct.pluck(definition.tenant_column).map(&:to_s).select { |tenant_key| enabled?(tenant_key) }
|
|
224
236
|
[ scope.where(definition.tenant_column => tenants), tenants ]
|
|
225
237
|
end
|
|
226
238
|
|
|
@@ -233,7 +245,10 @@ module Truffler
|
|
|
233
245
|
def meter(tenant_key)
|
|
234
246
|
ledger_tenant = ledger_available? ? definition.ledger_tenant(tenant_key) : nil
|
|
235
247
|
@meters[ledger_tenant] ||= begin
|
|
236
|
-
|
|
248
|
+
if ledger_available?
|
|
249
|
+
ledger = Records::BackfillSpend.ledger(model, self.class.ledger_version(model, ledger_tenant), tenant_key: ledger_tenant,
|
|
250
|
+
legacy_version: version_for(ledger_tenant))
|
|
251
|
+
end
|
|
237
252
|
SpendMeter.new(@client, cap: @spend_cap, spent: ledger ? 0.0 : @spent, ledger: ledger)
|
|
238
253
|
end
|
|
239
254
|
end
|
|
@@ -20,46 +20,48 @@ module Truffler
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def label(states, priority:)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
23
|
+
Current.scope do
|
|
24
|
+
return Result.new(labeled: 0, requests: 0, cost: 0.0, demoted: false) if states.empty?
|
|
25
|
+
|
|
26
|
+
tenant_key = tenant_key_of(states)
|
|
27
|
+
records = load_records(states, tenant_key)
|
|
28
|
+
vocabulary = definition.vocabulary
|
|
29
|
+
@labels = vocabulary.labels_for(tenant_key: tenant_key, all_users: true)
|
|
30
|
+
fingerprints = vocabulary.fingerprints(tenant_key: tenant_key, all_users: true)
|
|
31
|
+
version = vocabulary.version(tenant_key: tenant_key, all_users: true)
|
|
32
|
+
states_by_id = states.index_by { |state| state.record_id.to_s }
|
|
33
|
+
|
|
34
|
+
stored = stored_fingerprints(records)
|
|
35
|
+
askable = askable_labels
|
|
36
|
+
stale = records.map { |record| [ record, stale_keys(stored[record.id.to_s].to_h, fingerprints, tenant_key, askable) ] }
|
|
37
|
+
supplied = askable.select(&:supplied?).map(&:key)
|
|
38
|
+
supplier = Supplied.new(model)
|
|
39
|
+
written = supplier.write(stale.map { |record, keys| [ record, keys & supplied ] }, tenant_key: tenant_key)
|
|
40
|
+
current, pending = stale.map { |record, keys| [ record, keys - supplied ] }.partition { |_, keys| keys.empty? }
|
|
41
|
+
settled = current.reject { |record, _| supplier.failed_ids.include?(record.id) }
|
|
42
|
+
Records::RecordState.mark_labeled(settled.map { |record, _| states_by_id[record.id.to_s].id }, version: version)
|
|
43
|
+
Embeddings::LabelVector.new(model).write(current.map { |record, _| record.id } - written, tenant_key: tenant_key)
|
|
44
|
+
|
|
45
|
+
requests = RequestBuilder.new(definition, tenant_key: tenant_key, labels: @labels).build(pending)
|
|
46
|
+
cost = 0.0
|
|
47
|
+
requests.each_with_index do |request, index|
|
|
48
|
+
decision = budget.acquire(priority: priority, tenant_key: (tenant_key if index.zero?),
|
|
49
|
+
records: index.zero? ? pending.size : 1)
|
|
50
|
+
if decision.demoted?
|
|
51
|
+
retry_failed_supplied(supplier.failed_ids, states_by_id)
|
|
52
|
+
return Result.new(labeled: current.size, requests: index, cost: cost, demoted: true)
|
|
53
|
+
end
|
|
54
|
+
raise BudgetExhausted.new("no Jev budget for #{priority} labeling", retry_after: decision.retry_after) if decision.denied?
|
|
55
|
+
|
|
56
|
+
answers = client.ask(state: request.state, questions: request.questions, priority: decision.priority)
|
|
57
|
+
cost += answers.usage&.cost.to_f
|
|
58
|
+
charge_lenses(request, answers.usage&.cost.to_f)
|
|
59
|
+
store(request, answers, fingerprints, tenant_key, version, states_by_id)
|
|
52
60
|
end
|
|
53
|
-
raise BudgetExhausted.new("no Jev budget for #{priority} labeling", retry_after: decision.retry_after) if decision.denied?
|
|
54
61
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
charge_lenses(request, answers.usage&.cost.to_f)
|
|
58
|
-
store(request, answers, fingerprints, tenant_key, version, states_by_id)
|
|
62
|
+
retry_failed_supplied(supplier.failed_ids, states_by_id)
|
|
63
|
+
Result.new(labeled: records.size, requests: requests.size, cost: cost, demoted: false)
|
|
59
64
|
end
|
|
60
|
-
|
|
61
|
-
retry_failed_supplied(supplier.failed_ids, states_by_id)
|
|
62
|
-
Result.new(labeled: records.size, requests: requests.size, cost: cost, demoted: false)
|
|
63
65
|
end
|
|
64
66
|
|
|
65
67
|
private
|
|
@@ -51,50 +51,55 @@ module Truffler
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def request(model, query, tenant_key:, user_key: nil)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
questions
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
Current.scope do
|
|
55
|
+
present = present_options(model, tenant_key, user_key)
|
|
56
|
+
labels = labels(model, tenant_key, user_key, present)
|
|
57
|
+
questions = Questions.new
|
|
58
|
+
labels.each_value do |label|
|
|
59
|
+
questions.choice(:"intent__#{label.question_key}", instructions: intent_instructions(label), criteria: INTENTS)
|
|
60
|
+
end
|
|
61
|
+
labels.each_value do |label|
|
|
62
|
+
next unless label.type == :choice
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
options = PresentOptions.options(label, tenant_key, present).merge(NO_OPTION => "The query names none of these")
|
|
65
|
+
questions.choice(:"option__#{label.question_key}", instructions: %(Which "#{label.key}" option does the search query ask about?),
|
|
66
|
+
criteria: options)
|
|
67
|
+
end
|
|
66
68
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
words = query.tokens.each_with_index.reject do |token, position|
|
|
70
|
+
query.exact_tokens.include?(token) || query.time_position?(position)
|
|
71
|
+
end
|
|
72
|
+
asked = words.first(MAX_TOKEN_QUESTIONS)
|
|
73
|
+
token_ids = asked.to_h do |_token, position|
|
|
74
|
+
id = :"token__#{position}"
|
|
75
|
+
questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]? The labels it may name, ) +
|
|
76
|
+
%(with their options, are in `labels`.), criteria: TOKEN_ROLES)
|
|
77
|
+
[ position, id.to_s ]
|
|
78
|
+
end
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
state = { "query" => query.normalized, "tokens" => query.tokens, "labels" => vocabulary_state(labels, tenant_key, present) }
|
|
81
|
+
Request.new(state: state, questions: questions.to_h, token_ids: token_ids, exact_tokens: query.exact_tokens,
|
|
82
|
+
unasked_tokens: words.drop(MAX_TOKEN_QUESTIONS).map(&:first))
|
|
83
|
+
end
|
|
81
84
|
end
|
|
82
85
|
|
|
83
86
|
# Encodes the query pending under `cache_key`. Returns the encoding, or
|
|
84
87
|
# nil when the payload expired, the vocabulary moved on, or the encode
|
|
85
88
|
# budget was denied (a silent skip). Always releases the in-flight marker.
|
|
86
89
|
def encode(cache_key)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
Current.scope do
|
|
91
|
+
pending = cache.read_payload(cache_key)
|
|
92
|
+
return unless pending
|
|
93
|
+
|
|
94
|
+
model, query, tenant_key, user_key = pending.values_at(:model, :query, :tenant_key, :user_key)
|
|
95
|
+
return unless cache.key(model, query, tenant_key: tenant_key, user_key: user_key) == cache_key
|
|
96
|
+
|
|
97
|
+
encoding = cache.encoded?(cache_key) ? cache.read_encoding(cache_key, query) : encode_labels(model, query, tenant_key, user_key)
|
|
98
|
+
embed_query(model, query, tenant_key)
|
|
99
|
+
encoding
|
|
100
|
+
ensure
|
|
101
|
+
cache.release(cache_key)
|
|
102
|
+
end
|
|
98
103
|
end
|
|
99
104
|
|
|
100
105
|
# Polls the cache until the encoding lands or the deadline (seconds
|
|
@@ -118,8 +123,9 @@ module Truffler
|
|
|
118
123
|
boosts = {}
|
|
119
124
|
intent = {}
|
|
120
125
|
names = {}
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
present = present_options(model, tenant_key, user_key)
|
|
127
|
+
labels(model, tenant_key, user_key, present).each_value do |label|
|
|
128
|
+
key = storage_key(label, answers, tenant_key, present)
|
|
123
129
|
next unless key
|
|
124
130
|
|
|
125
131
|
case answers.choice("intent__#{label.question_key}")
|
|
@@ -175,25 +181,41 @@ module Truffler
|
|
|
175
181
|
cache.write_vector(model, query, vector, tenant_key: tenant_key)
|
|
176
182
|
end
|
|
177
183
|
|
|
178
|
-
|
|
184
|
+
# The searcher's vocabulary; with skip_empty_options, minus choice
|
|
185
|
+
# labels the tenant has no option rows for (see PresentOptions).
|
|
186
|
+
def labels(model, tenant_key, user_key, present = present_options(model, tenant_key, user_key))
|
|
187
|
+
labels = vocabulary_labels(model, tenant_key, user_key)
|
|
188
|
+
return labels unless present
|
|
189
|
+
|
|
190
|
+
labels.select { |_, label| label.type != :choice || PresentOptions.options(label, tenant_key, present).any? }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def vocabulary_labels(model, tenant_key, user_key)
|
|
179
194
|
model.truffler_definition.vocabulary.labels_for(tenant_key: tenant_key, user_key: user_key)
|
|
180
195
|
end
|
|
181
196
|
|
|
197
|
+
def present_options(model, tenant_key, user_key)
|
|
198
|
+
return unless PresentOptions.enabled?
|
|
199
|
+
|
|
200
|
+
PresentOptions.new.keys(model, vocabulary_labels(model, tenant_key, user_key), tenant_key: tenant_key)
|
|
201
|
+
end
|
|
202
|
+
|
|
182
203
|
# The label's storage key the query names, or nil for a choice label
|
|
183
204
|
# whose option answer is NO_OPTION.
|
|
184
|
-
def storage_key(label, answers, tenant_key)
|
|
205
|
+
def storage_key(label, answers, tenant_key, present)
|
|
185
206
|
return label.key unless label.type == :choice
|
|
186
207
|
|
|
187
208
|
option = answers.choice("option__#{label.question_key}")
|
|
188
|
-
"#{label.key}:#{option}" if option != NO_OPTION &&
|
|
209
|
+
"#{label.key}:#{option}" if option != NO_OPTION && PresentOptions.options(label, tenant_key, present).key?(option)
|
|
189
210
|
end
|
|
190
211
|
|
|
191
|
-
def vocabulary_state(labels, tenant_key)
|
|
212
|
+
def vocabulary_state(labels, tenant_key, present)
|
|
192
213
|
labels.transform_values do |label|
|
|
193
214
|
entry = { "description" => label.description }
|
|
194
215
|
if label.type == :choice
|
|
195
|
-
|
|
196
|
-
|
|
216
|
+
options = PresentOptions.options(label, tenant_key, present).keys
|
|
217
|
+
entry["options"] = options
|
|
218
|
+
names = label.option_names(tenant_key).slice(*options)
|
|
197
219
|
entry["option_names"] = names if names.any?
|
|
198
220
|
end
|
|
199
221
|
entry
|