truffler 0.1.0 → 0.1.2

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.
@@ -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,87 @@
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 hour(s)|day(s)|week(s)|month(s), past hour|week|month,
10
+ # last hour, since monday..sunday
11
+ #
12
+ # "past/last N units" and "past week" are rolling: they end now and
13
+ # start N units back. "this/last week/month" are calendar windows.
14
+ # Weeks start on `Date.beginning_of_week` (Monday by default). Only the
15
+ # first phrase in a query counts; a quoted phrase is exact text.
16
+ TimePhrase = Data.define(:name, :positions, :resolver) do
17
+ def self.find(tokens)
18
+ tokens.each_index do |start|
19
+ PATTERNS.each do |pattern|
20
+ phrase = pattern.call(tokens, start)
21
+ return phrase if phrase
22
+ end
23
+ end
24
+ nil
25
+ end
26
+
27
+ def window(now)
28
+ resolver.call(now)
29
+ end
30
+
31
+ def range(now)
32
+ from, to = window(now)
33
+ TimeRange.new(name: name, from: from, to: to)
34
+ end
35
+
36
+ def self.fixed(words, name, &resolver)
37
+ lambda do |tokens, start|
38
+ new(name: name, positions: (start...start + words.size).to_a, resolver: resolver) if tokens[start, words.size] == words
39
+ end
40
+ end
41
+
42
+ UNITS = %i[hours days weeks months].to_h { |unit| [ unit.to_s, unit ] }
43
+ .merge(%i[hour day week month].to_h { |unit| [ unit.to_s, :"#{unit}s" ] }).freeze
44
+
45
+ def self.rolling(tokens, start)
46
+ return unless %w[past last].include?(tokens[start]) && tokens[start + 1].to_s.match?(/\A\d{1,3}\z/)
47
+
48
+ count = Integer(tokens[start + 1], 10)
49
+ unit = UNITS[tokens[start + 2]]
50
+ return unless unit && count.positive?
51
+
52
+ name = "Last #{count} #{count == 1 ? unit.to_s.singularize : unit}"
53
+ new(name: name, positions: [ start, start + 1, start + 2 ], resolver: ->(now) { [ now - count.public_send(unit), nil ] })
54
+ end
55
+
56
+ # "past hour", "past week", "past month", "last hour": one unit back
57
+ # from now. "last week" and "last month" stay calendar phrases.
58
+ def self.rolling_unit(tokens, start)
59
+ unit = tokens[start + 1]
60
+ return unless (tokens[start] == "past" && %w[hour week month].include?(unit)) || tokens[start, 2] == %w[last hour]
61
+
62
+ new(name: "#{tokens[start].capitalize} #{unit}", positions: [ start, start + 1 ],
63
+ resolver: ->(now) { [ now - 1.public_send(unit), nil ] })
64
+ end
65
+
66
+ def self.since(tokens, start)
67
+ wday = Date::DAYNAMES.map(&:downcase).index(tokens[start + 1]) if tokens[start] == "since"
68
+ return unless wday
69
+
70
+ new(name: "Since #{Date::DAYNAMES[wday]}", positions: [ start, start + 1 ],
71
+ resolver: ->(now) { [ (now - ((now.wday - wday) % 7).days).beginning_of_day, nil ] })
72
+ end
73
+
74
+ PATTERNS = [
75
+ method(:rolling),
76
+ method(:rolling_unit),
77
+ method(:since),
78
+ fixed(%w[today], "Today") { |now| [ now.beginning_of_day, nil ] },
79
+ fixed(%w[yesterday], "Yesterday") { |now| [ now.yesterday.beginning_of_day, now.beginning_of_day ] },
80
+ fixed(%w[this week], "This week") { |now| [ now.beginning_of_week, nil ] },
81
+ fixed(%w[last week], "Last week") { |now| [ now.prev_week.beginning_of_week, now.beginning_of_week ] },
82
+ fixed(%w[this month], "This month") { |now| [ now.beginning_of_month, nil ] },
83
+ fixed(%w[last month], "Last month") { |now| [ now.prev_month.beginning_of_month, now.beginning_of_month ] }
84
+ ].freeze
85
+ end
86
+ end
87
+ 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.2"
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kieran Klaassen
@@ -198,6 +198,8 @@ files:
198
198
  - lib/generators/truffler/install/templates/channel.rb.tt
199
199
  - lib/generators/truffler/install/templates/initializer.rb.tt
200
200
  - lib/generators/truffler/install/templates/migration.rb.tt
201
+ - lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt
202
+ - lib/generators/truffler/upgrade/upgrade_generator.rb
201
203
  - lib/tasks/truffler.rake
202
204
  - lib/tasks/truffler/bench.rake
203
205
  - lib/tasks/truffler/suggestions.rake
@@ -283,6 +285,7 @@ files:
283
285
  - lib/truffler/query_encoding/prefetch.rb
284
286
  - lib/truffler/questions.rb
285
287
  - lib/truffler/railtie.rb
288
+ - lib/truffler/records/backfill_spend.rb
286
289
  - lib/truffler/records/embedding.rb
287
290
  - lib/truffler/records/label.rb
288
291
  - lib/truffler/records/query_miss.rb
@@ -295,6 +298,8 @@ files:
295
298
  - lib/truffler/search/query.rb
296
299
  - lib/truffler/search/result.rb
297
300
  - lib/truffler/search/sql.rb
301
+ - lib/truffler/search/time_phrase.rb
302
+ - lib/truffler/search/time_range.rb
298
303
  - lib/truffler/smart_search.rb
299
304
  - lib/truffler/smart_search/dispatcher.rb
300
305
  - lib/truffler/smart_search/reranker.rb