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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28c9f27b6811a398815e7cc1026b0d1ed0ef1caf546bd820a59be4efdd4dcffa
4
- data.tar.gz: 1889959cc8dfd3f32af19a37dda640673e08d6459f5a5e7722747cd32a7422fd
3
+ metadata.gz: 78736a9bfd83f38c6623b52904121d19a15c33f9d53bad271c1021968749c11d
4
+ data.tar.gz: 68f40b4143278a1ab48102aaa5d0146fc00c71885f332180dacdd2495e6e0a7d
5
5
  SHA512:
6
- metadata.gz: 76974d97ae716de75fc3cc1992868c2cabbd94c6851d6a85267fb5c3c1d18ca21f2f998c6ae1d135192081bd7b544351801fc88d33cde4070c56464a01be4d98
7
- data.tar.gz: 8a35786e72b7e5b2996ce915da8419cb8d0d0873c8839829155b7d831f043caf4df9a9be29b9219f75d2403abbbd2c915a09b06b31e0135566a0dbe53f14774e
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:` | with `from:` | Extra columns whose change refreshes the label, in addition to `reads`. |
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 supplied label's `watch:` column changed. Destroying a record removes its labels, state, and embeddings.
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` | nil | Dollar cap per backfill run. Supplied labels cost nothing and are still written once it is reached. |
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=5`) or `Truffler::Jobs::BackfillJob.perform_later("Email")` | After adopting Truffler, changing a declaration, or changing the model pin. |
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")`.
@@ -9,10 +9,18 @@ namespace :truffler do
9
9
  model
10
10
  end
11
11
 
12
- desc "Backfill stale, missing, and failed labels for a model (SPEND_CAP=dollars)"
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["SPEND_CAP"].presence&.to_f || Truffler.config.backfill_spend_cap
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 first option, or the lowest level.
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 = nil
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
@@ -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
- check_columns([ tenant_column, *fields, *Array(keyword).grep(String), *supplied_labels.flat_map(&:watch) ].compact.uniq)
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, `watch:` names extra columns that refresh it, and
10
- # `version:` forces a refresh when its logic changes.
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
- raise DefinitionError, "#{key}: choice options for tenant #{tenant_key.inspect} are empty" if options.empty?
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.to_h { |option, description| [ option.to_s, description ] }
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}: watch: and version: need from:" if @watch.any? || !@version.nil?
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
@@ -14,6 +14,7 @@ module Truffler
14
14
 
15
15
  def initialize(model, client: Truffler.config.client, budget: Budget.new)
16
16
  @model = model
17
+ model.truffler_definition.validate_columns!
17
18
  @client = client
18
19
  @budget = budget
19
20
  end
@@ -10,6 +10,7 @@ module Truffler
10
10
 
11
11
  def initialize(model, config: Truffler.config)
12
12
  @model = model
13
+ model.truffler_definition.validate_columns!
13
14
  @config = config
14
15
  end
15
16
 
@@ -12,6 +12,7 @@ module Truffler
12
12
 
13
13
  def initialize(model)
14
14
  @model = model
15
+ model.truffler_definition.validate_columns!
15
16
  @failed_ids = Set.new
16
17
  end
17
18
 
@@ -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.to_h { |key, label| [ key, label.question(scope.tenant_key) ] }
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) })
@@ -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
- keys = definition.supplied_labels.map(&:key)
66
- Labeling::Supplied.new(self.class).write([ [ self, keys ] ], tenant_key: definition.tenant_key_for(self)) if keys.any?
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?([ *definition.fields, definition.tenant_column ].compact)
81
+ if changed.intersect?(definition.relabel_columns)
81
82
  truffler_expire_labels
82
83
  else
83
- watched = definition.supplied_labels.select { |label| changed.intersect?(label.watch) }
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?([ *definition.fields, definition.tenant_column ].compact)
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 `none`, and each of the first 12 word tokens gets
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 above rather than text to match",
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 = "none".freeze
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 { |token, _| query.exact_tokens.include?(token) }
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}]?), criteria: TOKEN_ROLES)
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
- Request.new(state: { "query" => query.normalized, "tokens" => query.tokens }, questions: questions.to_h, token_ids: token_ids,
66
- exact_tokens: query.exact_tokens, unasked_tokens: words.drop(MAX_TOKEN_QUESTIONS).map(&:first))
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
- keyword_tokens = query.tokens.each_with_index.filter_map do |token, position|
122
- token if roles.fetch(position, "keyword") == "keyword"
123
- end
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 `none`.
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
- Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens) do
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.tokens - label_term_tokens)
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, @exact_tokens = tokenize(normalized)
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.freeze ]
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)
@@ -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)
@@ -1,3 +1,3 @@
1
1
  module Truffler
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
@@ -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
- # then the lens labels keyed "lens:<id>:<label>".
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? ? definition.labels : definition.labels.merge(lenses)
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.0
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