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
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
@@ -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
@@ -0,0 +1,3 @@
1
+ --color
2
+ -r spec_helper
3
+ -f documentation
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
+ [![Gem Version](https://img.shields.io/gem/v/spree_menu_chat.svg)](https://rubygems.org/gems/spree_menu_chat)
4
+ [![GitHub Release](https://img.shields.io/github/v/release/amitkssolanki/spree_menu_chat)](https://github.com/amitkssolanki/spree_menu_chat/releases)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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