我只要求把蛋塔改成蛋撻——一個字,527 張券每一張都出現了兩次
我只要求改一個字:把品項「蛋塔」正名成官方寫法「蛋撻」。Claude 接手,照專案文件裡白紙黑字的建議做——同義詞加進映射表,不要動資料檔。跑完批次合併,輸出長這樣:
Added: 527 ← 應該是 Changed
Changed: 0
Total output: 1513 ← 原本 986
這個動作沒有新增任何一張券,本來只該改名字。結果 986 張券的語料平白多出 527 張。一個字,踩爆了三顆埋了很久的地雷——而全程我的角色只有下判斷:計畫、執行、闖禍、診斷、修復,都是 Claude。
背景:品項家族籤,前一天才上線
coupons 專案在做的事,是把一堆優惠券照實際划算程度排給我看。前一天剛上線「品項家族篩選」:券列表上方八個籤——漢堡、薯條……點了只看含那個家族的券。
籤的顆粒度不是拍腦袋定的,是拿真實語料量出來的:原始品名有 36 個,兩兩組合篩選有 630 組,其中 391 組是空集合(62%)——因為一張券很少同時含兩種品項;收成 8 個家族之後,28 組兩兩組合全非空。這個數字是整個設計的承重牆:預設 AND 能成立,前提是每個籤本身已經是一次 OR。
上線第二天,新資料就咬了一口
隔天新一輪爬蟲帶進兩個新品名(一個堡類、一個蛋撻口味),不在 menu.json 裡。families() 當時的行為是:對不到的名字直接略過。於是一張含漢堡的券,篩不到「漢堡」。
要修。怎麼修,我下了這輪第一個判斷:
要修,但從通用性的觀點下去修
具體做法也是我提的,比 Claude 的原案省:對不到的就集中到一個籤底下,常態不顯示、有東西才出現。Claude 原本要在 crawl.yml 加 commit message 標註和 CI annotation,那半套因此被砍掉——籤本身就是訊號,而且它出現在使用者會看的地方,不是沒人看的 CI log。
連命名都有講究:不用「其他」,因為頁面分區已經有一個「其他」,意思不同;也不用「新品」,因為對不到的名字未必是新的——來源改名、拆規格、錯字都會產生同樣的結果,那會是一個可能說謊的標籤。id 用 unknown,避免讀 DOM 時 data-families 跟 category 混淆。
引爆點:一個字的正名
然後是我下的第二個判斷,也就是開頭那一個字:家族 label 該用官方寫法「蛋撻」。語料本身就不一致——短名寫 蛋塔,出現 546 次;長名品項的結尾寫的是 蛋撻。
專案的 CLAUDE.md 裡明文寫著:來源同義詞要加在 menu_mapping,不要改 coupons.json——後者隔天就會被爬蟲覆蓋回去。Claude 照做,然後跑 --allow-bulk,就跑出了開頭那份輸出:527 張券被當成新券加入,語料被複製了一份。
三顆疊在一起的地雷
追下去,是 merge.ts 裡三個從來沒引爆過的缺陷。
第一顆:比對迴圈用品項名稱組簽章。 找既有券的鍵是 code|price|品項簽章,而簽章由品項名稱組成。改名之後對不上,527 張全部走「新券」分支,配了新的 id。
第二顆:消失迴圈又重算了一次簽章,而且同樣沒正規化。 舊券的原簽章不在 seenSigs 裡,於是被當成「消失但保留」,再加回去一次。所以是複製兩次,不是一次。Claude 的第一版計畫曾經斷言「比對成功後舊券不會走消失那段」——這個斷言是錯的;實作者照 Out of Scope 停手沒改,review 則正確地判定這是 plan defect,不是實作疏失。
第三顆最陰:改名碰不到來源已不再提供的券。 那些券永遠不會被重新解析,所以永遠停在舊名字。實測留下一張過期券。
第三顆危險在哪?它沒有任何症狀。那些正是餵迴歸擬合集的過期券——一次改名,讓同一個品項的樣本分裂在兩個名字底下,CP 排序會慢慢變糟,而不會有人發現。前兩顆起碼會鬧出 1513 這種一眼看穿的數字;第三顆不會鬧出任何東西。
三顆地雷為什麼一直沒爆
因為純粹是順序上的運氣。唯一一次既有的改名——某個可樂品項,來源後來把長名簡化——是在還沒有任何券存著舊名字的時候加的。實測確認:語料中那個舊名 0 筆、同 code+price 重複 0 組。地雷不是不存在,只是剛好沒有人踩過。
Claude 的第二個錯:要求本身是錯的
修之前,Claude 還提過一個要求:「改名不得讓兩張不同的券併成一張」。這個要求本身就是錯的——正規化之後兩筆的 code、price、items 完全相同,它們就是同一張券,合併才是對的,硬保住兩張反而製造重複。真正該守的是別丟掉較早的 first_seen,而且勝負不能由插入順序決定。測試後來改成斷言這個。
修法:同一份改名表,用在三個地方
修法說起來一句話:同一份改名表要用在三處——比對索引、消失判定、消失券保留。locked 券不套用(專案硬規則);verified_at 維持凍結,因為改名不是重新確認。
修好後重跑:Added 0、Changed 527、總數維持 986,id 集合完全相同,first_seen 零改動。守衛算出 527 / 門檻 394——沒有 --allow-bulk 的話會被擋下來,而那是對的:一次五百多張的改寫,本來就該由人放行。守衛門檻刻意沒動。
附帶收穫:只寫有把握的規則
事件裡還有我下的第三個判斷:
至少 xx堡 屬於漢堡、xx蛋撻 屬於蛋撻 這種基本款總要有的。
Claude 原先是反對推論的,理由是「只補四分之一」——新品項還需要 type、portion、anchor_price。但規則+未分類 fallback 的設計把那個反對解掉了:只寫有把握的規則,其餘照樣落入未分類。
效果拿 36 個已知品項當成「沒見過」實測(精確查表關掉):形態詞規則推對 20 個、推錯 0 個、落入未分類 16 個。落入未分類的幾乎都是以食材命名的品項。中間有個刻意的取捨:不加「雞」這條規則——雞腿堡類會同時命中「雞」和「堡」,為了幾個炸雞品項引入優先序打架,不值得。
三個設計點:解析順序是 menu.json 精確查表 → 規則 → unknown,精確查表永遠優先,既有品項的分類一行都不會變;長詞優先,薯餅(其他炸物)能贏過 薯(薯條);蛋塔 舊寫法留在規則裡,因為 menu_mapping 只映射完整品名,來源日後出現 草莓蛋塔 不會被映射,得靠規則接住。規則放在 brand.json 的 item_families.match——它是資料不是程式碼,換品牌不用改 derive.ts。
還有一條驗收條件寫得特別白:規則不得掩蓋「還沒收錄」。build log 的 items absent from menu.json 警告要留著,check:unmatched 仍要列出那兩個品名——規則只解決「篩得到」,不解決 type、portion、anchor_price,把警告消掉等於把還沒解決的事一起藏起來。上線之後,未分類籤自動消失(籤數 9 → 8)——不是手動關掉的,是沒有券落入所以不渲染。自癒機制的第一次實戰。
順帶:兩處寫死的數字
我下的第四個判斷很小,但抓到的東西不小:把會變動的值記在 CLAUDE.md 裡很怪。於是「917 coupons」那三處被拿掉,改成記「怎麼查」。同一天發現 /methodology/ 頁面上有更嚴重的一處——寫著「目前系統內有 5 個品項待補基準、7 張券因此降級」,實際是 2 個品項、4 張券。那是公開頁面,而且那一節的標題,正好是「誠實揭露與限制」。具體數字一併拿掉。
事後的判準
回頭看,這件事之所以被發現,只是因為有人為了改一個字而去動了映射表——地雷在那裡躺了很久,平常根本不會有人走到。而我在整個事件裡的工作量,是四個判斷:通用性的修法、一個字的正名、分類器的基本款底線、把寫死的數字拿掉。剩下的,從錯誤的斷言到正確的修復,都是 Claude 做的。
所以判準是這條:要改一個品項名字,先問「既有券怎麼辦」。 管線裡凡是「以內容算出來的鍵」——這裡是 code|price|品項簽章——只要內容可被設定改寫,就必然有這一類的失配問題。下一次要在管線裡新增任何正規化層之前,先檢查一次。