模組三|腳本與確定性驗證器(Day 12–16)
昨天講那支 64 行 bash 的設計。今天講它檢查什麼,以及怎麼從一份檢查清單,反推出這個專案踩過哪些坑。
組 0 前置條件
├ 目錄存在
├ 口播稿 HTML 存在
└ 檔案 ≥ 8000 bytes ← 註解:likely a stub
組 1 骨架
├ 含資料陣列宣告
├ 含「片頭」
├ 含「收尾」
└ 含「附錄」
組 2 立場
└ 含「立場守則」guard block
組 3 節目慣例
├ 含收尾標記
└ 含「過場音效」
組 4 敘事節拍
├ 含「一句話事件」
├ 含拆解段標記
└ 含在地對照段標記
組 5 查核表
├ 含事實表宣告
├ 事實表非空(至少一列)
└ 含信源層級標籤之一
組 6 視角
└ 含某個議題視角的關鍵詞之一
組 7 禁止字串
└ 不得出現 `undefined` ← 註解:likely a render bug
檢查清單不會憑空長出來。每一條都在回答「這是為了防哪一次」,而有三條的註解直接指向了原因。
先講清楚證據強度:下面這三條的「事故」是我從註解與程式碼形狀推出來的,不是我找到了當時的事故紀錄。 我沒有那些 commit。它們發生在我開始寫剪輯記錄之前。所以這一節是推論,只是形狀證據很強。
第一條:不得出現 undefined。
註解寫「likely a render bug」。
我想不出第二個解釋:某次產出的成品 HTML 裡,應該是真的印出了 undefined。 前端模板讀了一個不存在的欄位,JavaScript 老實地把它 render 成字串,然後那份稿子被存下來了。
而它是 grep 得到的,所以它變成一條檢查。
第二條:檔案必須大於 8000 bytes。
註解寫「likely a stub」。
這條防的是:產出的東西看起來完成了,但其實是半截。 一份完整的口播稿至少有幾十 KB,一份只有標題跟骨架的空殼幾 KB。
這條檢查很土。它不知道內容對不對,只知道「太小就一定不對」。而它擋的正是 AI 最典型的失效模式:產出一個結構正確但內容空洞的東西,然後宣稱完成。
第三條:必須含信源層級標籤之一。
這條對應的是 Day 8 那 125 次「根本沒做分層」。查核表裡如果一個層級標籤都沒有,代表這一集的主張沒有被分級,而那正是「未定事實被講成定論」的前置條件。

組 6 只有一條:稿子裡必須出現某個議題視角的關鍵詞之一。
註解寫著:這是節目不可協商的視角。
我第一次重讀到這行的時候愣了一下。這不是技術檢查,這是編輯方針。
而它被寫成了 grep -q。
這件事可以有兩種讀法。
負面的讀法:把編輯立場降維成關鍵詞比對,是很粗暴的。稿子裡塞一個詞就能通過,但那不代表這個視角真的有被納入。它驗的是形式,不是實質。
正面的讀法:它擋住的是遺忘,不是敷衍。而遺忘才是常見的失效模式:趕稿的時候整集寫完,才發現某個角度完全沒碰到,這時候要補進去成本很高。有這條檢查,我在產出當下就會知道。
我傾向後者,但要加一句限制:這類檢查只能用在「不做就一定錯」的必要條件上,不能用來判斷品質。
一個關鍵詞的存在無法保證視角有被好好處理,但它的缺席可以保證沒有。必要條件的價值在於它的否命題。
同一條產線的另一頭,有一支更短的驗證器,它的 docstring 是我看過最誠實的技術文件:
驗證資料檔可被瀏覽器載入:合法 JSON + id 不重複 + 必要欄位齊全。
建議掛 pre-commit / CI,防再把合併衝突標記 commit 進去。
「防再」兩個字。
這支檔案只有一個 commit,而那個 commit 的訊息把整個事故鏈講完了:
解開 commit 進檔的合併衝突、救回被丟的 6 則、加驗證
事故的完整經過可以從這句話還原:
而它的檢查順序就是優先級,而且遇到第一條失敗就 return:
1. 逐行掃衝突標記(<<<<<<< / ======= / >>>>>>>)
2. json.loads 解析
3. 頂層必須是陣列
4. id 不得重複
5. 每筆必須有必要欄位
第一條放最前面,因為它是最直接的死因。而如果檔案有衝突標記,後面四條全部沒有意義:json.loads 會直接爆,錯誤訊息會是難懂的語法錯誤,而不是「你有衝突標記沒解」。
把最常見的死因放最前面,並且在它命中時停下來,可以省掉一整串誤導性的下游錯誤。

把這兩支腳本的檢查項排在一起,這個專案的傷疤就浮出來了:
| 檢查 | 它在說 |
|---|---|
不得出現 undefined |
模板 render 過壞資料 |
| 檔案 ≥ 8000 bytes | AI 產過空殼還宣稱完成 |
| 必須含信源層級標籤 | 有稿子沒做分層 |
| 事實表不得為空 | 有稿子的查核表是空的 |
| 掃合併衝突標記 | 衝突標記被 commit 進資料檔 |
| id 不得重複 | 撞過 id |
| 必要欄位齊全 | 有條目缺欄位 |
七道疤,七條檢查。
這給了一個很實用的閱讀方法:看到一份檢查清單,先問「哪幾條看起來很奇怪」,那幾條就是事故現場。
正常的檢查(欄位齊全、格式正確)到處都有;而「不得出現 undefined」「檔案要大於 8000 bytes」這種具體到不像通則的條目,一定是有人被咬過。
這 15 條檢查的代價是它們全部是硬編字串。
段落標記改個名字,對應的檢查就會失敗——它會噴錯,不會靜默通過(這點昨天我一開始記錯了,現場修正過),所以不算危險。但它意味著稿子格式跟檢查器是緊耦合的:我不能自由地重新命名段落,因為每改一個都要同步改腳本。
實際的後果是:我沒有改過段落名。 不是因為現在的命名最好,是因為改的成本超過收益。這是一種很小、但確實存在的僵化。
第二個代價:這 15 條全部是「必須存在」,沒有一條是「必須不存在」——除了 undefined 那條。
也就是說它擅長抓遺漏,不擅長抓多餘。一份稿子如果多了不該有的東西(例如複製貼上殘留的上一集內容),它一條都不會響。Day 27 會講這個問題在檔案系統層級的版本。
寫檢查清單的時候,每一條都要答得出「這是為了防哪一次」。
答不出來的,通常是憑想像加的——那些條目會在未來某天被人以「這條沒意義」為由拿掉,而且他大概是對的。
反過來,答得出來的條目要把答案寫進註解。「likely a render bug」這五個字,讓我幾個月後還知道那條檢查在幹嘛。沒有它,那行 grep -i undefined 看起來只是個怪癖。
第二條,關於怎麼把主觀規則變成檢查:
找必要條件,不要找充分條件。 「稿子有沒有好好處理某個視角」驗不了;「有沒有出現相關關鍵詞」驗得了,而且沒有關鍵詞就一定沒處理。
必要條件擋不住敷衍,但擋得住遺忘。而在真實工作裡,遺忘比敷衍常見得多。
第三條:把最常見的死因放檢查的第一條,並在它命中時立刻停。 否則你會得到一串由第一個錯誤衍生出來的、彼此矛盾的下游錯誤訊息。
明天講那擋不住的另一半:為什麼起草的那個模型不能自己給自己打分數。