Part 5|第 23/30 篇
今日要做的事: 用第一天的冰箱照當起點,鎖定五天的時間、預算、設備和過敏,讓 DishFlow 每天只選一道;再跑一組「每天都以為冰箱還是第一天」的對照。
今天要解決的目的: 看連續記錄有沒有讓下一餐少買、少超支、少把快壞的東西放過期。不是一次把九道菜丟給模型認。
把九道菜的照片一次貼上去,模型是在考試「這是什麼菜」。
DishFlow 要做的是另一件事:今天冰箱裡有這些,時間和預算只有這些,今晚只煮一道。明天的冰箱,是今晚扣完、再過一天之後的冰箱。

| 項目 | 內容 |
|---|---|
| 產出 | 鎖定的冰箱與五天條件、有記憶 5 天、沒記憶 5 天、兩邊的結算 |
| Google 服務 | Firebase Auth;Firestore 存 benchmark/replay_scenario_v1 和 replay_runs;Gemini Structured Output(gemini-3.5-flash)只負責從合格候選裡選一道 |
| 素材 | 冰箱 4 張:去殼蝦仁、花枝、雞蛋、青江菜、袖珍菇、花椰菜米。醬油、味醂、鵝油算常備。牛奶、優格、啤酒不算食材 |
| 候選 | 15 道。9 月煮過的菜只拿來當「有沒有照片」的註記,不拿來當標準答案 |
| 不做 | 九道菜一次送檢、重煮、用碼表、看完推薦再改五天條件 |
| 對照 | 規則、候選、Gemini 都一樣。唯一差別是規劃時看的冰箱 |
下面的數字來自網頁上這一次正式回放。
直接問「冰箱有蝦仁、花枝、蛋、青菜,晚餐吃什麼」,模型可以每天都給出一道理直氣壯的答案。
問題是它不知道昨天已經把花枝用掉一尾、義大利麵買過了、青江菜只剩三天。沒記憶的規劃會一直對著第一天的冰箱說話。真的要煮的時候,冰箱已經不是那一格。
所以今天比的不是菜名猜不猜得中。比的是:
有記憶:每天看扣過、過完一天的冰箱
沒記憶:每天都拿第一天的冰箱來規劃
煮的時候:兩組都照真實冰箱扣

| 食材 | 數量 | 還能放 | 標記 |
|---|---|---|---|
| 去殼蝦仁 | 20 尾 | 60 天 | 冷凍 |
| 花枝 | 2 尾 | 60 天 | 冷凍 |
| 雞蛋 | 4 顆 | 12 天 | |
| 青江菜 | 2 把 | 3 天 | 快過期 |
| 袖珍菇 | 1 把 | 3 天 | 快過期 |
| 花椰菜米 | 1 包 | 30 天 | 冷凍 |
數量和天數是看照片估的,鎖定之後就不能為了讓某一天好看再改。袖珍菇可以頂鴻喜菇或香菇。蝦不會自動換成花枝,因為過敏原不一樣。
常備品不扣量、不算錢:蒜、醬油、鹽、油、味醂、鵝油、米酒、辣椒片、水。
| 情境 | 時間 | 預算 | 設備 | 過敏 | |
|---|---|---|---|---|---|
| D1 | 週一晚餐,下班很累 | 30 分 | 低 | 全部 | — |
| D2 | 週二朋友來吃 | 45 分 | 中 | 全部 | 不能有蝦 |
| D3 | 週三電磁爐壞了 | 30 分 | 中 | 只有微波爐 | — |
| D4 | 週四只有 15 分鐘 | 15 分 | 低 | 全部 | — |
| D5 | 週五晚餐,想先清快壞的 | 60 分 | 中 | 全部 | — |
預算用點數,不用紙鈔。買一樣東西記一次該等級的點數:低 1、中 2、高 3。當天上限是低 2 點、中 4 點。0 點叫免買,1~2 點叫低,3~4 點叫中,5 點以上叫高。
買一次拿到的是一整包,用不完留到明天。雞腿肉買回來只放 2 天,蛤蜊只放 1 天。剩咖哩買不到,沒有先煮出歐姆蛋咖哩,咖哩烏龍就不能選。
硬規則先篩:設備、過敏、時間、食材買不買得到、預算。剩下的清單才送給 Gemini。
它必須用 Structured Output,從清單的 id 裡選一個,並寫一句理由、一句明天可以注意的事。選的順序寫在 prompt 裡:先用快過期的,再少買、少花錢,最後才是好不好吃。
週二沒出現蝦,不是模型突然很懂過敏。含 crustacean 的菜在進 Gemini 之前就被拿掉了。週三只有微波丼過得了「只有微波爐」這一關,所以那一天 Gemini 沒有真正的選擇。
day22/
├─ index.html 冰箱、五天條件、兩條時間軸、結算
├─ test.js 10 個測試,不打 Gemini
└─ src/
├─ scenario.js 第一天冰箱、採購表、五天條件
├─ recipes.json 15 道候選
├─ engine.js 規劃、篩選、扣庫存、過一天、回放
├─ choose.js Gemini 從候選裡選一道
├─ main.js 鎖定、兩組回放、寫 Firestore
└─ firebase.js
.env 沿用 Day 19。金鑰在前端,這頁只在 localhost 跑。
Firestore 寫在 users/{uid} 底下:
benchmark/replay_scenario_v1 冰箱、五天、採購表、15 道 id,附 hash
replay_runs/memory 有記憶的結算
replay_runs/no_memory 沒記憶的結算
replay_runs/{mode}/days/D1…D5 每一天的候選、選擇、實際購買、過期
內容和已鎖定的 hash 不同就拒絕寫入。要改冰箱或五天條件,得開新版本。
export function candidates(recipes, day, pantry) {
const rejected = [];
const ok = [];
for (const r of recipes) {
const why = [];
if (!r.equipment.every((e) => day.equipment.includes(e))) why.push("設備");
if (r.allergens.some((a) => day.avoid_allergens.includes(a))) why.push("過敏");
if (r.minutes > day.time_cap) why.push("時間");
const p = plan(r, pantry);
if (!p.feasible) why.push("食材");
else if (p.points > BUDGET_POINTS[day.budget]) why.push("預算");
if (why.length) rejected.push({ id: r.id, why });
else ok.push({ id: r.id, name_zh: r.name_zh, minutes: r.minutes, ...p });
}
return { ok, rejected };
}
以第一天的冰箱來說,週一過關 6 道,週二 7 道,週三只剩微波丼,週四只剩花椰菜米蛋炒飯、蔥花玉子燒、蝦仁滑蛋。後面幾天的候選數會跟著真實冰箱變,所以不能拿這組數字套到每一天。
const seen = memory ? real : fridge;
const { ok } = candidates(recipes, day, seen);
const pick = ok.find((c) => c.id === ai.result.pick);
const cooked = cook(recipe, real);
seen 用來產生候選和「要買什麼」。cook() 永遠扣真實冰箱。沒記憶的那組會出現一種錯:規劃時以為不用買,真的煮卻要買。這就是畫面上的「意外要買」。多買的點數如果超過當天預算,再標「超出預算」。
煮完之後天數減一。天數變成負的,記成浪費,移出冰箱。
四步:登入、鎖定情境、回放有記憶、回放沒記憶。

| 有記憶 | 沒記憶 | |
|---|---|---|
| D1 週一晚餐 | 蒜辣透抽義大利麵,買義大利麵,低 | 蒜辣透抽義大利麵,買義大利麵,低 |
| D2 週二朋友來吃 | 同一道,不用再買 | 同一道,但標了意外要買 |
| D3 週三只剩微波爐 | 微波洋蔥雞肉蛋蓋飯,買雞腿肉、洋蔥、白飯,中 | 同一道 |
| D4 只有 15 分鐘 | 蝦仁滑蛋;當晚袖珍菇過期 | 花椰菜米蛋炒飯,另外出現意外要買,袖珍菇也過期 |
| D5 週五晚餐 | 又是微波丼;當晚雞腿肉過期 | 蛤蜊番茄蛋,花費高,超出預算,雞腿肉過期 |
週一兩邊一樣,是因為真實冰箱還是第一天,記憶還沒有差別。週二起才分開。
週二兩組都再選蒜辣透抽義大利麵。有記憶知道義大利麵昨天買過,所以不用再買。沒記憶還對著第一天的空櫥櫃規劃。連續兩天同一道,規則沒有擋。prompt 只說少買、用掉快過期,沒說不要連著吃一樣的。
週三沒有選擇餘地。設備篩選之後只剩微波丼。
週四有記憶選了冰箱裡就有的蝦仁滑蛋,沒有去動只剩一天的袖珍菇,菇當晚過期。沒記憶改選花椰菜米蛋炒飯,規劃和真實冰箱對不上,出現意外採購。
週五有記憶又選微波丼。D3 買的雞腿肉只放兩天,沒在期限內用完,D5 過期。沒記憶選了蛤蜊番茄蛋,蛤蜊是高單價,再加上番茄和高麗菜,超過「中」的 4 點,畫面上標超出預算。
| 有記憶 | 沒記憶 | |
|---|---|---|
| 5 天花費點數 | 7 | 12 |
| 意外要買的東西 | 0 | 2 |
| 超出預算的天數 | 0 | 1 |
| 用掉的快過期食材 | 2 | 1 |
| 有 9 月實煮證據的天數 | 2 | 3 |
有記憶比較省,也沒有意外採購、沒有超支。它多記住的是「昨天買過的還在、昨天用掉的不能再算一次」。
這不是零浪費。袖珍菇和雞腿肉兩邊都有過期。有記憶沒有因為「想把快壞的先用掉」就把 D5 拿去清冰箱,它又選了一道要買雞肉的微波丼。
「有 9 月實煮證據」沒記憶是 3、有記憶是 2。這個數字只表示推薦碰巧是我 9 月煮過的菜。沒記憶較高,不代表它比較會選晚餐。蒜辣透抽和蝦仁滑蛋 9 月沒煮過,所以有記憶的 D1、D2、D4 不算在這欄。
網頁截圖證明畫面上長這樣。Firestore 再留兩張,證明這次有寫進去。麵包屑上的 uid 已遮掉。

benchmark/replay_scenario_v1 裡是跑之前鎖進去的採購表。義大利麵、梅花豬、馬鈴薯、白飯的等級和天數都在這份文件裡,不是事後補的。

replay_runs/memory 的 summary 對得上網頁:花費點數 7、意外採購 0、超支 0 天、煮完 5 天。scenario_hash 是 0c0b8d49338fd10c,寫入時間是 2026/10/6 11:37。右邊打開的是 D1,選的是蒜辣透抽義大利麵,實際買了義大利麵,1 點。過期清單裡有袖珍菇。D2 到 D5 在同一個 days 集合,沒有另外貼十張。

這 10 個測試不打 Gemini,守的是冰箱怎麼扣:
同一格冰箱回放五天,有記憶的花費是 7 點,沒記憶是 12 點;意外採購 0 對 2,超支 0 天對 1 天。差別從第二天開始,因為第一天兩邊看到的冰箱一樣。有記憶仍讓袖珍菇和雞腿肉過期,也連著兩天選了同一道透抽麵。
下一篇: 拿這五天裡的一筆,做成一張 Result Card。時間用這次的估計分鐘,不假裝有碼表。
day: D2
seen_pantry: real 有記憶;沒記憶這欄是 day1
outcome: COOKED
pick: garlic-chili-squid-pasta
planned.buy 規劃時以為要買的
actual.buy 照真實冰箱真正買的
surprise_buy actual 有、planned 沒有的
over_budget actual 點數超過當天上限
wasted 這天結束時過期的