An intelligent AI-powered e-commerce assistant built with a modern React frontend and FastAPI backend. Features agentic reasoning, secure authentication, and a clean light UI shared by the landing page and the app.
- Agentic reasoning β a LangChain agent routes each message to four tools: product search (text-to-SQL), FAQ (RAG), saved items, and cart/orders. It can chain several tools in one turn or reply without any.
- Swappable LLM provider β Gemini 2.5 Flash or Cloudflare Workers AI via
LLM_MODEL; shoppers can also bring their own key in Settings (stored encrypted). - Shop by photo β upload a shoe photo and get similar products. Non-shoe photos are refused, not answered with invented results.
- Cart & simulated orders β add to cart, place and cancel orders from chat. Out-of-stock items block the order instead of being dropped silently.
- Long-term memory β preferences like "I prefer Puma under 3000" are stored per user in Supermemory and recalled in later chats.
- Multi-part questions β "return policy and Nike under 3000" or "4 Nike and 5 Puma" are split and each part is answered.
- Honest answers β recommends only in-stock items, refuses filters the data can't support, and stays on shopping topics.
- Streaming & resilient jobs β answers stream over SSE from a queued job, so closing the tab or redeploying doesn't lose the answer. Users are scheduled fairly under load.
- Provider failover β a circuit breaker switches to the other provider after repeated failures.
- Bounded spend β daily token budget and per-user rate limits.
- Live product data β scripts refresh prices/stock and discover new listings from Flipkart's schema.org data.
- Quality tracking β a 160-question evaluation (
evals/run_eval_v2.py, DeepEval GEval) (see Evaluation Results).
Five layers, read top to bottom. Each arrow is a hand-off between layers; the shared services (data, external AI) are reached once per layer rather than by every tool, so the flow stays legible. Caching and observability are cross-cutting.
graph TD
User(["π€ Shopper"])
subgraph CLIENT ["1 Β· Client layer β React / Vite"]
UI["π¬ Chat UI Β· streamed answers Β· save Β· compare"]
Land["π¬ Landing page"]
end
subgraph APP ["2 Β· Application layer β FastAPI (main.py)"]
Auth["π Auth Β· bcrypt Β· JWT Β· login lockout Β· identity rate-limit"]
REST["ποΈ POST /message β 202 + job_id Β· GET /jobs/{id}/events (SSE)<br>poll Β· cancel Β· saved products"]
WORK["βοΈ Worker Β· claims from the Postgres queue<br>lease Β· reaper Β· token metering (worker.py)"]
REST -->|enqueue job| WORK
end
subgraph AI ["3 Β· Reasoning layer β LangChain agent"]
Mem["π§ Memory Β· rewrite query from history (memory.py)"]
Route["π§ create_agent Β· LLM picks tools in turn, or none<br>verbatim tool output Β· runtime ctx (agent.py)"]
Mem --> Route
Route --> SQL["π Text-to-SQL Β· read-only engine (sql.py)"]
Route --> FAQ["π FAQ Β· RAG (faq.py)"]
Route --> CMP["βοΈ Compare saved Β· user-scoped (compare.py)"]
end
subgraph DATA ["4 Β· Data layer β Neon Postgres + Pinecone"]
PG[("Postgres Β· product Β· chat_sessions/messages<br>ecommerce_accounts Β· saved_products Β· llm_cache<br>jobs Β· job_events")]
Pine[("Pinecone Β· FAQ vectors")]
end
subgraph EXT ["5 Β· External AI services β app default or shopper choice"]
Gem["βοΈ Google Gemini Β· 2.5 Flash<br>routing / SQL / FAQ / compare"]
CF["βοΈ Cloudflare Workers AI Β· gpt-oss-20b<br>routing / SQL / FAQ / compare"]
Emb["π’ Gemini embeddings Β· always, either way"]
end
Cache["ποΈ Caching Β· cross-cutting<br>Postgres llm_cache Β· SQL / FAQ / routing"]
OBS["π Observability Β· cross-cutting<br>Langfuse LLM traces + request logs"]
User --> CLIENT
CLIENT -->|HTTP + JWT| APP
APP -->|auth Β· sessions Β· saved| DATA
WORK -->|optimized query| AI
SQL -->|read-only SQL| PG
FAQ -->|semantic search| Pine
CMP -->|saved + live prices| PG
Route -->|generate Β· embed| EXT
AI -.->|hit / miss| Cache
APP -.->|request logs| OBS
AI -.->|LLM traces| OBS
The reasoning layer is a LangChain agent (create_agent) that routes each message
to four tools via the LLM, one at a time, (no rule-based routing β the model chooses,
which is what keeps it an agent). The tool docstrings are the routing prompt:
| Tool | Module | Routed when |
|---|---|---|
| Product search (text-to-SQL) | sql.py |
shopper asks about products β price, brand, rating, stock, "cheaper than X" |
| FAQ (RAG) | faq.py |
shopper asks about store policy β delivery, returns, payment, cancellation |
Saved items (add / remove / compare) |
compare.py |
anything about their shortlist β "save 2", "remove the Puma", "which of my saved is best" |
Cart & orders (add_results_to_cart / add_to_cart / place / cancel / view) |
orders.py |
the cart, placing or cancelling an order, or what they have ordered before |
| (no tool) | β | greetings, thanks, opinions, questions about themselves β ordinary conversation |
Actions are grouped into one tool each because they are the same kind of action on the same data. Saved items and orders stay separate on purpose: merging them is what makes a model confuse "which saved shoe is best?" with "buy it".
Four design points worth knowing:
- Tool output is shown verbatim; the model never re-types it. Product lists, rating counts, price-age and unsupported-filter notes stream straight to the shopper. The model only decides the next tool, one at a time, and may add one short linking sentence, which is dropped if it restates prices, ratings or links. A single request stops after its tool with no extra model call. A cart action in the same message resolves only against that message's results, and asks when there are none.
user_idtravels in the agent's runtime context, never as a tool argument β so the model cannot hallucinate one, or be talked into supplying someone else's, and read a stranger's shortlist. The raw message and the transcript ride there too: "save 2" means the second row of the list on screen, and neither the number nor the list survives the history-aware rewrite.- A tool is optional. When nothing fits, the model answers directly. Forcing a tool on every message is what made greetings get answered out of the returns policy; a zero-tool turn is logged instead, because a missing capability shows up as the model improvising rather than as an error.
- The routing decision is cached in middleware. A cache hit returns a synthetic tool call, so the model is never invoked, but the tool still executes and streams exactly as it otherwise would.
Offline/ops scripts sit alongside the request path, not in it: refresh_products.py
/ discover_products.py (keep the catalogue live), admin_ingest_faqs.py (push FAQ
vectors), evals/run_eval_v2.py (quality), and load_test.py
(responsiveness) β see Project Structure.
| Layer | Technology |
|---|---|
| Frontend | React 19 + Vite, Axios, React Markdown, Lucide Icons |
| Backend | FastAPI, Uvicorn, Pydantic, SlowAPI |
| Jobs | Postgres-backed queue (FOR UPDATE SKIP LOCKED), fair per-user claiming, depth-scaled concurrency |
| Agent | LangChain 1.x create_agent (LangGraph-backed), 3 @tools |
| AI Models | Gemini 2.5 Flash or Cloudflare Workers AI @cf/openai/gpt-oss-20b, with LLM_MODEL as the app-paid default and a per-user BYOK choice; Gemini 2.5 Pro as SQL fallback |
| Auth | JWT (python-jose), bcrypt |
| Database | PostgreSQL (Neon Cloud), SQLAlchemy ORM |
| Vector DB | Pinecone (gemini-embedding-001, 1024-dim) |
| Logging | Python logging module (structured, leveled) |
- Node.js (v18+) for frontend
- Python 3.10+ for backend
cd backend
pip install -r requirements.txtCopy backend/app/.env.example to backend/app/.env and fill in your credentials.
Shoppers can save their own Gemini key or Cloudflare credentials in Settings (encrypted, never returned by the API); a selected user-paid provider lifts the daily caps.
If setting up your own Neon database, run backend/migrations.sql in its SQL editor.
Run the server:
uvicorn main:app --port 8000cd frontend
npm install
npm run devCreate frontend/.env (the deployed URL is the fallback in api.js, so this is only for local dev):
VITE_API_BASE_URL=http://localhost:8000/apiOpen http://localhost:5173 in your browser.
The catalogue is scraped, so prices/stock drift. Both scripts are plain CLI, need no browser, and are safe to re-run (they process oldest-first):
python -m app.refresh_products --limit 500 # re-check prices/ratings/stock (2 workers)
python -m app.discover_products --query "running shoes for men" --pages 10 # find new products.github/workflows/refresh.yml runs the refresh nightly at 00:00 IST (--limit 500), and
can be triggered by hand. It needs one repo secret: DATABASE_URL.
Key endpoints (all under /api, JWT except auth and health; full list in backend/main.py or /docs):
| Method | Endpoint | Description |
|---|---|---|
POST |
/auth/signup, /auth/login |
Create account / login (5/min, 10/min per username) |
POST |
/chats/{id}/message |
Submit a message β 202 + job_id (30/min, Idempotency-Key supported) |
GET |
/jobs/{id}/events |
Stream the answer as SSE; ?after=<seq> resumes |
GET/POST |
/saved, /cart |
Saved items / cart, joined to live prices |
POST |
/orders |
Place an order from the cart; 409 if anything is out of stock |
PUT |
/account/provider |
Choose FREE, GEMINI or CLOUDFLARE (own credentials for paid) |
The message endpoint returns a job, not the answer; reconnect to /events?after=<seq> to resume. Photos go to the same endpoint as image (base64), image_mime and optional image_thumb. Every 429 carries Retry-After.
- SQL Injection Prevention: LLM-generated SQL runs on a read-only PostgreSQL engine (
SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY) - Password Security: bcrypt hashing with auto-migration from legacy SHA-256
- JWT Auth: HS256 tokens expire after 12 hours (
JWT_EXPIRY_HOURS). Login state uses tab-scopedsessionStorage: reload keeps the login, but closing the tab normally requires signing in again. Browser session restore can preserve tab storage; closing a tab does not revoke an issued JWT server-side. - Login Lockout: 5 failed logins within 15 minutes locks the username (DB-backed, holds across instances)
- Concurrency-Safe Writes: chat updates take a row-level lock so simultaneous messages can't overwrite each other
- Rate Limiting: signup 5/min and login 10/min per username; chat messages 30/min per user; provider selection and credential validation 10/min per user. Failed-login lockout remains separate.
- Input Validation: Pydantic validators reject malformed/oversized inputs before they reach business logic
- CORS: Whitelisted origins only
backend/evals/run_eval_v2.py runs 160 shopper-style questions (53 easy, 54 medium,
53 hard) in-process: product search, store policy, saved items, cart and orders,
follow-ups, photos, two requests in one message, and safety cases. DeepEval GEval
with a gemini-3.5-flash-lite judge scores the answers; code checks PII leakage.
App answers use gemini-2.5-flash (rewrite on gemini-3.1-flash-lite). The figures
below merge 157 cases from the 2026-10-18 run with three new cases run separately.
They are not a fresh 160-case run after the compare-results tool was removed.
All 160 cases:
| Latency (s) | TTFT mean (s) | Cost per query ($) | ||||||
|---|---|---|---|---|---|---|---|---|
| p50 | p95 | p99 | mean | p50 | p95 | p99 | mean | |
| 9.5 | 21.4 | 41.2 | 10.7 | 9.6 | 0.0011 | 0.0058 | 0.0121 | 0.0019 |
| Metric | Cases scored | Above threshold | Rate | Mean score |
|---|---|---|---|---|
| Answer completeness | 160 | 147 | 91.9% | 0.92 |
| Answer relevancy | 160 | 153 | 95.6% | 0.97 |
| Faithfulness (answers with product, FAQ or saved-item evidence) | 97 | 93 | 95.9% | 0.97 |
| Toxicity (non-toxic) | 8 | 8 | 100% | β |
| Scope adherence | 10 | 10 | 100% | β |
| PII leakage (leak-free) | 6 | 6 | 100% | β |
Latency is in-process (no queue or network). Scores are LLM-judge, not human-calibrated. Source runs: backend/evals/results_v2/run_2026-10-18_160_merged_sources.json.
Where one query's time goes β "Puma running shoes under 3000", third of three runs in one process, caches off:
| Step | Time |
|---|---|
| Off-topic check (keyword fast path) | 0.00s |
| Query rewrite (no history, skipped) | 0.00s |
| Memory recall (Supermemory) | 1.27s |
| Agent routing (model picks the tool) | 2.35s |
| SQL generation (model) | 1.85s |
| Postgres query (Neon) | 0.89s |
| Formatting + streaming | 0.94s |
| Total | 7.3s |
The first query in a fresh process took 16.2s: opening the Neon connection and the model clients. A route or SQL cache hit removes most of the two model steps.
cd backend
python evals/run_eval_v2.py --all # all 160 cases
python evals/run_eval_v2.py --ids Q001,Q151 # selected cases
python evals/run_eval_v2.py --all --trace # one Langfuse trace per case (environment=eval)Commands (run only with Render suspended for any job mode):
cd backend
python load_test_worker.py --render-suspended --jobs 40 --workers 2 # queue drainLocal k6 tests (2026-10-19, Render suspended). Real FastAPI, Neon queue and three separate workers Γ 20 job slots; the AI is replaced by a 7.3s wait, so no model is called. Each virtual user logs in once with the correct password, then browses chats, saved items, cart, orders and credits every 5β10s, submits one message and polls it. Stepped levels ramp for 30s and hold for 180s. k6's default request timeout is 60s. These numbers include laptop-to-Neon latency and were measured with about 0.8 GB of free RAM; they are not production capacity claims.
Load: 25β150 users.
| Users | Requests | Errors | Login (s) | Chats (s) | Submit (s) | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| p50 | p95 | p99 | p50 | p95 | p99 | p50 | p95 | p99 | |||
| 25 | 2,872 | 0% | 3.5 | 4.1 | 4.2 | 1.6 | 5.1 | 6.6 | 6.8 | 7.8 | 8.1 |
| 50 | 4,801 | 0% | 2.6 | 4.0 | 4.0 | 4.1 | 6.6 | 9.2 | 10.0 | 13.8 | 14.2 |
| 75 | 5,086 | 0% | 3.5 | 4.8 | 5.4 | 8.6 | 10.3 | 10.9 | 30.8 | 36.8 | 37.7 |
| 100 | 4,871 | 0% | 4.1 | 5.6 | 6.3 | 12.2 | 15.4 | 16.2 | 48.6 | 55.7 | 56.2 |
| 150 | 5,120 | 2.13% | 2.8 | 4.1 | 4.2 | 18.4 | 28.0 | 31.8 | 40.1 | 60.0 | 60.0 |
| Users | Jobs | Job ID received | Queue wait (s) | |||||
|---|---|---|---|---|---|---|---|---|
| Created | Succeeded | Errored | Clients | Expected | p50 | p95 | p99 | |
| 25 | 25 | 25 | 0 | 25 | 25 | 3.3 | 18.7 | 18.8 |
| 50 | 50 | 50 | 0 | 50 | 50 | 2.7 | 27.8 | 49.3 |
| 75 | 75 | 75 | 0 | 75 | 75 | 2.8 | 15.4 | 42.4 |
| 100 | 100 | 100 | 0 | 100 | 100 | 2.9 | 16.2 | 19.5 |
| 150 | 150 | 150 | 0 | 150 | 150 | 3.3 | 26.2 | 51.6 |
Before any job mode: suspend Render and stop other workers on the shared Neon queue (--render-suspended acknowledges this; the harness also refuses an active queue). Reports go to ignored backend/load_test_results/. Clean up an interrupted load_test.py run with --cleanup --cleanup-user.
GitHub Actions (.github/workflows/ci.yml) β four gated jobs on every push/PR:
- backend:
rufflint +compileallsyntax check + a no-network unit test (tests/test_logging.py). No API keys or DB needed, so it's fast and honest. - frontend:
npm ci+eslint+vite build. - docker: builds both images (no push) with GitHub Actions cache, so a broken
COPYor dependency breaks here rather than atcompose up. - deploy: only on push to
main, only after the other three succeed β triggers the Render deploy hooks (RENDER_DEPLOY_HOOK_*secrets; skips cleanly if unset).
Docker β docker compose up --build runs the API (backend/Dockerfile, uvicorn)
and the frontend (frontend/Dockerfile, Vite build β nginx) locally; Neon and
Pinecone stay remote. Images are non-root, healthchecked against /api/health, and
carry no secrets β credentials are injected at runtime via env_file.
βββ .github/workflows/ci.yml # CI: backend + frontend + docker build + deploy
βββ docker-compose.yml # Local stack: API + frontend (Neon/Pinecone remote)
βββ .dockerignore
βββ ruff.toml # Lint config (pinned default rule set; E402 for load-dotenv-before-import)
βββ tests/
β βββ test_logging.py # No-network unit test (request_id JSON logging)
β
βββ frontend/
β βββ Dockerfile # Vite build -> nginx static serve
β βββ nginx.conf # SPA routing + asset caching
β βββ src/
β β βββ components/
β β β βββ LandingPage.jsx # Marketing landing page (entry point)
β β β βββ landing.css # Landing page styles
β β β βββ Auth.jsx # Login/Signup with password requirements
β β β βββ Sidebar.jsx # Chats / Saved tabs, search, price-drop badge
β β β βββ ChatArea.jsx # Chat interface (SSE streaming, save buttons)
β β βββ api.js # Axios config with JWT interceptor
β β βββ App.jsx # Main app with session persistence
β β βββ index.css # Design tokens (:root) + app styles
β βββ package.json
β
βββ backend/
β βββ Dockerfile # python:3.12-slim, uvicorn, non-root, healthcheck
β βββ main.py # FastAPI app, auth, chat + saved endpoints
β βββ migrations.sql # Product indexes, scraped_at/availability/pid; drops legacy discount + index cols
β βββ evals/ # 160-question eval (DeepEval GEval judge + code checks)
β βββ load_test.py # API browse ramp, saturation and real calibration
β βββ load_test_worker.py # stubbed queue drain and multi-process claims
β βββ requirements.txt
β βββ app/
β β βββ agent.py # LangChain create_agent, 4 tools, multi-step routing + route-cache middleware
β β βββ llm_provider.py # Provider switch (LLM_MODEL): Gemini or Cloudflare chat model
β β βββ jobs.py # Postgres job queue: claim, lease, events, usage, reaper
β β βββ worker.py # The worker; runs the agent outside the request, scales with queue depth
β β βββ memory.py # Context-aware query optimization
β β βββ sql.py # Text-to-SQL pipeline (gemini-2.5-flash, Pro fallback)
β β βββ compare.py # Saved items: add from results, remove, compare; reference resolution
β β βββ orders.py # Cart + simulated orders; place/cancel shared with the HTTP endpoints
β β βββ order_history.py # Order summary (one query, totals and spend)
β β βββ memory_store.py # Long-term memory via Supermemory, scoped per user, fail-open
β β βββ cache.py # Postgres-backed LLM response cache
β β βββ observability.py # Optional Langfuse LLM tracing, fail-open
β β βββ logging_setup.py # Structured JSON logs correlated by request_id
β β βββ llm_utils.py # Retry/backoff for transient LLM errors
β β βββ refresh_products.py # Re-check prices/stock via JSON-LD
β β βββ discover_products.py # Find newly listed products
β β βββ faq.py # RAG pipeline with Pinecone (gemini-2.5-flash)
β β βββ admin_ingest_faqs.py # FAQ vector ingestion script
β β βββ db/
β β βββ database.py # SQLAlchemy engines (read-write + read-only)
β β βββ models.py # ORM models (EcommerceAccount, Chat, Message, SavedProduct, LLMCache, LoginFailure)
β βββ app/resources/
β βββ faq_data.csv # FAQ knowledge base
β βββ ecommerce_data_final.csv
β
βββ web-scrapping/ # Flipkart data collection scripts
MIT License β see LICENSE. Copyright (c) 2026 Vivek P Marakumbi.