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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +38 -8
- data/lib/generators/truffler/install/templates/migration.rb.tt +10 -0
- data/lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt +19 -0
- data/lib/generators/truffler/upgrade/upgrade_generator.rb +24 -0
- data/lib/tasks/truffler.rake +42 -4
- data/lib/truffler/budget.rb +23 -8
- data/lib/truffler/clients/fake.rb +8 -2
- data/lib/truffler/configuration.rb +2 -1
- data/lib/truffler/definition.rb +35 -2
- data/lib/truffler/errors.rb +11 -1
- data/lib/truffler/jobs/backfill_job.rb +13 -6
- data/lib/truffler/label_definition.rb +14 -6
- data/lib/truffler/labeling/backfill.rb +148 -33
- data/lib/truffler/labeling/labeler.rb +2 -1
- data/lib/truffler/labeling/queue.rb +1 -0
- data/lib/truffler/labeling/supplied.rb +1 -0
- data/lib/truffler/lenses/drafter.rb +2 -1
- data/lib/truffler/model.rb +6 -5
- data/lib/truffler/query_encoding/encoder.rb +89 -14
- data/lib/truffler/records/backfill_spend.rb +51 -0
- data/lib/truffler/search/encoding.rb +9 -6
- data/lib/truffler/search/keystroke.rb +14 -3
- data/lib/truffler/search/query.rb +18 -6
- data/lib/truffler/search/result.rb +4 -2
- data/lib/truffler/search/sql.rb +11 -3
- data/lib/truffler/search/time_phrase.rb +87 -0
- data/lib/truffler/search/time_range.rb +10 -0
- data/lib/truffler/smart_search/starter.rb +3 -3
- data/lib/truffler/version.rb +1 -1
- data/lib/truffler/vocabulary.rb +5 -3
- data/lib/truffler.rb +4 -0
- metadata +6 -1
|
@@ -3,14 +3,15 @@ module Truffler
|
|
|
3
3
|
# A normalized, tokenized search query. Quoted phrases stay one token.
|
|
4
4
|
# Exact-text signals (quoted phrases, digit-bearing tokens, emails, and
|
|
5
5
|
# identifier shapes such as `INV-4471` or `order_id`) are detected
|
|
6
|
-
# locally and never asked of Jev (R18)
|
|
6
|
+
# locally and never asked of Jev (R18), and so is a time phrase ("this
|
|
7
|
+
# week"), whose words are neither exact tokens nor keywords.
|
|
7
8
|
class Query
|
|
8
9
|
QUOTED = /"([^"]*)"/
|
|
9
10
|
EMAIL = /\A[^@\s]+@[^@\s]+\.[a-z]{2,}\z/
|
|
10
11
|
IDENTIFIER = /\A[a-z0-9]+(?:[-_.][a-z0-9]+)+\z/
|
|
11
12
|
EDGE_PUNCTUATION = /\A[^\p{Alnum}@]+|[^\p{Alnum}]+\z/
|
|
12
13
|
|
|
13
|
-
attr_reader :raw, :normalized, :tokens, :exact_tokens
|
|
14
|
+
attr_reader :raw, :normalized, :tokens, :exact_tokens, :time_phrase
|
|
14
15
|
|
|
15
16
|
def self.wrap(query)
|
|
16
17
|
query.is_a?(self) ? query : new(query)
|
|
@@ -23,7 +24,18 @@ module Truffler
|
|
|
23
24
|
def initialize(raw)
|
|
24
25
|
@raw = raw.to_s
|
|
25
26
|
@normalized = self.class.normalize(raw)
|
|
26
|
-
@tokens,
|
|
27
|
+
@tokens, exact = tokenize(normalized)
|
|
28
|
+
@time_phrase = TimePhrase.find(@tokens)
|
|
29
|
+
@exact_tokens = exact.reject { |position| time_position?(position) }.map { |position| @tokens[position] }.freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def time_position?(position)
|
|
33
|
+
time_phrase&.positions&.include?(position) || false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The tokens a keyword source may match: all but the time phrase.
|
|
37
|
+
def search_tokens
|
|
38
|
+
tokens.each_index.reject { |position| time_position?(position) }.map { |position| tokens[position] }
|
|
27
39
|
end
|
|
28
40
|
|
|
29
41
|
def blank?
|
|
@@ -44,19 +56,19 @@ module Truffler
|
|
|
44
56
|
phrase = part.squish
|
|
45
57
|
next if phrase.empty?
|
|
46
58
|
|
|
59
|
+
exact << tokens.size
|
|
47
60
|
tokens << phrase
|
|
48
|
-
exact << phrase
|
|
49
61
|
else
|
|
50
62
|
part.split.each do |word|
|
|
51
63
|
word = word.gsub(EDGE_PUNCTUATION, "")
|
|
52
64
|
next if word.empty?
|
|
53
65
|
|
|
66
|
+
exact << tokens.size if exact?(word)
|
|
54
67
|
tokens << word
|
|
55
|
-
exact << word if exact?(word)
|
|
56
68
|
end
|
|
57
69
|
end
|
|
58
70
|
end
|
|
59
|
-
[ tokens.freeze, exact
|
|
71
|
+
[ tokens.freeze, exact ]
|
|
60
72
|
end
|
|
61
73
|
|
|
62
74
|
def exact?(word)
|
|
@@ -29,12 +29,14 @@ module Truffler
|
|
|
29
29
|
records.map(&:id)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# Applied filters, then boosts, as `{key:, label:, kind:, name:}`.
|
|
32
|
+
# Applied filters, then boosts, then the time range, as `{key:, label:, kind:, name:}`.
|
|
33
33
|
def chips
|
|
34
34
|
return [] unless encoding
|
|
35
35
|
|
|
36
36
|
filters = encoding.filters.keys.map { |key| chip(key, :filter) }
|
|
37
|
-
filters + (encoding.boosts.keys - encoding.filters.keys).map { |key| chip(key, :boost) }
|
|
37
|
+
chips = filters + (encoding.boosts.keys - encoding.filters.keys).map { |key| chip(key, :boost) }
|
|
38
|
+
time = encoding.time
|
|
39
|
+
time ? chips + [ { key: TimeRange.key, label: TimeRange.key, kind: :time, name: time.name } ] : chips
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
def score(record)
|
data/lib/truffler/search/sql.rb
CHANGED
|
@@ -24,16 +24,18 @@ module Truffler
|
|
|
24
24
|
@store = store
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# The caller's relation, ANDed with the tenant and the hard filters.
|
|
27
|
+
# The caller's relation, ANDed with the tenant, the time range, and the hard filters.
|
|
28
28
|
def base(scope)
|
|
29
29
|
scope = scope.where(definition.tenant_column => tenant_key) if definition.scoped?
|
|
30
|
+
scope = within_time(scope, encoding.time) if encoding.time
|
|
30
31
|
encoding.filters.reduce(scope) { |relation, (key, threshold)| relation.where(Arel.sql(label_filter_sql(key, threshold))) }
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
# Every record the query can return, before ranking.
|
|
34
|
+
# Every record the query can return, before ranking. Under a label
|
|
35
|
+
# filter the filter decides membership and text matches only rank.
|
|
34
36
|
def candidates(scope)
|
|
35
37
|
base = base(scope)
|
|
36
|
-
return base if label_only?
|
|
38
|
+
return base if label_only? || encoding.filters.any?
|
|
37
39
|
|
|
38
40
|
conditions = [ keyword_sql, exact_sql, (text_candidate_sql if text_score_sql) ].compact
|
|
39
41
|
base.where(Arel.sql(conditions.any? ? conditions.map { |condition| "(#{condition})" }.join(" OR ") : "1 = 0"))
|
|
@@ -104,6 +106,12 @@ module Truffler
|
|
|
104
106
|
"#{label_column('record_type')} = #{quote(model.polymorphic_name)}#{tenant} AND #{label_column('record_id')} = #{primary_key}"
|
|
105
107
|
end
|
|
106
108
|
|
|
109
|
+
def within_time(scope, range)
|
|
110
|
+
arrived_at = model.arel_table[definition.arrived_at_column]
|
|
111
|
+
scope = scope.where(arrived_at.gteq(range.from))
|
|
112
|
+
range.to ? scope.where(arrived_at.lt(range.to)) : scope
|
|
113
|
+
end
|
|
114
|
+
|
|
107
115
|
def label_filter_sql(key, threshold)
|
|
108
116
|
"EXISTS (SELECT 1 FROM #{quoted_labels} WHERE #{label_scope_sql} AND #{label_column('label_key')} = #{quote(key)} " \
|
|
109
117
|
"AND #{label_column('value')} >= #{Float(threshold)})"
|
|
@@ -0,0 +1,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)
|
data/lib/truffler/version.rb
CHANGED
data/lib/truffler/vocabulary.rb
CHANGED
|
@@ -17,11 +17,13 @@ module Truffler
|
|
|
17
17
|
@model = model
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
# {key => LabelDefinition or Lenses::LensLabel}: the declared labels
|
|
21
|
-
#
|
|
20
|
+
# {key => LabelDefinition or Lenses::LensLabel}: the declared labels
|
|
21
|
+
# the tenant has (a per-tenant choice with no options for it is left
|
|
22
|
+
# out), then the lens labels keyed "lens:<id>:<label>".
|
|
22
23
|
def labels_for(tenant_key: nil, user_key: nil, all_users: false)
|
|
24
|
+
declared = definition.per_tenant_vocabulary? ? definition.labels.select { |_, label| label.available?(tenant_key) } : definition.labels
|
|
23
25
|
lenses = Lenses.labels(definition.model, tenant_key: tenant_key, user_key: user_key, all_users: all_users)
|
|
24
|
-
lenses.empty? ?
|
|
26
|
+
lenses.empty? ? declared : declared.merge(lenses)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def fingerprint(label_key, tenant_key: nil)
|
data/lib/truffler.rb
CHANGED
|
@@ -19,6 +19,10 @@ loader.do_not_eager_load("#{__dir__}/truffler/lenses/ruby_llm_generator.rb")
|
|
|
19
19
|
loader.setup
|
|
20
20
|
|
|
21
21
|
module Truffler
|
|
22
|
+
# The answer to a choice question in query encoding that means "no
|
|
23
|
+
# option". The colon keeps it out of host option names, which reject it.
|
|
24
|
+
NO_OPTION = "truffler:none".freeze
|
|
25
|
+
|
|
22
26
|
class << self
|
|
23
27
|
def config
|
|
24
28
|
@config ||= Configuration.new
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: truffler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.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
|