跳至主要內容

14 / WORK / CASE STUDY

四時煮食時 — AI 食療推薦與健康管理平台

2024 年科工館「教育部 113 年度 AI 健康應用計畫」的作品:BERT 實體標記食譜推薦、健康追蹤與剩食訂購;近期以 AI 輔助完成資安稽核與全面重構,開源上架為作品集。

TYPE
AI TOOLING
SCOPE
AI 健康應用計畫 · 自建 → AI 輔助重構
STACK
DJANGO · DRF · BERT · PYTORCH · REACT · VITE · TAILWIND
LINKS
LIVE

01 / PROBLEM

四時煮食時是 2024 年參與科工館「教育部 113 年度 AI 健康應用計畫」的專題:把個人化食譜推薦、日常健康管理(卡路里/飲水/運動追蹤)、營養知識專區與「剩食訂購」結合成一個「吃得健康、不浪費食物」的平台。推薦引擎的核心是一個 BERT 實體標記(NER)模型——把使用者的自然語言需求拆成食材、營養、慢性病、過敏原等 12 類實體,再對食譜資料庫做知識查詢。兩年後,目標是把這個學生時期的專題翻新成能公開展示、且資安過關的作品集。

02 / CONSTRAINTS

  • 三個公開 repo(Django REST 後端、React 前端、PyTorch BERT 模型)。誠實揭露分工:原始專案(2024)由本人於計畫期間開發;近期這輪資安稽核與全面重構為 AI(Claude)輔助完成,每一項修復皆對應到可查證的 commit,數字不灌水。
  • 展示環境沒有 MySQL、沒有 GPU、也沒有原始訓練資料,demo 仍必須完整可跑。
  • 前端全部重寫後,仍要與既有 Django API 契約一字不差地對齊。

03 / ARCHITECTURE

一次跨三個 repo 的稽核以多代理平行進行,產出 175 項經對抗式驗證的發現(4 項 Critical),再依 P0 資安 → P1 正確性 → P2 架構的順序修復,落地為 54 個原子 commit、26 個回歸測試(後端 13、前端 8、模型 5)全數綠燈。

後端 Django 5 + DRF + SimpleJWT,四個領域 app(會員、食譜、紀錄、健康管理);BERT 為 bert-base-cased token classification、12 類 BIO 標籤,產出的 state_dict 供後端推論。前端從停止維護的 Create React App 全面重寫為 Vite + TypeScript + Tailwind + shadcn,綠色食療設計系統、11 個頁面、記憶體內權杖與集中式 API service 層。

04 / RESPONSIBILITIES

2024 年於計畫期間獨立開發原始三件式(後端 API、前端、BERT 模型)。2026 年這輪的資安稽核、後端硬化、前端重構與模型修復為 AI(Claude)輔助完成,分三個 repo 推進,commit 歷史與測試結果皆可查證。

05 / CHALLENGES → SOLUTIONS

CHALLENGE

後端有兩個可直接利用的高風險漏洞:任何登入者能透過 ModelViewset 讀取、修改或刪除全部會員(IDOR),且忘記密碼只憑 email + 生日就能重設任意帳號密碼。

SOLUTION

把會員端點收斂為僅限本人的自助動作、移除自動 CRUD;忘記密碼改為簽章式一次性權杖流程並加上限流與不洩漏帳號存在與否;全域權限預設由「同時掛 IsAuthenticated 與 AllowAny(等於全開)」收斂為單一 IsAuthenticated。這些修復都寫成回歸測試(13 個,全綠)。

CHALLENGE

食譜是平台主功能,卻每一次回應都 500——序列化器對一張從未被填入的屬性表呼叫 .get(),必然拋 DoesNotExist。

SOLUTION

把 .get() 改為 .filter().first() 並在無資料時回傳空屬性,連同 week_record、會員更新、健康目標計算等一連串必炸端點一併修好,讓核心流程真正能回資料。

CHALLENGE

BERT 訓練腳本的 argmax 取在序列軸而非標籤軸,訓練直接崩潰;準確率又被除以恆為 2 的長度、且測試集同時被當驗證集(資料洩漏)——看似在訓練,數字卻毫無意義。

SOLUTION

修正 argmax 軸、拆出互斥的 train/val/test、以有效 token 正確計算準確率、換用 AdamW + 排程器、加上 seqeval 的實體級 F1 與固定亂數種子,並統一 state_dict 存/載契約。附一組小樣本 fixture,讓 python train.py 能在 CPU 上真正跑完一個 epoch 並存回可載入的模型。

CHALLENGE

前端 API base 硬編碼成一條早已失效的 ngrok 通道,任何人 clone 下來畫面全是錯誤;而作品集又需要在完全沒有後端的 GitHub Pages 上完整展示。

SOLUTION

重寫時內建「Demo 模式 / API 模式」切換:Demo 模式以 MSW 攔截每一支 API,回傳一整套繁中種子資料(食譜、營養、健康、剩食),讓網站在 GitHub Pages 上無需後端即可完整運作;前端 service 層對後端 18 個端點逐一比對,契約 18/18 對齊。

06 / SYSTEM FACTS

175

稽核發現(4 Critical)

54

原子 commit

26

回歸測試全綠

3

repo:API · 前端 · 模型

07 / LESSONS

指標算錯比沒有指標更危險:BERT 的 argmax 取錯軸,讓訓練看起來在跑、印出的準確率卻毫無意義。模型類的「綠燈」一定要回頭確認算的是不是對的東西,而不是只看它有沒有印出數字。

要讓舊專案變成能公開展示的作品集,先決條件是它「能在沒有後端的情況下獨立跑起來」:一層 seed/mock 模式比任何視覺翻新都更早該處理,否則 demo 連載入都失敗。

重寫前端時,最該先鎖住的是與後端的 API 契約:先把 18 個端點逐一對齊,再重畫畫面,串接才不會在重構後悄悄斷掉。

08 / SCREENS

以下畫面皆為示範(mock)資料,不含任何真實使用者資料。

健康總覽頁:飲水/熱量/運動三個進度環、連續達標卡、今日目標完成度徑向圖、可切換指標的近 7 日趨勢圖、本週紀錄表與個人化建議
登入後的健康總覽。三個環顯示的是「還差多少」而不是已完成量,7 日趨勢可切換指標並疊上目標線;整頁資料由 repo 內建的 demo 模式(MSW 攔截 23 支 API)供應,不需要任何後端。
食譜搜尋頁:自然語言輸入框、時段/料理/健康條件三組共 20 個篩選標籤,以及 8 張食譜卡片
食譜搜尋。輸入框接的是整條 BERT 管線的入口——正式模式下這句中文會被翻成英文、切成 12 類實體再組成 ORM 查詢;左側 20 個標籤則對應後端 frontendQuery.json 的分類。預設列出種子資料 12 筆食譜中的前 8 筆。
食譜詳情頁:主圖、標籤、時間/步驟/食材數摘要、食材清單、編號步驟、三大營養素甜甜圈圖與完整營養標示
食譜詳情——食材、編號步驟與營養標示。頁首那張沙漠夜景不是挑錯圖:種子資料用 picsum.photos 依 id 取隨機照片,所以「蒜香檸檬烤雞胸」配到了一片沙漠。這是程式真實渲染的結果,我沒有為了截圖去換掉它。
剩食專區:四格影響力數字、店家搜尋與距離排序、類別分頁,以及 8 筆帶折扣標籤、取餐時段與距離的即期品項卡片
剩食專區。頂端四格中,可預約品項、合作店家與平均折扣都是即時由清單算出的,只有「可減少浪費」是品項數乘上一個固定常數(320 克/件)——估算式的展示文案,不是量測值。另一件該講清楚的:Django 後端至今沒有任何 /Surplus/ 端點,這一整頁只活在 demo 模式的 mock 層裡。
首頁全頁:漸層主視覺、三大核心功能卡、當令食譜與知識專區橫排,以及影響力數字與 Recharts 面積圖
首頁全頁,也是綠色食療設計系統最完整的一次展開。底部那四個數字(12,000+ 會員、8.2 噸)是寫死在 Landing.tsx 的展示文案,不是統計;旁邊的面積圖同樣讀種子資料——會特別做 prefers-reduced-motion 處理,是因為全頁截圖會讓 Recharts 重掛動畫。
健康總覽頁的 390px 行動版:三個進度環改為直向堆疊、快速記錄按鈕撐滿寬度,連續達標與目標完成度卡片接續在下方
同一張健康總覽在 390px 寬。桌機版並排的三個環改為直向堆疊、快速記錄按鈕撐滿寬度,原本在右欄的連續達標與目標完成度卡片接到主欄下方——排版重排而不是等比縮小。

09 / ARCHITECTURE DIAGRAMS

以下架構圖皆由專案原始碼推導繪製,每個節點都可回溯到實際檔案。圖較寬,可左右捲動,或點擊開啟原尺寸 SVG。

POST /Recipe/get/ 的流程圖:翻譯、BERT 標記、B-/I- 併段、篩選標籤合流、組成兩個 Q 物件並以 rid 相交,含兩條寫死的 fallback 路徑
一句中文如何變成 ORM 查詢:先由 GoogleTranslator 翻成英文(一次對外 HTTP),再交給 BERT 切出 12 類實體,B-/I- 併成片語後與篩選標籤合流,最後組成兩個 Q 物件——實體側查 recipe_recipe_ob、屬性側查 recipe_recipe_at,以 rid 相交後取前 3 筆。圖上兩個紅框是這張圖最該看的地方:標不出任何實體、或查詢結果為空時,程式回傳寫死的三個食譜 id。
12 類 BIO 標籤與五層規則串接的流程圖,包含詞典片語比對、單字詞典與靜態關鍵字表,以及整列被丟棄的條件
12 個標籤是怎麼被標上去的。五層規則依序覆寫:數字 → 食材詞典片語 → 食譜標籤詞典片語 → 單字詞典 → 靜態關鍵字表,後面的規則蓋掉前面的。只有 ING 與 TAG 有 I- 延續標籤,num 完全不帶前綴——這正是推論端必須特判它的原因。標到最後整列只剩 O 與 num 的訓練資料會直接丟掉。
字詞級 BIO 標籤對齊 WordPiece 的流程圖,以實跑範例顯示 -100 遮罩如何套用在續接片段與特殊 token 上
整個模型最容易寫錯的一步,用實跑的例子畫出來:fixture 第 2 行「i am allergic to peanuts and milk」中,peanuts 被切成 p / ##eanut / ##s,只有第一個片段拿到 B-ALG,其餘連同 [CLS]/[SEP]/[PAD] 一律填 -100,讓 CrossEntropy 完全略過——所以子詞與 padding 既不貢獻 loss,也不進準確率的分母。推論時同一套走訪改成輸出遮罩,過濾 logits 後保證「一個字一個標籤」。
BERT 專案的五階段管線圖:語料前處理、已入版控的 fallback 資料、微調、產出權重與推論、以及跨 repo 的下游消費端
模型從語料到下游的全貌,也順帶說明這個 repo 為什麼 clone 下來就能跑。左段的 Kaggle Food.com CSV 與 zjy1.xlsx 都在 .gitignore 裡,所以中段那兩個 fallback 才是實際入口:40 行手寫的 train.jsonl 是預設資料集,沒有權重時 demo.py 就直接拿它就地微調。右端跨出本 repo——DB_search 的契約在這裡只有一份 import 不到 Django 的參考副本。
python demo.py 跑一題的時序圖:載入權重或就地微調的分支、tokenize、對齊遮罩、logits argmax、抽出 typed spans,以及交給另一個 repo 的邊界
一題問句從 CLI 到食譜 id 的完整時序。上半段的 alt 分支是這個專案最實用的設計:有權重就 torch.load state_dict,沒有就退回用 40 行 fixture 就地微調 25 個 epoch,讓沒有任何前置作業的人也能看到東西。下半段被標成 BOUNDARY——spans 交給另一個 repo 的 Django 後端,那段在本 repo 裡跑不起來,圖上如實畫成跨界。
後端系統架構圖:middleware 鏈、DRF 全域預設(JWT、權限、限流)、六組路由 viewset、規則引擎與查詢服務層、SQLite/MySQL 切換,以及虛線標示的選用 ML 模組
Django 後端的全貌:middleware 鏈、DRF 全域預設(JWT HS256、預設 IsAuthenticated、匿名 30/min、密碼重設 5/hr)、六組路由 viewset,以及規則引擎與 DB_query 的服務層。右下角刻意畫成虛線的是選用模組——BERT 推論與 Kaggle 匯入都靠 requirements-ml.txt 額外安裝,而且權重目錄 recipe/BertModel/trained/ 在 repo 裡根本不存在。實際跑起來的資料庫裡,三張食譜表都是 0 列。
核心領域 ER 圖,使用真實表名與欄位名:會員四張一對一表、每日事件表與彙總表、題庫作答表,以及食譜三張表之間沒有外鍵的關係
真實的表名與欄位名——是從 sqlite 的 PRAGMA 讀出來的,不是照 models.py 抄。三件只有攤開 schema 才看得到的事:recipe_recipe_ob 與 recipe_recipe_at 之間沒有任何外鍵,靠 Python 以 rid 相接;Member_healthtarget 的外鍵指向 Member_member 而不是認證用的 Member_memberp;email 的唯一性只在序列化器把關,資料庫層沒有約束。
BMI 推導每日目標的流程圖:註冊交易與延遲補算兩個入口、純 Python 規則引擎的三種分派、BMI 區間表,以及寫入 HealthTarget 的計算式
BMI 怎麼變成每日的飲水/熱量/運動目標。兩個入口:註冊時在單一 transaction.atomic 內連建四張表,或首次呼叫 /HManage/Personal/ 時延遲補算。規則引擎是一張純 Python 的 BMI 區間表——用來取代在 Python 3.10+ 已經 import 不了的 experta——回傳的是 dict 複本,呼叫端動不到規則表本身。圖上也標了那個 /100 修正:身高沒先換算成公尺前,每個人的 BMI 都趨近 0。
每日健康紀錄的時序圖:一次 JWT 驗證後的寫入(含缺鍵 400 分支與強度加權),以及一次彙總讀取(當日時區窗、目標補算、週狀態碼)
一次寫入加一次彙總讀取的時序。JWT 以 account 宣告解析使用者,運動分鐘依強度乘 2/5/7 的權重累進當日彙總列;讀取端以 Asia/Taipei 的當日區間過濾而不是裸 UTC,並在還沒有目標時就地呼叫 BMI 規則補算一份。週狀態 0–3 的判定條件(整週無紀錄、當日無紀錄、落在寬限值內、熱量另外再 ±200)也一併標在圖上。

NEXT

HelmetDetect 工地安全帽偵測平台