Part 2|第 6/30 篇
今日要做的事: 把餐點照接到「真的能寫進 today_meals」——結構壞了怎麼救、語意不穩怎麼問人。
今天要解決的目的: Part 2 收工。契約定了,明天才上 Firebase。
Day 4 格式漂亮,它卻不說「不確定」。
今天我本來想找一張「完全寫不出來」的翻車照……結果翻車沒翻成功,反而逼我把進庫的門講清楚。
| 項目 | 內容 |
|---|---|
| 產出 | 進庫管線(recovery → Verifier → 寫入)、確認卡規則、一筆壞照實跑 |
| 工具 | AI Studio(Gemini 3.1 Pro Preview);本地一小段 validator |
| 不使用 | Firebase(明天才上)、confidence、無限 retry、第二個 LLM 修 JSON |
| 素材 | 布丁波羅麵包:標籤清楚照(正解)+刻意拍糊的測試照 |
Day 4 的感覺很尷尬:JSON 解得開,我卻不敢存。parsed_ok=1,該確認的時候它還跟我說不用確認。
若這時候直接上 Firestore,只會多一批「長得很合法的錯資料」。所以 Part 2 收尾就做一件事——門怎麼開:

圖:照片 → SO → 結構閘門 → 語意閘門 → 才寫 today_meals。
① 管形狀,② 管敢不敢信。兩件事不要混在同一個 retry 裡——語意看錯再問一次,常常只換到一份更流暢的錯答案。
常見三種:parse_error、missing_required、invalid_enum。
| 層 | 做 | 不做 |
|---|---|---|
| L1 | 帶 error 重送一次 | 再送第二次 |
| L2 | 外食→eat_out;未知 tag 進 extra_tags |
猜主菜、塞空 items |
| L3 | 人選粗欄位,raw 留著 | 叫人重貼整段 Prompt |
async function recover(request, firstRaw) {
const a0 = inspect("L0", firstRaw);
if (a0.ok) return pass([a0]);
const a1 = inspect("L1", await runModelOnce(request, a0.errors));
if (a1.ok) return pass([a0, a1]);
const mapped = normalizeDeterministically(a1);
if (mapped.ok) return pass([a0, a1], "L2", mapped.value);
return manualFallback([a0, a1]); // L3
}
L2 不再呼叫模型。缺 items/label 不能裝成功。
needs_confirmation 不進模型 schema,由程式依 decision 推:
const needs_confirmation = decision === "CONFIRM";
const write_allowed = decision === "ACCEPT";

圖:ACCEPT 可寫;CONFIRM 最多兩題;REJECT 重拍或手動。
就算模型回了 uncertain_items,人標的 capture_flags(blur/glare/裁切)仍要一起算——補 Day 4「它自己說很確定」的洞。問題要能改欄位;人答完另存 human_patch,不改 model_raw。
說實在的,我今天原本想找一個「完整翻車」——最好它吐出一坨解不開的字、或亂編一道我從沒看過的菜,我才好示範 REJECT、L1 retry、整條 recovery。
於是我去櫃上先拍清楚正解。標籤寫 布丁波羅麵包,35 元:

圖:人眼 Ground Truth。價簽寫死品名。
接著故意搞破壞:失焦、裁到只剩一角黃心、塑膠袋反光拉滿。心裡想的是:「這總該寫不出來了吧。」

圖:丟進模型的測試輸入。人先記 capture_flags: ["blur"](裁切明顯也可加 occluded)。
結果——翻車沒翻成功。
AI Studio 一樣 Temp 0、Structured outputs 開、Tools 全關。它不但吐出合法 JSON,還很客氣地承認自己看不清口味:

圖:SO 開、Temperature 0。格式過了,沒有解不出來的慘案。
{
"slot": "snack",
"label": "麵包",
"food_source": "convenience_store",
"items": [
{ "name": "麵包", "role": "staple", "cook_method": "unknown" }
],
"tags": ["refined_carb_heavy"],
"uncertain_items": ["麵包口味"]
}
當下我有點哭笑不得:我準備好的是「寫不出來」的劇本,它給我的是「寫得出來、但不敢寫死」的劇本。品名沒對到布丁波羅,可是比 Day 4 那輪空的 uncertain_items 誠實多了。
| 檢查 | 結果 |
|---|---|
| 我以為會怎樣 | JSON 掛掉/亂編品名 → 好示範 REJECT |
| 實際怎樣 | 結構過;只寫「麵包」;口味進 uncertain |
| 跟正解比 | 粗,但沒裝成「確定是某某口味」 |
| Verifier | CONFIRM(uncertain 非空+人標 blur)→ 不准直接寫入 |
這種「想翻車卻沒翻成」其實更接近真實使用:模型常常不是炸裂,而是溫柔地裝有把握或溫柔地含糊。所以門不能只防崩潰,還要防「看起來能存」。
正式進庫前仍要問人,例如:
{
"decision": "CONFIRM",
"needs_confirmation": true,
"questions_for_user": [
{ "field": "items[0].name", "options": ["布丁波羅麵包", "一般麵包", "看不出來"] }
],
"write_allowed": false
}
我選「布丁波羅麵包」,另存 human_patch,再跑同一套 Verifier;變 ACCEPT 才准寫。food_source 它寫成 convenience_store,若其實是麵包店,同一輪確認一起改就好——重點是 model_raw 原封不動留著,之後才分得出來誰改過什麼。
語意閘門骨架(本機可測,還沒接雲):
function verifyMeal(modelRaw, captureFlags) {
const parsed = safeParseAndValidate(modelRaw);
if (!parsed.ok) return result("REJECT", modelRaw, ["invalid_structure"]);
if (captureFlags.some(x => ["not_food", "unreadable"].includes(x)))
return result("REJECT", modelRaw, captureFlags);
const reasons = [
...parsed.value.uncertain_items.map(x => x.reason ?? x),
...captureFlags.filter(x => ["glare", "blur", "occluded", "too_far"].includes(x)),
...findContradictions(parsed.value)
];
const questions = buildFieldQuestions(parsed.value, reasons).slice(0, 2);
if (reasons.length && !questions.length) return result("REJECT", modelRaw, reasons);
if (reasons.length) return result("CONFIRM", modelRaw, reasons, questions);
return result("ACCEPT", modelRaw, []);
}
結構那邊的 L1/L2/L3,今天沒等到「截斷 JSON」那種真實炸裂;附錄 B 先用 fixture 保底。反正規則寫好,真遇到再套。
| 容易怎樣 | 怎麼守 |
|---|---|
| 以為一定能拍到模型崩潰 | 崩潰不是常態;含糊+合法 JSON 才常見 |
| 有 uncertain 仍當 ACCEPT | Verifier 看到非空就 CONFIRM |
| 只信模型、忽略糊圖 | capture_flags 人寫,模型蓋不掉 |
L2 把缺 items 補成 [] |
核心欄缺 → L3 |
| 一直 retry | L1 上限 1;語意錯不進結構 retry |
下一篇開始上 Firebase。 不是為了「有用雲端」,是資料要活過隔天:誰的 uid、today_meals/pantry 長什麼樣、什麼條件才准寫入(就是今天的 decision === "ACCEPT")。先 Auth 把「這是誰的」釘住,後面 Rules、Storage 才有意義。
下一篇: Firebase Auth——資料要活過隔天,先回答「這是誰的」。
{
"type": "object",
"properties": {
"slot": { "type": "string", "enum": ["breakfast", "lunch", "dinner", "snack"] },
"label": { "type": "string" },
"food_source": {
"type": "string",
"enum": ["home", "convenience_store", "supermarket", "eat_out"]
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"role": { "type": "string", "enum": ["staple", "main", "side", "other"] },
"cook_method": {
"type": "string",
"enum": ["fried", "braised", "stir_fried", "steamed", "grilled", "raw", "unknown"]
}
},
"required": ["name", "role", "cook_method"]
}
},
"tags": {
"type": "array",
"maxItems": 4,
"items": {
"type": "string",
"enum": [
"fried", "braised", "stir_fried", "poultry", "seafood",
"protein_present", "vegetable_present", "vegetable_low",
"sauce_heavy", "refined_carb_heavy"
]
}
},
"uncertain_items": { "type": "array", "items": { "type": "string" } }
},
"required": ["slot", "label", "food_source", "items", "tags", "uncertain_items"]
}
外食→eat_out、烤→grilled、滷→braised。food_source:"外食"→L2;缺 items→L3。raw 不可被 normalize 覆寫。