A user clicks Buy on a 4K Smart TV. Instantly, the storefront doesn’t just suggest a soundbar - it inspects their previous purchase of a laptop and surfaces a portable solar charger as a complement. That intent-aware jump from rule-based recommendations to LLM-shaped suggestions is the heart of this build.

This post is the full journey of SmartShop, our reference storefront. Everything you read about here lives in the public repo gitlab.com/nurazhar/lithan_smartshop. Code, branches, configs, demo screenshots, and a one-liner to bring the whole stack up.

Goal of the post: by the end, you should be able to fork the repo, paste a Gemini key, and demo the entire flow to a stakeholder in under a minute.


Diagram 1 - What SmartShop actually is

Architecture Diagram

Two services, one persistent volume for media, an optional AI leg. Everything else is convention.

Diagram 1a - Complete request lifecycle

Architecture Diagram

This is the loop that runs from cold start to a personalised recommendation in under a second.

Diagram 1b - System trust boundaries

Architecture Diagram

Trust is explicit: the frontend never sees the API key, the backend never trusts a client-supplied user_id, and the LLM is strictly optional.


1. Starting from zero

When you sit down to build a “real-feeling” storefront in 2026, the temptation is to spin up five managed services and a CI pipeline before writing a single model. The discipline of this project is the opposite: keep the dependency surface to things that run on a developer laptop with zero accounts.

Our “must-haves” list, ranked by what hurts when it’s missing:

Anything that ate more than a day of yak-shaving was a candidate to defer.

Diagram 2 - The minimum viable stack

Architecture Diagram

Two runtimes, three pinned dependencies, one optional AI leg. We add Postgres, Redis, a queue, or a managed object store only when the SQLite boundary starts to hurt.


2. Stack choice and architecture trade-offs

Why Django REST over FastAPI

Boring-on-purpose wins here. DRF ships with token auth, an ORM, a migrations engine, a permissions layer, browsable API, and a serialiser abstraction. For a demo with nine endpoints, that’s the entire backend library. FastAPI is excellent when you need async streaming and 10k req/s; we don’t, and we’d be writing the auth scaffolding ourselves if we used it.

Why Vite + React over Next.js

A static SPA is the right fit when the only data source is JSON over HTTP, and when SSR/SEO doesn’t matter for the use case (an authenticated dashboard demo). Vite gives us a 200 ms dev-loop on a 50-file React app; Next.js would have asked us to model routing, layouts, and edge caching we don’t need.

Why SQLite (deliberately)

For a portfolio/demo, SQLite means: zero config, single file you can rm and reseed, full Django ORM semantics, and instant cold start in Docker. The day we need concurrent writes, we promote to Postgres behind the same DATABASES setting.

Diagram 3 - The physical layout

Architecture Diagram

A bind mount on backend/ is intentional - it lets us edit models and see them without rebuilding. Production would inject the image instead.

Note (added post-audit): the docker-compose.yml for the published repo now wires every required env into the backend container: DJANGO_SECRET_KEY is declared required (the service refuses to start if it is unset, via ${VAR:?...} syntax), and DJANGO_DEBUG, DJANGO_ALLOWED_HOSTS, DJANGO_CORS_ALLOWED_ORIGINS, GEMINI_API_KEY, OPEN_API_KEY, DJANGO_ADMIN_RESET_TOKEN, DJANGO_SETTINGS_JSON, throttle rates, and reseed-path overrides are all routed. The previously-quiet docker fallback to a hardcoded dev SECRET_KEY is gone.


3. Backend foundation - scaffolding the shop

Models in five lines

The data model is two tables, both pragmatic. No soft-delete columns, no audit trails, no created_by/updated_by. The demo lives or dies on visibility, not forensic evidence.

backend/smartshop/models.py

That’s the entire schema.

Diagram 4 - Entity-relationship

Architecture Diagram

One user, many purchase orders. One product, many purchase orders. The recommender reads order history per user; the recommend endpoint takes user_id as a path parameter.

URLs and views - the API surface

backend/smartshop/urls.py

Diagram 5 - API controller map

Architecture Diagram

Function-based views on purpose. CBVs shine when you reuse mixins; we don’t.

Auth in two endpoints

register_user and login_user are the only authentication endpoints. Both create/return DRF authtoken tokens. The frontend stores the token in component state (we deliberately accept the demo-grade trade-off - in production we’d promote to httpOnly cookies + same-site strict).

Note (added post-audit): the /api/buy/ endpoint originally looked up the buyer from a user_id field in the request body - a textbook IDOR pattern. It is now IsAuthenticated and uses only request.user; any client-supplied user_id is ignored. Quantity is validated as a positive integer in the [1, 1000] range, and unhandled errors no longer leak str(e) to the response body.

Diagram 6 - Registration sequence

Architecture Diagram

The login endpoint is identical except it calls authenticate() instead.

Diagram 6a - Purchase-to-recommendation causal chain

Architecture Diagram

Every Buy mutates the recommendation surface for exactly one user. No fan-out, no cron, no cache invalidation - the query is cheap enough to run on demand.


4. Frontend - React + Vite UI shell

A 600-line App.jsx covers the entire demo UI. Three reasons we resisted splitting it into Catalog.jsx, Cart.jsx, Login.jsx, … :

  1. The cognitive load of jumping across six files for a beginner reader is real.
  2. State is genuinely shared (the current user token affects header, cart, recommender all at once).
  3. A portfolio reader can land on App.jsx and follow the entire user journey top-to-bottom.

In a real app this would split into routes (/login, /catalog/:productId, /cart). For the demo, single-file wins.

Glue: a small state bag

The React side carries four top-level states:

Diagram 7 - Frontend state lifecycle

Architecture Diagram

The state machine helps when reading App.jsx - every conditional render maps to one of these nodes.

Glassmorphism without a design system

The look - frosted header, soft shadows, animated buttons - is six CSS custom properties plus a @keyframes rule. We add nothing to the bundle for it. Two lessons:


5. Integrating AI - hooking up Gemini

The reason we picked Gemini (and not the other big three)

Free tier, fast flash variant, and a generous multimodality opening for a future image-features demo. The kicker, though, is the model family list we wire in views.py:

# Indicative ordering; per-view lists differ in views.py.
models_to_try = ["gemini-1.5-flash", "gemini-2.0-flash", "gemini-flash-latest",
                 "gemini-1.5-pro",
                 "gemma-2-27b-it", "gemma-2-9b-it"]

When a quota error hits one model, we walk the list. The UI shows whichever model answered (or “rule_fallback” if none did). That’s both a graceful degradation pattern and a fidelity signal - the user sees the truth, not “AI dust”.

Where the key lives

Two places, both deliberate:

  1. .env on the backend - read by Django at startup. Kept for boot-time configuration.
  2. In-app admin bar - lets the demo speaker add a key at runtime without restarting the Docker container. The backend persists the in-app input to a runtime settings file.

Diagram 8 - Secure API key storage flow

Architecture Diagram

The “red dot -> green dot” UI pill is intentional feedback. The user trusts the toggle more than a console log.

Diagram 8a - API key precedence and fallback chain

Architecture Diagram

This precedence chain means the demo never hard-fails on quota: the speaker can keep clicking Buy even when Google returns 429.


6. The recommendation engine - the cascade

The point of an LLM-shaped recommender is that it can read intent from a query, not just categories. We solve it as a cascade.

Path A - catalog rules (offline-safe)

Look at the user’s most-recent purchase, find products in the same category that they haven’t bought yet, top up with popular items. Cheap, deterministic, fast. Always available.

Path B - Gemini assists (online-only)

Render a strict prompt:

“Here are the user’s last purchases. Here is the candidate catalog. Return a comma-separated list of product NAMES that complement the user’s owned items.”

Then we post-process: difflib.get_close_matches(ai_name, real_names) to map any hallucinated name back to a real DB row. This is critical - LLMs hallucinate product names. Mapping them back is the second half of the integration.

Diagram 9 - The recommendation cascade

Architecture Diagram

method is the UI fidelity bit. It returns gemini (gemini-1.5-flash), gemini_quota_exceeded, gemini_error (4xx: ...), or rule_fallback. The frontend shows the badge verbatim.

Diagram 10 - LLM strict-prompting protocol

Architecture Diagram

When you trace this in real terms, “context” is just a string with a shape. The discipline is in shaping it.

Diagram 10a - Gemini fallback model rotation

Architecture Diagram

The 6-model list isn’t just belt-and-braces - it’s a deliberate cost/latency cascade. gemini-1.5-flash is tried first because it’s cheapest; we only escalate if it fails.


7. Smart semantic search (with offline fallback)

LIKE '%query%' was the V1 find-by-name behaviour. It’s fine for known keywords but misses the entire intent layer. “warm jackets” ought to find coats and hoodies, not literally the word “warm”.

Online: let the LLM return product IDs

We feed Gemini the catalog as [{id, name, category}, ...], ask it to return comma-separated IDs (not names). Parsing IDs is robust to hallucination - any non-existent ID simply doesn’t match a row in the category slice we render.

Offline: a curated synonyms map

For cold querying, a small dictionary ships with the demo:

The LLM does better, but the offline fallback does okay enough to keep the demo credibly working in airplane mode.

Diagram 11 - Search paths

Architecture Diagram

Each path has a different latency profile; the offline path is <50 ms, the LLM path is ~700 ms. We log both so the demo spokesperson can decide which to showcase.

Diagram 11a - Search endpoint decision tree

Architecture Diagram

Why IDs instead of names for search? Because 42, 17, 3 either matches rows or it doesn’t. There’s no fuzzy-match ambiguity.


8. Dynamic product details & synthesised pros/cons

Click a product card and a detail panel opens. Some bits are deterministic (the image, the specs column). The AI bit is a button labelled “Analyse with AI”. Click it, and /api/products/<pk>/ai-details/ returns:

{
  "summary": "Compact 4K HDR display for small rooms.",
  "pros": ["... 2-line bullet", "... 2-line bullet"],
  "cons": ["... 2-line bullet", "... 2-line bullet"]
}

We force the LLM to return a JSON block (with a regex scrape if needed). Pure-text outputs are rejected - the UI demands structure.

Diagram 12 - Forcing structured JSON out of the LLM

Architecture Diagram

Two failure modes for free: - LLM emits code fences; we strip them - LLM refuses to JSON; we degrade gracefully

Either way, the UI never breaks.

Diagram 12a - AI details request with caching rationale

Architecture Diagram

Why no server-side cache? Because the demo runs on SQLite and the product set is ~50 rows. A Redis layer would be a yak-shave we don’t need; the LLM latency is acceptable for a stakeholder demo.


9. The in-app shopping assistant

A floating bubble at the bottom-right opens a chat panel. The user types a question (“What electronics do you recommend?”). The frontend POSTs to /api/chatbot/, the backend composes a system prompt that injects the catalog as context, and the LLM responds conversationally.

The bot is strictly read-only against the catalogue - it cannot perform actions. That keeps the trust boundary obvious.

Diagram 13 - Chatbot async state

Architecture Diagram

The asymmetry between the happy path and the error path is important. Errors don’t bubble as red toast - they degrade to a polite “Here are some popular items:” so the user never feels stranded.

Diagram 13a - Chatbot context assembly

Architecture Diagram

The catalog is injected as a system preamble so the bot can answer “Do you have anything under $50?” without a database round-trip.


10. Infrastructure & dev experience

Docker Compose, one command

docker-compose.yml builds two services: backend (Python 3.12-slim) and frontend (Node 20-alpine + Vite dev server). The bind mounts are subtle - they tolerate container rebuild, but they expose dev code paths in the host for editing without going through docker cp.

Diagram 14 - Docker-Compose topology

Architecture Diagram

Three intentional choices: - restart: always - the demo screenshot taken two hours later still works - volumes: ./backend:/app - editing models in the host reflects immediately - /app/node_modules named volume - host edits don’t blow away the deps

Diagram 14a - Docker image layer composition

Architecture Diagram

Layers are cached aggressively. Changing views.py reuses the pip layer; changing package.json reuses the node layer. Rebuilds after a single-file edit are typically under 20 seconds.

The reseed button (a tiny magic trick)

The top admin bar has a button labelled “Reset Database”. Click it, and the entire catalogue + orders are wiped and re-seeded from two SQL files (Products.sql, PurchaseOrder.sql). The backend uses connection.cursor() on the raw sqlite connection to run the script, which Django’s ORM doesn’t otherwise expose.

Diagram 15 - The reseed round-trip

Architecture Diagram

The “log in again” toast is a tiny budget: we know the demo user wants to re-paste creds or hop accounts. We tell them rather than letting the UI silently 401.

Why use raw SQL for seeding (a confession)

seed_db.py reads SQL files via connection.connection.executescript(). Trade-offs:

For a demo that lives forever in SQLite, that’s a fine trade.

Diagram 15a - Reseed SQL file paths and env overrides

Architecture Diagram

The env-driven paths mean the published repo never hardcodes ~/ ... - a post-audit fix.


11. Shipping open source - the zero-to-one release

The repo goes from local folder to public GitHub. The step that matters most is the security scrub - skipped at your peril. The original draft of this section described five concrete actions; after the post was written the team ran a public-grade audit and found several gaps that those five actions alone did not catch. What the published repo currently ships (commits 00d5ad1 -> 8903a1d -> 1625a91 -> 27e6991) goes further than the list below.

Five concrete actions (the original v1 checklist; aim higher than these today):

Action 1 - what NOT to commit

Action 2 - what to commit instead

Action 3 - inject SECRET_KEY through env, keep dev fallback

The default django-insecure-... SECRET_KEY Django ships is a publicly-known dev value. Two changes:

SECRET_KEY = os.getenv(
    'DJANGO_SECRET_KEY',
    'django-insecure-DO-NOT-USE-IN-PRODUCTION',
)

The fallback preserves every developer’s “runs-from-fresh-clone” experience. Production overrides the env var.

Action 4 - verify with a live secret sweep

Before pushing to GitHub, sweep the staging tree for any AIza..., sk-..., ghp_... pattern. With .gitignore in place these should all return zero matches. Sweep the remote after pushing to confirm - GitHub’s tree API lets us decode and grep every blob.

Action 5 - choose visibility deliberately

Public repos are great for portfolios, but if the project has any environment-specific secrets we missed, private is fine, and GitHub lets you flip later. We picked public here for the demo - and committed to the live-sweep afterwards.

Diagram 16 - Open-source release checklist

Architecture Diagram

The loop is real. We’ve needed two passes on real projects - the first push, the secret scan callout, the patch, the second push.

Diagram 16a - Security audit findings mapped to code changes

Architecture Diagram

Each finding maps to one or more code-level fixes in the published repo.


12. What this all buys you

A portfolio reader who clones the repo and pushes a button gets:

A senior engineer reading the codebase gets a small, opinionated answer to “how do you wire an LLM into a CRUD app without surrendering the engineering quality”: constraints, cascade, failover, named state, public release scrubbed and verified.

Diagram 17 - What changed when AI was bolted on

Architecture Diagram

Each rung adds optionality. Never optionality on top of a broken baseline - always optionality on top of a working baseline.

Diagram 18 - Full-system data flow for the assessor

Architecture Diagram

This is the complete wiring diagram. Every arrow is a real code path.


13. Closing notes for the curious

The full source: gitlab.com/nurazhar/lithan_smartshop.

Bring your own Gemini key, run docker compose up --build, and try the “Analyse with AI” chip on the 4K Smart TV. The rest will look after itself.

Update: post-launch security audit

Posted 2026-06-18: after this blog was drafted, the SmartShop repo was reviewed against a public-grade security audit. The original “Shipping Open Source” section marketed the security scrub as a clean set of five actions. The audit found real gaps in what those five actions actually covered; the remediations below are now live on nurazhardotcom/lithan_smartshop main.

Findings vs remediations

Audit finding Code state now
Hardcoded SECRET_KEY fallback (django-insecure-...) settings.py raises django.core.exceptions.ImproperlyConfigured on missing DJANGO_SECRET_KEY. Dev-only opt-in requires DJANGO_ALLOW_INSECURE_DEV_KEY=1 plus DJANGO_DEBUG=True, and even then the fallback is the explicit marker 'django-insecure-DO-NOT-USE-IN-PRODUCTION' rather than the original Django literal.
DEBUG = True hardcoded DEBUG = _env_bool("DJANGO_DEBUG", "False")
ALLOWED_HOSTS = [] env-driven allowlist with safe Docker-aware dev defaults
CORS_ALLOW_ALL_ORIGINS = True explicit allowlist via DJANGO_CORS_ALLOWED_ORIGINS; the open-CORS escape hatch requires DJANGO_CORS_ALLOW_ALL=1
IDOR: purchase_product honoured a client-supplied user_id now IsAuthenticated, uses only request.user, validates quantity as int in [1, 1000]
reseed_db, manage_settings, and buy were @permission_classes([AllowAny]) reseed_db and manage_settings are IsAdminUser; buy is IsAuthenticated; reseed_db has an optional X-Admin-Token double-gate when DJANGO_ADMIN_RESET_TOKEN is set
reseed_db/seed_db.py hardcoded ~/ ... fixture paths env-driven RESEED_PRODUCTS_SQL / RESEED_PURCHASE_SQL (configurable) with safe BASE_DIR/smartshop/fixtures/*.sql defaults; missing files are tolerated by reseed_db with a warning
settings.json written to a hardcoded BASE_DIR/settings.json routed through settings.SETTINGS_JSON_PATH (configurable via DJANGO_SETTINGS_JSON)
No rate limiting on auth endpoints @ratelimit(key='ip', rate='5/m', method='POST', block=True) on register_user; @ratelimit(...rate='10/m', ...) on login_user. DRF defaults 60/min anon and 240/min user, both env-tunable
Several views returned Response({"error": str(e)}) replaced with logger.exception(...) + generic {"error": "Internal error"} body
docker-compose.yml was not passing secret env into the backend container now declares DJANGO_SECRET_KEY as required via ${VAR:?...} and wires DEBUG, ALLOWED_HOSTS, CORS, throttle rates, optional Gemini/OpenAI/admin-reset-token, settings-JSON path, and reseed-path overrides

Demo-grade shortcuts still in the repo

These are intentional - they power the one-click demo flow that the talk hinges on - and they are documented in the SmartShop README under Security Notes for Reviewers. Replace them before any non-demo use:

What you do before deploying this anywhere real

  1. Generate a fresh DJANGO_SECRET_KEY and put it in .env. The README has the one-liner: python -c "import secrets; print(secrets.token_urlsafe(64))".
  2. Set DJANGO_ADMIN_RESET_TOKEN so even an is_staff=True user cannot trigger reseed alone.
  3. Override the seed passwords via SEED_ADMIN1_PASSWORD / SEED_ADMIN2_PASSWORD env vars, or just delete the demo accounts entirely.
  4. If you cloned the repo before the audit commits landed, rotate anything ever derived from the historic SECRET_KEY literal.

Why this post is still worth reading

The architecture narrative (Django REST + React + Vite + Gemini), the AI cascade (history check -> Gemini cascade -> category fallback), the difflib.get_close_matches hallucination-mapper, the model fallback chain (gemma-2-27b -> gemini-1.5-flash -> …), the Docker topology, and the open-source release checklist shape - all of that is still correct. What the audit forced was the operational stance: configuration was a precondition, not an ideal. The post captures the shape; the remediations make the shape deployable.

Full source lives at gitlab.com/nurazhar/lithan_smartshop; live security posture lives in its README under “Security Notes for Reviewers”.