spree_menu_chat 0.1.0

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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.env +7 -0
  3. data/.env.local.example +12 -0
  4. data/.github/workflows/test.yml +111 -0
  5. data/.gitignore +27 -0
  6. data/.rspec +3 -0
  7. data/CHANGELOG.md +164 -0
  8. data/CONTRIBUTING.md +32 -0
  9. data/Gemfile +27 -0
  10. data/LICENSE.md +9 -0
  11. data/README.md +95 -0
  12. data/Rakefile +23 -0
  13. data/app/controllers/spree/admin/menu_chat_conversations_controller.rb +15 -0
  14. data/app/controllers/spree/admin/menu_chat_credentials_controller.rb +38 -0
  15. data/app/controllers/spree_menu_chat/chat_controller.rb +114 -0
  16. data/app/jobs/spree_menu_chat/base_job.rb +5 -0
  17. data/app/jobs/spree_menu_chat/reembed_catalog_job.rb +27 -0
  18. data/app/jobs/spree_menu_chat/reembed_product_job.rb +25 -0
  19. data/app/models/spree/product_decorator.rb +23 -0
  20. data/app/models/spree_menu_chat/conversation.rb +39 -0
  21. data/app/models/spree_menu_chat/credential.rb +16 -0
  22. data/app/models/spree_menu_chat/embedding.rb +30 -0
  23. data/app/models/spree_menu_chat/message.rb +35 -0
  24. data/app/models/spree_menu_chat/rate_limit.rb +14 -0
  25. data/app/models/spree_menu_chat/token_usage.rb +13 -0
  26. data/app/services/spree_menu_chat/alerting.rb +20 -0
  27. data/app/services/spree_menu_chat/answer_generator.rb +143 -0
  28. data/app/services/spree_menu_chat/content_builder.rb +65 -0
  29. data/app/services/spree_menu_chat/embedder.rb +39 -0
  30. data/app/services/spree_menu_chat/embedding_client.rb +161 -0
  31. data/app/services/spree_menu_chat/llm_client.rb +186 -0
  32. data/app/services/spree_menu_chat/rate_limiter.rb +53 -0
  33. data/app/services/spree_menu_chat/request_error.rb +26 -0
  34. data/app/services/spree_menu_chat/retriever.rb +64 -0
  35. data/app/services/spree_menu_chat/token_budget.rb +52 -0
  36. data/app/views/spree/admin/menu_chat_conversations/index.html.erb +5 -0
  37. data/app/views/spree/admin/menu_chat_conversations/show.html.erb +27 -0
  38. data/app/views/spree/admin/menu_chat_credentials/show.html.erb +44 -0
  39. data/config/brakeman.ignore +28 -0
  40. data/config/initializers/spree_admin_menu_chat_navigation.rb +21 -0
  41. data/config/initializers/spree_admin_menu_chat_tables.rb +61 -0
  42. data/config/routes.rb +26 -0
  43. data/db/migrate/20260814000001_create_spree_menu_chat_credentials.rb +16 -0
  44. data/db/migrate/20260816000001_enable_pgvector_extension.rb +16 -0
  45. data/db/migrate/20260816000002_create_spree_menu_chat_embeddings.rb +44 -0
  46. data/db/migrate/20260816000003_create_spree_menu_chat_rate_limits.rb +27 -0
  47. data/db/migrate/20260816000004_create_spree_menu_chat_token_usages.rb +19 -0
  48. data/db/migrate/20260816000005_create_spree_menu_chat_conversations.rb +20 -0
  49. data/db/migrate/20260816000006_create_spree_menu_chat_messages.rb +18 -0
  50. data/lib/generators/spree_menu_chat/install/install_generator.rb +20 -0
  51. data/lib/spree_menu_chat/configuration.rb +63 -0
  52. data/lib/spree_menu_chat/engine.rb +45 -0
  53. data/lib/spree_menu_chat/factories.rb +48 -0
  54. data/lib/spree_menu_chat/version.rb +7 -0
  55. data/lib/spree_menu_chat.rb +30 -0
  56. data/lib/tasks/spree_menu_chat.rake +93 -0
  57. data/spree_menu_chat.gemspec +63 -0
  58. metadata +216 -0
@@ -0,0 +1,161 @@
1
+ require 'faraday'
2
+
3
+ module SpreeMenuChat
4
+ # Thin wrapper around Voyage AI's embeddings REST endpoint. All embedding
5
+ # calls in this extension go through here — same "no official Ruby SDK,
6
+ # call the REST API directly" situation as SpreeMenuChat::LlmClient (for
7
+ # Gemini) and SpreeDoordash::Client (for DoorDash's Drive API).
8
+ #
9
+ # Self-throttles to the real limit (see #throttle!) rather than firing
10
+ # requests as fast as the caller loops and letting a generic error bubble
11
+ # up to ReembedCatalogJob's job-level retry_on — found live, running
12
+ # reembed_all against the real synced catalog: Voyage accounts with no
13
+ # payment method on file (true even for accounts still fully covered by
14
+ # the 200M-token free grant — the reduced limit is separate from the
15
+ # free-token allowance) are throttled to a real 3 requests/minute, 10K
16
+ # tokens/minute.
17
+ #
18
+ # First attempt at this fix (reactive: catch a 429, sleep ~20s, retry)
19
+ # undershot in practice — confirmed live: after the initial 3-request
20
+ # burst, retries kept landing inside the *same* still-open rate window
21
+ # (a 3-req/60s window doesn't fully clear just because 20s passed since
22
+ # the failure), so most retries 429'd again and the run silently
23
+ # bottomed out at 21/43 records. Self-throttling — tracking this
24
+ # process's own last N request timestamps and sleeping proactively
25
+ # before the (N+1)th falls inside the window — avoids firing requests
26
+ # doomed to 429 in the first place, rather than reacting after the fact.
27
+ # The reactive 429-retry (#request's rescue) stays as a safety net for
28
+ # anything the proactive throttle doesn't account for (clock skew,
29
+ # another process/job sharing this store's key), not as the primary
30
+ # mechanism anymore.
31
+ #
32
+ # Class-level (not per-instance) because the rate limit is scoped to the
33
+ # Voyage account/API key, not to any one EmbeddingClient instance — and
34
+ # this client is deliberately non-memoized (see .for_store below), so
35
+ # per-instance state would track nothing real. In-process/in-memory is
36
+ # sufficient here, not a Redis-backed limiter: this stack runs a single
37
+ # Puma+Solid-Queue process (no Redis anywhere in this project, same
38
+ # rationale as SpreeMenuChat::RateLimiter), and the account-level limit
39
+ # is already generous enough (3 RPM) that cross-process undercounting
40
+ # in a multi-dyno deployment would need to be revisited before that kind
41
+ # of scale, not before this.
42
+ class EmbeddingClient
43
+ class MissingCredentialsError < StandardError; end
44
+
45
+ BASE_URL = 'https://api.voyageai.com'.freeze
46
+
47
+ # The real, live-confirmed no-payment-method limit (see class comment).
48
+ MAX_REQUESTS_PER_WINDOW = 3
49
+ WINDOW_SECONDS = 60
50
+ WINDOW_MARGIN_SECONDS = 2 # clears clock-precision/latency edge cases
51
+
52
+ # Reactive safety net for a 429 the proactive throttle didn't prevent.
53
+ RATE_LIMIT_RETRY_WAIT = WINDOW_SECONDS + WINDOW_MARGIN_SECONDS
54
+ RATE_LIMIT_MAX_ATTEMPTS = 3
55
+
56
+ @request_times = []
57
+ @request_times_mutex = Mutex.new
58
+
59
+ class << self
60
+ # Exposed for specs (reset between examples) — not meant to be read
61
+ # or written from application code.
62
+ attr_accessor :request_times
63
+
64
+ def request_times_mutex
65
+ @request_times_mutex
66
+ end
67
+ end
68
+
69
+ # Deliberately NOT memoized — same rationale as every other client in
70
+ # this project (SpreeSquare::Client, SpreeDoordash::Client,
71
+ # SpreeMenuChat::LlmClient): a store's credential can be created/edited
72
+ # by an admin mid-process, and a Faraday connection is cheap enough to
73
+ # not be worth caching against that risk.
74
+ def self.instance
75
+ for_store
76
+ end
77
+
78
+ def self.for_store(store = Spree::Store.default)
79
+ new(credential: SpreeMenuChat::Credential.find_by(store: store))
80
+ end
81
+
82
+ def initialize(credential: nil)
83
+ @credential = credential
84
+ raise MissingCredentialsError, 'No Menu Chat credential connected for this store' unless @credential
85
+ raise MissingCredentialsError, 'No Voyage AI API key set for this store' if @credential.voyage_api_key.blank?
86
+ end
87
+
88
+ # `input` is a single string or an array of strings — Voyage's own
89
+ # accepted shape supports batching several documents into one request,
90
+ # though every current caller (SpreeMenuChat::Embedder, one record at a
91
+ # time) passes a single string; batching multiple products per call
92
+ # would cut the request count for ReembedCatalogJob's full-catalog pass
93
+ # and is worth doing as a follow-up given the real 3 RPM limit
94
+ # documented above, but isn't required for correctness now that every
95
+ # call is self-throttled to that limit. `input_type` is Voyage's own asymmetric-embedding
96
+ # hint: "document" for content being indexed (what this extension
97
+ # always embeds), "query" for the customer's question at retrieval time
98
+ # (SpreeMenuChat::Retriever, M3) — using the right one measurably
99
+ # improves retrieval quality per Voyage's own docs, it's not optional
100
+ # boilerplate.
101
+ def embed(input, input_type:, model: SpreeMenuChat::Config.embedding_model)
102
+ body = { input: Array(input), model: model, input_type: input_type }
103
+ result = request(:post, '/v1/embeddings', body)
104
+ result.fetch('data', []).map { |row| row['embedding'] }
105
+ end
106
+
107
+ private
108
+
109
+ def request(method, path, body = nil)
110
+ attempt = 1
111
+ begin
112
+ throttle!
113
+ response = connection.send(method) do |req|
114
+ req.url path
115
+ req.headers['Authorization'] = "Bearer #{@credential.voyage_api_key}"
116
+ req.headers['Content-Type'] = 'application/json'
117
+ req.body = body.to_json if body
118
+ end
119
+ handle_response(response)
120
+ rescue RequestError => e
121
+ raise unless e.status == 429 && attempt < RATE_LIMIT_MAX_ATTEMPTS
122
+
123
+ attempt += 1
124
+ sleep(RATE_LIMIT_RETRY_WAIT)
125
+ retry
126
+ end
127
+ end
128
+
129
+ # Blocks the calling thread until issuing another request keeps this
130
+ # process within MAX_REQUESTS_PER_WINDOW calls per WINDOW_SECONDS. See
131
+ # the class comment for why this needs to be proactive, not just a
132
+ # reactive retry-after-429.
133
+ def throttle!
134
+ self.class.request_times_mutex.synchronize do
135
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
136
+ self.class.request_times.reject! { |t| now - t >= WINDOW_SECONDS }
137
+
138
+ if self.class.request_times.size >= MAX_REQUESTS_PER_WINDOW
139
+ oldest = self.class.request_times.min
140
+ wait = (WINDOW_SECONDS - (now - oldest)) + WINDOW_MARGIN_SECONDS
141
+ sleep(wait) if wait.positive?
142
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
143
+ self.class.request_times.reject! { |t| now - t >= WINDOW_SECONDS }
144
+ end
145
+
146
+ self.class.request_times << Process.clock_gettime(Process::CLOCK_MONOTONIC)
147
+ end
148
+ end
149
+
150
+ def connection
151
+ @connection ||= Faraday.new(url: BASE_URL)
152
+ end
153
+
154
+ def handle_response(response)
155
+ parsed = response.body.present? ? JSON.parse(response.body) : {}
156
+ return parsed if response.status.between?(200, 299)
157
+
158
+ raise RequestError.new("Voyage API error (#{response.status}): #{parsed.inspect}", status: response.status, body: parsed)
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,186 @@
1
+ require 'faraday'
2
+
3
+ module SpreeMenuChat
4
+ # Thin wrapper around the Gemini API's generateContent/streamGenerateContent
5
+ # REST endpoints. All generation calls in this extension go through here.
6
+ #
7
+ # There is no official Google-maintained Ruby SDK for the Gemini API
8
+ # (confirmed — only unofficial community gems exist) — same "no SDK, call
9
+ # the REST API directly" situation as SpreeDoordash::Client. Auth is a
10
+ # plain API key on the query string (Gemini's own documented shape;
11
+ # unlike DoorDash there's no per-request signing).
12
+ #
13
+ # Model default is the gemini-flash-lite-latest ALIAS, not a pinned dated
14
+ # model (see SpreeMenuChat::Configuration for why — confirmed live that a
15
+ # pinned model can 404 for new API keys while still appearing in the
16
+ # models.list catalog). If this ever 404s anyway, check the current model
17
+ # catalog and free-tier limits at https://aistudio.google.com.
18
+ class LlmClient
19
+ class MissingCredentialsError < StandardError; end
20
+
21
+ BASE_URL = 'https://generativelanguage.googleapis.com'.freeze
22
+
23
+ # Deliberately NOT memoized, same rationale as SpreeSquare::Client and
24
+ # SpreeDoordash::Client: a store's credential can be created/edited by an
25
+ # admin mid-process, and a Faraday connection is cheap enough to not be
26
+ # worth caching against that risk.
27
+ def self.instance
28
+ for_store
29
+ end
30
+
31
+ def self.for_store(store = Spree::Store.default)
32
+ new(credential: SpreeMenuChat::Credential.find_by(store: store))
33
+ end
34
+
35
+ def initialize(credential: nil)
36
+ @credential = credential
37
+ raise MissingCredentialsError, 'No Menu Chat credential connected for this store' unless @credential
38
+ raise MissingCredentialsError, 'No Gemini API key set for this store' if @credential.gemini_api_key.blank?
39
+ end
40
+
41
+ # Single-shot, non-streaming generation — used by M1's verify_connection
42
+ # rake task and SpreeMenuChat::AnswerGenerator#call. ChatController
43
+ # itself uses #generate_stream (below) as of M4; #call/#generate stay
44
+ # around as the simpler synchronous API for callers that don't need
45
+ # streaming (rake tasks, tests, a future admin "test this question"
46
+ # action).
47
+ #
48
+ # system_instruction is Gemini's own top-level field for a system prompt
49
+ # (kept separate from `contents`, not concatenated into the user turn —
50
+ # this is the mechanism SpreeMenuChat::AnswerGenerator uses to scope the
51
+ # assistant to menu/FAQ-only, per the plan's read-only guardrail).
52
+ def generate(prompt, system_instruction: nil, model: SpreeMenuChat::Config.generation_model)
53
+ request(:post, "/v1beta/models/#{model}:generateContent", generate_body(prompt, system_instruction))
54
+ end
55
+
56
+ # Streaming counterpart of #generate (M4) — calls Gemini's own
57
+ # `:streamGenerateContent?alt=sse` endpoint and yields each text
58
+ # fragment to the block as it arrives, instead of waiting for the full
59
+ # response. Confirmed live (2026-08-16): Gemini's real SSE frames are
60
+ # `data: <json>` blocks separated by a blank line, using `\r\n` line
61
+ # endings (see #consume_sse_frames for why that specific detail is
62
+ # load-bearing), where each JSON object has the same
63
+ # candidates[0].content.parts[0].text shape #generate already parses —
64
+ # streaming just delivers it in more, smaller pieces, not a different
65
+ # shape. The very last frame typically carries an empty `text` plus a
66
+ # `finishReason`/`thoughtSignature` — skipped here since there's
67
+ # nothing to yield.
68
+ #
69
+ # Uses Faraday's `on_data` streaming callback (confirmed live to work
70
+ # with this project's plain net_http adapter — no extra gem needed)
71
+ # rather than buffering the whole response and chunking it ourselves,
72
+ # so a slow/long answer actually reaches the caller incrementally
73
+ # instead of only feeling instant right at the very end.
74
+ #
75
+ # Returns the final totalTokenCount Gemini reported (each SSE frame's
76
+ # usageMetadata carries a running total, so the last one seen is the
77
+ # true total for the whole turn) — SpreeMenuChat::AnswerGenerator feeds
78
+ # this straight into SpreeMenuChat::TokenBudget (M5) rather than
79
+ # estimating spend itself. Returns nil if the response never included
80
+ # usage data (shouldn't happen in practice, but callers must not
81
+ # assume a value).
82
+ def generate_stream(prompt, system_instruction: nil, model: SpreeMenuChat::Config.generation_model, &block)
83
+ raw = +''
84
+ sse_buffer = +''
85
+ total_tokens = nil
86
+
87
+ response = connection.post("/v1beta/models/#{model}:streamGenerateContent") do |req|
88
+ req.params['alt'] = 'sse'
89
+ req.params['key'] = @credential.gemini_api_key
90
+ req.headers['Content-Type'] = 'application/json'
91
+ req.body = generate_body(prompt, system_instruction).to_json
92
+ req.options.on_data = proc do |chunk, _bytes|
93
+ raw << chunk
94
+ sse_buffer << chunk
95
+ seen = consume_sse_frames(sse_buffer, &block)
96
+ total_tokens = seen if seen
97
+ end
98
+ end
99
+
100
+ return total_tokens if response.status.between?(200, 299)
101
+
102
+ # Confirmed live: an error response on this endpoint is plain JSON
103
+ # (Gemini's normal {"error": {...}} shape), not an SSE "data:" frame
104
+ # — it never matched the `data:` prefix above, so it's still sitting
105
+ # in `raw` untouched here, same as #handle_response would see it in
106
+ # response.body for the non-streaming endpoint.
107
+ parsed = raw.present? ? JSON.parse(raw) : {}
108
+ raise RequestError.new("Gemini API error (#{response.status}): #{parsed.inspect}", status: response.status, body: parsed)
109
+ end
110
+
111
+ private
112
+
113
+ def generate_body(prompt, system_instruction)
114
+ body = { contents: [{ parts: [{ text: prompt }] }] }
115
+ body[:systemInstruction] = { parts: [{ text: system_instruction }] } if system_instruction
116
+ body
117
+ end
118
+
119
+ # Pulls complete "data: {...}<blank line>" frames out of the buffer as
120
+ # they become available and yields each frame's real text fragment (if
121
+ # any) to the block — leaves any trailing partial frame in the buffer
122
+ # for the next chunk to complete.
123
+ #
124
+ # Normalizes CRLF to LF on the way in before splitting on a blank
125
+ # line. This isn't defensive over-engineering: confirmed live
126
+ # (2026-08-16) that Gemini's real streamGenerateContent responses use
127
+ # "\r\n" line endings, not bare "\n" — a version of this method that
128
+ # only ever searched for a literal "\n\n" (matching the SSE spec's
129
+ # most commonly *assumed* framing, and what every hand-written WebMock
130
+ # fixture in this gem's own spec suite naturally produces) silently
131
+ # yielded zero chunks against the real API every single time, with no
132
+ # exception and no error logged — `raw` never matched `data:` because
133
+ # it was never split into frames at all. The self-authored WebMock
134
+ # stubs couldn't catch this: they tested this method's logic against
135
+ # my own (wrong) assumption about the wire format, not Gemini's actual
136
+ # one.
137
+ #
138
+ # Returns the last usageMetadata.totalTokenCount seen among the frames
139
+ # consumed on *this* call (nil if none carried one) — #generate_stream
140
+ # keeps whichever value was most recently non-nil across every on_data
141
+ # invocation, since a single call here only ever sees the frames that
142
+ # arrived in one chunk.
143
+ def consume_sse_frames(buffer)
144
+ buffer.gsub!("\r\n", "\n")
145
+ last_total_tokens = nil
146
+
147
+ while (idx = buffer.index("\n\n"))
148
+ frame = buffer.slice!(0..idx + 1)
149
+ next unless frame.start_with?('data:')
150
+
151
+ json_str = frame.delete_prefix('data:').strip
152
+ next if json_str.empty?
153
+
154
+ parsed = JSON.parse(json_str)
155
+ text = parsed.dig('candidates', 0, 'content', 'parts', 0, 'text')
156
+ yield text if text.present?
157
+
158
+ tokens = parsed.dig('usageMetadata', 'totalTokenCount')
159
+ last_total_tokens = tokens if tokens
160
+ end
161
+
162
+ last_total_tokens
163
+ end
164
+
165
+ def request(method, path, body = nil)
166
+ response = connection.send(method) do |req|
167
+ req.url path
168
+ req.params['key'] = @credential.gemini_api_key
169
+ req.headers['Content-Type'] = 'application/json'
170
+ req.body = body.to_json if body
171
+ end
172
+ handle_response(response)
173
+ end
174
+
175
+ def connection
176
+ @connection ||= Faraday.new(url: BASE_URL)
177
+ end
178
+
179
+ def handle_response(response)
180
+ parsed = response.body.present? ? JSON.parse(response.body) : {}
181
+ return parsed if response.status.between?(200, 299)
182
+
183
+ raise RequestError.new("Gemini API error (#{response.status}): #{parsed.inspect}", status: response.status, body: parsed)
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,53 @@
1
+ module SpreeMenuChat
2
+ # Fixed-window rate limiter — SpreeMenuChat::Config.rate_limit_per_hour
3
+ # requests per (store, identifier) per rolling hour, Postgres-backed
4
+ # (no Redis anywhere in this stack, same rationale as Solid Queue running
5
+ # inside Postgres rather than a separate broker).
6
+ #
7
+ # Called from SpreeMenuChat::ChatController with request.remote_ip as the
8
+ # identifier — the widget is anonymous (no session/customer model yet),
9
+ # so IP is what's actually available before any answer is generated.
10
+ class RateLimiter
11
+ class LimitExceededError < StandardError; end
12
+
13
+ def self.check!(identifier, store: Spree::Store.default)
14
+ new(identifier, store: store).check!
15
+ end
16
+
17
+ def initialize(identifier, store:)
18
+ @identifier = identifier
19
+ @store = store
20
+ end
21
+
22
+ # Raises LimitExceededError once the identifier has made
23
+ # rate_limit_per_hour requests within the current window; otherwise
24
+ # increments the counter and returns normally. Fixed window (resets a
25
+ # full hour after the first request in it), not a true sliding window
26
+ # — simpler, and standard practice for a limit this size.
27
+ def check!
28
+ ActiveRecord::Base.transaction do
29
+ record = SpreeMenuChat::RateLimit.lock.find_or_initialize_by(store: @store, identifier: @identifier)
30
+ reset_if_window_expired(record)
31
+
32
+ raise LimitExceededError if record.count >= SpreeMenuChat::Config.rate_limit_per_hour
33
+
34
+ record.count += 1
35
+ record.save!
36
+ end
37
+ rescue ActiveRecord::RecordNotUnique
38
+ # Lost a race with a concurrent first-request from the same
39
+ # identifier — the row exists now, retry against it. Same pattern as
40
+ # SpreeSquare::WebhooksController#find_or_log_event.
41
+ retry
42
+ end
43
+
44
+ private
45
+
46
+ def reset_if_window_expired(record)
47
+ return if record.window_started_at.present? && record.window_started_at > 1.hour.ago
48
+
49
+ record.window_started_at = Time.current
50
+ record.count = 0
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,26 @@
1
+ module SpreeMenuChat
2
+ # Shared error class raised by both SpreeMenuChat::LlmClient (Gemini) and
3
+ # SpreeMenuChat::EmbeddingClient (Voyage) on a non-2xx response.
4
+ #
5
+ # This used to live inline at the bottom of llm_client.rb — that worked in
6
+ # every spec run (Zeitwerk's eager_load_all, which spec/zeitwerk_spec.rb
7
+ # exercises, loads every file up front regardless of order) but broke for
8
+ # real: `rails runner` autoloads lazily, so calling
9
+ # SpreeMenuChat::EmbeddingClient without anything having first touched
10
+ # SpreeMenuChat::LlmClient left RequestError genuinely undefined —
11
+ # `NameError: uninitialized constant SpreeMenuChat::EmbeddingClient::
12
+ # RequestError`, confirmed live while running SpreeMenuChat::ReembedCatalogJob
13
+ # against the real synced catalog. Zeitwerk expects one constant per file
14
+ # matching its path; a class defined as a second citizen of another file
15
+ # only resolves by the accident of load order. Given its own file here, it
16
+ # autoloads correctly regardless of which client references it first.
17
+ class RequestError < StandardError
18
+ attr_reader :status, :body
19
+
20
+ def initialize(message, status:, body:)
21
+ super(message)
22
+ @status = status
23
+ @body = body
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,64 @@
1
+ module SpreeMenuChat
2
+ # Embeds a customer's question and returns the top-K most similar
3
+ # SpreeMenuChat::Embedding rows for the store, via pgvector cosine
4
+ # distance — the retrieval half of RAG. SpreeMenuChat::AnswerGenerator is
5
+ # the only caller; kept separate so retrieval and generation each have
6
+ # one job.
7
+ #
8
+ # Uses `input_type: 'query'` (not 'document') on the embedding call —
9
+ # Voyage's asymmetric-embedding hint for the search-query side of a
10
+ # document/query pair, per the same rationale already documented on
11
+ # SpreeMenuChat::EmbeddingClient#embed.
12
+ class Retriever
13
+ def self.call(question, store: Spree::Store.default)
14
+ new(question, store: store).call
15
+ end
16
+
17
+ def initialize(question, store:)
18
+ @question = question
19
+ @store = store
20
+ end
21
+
22
+ # Returns a plain Array of SpreeMenuChat::Embedding (each with a real
23
+ # #neighbor_distance, populated by the `nearest_neighbors` scope) —
24
+ # empty if the question is blank, embedding it fails, or nothing clears
25
+ # the similarity floor. Never raises SpreeMenuChat::RequestError itself;
26
+ # SpreeMenuChat::AnswerGenerator treats "nothing retrieved" as the
27
+ # trigger for its no-context fallback regardless of *why* nothing came
28
+ # back, so a Voyage outage degrades to the same honest "I don't have
29
+ # that" response as a genuinely unanswerable question, not a 500.
30
+ def call
31
+ return [] if @question.blank?
32
+
33
+ query_vector = embed_question
34
+ return [] if query_vector.nil?
35
+
36
+ # `nearest_neighbors` is chainable (a plain `scope`, see the neighbor
37
+ # gem) but its `neighbor_distance` is a SELECT-list alias — Postgres
38
+ # doesn't allow referencing that in a WHERE clause on the same query,
39
+ # so the similarity floor is applied in Ruby after fetching, not as
40
+ # SQL. `retrieval_top_k` keeps that fetch small regardless.
41
+ SpreeMenuChat::Embedding
42
+ .where(store: @store)
43
+ .nearest_neighbors(:embedding, query_vector, distance: 'cosine')
44
+ .limit(SpreeMenuChat::Config.retrieval_top_k)
45
+ .select { |record| record.neighbor_distance <= max_distance }
46
+ end
47
+
48
+ private
49
+
50
+ def embed_question
51
+ SpreeMenuChat::EmbeddingClient.for_store(@store).embed(@question, input_type: 'query').first
52
+ rescue SpreeMenuChat::RequestError, SpreeMenuChat::EmbeddingClient::MissingCredentialsError => e
53
+ SpreeMenuChat::Alerting.capture(e, context: { area: 'retriever', store_id: @store.id })
54
+ nil
55
+ end
56
+
57
+ # Cosine distance = 1 - cosine similarity, so a similarity floor of 0.35
58
+ # (the default — see SpreeMenuChat::Configuration for why) becomes
59
+ # "keep anything within 0.65 distance."
60
+ def max_distance
61
+ 1.0 - SpreeMenuChat::Config.similarity_floor.to_f
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,52 @@
1
+ module SpreeMenuChat
2
+ # Per-store daily cap on Gemini generation token spend (see
3
+ # SpreeMenuChat::Configuration#daily_token_budget) — the other half of
4
+ # M5's guardrails alongside SpreeMenuChat::RateLimiter. Once a store's
5
+ # daily total is exhausted, SpreeMenuChat::AnswerGenerator stops calling
6
+ # Gemini for the rest of the day and falls back to a "come back tomorrow
7
+ # / contact us" response instead, rather than continuing to spend.
8
+ #
9
+ # Deliberately tracks *generation* tokens only, using Gemini's own
10
+ # reported usageMetadata.totalTokenCount (not embedding tokens, and not
11
+ # an estimate). Voyage's embedding call in the live chat path is just the
12
+ # query embedding (SpreeMenuChat::Retriever) — a few dozen tokens,
13
+ # dwarfed by a real generation call's typical few-hundred-token cost.
14
+ # Folding embedding spend in too would be more complete, but it would
15
+ # mean EmbeddingClient/Retriever both starting to return usage alongside
16
+ # vectors — real plumbing, not just a config tweak — so it's left as a
17
+ # follow-up rather than blocking this guardrail on it.
18
+ class TokenBudget
19
+ def self.exceeded?(store: Spree::Store.default)
20
+ new(store: store).exceeded?
21
+ end
22
+
23
+ def self.record!(tokens, store: Spree::Store.default)
24
+ new(store: store).record!(tokens)
25
+ end
26
+
27
+ def initialize(store:)
28
+ @store = store
29
+ end
30
+
31
+ def exceeded?
32
+ used = SpreeMenuChat::TokenUsage.find_by(store: @store, date: Date.current)&.tokens_used || 0
33
+ used >= SpreeMenuChat::Config.daily_token_budget
34
+ end
35
+
36
+ def record!(tokens)
37
+ tokens = tokens.to_i
38
+ return if tokens <= 0
39
+
40
+ ActiveRecord::Base.transaction do
41
+ usage = SpreeMenuChat::TokenUsage.lock.find_or_initialize_by(store: @store, date: Date.current)
42
+ usage.tokens_used = usage.tokens_used.to_i + tokens
43
+ usage.save!
44
+ end
45
+ rescue ActiveRecord::RecordNotUnique
46
+ # Lost a race with a concurrent first-request-of-the-day for this
47
+ # store — the row exists now, retry against it. Same pattern as
48
+ # SpreeMenuChat::RateLimiter#check!.
49
+ retry
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ <% content_for :page_title do %>
2
+ Menu Chat Conversations
3
+ <% end %>
4
+
5
+ <%= render_table @collection, :menu_chat_conversations %>
@@ -0,0 +1,27 @@
1
+ <% content_for :page_title do %>
2
+ Conversation — <%= @object.identifier %>
3
+ <% end %>
4
+
5
+ <div class="max-w-3xl mx-auto space-y-4 py-6">
6
+ <div class="text-sm text-gray-500">
7
+ Started <%= l(@object.created_at, format: :long) %> ·
8
+ Last message <%= l(@object.updated_at, format: :long) %>
9
+ </div>
10
+
11
+ <ul class="space-y-3">
12
+ <% @object.messages.order(:created_at).each do |message| %>
13
+ <li class="flex <%= message.role == 'user' ? 'justify-end' : 'justify-start' %>">
14
+ <div class="max-w-[80%] rounded-lg px-4 py-2 <%= message.role == 'user' ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-900' %>">
15
+ <div class="text-xs opacity-70 mb-1"><%= message.role.capitalize %> · <%= l(message.created_at, format: :short) %></div>
16
+ <% if message.role == 'user' %>
17
+ <div class="whitespace-pre-wrap text-sm"><%= message.rendered_content %></div>
18
+ <% else %>
19
+ <div class="prose prose-sm max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0">
20
+ <%= message.rendered_content %>
21
+ </div>
22
+ <% end %>
23
+ </div>
24
+ </li>
25
+ <% end %>
26
+ </ul>
27
+ </div>
@@ -0,0 +1,44 @@
1
+ <% content_for :page_title do %>
2
+ Menu Chat Connection
3
+ <% end %>
4
+
5
+ <div class="card w-full max-w-2xl">
6
+ <div class="card-header flex items-center justify-between <%= 'bg-green-50' if @credential.persisted? %>">
7
+ <span class="font-medium">Menu Chat Assistant</span>
8
+ <% if @credential.persisted? %>
9
+ <span class="inline-flex items-center text-xs font-medium text-green-700">
10
+ <span class="w-1.5 h-1.5 rounded-full bg-green-500 mr-1"></span>
11
+ Configured
12
+ </span>
13
+ <% else %>
14
+ <span class="inline-flex items-center text-xs font-medium text-gray-500">
15
+ <span class="w-1.5 h-1.5 rounded-full bg-gray-400 mr-1"></span>
16
+ Not configured
17
+ </span>
18
+ <% end %>
19
+ </div>
20
+
21
+ <%= form_for @credential, url: admin_menu_chat_credential_path, method: :patch,
22
+ as: :spree_menu_chat_credential, html: { class: 'card-body space-y-4' } do |f| %>
23
+ <p class="text-sm text-gray-600">
24
+ Create a free API key at
25
+ <a href="https://aistudio.google.com" target="_blank" rel="noopener" class="text-blue-600 underline">Google AI Studio</a>
26
+ (generation) and
27
+ <a href="https://www.voyageai.com" target="_blank" rel="noopener" class="text-blue-600 underline">Voyage AI</a>
28
+ (embeddings), then paste both below. Stored encrypted — neither service uses OAuth.
29
+ </p>
30
+
31
+ <div>
32
+ <%= f.label :gemini_api_key, 'Gemini API key', class: 'label' %>
33
+ <%= f.password_field :gemini_api_key, value: '', placeholder: (@credential.gemini_api_key.present? ? '•••••••• (leave blank to keep current)' : nil), class: 'form-control' %>
34
+ </div>
35
+ <div>
36
+ <%= f.label :voyage_api_key, 'Voyage AI API key', class: 'label' %>
37
+ <%= f.password_field :voyage_api_key, value: '', placeholder: (@credential.voyage_api_key.present? ? '•••••••• (leave blank to keep current)' : nil), class: 'form-control' %>
38
+ </div>
39
+
40
+ <div class="card-footer -mx-4 -mb-4">
41
+ <%= f.submit 'Save', class: 'btn btn-primary' %>
42
+ </div>
43
+ <% end %>
44
+ </div>
@@ -0,0 +1,28 @@
1
+ {
2
+ "ignored_warnings": [
3
+ {
4
+ "warning_type": "Cross-Site Request Forgery",
5
+ "warning_code": 86,
6
+ "fingerprint": "d7d765af355e7f6488034527edd09cc2dc9a043630f9d8eb0e2d924a1c0665f1",
7
+ "check_name": "ForgerySetting",
8
+ "message": "`protect_from_forgery` should be configured with `with: :exception`",
9
+ "file": "app/controllers/spree_menu_chat/chat_controller.rb",
10
+ "line": 24,
11
+ "link": "https://brakemanscanner.org/docs/warning_types/cross-site_request_forgery/",
12
+ "code": "protect_from_forgery(:with => :null_session)",
13
+ "render_path": null,
14
+ "location": {
15
+ "type": "controller",
16
+ "controller": "SpreeMenuChat::ChatController"
17
+ },
18
+ "user_input": null,
19
+ "confidence": "Medium",
20
+ "cwe_id": [
21
+ 352
22
+ ],
23
+ "note": "Intentional, not a false positive to fix: this is a customer-facing JSON endpoint authenticated by the store's publishable API key (X-Spree-Api-Key), not by a Rails session, so there's no session-based state for CSRF to protect in the first place. `:exception` (Brakeman's preferred default) would make every legitimate request raise ActionController::InvalidAuthenticityToken, since the Next.js proxy never sends a Rails CSRF token — that would break the endpoint entirely, not harden it. `:null_session` is the correct, standard Rails pattern here, same as spree_square's WebhooksController (see that gem's config/brakeman.ignore for the identical rationale)."
24
+ }
25
+ ],
26
+ "updated": "2026-08-16 00:00:00 +0530",
27
+ "brakeman_version": "8.0.6"
28
+ }