iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0

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 一次交出菜和理由。


1. 問題:理由若不能指回資料,就只是另一段生成文

Day 11 到 15,我先替 AI 畫出不能跨過的線;Day 16 開始讓 Gemini 進場,讀冰箱、出候選,方案能不能上桌由伺服器 validate。今天不再加一條規則,而是追問上桌之後那句「為什麼」。

這三句看起來都合理:

用了快過期食材
回應最近蛋白質偏少
符合少浪費的目標

DishFlow 還得答得出來:

哪一筆 pantry 是 use_soon?
哪一個 balance 數字大於 0?
哪一個 goal 對到已經啟用的策略?

答不出來的時候,C 只是比 A 多了一段 JSON。

https://ithelp.ithome.com.tw/upload/images/20261003/201210529mi5InDKer.jpg

沿用 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。模型若寫「回應蔬菜偏少」,檢查應該失敗,而不是幫它找一個比較像的解釋。


2. 設計:同一次輸出,三條引用

Gemini 一次交出 option、why、三組 refs。不先出菜、再問一輪「為什麼」。第二輪看不到同一個生成狀態,也很會替第一輪補出當時沒有的理由。

https://ithelp.ithome.com.tw/upload/images/20261003/20121052xtSLUEbwmu.png

對得上的時候,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 裡,給評測和除錯。

https://ithelp.ithome.com.tw/upload/images/20261003/20121052XNoaNFpUYg.png


3. 實作:文案不計分

檔案 負責
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 不因為這次想看結果就改掉。


4. 驗證與翻車

4.1 本機:把錯引用先撞一遍

node test.js

先讓真的豆腐蛋炒跑一次,四個分數都是 1。接著只改引用,不改那句好聽的 why:

https://ithelp.ithome.com.tw/upload/images/20261003/20121052BIF5BRLwUb.jpg

tofu_99 不在冰箱,回 PANTRY_MISSING。近 3 日 veg_light_days 明明是 0,卻聲稱回應蔬菜偏少,balance_response 掉成 0。拿掉 leftover_first 後,少浪費目標回 STRATEGY_INACTIVE;只煎蛋卻複製豆腐引用,則是 NOT_IN_RECIPE。

推薦送出後,我把最新 pantry 裡的豆腐 priority 改成 normal。如果驗證讀最新資料,當初成立的理由會被後來的變動推翻:

https://ithelp.ithome.com.tw/upload/images/20261003/20121052rGKi4eri3r.png

這次 request 的 snapshot 仍是 use_soon,所以原判定保持 1;故意拿後來的 pantry 重算才會看到 PRIORITY_MISMATCH。這張圖證明 evidence 綁的是「當時為什麼推薦」,不是「現在冰箱長什麼樣」。

最後兩題把證據帶到不該出現的地方,再確認抽查比例沒有為了展示而改:

https://ithelp.ithome.com.tw/upload/images/20261003/20121052C0J621Vai1.png

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

4.2 同一次請 Gemini 交理由

node live.js

https://ithelp.ithome.com.tw/upload/images/20261003/20121052Nmu3afYUng.jpg

這次 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。

https://ithelp.ithome.com.tw/upload/images/20261003/20121052OINYkR0mez.png

verifier 重算之後 evidence_ref_ok、leftover_hit、balance_response、goal_consistency 都是 1,issues 是空的。why 有印出來,但沒有參與計分。

它這次沒有發明 tofu_99。4.1 的錯引用就是在留「它哪天編了一個 id」的位置:那時分數要掉,不能靠 why 把分數救回來。


5. 這和 Eat-Cost Balance 有什麼關係?

上面兩段輸出,驗的不是「豆腐蛋炒一定比較健康」,也沒有做營養判斷。我在意的是另一件事:Gemini 說這道菜比較划算、比較均衡的時候,它講的是我家冰箱和我這幾天吃的東西,還是一句換個人也能用的話。

Day 0 定義 Eat-Cost Balance 時,Eat 是吃得像樣,Cost 是錢、時間和心力扛得住、浪費少。今天的三條引用剛好各接一段:

  • Eat:菜真的用了冰箱裡的豆腐和雞蛋,不是模型另外編一道。
  • Cost:tofu_use_soon 指回那半盒快過期的豆腐,少浪費一份食材。預算、時間和單口爐已經由 Day 14、Day 17 擋過,這裡不再重算。
  • Balance:近 3 日 protein_light_days = 1,方案帶著 protein_present;veg_light_days = 0,就不能說它回應了蔬菜不足。

https://ithelp.ithome.com.tw/upload/images/20261003/20121052Ir4jFTGBsJ.png

左邊是這餐要扛得住的 Cost,右邊是吃得到、少浪費、有回應近期飲食的 Eat 和 Balance。Gemini 從上方送出推薦,refs 把它接回 LifeFlow,verifier 決定這個理由站不站得上天秤。

這也是之後 A、B1、B2、C 要比的東西。四種方法不比誰的理由寫得漂亮,比的是 leftover_hit、balance_response、goal_consistency 能不能從同一份資料重算出來。


6. 今日結論

  1. 推薦和理由同一次交出。 不再請第二輪模型替自己補故事。
  2. 三條引用都要回到這次 snapshot。 快過期的是 tofu_use_soon,近 3 日蔬菜訊號是 0,就不能算回應了蔬菜。
  3. why 給人看,分數由檢查重算。 抽查比例 20 先鎖住。

下一篇: context、工具和 evidence 都進來之後,一次請求的 token、延遲,以及 cache 什麼時候失效。


附錄 A:推薦卡只留一行

為什麼這道:優先用快到期豆腐,並補近三日蛋白質偏少的那一天。

附錄 B:request 裡要留的

pantry 當時的 id 和 priority、balance 的 window、goal_id 與 strategy_id、verifier 算出的整體引用結果與三個分數、audit_status。實際用量仍只放在 Day 17 的 cook_run,不回寫這份推薦。


上一篇
[Day 17] 按下「採用」之後,庫存就該立刻變少嗎?
下一篇
[Day 19] 第一版 Alpha 網頁上線:簡單 UI 接上 Gemini 和 Firestore,Cache 會留下舊過敏嗎?
系列文
DishFlow AI Agent:用 Google AI 打造 Eat-Cost Balance 的下一餐決策系統 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言