Part 4|第 12/30 篇
今日要做的事: 把策略口號拆成 hard、soft、context IDs,存入 Firestore,並把 Preset 編譯成當日設定。
今天要解決的目的: 讓 DishFlow 的策略可以被保存、編譯與重跑;推薦與排名留到後面。
Day 9 的 Strategy 頁只有幾顆 chips,按下去還只是畫面狀態。
今天要補上資料層:按下「忙碌平日」後,系統到底得到哪些 ID?
| 項目 | 內容 |
|---|---|
| 產出 | Strategy 共用外殼、v1 ID 目錄、Preset、compileStrategies() 輸出契約 |
| 工具 | Cloud Firestore;本地純函式或測試程式 |
| 不使用 | Gemini、候選菜生成、Hard 過濾、Soft 排名 |
| 驗收 | 文件可讀回、ID 合法、Preset 不移除安全限制、compile 可重現 |
程式在 material/code/day11。這一篇只做到 store/compile,還沒有進入推薦與排名。
先把「高蛋白」「剩料優先」這類口號收成英文 ID,分成 hard、soft、context。接著寫 compileStrategies():忙碌平日可以少勾幾項,但 allergy 仍要合併進來。同一份輸入用 node test.js 跑兩次,JSON 要一樣。然後用同一個 Firebase 登入,把目錄和 preset_busy_weekday 寫進去。最後到 Firestore 對三處:strategies、strategy_presets、daily/2026-09-26 的 session。

A 只有一句需求;B1 加上手邊想到的食材;B2 會貼完整當日 JSON。
若 C 仍然只存「想吃高蛋白、便宜、快速」,那麼四種方法最後只能靠模型文案判斷有沒有聽懂。
問題不在口號太短,而在它沒有穩定的機器語意:
「便宜」是超過就不合格,還是越便宜越好?
「少洗鍋」是偏好,還是多一個鍋就禁止?
「今天外食」是策略,還是資料流情境?
先把這三種責任拆開,Day 12、Day 13 才能分別處理門禁與排序。
每份文件都用英文 snake_case ID,UI 再顯示繁體中文:
{
"id": "leftover_first",
"type": "soft_preference",
"enabled": true,
"scope": "user_default",
"weight": 0.8,
"params": {
"prefer_priority": "use_soon"
},
"ui": {
"label_zh": "剩料優先",
"group": "cooking"
}
}
三種類型只回答三件事:
| type | 意義 | 今天 compile 到哪裡 |
|---|---|---|
hard_constraint |
違反就不合格 | hard_constraint_ids |
soft_preference |
合格方案之間調順序 | soft_preferences |
context_module |
描述今天的環境 | context_modules |
weight 只對 soft 有效。
hard 與 context 即使保留相同外殼,也不能因為 weight 較低就被忽略。
今天先固定名稱,不加入運動或精細營養功能:
Hard
allergy
forbidden_foods
diet_pattern
equipment
budget_cap
time_cap
Soft
leftover_first
prefer_protein
prefer_non_fried
minimize_dishes
meal_balance_recent
Context
food_source
eating_window_16_8
prefer_protein 只讀粗 tags,例如 protein_present,不計克數、不變成營養處方。meal_balance_recent 只回應既有 today_meals tags,不在本篇增加新營養資料。
設備固定從 profile 讀:
{
"equipment": [
"induction_cooktop",
"deep_wok",
"tamagoyaki_pan",
"microwave"
],
"burners": 1,
"max_pots_willing": 2
}
這裡有一個容易混淆的地方:burners=1 是設備限制,屬於 hard;max_pots_willing=2 是少洗鍋偏好,屬於 soft。
「忙碌平日」不是第四種 Strategy:
{
"id": "preset_busy_weekday",
"label_zh": "忙碌平日",
"applies": {
"active_strategy_ids": [
"equipment",
"budget_cap",
"time_cap",
"leftover_first",
"minimize_dishes"
],
"session_overrides": {
"food_source": "home"
}
}
}
這份內容已寫進 strategy_presets/preset_busy_weekday。清單裡沒有 allergy。編譯時仍會補上 allergy、forbidden_foods、diet_pattern。Preset 沒有刪掉它們的權限。
Firestore 路徑固定為:
users/{uid}/strategies/{strategyId}
users/{uid}/strategy_presets/{presetId}
users/{uid}/daily/{yyyy-mm-dd}.session.active_strategy_ids
users/{uid}/daily/{yyyy-mm-dd}.session.strategy_param_overrides
今天寫進 Firestore 的是英文 ID。Day 10 儲存的仍是中文 strategy_chips,尚未沿用今天這套 ID。從 Day 11 開始,中文只放在 ui.label_zh;即使標籤改名,compile 對到的仍是同一個 ID。
編譯順序定成純函式:
function compileStrategies(profile, catalog, preset, dailySession) {
const safetyIds = ["allergy", "forbidden_foods", "diet_pattern", "equipment"];
const activeIds = union(
safetyIds,
preset?.applies.active_strategy_ids ?? [],
dailySession.active_strategy_ids ?? []
);
return partitionAndApplyOverrides(activeIds, catalog, dailySession);
}
這個純函式只做四件事:
在 material/code/day11 執行 node test.js,六項測試都通過。同一份輸入印了兩次,兩份 JSON 相同。


實際輸出是:
{
"hard_constraint_ids": [
"allergy",
"forbidden_foods",
"diet_pattern",
"equipment",
"budget_cap",
"time_cap"
],
"soft_preferences": [
{
"id": "leftover_first",
"weight": 0.8,
"params": { "prefer_priority": "use_soon" }
},
{
"id": "minimize_dishes",
"weight": 0.6,
"params": { "max_pots_willing": 2 }
}
],
"context_modules": [
{ "id": "food_source", "params": { "source": "home" } }
]
}
hard_constraint_ids 前四個是安全 ID。Preset 沒有勾 allergy,編譯仍把它留著。leftover_first 同時帶目錄裡的 prefer_priority。
接著用同一個 Firebase 專案開 http://localhost:3001,Google 登入後按「寫入 Firestore」。這頁只寫策略目錄、preset_busy_weekday,以及今天的 session。狀態列顯示已寫入 strategies、preset_busy_weekday、daily/2026-09-26;程式沒有更新 pantry。

Console 裡,strategies 的文件名就是這些 ID。打開 allergy 後,可以看到 type 是 hard_constraint,ui.label_zh 是「過敏」。

equipment 的 params.burners 是 1,類型仍是 hard。這對上「爐口數是硬限制」。

preset_busy_weekday 的 active_strategy_ids 是 equipment、budget_cap、time_cap、leftover_first、minimize_dishes。裡面沒有 allergy。session_overrides.food_source 是 home,中文標籤是「忙碌平日」。

daily/2026-09-26 的 session.active_strategy_ids 是 leftover_first、minimize_dishes、food_source。strategy_param_overrides 裡,food_source.source 是 home,minimize_dishes.max_pots_willing 是 2。preset_id 與文件上的 strategy_preset 都是 preset_busy_weekday。這三個 ID 是當天勾選的項目;allergy 不寫在這份清單裡,它是編譯時從安全 ID 補上去的。

拆成 Strategy ID 之後,Eat-Cost Balance 各自有了資料入口:
Eat → today_meals + food_source context
Cost → budget_cap + time_cap + leftover_first
Balance → meal_balance_recent + prefer_non_fried + prefer_protein
這不是一個總分公式。
Eat 提供已吃事實,Cost 定義做不做得到,Balance 只在安全集合內調方向。
這次測試到 compile 為止:
| 測試 | 預期 |
|---|---|
| 重複 ID | 拒絕載入 |
| 未知 type | 拒絕 compile |
Preset 未列 allergy |
compiler 仍從 safety IDs 合併 |
| 當日修改 soft weight | 只改輸出,不改 catalog |
| 中文 label 改名 | ID 與 compile 結果不變 |
| 同一輸入跑兩次 | 深度相等 |
本地測試與 Firestore Console 檢查結果:
firestore_write_and_console_readback = PASS
users/{uid}/strategies/{strategyId}
users/{uid}/strategy_presets/preset_busy_weekday
daily/2026-09-26 的 session.active_strategy_ids 已讀回
invalid_catalog_rejected = PASS
重複 ID:duplicate id: allergy
未知 type:unknown type: slogan
preset_keeps_safety = PASS
preset 未列 allergy,hard_constraint_ids 仍含四個安全 ID
compile_deterministic = PASS
同一輸入兩次 JSON 深度相等
推薦是否安全、哪道菜排第一、hard_ok 和 Gemini 是否遵守策略,都還沒有測。這些要等 Rule Engine 與候選菜接上後才有答案。
今天的結論是:策略要能被保存、組合與重現,才不會只停在畫面上的一句話。
下一篇: 只做過敏與設備的雙重門禁,驗蝦、花枝排、客人過敏與共用鍋具。
hard_constraint:
allergy:
source: profile.hard_constraints.allergens + daily.session.guest_allergens
forbidden_foods:
source: profile.hard_constraints.forbidden_ingredients
diet_pattern:
source: profile.hard_constraints.diet_tags
equipment:
equipment:
- induction_cooktop
- deep_wok
- tamagoyaki_pan
- microwave
burners: 1
budget_cap:
params: max_twd
time_cap:
params: max_minutes
soft_preference:
leftover_first:
params: prefer_priority=use_soon
prefer_protein:
evidence_tag: protein_present
prefer_non_fried:
evidence_tag: non_fried
minimize_dishes:
params: max_pots_willing=2
meal_balance_recent:
params: window_days in [3, 5, 7]
context_module:
food_source:
params: source in [home, convenience_store, supermarket, eat_out]
eating_window_16_8:
params: window_start, window_end, user_declared=true
{
"profile_safety_ids": ["allergy", "forbidden_foods", "diet_pattern", "equipment"],
"preset_id": "preset_busy_weekday",
"daily_session": {
"active_strategy_ids": ["leftover_first", "minimize_dishes", "food_source"],
"strategy_param_overrides": {
"food_source": { "source": "home" },
"minimize_dishes": { "max_pots_willing": 2 }
}
},
"assertions": [
"safety_ids_cannot_be_removed",
"unknown_ids_are_rejected",
"catalog_is_not_mutated",
"same_input_same_output"
]
}