iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0

Part 4|第 14/30 篇
今日要做的事: 在 Hard 合格的方案裡,用剩料、蛋白質、少洗鍋三個訊號排順序。
今天要解決的目的: 排序要能從資料重算;任何加分都救不回過敏或設備不合格的方案。

Day 12 把門關好之後,終於可以談「比較想吃哪一道」。

順序不能反。蝦料理如果因為剛好用掉快過期的豆腐就排第一,等於把過敏做成加權投票。


今日任務卡

項目 內容
產出 rankOptions()、scoreSoft()、P1/P2 分數明細、同分排序規則
工具 Node 純函式,沿用 Day 12 的 validateAfterGeneration()
不使用 Gemini 自評分、營養克數、預算與時間
驗收 Hard 不合格不進排名;三個分數都能從資料重算;同分順序固定

程式在 material/code/day13。方案叫 P1、P2,不叫 A、B,因為 A/B1/B2/C 是 Day 24 比較四種做法用的代號。


1. 問題:合格之後,還是要選一道

Hard 只回答「能不能上 Result」,不回答「哪一道比較適合今天」。過敏和設備都通過後,可能還剩好幾道:用掉快過期豆腐的、要新買雞胸的,兩道都有蛋白質,也都能在一個爐口上依序做完。

如果最後只看推薦文字決定順位,leftover_first 和畫面上那顆「剩料優先」就沒有差別。一句「幫你惜食」,不代表方案真的用了那塊豆腐。更糟的是反過來:一道蝦料理剛好用了快過期豆腐,三項分數都滿,只看分數會排第一。客人對蝦過敏時,這張卡不該出現。

https://ithelp.ithome.com.tw/upload/images/20260928/20121052b8suo6gx5V.png

左邊是只看分數的結果:蝦仁豆腐蒸蛋算出 1.0,排第一。右邊是今天要的順序:它先被過敏規則移出,沒有分數;同樣 1.0 的豆腐蛋炒青菜才排第一。所以排序只能讀資料,用了哪個 pantry item、有沒有 tag、用了哪幾個鍋,而且這些分數只在合格之後才算。


今天走的路

候選方案有四份:P1 豆腐蛋炒青菜、P2 新購雞胸配蛋,另外故意放一份蝦料理和一份要兩個爐口同時開的方案。四份先過 Day 12 的 Hard,不合格的直接移出。剩下的才算三個分數:剩料看有沒有引用快過期的 pantry item,蛋白質看 tag,少洗鍋看用了幾個鍋。加權後高分在前,同分就用 option_id 排。最後用 node test.js 跑七項。

https://ithelp.ithome.com.tw/upload/images/20260928/20121052Nsh2vU8fd6.png


2. 設計:先過濾,再算分

2.1 管線

候選方案
  → validateAfterGeneration()   Day 12 的過敏、設備、爐口
  → 合格的方案
  → deriveSoftFeatures()        算三個 feature
  → scoreSoft()                 加權
  → 排序,同分看 option_id

hard_ok=false 的方案不會進 scoreSoft()。它不是拿低分,而是不在名單上。

2.2 三個 feature 各讀一種資料

feature 讀什麼 值
leftover_hit uses_pantry_item_ids 有沒有對到 priority: "use_soon" 1 或 0
protein_present tags 裡有沒有 protein_present 1 或 0
few_pots cookware_used 有幾個不同的鍋 1 個 1、2 個 0.5、3 個以上 0

剩料用 item_id 比,不用中文名稱比,免得「豆腐」「板豆腐」「嫩豆腐」混在一起。蛋白質只看粗 tag,不算克數。

鍋數要跟爐口分開:只有一個爐口是 Hard,Day 12 已經擋;願意洗兩個鍋是偏好,超過只是排後面,不算違規。P2 用兩個鍋,但在同一個爐口上依序煎,所以它合格、只是扣分。

2.3 權重

soft_score = (0.5 × leftover_hit + 0.3 × protein_present + 0.2 × few_pots) ÷ 啟用權重的總和

剩料權重最高,因為這個系列要解決的是冰箱裡的東西放到壞。除以啟用權重的總和,是為了之後關掉某一項時,總分不會因為少一項就整體變低。

這組 0.5/0.3/0.2 是今天測試固定的值,還沒讀 Day 11 存在 Firestore 的 weight。接上之後分數會變,但「先過濾、再加權、同分看 id」這個規則不變。


3. 實作:三個檔

fixtures.js 放冰箱和四個方案,soft.js 放算分與排序,test.js 放七個案例。下面是 soft.js 的重點。

3.1 feature 只從欄位算

export function potsScore(cookware, maxPots = 2) {
  const count = new Set(cookware ?? []).size;
  if (count <= 1) return 1;
  if (count <= maxPots) return 0.5;
  return 0;
}

export function deriveSoftFeatures(option, pantryById) {
  const ids = option.uses_pantry_item_ids ?? [];
  return {
    leftover_hit: ids.some((id) => pantryById[id]?.priority === "use_soon") ? 1 : 0,
    protein_present: (option.tags ?? []).includes("protein_present") ? 1 : 0,
    few_pots: potsScore(option.cookware_used),
  };
}

potsScore 先用 Set 去重,同一個深鍋寫兩次還是算一個。deriveSoftFeatures 完全不讀 why 或標題;方案文字寫得再好聽,這三個值也不會變。

3.2 分數附上明細

export function scoreSoft(option, pantryById, weights = WEIGHTS) {
  const features = deriveSoftFeatures(option, pantryById);
  const active = Object.keys(weights).filter((key) => weights[key] > 0);
  const weightSum = active.reduce((sum, key) => sum + weights[key], 0);
  const raw = active.reduce((sum, key) => sum + weights[key] * features[key], 0);
  return {
    option_id: option.option_id,
    title: option.title,
    features,
    weights: Object.fromEntries(active.map((key) => [key, weights[key]])),
    soft_score: weightSum > 0 ? round(raw / weightSum) : 0,
    matched_strategies: active
      .filter((key) => features[key] > 0)
      .map((key) => STRATEGY_BY_FEATURE[key]),
  };
}

回傳的不只是一個總分,還有每個 feature 和權重。之後推薦卡要說「為什麼它排第一」,就讀這份明細。matched_strategies 由程式依 feature 回填;之後接上 Gemini,它可以寫理由句,但不能自己宣布「我有用到剩料」。

round 把結果取到小數第三位,避免浮點數印出 0.30000000000000004 這種值,讓測試可以直接比 0.4。

3.3 先過 Hard,再排序

export function rankOptions(options, pantryById, context, weights = WEIGHTS) {
  const removed = [];
  const scored = [];

  for (const option of options) {
    const hard = validateAfterGeneration(option, context);
    if (!hard.hard_ok) {
      removed.push({
        option_id: option.option_id,
        title: option.title,
        codes: [...new Set(hard.issues.map((item) => item.code))],
      });
      continue;
    }
    scored.push(scoreSoft(option, pantryById, weights));
  }

  scored.sort((a, b) => {
    if (b.soft_score !== a.soft_score) return b.soft_score - a.soft_score;
    return a.option_id.localeCompare(b.option_id);
  });

  return {
    ranked: scored.map((row, index) => ({ rank: index + 1, ...row })),
    removed,
  };
}

validateAfterGeneration 直接從 ../day12/validate.js 匯入,沒有重寫一份過敏規則。被擋的方案放進 removed,附上原因碼,不會默默消失。

排序比較函式有兩層:先比分數,同分再比 option_id。少了第二層,同分的兩道菜會照輸入順序排。候選陣列只要換個順序,第一名就可能跟著改變。


4. 驗證:七項測試

測試 期望
P1、P2 一起排 P1 第一 1.0,P2 第二 0.4
蝦仁豆腐蒸蛋,三個分數都滿 不進排名,原因 ALLERGEN_GROUP_BLOCKED
P1 拿掉快過期豆腐 leftover_hit 從 1 變 0
理由寫「高蛋白」但沒有 tag protein_present=0
一鍋改兩鍋 few_pots 從 1 變 0.5
煮湯時同時煎蛋 不進排名,原因 BURNER_CONCURRENCY_EXCEEDED
兩道同分,輸入順序 T2、T1 兩次都排成 T1、T2

P1 和 P2 的分數是這樣算出來的:

P1 豆腐蛋炒青菜  0.5×1 + 0.3×1 + 0.2×1   = 1.0
P2 新購雞胸配蛋  0.5×0 + 0.3×1 + 0.2×0.5 = 0.4

4.1 合格的兩道,為什麼 P1 排前面

https://ithelp.ithome.com.tw/upload/images/20260928/20121052MsZk9R3pO4.png

這張證明排序讀的是三個欄位,不是菜名。P1 三項都是滿的:用到 tofu_use_soon、tag 有 protein_present、只用一個鍋。P2 的 leftover_hit 是 0,因為它的 uses_pantry_item_ids 是空的,雞胸要新買;few_pots 是 0.5,因為用了深鍋和玉子燒鍋。兩道都有蛋白質,所以順序是被剩料和鍋數拉開的。matched_strategies 也跟著 feature 走:P2 沒有 leftover_first。

4.2 分數再高,過敏還是先出局

https://ithelp.ithome.com.tw/upload/images/20260928/20121052uhVYMDELpx.png

蝦仁豆腐蒸蛋用了快過期豆腐、有蛋白質、只用一個鍋。若跳過 Hard 直接算分,它會拿到 1.0,跟 P1 並列第一。輸出裡它只出現在被移出的清單,原因碼是 ALLERGEN_GROUP_BLOCKED,沒有 soft_score。這就是「加分不能把不合格的方案救回來」。

4.3 改資料,分數才會動

https://ithelp.ithome.com.tw/upload/images/20260928/20121052z9gAHG5him.png

同一道 P1,只把 tofu_use_soon 從引用清單拿掉,leftover_hit 從 1 變 0,總分從 1.0 掉到 0.5,剛好是剩料那 0.5 的權重。後面兩行 PASS 是同一輪跑的:文案寫「高蛋白」但 tags 是空的,protein_present 仍是 0;一鍋改成兩鍋,few_pots 從 1 變 0.5。三項都是改欄位才改分數,改理由句不會動。

4.4 兩個爐口同時開,連分數都沒有

https://ithelp.ithome.com.tw/upload/images/20260928/201210529DCeTVN9U6.png

這道也用了快過期豆腐,剩料分是滿的。但它在 0~10 分煮湯、3~8 分同時煎蛋,單口爐做不到。Day 12 的規則回 BURNER_CONCURRENCY_EXCEEDED,所以它跟蝦料理一樣只出現在移出清單。願意多洗一個鍋是扣分;同時要兩個爐口是不能上桌。

4.5 同分不能看候選輸入順序

https://ithelp.ithome.com.tw/upload/images/20260928/20121052dHr4noAR60.png

T1 和 T2 的分數一樣,輸入順序故意先給 T2。兩次排名都是 T1、T2,因為第二排序鍵是 option_id。沒有這個鍵,排名會跟著輸入順序漂,同一份資料今天和明天可能排出不同的第一名。

七項都過。這次沒有呼叫 Gemini,也還沒把預算和時間算進去。


5. 今日結論

今天的結論是:分數只負責在合格的方案裡排先後,永遠不能把不合格的方案加分加回來。

  • 剩料讀 pantry item,蛋白質讀 tag,少洗鍋讀實際用到的鍋;理由句不影響分數。
  • 爐口數是 Hard,願意洗幾個鍋是偏好,兩者分開算。
  • 同分要有固定的第二排序鍵,不然結果會跟著輸入順序漂。

下一篇: 把預算與時間的計算方式寫死,再讓 16:8 只負責排程。


附錄:兩個方案的 fixture

[
  {
    "option_id": "P1",
    "title": "豆腐蛋炒青菜",
    "uses_pantry_item_ids": ["tofu_use_soon", "egg_01", "bok_choy_01"],
    "tags": ["protein_present"],
    "cookware_used": ["deep_wok"],
    "steps": [{ "text": "豆腐、蛋、青菜同鍋炒", "uses_burner": true, "start_min": 0, "end_min": 12 }]
  },
  {
    "option_id": "P2",
    "title": "新購雞胸配蛋",
    "uses_pantry_item_ids": [],
    "needs_purchase": ["chicken_breast"],
    "tags": ["protein_present"],
    "cookware_used": ["deep_wok", "tamagoyaki_pan"],
    "steps": [
      { "text": "深鍋煎雞胸", "uses_burner": true, "start_min": 0, "end_min": 10 },
      { "text": "玉子燒鍋煎蛋", "uses_burner": true, "start_min": 10, "end_min": 15 }
    ]
  }
]

冰箱裡 tofu_use_soon 的 priority 是 use_soon,蛋和青江菜是 normal。P2 的兩個步驟一個在 0~10 分、一個在 10~15 分,接得上但不重疊,所以單口爐可以做。


上一篇
[Day 12] 同樣是海鮮,蝦要擋,花枝條不能一起消失
下一篇
[Day 14] 100 元與 20 分鐘不是參考值
系列文
DishFlow AI Agent:用 Google AI 打造 Eat-Cost Balance 的下一餐決策系統 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言