An AI shopping assistant over a scraped Flipkart catalogue, with a React frontend and FastAPI backend.
- 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.
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 agent (create_agent) picks one tool per step; 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 |
Saved items and orders are separate tools so the model doesn't confuse "which saved shoe is best?" with "buy it".
Design points:
- Tool output is shown verbatim. Product lists, ratings and prices stream straight from the tool. The model may add one linking sentence, dropped if it restates prices, ratings or links. Single-request messages stop after one tool with no extra model call.
user_idcomes from runtime context, never a tool argument, so the model can't invent or swap it. The raw message and transcript ride there too, so "save 2" resolves against the list on screen.- A tool is optional. Greetings and small talk get a direct answer; zero-tool turns are logged to spot missing capabilities.
- Routing is cached in middleware. A cache hit skips the model; the tool still runs and streams as usual.
Offline scripts (not in the request path): refresh_products.py, discover_products.py,
admin_ingest_faqs.py, evals/run_eval_v2.py and load_test.py.
| 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), 4 @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.txtFrom backend/, copy app/.env.example to 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) and are kept in tab-scopedsessionStorage. Closing the tab doesn't revoke the token 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.
- 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, multi-part messages and safety. gemini-2.5-flash answers
(gemini-2.5-pro as SQL fallback, gemini-3.1-flash-lite for follow-up rewrites and
splitting); a gemini-3.5-flash-lite DeepEval GEval judge scores them, and code
checks PII leakage. The figures merge 157 cases from the 2026-10-18 run with three
newer cases on the same models, so they are not a fresh 160-case run after the
compare-results tool was removed.
| 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 measures in-process execution, including model and database calls but excluding the job queue and client-to-server transit. Scores are LLM-judge, not human-calibrated. Run sources are listed in 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 workers × 20 job slots; the model is replaced by a 7.3s wait. Each virtual user logs in, browses every 5–10s, submits one message and polls it. Each level ramps 30s and holds 180s; k6 times out requests at 60s. Run from a laptop against Neon with about 0.8 GB free RAM, so these are not production capacity numbers.
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:
ruff+compileall+ a no-network unit test (tests/test_logging.py); no API keys or DB needed. - frontend:
npm ci+eslint+vite build. - docker: builds both images (cached, no push).
- deploy: on push to
mainafter the other three pass, triggers the Render deploy hooks (RENDER_DEPLOY_HOOK_*secrets; skipped 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.