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.
- checksums.yaml +7 -0
- data/.env +7 -0
- data/.env.local.example +12 -0
- data/.github/workflows/test.yml +111 -0
- data/.gitignore +27 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +164 -0
- data/CONTRIBUTING.md +32 -0
- data/Gemfile +27 -0
- data/LICENSE.md +9 -0
- data/README.md +95 -0
- data/Rakefile +23 -0
- data/app/controllers/spree/admin/menu_chat_conversations_controller.rb +15 -0
- data/app/controllers/spree/admin/menu_chat_credentials_controller.rb +38 -0
- data/app/controllers/spree_menu_chat/chat_controller.rb +114 -0
- data/app/jobs/spree_menu_chat/base_job.rb +5 -0
- data/app/jobs/spree_menu_chat/reembed_catalog_job.rb +27 -0
- data/app/jobs/spree_menu_chat/reembed_product_job.rb +25 -0
- data/app/models/spree/product_decorator.rb +23 -0
- data/app/models/spree_menu_chat/conversation.rb +39 -0
- data/app/models/spree_menu_chat/credential.rb +16 -0
- data/app/models/spree_menu_chat/embedding.rb +30 -0
- data/app/models/spree_menu_chat/message.rb +35 -0
- data/app/models/spree_menu_chat/rate_limit.rb +14 -0
- data/app/models/spree_menu_chat/token_usage.rb +13 -0
- data/app/services/spree_menu_chat/alerting.rb +20 -0
- data/app/services/spree_menu_chat/answer_generator.rb +143 -0
- data/app/services/spree_menu_chat/content_builder.rb +65 -0
- data/app/services/spree_menu_chat/embedder.rb +39 -0
- data/app/services/spree_menu_chat/embedding_client.rb +161 -0
- data/app/services/spree_menu_chat/llm_client.rb +186 -0
- data/app/services/spree_menu_chat/rate_limiter.rb +53 -0
- data/app/services/spree_menu_chat/request_error.rb +26 -0
- data/app/services/spree_menu_chat/retriever.rb +64 -0
- data/app/services/spree_menu_chat/token_budget.rb +52 -0
- data/app/views/spree/admin/menu_chat_conversations/index.html.erb +5 -0
- data/app/views/spree/admin/menu_chat_conversations/show.html.erb +27 -0
- data/app/views/spree/admin/menu_chat_credentials/show.html.erb +44 -0
- data/config/brakeman.ignore +28 -0
- data/config/initializers/spree_admin_menu_chat_navigation.rb +21 -0
- data/config/initializers/spree_admin_menu_chat_tables.rb +61 -0
- data/config/routes.rb +26 -0
- data/db/migrate/20260814000001_create_spree_menu_chat_credentials.rb +16 -0
- data/db/migrate/20260816000001_enable_pgvector_extension.rb +16 -0
- data/db/migrate/20260816000002_create_spree_menu_chat_embeddings.rb +44 -0
- data/db/migrate/20260816000003_create_spree_menu_chat_rate_limits.rb +27 -0
- data/db/migrate/20260816000004_create_spree_menu_chat_token_usages.rb +19 -0
- data/db/migrate/20260816000005_create_spree_menu_chat_conversations.rb +20 -0
- data/db/migrate/20260816000006_create_spree_menu_chat_messages.rb +18 -0
- data/lib/generators/spree_menu_chat/install/install_generator.rb +20 -0
- data/lib/spree_menu_chat/configuration.rb +63 -0
- data/lib/spree_menu_chat/engine.rb +45 -0
- data/lib/spree_menu_chat/factories.rb +48 -0
- data/lib/spree_menu_chat/version.rb +7 -0
- data/lib/spree_menu_chat.rb +30 -0
- data/lib/tasks/spree_menu_chat.rake +93 -0
- data/spree_menu_chat.gemspec +63 -0
- 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,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
|
+
}
|