spree_menu_chat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.env +7 -0
- data/.env.local.example +12 -0
- data/.github/workflows/test.yml +111 -0
- data/.gitignore +27 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +164 -0
- data/CONTRIBUTING.md +32 -0
- data/Gemfile +27 -0
- data/LICENSE.md +9 -0
- data/README.md +95 -0
- data/Rakefile +23 -0
- data/app/controllers/spree/admin/menu_chat_conversations_controller.rb +15 -0
- data/app/controllers/spree/admin/menu_chat_credentials_controller.rb +38 -0
- data/app/controllers/spree_menu_chat/chat_controller.rb +114 -0
- data/app/jobs/spree_menu_chat/base_job.rb +5 -0
- data/app/jobs/spree_menu_chat/reembed_catalog_job.rb +27 -0
- data/app/jobs/spree_menu_chat/reembed_product_job.rb +25 -0
- data/app/models/spree/product_decorator.rb +23 -0
- data/app/models/spree_menu_chat/conversation.rb +39 -0
- data/app/models/spree_menu_chat/credential.rb +16 -0
- data/app/models/spree_menu_chat/embedding.rb +30 -0
- data/app/models/spree_menu_chat/message.rb +35 -0
- data/app/models/spree_menu_chat/rate_limit.rb +14 -0
- data/app/models/spree_menu_chat/token_usage.rb +13 -0
- data/app/services/spree_menu_chat/alerting.rb +20 -0
- data/app/services/spree_menu_chat/answer_generator.rb +143 -0
- data/app/services/spree_menu_chat/content_builder.rb +65 -0
- data/app/services/spree_menu_chat/embedder.rb +39 -0
- data/app/services/spree_menu_chat/embedding_client.rb +161 -0
- data/app/services/spree_menu_chat/llm_client.rb +186 -0
- data/app/services/spree_menu_chat/rate_limiter.rb +53 -0
- data/app/services/spree_menu_chat/request_error.rb +26 -0
- data/app/services/spree_menu_chat/retriever.rb +64 -0
- data/app/services/spree_menu_chat/token_budget.rb +52 -0
- data/app/views/spree/admin/menu_chat_conversations/index.html.erb +5 -0
- data/app/views/spree/admin/menu_chat_conversations/show.html.erb +27 -0
- data/app/views/spree/admin/menu_chat_credentials/show.html.erb +44 -0
- data/config/brakeman.ignore +28 -0
- data/config/initializers/spree_admin_menu_chat_navigation.rb +21 -0
- data/config/initializers/spree_admin_menu_chat_tables.rb +61 -0
- data/config/routes.rb +26 -0
- data/db/migrate/20260814000001_create_spree_menu_chat_credentials.rb +16 -0
- data/db/migrate/20260816000001_enable_pgvector_extension.rb +16 -0
- data/db/migrate/20260816000002_create_spree_menu_chat_embeddings.rb +44 -0
- data/db/migrate/20260816000003_create_spree_menu_chat_rate_limits.rb +27 -0
- data/db/migrate/20260816000004_create_spree_menu_chat_token_usages.rb +19 -0
- data/db/migrate/20260816000005_create_spree_menu_chat_conversations.rb +20 -0
- data/db/migrate/20260816000006_create_spree_menu_chat_messages.rb +18 -0
- data/lib/generators/spree_menu_chat/install/install_generator.rb +20 -0
- data/lib/spree_menu_chat/configuration.rb +63 -0
- data/lib/spree_menu_chat/engine.rb +45 -0
- data/lib/spree_menu_chat/factories.rb +48 -0
- data/lib/spree_menu_chat/version.rb +7 -0
- data/lib/spree_menu_chat.rb +30 -0
- data/lib/tasks/spree_menu_chat.rake +93 -0
- data/spree_menu_chat.gemspec +63 -0
- metadata +216 -0
|
@@ -0,0 +1,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,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
|