Skip to main content

14 / WORK / CASE STUDY

Four Times for Cook — AI Recipe & Health Platform

Built for the 2024 National Science and Technology Museum × MOE “AI Health Application” program — BERT-tagged recipe recommendation, health tracking and food-surplus ordering; recently hardened and rebuilt with AI assistance, open-sourced as a portfolio piece.

TYPE
AI TOOLING
SCOPE
AI health program · self-built → AI-assisted rebuild
STACK
DJANGO · DRF · BERT · PYTORCH · REACT · VITE · TAILWIND
LINKS
LIVE

01 / PROBLEM

Four Times for Cook was my 2024 project for the National Science and Technology Museum × Ministry of Education “AI Health Application” program: a platform folding personalized recipe recommendation, daily health tracking (calories / water / exercise), a nutrition knowledge base and food-surplus ordering into one “eat well, waste less” product. The recommendation engine centers on a BERT named-entity model that parses a user’s natural-language request into 12 entity classes — ingredient, nutrition, chronic disease, allergen and more — then runs a knowledge query over the recipe database. Two years on, the goal was to turn that student project into a public, security-clean portfolio piece.

02 / CONSTRAINTS

  • Three public repos — a Django REST backend, a React frontend and a PyTorch BERT model. Attribution kept honest: the original project (2024) is my own work from the program; the recent security audit and full rebuild were done with AI (Claude) assistance, every fix tied to a verifiable commit, no numbers inflated.
  • The showcase environment has no MySQL, no GPU and no original training data, yet the demo still has to run in full.
  • After a full frontend rewrite, it still had to line up exactly with the existing Django API contract.

03 / ARCHITECTURE

A single audit across the three repos ran as parallel agents, producing 175 adversarially-verified findings (4 critical), remediated in a P0-security → P1-correctness → P2-architecture order and landed as 54 atomic commits with 26 regression tests (13 backend, 8 frontend, 5 model) all green.

The backend is Django 5 + DRF + SimpleJWT across four domain apps (member, recipe, record, health); the BERT model is a bert-base-cased token classifier with 12 BIO labels whose state_dict feeds backend inference. The frontend was fully rewritten from the unmaintained Create React App to Vite + TypeScript + Tailwind + shadcn — a green “food-as-medicine” design system, 11 pages, in-memory tokens and a centralized API service layer.

04 / RESPONSIBILITIES

Independently built the original three pieces (backend API, frontend, BERT model) during the 2024 program. The 2026 security audit, backend hardening, frontend rebuild and model fixes were done with AI (Claude) assistance across the three repos, with verifiable commit history and test results.

05 / CHALLENGES → SOLUTIONS

CHALLENGE

The backend had two directly exploitable flaws: any logged-in user could read, edit or delete every member through a ModelViewset (IDOR), and password reset changed any account’s password given only an email and birthdate.

SOLUTION

Member endpoints were narrowed to owner-only self-service with the auto CRUD removed; password reset became a signed single-use-token flow with throttling and no account-existence leak; the global permission default — which had listed both IsAuthenticated and AllowAny, i.e. fully open — was collapsed to a single IsAuthenticated. Every fix was captured as a regression test (13, all green).

CHALLENGE

Recipes are the platform’s core feature, yet every response 500’d — the serializer called .get() on an attributes table that was never populated, so it always raised DoesNotExist.

SOLUTION

Changed .get() to .filter().first() returning empty attributes when absent, and fixed a chain of always-crashing endpoints alongside it — weekly records, profile update, health-target computation — so the core flows actually return data.

CHALLENGE

The BERT trainer’s argmax reduced the sequence axis instead of the label axis, crashing training outright; accuracy was divided by a length that is always 2, and the test set doubled as the validation set (leakage) — it looked like it was training while the numbers meant nothing.

SOLUTION

Fixed the argmax axis, split disjoint train/val/test sets, computed accuracy over valid tokens, switched to AdamW with a scheduler, added seqeval entity-level F1 and a fixed seed, and unified the state_dict save/load contract. A tiny sample fixture lets python train.py actually complete one epoch on CPU and save a reloadable model.

CHALLENGE

The frontend’s API base was hard-coded to a long-dead ngrok tunnel, so anyone cloning it saw nothing but errors — and the portfolio still had to demo in full on GitHub Pages with no backend at all.

SOLUTION

The rewrite bakes in a Demo/API toggle: Demo mode intercepts every API call with MSW and returns a full set of Traditional-Chinese seed data (recipes, nutrition, health, surplus) so the site runs completely on GitHub Pages with no backend; the frontend service layer was checked against all 18 backend endpoints — 18/18 aligned.

06 / SYSTEM FACTS

175

findings (4 critical)

54

atomic commits

26

regression tests, all green

3

repos: API · web · model

07 / LESSONS

A wrong metric is more dangerous than no metric: the BERT argmax on the wrong axis made training look alive while the printed accuracy was meaningless. A model’s “green” must be checked for measuring the right thing, not just for printing a number.

Turning an old project into a public portfolio piece hinges first on running standalone with no backend: a seed/mock layer comes before any visual refresh — without it the demo fails to even load.

When rewriting a frontend, the API contract with the backend is what to lock first: aligning all 18 endpoints before redrawing screens keeps the integration from silently breaking after the refactor.

08 / SCREENS

All screens below show demo (mock) data — no real user data.

Health dashboard: water / calorie / exercise progress rings, a streak card, a daily goal radial, a 7-day trend chart with metric tabs, the weekly log table and personalised suggestions
The health dashboard behind login. The rings count down what is still missing rather than what is done, and the 7-day chart switches metric with the goal line overlaid. The whole page is fed by the repo’s built-in demo mode — MSW intercepting all 23 API routes — with no backend running.
Recipe search: a natural-language input, 20 filter chips in three groups (time of day / cooking method / health goal) and eight recipe cards
Recipe search. The text box is the front door of the whole BERT pipeline: in API mode that Chinese sentence is translated, split into 12 entity classes and turned into an ORM query. The 20 chips on the left mirror the backend’s frontendQuery.json categories; the default list shows 8 of the 12 seeded recipes.
Recipe detail: hero image, tags, a time/steps/ingredients summary, ingredient list, numbered steps, a macronutrient donut and a full nutrition panel
Recipe detail — ingredients, numbered steps and the nutrition panel. The desert at night is not a mis-picked image: the seed pulls `picsum.photos/seed/<id>`, an arbitrary photo per id, so a garlic-lemon roast chicken gets a desert. That is what the code actually renders, and I did not swap it out for the screenshot.
Food-surplus marketplace: a four-stat impact strip, store search with distance sorting, category tabs and eight discounted listings with pickup windows and distances
The surplus marketplace. Of the four stats on top, item count, partner stores and average discount are computed live from the listing set; only “waste avoided” is item-count times a fixed constant (320 g per item) — a display estimate, not a measurement. Worth stating plainly: the Django backend has no /Surplus/ endpoint at all, so this entire page currently lives only in the demo-mode mock layer.
Full-page landing: gradient hero, three feature cards, seasonal recipe and knowledge rails, plus an impact stat block with a Recharts area chart
The full landing page, and the widest view of the green “food is medicine” design system. The four figures at the bottom (12,000+ members, 8.2 tonnes) are display copy hard-coded in Landing.tsx, not statistics; the area chart beside them reads seed data too. It honours prefers-reduced-motion — which is what made a full-page capture possible, since resizing otherwise restarts the Recharts animation.
The health dashboard at 390px: progress rings stacked vertically, full-width quick-log buttons, with the streak and goal-completion cards following below
The same dashboard at 390px. The three rings that sit side by side on desktop stack vertically, the quick-log buttons go full width, and the right-column streak and goal cards fall into the main column — a reflow, not a scaled-down desktop layout.

09 / ARCHITECTURE DIAGRAMS

Every diagram below is derived from the project's actual source; each node traces back to a real file. They are wide — scroll sideways, or click to open the full-size SVG.

Flowchart of POST /Recipe/get/: translation, BERT tagging, B-/I- span merging, chip merging, two Q objects intersected on rid, and two hard-coded fallback paths
How one Chinese sentence becomes an ORM query: GoogleTranslator turns it into English (one outbound HTTP call), BERT splits out 12 entity classes, B-/I- spans are merged and joined with the filter chips, and two Q objects are built — the entity side against recipe_recipe_ob, the attribute side against recipe_recipe_at — intersected on rid, top three taken. The two red boxes are the part worth reading: when nothing is tagged, or nothing matches, the code returns three hard-coded recipe ids.
Flowchart of the 12-label BIO scheme and the five-rule cascade — gazetteer phrase matching, single-word vocabularies and static keyword lists — plus the row-drop condition
How the 12 labels get assigned. Five rules run in order and later ones overwrite earlier ones: numerics → ingredient-gazetteer phrases → recipe-tag phrases → single-word vocabularies → static keyword lists. Only ING and TAG have an I- continuation and `num` carries no prefix at all — which is exactly why the inference side special-cases it. A training row that ends up as nothing but O and num is discarded.
Flowchart of word-level BIO tags aligned to WordPiece tokens, with a real worked example showing the -100 mask on continuation pieces and special tokens
The single easiest step to get wrong, drawn from a real run: in fixture line 2, “i am allergic to peanuts and milk”, *peanuts* becomes p / ##eanut / ##s — only the first piece receives B-ALG, and every continuation piece plus [CLS]/[SEP]/[PAD] is set to -100 so CrossEntropy skips it. Sub-words and padding therefore contribute neither loss nor an accuracy denominator. At inference the same walk emits a mask instead, filtering the logits down to exactly one tag per input word.
Five-stage pipeline of the BERT repo: corpus prep, committed fallbacks, fine-tuning, the weight artifact and inference, and the cross-repo downstream consumer
The model from corpus to consumer — and why the repo runs straight out of a clone. The Kaggle Food.com CSVs and zjy1.xlsx on the left are both gitignored, so the two committed fallbacks in the middle are the real entry point: a 40-line hand-written train.jsonl is the default dataset, and with no weights on disk demo.py fine-tunes on it in memory. The right-hand stage leaves this repo entirely — the DB_search contract exists here only as a reference copy that cannot import Django.
Sequence diagram of one question through python demo.py: the load-weights-or-fine-tune branch, tokenization, the alignment mask, logits argmax, typed-span extraction and the hand-off boundary to the other repo
One question from the CLI to recipe ids. The alt branch at the top is the most practical decision in the repo: load a state_dict if weights exist, otherwise fall back to fine-tuning the 40-line fixture for 25 epochs in memory, so someone with zero setup still sees output. The lower half is marked BOUNDARY — the typed spans go to the Django backend in a different repo, and that stretch genuinely cannot run here, so it is drawn as a crossing rather than glossed over.
Backend architecture: the middleware chain, DRF global defaults (JWT, permissions, throttles), six routed viewsets, the rules-engine and query service layer, the SQLite/MySQL switch, and dashed optional ML modules
The Django backend end to end: the middleware chain, DRF global defaults (JWT HS256, IsAuthenticated by default, 30/min anonymous, 5/hour on password reset), six routed viewsets, and the rules-engine plus DB_query service layer. The dashed boxes bottom-right are deliberate — BERT inference and the Kaggle import both need the separate requirements-ml.txt, and the weights directory recipe/BertModel/trained/ does not exist in the repo at all. In a freshly migrated database, all three recipe tables hold zero rows.
Core domain ER diagram with real table and column names: the four one-to-one member tables, daily event and aggregate tables, the quiz tables, and the FK-less relationships between the three recipe tables
Real table and column names — read out of sqlite via PRAGMA, not transcribed from models.py. Three things only an expanded schema shows: recipe_recipe_ob and recipe_recipe_at have no foreign key between them and are joined in Python on rid; Member_healthtarget points at Member_member rather than the auth model Member_memberp; and email uniqueness is enforced only in the serializer, with no database constraint behind it.
Flowchart deriving daily targets from BMI: the registration transaction and the lazy-backfill entry point, the pure-Python rules engine’s three dispatches, the BMI interval table, and the HealthTarget write
How BMI becomes daily water / calorie / exercise targets. Two entry points: registration builds four tables inside one transaction.atomic, or the first call to /HManage/Personal/ backfills lazily. The rules engine is a plain-Python BMI interval table — written to replace experta, which no longer imports on Python 3.10+ — and it returns a copy of the dict so callers cannot mutate the rule table. The diagram also marks the /100 fix: before height was converted from cm to metres, every BMI came out near zero.
Sequence diagram of the daily health loop: one JWT-authenticated write (with the missing-key 400 branch and intensity weighting) and one aggregate read (today’s timezone window, target backfill, weekly status codes)
One write and one aggregate read, in order. JWT resolves the user from the `account` claim; exercise minutes accumulate into the day’s aggregate row weighted 2/5/7 by intensity. The read side filters on today’s Asia/Taipei window rather than naive UTC, and backfills a target on the spot by calling the BMI rules if none exists. The 0–3 weekly status codes are spelled out too: nothing all week, nothing that day, inside the slack thresholds, and calories additionally within ±200.

NEXT

HelmetDetect — Construction-Site Helmet Detection