第 5 篇2026-03-09

霧中前行, 第 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 中明確記錄:

這是兩個不同的問題。「這個放在補給室的哪裡?」跟「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 個豬肉漢堡扒加一個沒人會處理的警告。

結構性的改變

這一篇是應用程式架構發生轉變的時刻。原來的模型是:數量進、數量出。飲食系統是事後檢查 — 先算好所有東西,再警告有問題。

新的模型是:數量進、人進、調整後的數量出。計算器現在在計算階段就進行分組和替代,而不是在顯示階段。這觸及了每一層:

十一個新測試。設計文件列出了每個需要修改的檔案和每個需要的測試。實作幾乎完全跟著計劃走 — 這種情況少見到值得一提。1

下一篇:第六篇 — Field Ready,軟件必須在 320px 的手機上、在一個補給主任不會用軟件思維思考的倉庫裡正常運作。

Footnotes

  1. scenario_dietary_mix 表從未被刪除。計算器先檢查參加者,再回退到舊的計數。這是刻意的 — 在重構之前建立的舊情境仍然可以使用。遷移壓力很低,因為系統尚未上線。 ↩