iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

Part 4|第 13/30 篇
今日要做的事: 只替過敏與設備做 Hard 門禁,驗 crustacean、mollusk、客人過敏、共用鍋具與單口爐。
今天要解決的目的: 讓 DishFlow 在產生候選前後各驗一次安全;預算與時間留到 Day 14,不把所有 Hard 塞進同一篇。

冰箱裡同時有冷凍蝦與澎湖生凍花枝條時,「朋友對海鮮過敏」不是一個夠用的欄位。

蝦屬於 crustacean,花枝條的基底屬於 mollusk。兩包都擋掉當然安全,但那樣就等於沒有分類。


今日任務卡

項目 內容
產出 過敏 taxonomy、guest_allergens 疊加、設備檢查、前後兩次 validator
工具 自寫 Rule Engine 與固定 Golden fixtures
不使用 Gemini 安全判定、預算/時間規則、Soft 排名
驗收 蝦擋下、花枝條不被籠統連坐、交叉接觸有清洗、單口爐不並行

程式在 material/code/day12,只用 Node 跑,沒有呼叫 Gemini。


今天走的路

先拍冷凍蝦和澎湖生凍花枝條。蝦從包裝上的 SHRIMP 歸到 crustacean;花枝條的品名先歸到 mollusk,但這張還沒拍到過敏原標示,所以先追問,不放行。客人的限制只疊在這一次,不寫回我的長期資料。產生候選前把蝦拿掉,並記下 keep_frozen。今天還沒接模型,候選方案是手寫的;產生後再驗一次,步驟裡的蝦醬、沒洗的深鍋、兩個爐口同時加熱,都要擋。最後用 node test.js 跑完六項。

https://ithelp.ithome.com.tw/upload/images/20260927/20121052qXpHPZh6VR.png


1. 問題:Day 7 鎖資料,今天鎖推薦

Day 7 的問題是「別的帳號能不能讀到我的過敏資料」,答案由 Firestore Rules 負責。
今天的問題不同:

DishFlow 已經合法讀到過敏資料後,會不會仍把不安全的方案送上 Result?

前端看不到別人的文件,不代表推薦本身安全。
把兩題混在一起,會得到「權限測試通過,所以蝦料理可以上架」這種錯誤結論。

https://ithelp.ithome.com.tw/upload/images/20260927/2012105253YgFMLSm7.png

左邊是 Day 7:別人的帳號想讀我的過敏資料,Firestore Rules 看到 uid 對不上就不給讀。右邊是今天:資料是我自己合法讀到的,客人對 crustacean 過敏,候選方案一份一份進 validator。直接用蝦、步驟偷加蝦醬、深鍋煮過蝦沒洗就炒花枝,都會被擋下;只有鍋子洗過、花枝條標示也確認過的方案,才會上 Result。

過敏也不能只靠 Prompt。系列裡的四種做法,差在資料給多少、有沒有程式擋:

A   只有一句需求
B1  再加上手邊想到的食材
B2  貼上完整的當日 JSON
C   同樣的資料,再經過這支可以重跑的 validator

B1 可能只寫「朋友不吃蝦」。B2 就算把過敏資料貼齊,模型還是可能寫出蝦醬。C 要在程式裡擋下來,而且同一份輸入再跑一次,結果要一樣。四種做法的違反率留到 Day 24 再比,今天先把 C 的這道門做出來。


2. 設計:先分類,再處理器具狀態

2.1 客人的限制只疊在當次 session

我的 profile 與今天來吃飯的人要分開:

{
  "profile_allergens": [],
  "guest_allergens": ["crustacean"],
  "effective_allergens": ["crustacean"]
}

effective_allergens 由程式做聯集,不回寫 profile。
客人離開後,長期資料不應永久多一個過敏原。

2.2 crustacean 與 mollusk 分開

最小 taxonomy 先鎖:

shrimp / prawn / crab / lobster → crustacean
squid / cuttlefish / octopus   → mollusk

因此「蝦過敏」不能自動展開成所有海鮮。

我家冷凍庫的兩包是這樣:冷凍蝦包裝寫著 SHRIMP;旁邊那包是澎湖生凍花枝條,淨重 650 公克。

https://ithelp.ithome.com.tw/upload/images/20260927/20121052qJCvXotNo6.jpg

https://ithelp.ithome.com.tw/upload/images/20260927/20121052gcXVJ6BMPn.jpg

花枝條的主體可以對到 mollusk。但它是不是只含花枝、有沒有蝦粉或共線警語,要看包裝上的過敏原標示,這兩張都沒拍到那一欄。所以這包先標 needs_confirmation=true,不給一個 0.8 的信心分數硬放行。

2.3 共用鍋具是步驟限制

食材沒有蝦,不等於過程沒有交叉接觸。
方案要提供最小器具事件:

{
  "equipment_events": [
    { "equipment": "deep_wok", "contacts": ["crustacean"] },
    { "equipment": "deep_wok", "action": "wash" },
    { "equipment": "deep_wok", "contacts": ["mollusk"] }
  ]
}

上面這三筆事件的順序是:先碰蝦、再洗、再做花枝,這份可以過。拿掉中間的 wash,同一口鍋就不合格。

我當天的做法更單純:客人在的時候不處理蝦,冷凍蝦留著,改天自己吃。規則還是要能抓住「沒洗就接著做」,因為之後模型仍可能寫出這種步驟。

2.4 設備只看可行性

固定設備是:

induction_cooktop + deep_wok + tamagoyaki_pan + microwave
burners = 1

需要不存在設備的方案直接擋。
需要兩個爐口同時加熱的步驟也直接擋。我有兩個鍋,但只有一個爐口。

max_pots_willing=2 今天不進 Hard;它是 Day 13 的少洗鍋偏好。預算與時間留到 Day 14。


3. 實作/實跑:同一份規則驗兩次

兩道門共用同一份當日條件。客人過敏、家裡有哪些器具、幾個爐口,先收成這個物件,後面兩個函式都只讀它,不再各自猜:

{
  "blocked_allergen_groups": ["crustacean"],
  "allowed_equipment": [
    "induction_cooktop",
    "deep_wok",
    "tamagoyaki_pan",
    "microwave"
  ],
  "burners": 1,
  "requires_cross_contact_check": true
}

實作拆成三個檔。allergen-map.js 是品名對照表,validate.js 是前驗與後驗,test.js 是六個案例。下面只貼決定行為的段落;lookupAllergenKey 就是依序跑 NAME_RULES,回傳第一個對上的 key,ALLERGEN_MAP 在附錄 A。

3.1 品名對到過敏原群組

const NAME_RULES = [
  { key: "lobster", pattern: /龍蝦|lobster/i },
  { key: "prawn", pattern: /明蝦|prawn/i },
  { key: "crab", pattern: /螃蟹|蟹|crab/i },
  { key: "shrimp", pattern: /蝦|shrimp/i },
  { key: "cuttlefish", pattern: /花枝|cuttlefish/i },
  { key: "squid", pattern: /透抽|軟絲|squid/i },
  { key: "octopus", pattern: /章魚|octopus/i },
];

export function groupsForName(name) {
  const key = lookupAllergenKey(name);
  return key ? [...ALLERGEN_MAP[key]] : [];
}

比對的是整段文字,不是已經分好的欄位。「龍蝦」「明蝦」要排在「蝦」前面:/蝦/ 最寬,放前面會把「龍蝦」也配成蝦。回傳時複製一份陣列,呼叫端改到結果,不會改到對照表。

所以步驟裡的「蝦醬」「蝦米」會對到 crustacean。沒有出現這些字的「海鮮醬」,這張表抓不到,仍要看包裝標示。

3.2 第一道門:產生候選前

export function validateBeforeGeneration(session, pantry, allergenMap) {
  const profileBefore = JSON.stringify(session?.profile_allergens ?? []);
  const blocked = effectiveAllergens(session);
  const excluded = [];
  const candidateIngredients = [];
  const inventoryActions = [];
  const issues = [];
  for (const item of pantry ?? []) {
    const mapped = groupsForName(item.name);
    const labelGroups = item.label_allergen_groups ?? mapped;
    const mayContain = item.may_contain_groups ?? [];
    const knownGroups = unique([...mapped, ...labelGroups]);

    if (intersects(knownGroups, blocked) || intersects(mayContain, blocked)) {
      excluded.push({ item: item.name, groups: knownGroups, code: "ALLERGEN_GROUP_BLOCKED" });
      if (mapped.includes("crustacean")) {
        inventoryActions.push({ item: item.name, action: "keep_frozen", note: "改天沒有這位客人時自己吃" });
      }
      continue;
    }

    if (mapped.length > 0 && item.label_verified !== true) {
      issues.push({ code: "ALLERGEN_LABEL_UNVERIFIED", item: item.name, needs_confirmation: true });
      continue;
    }

    candidateIngredients.push({ item: item.name, groups: item.label_verified ? labelGroups : mapped });
  }

  if (JSON.stringify(session?.profile_allergens ?? []) !== profileBefore) {
    throw new Error("profile_allergens was mutated");
  }

  return {
    ok: issues.length === 0,
    effective_allergens: blocked,
    excluded,
    candidate_ingredients: candidateIngredients,
    inventory_actions: inventoryActions,
    issues,
  };
}

每一樣冰箱食材只會走到三個結果之一:

  1. 排除:品名、標示或「可能含有」碰到今天要擋的群組。蝦會在這裡被拿掉,另外記一筆 keep_frozen,庫存不會憑空消失。
  2. 追問:品名看得出是海鮮,但 label_verified 不是 true。這裡寫 !== true,不是 === false;欄位忘了填,也當成還沒確認。
  3. 可以用:通過前兩關,才進候選食材。

effectiveAllergens() 把 profile_allergens 和 guest_allergens 合成一個新陣列。函式開頭先把 profile 做成字串,結束時再比一次;中間如果有人把它改掉,就直接丟錯。intersects 只判斷兩個清單有沒有共同項目。

3.3 第二道門:產生候選後

export function validateAfterGeneration(option, context) {
  const blocked = context?.blocked_allergen_groups ?? [];
  const issues = [];

  for (const text of textsOf(option)) {
    const groups = groupsForName(text);
    if (intersects(groups, blocked)) {
      issues.push({ code: "ALLERGEN_GROUP_BLOCKED", text, groups });
    }
  }
  // 設備、洗鍋、爐口檢查
  return { hard_ok: issues.length === 0, issues };
}

textsOf() 把食材、調味料和每一步驟的文字都收進來,一條一條對。標題寫「蒜辣花枝」,步驟卻是「起鍋前加蝦醬」,照樣擋。

回傳的不是 true 或 false 而已,而是 hard_ok 加上 issues。之後畫面要跟使用者說「為什麼這道不見了」,就讀這個原因碼。

3.4 共用鍋:按時間順序走一遍

function checkCrossContact(events, blocked) {
  const byEquipment = new Map();
  const issues = [];
  for (const event of events ?? []) {
    const list = byEquipment.get(event.equipment) ?? [];
    list.push(event);
    byEquipment.set(event.equipment, list);
  }

  for (const [equipment, list] of byEquipment) {
    const dirty = new Set();
    for (const event of list) {
      if (event.action === "wash") {
        dirty.clear();
        continue;
      }
      if (intersects([...dirty], blocked) && (event.contacts?.length ?? 0) > 0) {
        issues.push({ code: "CROSS_CONTACT_WASH_MISSING", equipment });
      }
      for (const group of event.contacts ?? []) dirty.add(group);
    }
  }
  return issues;
}

每一個鍋有一個 dirty 集合,記它碰過什麼。碰到蝦就放進去;洗鍋就清空;下一次拿來煮東西時,如果集合裡還有被擋的群組,就記一筆沒洗。只看食材清單抓不到這種錯,一定要照步驟順序走。

3.5 單口爐:時間重疊就擋

const overlaps = a.start_min < b.end_min && b.start_min < a.end_min;

每個用爐子的步驟都有開始和結束分鐘。兩段時間只要重疊,而我只有一個爐口,就是 BURNER_CONCURRENCY_EXCEEDED。熱鍋 0~10 分、另一鍋煮麵 5~15 分,中間 5 分鐘重疊,所以被擋。

今天還沒有接候選產生器。後驗吃的是 test.js 裡手寫的方案;等 Gemini 接上,同一個 validateAfterGeneration 放在模型輸出後面即可,不用再寫第二套規則。實跑紀錄在下一節。


4. 驗證:六項測試

案例 期望
客人 crustacean 過敏,冰箱有冷凍蝦 前驗排除,記 keep_frozen
標示確認只有 mollusk 的花枝條 不因「海鮮」一詞被封殺
步驟裡加蝦醬 後驗擋下,hard_ok=false
deep_wok 先碰蝦、沒洗就做花枝 hard_ok=false
同一情境,中間有洗鍋 hard_ok=true
兩個爐口同時加熱 hard_ok=false

在 material/code/day12 執行 node test.js。截圖裡前驗的 ok 是 false,測試仍然 PASS:蝦已經正確排除,ok: false 是因為照片上的花枝條還沒確認標示,程式停下來追問。若這裡印出 ok: true,反而是把沒看過標示的花枝條放行了。

https://ithelp.ithome.com.tw/upload/images/20260927/20121052E2enzfW9tr.jpg

對照上面六列,結果是:

pre_blocks_crustacean = PASS
  effective_allergens = crustacean
  冷凍蝦 ALLERGEN_GROUP_BLOCKED,action = keep_frozen
post_blocks_hidden_crustacean = PASS
  起鍋前加蝦醬 → ALLERGEN_GROUP_BLOCKED,hard_ok = false
mollusk_not_overblocked = PASS
  這題是 label_verified=true、只有 mollusk 的 fixture
  照片上的澎湖生凍花枝條仍是 ALLERGEN_LABEL_UNVERIFIED
shared_wok_requires_wash = PASS
  沒洗:CROSS_CONTACT_WASH_MISSING
  中間有 wash:hard_ok = true
single_burner_enforced = PASS
  熱鍋與另一鍋煮麵時間重疊 → BURNER_CONCURRENCY_EXCEEDED

放行花枝條的那題,是標示已確認的假設資料。我家那包還沒看到標示,仍是追問狀態;若標示寫了甲殼類,或寫無法排除交叉接觸,它就該被擋。

EQUIPMENT_NOT_AVAILABLE(例如方案要用烤箱)程式裡有,這次六項沒有測到。

4.1 測試不一定要自己一行一行寫

上面那張六列的表,其實就是測試規格:輸入是什麼、要看到哪個原因碼。流程和原因碼定好之後,可以把 validate.js 和這張表一起貼給 AI,請它產生 node:assert 的測試,例如:

這是 validate.js。請用 node:assert/strict 寫 test.js,涵蓋下面六個案例。
每個案例要檢查 hard_ok 和 issues 裡的原因碼,不要只檢查數量。
不要修改 validate.js。

AI 寫出來的測試要自己看過兩件事。第一,它有沒有真的斷言原因碼,而不是只 console.log。第二,故意把 validate.js 裡一條規則註解掉再跑一次,測試要變成 FAIL;如果還是全部 PASS,那份測試沒有在測東西。


5. 今日結論

今天的結論是:推薦安不安全,不靠 Prompt 寫得多仔細;產生前先拿掉不能用的材料,產生後再把方案整份驗一次。

  • Day 7 保護誰能讀資料;Day 12 保護什麼能出現在推薦,兩者不能混寫。
  • crustacean 與 mollusk 要分開,客人限制只疊加在當次 session。
  • 共用鍋具要有清洗事件;過敏日的蝦保持冷凍,改天由我自己吃。

下一篇: Hard 通過後,再讓剩料、蛋白質與少洗鍋決定合格方案的順序。


附錄 A:最小 allergen map

{
  "shrimp": ["crustacean"],
  "prawn": ["crustacean"],
  "crab": ["crustacean"],
  "lobster": ["crustacean"],
  "squid": ["mollusk"],
  "cuttlefish": ["mollusk"],
  "octopus": ["mollusk"]
}

加工品另存:

label_verified
label_allergen_groups
may_contain_groups
verified_at

label_verified 不是 true 就不放行。沒填和明確的 false 都算還沒確認。verified_at 是之後要補的時間戳,今天的函式還沒讀它。

附錄 B:validator 原因碼

ALLERGEN_GROUP_BLOCKED
ALLERGEN_LABEL_UNVERIFIED
CROSS_CONTACT_WASH_MISSING
EQUIPMENT_NOT_AVAILABLE
BURNER_CONCURRENCY_EXCEEDED

上一篇
[Day 11] 系統不會讀心:先把飲食條件存成它讀得懂的 ID
下一篇
[Day 13] 先過 Hard,少洗鍋才有投票權
系列文
DishFlow AI Agent:用 Google AI 打造 Eat-Cost Balance 的下一餐決策系統 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言