霧中前行, 第 5 篇: Ahmad 兩者都是
「霧中前行」第五篇 — 一個關於開發 Ridgeline 的系列,Ridgeline 是為 Outward Bound Hong Kong 打造的補給決策引擎。第四篇講了熱量不足的問題 — 正確的數字揭示了原有餐單只提供了青少年遠征所需熱量的一半。
數不到的計數
第一版飲食系統很簡單。一個情境有一組飲食組合:halal: 2, vegetarian: 1。14 人中有 3 人有飲食限制。計算器對照這些數字檢查每種食材,然後產生警告:「D1 的豬肉漢堡扒不是清真 — 影響 2 人。」
然後來了一個假設:如果一個人同時是清真又是素食呢?
輸入為 halal: 1, vegetarian: 1?那就是兩個有限制的人。但其實只有一個人。團隊人數是 14,不是 15。補給清單按 14 人計算 — 但系統以為一個人需要清真替代品、另一個人需要素食替代品,而實際上兩種要求必須在同一碟餐中同時滿足。
匿名計數無法表達交叉的飲食需求。一個同時是清真和素食的人需要的食物必須同時滿足兩個限制。系統需要知道這些標籤屬於同一個人 — 也就是說,它需要知道人的資料。
從計數到姓名
這次重構分兩個階段在 2 月 25 日完成,在寫任何程式之前先做了設計文件(docs/plans/per-person-dietary-profiles.md)。
第一階段(5c7f942):用具名參加者取代匿名計數。新增 scenario_participants 表:
create table public.scenario_participants (
id uuid primary key default gen_random_uuid(),
scenario_id uuid not null references public.scenarios(id) on delete cascade,
name text not null,
dietary_flags text[] not null default '{}',
created_at timestamptz not null default now()
);
不再是 halal: 2, vegetarian: 1,而是參加者卡片:Ahmad(halal、vegetarian)、Fatima(halal)、其餘 12 人無限制。舊的 scenario_dietary_mix 表保留了下來以維持向後相容 — 計算器先檢查參加者,如果沒有才回退到舊的計數。
警告變得有人名了。不再是「影響 2 人」,系統現在說「D1 的豬肉漢堡扒不是清真 — 影響 Ahmad、Fatima。」名字改變了對話的性質。一個補給主任看到「影響 2 人」可能會掃一眼就過。「影響 Ahmad」意味著一個具體的孩子沒晚餐吃。
芝麻奶問題
在為 Big 9 過敏原建立資料(cff3b35)的時候,出現了一個分類衝突。Hart Limited 的匯入把「芝麻奶」列為一種食材。ingredient_category 枚舉中包含 dairy。dietary_flags 中包含 dairy_free。
芝麻奶是飲品(category: beverage),不是乳製品(category)。它被標記為 dairy_free 因為它不含牛奶。但如果有人同時把 dairy 用作分類和飲食標籤,系統就會把芝麻奶標記為乳製品同時又是不含乳製品 — 自相矛盾。
修正方案在 ADR-004 中明確記錄:
ingredient_category= 補給角色(你怎麼採購、儲存和運輸它)dietary_flags= 過敏原和飲食屬性(誰可以吃它)
這是兩個不同的問題。「這個放在補給室的哪裡?」跟「Ahmad 可以吃這個嗎?」不是同一回事。
同一次審查還發現了其他標籤錯誤。椰奶標了 nut_free — 但 FDA 把椰子歸類為堅果過敏原。芝麻奶標了 sesame_free,完全反了。蛋麵標了 egg_free。每一個都是安靜的數據 bug,只有在某個孩子有過敏時才會出事。
Vegan 包含 Vegetarian
數據裡還藏著另一個假設:vegetarian 標籤沒有放在純素食品上。
豆腐標了 vegan 但沒標 vegetarian。大豆蛋白(TVP)標了 vegan 但沒標 vegetarian。如果 Ahmad 是素食者,系統不會向他推薦豆腐 — 因為豆腐沒有 vegetarian 標籤,即使每種純素食物按定義都是素食。
修正(3f975db)分兩部分。一個 migration 將 vegetarian 補加到所有 vegan 食材上(以及像芝士粉和奶粉這類屬於素食但不是純素的乳蛋製品)。計算器也加入了階層對應:
const DIETARY_IMPLIES: Record<string, string[]> = {
vegan: ['vegetarian'],
}
// Expand ingredient flags with implied flags
const expandedFlags = new Set(ingredient.dietary_flags)
for (const flag of ingredient.dietary_flags) {
const implied = DIETARY_IMPLIES[flag]
if (implied) {
for (const f of implied) expandedFlags.add(f)
}
}
雙重保險 — 數據本身是正確的,但程式也處理了隱含關係,以防有人忘記在新的純素食材上加 vegetarian。
第二階段:自動替代
警告是有用的。但一個補給主任看到「豬肉漢堡扒不是清真 — 影響 Ahmad」之後,仍然要自己想出該買什麼替代品。第二階段(96aa165)讓系統來做這件事。
新的 ingredient_alternatives 表將食材連結到替代品,按優先順序排列:
Pork Burger → Halal Sausage (priority 0), TVP (priority 1), Tofu (priority 2)
Ham → Halal Sausage (priority 0), Tempeh (priority 1), Tofu (priority 2)
Luncheon Meat → Halal Sausage (priority 0), Lentils (priority 1)
計算器將參加者分組。對於每個 per_person 的食材,它檢查誰可以吃原版、誰需要替代品。computeSubstitutionGroups 函式:
for (const p of participants) {
const unsatisfied = p.dietary_flags.filter(f => !ingredientFlags.has(f))
if (unsatisfied.length === 0) {
mainNames.push(p.name)
continue
}
// Find best alternative satisfying ALL of this participant's flags
const best = findBestAlternative(alternatives, p.dietary_flags)
if (best) {
subMap.set(best.alternative_ingredient_id, { ... })
} else {
noAlternativeNames.push(p.name)
mainNames.push(p.name) // better to have food than no food
}
}
Ahmad 同時是清真和素食。系統找出能同時滿足兩個要求的最高優先級替代品。Halal Sausage(priority 0)是清真但不是素食 — 跳過。TVP(priority 1)是純素(意味著素食),而且是清真。Ahmad 得到 TVP。
Fatima 只有清真。Halal Sausage(priority 0)是清真。Fatima 得到 Halal Sausage。
補給清單現在顯示:12 個豬肉漢堡扒(主要組別)、1 個 Halal Sausage(給 Fatima)、1 個 TVP(給 Ahmad)。而不是 14 個豬肉漢堡扒加一個沒人會處理的警告。
結構性的改變
這一篇是應用程式架構發生轉變的時刻。原來的模型是:數量進、數量出。飲食系統是事後檢查 — 先算好所有東西,再警告有問題。
新的模型是:數量進、人進、調整後的數量出。計算器現在在計算階段就進行分組和替代,而不是在顯示階段。這觸及了每一層:
- 資料庫: 新增參加者和替代品的表
- 計算器: 在主要補給迴圈中加入替代邏輯
- 工作台介面: 參加者卡片取代了計數下拉選單,替代品縮排顯示在原食材下方
- 新建情境表單: 參加者輸入取代了飲食組合
- 複製路由: 在複製其他情境資料時一併複製參加者
- CSV 匯出: 新增「Substitutes For」欄
十一個新測試。設計文件列出了每個需要修改的檔案和每個需要的測試。實作幾乎完全跟著計劃走 — 這種情況少見到值得一提。1
下一篇:第六篇 — Field Ready,軟件必須在 320px 的手機上、在一個補給主任不會用軟件思維思考的倉庫裡正常運作。
Footnotes
-
scenario_dietary_mix表從未被刪除。計算器先檢查參加者,再回退到舊的計數。這是刻意的 — 在重構之前建立的舊情境仍然可以使用。遷移壓力很低,因為系統尚未上線。 ↩