Part 4|第 16/30 篇
今日要做的事: 先用一輪 compile mock 驗收 Strategy Stack,再鎖定 N=3~5 的 Pilot 協議。
今天要解決的目的: 確認 Strategy Stack 接起來仍守得住 LifeFlow,並把「C 是否比貼 Prompt 少步驟」鎖成之後可量的協議。
hard、soft、context 各自正確,不代表接起來也正確,更不代表真人願意每天用。
所以我先跑 mock,Stack 過關後才邀請人。
| 項目 | 內容 |
|---|---|
| 產出 | compile mock 驗收、Pilot 協議與事件格式;步驟比較表等真人數字 |
| 工具 | 自寫 Rule Engine;本篇用本地 snapshot,未寫 Firestore、未呼叫 Gemini |
| 不使用 | Health Connect、運動營養建議、真人資料代填 |
| 固定環境 | induction_cooktop、deep_wok、tamagoyaki_pan、microwave;burners=1 |
| 指標 | compile_ok、hard_ok、user_steps、elapsed_seconds、adopted |
Day 11~14 的 compile、Hard、Soft、預算與時間在這裡串成 Gate 0;沒有呼叫 Gemini。
Day 12~14 解決的是個別規則:過敏不能投票、剩料只是排序、時間與預算是 hard、16:8 只做使用者自訂排程。
今天的新問題只有一個:
DishFlow 把 Strategy Stack 串起來後,是否比 B1/B2 手貼 Prompt 少做事?
A 最少步驟,但幾乎沒有 LifeFlow;B1 是十秒掃一眼後手打;B2 還要貼完整 pantry JSON。C 若要求每餐重選五個策略、重拍冰箱、再確認一次 profile,就算推薦較完整,產品仍然輸。

因此 Pilot 不先問「喜不喜歡 AI」,而是量:
完成一次下一餐請求,需要幾個使用者動作?
同一份資料,B1/B2 貼 Prompt 要多久,C 又要多久?
固定一份 LifeFlow,用 mock 回兩個候選(一個合格、一個故意爐火重疊)。走 compile → mock → validate(過敏/設備/budget/time)→ soft 只重排合格案 → snapshot 可重播。Gate 0 有 PASS trace 才談 N=3~5 Pilot;事件只收步驟數與秒數,背景 compile 不算使用者步驟。

我先固定一份 LifeFlow,不讓 Gemini 參與,避免接線錯誤被自然語言掩蓋。
{
"food_source": "home",
"budget_twd": 120,
"available_time_minutes": 20,
"kitchen": {
"equipment": ["induction_cooktop", "deep_wok",
"tamagoyaki_pan", "microwave"],
"burners": 1
},
"active_strategy_ids": ["allergy", "equipment", "budget_cap",
"time_cap", "leftover_first", "meal_balance_recent"]
}
mock 只回兩個候選:P1 符合設備與預算;X2 故意在 0~10 分煮湯、3~8 分同時煎蛋。驗收順序固定:
LifeFlow snapshot
→ compile(rule_engine, soft_scores)
→ mock recommendation
→ validate
→ 合格案可顯示;違規案不得進前端
通過條件不是「看起來合理」,而是:
allergy 等安全 ID 仍在 hard_constraint_ids)burners=1 寫進 kitchen context,重疊爐火被擋Gate 0 mock 驗收(本地 node test.js)
| 項目 | 結果 |
|---|---|
| compile 合併安全 ID + budget/time hard | PASS |
| P1 可顯示(hard/budget/time) | PASS |
| X2 爐火重疊 → 不顯示 | PASS |
| soft 不救 Z9 違規案 | PASS |
| 預算 121 > 120 → Stack 擋下 | PASS |
| snapshot 重播 compile | PASS |
| Pilot 事件 schema | PASS |
| 背景步驟不計入 user_steps | PASS |
只有 Gate 0 通過後才發邀請。Pilot 是探索性試用,不代表台灣使用者,也不拿來宣稱健康成效。
參與者每天只做最小流程:
庫存有變才更新
吃完記一筆 today_meals
預算/時間/通路不同時才覆寫
需要下一餐時按推薦
採用或略過,各按一次
外食只寫 today_meals,不扣 pantry。自煮則要等實際煮完,後面的 Outflow 才能扣 pantry 並產生 tip_card。
runGate0Mock():串起 Day 11~14// stack.js(節錄)
export function runGate0Mock(lifeFlow, dailySession, mockResponse, catalog) {
const compiled = attachParamHints(
compileStrategies(null, catalog, null, dailySession),
catalog
);
const limits = extractLimits(lifeFlow, compiled);
const context = buildKitchenContext(lifeFlow);
const eligible = [];
for (const option of mockResponse.options ?? []) {
const verdict = validateOptionStack(option, context, limits);
if (verdict.displayable) eligible.push(option);
}
const ranked = rankOptions(eligible, mockResponse.pantryById ?? {}, context);
return { compiled, limits, perOption, ranked, snapshot: createRequestSnapshot(...) };
}
單案驗證依序是 Day 12 validateAfterGeneration → Day 14 validateBudget → validateTime;三者都過才 displayable: true。
export function validateOptionStack(option, context, limits) {
const hard = validateAfterGeneration(option, context);
if (!hard.hard_ok) return { displayable: false, hard_ok: false, hard };
const payable = estimatePayable(option.meal_cost ?? {});
const budget = validateBudget(
payable.estimated_payable_twd,
limits.max_budget_twd,
payable.cost_complete
);
if (!budget.ok || budget.needs_confirmation) {
return { displayable: false, hard_ok: true, budget_ok: false, budget, payable };
}
const timeEst = estimateTotalMinutes(option.time_input ?? {}, context.burners);
const time = validateTime(
timeEst.estimated_total_minutes,
limits.max_time_minutes,
timeEst.estimate_complete
);
if (!time.ok) return { displayable: false, time_ok: false, time, timeEst };
return { displayable: true, hard_ok: true, budget_ok: true, time_ok: true };
}
產品端與 B 對照都記同一組事件。下面的 2 步、18 秒只是格式範例,不是量到的數字:
{
"participant_id": "anonymous_id",
"method": "B1|B2|C",
"task": "request_next_meal",
"user_steps": 2,
"elapsed_seconds": 18,
"abandoned": false,
"adopted": true
}
user_steps 只算使用者可感知動作:打字、貼上、改欄位、按確認。背景的 compile_*、validate_* 不算步驟,但延遲仍另記 elapsed_seconds。
// pilot.js
export function countUserSteps(stepIds) {
return stepIds.filter((id) => !isBackgroundStep(id)).length;
}
B1/B2 與 C 使用同一個任務起點與終點:
起點:看到「今晚吃什麼」任務
終點:畫面出現可採用方案,或使用者放棄
Pilot 說明只承諾研究操作成本:
我們想知道:連續記下庫存與餐點後,
下一餐是否更容易決定、是否少做重複貼資料的動作。
這不是診斷或運動營養計畫;你可以隨時停止。
人還沒找,步驟、秒數和說明書截圖都先空著。等真的有人用過,再把量到的數字補上。
執行test code:
node test.js
輸出太長,終端機一次截不完,下面依同一輪跑分六段截。

hard_constraint_ids 含安全四項與 budget_cap、time_cap;limits 寫死 120 元、20 分鐘、burners=1。當日清單沒勾 forbidden_foods、diet_pattern,compile 仍把它們併進來。meal_balance_recent 有進 soft 清單,本篇排名還沒用到它。

mock 兩個候選:豆腐蛋炒青菜通過;故意爐火重疊的 X2 hard_ok: false,不進前端。

Z9 就算分數再高,原因碼仍是 BURNER_CONCURRENCY_EXCEEDED,只出現在 removed。

過敏與設備都過了,121 > 120 仍在 budget 層被擋,不會進榜。

同一份 snapshot 再 compile,hard 清單不變。

user_steps/elapsed_seconds 未填會拒收;範例 C、2 步、18 秒格式合法。最後一行:全部通過,Gate 0 mock 未呼叫 Gemini。招募前 actual_N 維持空白。
Day 11 到今天,畫面都很像:一條規則、一輪 node test.js、幾張 PASS。我是故意先停在本機。過敏、爐口、預算、時間這些門禁,先當本地 unit test 跑穩,再送上 Gemini 或雲端。規則還沒定,每次失敗都在燒配額,服務費用不該花在這種地方。
下一篇: 同一套門禁開始接到 Google。DishFlow 用 Function Calling 讀 LifeFlow,工具仍然不能取代 hard 門禁。
[x] hard profile 永遠保留;equipment 名稱一致;burners=1 可驗
[x] soft 只排序;違規 mock 不顯示
[x] request snapshot 可重播
只收匿名 participant id、事件時間、步驟數、是否採用、是否完成、卡住原因。不要收病歷,也不擴張到運動營養。
actual_N = (招募完成後填 3~5)
pilot_started_at = (真實啟動時間)
adoption_rate = (Day 26 依事件計算)
median_user_steps = (收齊資料後計算)