iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0

Part 4|第 18/30 篇
今日要做的事: 把 Rule → Gemini 候選 → validate 收成一條決策流,並把採用和煮完扣庫存拆開。
今天要解決的目的: 使用者只是先記下打算做的菜時,LifeFlow 的 pantry 不能先被改掉。

昨天工具已經能把冰箱和食譜拿回來,兩爐食譜也擋得住。今天卡住的不是推薦,而是推薦之後的五分鐘:我剛按下「採用」,公司群組就說晚餐已經幫大家訂好了。

如果豆腐已經先扣掉,取消時要補回多少?雞蛋打了沒?這些事系統根本不知道。按鈕一按就扣庫存,資料會比沒記還糟。


今日任務卡

項目 內容
產出 決策流 decide、採用/煮完狀態、六項本機時序測試、一次 Gemini 端到端實跑
Google 服務 Gemini API Structured Output(responseSchema 鎖住候選格式)
沿用 Day 12 validate、Day 16 的五支工具與單口爐 fixture
不允許 採用即扣庫存;用模型輸出的 hard_constraints_ok 代替伺服器 validate
今日範圍 Firestore transaction 還沒接,扣庫存先在本機記憶體裡驗時序
固定設備 induction_cooktop、deep_wok、tamagoyaki_pan、microwave;burners=1
指標 hard_ok、not_adoptable、outflow_once、pantry_delta_valid

下面只摘會影響時序的程式。node test.js 用固定輸出反覆撞失敗案例,不花配額;node live.js 才真的請 Gemini 出候選,再走完整條流程。


今天走的路:不是重做 Day 10

Day 10 已經在畫面和 Firestore 證明「採用不扣 qty」。那時回答的是按鈕當下不能做什麼;今天往後補成伺服器契約,回答何時才准扣、網路重送怎麼不重扣。

https://ithelp.ithome.com.tw/upload/images/20261002/20121052FcobdbE1kT.png

今天實際跑的是:Gemini 用 Structured Output 交候選 → 伺服器 decide 重算 hard → 通過的才能 adopted → 收到 complete_cook 才 outflow。UI 不重做,Firestore transaction 也還沒接;先把最容易寫髒庫存的時序鎖住。


1. 問題:推薦流程和消耗流程被混在一起

Rule → Gemini → validate 回答的是「這道菜能不能顯示」。它沒有回答「使用者是否真的煮了」。

昨天的 settleMeal 只處理外食扣不扣,沒有處理按鈕按下去的時間。今天至少要分開四個事實:

recommended:validate 通過,進得了清單
adopted:使用者表示打算做
cooked:確認已煮完
outflowed:cook_run、pantry 與 tip 已寫回

adopted 還沒到 cooked。少這一層的話,之後 Pilot 的採用率、實際完成率、pantry 會被同一顆按鈕污染。

外食仍是另一條路:food_source=eat_out 只在吃完後寫 today_meals,不扣家裡的庫存。

https://ithelp.ithome.com.tw/upload/images/20261002/2012105276QWVJ2iRJ.png


2. 設計:先把時序寫死

2.1 推薦主線

1. 讀 authoritative profile、pantry、today
2. compile → 過敏、設備、爐口
3. Gemini Structured Output 交出候選方案
4. 伺服器 validate;只留下 hard_ok
5. 保存這次 request 看得到的 options

Gemini 會在每個方案上填 hard_constraints_ok。那一欄只代表模型自己這麼說,伺服器仍用 Day 12 的 validate 重算。重算沒過的方案不進 options,後面也不能被採用。

若所有候選都不合格,合法結果是沒有方案,而不是挑一個最不犯規的露出來:

{
  "status": "NO_FEASIBLE_OPTION",
  "options": [],
  "next_action": "請使用者明確放寬可變條件"
}

過敏與飲食禁忌不能列進可放寬條件。

2.2 消耗主線

自煮的順序是:

推薦 → 採用 → 實際煮完 → 建立 cook_run
→ 扣 pantry、寫自煮 today_meals、產生一張 tip_card

採用當下只寫意向:

{
  "request_id": "req_adopt",
  "option_id": "tofu_scramble",
  "status": "adopted",
  "adopted_at": "SERVER_TIMESTAMP"
}

這裡沒有 pantry_delta,也沒有 tip_card。豆腐還是 0.5 盒。

https://ithelp.ithome.com.tw/upload/images/20261002/20121052UgWtvakaGU.png

圖上的 cooked 是完成事件本身。程式不單獨存一筆 cooked:complete_cook 進來才代表煮完,同一筆草稿裡接著寫 outflowed。用量對不上庫存時,整筆草稿作廢,cook_run 也不留下。


3. 實作:決策流只負責過門,扣庫存另走完成事件

檔案 負責
live.js 請 Gemini 用 Structured Output 出候選,再走完整條流程
decide.js compile、重算 validate、沒有合格方案就停
outflow.js 採用、煮完、重送、外食、超量拒絕
test.js 六項時序,用固定模型輸出,不打 API

3.1 Gemini 交候選:用 schema 鎖住格式

Day 16 是讓 Gemini 自己叫工具讀資料。今天換個分工:資料由伺服器用同一組工具先讀好,Gemini 只負責「從這些食譜裡挑候選、說理由」,輸出格式用 responseSchema 鎖死:

const responseSchema = {
  type: "OBJECT",
  properties: {
    options: {
      type: "ARRAY",
      items: {
        type: "OBJECT",
        properties: {
          option_id: { type: "STRING" },
          recipe_id: { type: "STRING", enum: ["tofu_scramble", "two_burner_soup"] },
          title: { type: "STRING" },
          reason: { type: "STRING" },
          hard_constraints_ok: { type: "BOOLEAN" },
        },
        required: ["option_id", "recipe_id", "title", "reason", "hard_constraints_ok"],
      },
    },
  },
  required: ["options"],
};

送出時把 schema 放進 generationConfig:

const response = await fetch(
  `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent`,
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-goog-api-key": process.env.GEMINI_API_KEY,
    },
    body: JSON.stringify({
      contents: [{ role: "user", parts: [{ text: prompt }] }],
      generationConfig: { responseMimeType: "application/json", responseSchema },
    }),
  }
);

recipe_id 用 enum 限制,回傳值只能是這兩個既有 id。拿回來之後,食譜的食材、步驟、設備一律換回伺服器 get_recipe 讀到的版本;模型只貢獻 title、reason 和它自己判斷的 hard_constraints_ok。這樣 validate 看的是原始食譜,不是模型轉述後的版本。

3.2 decide:不相信模型蓋的章

即使候選帶著 hard_constraints_ok: true,過不了爐口檢查也不會出現在清單裡:

const judged = modelOptions.map((option) => {
  const verdict = validateRecipeOption(option, context);
  return {
    option_id: option.option_id ?? option.recipe_id,
    hard_ok: verdict.hard_ok,
    issues: verdict.issues.map((item) => item.code),
    model_said_ok: option.hard_constraints_ok === true,
  };
});
const options = judged.filter((item) => item.hard_ok);

採用只查這份已經過濾過的清單。hard_ok !== true 的方案回 NOT_ADOPTABLE,pantry 一個字都不寫。

3.3 Outflow:煮完才扣,重送不重扣

煮完才接受實際用量。run_id 由 request_id 和 option_id 組成,同一件完成事件再送一次,就回原來的 cook_run:

const runId = `run_${event.request_id}_${event.option_id}`;
if (store.cookRuns[runId]) {
  return { ok: true, replay: true, run_id: runId, pantry_writes: 0 };
}

自煮扣庫存前先複製一份草稿。用量超過現有 qty、或單位對不上,函式直接回來,草稿不寫回。這是 Firestore transaction 以後要保證的同一件事:cook_run 寫不進去時,pantry 不能先被改。今天的測試在本機記憶體跑,沒有連上 Firestore。

外食不進這段扣庫存。food_source=eat_out 只追加 today_meals,pantry_delta 是空陣列。


4. 驗證與翻車

4.1 本機:六項時序

先在本機執行:

node test.js

這裡的模型輸出是固定的,刻意讓兩爐湯帶著 hard_constraints_ok: true,專門測模型蓋錯章的情況。六項依序看:

第一題:模型說合格,伺服器不認。 model_said_ok: true,但 hard_ok: false,清單只剩 tofu_scramble。硬要採用兩爐湯,回 NOT_ADOPTABLE。

https://ithelp.ithome.com.tw/upload/images/20261002/20121052QY47ir71kN.png

第二題:一個都不合格,就回沒有方案。 候選只剩兩爐湯時,options 是空的,不會挑一個最不犯規的露出來。

https://ithelp.ithome.com.tw/upload/images/20261002/20121052AHOF7VIvAp.png

第三題:採用之後,冰箱沒動。 狀態是 adopted,豆腐仍是 0.5 盒、雞蛋 4 顆,tips: 0、pantry_writes: 0。這就是開頭那個「剛按採用就被通知晚餐訂好了」的情況:什麼都不用補回。

https://ithelp.ithome.com.tw/upload/images/20261002/20121052EVSDjTN4ib.png

第四題:煮完才扣,重送不重扣。 first 扣掉 0.5 盒,pantry_writes: 1、tips: 1。同一個完成事件再送一次,replay: true,run_id 相同,pantry_writes: 0,豆腐停在 0,tip 也還是 1 張。

https://ithelp.ithome.com.tw/upload/images/20261002/20121052ipsLPsIDZE.png

第五題:用量對不上,整筆不寫。 冰箱只有 0.5 盒卻申報用掉 2 盒,回 PANTRY_DELTA_INVALID。豆腐仍是 0.5,cook_runs 是空陣列,不會出現「扣了一半」的庫存。

https://ithelp.ithome.com.tw/upload/images/20261002/2012105217GhLxNvM3.png

第六題:外食只記一筆餐。 today_meals 多了一筆 eat_out,pantry_delta: []、pantry_writes: 0,冰箱數字和一開始一樣。最後一行是「這輪沒有呼叫 Gemini,也沒有寫 Firestore」。

https://ithelp.ithome.com.tw/upload/images/20261002/20121052aj2IM6PVIJ.png

4.2 接上 Gemini:完整跑一次

規則鎖住之後,才讓 Gemini 真的出候選。金鑰放在本機 .env 的 GEMINI_API_KEY:

node live.js

https://ithelp.ithome.com.tw/upload/images/20261002/20121052thU5FAPFBs.jpg

gemini-3.8-flash 先回了一次忙碌,重試後才回兩個候選:豆腐蛋炒 hard_constraints_ok: true,理由寫「符合單爐口限制且食材無過敏原,設備齊全」;兩爐湯 false,理由寫「需要同時使用兩個爐口,不符合廚房僅有一個爐口的硬性限制」。用量 total_tokens=1185,其中思考 551、回答 120,思考是回答的四倍多。

截圖中間那段 TLS 警告,是我本機網路有憑證攔截,執行前關掉了 Node 的憑證驗證。只在自己電腦上跑實驗時這樣做,部署出去的服務不要設。

https://ithelp.ithome.com.tw/upload/images/20261002/20121052bQCoPpBoFX.png

這一輪模型自己也判對了,兩爐湯沒有被蓋成合格。伺服器的 decide 結果一致:清單只剩 opt_tofu_scramble,兩爐湯以 BURNER_CONCURRENCY_EXCEEDED 放進 blocked,model_said_ok: false。

模型判對,不代表 validate 可以拿掉。今天這一次它對了,4.1 的第一題就是在測它哪天把兩爐湯填成 true 的時候:擋下的原因碼一樣是 BURNER_CONCURRENCY_EXCEEDED,而且那筆不能被採用。

採用 opt_tofu_scramble 之後,豆腐還是 0.5 盒,pantry_writes: 0。

https://ithelp.ithome.com.tw/upload/images/20261002/20121052maTHN3YhNl.png

送出 complete_cook 後才建立 run_req_live_opt_tofu_scramble,pantry_delta 記下用掉 0.5 盒,豆腐變 0,pantry_writes: 1。從 Gemini 出候選到扣庫存,同一條線跑完;只有最後寫回還在本機記憶體,Firestore transaction 留到後面接。

比較容易翻車的寫法:

  • 前端直接改 pantry,繞過這次完成事件
  • 採用時先預扣,取消再猜著補回
  • 用推薦用量代替使用者煮完申報的實際用量
  • eat_out 跟自煮共用會扣庫存的那條路
  • 庫存不夠仍先扣,cook_run 卻沒寫成

我最在意的是第二種。假設我採用豆腐蛋炒後臨時改吃公司便當,正確結果不是「取消後補回 0.5 盒」,而是 pantry 從頭到尾都維持 0.5。沒有發生過的 Outflow,不需要補償交易,也不該留下假的 tip。


5. 今日結論

  1. 推薦流是 Gemini Structured Output → 伺服器 validate。 schema 鎖格式,validate 鎖合格;模型自己寫的 hard_constraints_ok 不能讓方案上桌,就算這次它填對了。
  2. 採用只記打算做。 自煮要到 complete_cook,同一筆裡寫出 cook_run,才扣 pantry、產生 tip_card。
  3. 外食不扣家裡的庫存。 food_source=eat_out 只寫 today_meals。

下一篇: 推薦能通過還不夠。leftover、balance、goal 的理由要能回指同一份 LifeFlow,不能只靠一句「因為比較健康」。


附錄 A:沒走完的狀態

recommended → adopted → (complete_cook) → outflowed
recommended → skipped
adopted → abandoned

cooked 不另存一筆文件,它就是 complete_cook 這個事件。目前程式只有採用與煮完兩個入口;skipped、abandoned 先留在狀態契約裡。等入口補上時,它們只能改狀態,不能呼叫 completeCook,因此不會扣 pantry,也不會產生 tip。

附錄 B:request 要留下的

保存 request_id、看得到的 options、被擋下的方案與原因碼、hard_constraint_ids、模型版號。實際用量只放在 cook_run,不回寫去改當初那份推薦。


上一篇
[Day 16] 模型會自己拿資料,就不用再貼整份人生了嗎?用 Function Calling 讓 Gemini 自己讀冰箱
下一篇
[Day 18] 「因為比較健康」算證據,還是只是模型很會說?
系列文
DishFlow AI Agent:用 Google AI 打造 Eat-Cost Balance 的下一餐決策系統 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言