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 跑完六項。

Day 7 的問題是「別的帳號能不能讀到我的過敏資料」,答案由 Firestore Rules 負責。
今天的問題不同:
DishFlow 已經合法讀到過敏資料後,會不會仍把不安全的方案送上 Result?
前端看不到別人的文件,不代表推薦本身安全。
把兩題混在一起,會得到「權限測試通過,所以蝦料理可以上架」這種錯誤結論。

左邊是 Day 7:別人的帳號想讀我的過敏資料,Firestore Rules 看到 uid 對不上就不給讀。右邊是今天:資料是我自己合法讀到的,客人對 crustacean 過敏,候選方案一份一份進 validator。直接用蝦、步驟偷加蝦醬、深鍋煮過蝦沒洗就炒花枝,都會被擋下;只有鍋子洗過、花枝條標示也確認過的方案,才會上 Result。
過敏也不能只靠 Prompt。系列裡的四種做法,差在資料給多少、有沒有程式擋:
A 只有一句需求
B1 再加上手邊想到的食材
B2 貼上完整的當日 JSON
C 同樣的資料,再經過這支可以重跑的 validator
B1 可能只寫「朋友不吃蝦」。B2 就算把過敏資料貼齊,模型還是可能寫出蝦醬。C 要在程式裡擋下來,而且同一份輸入再跑一次,結果要一樣。四種做法的違反率留到 Day 24 再比,今天先把 C 的這道門做出來。
我的 profile 與今天來吃飯的人要分開:
{
"profile_allergens": [],
"guest_allergens": ["crustacean"],
"effective_allergens": ["crustacean"]
}
effective_allergens 由程式做聯集,不回寫 profile。
客人離開後,長期資料不應永久多一個過敏原。
crustacean 與 mollusk 分開最小 taxonomy 先鎖:
shrimp / prawn / crab / lobster → crustacean
squid / cuttlefish / octopus → mollusk
因此「蝦過敏」不能自動展開成所有海鮮。
我家冷凍庫的兩包是這樣:冷凍蝦包裝寫著 SHRIMP;旁邊那包是澎湖生凍花枝條,淨重 650 公克。


花枝條的主體可以對到 mollusk。但它是不是只含花枝、有沒有蝦粉或共線警語,要看包裝上的過敏原標示,這兩張都沒拍到那一欄。所以這包先標 needs_confirmation=true,不給一個 0.8 的信心分數硬放行。
食材沒有蝦,不等於過程沒有交叉接觸。
方案要提供最小器具事件:
{
"equipment_events": [
{ "equipment": "deep_wok", "contacts": ["crustacean"] },
{ "equipment": "deep_wok", "action": "wash" },
{ "equipment": "deep_wok", "contacts": ["mollusk"] }
]
}
上面這三筆事件的順序是:先碰蝦、再洗、再做花枝,這份可以過。拿掉中間的 wash,同一口鍋就不合格。
我當天的做法更單純:客人在的時候不處理蝦,冷凍蝦留著,改天自己吃。規則還是要能抓住「沒洗就接著做」,因為之後模型仍可能寫出這種步驟。
固定設備是:
induction_cooktop + deep_wok + tamagoyaki_pan + microwave
burners = 1
需要不存在設備的方案直接擋。
需要兩個爐口同時加熱的步驟也直接擋。我有兩個鍋,但只有一個爐口。
max_pots_willing=2 今天不進 Hard;它是 Day 13 的少洗鍋偏好。預算與時間留到 Day 14。
兩道門共用同一份當日條件。客人過敏、家裡有哪些器具、幾個爐口,先收成這個物件,後面兩個函式都只讀它,不再各自猜:
{
"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。
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。沒有出現這些字的「海鮮醬」,這張表抓不到,仍要看包裝標示。
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,
};
}
每一樣冰箱食材只會走到三個結果之一:
keep_frozen,庫存不會憑空消失。label_verified 不是 true。這裡寫 !== true,不是 === false;欄位忘了填,也當成還沒確認。effectiveAllergens() 把 profile_allergens 和 guest_allergens 合成一個新陣列。函式開頭先把 profile 做成字串,結束時再比一次;中間如果有人把它改掉,就直接丟錯。intersects 只判斷兩個清單有沒有共同項目。
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。之後畫面要跟使用者說「為什麼這道不見了」,就讀這個原因碼。
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 集合,記它碰過什麼。碰到蝦就放進去;洗鍋就清空;下一次拿來煮東西時,如果集合裡還有被擋的群組,就記一筆沒洗。只看食材清單抓不到這種錯,一定要照步驟順序走。
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 放在模型輸出後面即可,不用再寫第二套規則。實跑紀錄在下一節。
| 案例 | 期望 |
|---|---|
客人 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,反而是把沒看過標示的花枝條放行了。

對照上面六列,結果是:
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(例如方案要用烤箱)程式裡有,這次六項沒有測到。
上面那張六列的表,其實就是測試規格:輸入是什麼、要看到哪個原因碼。流程和原因碼定好之後,可以把 validate.js 和這張表一起貼給 AI,請它產生 node:assert 的測試,例如:
這是 validate.js。請用 node:assert/strict 寫 test.js,涵蓋下面六個案例。
每個案例要檢查 hard_ok 和 issues 裡的原因碼,不要只檢查數量。
不要修改 validate.js。
AI 寫出來的測試要自己看過兩件事。第一,它有沒有真的斷言原因碼,而不是只 console.log。第二,故意把 validate.js 裡一條規則註解掉再跑一次,測試要變成 FAIL;如果還是全部 PASS,那份測試沒有在測東西。
今天的結論是:推薦安不安全,不靠 Prompt 寫得多仔細;產生前先拿掉不能用的材料,產生後再把方案整份驗一次。
crustacean 與 mollusk 要分開,客人限制只疊加在當次 session。下一篇: Hard 通過後,再讓剩料、蛋白質與少洗鍋決定合格方案的順序。
{
"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 是之後要補的時間戳,今天的函式還沒讀它。
ALLERGEN_GROUP_BLOCKED
ALLERGEN_LABEL_UNVERIFIED
CROSS_CONTACT_WASH_MISSING
EQUIPMENT_NOT_AVAILABLE
BURNER_CONCURRENCY_EXCEEDED