Shopify Support Chatbot — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build an embeddable Shopify support chatbot that answers product/info questions (live catalog + a knowledge base of PDFs/URLs) and order/tracking questions (GraphQL Admin API, identity-verified), in the customer's language, on free LLMs (Groq → Cloudflare failover) with local embeddings.
Architecture: A Python FastAPI backend exposes a chat endpoint reached from a JS widget through a Shopify App Proxy (no CORS, HMAC-verified). The backend orchestrates a provider-agnostic LLM (Groq primary, Cloudflare Workers AI failover) with tool-calling over three tools: search_knowledge (RAG via local embeddings + pgvector), search_products (Shopify GraphQL), lookup_order (Shopify GraphQL + tiered identity verification). An admin panel manages knowledge sources. Postgres+pgvector stores vectors, config, sessions. Deployed on Railway.
Tech Stack: Python 3.12, FastAPI, Uvicorn, SQLAlchemy 2.x, Alembic, pgvector, psycopg, sentence-transformers (multilingual-e5-small) for local embeddings, httpx (Groq/Cloudflare/Shopify HTTP), pypdf + openpyxl + python-docx + selectolax (doc/URL extraction), pytest + pytest-asyncio + respx (HTTP mocking), vanilla JS widget, Shopify Theme App Extension. Package manager: uv.
Open decisions locked by owner (2026-06-09, full autonomy):
- Start on free LLM tiers; document paid upgrade path. No paid keys required for v1.
- v1 covers orders ≤60 days (
read_orders).read_all_ordersleft as a config flag (SHOPIFY_READ_ALL_ORDERS) to enable after Shopify approval. - Human escalation = minimal: capture the user's email + question and email the store's support address.
Conventions:
- All code under
app/, tests undertests/mirroring module paths. - TDD: write the failing test, run it (red), implement minimal code, run (green), commit.
- Every commit message:
<type>: <summary>then trailerCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>. - Async everywhere for I/O (httpx.AsyncClient, async SQLAlchemy). Embeddings model runs sync in a threadpool.
- Tests must assert positive outcomes (never a vacuous pass). Run the FULL suite green before each phase-end commit.
- Secrets only via env (
app/config.pySettings). Never commit secrets.
File Structure
shopify-support-bot/
├── pyproject.toml # uv project, deps, pytest config
├── .env.example # documented env vars (no secrets)
├── .gitignore
├── README.md
├── alembic.ini
├── migrations/ # alembic
│ ├── env.py
│ └── versions/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app factory, routers, lifespan
│ ├── config.py # Settings (pydantic-settings)
│ ├── db.py # async engine/session, Base
│ ├── models.py # ORM: Config, KnowledgeSource, KnowledgeChunk, ChatSession, ChatMessage
│ ├── schemas.py # pydantic request/response models
│ ├── llm/
│ │ ├── __init__.py
│ │ ├── base.py # LLMProvider protocol, ChatResult, ToolCall, ToolSpec
│ │ ├── groq.py # GroqProvider
│ │ ├── cloudflare.py # CloudflareProvider
│ │ └── router.py # LLMRouter: model routing + Groq→Cloudflare failover
│ ├── embeddings.py # local multilingual embedder (singleton, threadpool)
│ ├── rag/
│ │ ├── __init__.py
│ │ ├── extract.py # file/URL -> text
│ │ ├── chunk.py # text -> chunks
│ │ └── index.py # index/reindex/delete sources; search
│ ├── shopify/
│ │ ├── __init__.py
│ │ ├── token.py # ShopifyTokenManager (client_credentials, 24h refresh)
│ │ ├── client.py # ShopifyGraphQLClient (httpx)
│ │ ├── proxy.py # App Proxy HMAC verification
│ │ ├── products.py # search_products
│ │ └── orders.py # lookup_order_raw + tracking parsing
│ ├── tools/
│ │ ├── __init__.py
│ │ ├── registry.py # ToolSpec list + dispatch
│ │ ├── knowledge_tool.py # search_knowledge
│ │ ├── products_tool.py # search_products
│ │ └── order_tool.py # lookup_order + verification + escalation
│ ├── verification.py # identity verification state machine + lockout
│ ├── orchestrator.py # chat turn: history + LLM tool loop -> reply
│ ├── escalation.py # capture email + question -> send to store
│ ├── mailer.py # SMTP send (escalation)
│ ├── prompts.py # system prompt builder (multilingual, persona, rules)
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── chat.py # POST /apps/chat (proxy-verified)
│ │ ├── admin.py # admin API (auth): sources CRUD, reindex, config
│ │ └── health.py # GET /healthz
│ └── admin_ui/
│ └── index.html # minimal admin SPA (vanilla)
├── extension/ # Shopify Theme App Extension
│ ├── shopify.app.toml # app config incl. app_proxy + scopes
│ └── extensions/chat-widget/
│ ├── shopify.extension.toml
│ ├── blocks/chat.liquid # app embed block (settings + loader)
│ └── assets/widget.js # chat widget
└── tests/
├── conftest.py # db fixture, app client, fakes
├── llm/... # provider + router + failover tests
├── rag/... # extract/chunk/index/search tests
├── shopify/... # token/proxy/products/orders tests
├── tools/... # tool dispatch tests
├── test_verification.py
├── test_orchestrator.py
├── test_escalation.py
└── routes/... # chat/admin/health endpoint tests
PHASE 0 — Project scaffold & CI-ready test harness
Task 0.1: Initialize uv project & dependencies
Files: Create pyproject.toml, .gitignore, .env.example, README.md
- Step 1: Create
pyproject.toml:
[project]
name = "shopify-support-bot"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"pydantic>=2.9",
"pydantic-settings>=2.6",
"sqlalchemy[asyncio]>=2.0",
"psycopg[binary]>=3.2",
"pgvector>=0.3",
"alembic>=1.14",
"httpx>=0.28",
"sentence-transformers>=3.3",
"pypdf>=5.1",
"openpyxl>=3.1",
"python-docx>=1.1",
"selectolax>=0.3.25",
"python-multipart>=0.0.12",
"tenacity>=9.0",
]
[dependency-groups]
dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"respx>=0.22",
"aiosqlite>=0.20",
"ruff>=0.8",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
filterwarnings = ["ignore::DeprecationWarning"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
- Step 2: Create
.gitignore(Python,.env,.venv,__pycache__,*.db,.pytest_cache, model cache.hf_cache/,node_modules). - Step 3: Create
.env.exampledocumenting every var (see Task 1.1 Settings) with placeholder values and comments. - Step 4: Run
uv syncand confirm the venv builds.
Run: uv run python -c "import fastapi, sqlalchemy, httpx, sentence_transformers; print('ok')"
Expected: prints ok.
- Step 5: Commit
chore: scaffold uv project and dependencies.
Task 0.2: Test harness with async DB fixture (sqlite for unit, pg for integration)
Files: Create tests/conftest.py, app/db.py, app/__init__.py
Decision: unit tests use in-memory sqlite (aiosqlite) with a vector fallback (store embeddings as JSON + cosine in Python when the dialect isn't postgres); pgvector path is exercised in Phase 2 integration tests guarded by DATABASE_URL pointing at postgres. This keeps the suite runnable with zero external services.
- Step 1: Write
app/db.py:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
_engine = None
_sessionmaker = None
def init_engine(url: str) -> None:
global _engine, _sessionmaker
_engine = create_async_engine(url, future=True)
_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False, class_=AsyncSession)
def get_sessionmaker() -> async_sessionmaker:
assert _sessionmaker is not None, "engine not initialized"
return _sessionmaker
async def get_session() -> AsyncIterator[AsyncSession]:
async with get_sessionmaker()() as session:
yield session
- Step 2: Write
tests/conftest.pywith fixtures:db_session(creates all tables on a fresh in-memory sqlite engine per test),client(httpx ASGITransport against the app with DB dependency overridden). Include a_is_postgreshelper. Provide ananyio_backend/asyncio config via pytest-asyncio auto mode. - Step 3: Write a trivial
tests/test_smoke.py::test_db_fixture_creates_tablesthat inserts and reads back a row from a throwaway model to prove the fixture works. - Step 4: Run
uv run pytest tests/test_smoke.py -v→ PASS. - Step 5: Commit
test: async db test harness.
PHASE 1 — Config, models, and the LLM layer (Groq → Cloudflare failover)
Task 1.1: Settings
Files: Create app/config.py, Test tests/test_config.py
- Step 1 (test): assert
Settingsreads env vars with defaults and thatllm_provider_orderdefaults to["groq", "cloudflare"].
def test_settings_defaults(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite+aiosqlite://")
from app.config import Settings
s = Settings()
assert s.llm_provider_order == ["groq", "cloudflare"]
assert s.model_small and s.model_large
assert s.order_verify_max_attempts == 5
- Step 2: Run → FAIL.
- Step 3 (impl):
Settings(BaseSettings)fields:database_url,groq_api_key,groq_base_url="https://api.groq.com/openai/v1",cloudflare_account_id,cloudflare_api_token,model_large="llama-3.3-70b-versatile",model_small="llama-3.1-8b-instant",cf_model_large="@cf/meta/llama-3.3-70b-instruct-fp8-fast",cf_model_small="@cf/meta/llama-3.1-8b-instruct",llm_provider_order=["groq","cloudflare"],shopify_shop,shopify_client_id,shopify_client_secret,shopify_api_version="2026-01",shopify_app_proxy_secret,shopify_read_all_orders=False,admin_token,embedding_model="intfloat/multilingual-e5-small",order_verify_max_attempts=5,order_verify_lockout_seconds=900,session_retention_days=30,smtp_*,support_email.model_config = SettingsConfigDict(env_file=".env", extra="ignore"). - Step 4: Run → PASS. Step 5: Commit
feat: settings.
Task 1.2: ORM models + schemas
Files: Create app/models.py, app/schemas.py, Test tests/test_models.py
- Step 1 (test): create a
ChatSession, add aChatMessage, flush, and assert relationship + defaults (verified=False,verify_attempts=0). - Step 2: FAIL.
- Step 3 (impl): Models on
Base:Config(id, key UNIQUE, value JSON)— singleton-ish store for branding/flags.KnowledgeSource(id, kind[file|url], name, location, status[pending|indexed|error], error, created_at)KnowledgeChunk(id, source_id FK, ordinal, text, embedding[Vector(384) on pg / JSON on sqlite via a TypeDecorator], meta JSON)ChatSession(id[str uuid], shop, lang, verified bool=False, verify_attempts int=0, locked_until datetime|None, created_at, last_seen)ChatMessage(id, session_id FK, role, content, created_at)Implement anEmbeddingTypeTypeDecoratorthat usespgvector.sqlalchemy.Vector(384)on postgres andJSON(list of floats) on other dialects, so unit tests run on sqlite.schemas.py:ChatRequest{session_id?, message},ChatResponse{session_id, reply, lang, used_tools[]},SourceIn{kind, location?},SourceOut{...}.
- Step 4: PASS. Step 5: Commit
feat: orm models and schemas.
Task 1.3: LLM base protocol
Files: Create app/llm/base.py, Test tests/llm/test_base.py
- Step 1 (test): assert dataclasses construct:
ToolSpec(name, description, parameters),ToolCall(id, name, arguments: dict),ChatResult(content: str|None, tool_calls: list[ToolCall], finish_reason). AssertLLMProvideris atyping.Protocolwith asyncchat(messages, tools, model, temperature). - Step 2: FAIL. Step 3 (impl): dataclasses + Protocol + a
ProviderError(Exception)andRateLimitError(ProviderError). - Step 4: PASS. Step 5: Commit
feat: llm base protocol.
Task 1.4: GroqProvider (OpenAI-compatible chat completions, tool calling)
Files: Create app/llm/groq.py, Test tests/llm/test_groq.py
- Step 1 (test): with
respxmock POSThttps://api.groq.com/openai/v1/chat/completions:- returns assistant message content →
ChatResult.content == "hola",tool_calls == []. - returns a
tool_callsarray → parsed intoToolCall(name=..., arguments=dict)(arguments JSON-decoded). - returns HTTP 429 → raises
RateLimitError. - returns HTTP 500 → raises
ProviderError.
- returns assistant message content →
@respx.mock
async def test_groq_parses_tool_calls():
respx.post("https://api.groq.com/openai/v1/chat/completions").mock(
return_value=httpx.Response(200, json={
"choices":[{"finish_reason":"tool_calls","message":{"content":None,"tool_calls":[
{"id":"c1","type":"function","function":{"name":"lookup_order","arguments":"{\"email\":\"a@b.c\"}"}}]}}]}))
p = GroqProvider(api_key="k", base_url="https://api.groq.com/openai/v1")
r = await p.chat(messages=[{"role":"user","content":"x"}], tools=[], model="llama-3.1-8b-instant")
assert r.tool_calls[0].name == "lookup_order"
assert r.tool_calls[0].arguments == {"email":"a@b.c"}
- Step 2: FAIL. Step 3 (impl):
GroqProvider.chatbuilds OpenAI-style payload (messages,tools=[{"type":"function","function":ToolSpec}],tool_choice="auto",model,temperature), posts via sharedhttpx.AsyncClient, maps 429→RateLimitError, other ≥400→ProviderError, parses choices[0]. - Step 4: PASS. Step 5: Commit
feat: groq provider.
Task 1.5: CloudflareProvider (Workers AI, OpenAI-compatible endpoint)
Files: Create app/llm/cloudflare.py, Test tests/llm/test_cloudflare.py
- Step 1 (test): respx mock POST
https://api.cloudflare.com/client/v4/accounts/{acct}/ai/v1/chat/completions(OpenAI-compatible) → same parsing contract as Groq (content + tool_calls + 429→RateLimitError). Use account idacctand Bearer token header asserted. - Step 2: FAIL. Step 3 (impl):
CloudflareProvider(account_id, api_token)posting to the/ai/v1/chat/completionsOpenAI-compatible route withAuthorization: Bearer. Same payload/response mapping as Groq (share a small_parse_openai_choicehelper inbase.py). - Step 4: PASS. Step 5: Commit
feat: cloudflare workers ai provider.
Task 1.6: LLMRouter — model routing + failover
Files: Create app/llm/router.py, Test tests/llm/test_router.py
- Step 1 (test):
tier="small"routes tomodel_small/cf_model_small;tier="large"to large models.- Primary (Groq) raises
RateLimitError→ router falls back to Cloudflare and returns its result. Assert the fake Cloudflare provider was called once and Groq once. - All providers fail → raises
ProviderErrorafter exhaustingllm_provider_order.
async def test_router_failover_on_ratelimit():
groq = FakeProvider(raise_exc=RateLimitError())
cf = FakeProvider(result=ChatResult(content="ok", tool_calls=[], finish_reason="stop"))
router = LLMRouter(providers={"groq":groq,"cloudflare":cf}, order=["groq","cloudflare"],
models={"groq":{"small":"gs","large":"gl"},"cloudflare":{"small":"cs","large":"cl"}})
r = await router.chat(messages=[{"role":"user","content":"x"}], tools=[], tier="small")
assert r.content == "ok"
assert groq.calls == 1 and cf.calls == 1
- Step 2: FAIL. Step 3 (impl):
LLMRouter.chat(messages, tools, tier)iteratesorder, picks each provider's model for the tier, callschat, returns first success; onProviderError/RateLimitErrorlogs and tries next; raises if all fail.tenacityretry (2 attempts, expo backoff) per provider before moving on. - Step 4: PASS. Step 5: Commit
feat: llm router with failover.
PHASE 2 — Embeddings, extraction, RAG index & search
Task 2.1: Local embedder
Files: Create app/embeddings.py, Test tests/test_embeddings.py
- Step 1 (test):
embed_texts(["hola mundo","adios"])returns 2 vectors of equal length >0; identical text yields identical vectors; cosine(self,self)≈1. Mark@pytest.mark.slow(loads the model once). - Step 2: FAIL. Step 3 (impl): lazy-loaded
SentenceTransformer(settings.embedding_model)singleton;embed_texts(list[str]) -> list[list[float]]andembed_query(str)->list[float](e5 wants"query: "/"passage: "prefixes — apply them). Run inference inanyio.to_thread. Cache model in module global. SetHF_HOME=.hf_cache. - Step 4: PASS (downloads model on first run). Step 5: Commit
feat: local multilingual embedder.
Task 2.2: Text extraction (file + URL)
Files: Create app/rag/extract.py, Test tests/rag/test_extract.py + fixtures tests/rag/fixtures/sample.pdf|.xlsx|.docx|.txt
- Step 1 (test):
extract_file(path)returns non-empty text for each fixture type with a known substring.extract_url(html_bytes)(pass raw HTML to a pure functionextract_html(bytes)->str) strips tags and returns visible text containing a known phrase. Generate fixtures programmatically in a fixture-builder step. - Step 2: FAIL. Step 3 (impl): dispatch by extension:
.pdf→pypdf,.xlsx→openpyxl (join non-empty cells),.docx→python-docx,.txt→read.extract_html(bytes)viaselectolax(.text()after removing script/style).extract_url(url)fetches via httpx then callsextract_html. - Step 4: PASS. Step 5: Commit
feat: text extraction for files and urls.
Task 2.3: Chunking
Files: Create app/rag/chunk.py, Test tests/rag/test_chunk.py
- Step 1 (test):
chunk_text("a"*2500, size=1000, overlap=200)→ 3 chunks, each ≤1000 chars, consecutive chunks overlap by 200, concatenation order preserved. Empty/whitespace →[]. - Step 2: FAIL. Step 3 (impl): sliding window by characters with overlap; trim whitespace; drop empty. (Keep simple/deterministic; token-accurate chunking is YAGNI.)
- Step 4: PASS. Step 5: Commit
feat: text chunking.
Task 2.4: Index/reindex/delete + search
Files: Create app/rag/index.py, Test tests/rag/test_index.py
- Step 1 (test): using
db_session+ a fake embedder (monkeypatchembed_texts/embed_queryto deterministic small vectors):index_source(session, source)extracts→chunks→embeds→storesKnowledgeChunkrows and sets sourcestatus="indexed".search(session, "query", k=2)returns the most similar chunks (cosine), highest first.delete_source(session, id)removes chunks. On extraction error, sourcestatus="error"anderrorpopulated. - Step 2: FAIL. Step 3 (impl):
index_source(uses extract+chunk+embed_texts),searchcomputes cosine in-Python over stored vectors when dialect != postgres, else uses pgvector<=>operator ordering; returns[(chunk, score)].reindex_all. Wrap extraction in try/except → status error. - Step 4: PASS. Step 5: Commit
feat: rag index and search.
Task 2.5: pgvector integration test (guarded)
Files: Test tests/rag/test_pgvector.py
- Step 1 (test): skip unless
TEST_DATABASE_URL(postgres) set. Create extensionvector, run a real index+search, assert ordering matches the in-Python path. (Run locally/CI with a postgres service.) - Step 2-4: Implement guard + assertions; PASS or SKIP. Step 5: Commit
test: pgvector integration (guarded).
PHASE 3 — Shopify integration (token, client, proxy, products, orders, verification)
Task 3.1: ShopifyTokenManager (client_credentials, 24h refresh)
Files: Create app/shopify/token.py, Test tests/shopify/test_token.py
- Step 1 (test): respx mock POST
https://{shop}/admin/oauth/access_tokenreturns{access_token, expires_in: 86399}.await mgr.get_token()returns the token and caches it; a second call within expiry does NOT re-POST (assert respx call count == 1). Simulate clock past expiry (inject anow()callable) → re-POSTs. On 401 invalidate + refetch. - Step 2: FAIL. Step 3 (impl):
ShopifyTokenManager(shop, client_id, client_secret, now=...): caches(token, expires_at), refreshes whennow() >= expires_at - 60s; async lock to avoid stampede;invalidate(). - Step 4: PASS. Step 5: Commit
feat: shopify token manager with refresh.
Task 3.2: ShopifyGraphQLClient
Files: Create app/shopify/client.py, Test tests/shopify/test_client.py
- Step 1 (test): respx mock POST
https://{shop}/admin/api/{ver}/graphql.jsonwithX-Shopify-Access-Tokenheader asserted;await client.execute(query, variables)returnsdata; GraphQLerrorsin body → raisesShopifyError; HTTP 401 → calls token managerinvalidatethen retries once. - Step 2: FAIL. Step 3 (impl):
ShopifyGraphQLClient(shop, api_version, token_manager): posts query+variables with token header; onerrorsraise; on 401 invalidate+retry once; onTHROTTLED/429 backoff via tenacity readingextensions.cost.throttleStatus. - Step 4: PASS. Step 5: Commit
feat: shopify graphql client.
Task 3.3: App Proxy HMAC verification
Files: Create app/shopify/proxy.py, Test tests/shopify/test_proxy.py
- Step 1 (test):
verify_proxy_signature(query_params: dict, secret)— build params, compute the correct Shopify proxy signature (sorted params, concatenated withoutsignature, HMAC-SHA256 hex), assert valid passes and a tampered signature fails. Missingsignature→ False.
def test_proxy_signature_valid():
secret = "shh"
params = {"shop":"x.myshopify.com","path_prefix":"/apps/chat","timestamp":"1700000000"}
sig = _compute(params, secret) # helper in test mirrors Shopify spec
assert verify_proxy_signature({**params, "signature": sig}, secret) is True
assert verify_proxy_signature({**params, "signature": "bad"}, secret) is False
- Step 2: FAIL. Step 3 (impl): per Shopify App Proxy spec: remove
signature, sort keys, join ask=v(comma-join multi-values) concatenated, HMAC-SHA256 with secret,hmac.compare_digestagainst provided hex. - Step 4: PASS. Step 5: Commit
feat: app proxy hmac verification.
Task 3.4: search_products
Files: Create app/shopify/products.py, Test tests/shopify/test_products.py
- Step 1 (test): fake GraphQL client returns a
productsconnection;await search_products(client, "treadmill")returns list of{title, price, available, url, description}mapped from nodes. Empty →[]. - Step 2: FAIL. Step 3 (impl): GraphQL
products(first:5, query:$q)selecting title, handle, onlineStoreUrl, description(truncate), variants(price, availableForSale). Map to dicts; compute min price + any-available. - Step 4: PASS. Step 5: Commit
feat: shopify product search.
Task 3.5: orders — lookup + tracking parsing
Files: Create app/shopify/orders.py, Test tests/shopify/test_orders.py
- Step 1 (test): fake client returns an
orders(query:"email:...")payload with one order incl. fulfillments/trackingInfo/displayFulfillmentStatus.await find_orders_by_email(client, "a@b.c")returns parsed orders incl.name(order number),financial_status,fulfillment_status.parse_tracking(order)returns{status, tracking:[{company,number,url}], estimated_delivery, delivered_at}. Double-quote the email in the query (assert query string containsemail:"a@b.c"). - Step 2: FAIL. Step 3 (impl): GraphQL query from spec §using
orders(first:10, query:$q, sortKey:CREATED_AT, reverse:true)with fulfillments{displayStatus, estimatedDeliveryAt, deliveredAt, inTransitAt, trackingInfo{company number url}}. IfSHOPIFY_READ_ALL_ORDERSfalse, rely on default 60-day window. Helpersfind_orders_by_email,find_order_by_email_and_number,parse_tracking. - Step 4: PASS. Step 5: Commit
feat: shopify order lookup and tracking.
Task 3.6: Identity verification state machine
Files: Create app/verification.py, Test tests/test_verification.py
- Step 1 (test):
verify(session, email, order_number, orders)where orders contains a match (email+number) → returnsVerifyResult(ok=True), setssession.verified=True.- No match →
ok=False, incrementsverify_attempts, generic message (no field-specific leak), and aftermax_attemptssetslocked_untiland returnslocked=True. is_locked(session, now)True whilelocked_untilin future.- Logged-in trust:
mark_trusted(session)sets verified without attempts.
- Step 2: FAIL. Step 3 (impl): pure functions over
ChatSession; case-insensitive email compare; order number normalized (strip leading#); generic failure text constant; lockout via settings. - Step 4: PASS. Step 5: Commit
feat: identity verification state machine.
PHASE 4 — Tools, prompts, orchestrator, escalation
Task 4.1: Tool registry + ToolSpecs
Files: Create app/tools/registry.py, app/tools/knowledge_tool.py, app/tools/products_tool.py, app/tools/order_tool.py, Tests tests/tools/...
- Step 1 (tests, one per tool):
knowledge_tool: given fakesearchreturning chunks,run(args)returns concatenated context + sources list.products_tool: wrapssearch_products, returns compact product list.order_tool: requiressession; if not verified and args lack email/order_number → returns a "need verification" structured result asking for email+order number; with valid args and a matching fake order → returns tracking summary; on lockout → returns generic locked message; never returns address/payment fields.registry.specs()returns 3ToolSpecs with correct JSON-schema params;registry.dispatch(name, args, ctx)routes to the right tool.
- Step 2: FAIL. Step 3 (impl): each tool a small async callable
run(args, ctx)wherectxcarriessession, dbsession, shopify client.registryholds specs + name→callable. JSON-schema params:search_knowledge{query},search_products{query},lookup_order{email?, order_number?}. - Step 4: PASS. Step 5: Commit
feat: tool registry and tools.
Task 4.2: Prompt builder
Files: Create app/prompts.py, Test tests/test_prompts.py
- Step 1 (test):
build_system_prompt(brand)includes: reply-in-customer-language rule, persona/brand name, the verification policy (ask email+order number, reveal only shipment status), honesty + escalation rule, and tool-use guidance. Assert key phrases present and brand name interpolated. - Step 2: FAIL. Step 3 (impl): template returning a string with the rules above; pt-PT hint; "never reveal which field failed verification"; "if you cannot help, offer to pass the question to the team and ask for their email".
- Step 4: PASS. Step 5: Commit
feat: system prompt builder.
Task 4.3: Escalation + mailer
Files: Create app/escalation.py, app/mailer.py, Test tests/test_escalation.py
- Step 1 (test):
create_escalation(session, email, question, sender)calls a fakesenderwith the storesupport_email, subject incl. session id, body incl. question+customer email; returns confirmation text. Mailer tested via a fake SMTP (assertsendmailcalled with right args) — inject SMTP client. - Step 2: FAIL. Step 3 (impl):
escalation.create_escalation(...)builds message and calls injectedsender.mailer.SmtpMailer(settings)withsend(to, subject, body);senderdefault wires to it. No-op/log if SMTP unconfigured. - Step 4: PASS. Step 5: Commit
feat: human escalation via email.
Task 4.4: Orchestrator (chat turn + tool loop)
Files: Create app/orchestrator.py, Test tests/test_orchestrator.py
- Step 1 (test): with a
FakeRouterscripted to: (turn 1) return atool_callsforsearch_knowledge, (turn 2) return final content.run_turn(ctx, session, "¿envíos?"):- persists user + assistant messages,
- executes the tool via registry (fake search returns context),
- feeds tool result back, gets final reply,
- returns
ChatResponse(reply, lang, used_tools=["search_knowledge"]). - Second test: order intent unverified → tool returns need-verification → final reply asks for email+order number;
used_toolsincludeslookup_order. - Guard: max 4 tool iterations then force final answer.
- Step 2: FAIL. Step 3 (impl): load short history (last N msgs), build messages = [system, ...history, user]; loop: call
router.chat(tier="large", tools=registry.specs()); if tool_calls, dispatch each, append tool results as{"role":"tool",...}, continue; else return content. Detect language is delegated to the model (system rule); storelangif model annotates, else heuristic. Cap iterations. - Step 4: PASS. Step 5: Commit
feat: chat orchestrator with tool loop.
PHASE 5 — HTTP routes, app wiring, admin, widget, deploy
Task 5.1: health + app factory + lifespan
Files: Create app/main.py, app/routes/health.py, Test tests/routes/test_health.py
- Step 1 (test):
GET /healthz→ 200{"status":"ok"}. - Step 2: FAIL. Step 3 (impl):
create_app()sets up settings,init_engine, includes routers, lifespan warms embedder (skippable via env in tests). GDPR retention: addpurge_old_sessions(session, days)(deletesChatSession/ChatMessageolder thansession_retention_days), invoked lazily on startup and opportunistically per chat turn. Addtests/routes/test_retention.pyasserting old sessions are purged and recent ones kept. - Step 4: PASS. Step 5: Commit
feat: app factory and health route.
Task 5.2: POST /apps/chat (proxy-verified)
Files: Create app/routes/chat.py, Test tests/routes/test_chat.py
- Step 1 (test): with proxy verification monkeypatched/valid signature + a fake orchestrator bound via dependency override:
- valid signature + body
{message}→ 200ChatResponse; creates a session if nosession_id. - invalid signature → 401.
- logged-in customer signal (
logged_in_customer_idquery param present & non-empty) → session marked trusted (assert verification trust path used).
- valid signature + body
- Step 2: FAIL. Step 3 (impl): verify proxy signature from query params; load/create session; if
logged_in_customer_idpresent →mark_trusted; call orchestrator; return response. Dependencies (get_router,get_shopify_client,get_session) overridable for tests. - Step 4: PASS. Step 5: Commit
feat: chat endpoint behind app proxy.
Task 5.3: Admin API (auth) — sources CRUD + reindex + config
Files: Create app/routes/admin.py, Test tests/routes/test_admin.py
- Step 1 (test): requests without
Authorization: Bearer <admin_token>→ 401. With token:POST /admin/sources(url) → creates source, kicks index (background or sync in test) → 201 with id.POST /admin/sourcesmultipart file upload → stores + indexes.GET /admin/sources→ lists with status.DELETE /admin/sources/{id}→ removes + deletes chunks.POST /admin/reindex→ reindexes all.PUT /admin/config→ updates branding/flags.
- Step 2: FAIL. Step 3 (impl):
Depends(require_admin)checks token; endpoints use rag.index functions; file saved to a temp/work dir then indexed; indexing runs viaBackgroundTasksin prod, synchronously whenTESTING. - Step 4: PASS. Step 5: Commit
feat: admin api for knowledge sources and config.
Task 5.4: Alembic migrations + pgvector
Files: Create alembic.ini, migrations/env.py, initial revision
- Step 1:
uv run alembic init migrations; wireenv.pytoBase.metadata+ async engine +DATABASE_URL. - Step 2: Author initial migration:
CREATE EXTENSION IF NOT EXISTS vector;then all tables (embedding columnVector(384)on pg). - Step 3: Test (guarded by
TEST_DATABASE_URL): runupgrade headagainst postgres, assert tables exist. - Step 4: Run migration locally if postgres available, else document. Step 5: Commit
feat: alembic migrations with pgvector.
Task 5.5: Admin UI (minimal vanilla SPA)
Files: Create app/admin_ui/index.html, serve via a route; Test tests/routes/test_admin_ui.py
- Step 1 (test):
GET /admin/(with token via query for the page load, then JS uses Bearer) returns HTML containing the words "Fuentes" and a file input + URL input. - Step 2: FAIL. Step 3 (impl): single HTML page with fetch() calls to the admin API (token entered in a field, stored in memory): list/add/delete sources, paste URL, upload file, edit branding. No build step.
- Step 4: PASS. Step 5: Commit
feat: minimal admin UI.
Task 5.6: Chat widget + Theme App Extension
Files: Create extension/shopify.app.toml, extension/extensions/chat-widget/shopify.extension.toml, blocks/chat.liquid, assets/widget.js; Test tests/test_widget_assets.py
- Step 1 (test): a Python test asserts
widget.jsexists, contains afetch("/apps/chat"call and posts{message, session_id}, persistssession_idinlocalStorage, and reads block settings (data attributes).chat.liquidcontains a{% schema %}withtarget: "body"(app embed) and settings:backend_path(default/apps/chat),brand_name,brand_color,welcome. Assert these keys present. - Step 2: FAIL. Step 3 (impl):
chat.liquid: app embed block rendering a container with data-* from settings and{{ 'widget.js' | asset_url | script_tag }}; passeslogged_in_customer_id: {{ customer.id }}into the widget config.widget.js: render bubble + panel; on send POST tobackend_pathwith{message, session_id}and append?logged_in_customer_id=when known; localStorage session; typing indicator; language is whatever the user types (backend handles).shopify.app.toml: name, scopesread_orders,read_products,read_customers,[app_proxy] url, subpath="chat", prefix="apps".
- Step 4: PASS. Step 5: Commit
feat: chat widget and theme app extension.
Task 5.7: README, .env.example finalize, run docs
Files: Modify README.md, .env.example
- Step 1: Document: local run (
uv run uvicorn app.main:app), tests (uv run pytest), Shopify app setup (Dev Dashboard, scopes, app proxy, install, client credentials), Railway deploy, env vars, admin usage, LLM keys (Groq + Cloudflare), paid upgrade path,read_all_ordersflag. - Step 2: Commit
docs: readme and env docs.
Task 5.8: Railway deployment config
Files: Create railway.json/Procfile/Dockerfile as needed, nixpacks config
- Step 1: Provide start command
uvicorn app.main:app --host 0.0.0.0 --port $PORT, ensure Postgres provisioned +DATABASE_URL, runalembic upgrade headon release. Pin model download at build (optional) or warm at first boot. - Step 2: Document Railway steps in README (use the use-railway skill at deploy time). Step 3: Commit
chore: railway deployment config.
Final verification (run before declaring complete)
-
uv run ruff check .→ clean. -
uv run pytest -q→ full suite green (assertions positive, no skips except the postgres-guarded ones when no PG). - Run the app locally;
curl /healthzok; with a signed proxy request, a chat turn returns a reply using a mocked/real LLM key. - Spec coverage check: every spec §1-§9 requirement maps to a task (see self-review below).
- Code review pass (requesting-code-review skill) before final commit/tag.
Self-Review (plan vs spec)
Spec coverage:
- §1/§2 info+order flows → Tasks 4.1 (tools), 4.4 (orchestrator), 5.2 (chat route). ✓
- §2 GraphQL/token/scopes/proxy/LLM/embeddings/Railway → Tasks 3.1–3.3, 1.4–1.6, 2.1, 5.8. ✓
- §3 components → all mapped (widget 5.6, proxy 3.3, orchestrator 4.4, tools 4.1, indexer 2.4, admin 5.3/5.5, token 3.1, db 1.2/5.4). ✓
- §4 flows incl. logged-in trust + email+order# → Tasks 3.6, 4.1 (order_tool), 5.2. ✓
- §5 security/GDPR (HMAC, admin auth, generic errors, lockout, retention, no-train LLM, local embeddings) → Tasks 3.3, 5.3, 3.6, (retention: add purge in 5.1 lifespan note), 1.4–1.6, 2.1. ✓
- §6 resilience (token refresh, failover, 60-day, throttle, escalation, free caps/routing) → Tasks 3.1, 1.6, 3.5, 3.2, 4.3, 4.4(tier routing). ✓
- §7 data model → Task 1.2 + 5.4. ✓
- §8 deploy → 5.4, 5.7, 5.8. ✓
- §9 testing → tests in every task + Final verification. ✓
Gaps fixed inline: Session retention purge — add to Task 5.1 lifespan (a periodic/lazy purge of sessions older than session_retention_days). Add a note there.
Type consistency: ChatResult/ToolCall/ToolSpec used identically across 1.3–1.6, 4.1, 4.4. search(session, query, k) signature consistent 2.4↔4.1. verify(...)/mark_trusted consistent 3.6↔5.2↔4.1. ✓