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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e773d5fcf8ca557cd5fc7cb58930c80ebeb82a9a2d4d9adc1ee06a0743fac99c
|
|
4
|
+
data.tar.gz: f86c96e4b2212539847eb6f2786b90d71047c4dffd87ecd6562e176d15a2e27a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '0941702474b90131942c7e33d83826d84bbfe77fb89b8f5cb554d92406905b1b665b925bee32828337b47fb61ea3b01447b2ae8b70f1e91120b3ef2c59068749'
|
|
7
|
+
data.tar.gz: 5668abbbbcffdc651d591849f1e4c98dca3b11622bfa8501c2b7f0b0a2fb3b88db0134f068aadb8bcfb9d3210cc2e79ffe29fb0501a9761a4ed9fd0848987ed7
|
data/.env
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Loaded via `dotenv/load` in spec/spec_helper.rb, for the dummy test app
|
|
2
|
+
# only — not read by anything that ships. Test-only Active Record Encryption
|
|
3
|
+
# keys (SpreeMenuChat::Credential) so specs can save/load encrypted columns;
|
|
4
|
+
# generated with SecureRandom, no real secret behind them.
|
|
5
|
+
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=NRCoSclmJmMJNmoyFqWPrH5fdkeL2tox
|
|
6
|
+
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=qdzUbi7VbUaAqmTDn7qHHnaXxlN1CE1u
|
|
7
|
+
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=HxVDWyTgl04erZuEZCqd7nhVmHtkIRFJ
|
data/.env.local.example
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Copy this file to .env.local (gitignored) and fill in real keys to run
|
|
2
|
+
# this extension's manual verification rake tasks against the live APIs.
|
|
3
|
+
#
|
|
4
|
+
# Gemini — free key at https://aistudio.google.com, no billing account
|
|
5
|
+
# required for the free tier (see README.md). Used by
|
|
6
|
+
# spree_menu_chat:verify_connection.
|
|
7
|
+
GEMINI_API_KEY=
|
|
8
|
+
|
|
9
|
+
# Voyage AI — free key at https://www.voyageai.com, first 200M tokens free
|
|
10
|
+
# per account (see README.md). Used by M2's embedding pipeline and its own
|
|
11
|
+
# verify_embeddings rake task.
|
|
12
|
+
VOYAGE_API_KEY=
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
test-postgres:
|
|
17
|
+
name: "PostgreSQL"
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
services:
|
|
20
|
+
postgres:
|
|
21
|
+
# pgvector/pgvector, not plain postgres — this extension's embeddings
|
|
22
|
+
# table (landing in M2) needs `CREATE EXTENSION vector`, which the
|
|
23
|
+
# official postgres image doesn't ship. Using this image from M1
|
|
24
|
+
# onward avoids a CI-only migration later.
|
|
25
|
+
image: pgvector/pgvector:pg16
|
|
26
|
+
env:
|
|
27
|
+
POSTGRES_USER: postgres
|
|
28
|
+
POSTGRES_PASSWORD: postgres
|
|
29
|
+
ports:
|
|
30
|
+
- 5432:5432
|
|
31
|
+
options: >-
|
|
32
|
+
--health-cmd pg_isready
|
|
33
|
+
--health-interval 10s
|
|
34
|
+
--health-timeout 5s
|
|
35
|
+
--health-retries 5
|
|
36
|
+
env:
|
|
37
|
+
DB: postgres
|
|
38
|
+
DB_HOST: localhost
|
|
39
|
+
DB_USERNAME: postgres
|
|
40
|
+
DB_PASSWORD: postgres
|
|
41
|
+
BUNDLE_JOBS: 4
|
|
42
|
+
BUNDLE_RETRY: 3
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
|
|
46
|
+
- uses: ruby/setup-ruby@v1
|
|
47
|
+
with:
|
|
48
|
+
ruby-version: '3.3'
|
|
49
|
+
bundler-cache: true
|
|
50
|
+
|
|
51
|
+
- name: Install libvips
|
|
52
|
+
run: sudo apt-get update && sudo apt-get install -y libvips-dev
|
|
53
|
+
|
|
54
|
+
- name: Create test app
|
|
55
|
+
run: bundle exec rake test_app
|
|
56
|
+
|
|
57
|
+
- name: Run tests
|
|
58
|
+
run: bundle exec rspec --format documentation
|
|
59
|
+
|
|
60
|
+
test-mysql:
|
|
61
|
+
name: "MySQL"
|
|
62
|
+
runs-on: ubuntu-latest
|
|
63
|
+
services:
|
|
64
|
+
mysql:
|
|
65
|
+
image: mysql:8.0
|
|
66
|
+
env:
|
|
67
|
+
MYSQL_ROOT_PASSWORD: password
|
|
68
|
+
ports:
|
|
69
|
+
- 3306:3306
|
|
70
|
+
options: >-
|
|
71
|
+
--health-cmd="mysqladmin ping"
|
|
72
|
+
--health-interval 10s
|
|
73
|
+
--health-timeout 5s
|
|
74
|
+
--health-retries 5
|
|
75
|
+
env:
|
|
76
|
+
DB: mysql
|
|
77
|
+
DB_HOST: 127.0.0.1
|
|
78
|
+
DB_USERNAME: root
|
|
79
|
+
DB_PASSWORD: password
|
|
80
|
+
BUNDLE_JOBS: 4
|
|
81
|
+
BUNDLE_RETRY: 3
|
|
82
|
+
steps:
|
|
83
|
+
- uses: actions/checkout@v4
|
|
84
|
+
|
|
85
|
+
- uses: ruby/setup-ruby@v1
|
|
86
|
+
with:
|
|
87
|
+
ruby-version: '3.3'
|
|
88
|
+
bundler-cache: true
|
|
89
|
+
|
|
90
|
+
- name: Install libvips
|
|
91
|
+
run: sudo apt-get update && sudo apt-get install -y libvips-dev
|
|
92
|
+
|
|
93
|
+
- name: Create test app
|
|
94
|
+
run: bundle exec rake test_app
|
|
95
|
+
|
|
96
|
+
- name: Run tests
|
|
97
|
+
run: bundle exec rspec --format documentation
|
|
98
|
+
|
|
99
|
+
brakeman:
|
|
100
|
+
name: Brakeman
|
|
101
|
+
runs-on: ubuntu-latest
|
|
102
|
+
steps:
|
|
103
|
+
- uses: actions/checkout@v4
|
|
104
|
+
|
|
105
|
+
- uses: ruby/setup-ruby@v1
|
|
106
|
+
with:
|
|
107
|
+
ruby-version: '3.3'
|
|
108
|
+
bundler-cache: true
|
|
109
|
+
|
|
110
|
+
- name: Run Brakeman
|
|
111
|
+
run: bundle exec brakeman --exit-on-warn --exit-on-error
|
data/.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
\#*
|
|
2
|
+
*~
|
|
3
|
+
.#*
|
|
4
|
+
.DS_Store
|
|
5
|
+
.idea
|
|
6
|
+
.localeapp/locales
|
|
7
|
+
.project
|
|
8
|
+
.vscode
|
|
9
|
+
coverage
|
|
10
|
+
default
|
|
11
|
+
Gemfile.lock
|
|
12
|
+
tmp
|
|
13
|
+
nbproject
|
|
14
|
+
pkg
|
|
15
|
+
*.sw?
|
|
16
|
+
spec/dummy
|
|
17
|
+
.rvmrc
|
|
18
|
+
.sass-cache
|
|
19
|
+
public/spree
|
|
20
|
+
.ruby-version
|
|
21
|
+
.ruby-gemset
|
|
22
|
+
*.gem
|
|
23
|
+
*/*.gem
|
|
24
|
+
|
|
25
|
+
# Real Gemini/Voyage API keys for manual verification — never committed,
|
|
26
|
+
# unlike the test-only encryption keys in .env.
|
|
27
|
+
.env.local
|
data/.rspec
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
## 0.1.0 (unreleased)
|
|
6
|
+
|
|
7
|
+
Initial development. M1 (foundation) — `SpreeMenuChat::Credential` (encrypted per-store Gemini +
|
|
8
|
+
Voyage AI API keys), `SpreeMenuChat::LlmClient` (Gemini `generateContent` REST calls — no official
|
|
9
|
+
Ruby SDK exists for the Gemini API, same "plain REST" situation `spree_doordash` already handles
|
|
10
|
+
for DoorDash's Drive API), and a plain credential-entry admin form.
|
|
11
|
+
|
|
12
|
+
One real bug caught only by `spree_menu_chat:verify_connection` against a genuinely fresh Gemini
|
|
13
|
+
API key, not by the spec suite (which stubs the request and never touches Google's model
|
|
14
|
+
availability): the pinned model `gemini-2.5-flash-lite` returned a real `404 "no longer available
|
|
15
|
+
to new users"` — despite still being listed by the `models.list` endpoint, which apparently doesn't
|
|
16
|
+
reflect real per-account availability. Fixed by switching the default `generation_model` from a
|
|
17
|
+
pinned dated model string to the `gemini-flash-lite-latest` alias (currently resolves to
|
|
18
|
+
`gemini-3.5-flash-lite`, confirmed live), which should avoid this exact rot going forward.
|
|
19
|
+
|
|
20
|
+
M2 (pgvector + embeddings pipeline) — `SpreeMenuChat::Embedding` (polymorphic, `has_neighbors`),
|
|
21
|
+
`SpreeMenuChat::EmbeddingClient` (Voyage `embeddings` REST calls), `ContentBuilder`, `Embedder`,
|
|
22
|
+
`ReembedProductJob`/`ReembedCatalogJob`, the `Spree::Product` re-embed hook, and
|
|
23
|
+
`spree_menu_chat:reembed_all`.
|
|
24
|
+
|
|
25
|
+
Three real bugs found only by running `reembed_all` against `spree_host`'s actual, live,
|
|
26
|
+
Square-synced catalog (39 products, 4 policies) — none of them visible to this gem's own isolated,
|
|
27
|
+
SQLite-backed dummy-app spec suite:
|
|
28
|
+
|
|
29
|
+
- `neighbor`'s `t.vector` migration helper raised `NoMethodError` — it's only a gemspec
|
|
30
|
+
(transitive) dependency of this gem, and `Bundler.require` doesn't auto-require those. Fixed with
|
|
31
|
+
an explicit `require 'neighbor'` in `lib/spree_menu_chat.rb`.
|
|
32
|
+
- `SpreeMenuChat::EmbeddingClient::RequestError` raised `NameError: uninitialized constant` under
|
|
33
|
+
`rails runner`'s lazy autoloading, despite passing every spec (Zeitwerk's `eager_load_all`, which
|
|
34
|
+
`spec/zeitwerk_spec.rb` exercises, loads every file regardless of order and masked the bug). Root
|
|
35
|
+
cause: it was defined as a second class inside `llm_client.rb` instead of its own file. Fixed by
|
|
36
|
+
giving it its own `app/services/spree_menu_chat/request_error.rb`.
|
|
37
|
+
- `ContentBuilder#modifier_summary` called `SpreeSquare::Modifier#display_price`, a method that
|
|
38
|
+
doesn't exist on that model (only a JSON key of the same name in `SpreeSquare::ProductSerializer`)
|
|
39
|
+
— raised `NoMethodError` on every product with a real Square modifier list attached. Invisible to
|
|
40
|
+
this gem's own specs because the dummy app never has `spree_square` installed at all (the
|
|
41
|
+
`defined?` soft-dependency guard short-circuits before reaching this code). Fixed to call the
|
|
42
|
+
model's real `#price` method instead, formatted as a plain `+$X.XX` string; added a regression
|
|
43
|
+
spec that stubs the `SpreeSquare` constants so this exact path is exercised going forward without
|
|
44
|
+
taking a hard gemspec dependency on `spree_square`.
|
|
45
|
+
|
|
46
|
+
Also discovered live, not a code bug but a real operating constraint worth documenting plainly:
|
|
47
|
+
Voyage AI accounts with no payment method on file are capped at 3 requests/minute, 10K
|
|
48
|
+
tokens/minute — separate from, and far tighter than, the genuinely generous 200M free-token
|
|
49
|
+
allowance. A tight per-record loop over even a ~40-item menu hits this within the first few calls.
|
|
50
|
+
`EmbeddingClient` now self-throttles every request to that limit (tracking this process's own
|
|
51
|
+
recent request timestamps, sleeping proactively before the next call would exceed it) rather than
|
|
52
|
+
firing calls as fast as the caller loops and hoping `ReembedCatalogJob`'s job-level `retry_on`
|
|
53
|
+
eventually catches up — see the class comment on `SpreeMenuChat::EmbeddingClient` for the full
|
|
54
|
+
reasoning, including why an earlier reactive-retry-only version of this fix undershot in practice.
|
|
55
|
+
|
|
56
|
+
M3 (retrieval + generation, no streaming) — `SpreeMenuChat::Retriever` (embeds the question with
|
|
57
|
+
Voyage's `query` input type, pgvector cosine-distance search scoped to the store, filters below the
|
|
58
|
+
configured similarity floor, caps at `retrieval_top_k`), `SpreeMenuChat::AnswerGenerator` (builds a
|
|
59
|
+
menu/FAQ-scoped system prompt, injects retrieved context, calls Gemini; returns a canned
|
|
60
|
+
"contact us" fallback — using the store's real `customer_support_email`/`contact_phone` — whenever
|
|
61
|
+
retrieval finds nothing above the floor or Gemini fails, rather than a raw error or a hallucinated
|
|
62
|
+
answer), and `SpreeMenuChat::ChatController` (`POST /menu_chat/chat`, authenticated with the store's
|
|
63
|
+
Storefront publishable key — the same `pk_...` key type `spree_api`'s Store API accepts).
|
|
64
|
+
|
|
65
|
+
The read-only guardrail is enforced structurally, not just described in the system prompt:
|
|
66
|
+
`AnswerGenerator` never builds or passes a `tools`/function-declarations argument to
|
|
67
|
+
`LlmClient#generate`, and no write-capable Spree model is referenced anywhere in the request path —
|
|
68
|
+
covered by a spec that inspects the real outgoing Gemini request body for the absence of a `tools`
|
|
69
|
+
key.
|
|
70
|
+
|
|
71
|
+
One real calibration bug, again only found by curling real questions against the real synced
|
|
72
|
+
catalog: the originally-planned `similarity_floor` default of 0.7 (max cosine distance 0.3) was
|
|
73
|
+
picked speculatively, before ever querying real embeddings, and was badly miscalibrated. Real
|
|
74
|
+
Voyage cosine distances for genuinely correct, on-topic matches ranged 0.32-0.63 across a set of
|
|
75
|
+
real test questions — a 0.7 floor rejected nearly all of them, meaning the assistant would have
|
|
76
|
+
given the canned fallback for almost every real, answerable question ("what comes with the buffalo
|
|
77
|
+
wings?" initially returned "I don't have information about that," despite Buffalo Wings being a
|
|
78
|
+
real menu item with a real matching description). Fixed by lowering the default to 0.35, based on
|
|
79
|
+
that live calibration data; a genuinely off-topic/unanswerable question in the same test batch
|
|
80
|
+
landed at 0.76 distance, comfortably excluded with margin to spare. See the class comment on
|
|
81
|
+
`SpreeMenuChat::Configuration` for the full data.
|
|
82
|
+
|
|
83
|
+
Manually verified against the real dev app: real curl'd questions ("what comes with the buffalo
|
|
84
|
+
wings?", "do you have vegetarian options?", "is the chicken parmesan spicy?") return accurate,
|
|
85
|
+
grounded answers; a request to place an order is correctly refused while still answering the
|
|
86
|
+
underlying menu question; a genuinely off-topic question ("what is the capital of France?")
|
|
87
|
+
triggers the no-context fallback rather than a hallucinated answer.
|
|
88
|
+
|
|
89
|
+
M4 (streaming) — `SpreeMenuChat::LlmClient#generate_stream` (Gemini's `streamGenerateContent?alt=sse`
|
|
90
|
+
endpoint, via Faraday's `on_data` streaming callback), `SpreeMenuChat::AnswerGenerator.stream` (yields
|
|
91
|
+
each real fragment as Gemini produces it, or the same canned fallback as a single fragment if there's
|
|
92
|
+
no context/generation fails), and `SpreeMenuChat::ChatController` now streams SSE
|
|
93
|
+
(`ActionController::Live`) instead of returning one JSON blob — the storefront widget's Next.js proxy
|
|
94
|
+
streams the response straight through unmodified.
|
|
95
|
+
|
|
96
|
+
**One serious real bug, found only by curling the live streaming endpoint against the real Gemini
|
|
97
|
+
API** — every spec passed and it looked completely broken in practice. `LlmClient#generate_stream`'s
|
|
98
|
+
SSE frame parser searched for a literal `"\n\n"` to split `data: <json>` frames — matching this gem's
|
|
99
|
+
own hand-written WebMock fixtures (which used bare `\n`, the natural-looking assumption) but not
|
|
100
|
+
Gemini's real wire format, which uses `\r\n` line endings. The parser never found a match, so every
|
|
101
|
+
real streaming request silently yielded zero chunks and zero errors — no exception, nothing logged —
|
|
102
|
+
and `AnswerGenerator.stream`'s "Gemini never yielded any text" guard quietly substituted the canned
|
|
103
|
+
"I don't have that" fallback for every single real question, including ones with a perfect context
|
|
104
|
+
match. Because the WebMock fixtures shared the same wrong assumption as the code under test, the spec
|
|
105
|
+
suite was self-consistently green throughout — this is exactly the failure mode this project's
|
|
106
|
+
"verify against real infrastructure" standard exists to catch, and did. Fixed by normalizing `\r\n` to
|
|
107
|
+
`\n` before splitting; the fixtures were also corrected to use `\r\n` so they now test the real wire
|
|
108
|
+
format instead of re-testing the same wrong assumption.
|
|
109
|
+
|
|
110
|
+
Verified: 75/75 specs green, Brakeman clean. Live-verified end to end after the fix: `curl -N` against
|
|
111
|
+
the real streaming endpoint for "what desserts do you have?" (previously silently falling back)
|
|
112
|
+
correctly streams three real SSE fragments naming actual real menu items, followed by a `done` event;
|
|
113
|
+
a genuinely off-topic question still correctly streams the no-context fallback as a single fragment.
|
|
114
|
+
|
|
115
|
+
M5 (guardrails) — `SpreeMenuChat::RateLimiter` (Postgres-backed fixed-window counter,
|
|
116
|
+
`rate_limit_per_hour` requests per (store, IP) — no Redis anywhere in this stack, same rationale as
|
|
117
|
+
Solid Queue running inside Postgres) and `SpreeMenuChat::TokenBudget` (Postgres-backed daily cap on
|
|
118
|
+
Gemini generation tokens per store, using Gemini's own reported `usageMetadata.totalTokenCount`, not
|
|
119
|
+
an estimate). `ChatController` checks the rate limit before opening the SSE stream (a real 429 JSON
|
|
120
|
+
error, not a raw exception or a broken stream); `AnswerGenerator` checks the token budget before
|
|
121
|
+
even retrieval, and skips straight to a distinct "we've reached today's limit" fallback once
|
|
122
|
+
exhausted, same as the existing no-context fallback but honestly worded for this different reason.
|
|
123
|
+
`LlmClient#generate_stream` now returns Gemini's final reported token total (each SSE frame's
|
|
124
|
+
`usageMetadata` carries a running total; the last one seen is the true total for the turn) so
|
|
125
|
+
`AnswerGenerator` can record real spend without estimating.
|
|
126
|
+
|
|
127
|
+
Deliberately scoped to generation tokens only, not embedding tokens — see the class comment on
|
|
128
|
+
`SpreeMenuChat::TokenBudget` for why folding in Voyage's query-embedding cost too was left as a
|
|
129
|
+
follow-up rather than blocking this guardrail.
|
|
130
|
+
|
|
131
|
+
Verified live against the real dev app, not just specs: a real chat question recorded its real
|
|
132
|
+
Gemini-reported token count (393 tokens) against today's budget; simulating the rate limit already
|
|
133
|
+
being at its configured cap made the next real request return a genuine `429` with a plain JSON
|
|
134
|
+
error body (confirmed `Content-Type: application/json`, no SSE stream opened) instead of a raw
|
|
135
|
+
error; simulating the daily token budget already being exhausted made the next real request stream
|
|
136
|
+
the budget-exceeded fallback as a single SSE fragment with zero further Gemini spend (confirmed the
|
|
137
|
+
stored token total was unchanged after). Also grepped the full `app/`/`lib/` tree to confirm neither
|
|
138
|
+
a write-capable Spree model (`Spree::Order`, `Spree::LineItem`, `Spree::Cart`, ...) nor a
|
|
139
|
+
`tools`/function-declarations argument is referenced anywhere outside this file's own comments —
|
|
140
|
+
the read-only guardrail described since M3 is still structurally true, not just documented.
|
|
141
|
+
|
|
142
|
+
M6 (admin visibility) — confirmed with the user before building: persist real conversation content
|
|
143
|
+
(not just operational counters), since that's a data-retention decision the plan explicitly flagged
|
|
144
|
+
rather than a default to build silently. `SpreeMenuChat::Conversation`/`SpreeMenuChat::Message`
|
|
145
|
+
(anonymous, grouped by a 30-minute time-window heuristic on (store, IP) rather than an explicit
|
|
146
|
+
client-sent session id — the widget has no session of its own, see `Conversation::SESSION_WINDOW`'s
|
|
147
|
+
comment for the real tradeoff this makes), logged by `ChatController` after each SSE stream closes
|
|
148
|
+
(best-effort — a logging failure is captured, never allowed to affect the response already sent).
|
|
149
|
+
Admin nav position 72 (right after the M1 credential page at 71) and a read-only
|
|
150
|
+
`Spree::Admin::MenuChatConversationsController` index — identifier, first question, message count,
|
|
151
|
+
started/last-message timestamps — scoped to the current store via a `for_store` scope
|
|
152
|
+
(`Admin::ResourceController#scope` picks this up automatically, no controller override needed).
|
|
153
|
+
|
|
154
|
+
`new_resource: false` set on the table registration up front, not found the hard way a third time —
|
|
155
|
+
both `spree_square` and `spree_doordash` hit and fixed the identical real bug (`new_admin_..._url`
|
|
156
|
+
routing error on an empty read-only, index-only table) on their own first read-only admin pages.
|
|
157
|
+
|
|
158
|
+
Verified against the real dev app, not the gem's own SQLite dummy-app suite (M6's own stated
|
|
159
|
+
standard, and precisely where both sibling extensions' changelogs record real Postgres-only bugs
|
|
160
|
+
invisible to specs): logged into the real admin, loaded the Conversations page **while genuinely
|
|
161
|
+
empty** and confirmed the empty state renders cleanly (no routing error); sent a real chat question
|
|
162
|
+
through the live endpoint, reloaded, and confirmed the real row appeared with the real question text,
|
|
163
|
+
a correct message count of 2, and correct relative timestamps; confirmed clicking the row does
|
|
164
|
+
nothing (no `:show` route exists, matching the index-only design) rather than erroring.
|
data/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for considering a contribution to `spree_menu_chat`.
|
|
4
|
+
|
|
5
|
+
## Getting set up
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bundle install
|
|
9
|
+
bundle exec rake test_app # generates spec/dummy, the Rails app specs run against
|
|
10
|
+
bundle exec rspec
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Making a change
|
|
14
|
+
|
|
15
|
+
1. Open an issue first for anything beyond a small fix, so the approach can be discussed before
|
|
16
|
+
you put time into it.
|
|
17
|
+
2. Add or update specs alongside any behavior change — `bundle exec rspec` should stay green.
|
|
18
|
+
3. Keep decorators as a last resort (see the main README's customization pattern order); prefer
|
|
19
|
+
Spree's Events/Subscribers or Dependencies mechanisms where they fit.
|
|
20
|
+
4. This extension is deliberately read-only — any change that would let it write to
|
|
21
|
+
`Spree::Order`, `Spree::LineItem`, `Spree::Cart`, or pass tool/function-calling definitions to
|
|
22
|
+
the generation call is out of scope for a pull request here.
|
|
23
|
+
5. Open a pull request describing what changed and why.
|
|
24
|
+
|
|
25
|
+
## Releasing (maintainers)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bundle exec gem bump --version [major|minor|patch] -t -m "Release v%s"
|
|
29
|
+
bundle exec gem release
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
See the [gem-release README](https://github.com/svenfuchs/gem-release) for more options.
|
data/Gemfile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
source 'https://rubygems.org'
|
|
2
|
+
|
|
3
|
+
gem 'rails-controller-testing'
|
|
4
|
+
|
|
5
|
+
# Pinned to the released 5.6.x line (matching spree_host), same rationale as
|
|
6
|
+
# spree_square's and spree_doordash's Gemfiles.
|
|
7
|
+
spree_opts = if ENV['SPREE_PATH']
|
|
8
|
+
{ 'path': ENV['SPREE_PATH'] }
|
|
9
|
+
else
|
|
10
|
+
'~> 5.6.0'
|
|
11
|
+
end
|
|
12
|
+
gem 'spree', spree_opts
|
|
13
|
+
gem 'spree_admin', spree_opts
|
|
14
|
+
|
|
15
|
+
gem 'spree_dev_tools', '>= 0.6.0.rc1'
|
|
16
|
+
|
|
17
|
+
if ENV['DB'] == 'mysql'
|
|
18
|
+
gem 'mysql2'
|
|
19
|
+
elsif ENV['DB'] == 'postgres'
|
|
20
|
+
gem 'pg'
|
|
21
|
+
else
|
|
22
|
+
gem 'sqlite3'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
gem 'propshaft'
|
|
26
|
+
|
|
27
|
+
gemspec
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amit Solanki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Spree Menu Chat
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/spree_menu_chat)
|
|
4
|
+
[](https://github.com/amitkssolanki/spree_menu_chat/releases)
|
|
5
|
+
[](LICENSE.md)
|
|
6
|
+
|
|
7
|
+
A read-only, retrieval-grounded chat assistant for [Spree Commerce](https://spreecommerce.org) storefronts. Answers customer questions about the menu, ingredients, dietary info, hours, and store policies — grounded entirely in the store's own synced catalog and `Spree::Policy` content, nothing from the model's general knowledge. It's the companion to [`spree_square`](https://github.com/amitkssolanki/spree_square) (reused, softly, for modifier-list grounding when present) and [`spree_doordash`](https://github.com/amitkssolanki/spree_doordash).
|
|
8
|
+
|
|
9
|
+
> ⚠️ **This extension is explicitly read-only.** It has no cart or order mutation capability at all — no `tools`/function-calling definitions are ever passed to the generation call, so there is no code path by which a response can add an item, place an order, or change any Spree state. That's a deliberately separate, higher-risk feature this extension does not attempt.
|
|
10
|
+
|
|
11
|
+
## What this does
|
|
12
|
+
|
|
13
|
+
- **Grounded Q&A over the real menu** — embeds every published product (name, description, category, modifier lists) and every store `Spree::Policy` (hours, delivery area, allergen info, etc.) into pgvector, and answers only from what's actually retrieved for a given question.
|
|
14
|
+
- **Live-synced, not a snapshot** — re-embeds automatically whenever a product changes, whether from a Square sync or a manual admin edit.
|
|
15
|
+
- **Graceful "I don't know"** — if nothing retrieved clears a similarity floor, it skips the generation call entirely and returns a real contact-us fallback rather than guessing.
|
|
16
|
+
- **No official SDK dependency** — neither the [Gemini API](https://ai.google.dev) (generation) nor [Voyage AI](https://www.voyageai.com) (embeddings) has an official Ruby SDK, so both are called directly over their REST APIs — the same pattern `spree_doordash` already uses for DoorDash's Drive API.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
1. Add this extension to your Gemfile with this line:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
bundle add spree_menu_chat
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
2. Run the install generator
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
bundle exec rails g spree_menu_chat:install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
3. Restart your server
|
|
33
|
+
|
|
34
|
+
If your server was running, restart it so that it can find the assets properly.
|
|
35
|
+
|
|
36
|
+
## Connecting Gemini and Voyage AI
|
|
37
|
+
|
|
38
|
+
Neither service uses OAuth — both are plain static API keys, entered directly:
|
|
39
|
+
|
|
40
|
+
1. Create a free API key at [Google AI Studio](https://aistudio.google.com) — no billing account
|
|
41
|
+
required. The free tier (as of writing: 1,000 requests/day on `gemini-flash-lite-latest`, no card
|
|
42
|
+
on file) comfortably covers a single restaurant's real traffic. One trade-off worth knowing:
|
|
43
|
+
Google's free-tier terms allow using your prompts/responses to improve their models (the paid
|
|
44
|
+
tier does not).
|
|
45
|
+
2. Create a free API key at [Voyage AI](https://www.voyageai.com) — the first 200 million tokens
|
|
46
|
+
are free per account on the `voyage-4`/`voyage-4-lite` family, which is enormous headroom for
|
|
47
|
+
embedding a restaurant menu (a few thousand tokens, typically). **Separately, and easy to miss:**
|
|
48
|
+
without a payment method on file, Voyage also caps *request rate* at just 3 requests/minute,
|
|
49
|
+
10K tokens/minute (confirmed live — the free 200M-token allowance is real and unaffected, but
|
|
50
|
+
this rate cap is much tighter than that headroom suggests). `SpreeMenuChat::EmbeddingClient`
|
|
51
|
+
self-throttles to that limit so a full-catalog `reembed_all` run doesn't 429, but it does mean
|
|
52
|
+
that run takes real wall-clock time (roughly one record every ~20s once past the first 3) — add
|
|
53
|
+
a payment method in the Voyage dashboard once you're past initial development to raise the cap;
|
|
54
|
+
the free token grant still applies afterward.
|
|
55
|
+
3. In your Spree admin, go to **Menu Chat Connection** in the sidebar and paste both keys in.
|
|
56
|
+
They're encrypted at rest ([ActiveRecord::Encryption](https://guides.rubyonrails.org/active_record_encryption.html))
|
|
57
|
+
— same mechanism `spree_square`/`spree_doordash` use for their own credentials, sharing the
|
|
58
|
+
same `ACTIVE_RECORD_ENCRYPTION_*` keys.
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
bundle install
|
|
64
|
+
bundle exec rake test_app # generates spec/dummy
|
|
65
|
+
bundle exec rspec
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
To manually verify a real Gemini key end to end (outside the spec suite):
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
cp .env.local.example .env.local # fill in GEMINI_API_KEY
|
|
72
|
+
bin/rails spree_menu_chat:verify_connection
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
When testing your application's integration with this extension you may use its factories.
|
|
76
|
+
Simply add this require statement to your spec_helper:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
require 'spree_menu_chat/factories'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Releasing a new version
|
|
83
|
+
|
|
84
|
+
```shell
|
|
85
|
+
bundle exec gem bump -p -t
|
|
86
|
+
bundle exec gem release
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
For more options please see [gem-release README](https://github.com/svenfuchs/gem-release)
|
|
90
|
+
|
|
91
|
+
## Contributing
|
|
92
|
+
|
|
93
|
+
If you'd like to contribute, please take a look at the
|
|
94
|
+
[instructions](CONTRIBUTING.md) for installing dependencies and crafting a good
|
|
95
|
+
pull request.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require 'bundler'
|
|
2
|
+
Bundler::GemHelper.install_tasks
|
|
3
|
+
|
|
4
|
+
require 'rspec/core/rake_task'
|
|
5
|
+
require 'spree/testing_support/extension_rake'
|
|
6
|
+
|
|
7
|
+
RSpec::Core::RakeTask.new
|
|
8
|
+
|
|
9
|
+
task :default do
|
|
10
|
+
if Dir['spec/dummy'].empty?
|
|
11
|
+
Rake::Task[:test_app].invoke
|
|
12
|
+
Dir.chdir('../../')
|
|
13
|
+
end
|
|
14
|
+
Rake::Task[:spec].invoke
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc 'Generates a dummy app for testing'
|
|
18
|
+
task :test_app do
|
|
19
|
+
ENV['LIB_NAME'] = 'spree_menu_chat'
|
|
20
|
+
Rake::Task['extension:test_app'].execute(
|
|
21
|
+
install_admin: true
|
|
22
|
+
)
|
|
23
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
# Read-only QA view of every logged chat conversation for the current
|
|
4
|
+
# store — same shape as Spree::Admin::SquareWebhookEventsController.
|
|
5
|
+
# Scoped to current_store automatically: ResourceController#scope calls
|
|
6
|
+
# `model_class.for_store(current_store)` when the model responds to it
|
|
7
|
+
# (see SpreeMenuChat::Conversation's `for_store` scope) — no override
|
|
8
|
+
# needed here.
|
|
9
|
+
class MenuChatConversationsController < ResourceController
|
|
10
|
+
def model_class
|
|
11
|
+
SpreeMenuChat::Conversation
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
# Plain credential-entry form for the current store's Gemini + Voyage AI
|
|
4
|
+
# API keys — not an OAuth connect/disconnect flow (neither service has
|
|
5
|
+
# one). Same shape as Spree::Admin::DoordashCredentialsController: an
|
|
6
|
+
# admin pastes the two key values in here directly, encrypted at rest.
|
|
7
|
+
class MenuChatCredentialsController < Spree::Admin::BaseController
|
|
8
|
+
def show
|
|
9
|
+
@credential = SpreeMenuChat::Credential.find_or_initialize_by(store: current_store)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def update
|
|
13
|
+
@credential = SpreeMenuChat::Credential.find_or_initialize_by(store: current_store)
|
|
14
|
+
|
|
15
|
+
if @credential.update(credential_params)
|
|
16
|
+
flash[:success] = Spree.t(:menu_chat_credential_saved, default: 'Menu Chat credentials saved.')
|
|
17
|
+
else
|
|
18
|
+
flash[:error] = @credential.errors.full_messages.to_sentence
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
redirect_to admin_menu_chat_credential_path
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# Blank fields mean "leave unchanged" — the form always renders these
|
|
27
|
+
# empty and never echoes the current value back (see
|
|
28
|
+
# SpreeDoordash::Credential's controller for the identical rationale).
|
|
29
|
+
def credential_params
|
|
30
|
+
permitted = params.require(:spree_menu_chat_credential).permit(:gemini_api_key, :voyage_api_key)
|
|
31
|
+
%i[gemini_api_key voyage_api_key].each do |field|
|
|
32
|
+
permitted.delete(field) if permitted[field].blank?
|
|
33
|
+
end
|
|
34
|
+
permitted
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|