spree_menu_chat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.env +7 -0
  3. data/.env.local.example +12 -0
  4. data/.github/workflows/test.yml +111 -0
  5. data/.gitignore +27 -0
  6. data/.rspec +3 -0
  7. data/CHANGELOG.md +164 -0
  8. data/CONTRIBUTING.md +32 -0
  9. data/Gemfile +27 -0
  10. data/LICENSE.md +9 -0
  11. data/README.md +95 -0
  12. data/Rakefile +23 -0
  13. data/app/controllers/spree/admin/menu_chat_conversations_controller.rb +15 -0
  14. data/app/controllers/spree/admin/menu_chat_credentials_controller.rb +38 -0
  15. data/app/controllers/spree_menu_chat/chat_controller.rb +114 -0
  16. data/app/jobs/spree_menu_chat/base_job.rb +5 -0
  17. data/app/jobs/spree_menu_chat/reembed_catalog_job.rb +27 -0
  18. data/app/jobs/spree_menu_chat/reembed_product_job.rb +25 -0
  19. data/app/models/spree/product_decorator.rb +23 -0
  20. data/app/models/spree_menu_chat/conversation.rb +39 -0
  21. data/app/models/spree_menu_chat/credential.rb +16 -0
  22. data/app/models/spree_menu_chat/embedding.rb +30 -0
  23. data/app/models/spree_menu_chat/message.rb +35 -0
  24. data/app/models/spree_menu_chat/rate_limit.rb +14 -0
  25. data/app/models/spree_menu_chat/token_usage.rb +13 -0
  26. data/app/services/spree_menu_chat/alerting.rb +20 -0
  27. data/app/services/spree_menu_chat/answer_generator.rb +143 -0
  28. data/app/services/spree_menu_chat/content_builder.rb +65 -0
  29. data/app/services/spree_menu_chat/embedder.rb +39 -0
  30. data/app/services/spree_menu_chat/embedding_client.rb +161 -0
  31. data/app/services/spree_menu_chat/llm_client.rb +186 -0
  32. data/app/services/spree_menu_chat/rate_limiter.rb +53 -0
  33. data/app/services/spree_menu_chat/request_error.rb +26 -0
  34. data/app/services/spree_menu_chat/retriever.rb +64 -0
  35. data/app/services/spree_menu_chat/token_budget.rb +52 -0
  36. data/app/views/spree/admin/menu_chat_conversations/index.html.erb +5 -0
  37. data/app/views/spree/admin/menu_chat_conversations/show.html.erb +27 -0
  38. data/app/views/spree/admin/menu_chat_credentials/show.html.erb +44 -0
  39. data/config/brakeman.ignore +28 -0
  40. data/config/initializers/spree_admin_menu_chat_navigation.rb +21 -0
  41. data/config/initializers/spree_admin_menu_chat_tables.rb +61 -0
  42. data/config/routes.rb +26 -0
  43. data/db/migrate/20260814000001_create_spree_menu_chat_credentials.rb +16 -0
  44. data/db/migrate/20260816000001_enable_pgvector_extension.rb +16 -0
  45. data/db/migrate/20260816000002_create_spree_menu_chat_embeddings.rb +44 -0
  46. data/db/migrate/20260816000003_create_spree_menu_chat_rate_limits.rb +27 -0
  47. data/db/migrate/20260816000004_create_spree_menu_chat_token_usages.rb +19 -0
  48. data/db/migrate/20260816000005_create_spree_menu_chat_conversations.rb +20 -0
  49. data/db/migrate/20260816000006_create_spree_menu_chat_messages.rb +18 -0
  50. data/lib/generators/spree_menu_chat/install/install_generator.rb +20 -0
  51. data/lib/spree_menu_chat/configuration.rb +63 -0
  52. data/lib/spree_menu_chat/engine.rb +45 -0
  53. data/lib/spree_menu_chat/factories.rb +48 -0
  54. data/lib/spree_menu_chat/version.rb +7 -0
  55. data/lib/spree_menu_chat.rb +30 -0
  56. data/lib/tasks/spree_menu_chat.rake +93 -0
  57. data/spree_menu_chat.gemspec +63 -0
  58. metadata +216 -0
@@ -0,0 +1,21 @@
1
+ Rails.application.config.after_initialize do
2
+ # Position 71 — right after spree_doordash's own entries (68-70), which
3
+ # themselves sit right after spree_square's (65-67), so all three
4
+ # extensions' nav items sit together without colliding.
5
+ Spree.admin.navigation.sidebar.add :menu_chat_credential,
6
+ label: 'Menu Chat Connection',
7
+ url: :admin_menu_chat_credential_path,
8
+ icon: 'chat-circle',
9
+ position: 71,
10
+ active: -> { controller_name == 'menu_chat_credentials' },
11
+ if: -> { can?(:manage, SpreeMenuChat::Credential) }
12
+
13
+ # M6 — position 72, right after the credential page above.
14
+ Spree.admin.navigation.sidebar.add :menu_chat_conversations,
15
+ label: 'Menu Chat Conversations',
16
+ url: :admin_menu_chat_conversations_path,
17
+ icon: 'chat-text',
18
+ position: 72,
19
+ active: -> { controller_name == 'menu_chat_conversations' },
20
+ if: -> { can?(:manage, SpreeMenuChat::Conversation) }
21
+ end
@@ -0,0 +1,61 @@
1
+ Rails.application.config.after_initialize do
2
+ # new_resource: false — a read-only QA table (no `:new`/`:create` route
3
+ # exists; only: [:index] in config/routes.rb). The default (true) crashes
4
+ # with a routing error the moment the table is ever empty, because the
5
+ # "no resource found" empty-state partial builds a `new_object_url` link
6
+ # unconditionally unless told not to. The exact bug both spree_square and
7
+ # spree_doordash hit and fixed on their own read-only admin tables —
8
+ # applied here up front instead of waiting to hit it live a third time.
9
+ # link_to_action: :show, row_actions: false — a row links straight to
10
+ # the full conversation (config/routes.rb's :show route +
11
+ # app/views/.../show.html.erb) rather than the framework's default
12
+ # :edit (which doesn't exist here — nothing in this table is ever
13
+ # edited). Without this, rows render as plain, unclickable text and the
14
+ # only way to read a conversation is the always-truncated
15
+ # `first_question` preview column below.
16
+ Spree.admin.tables.register(:menu_chat_conversations, model_class: SpreeMenuChat::Conversation,
17
+ search_param: :identifier_cont, new_resource: false,
18
+ link_to_action: :show, row_actions: false)
19
+
20
+ Spree.admin.tables.menu_chat_conversations.add :identifier,
21
+ label: :identifier,
22
+ type: :string,
23
+ sortable: true,
24
+ filterable: true,
25
+ default: true,
26
+ position: 10
27
+
28
+ Spree.admin.tables.menu_chat_conversations.add :first_question,
29
+ label: :first_question,
30
+ type: :string,
31
+ sortable: false,
32
+ filterable: false,
33
+ default: true,
34
+ position: 20,
35
+ method: ->(conversation) { conversation.messages.where(role: 'user').order(:created_at).first&.content&.truncate(80) }
36
+
37
+ Spree.admin.tables.menu_chat_conversations.add :message_count,
38
+ label: :messages,
39
+ type: :number,
40
+ sortable: false,
41
+ filterable: false,
42
+ default: true,
43
+ position: 30,
44
+ method: ->(conversation) { conversation.messages.size }
45
+
46
+ Spree.admin.tables.menu_chat_conversations.add :created_at,
47
+ label: :started_at,
48
+ type: :datetime,
49
+ sortable: true,
50
+ filterable: false,
51
+ default: true,
52
+ position: 40
53
+
54
+ Spree.admin.tables.menu_chat_conversations.add :updated_at,
55
+ label: :last_message_at,
56
+ type: :datetime,
57
+ sortable: true,
58
+ filterable: false,
59
+ default: true,
60
+ position: 50
61
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,26 @@
1
+ Spree::Core::Engine.add_routes do
2
+ # `isolate_namespace Spree` means a bare `to: 'chat#create'` here would
3
+ # resolve to `Spree::ChatController` — the leading `/` makes the
4
+ # controller path absolute, same fix spree_square's webhook route
5
+ # already needed for the same reason (see that file's comment).
6
+ post 'menu_chat/chat', to: '/spree_menu_chat/chat#create'
7
+
8
+ namespace :admin do
9
+ # Plain credential-entry form (gemini_api_key/voyage_api_key), not an
10
+ # OAuth connect/disconnect flow — neither service has one. Explicit named
11
+ # routes rather than `resource :menu_chat_credential` since there's no
12
+ # `new`/`create` — just show the one row for the current store and
13
+ # update it in place, `find_or_initialize_by(store:)` style. Same
14
+ # pattern as spree_doordash's doordash_credential routes.
15
+ get 'menu_chat_credential' => 'menu_chat_credentials#show', as: :menu_chat_credential
16
+ patch 'menu_chat_credential' => 'menu_chat_credentials#update'
17
+
18
+ # M6 — read-only QA view: index + show, no :new/:create/:edit/:destroy
19
+ # (nothing here is ever created or edited through the admin UI). See
20
+ # spree_admin_menu_chat_tables.rb's `new_resource: false` comment for
21
+ # the real bug that omission avoids, and its `link_to_action: :show`
22
+ # for why :show needs to exist for a conversation row to be clickable
23
+ # at all.
24
+ resources :menu_chat_conversations, only: %i[index show]
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ class CreateSpreeMenuChatCredentials < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_menu_chat_credentials do |t|
4
+ t.references :store, null: false, foreign_key: { to_table: :spree_stores }, index: { unique: true }
5
+
6
+ # Both are plain static API keys — no OAuth/refresh flow for either
7
+ # Gemini or Voyage AI. Encrypted at the application layer (see
8
+ # SpreeMenuChat::Credential), same as spree_doordash's credential
9
+ # columns.
10
+ t.text :gemini_api_key
11
+ t.text :voyage_api_key
12
+
13
+ t.timestamps
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ class EnablePgvectorExtension < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # This extension's semantic search is fundamentally Postgres+pgvector —
4
+ # there is no SQLite/MySQL equivalent to fall back to (unlike, say,
5
+ # spree_square's json/jsonb split, which was a syntax difference for the
6
+ # same feature). On a non-Postgres adapter this migration — and the one
7
+ # that follows it, creating spree_menu_chat_embeddings — is a deliberate
8
+ # no-op rather than an error, so `bundle exec rake test_app` and the
9
+ # Credential/LlmClient specs still work on SQLite/MySQL; only the
10
+ # embeddings feature itself requires Postgres in the host app (see
11
+ # README.md's Requirements section).
12
+ return unless connection.adapter_name.match?(/postg/i)
13
+
14
+ enable_extension 'vector'
15
+ end
16
+ end
@@ -0,0 +1,44 @@
1
+ class CreateSpreeMenuChatEmbeddings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # See EnablePgvectorExtension's comment — Postgres-only, deliberate no-op
4
+ # elsewhere.
5
+ return unless connection.adapter_name.match?(/postg/i)
6
+
7
+ create_table :spree_menu_chat_embeddings do |t|
8
+ # Polymorphic so both Spree::Product (menu items) and Spree::Policy
9
+ # (hours/delivery-area/allergen content — see the plan's decision to
10
+ # reuse Spree::Policy rather than build a bespoke FAQ model) embed
11
+ # into the same table and get retrieved together by
12
+ # SpreeMenuChat::Retriever (M3).
13
+ # index: false — the unique composite index added below (one embedding
14
+ # row per source record) already covers lookups on these two columns;
15
+ # a separate non-unique index on the same pair would be redundant.
16
+ t.references :embeddable, polymorphic: true, null: false, index: false
17
+ t.references :store, null: false, foreign_key: { to_table: :spree_stores }
18
+
19
+ # The exact text that was embedded — kept alongside the vector so
20
+ # Retriever can inject it directly into the generation prompt without
21
+ # a second lookup, and so a content_hash mismatch (below) is easy to
22
+ # diff/debug.
23
+ t.text :content, null: false
24
+
25
+ # 1024 dims — Voyage's default output_dimension for voyage-4-lite (see
26
+ # SpreeMenuChat::Configuration). Column type/limit are Postgres-only
27
+ # (via the `vector` extension enabled above); the `neighbor` gem
28
+ # provides the ActiveRecord query interface (`has_neighbors` /
29
+ # `nearest_neighbors`) on top of it.
30
+ t.vector :embedding, limit: 1024
31
+
32
+ # SHA-256 of `content` — lets SpreeMenuChat::ReembedProductJob skip
33
+ # re-embedding (and re-billing Voyage) when a product was saved but
34
+ # none of its embeddable fields actually changed.
35
+ t.string :content_hash, null: false
36
+
37
+ t.timestamps
38
+ end
39
+
40
+ # One embedding row per source record — re-embedding upserts in place
41
+ # rather than accumulating duplicates.
42
+ add_index :spree_menu_chat_embeddings, %i[embeddable_type embeddable_id], unique: true, name: 'index_menu_chat_embeddings_on_embeddable_unique'
43
+ end
44
+ end
@@ -0,0 +1,27 @@
1
+ class CreateSpreeMenuChatRateLimits < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_menu_chat_rate_limits do |t|
4
+ t.references :store, null: false, foreign_key: { to_table: :spree_stores }
5
+
6
+ # The requester — currently always request.remote_ip (see
7
+ # SpreeMenuChat::ChatController). A plain string rather than a
8
+ # belongs_to: there's no session/customer model to key off yet (the
9
+ # widget is anonymous), and an IP is what's actually available at the
10
+ # point rate limiting has to happen, before any answer is generated.
11
+ t.string :identifier, null: false
12
+
13
+ # Fixed-window counter, not a row-per-request log: one row per
14
+ # (store, identifier), reset when the window rolls over. Keeps this
15
+ # table's size bounded by unique requesters, not by message volume —
16
+ # same reasoning as SpreeMenuChat::EmbeddingClient's in-process
17
+ # throttle (M2) being a counter, not a log, just persisted instead of
18
+ # in-memory since this needs to survive across requests/processes.
19
+ t.integer :count, null: false, default: 0
20
+ t.datetime :window_started_at, null: false
21
+
22
+ t.timestamps
23
+ end
24
+
25
+ add_index :spree_menu_chat_rate_limits, %i[store_id identifier], unique: true, name: 'index_menu_chat_rate_limits_on_store_and_identifier'
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ class CreateSpreeMenuChatTokenUsages < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_menu_chat_token_usages do |t|
4
+ t.references :store, null: false, foreign_key: { to_table: :spree_stores }
5
+ t.date :date, null: false
6
+
7
+ # Gemini generation tokens only (see SpreeMenuChat::TokenBudget's
8
+ # class comment for why embedding tokens aren't folded in here yet)
9
+ # — a running total for the store-day, incremented after each real
10
+ # generation call using Gemini's own reported usageMetadata, not an
11
+ # estimate.
12
+ t.integer :tokens_used, null: false, default: 0
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :spree_menu_chat_token_usages, %i[store_id date], unique: true, name: 'index_menu_chat_token_usages_on_store_and_date'
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ class CreateSpreeMenuChatConversations < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_menu_chat_conversations do |t|
4
+ t.references :store, null: false, foreign_key: { to_table: :spree_stores }
5
+
6
+ # Same anonymous identifier SpreeMenuChat::RateLimiter keys on
7
+ # (request.remote_ip) — the widget has no session/customer model,
8
+ # so this is what's actually available. SpreeMenuChat::Conversation
9
+ # groups messages into one row per (store, identifier) within a
10
+ # short rolling window rather than per HTTP request — see that
11
+ # model's comment for why a time-window heuristic, not an explicit
12
+ # client-sent session id, was the M6 scope decision.
13
+ t.string :identifier, null: false
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :spree_menu_chat_conversations, %i[store_id identifier updated_at], name: 'index_menu_chat_conversations_on_store_identifier_updated'
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ class CreateSpreeMenuChatMessages < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_menu_chat_messages do |t|
4
+ t.references :conversation, null: false, foreign_key: { to_table: :spree_menu_chat_conversations }
5
+
6
+ # "user" or "assistant" — SpreeMenuChat::ChatController logs one of
7
+ # each per real chat exchange (the customer's real question, and the
8
+ # full assembled answer once streaming completes). A plain string,
9
+ # not an enum: two known values, no real benefit to the extra
10
+ # migration ceremony an enum would add here.
11
+ t.string :role, null: false
12
+ t.text :content, null: false
13
+
14
+ t.datetime :created_at, null: false
15
+ # No updated_at — a logged message is never edited after the fact.
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ module SpreeMenuChat
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ class_option :migrate, type: :boolean, default: true
5
+
6
+ def add_migrations
7
+ run 'bundle exec rake railties:install:migrations FROM=spree_menu_chat'
8
+ end
9
+
10
+ def run_migrations
11
+ run_migrations = options[:migrate] || ['', 'y', 'Y'].include?(ask('Would you like to run the migrations now? [Y/n]'))
12
+ if run_migrations
13
+ run 'bin/rails db:migrate'
14
+ else
15
+ puts 'Skipping rails db:migrate, don\'t forget to run it!'
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,63 @@
1
+ module SpreeMenuChat
2
+ class Configuration < Spree::Preferences::Configuration
3
+ # Generation model — the "flash-lite-latest" alias, not a pinned dated
4
+ # model. Confirmed live (2026-08-16): the pinned `gemini-2.5-flash-lite`
5
+ # already 404s for new API keys ("no longer available to new users"),
6
+ # despite still being listed by the models.list endpoint — Google's
7
+ # ListModels catalog does not reflect real per-account availability, so
8
+ # a pinned model string silently rots. The `-latest` alias resolves to
9
+ # whatever Google currently recommends (confirmed live: currently
10
+ # `gemini-3.5-flash-lite`) and is the more robust default. Grounded,
11
+ # low-stakes menu/FAQ Q&A over retrieved context doesn't need a larger
12
+ # model, and the free tier (no billing account required) keeps this
13
+ # project's running cost at $0 during development. Override to a larger
14
+ # Gemini model (or a different provider entirely, by swapping
15
+ # SpreeMenuChat::LlmClient) if quality on compound questions proves
16
+ # insufficient.
17
+ preference :generation_model, :string, default: 'gemini-flash-lite-latest'
18
+
19
+ # Embedding model — voyage-4-lite: cheapest model still explicitly
20
+ # covered by Voyage's free tier (confirmed against their own pricing
21
+ # docs: voyage-4-large/voyage-4/voyage-4-lite/voyage-context-4/
22
+ # voyage-code-3 get the first 200M tokens free; voyage-3-lite is not
23
+ # among the models Voyage's current docs even list anymore — same kind
24
+ # of model-catalog rot as Gemini's, checked live before picking this
25
+ # default rather than assumed). Output dimension defaults to 1024 (see
26
+ # SpreeMenuChat::Embedding's migration) — plenty for a restaurant-sized
27
+ # corpus, no need to request the smaller 256/512 options.
28
+ preference :embedding_model, :string, default: 'voyage-4-lite'
29
+
30
+ # Minimum cosine similarity a retrieved chunk must clear to be used as
31
+ # context. Below this, SpreeMenuChat::Retriever returns nothing and
32
+ # SpreeMenuChat::AnswerGenerator skips the LLM call entirely rather than
33
+ # answering from a weak/irrelevant match — see M3's guardrail note.
34
+ #
35
+ # 0.35, not the originally-planned 0.7 — corrected after live
36
+ # calibration against the real synced catalog (M3 implementation,
37
+ # 2026-08-16): 0.7 was a speculative default picked before ever
38
+ # querying real embeddings, and it was badly miscalibrated. Real
39
+ # Voyage cosine distances for genuinely correct, on-topic matches
40
+ # against real menu-item/policy content ranged 0.32-0.63 across 7 real
41
+ # test questions ("what comes with the buffalo wings?" -> Buffalo
42
+ # Wings at 0.32, "do you deliver?" -> Shipping Policy at 0.63, etc.) —
43
+ # a 0.7 similarity floor (max distance 0.3) rejected nearly every one
44
+ # of them, meaning the assistant would have given the canned
45
+ # "I don't have that" fallback for almost all real, answerable
46
+ # questions. A genuinely off-topic/unanswerable question ("what are
47
+ # your hours?", with no Hours policy actually seeded) landed at 0.76 —
48
+ # comfortably excluded by this floor with margin to spare.
49
+ preference :similarity_floor, :decimal, default: 0.35
50
+
51
+ # How many chunks to retrieve as context per question.
52
+ preference :retrieval_top_k, :integer, default: 5
53
+
54
+ # Rate limiting (M5) — per-session-or-IP, per rolling hour.
55
+ preference :rate_limit_per_hour, :integer, default: 20
56
+
57
+ # Daily token budget across the whole store, generation + embedding
58
+ # combined. Once exhausted, the chat falls back to the "contact us"
59
+ # response for the rest of the day rather than continuing to spend.
60
+ # Business decision, not a technical one — tune per store.
61
+ preference :daily_token_budget, :integer, default: 200_000
62
+ end
63
+ end
@@ -0,0 +1,45 @@
1
+ module SpreeMenuChat
2
+ class Engine < Rails::Engine
3
+ require 'spree/core'
4
+ isolate_namespace Spree
5
+ engine_name 'spree_menu_chat'
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ end
10
+
11
+ initializer 'spree_menu_chat.environment', before: :load_config_initializers do |_app|
12
+ SpreeMenuChat::Config = SpreeMenuChat::Configuration.new
13
+ end
14
+
15
+ # Same ordering requirement as spree_square's and spree_doordash's
16
+ # identical initializer — must run before Active Record's own
17
+ # "active_record_encryption.configuration" initializer reads
18
+ # config.active_record.encryption, or Credential's encrypted columns
19
+ # fail with "Missing Active Record encryption credential" the first
20
+ # time they're touched. Shares the same ACTIVE_RECORD_ENCRYPTION_* keys
21
+ # the sibling extensions already set up (one encryption key set per
22
+ # Rails app, not per extension).
23
+ initializer 'spree_menu_chat.active_record_encryption', before: 'active_record_encryption.configuration' do |app|
24
+ next if ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].blank?
25
+
26
+ app.config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
27
+ app.config.active_record.encryption.deterministic_key = ENV['ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY']
28
+ app.config.active_record.encryption.key_derivation_salt = ENV['ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT']
29
+ end
30
+
31
+ # Force-loads decorator files (the Spree::Product after_commit re-embed
32
+ # hook, added in M2) the same way spree_square's/spree_doordash's engines
33
+ # do — Zeitwerk lazy-autoloading never triggers a decorator file's
34
+ # `prepend` line otherwise. Kept here from the start even though M1 has
35
+ # no decorator yet, so adding one later doesn't require remembering this
36
+ # step.
37
+ def self.activate
38
+ Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')) do |c|
39
+ Rails.application.config.cache_classes ? require(c) : load(c)
40
+ end
41
+ end
42
+
43
+ config.to_prepare(&method(:activate).to_proc)
44
+ end
45
+ end
@@ -0,0 +1,48 @@
1
+ FactoryBot.define do
2
+ # Define your Spree extensions Factories within this file to enable applications, and other extensions to use and override them.
3
+ #
4
+ # Example adding this to your spec_helper will load these Factories for use:
5
+ # require 'spree_menu_chat/factories'
6
+
7
+ factory :menu_chat_credential, class: 'SpreeMenuChat::Credential' do
8
+ store
9
+ gemini_api_key { 'test-gemini-api-key' }
10
+ voyage_api_key { 'test-voyage-api-key' }
11
+ end
12
+
13
+ factory :menu_chat_embedding, class: 'SpreeMenuChat::Embedding' do
14
+ embeddable factory: :product
15
+ store
16
+ content { 'Test Product\n\nA product used for specs.' }
17
+ content_hash { SpreeMenuChat::Embedding.content_hash_for(content) }
18
+ # 1024 dims to match the migration's vector(1024) column (Voyage's
19
+ # default output_dimension for voyage-4-lite) — a fixed, deterministic
20
+ # vector rather than random noise, so distance-based spec assertions
21
+ # (nearest_neighbors ordering) are reproducible.
22
+ embedding { Array.new(1024) { 0.001 } }
23
+ end
24
+
25
+ factory :menu_chat_rate_limit, class: 'SpreeMenuChat::RateLimit' do
26
+ store
27
+ identifier { '127.0.0.1' }
28
+ count { 0 }
29
+ window_started_at { Time.current }
30
+ end
31
+
32
+ factory :menu_chat_token_usage, class: 'SpreeMenuChat::TokenUsage' do
33
+ store
34
+ date { Date.current }
35
+ tokens_used { 0 }
36
+ end
37
+
38
+ factory :menu_chat_conversation, class: 'SpreeMenuChat::Conversation' do
39
+ store
40
+ identifier { '127.0.0.1' }
41
+ end
42
+
43
+ factory :menu_chat_message, class: 'SpreeMenuChat::Message' do
44
+ conversation factory: :menu_chat_conversation
45
+ role { 'user' }
46
+ content { 'what comes with the burrito bowl?' }
47
+ end
48
+ end
@@ -0,0 +1,7 @@
1
+ module SpreeMenuChat
2
+ VERSION = '0.1.0'.freeze
3
+
4
+ def gem_version
5
+ Gem::Version.new(VERSION)
6
+ end
7
+ end
@@ -0,0 +1,30 @@
1
+ require 'spree'
2
+ # Required explicitly here, not left to Bundler.require: neighbor is only a
3
+ # gemspec dependency of this gem (a transitive dependency from a host app's
4
+ # point of view), and Bundler.require only auto-requires gems listed
5
+ # directly in the Gemfile. Without this, `t.vector` in migrations and
6
+ # `has_neighbors` on SpreeMenuChat::Embedding both raise NoMethodError —
7
+ # confirmed live: neighbor's Railtie (which registers the `vector` column
8
+ # type) never ran, even though `bundle install` had it installed and
9
+ # `Gem.loaded_specs.key?('neighbor')` was true. Loaded here, in the gem's
10
+ # main entry file, rather than lazily inside the Embedding model, because
11
+ # the Railtie needs to be in place before migrations run, not just before
12
+ # the model is first autoloaded.
13
+ require 'neighbor'
14
+ # Same rationale as neighbor above — kramdown is only a gemspec
15
+ # (transitive) dependency, not auto-required by Bundler.require.
16
+ # SpreeMenuChat::Message#rendered_content would raise NameError without
17
+ # this, in exactly the same "works under Zeitwerk eager-load, fails under
18
+ # rails runner/real request lazy autoloading" shape neighbor's bug had.
19
+ require 'kramdown'
20
+ require 'spree_menu_chat/engine'
21
+ require 'spree_menu_chat/version'
22
+ require 'spree_menu_chat/configuration'
23
+
24
+ module SpreeMenuChat
25
+ mattr_accessor :queue
26
+
27
+ def self.queue
28
+ @@queue ||= Spree.queues.default
29
+ end
30
+ end
@@ -0,0 +1,93 @@
1
+ namespace :spree_menu_chat do
2
+ # M1 verification: proves the API key this extension is given is actually
3
+ # accepted by a real Gemini endpoint, not just wired up correctly per the
4
+ # documented request shape (already covered by
5
+ # spec/services/spree_menu_chat/llm_client_spec.rb).
6
+ #
7
+ # Reads a real key from .env.local (gitignored, never committed — see that
8
+ # file's own comment) rather than the encrypted SpreeMenuChat::Credential
9
+ # row, so this can run standalone against the gem's own dummy app without
10
+ # needing a full spree_host install first.
11
+ #
12
+ # Usage:
13
+ # bin/rails spree_menu_chat:verify_connection
14
+ desc 'Verify the Gemini API key by requesting one real generation'
15
+ task verify_connection: :environment do
16
+ env_path = File.expand_path('../../.env.local', __dir__)
17
+ unless File.exist?(env_path)
18
+ abort "No .env.local found at #{env_path} — copy .env.local.example, fill in GEMINI_API_KEY, and try again."
19
+ end
20
+
21
+ require 'dotenv'
22
+ Dotenv.load(env_path)
23
+
24
+ abort 'GEMINI_API_KEY is blank in .env.local — fill it in first.' if ENV['GEMINI_API_KEY'].blank?
25
+
26
+ credential = SpreeMenuChat::Credential.new(gemini_api_key: ENV['GEMINI_API_KEY'])
27
+ client = SpreeMenuChat::LlmClient.new(credential: credential)
28
+
29
+ puts "Requesting a real Gemini completion (#{SpreeMenuChat::Config.generation_model})..."
30
+ begin
31
+ result = client.generate(
32
+ 'Reply with exactly one short sentence confirming this connection works.',
33
+ system_instruction: 'You are a connectivity test. Be brief.'
34
+ )
35
+ text = result.dig('candidates', 0, 'content', 'parts', 0, 'text')
36
+ puts '✅ Success — Gemini accepted the key and generated a real response:'
37
+ puts text || JSON.pretty_generate(result)
38
+ rescue SpreeMenuChat::RequestError => e
39
+ puts "❌ Gemini rejected the request (HTTP #{e.status}):"
40
+ puts JSON.pretty_generate(e.body)
41
+ exit 1
42
+ end
43
+ end
44
+
45
+ # M2 verification: proves the Voyage API key actually works against a
46
+ # real endpoint (same rationale as verify_connection above) before
47
+ # trusting reembed_all to spend real API calls across a whole catalog.
48
+ #
49
+ # Usage:
50
+ # bin/rails spree_menu_chat:verify_embeddings
51
+ desc 'Verify the Voyage AI API key by requesting one real embedding'
52
+ task verify_embeddings: :environment do
53
+ env_path = File.expand_path('../../.env.local', __dir__)
54
+ unless File.exist?(env_path)
55
+ abort "No .env.local found at #{env_path} — copy .env.local.example, fill in VOYAGE_API_KEY, and try again."
56
+ end
57
+
58
+ require 'dotenv'
59
+ Dotenv.load(env_path)
60
+
61
+ abort 'VOYAGE_API_KEY is blank in .env.local — fill it in first.' if ENV['VOYAGE_API_KEY'].blank?
62
+
63
+ credential = SpreeMenuChat::Credential.new(voyage_api_key: ENV['VOYAGE_API_KEY'])
64
+ client = SpreeMenuChat::EmbeddingClient.new(credential: credential)
65
+
66
+ puts "Requesting a real Voyage embedding (#{SpreeMenuChat::Config.embedding_model})..."
67
+ begin
68
+ vectors = client.embed('Spicy black bean burrito bowl with pickled onions', input_type: 'document')
69
+ vector = vectors.first
70
+ puts '✅ Success — Voyage accepted the key and returned a real embedding:'
71
+ puts "#{vector.length} dimensions, first 5 values: #{vector.first(5).inspect}"
72
+ rescue SpreeMenuChat::RequestError => e
73
+ puts "❌ Voyage rejected the request (HTTP #{e.status}):"
74
+ puts JSON.pretty_generate(e.body)
75
+ exit 1
76
+ end
77
+ end
78
+
79
+ # M2 backfill / admin "rebuild index" action — embeds every non-deleted,
80
+ # non-archived product plus every Spree::Policy for the given store (or
81
+ # Spree::Store.default). Safe to re-run: SpreeMenuChat::Embedder skips
82
+ # anything whose content hasn't actually changed since it was last
83
+ # embedded.
84
+ #
85
+ # Usage:
86
+ # bin/rails spree_menu_chat:reembed_all
87
+ # bin/rails "spree_menu_chat:reembed_all[store_id]"
88
+ desc 'Re-embed every product and policy for a store (default: the default store)'
89
+ task :reembed_all, %i[store_id] => :environment do |_t, args|
90
+ SpreeMenuChat::ReembedCatalogJob.perform_now(args[:store_id])
91
+ puts "✅ Re-embedded #{SpreeMenuChat::Embedding.count} record(s)."
92
+ end
93
+ end
@@ -0,0 +1,63 @@
1
+ # encoding: UTF-8
2
+ lib = File.expand_path('../lib/', __FILE__)
3
+ $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
4
+
5
+ require 'spree_menu_chat/version'
6
+
7
+ Gem::Specification.new do |s|
8
+ s.platform = Gem::Platform::RUBY
9
+ s.name = 'spree_menu_chat'
10
+ s.version = SpreeMenuChat::VERSION
11
+ s.summary = 'Spree Commerce Menu & FAQ Chat Assistant'
12
+ s.description = 'A read-only, retrieval-grounded chat assistant for Spree storefronts — answers ' \
13
+ 'customer questions about the menu, ingredients, dietary info, hours, and policies ' \
14
+ 'using the store\'s own synced catalog and Spree::Policy content. No cart or order ' \
15
+ 'mutation capability of any kind. Companion extension to spree_square, reused via a ' \
16
+ 'soft dependency for modifier-list grounding when present.'
17
+ s.required_ruby_version = '>= 3.2'
18
+
19
+ s.author = 'Amit Solanki'
20
+ s.email = 'amit@prayantr.com'
21
+ s.homepage = 'https://github.com/amitkssolanki/spree_menu_chat'
22
+ s.license = 'MIT'
23
+
24
+ s.metadata = {
25
+ 'homepage_uri' => s.homepage,
26
+ 'source_code_uri' => s.homepage,
27
+ 'changelog_uri' => "#{s.homepage}/blob/main/CHANGELOG.md",
28
+ 'bug_tracker_uri' => "#{s.homepage}/issues"
29
+ }
30
+
31
+ s.files = `git ls-files -z`.split("\x0").reject do |f|
32
+ f.start_with?('spec/') && !f.start_with?('spec/fixtures')
33
+ end
34
+ s.require_path = 'lib'
35
+ s.requirements << 'none'
36
+
37
+ spree_version = '>= 5.4.0.beta'
38
+ s.add_dependency 'spree', spree_version
39
+ s.add_dependency 'spree_admin', spree_version
40
+
41
+ # Neither Gemini (generation) nor Voyage AI (embeddings) has an official
42
+ # Google/Voyage-maintained Ruby SDK — both are called directly over their
43
+ # REST APIs via Faraday, the same "no SDK, plain REST" pattern
44
+ # spree_doordash already uses for DoorDash's Drive API (see
45
+ # SpreeDoordash::Client). pgvector similarity queries go through the
46
+ # `neighbor` gem (adds `nearest_neighbors`/cosine-distance query support
47
+ # on top of a plain `vector` column — no ActiveRecord adapter of its own
48
+ # to maintain).
49
+ s.add_dependency 'faraday', '~> 2.0'
50
+ s.add_dependency 'neighbor', '~> 0.5'
51
+
52
+ # Gemini's real answers regularly come back with markdown (bold
53
+ # ingredient names, bullet lists of menu items) — used to render both
54
+ # the admin conversation view (SpreeMenuChat::Message#content, plain
55
+ # text as stored) and, via the storefront's own react-markdown, the
56
+ # widget itself. Pure Ruby, no native extension — kramdown's own stated
57
+ # design goal, unlike redcarpet's C extension.
58
+ s.add_dependency 'kramdown', '~> 2.4'
59
+
60
+ s.add_development_dependency 'spree_dev_tools'
61
+ s.add_development_dependency 'webmock'
62
+ s.add_development_dependency 'gem-release'
63
+ end