Part 4|第 19/30 篇
今日要做的事: 讓推薦和理由在同一次 Structured Output 出現,並檢查 leftover、balance、goal 能不能指回這次的 LifeFlow。
今天要解決的目的: leftover_hit、balance_response、goal_consistency 要能重算,不能靠一句漂亮文案得分。
「兼顧營養、方便又符合需求。」豆腐蛋炒下面放得下,兩爐湯下面也放得下,外食便當下面還是放得下。
如果 A、B1、B2、C 都只截這句,四種方法的分數會一樣。好看,但沒有比較。
| 項目 | 內容 |
|---|---|
| 產出 | evidence 欄位、引用檢查、八項本機測試、一次 Gemini 同次輸出 |
| Google 服務 | Gemini API Structured Output,推薦與三條引用在同一份 JSON |
| 沿用 | Day 16 的豆腐、雞蛋、protein_light_days、leftover_first |
| 不計分 | why 的修辭、第二次請模型補理由、體重或運動量 |
| 抽查比例 | AUDIT_RATE = 20,寫進程式前就定好,不看結果再改 |
| 指標 | evidence_ref_ok、leftover_hit、balance_response、goal_consistency |
程式在 material/code/day18。node test.js 用固定的錯引用撞檢查;node live.js 才請 Gemini 一次交出菜和理由。
Day 11 到 15,我先替 AI 畫出不能跨過的線;Day 16 開始讓 Gemini 進場,讀冰箱、出候選,方案能不能上桌由伺服器 validate。今天不再加一條規則,而是追問上桌之後那句「為什麼」。
這三句看起來都合理:
用了快過期食材
回應最近蛋白質偏少
符合少浪費的目標
DishFlow 還得答得出來:
哪一筆 pantry 是 use_soon?
哪一個 balance 數字大於 0?
哪一個 goal 對到已經啟用的策略?
答不出來的時候,C 只是比 A 多了一段 JSON。

沿用 Day 16 那份冰箱,這次 snapshot 裡真正對得上的是這些:
| 要指的 | 這份資料裡實際有的 |
|---|---|
| leftover | tofu_use_soon,priority 是 use_soon,菜名是豆腐 |
| balance | 近 3 日 protein_light_days = 1;veg_light_days = 0 |
| goal | less_food_waste 對到 leftover_first,而且策略有開 |
近 3 日蔬菜訊號是 0。模型若寫「回應蔬菜偏少」,檢查應該失敗,而不是幫它找一個比較像的解釋。
Gemini 一次交出 option、why、三組 refs。不先出菜、再問一輪「為什麼」。第二輪看不到同一個生成狀態,也很會替第一輪補出當時沒有的理由。

對得上的時候,evidence 長這樣:
{
"leftover_refs": [
{"pantry_item_id": "tofu_use_soon", "observed_priority": "use_soon", "used_in_option": true}
],
"balance_refs": [
{"signal": "protein_light_days", "window_days": 3, "response_tag": "protein_present"}
],
"goal_refs": [
{"goal_id": "less_food_waste", "strategy_id": "leftover_first"}
],
"why": "優先用快到期豆腐,並補近三日蛋白質偏少的那一天。"
}
why 留給推薦卡。分數只看 refs 能不能回到這次 snapshot:pantry、balance、goals 與當時啟用的策略。推薦送出之後 pantry 若被改成 normal,檢查仍然讀當初那份,不讀最新值。
使用者只看一句。pantry id、策略名稱和分數留在這次 request 裡,給評測和除錯。

| 檔案 | 負責 |
|---|---|
snapshot.js |
這次 request 的 pantry、balance、goals |
evidence.js |
引用檢查、三個分數、抽查 |
test.js |
八項,含刻意寫錯的引用 |
live.js |
請 Gemini 把菜和 refs 一次交出來 |
檢查不讀 why。leftover 要 id 存在、priority 相等、菜裡真的有這個食材。balance 的數字要大於 0,而且 option 的 tags 裡要有對應的 response_tag。goal 要在 snapshot 裡,對到的策略也要在 active_strategy_ids。
const leftoverOk = evidence.leftover_refs.some((ref) => {
const item = snapshot.pantry.find((row) => row.id === ref.pantry_item_id);
return item?.priority === "use_soon"
&& namesOf(option).includes(item.name);
});
外食若還附冰箱 leftover,直接記 EAT_OUT_NO_LEFTOVER,leftover_hit 是 0。
抽查用 request_id 和 option_id 的 hash,不靠人工挑好看的案例。比例是程式裡的常數:
export const AUDIT_RATE = 20;
這筆 opt_tofu 在 20% 時沒被抽到,shouldAudit 回 false。測試裡把比例暫調成 100,才看得到對得上的 verified,和引用 tofu_99 時的 mismatch。正式的 20 不因為這次想看結果就改掉。
node test.js
先讓真的豆腐蛋炒跑一次,四個分數都是 1。接著只改引用,不改那句好聽的 why:

tofu_99 不在冰箱,回 PANTRY_MISSING。近 3 日 veg_light_days 明明是 0,卻聲稱回應蔬菜偏少,balance_response 掉成 0。拿掉 leftover_first 後,少浪費目標回 STRATEGY_INACTIVE;只煎蛋卻複製豆腐引用,則是 NOT_IN_RECIPE。
推薦送出後,我把最新 pantry 裡的豆腐 priority 改成 normal。如果驗證讀最新資料,當初成立的理由會被後來的變動推翻:

這次 request 的 snapshot 仍是 use_soon,所以原判定保持 1;故意拿後來的 pantry 重算才會看到 PRIORITY_MISMATCH。這張圖證明 evidence 綁的是「當時為什麼推薦」,不是「現在冰箱長什麼樣」。
最後兩題把證據帶到不該出現的地方,再確認抽查比例沒有為了展示而改:

外食附 pantry leftover,回 EAT_OUT_NO_LEFTOVER。正式 audit_rate 是 20,這筆剛好沒抽中;測試暫時用 100 才得到一筆 verified 和一筆 mismatch。最後一行是「全部通過。引用檢查在本機跑完,這輪沒有呼叫 Gemini。」
node live.js

這次 gemini-3.8-flash 忙了三次,程式依原定 fallback 改打 gemini-3.5-flash,schema 沒變。它回一道豆腐蛋炒,why 寫「這道料理能幫您消耗即將過期的豆腐,並為您補充近期較缺乏的蛋白質」;同一份 JSON 也帶回 leftover_refs、balance_refs、goal_refs。
leftover 寫了 tofu_use_soon(use_soon)和 egg_01(normal),兩個 id 都在冰箱裡,菜也真的用到。balance 指近 3 日的 protein_light_days;goal 指 less_food_waste 和 meal_balance。這一輪共 2045 token,輸入 402、回答 196、思考 1447。

verifier 重算之後 evidence_ref_ok、leftover_hit、balance_response、goal_consistency 都是 1,issues 是空的。why 有印出來,但沒有參與計分。
它這次沒有發明 tofu_99。4.1 的錯引用就是在留「它哪天編了一個 id」的位置:那時分數要掉,不能靠 why 把分數救回來。
上面兩段輸出,驗的不是「豆腐蛋炒一定比較健康」,也沒有做營養判斷。我在意的是另一件事:Gemini 說這道菜比較划算、比較均衡的時候,它講的是我家冰箱和我這幾天吃的東西,還是一句換個人也能用的話。
Day 0 定義 Eat-Cost Balance 時,Eat 是吃得像樣,Cost 是錢、時間和心力扛得住、浪費少。今天的三條引用剛好各接一段:
tofu_use_soon 指回那半盒快過期的豆腐,少浪費一份食材。預算、時間和單口爐已經由 Day 14、Day 17 擋過,這裡不再重算。protein_light_days = 1,方案帶著 protein_present;veg_light_days = 0,就不能說它回應了蔬菜不足。
左邊是這餐要扛得住的 Cost,右邊是吃得到、少浪費、有回應近期飲食的 Eat 和 Balance。Gemini 從上方送出推薦,refs 把它接回 LifeFlow,verifier 決定這個理由站不站得上天秤。
這也是之後 A、B1、B2、C 要比的東西。四種方法不比誰的理由寫得漂亮,比的是 leftover_hit、balance_response、goal_consistency 能不能從同一份資料重算出來。
tofu_use_soon,近 3 日蔬菜訊號是 0,就不能算回應了蔬菜。why 給人看,分數由檢查重算。 抽查比例 20 先鎖住。下一篇: context、工具和 evidence 都進來之後,一次請求的 token、延遲,以及 cache 什麼時候失效。
為什麼這道:優先用快到期豆腐,並補近三日蛋白質偏少的那一天。
pantry 當時的 id 和 priority、balance 的 window、goal_id 與 strategy_id、verifier 算出的整體引用結果與三個分數、audit_status。實際用量仍只放在 Day 17 的 cook_run,不回寫這份推薦。