| name | architect |
| description | Planning principles, model selection, and scope rules for the Architect agent |
| applyTo | .github/agents/architect.agent.md |
Architect Skills
Read this at the start of every session before producing any plan.
Planning
- Always check whether a new feature requires a DB schema change. If yes, mark Step 1 as a manual migration with verification SQL.
- Identify all code paths affected by a data model change — not just the obvious one (a new column needs both the table AND every writer that populates it).
- Never ship a plan with an untested API or signature change. Include an explicit smoke-test step.
- Confirm that all pending migrations are applied before designing features that build on them.
- Domain-policy surface audit(2026-07-11 教訓):需求若把某類記錄的欄位定義為「不適用、隱藏、免 QA、不得自動補值」,不可只改公開頁面的摘要列。計畫起草前必須建立 surface matrix,逐層追查:(1) scraper 分類與提取、(2) database writer/entity enrichment、(3) annotator/field corrections、(4) backfill 與 rollback、(5) QA detect/fix/reconcile、(6) admin quality metrics、(7) 公開摘要/敘事/FAQ/Calendar/回報 UI、(8) JSON-LD/SEO。每層都要明列適用 predicate、未來寫入守衛、legacy cleanup 與負向 fixture;缺任一層都可能讓已隱藏資料從衍生輸出重新洩漏。
- 量詞轉 acceptance matrix(2026-07-11 教訓):使用者說「所有 A」「A 與 B」「除 C 外」時,計畫必須把每個集合展開成驗收矩陣,逐列列出 UI、DB、QA、SEO 的期望行為,並加入邊界反例。不得把全集合靜默縮成某個 subtype(例如把「所有純出版記錄隱藏料金」縮成「只有雜誌文章隱藏料金」)。source、category、標題前綴不可代替真正的 domain invariant;混合型記錄必須有 negative fixture 防止過度套用。
- Design system first(2026-06-03 教訓):任何
web/ UI 計畫,優先採用本站既有 design system / design token 元件,不得預設用原生 HTML control。若已有 DesignSelect、dialog、button、input、badge 等對應元件,計畫中必須明列使用它;只有在設計系統無法表達互動語意時,才考慮新增原生 fallback。
- RLS/GRANT 縮權前先查 direct writes(2026-06-03 教訓):任何 migration 若要
REVOKE authenticated table grants、drop/recreate RLS policy、或改 view security 行為,必須先 grep web/ 是否仍有 browser-client .insert() / .update() / .delete() 使用該表。admin browser-client writes 可保留 table grant,實際權限由 admin-only RLS policy 限制。security_invoker view 也必須同步確認底層表的最小欄位 grants 與 row policy,不能只 grant view。
- Derived enrichment candidate audit(2026-07-11 教訓):任何衍生 enrichment pipeline(Vision/OCR、web search、title/person enrichment)在規劃時必做雙層審計:
candidate select 必須顯式載入 domain guard 欄位(例:event_form/source_name/image_url),且在實際 write 前必須 re-read 同一 row 再次驗證 guard,避免 TOCTOU 與 helper 直呼繞過查詢 filter。若來源存在共用 placeholder 媒體(例:Hanmoto noimage.jpg/no-cover),需以 canonical helper 精準拒絕 placeholder 變體,不得 blanket 禁用整個來源的真實內容。
- Supabase 分頁完整性先驗證(2026-05-26 教訓):凡計畫依賴
select(...).execute() 載入「保護判斷資料」(例如 field_corrections、blacklist、mapping)時,必須先驗證是否被預設分頁截斷。最少要做三件事:
- 比對
first_page_count 與 count='exact'
- 以
.range(offset, offset+999) 全量掃描一次確認總筆數
- 用已知關鍵樣本驗證是否在首 1000 筆之外
未完成這三步前,不可把問題歸因於 upsert/constraint 異常。
- 規模量化先於工具化(2026-05-26 教訓):起草任何「批次處理 / daily CI / backfill 腳本」前,必須先跑 DB SQL 量化候選池規模。
< 20 個事件 → 一律改為「一次性手動 patch」或「prompt-only 修法」,不做成 daily routine
20–100 個事件 → 一次性 backfill 腳本,跑完 archive,不接 CI
> 100 個事件且持續累積 → 才考慮做成 daily CI step
違反這條會徒增 CI 時間 + 檔案維護成本,且 Plan Critic 會擋下。Reference: 2026-05-26 enrich_organizers.py 計畫瘦身(5 Phase → 3 Phase)。
- 強標識符 (Strong Identifier) 優先去重原則(2026-06-07 教訓):凡具有 standard UID (如 ISBN、行銷條碼、官網極致 unique URL 等) 的特定
category/event_form 類型,在設計其查重合流機制(merger)時,應設置專屬的 Pass (例如 Pass 1.1) 優先於 start_date same-day 分組匹配之外執行。在時差 30 日內或年末佔位符階段(如 12-31)強制合併,並提供權威源 metadata 傳遞演算,確保不會受 scraper date extraction 漂移造成重複入庫。
- Sub-event source_id drift guard(2026-06-24 教訓):任何會新增、移除、或在中間插入 annotator-generated 子活動的計畫,必須先檢查子活動
source_id 是否為 _subN 位置序號。修法必須先用標題與日期匹配既有子活動並復用原 source_id,新子活動只能 append 到目前最大 _subN 之後,避免 re-annotation 把既有 _sub2〜_subN 覆寫成不同活動。若同日既有子活動是 bundle(開幕イベント、表演+解說等),GPT 拆出的 component(講演、映画、解説)必須命中並跳過 duplicate allocation;sub-event upsert 也必須尊重 field_corrections。
- 三語 Localization 硬配對守則(2026-06-07 教訓):任何 front-end categories, actor_types, 或者是 web schemas 異動,必須將
web/messages/{en,ja,zh}.json 三包語系檔做 simultaneous update 同步更新。若有漏配,會阻礙 production next build compiler 通過。
- Hybrid Venue 同時標註則(2026-06-07 教訓):針對
performing_arts 等混合型活動(現場+線上),設計上必須同時保留實體 location_address 與在 location_name 中並列 オンライン。記得兩個都標,不可因線上屬性而抹除地址資料。
- 日文都道府縣正規化禁用單一
rstrip("都道府県")(2026-06-29 教訓):rstrip 是字元集合剝除非後綴剝除,京都府→京、東京都→東京、北海道→北海 皆過度剝離(「都」同時是京都/東京內字)。任何依縣名建查表/分組/排序的計畫,必須要求多段 fallback(精確 label match → rstrip → 原值 direct → 去末字 [:-1]),並列子字串陷阱測試(東京都≠京都、神奈川≠奈良、和歌山≠山形/岡山、福岡≠福島/福井)。Reference: 2026-06-29 週報 geo-sort(commit 3c590e4)。
- 工作流/治理計畫三查(2026-07-10 教訓):規劃涉及 (a) push/merge 行為 → 先讀
.github/instructions/git.instructions.md 現行政策並比對實務(V-M-D/CI 實際怎麼推),矛盾拆獨立任務對齊,不在子段落暗改全 repo 政策;(b) 新增「供追蹤」的 metadata 欄位(spec frontmatter/DB 欄位)→ 先追其 type/parser/consumer,無 consumer=dead field 會被靜默丟棄(v1 的 worktree: frontmatter 就會被 spec dashboard snapshot parser 丟掉),可推導值優先於新增可過期欄位;(c) 強制/1:1 規則 → 只 prospective 套用並 grandfather 既有資產。詳見 history.md 2026-07-10 spec⟺worktree v1 三 P0。
Security Release Acceptance Matrix Guard(資安發布驗收分層)
自 2026-07-18 起,資安敏感 Web 發布的計畫與驗收必須分別蒐集四層證據:
- Static/build:security assertions、build 與 changed-hunk lint。
- Deployment provenance:記錄 pushed SHA,確認它仍等於 GitHub
main,且 Production deployment 成功綁定同一 SHA。
- Public production behavior:透過 production custom domain 驗證 headers、routes、robots、cookie 與 JSON-LD。
- Authenticated authorization behavior:驗證 OAuth、email magic link、session refresh/logout、ordinary-user deny 與登入後 Admin CRUD。
第 1 至 3 層 PASS 不代表第 4 層 PASS。缺少 auth credentials 或 test context 時,未測流程必須明列為 NOT TESTED residual risk/backlog,不得改寫成 PASS。Report-Only CSP 只提供 observability,不是 enforcement;後續切換 enforced CSP 前,必須先完成 violation inventory 與 regression testing。
若 immutable deployment URL 回傳 Deployment Protection 訊號(302、_vercel_sso_nonce、平台 X-Robots-Tag: noindex),不得弱化保護或判為 app regression。改以 GitHub/Vercel exact-SHA provenance 確認部署,再透過 production custom domain 做 app-level smoke。Full-file lint debt 與 changed-hunk regression 必須分開回報;零 changed-hunk overlap 可讓本次 feature 通過,但不會關閉既有 lint backlog。
Pre-flight Parallel-Operation & Baseline-Drift Guard(執行前並行操作與 baseline 漂移檢查)
計畫 gate 中的 DB count assertion 與 git HEAD 假設只在規劃時算一次。派工後若有並行 session(使用者另一視窗、手動腳本、cron)動到 DB 或 push,baseline 會漂移而 gate 仍用舊值放行。審核任何依賴 DB count 或 git HEAD 的計畫 gate 時,必須要求 Engineer 在 Step 0(執行開始) re-confirm 三點,偵測到 drift 即 STOP、先 reconcile 再進 gate:
- HEAD 對齊:
git fetch origin 後 git rev-parse HEAD == git rev-parse origin/main。
- Baseline count 對齊:重查計畫 baseline 的 DB count,與計畫值比對。
- 無預期外新 report_types:distinct
report_type 必須 ⊆ 計畫快照(並行 auto_qa.py live run 會注入 auto_simplified_chinese、missing_date 等新型別)。
r = sb.table('event_reports').select('report_type', count='exact').eq('status', 'pending').execute()
print('pending total:', r.count)
types = sorted({row['report_type'] for row in r.data})
print('report_types:', types)
配套規則(同次事件學到):
- DB cleanup 與 root-cause code fix 同一 atomic commit:只清 DB、抑制再生的 guard 留 uncommitted,nightly cron 跑 OLD origin code 會立即重生清掉的 reports。計畫 Verification 段必須明示兩者同 commit 並儘速 push。
- 中斷的 Engineer 帶 live DB work → 先做 3 點取證:(a) 哪些變更已 LIVE 在 DB、(b) code 是否已 commit、(c) rollback snapshot 是否完整;三點清楚才決定 fix-forward 或 rollback。
- report cleanup verify assertion 先分支 single-type vs multi-type:multi-type row 可能被 multi-type assertion 合法留 pending,「某型別 MUST NOT APPEAR」是過度簡化,會誤報。
- uuid 欄位禁用
.like():.like('id', prefix+'%') 觸發 Postgres 42883 (uuid ~~ unknown);改用 full-uuid .eq()、對 text 欄位(如 field_corrections.event_id)比對,或 Python 過濾。
- Spec ⟺ Worktree coupling(大型 feature 隔離;決策見 agent Gate,命令見 canonical):凡值得建
docs/specs/active/<slug>/ 的長期 feature(跨 session/多檔/新模塊),計畫必須 provision 一個 ttr-<slug>-worktree(branch feat/<slug>),1:1 綁定、僅適用規則上線後新建/重啟的 spec(既有 spec grandfather)。小改動不建 spec、不建 worktree,走主工作目錄 + trunk-based 流程。並行 session 的 git stash/clean 碰不到 linked worktree,避免未 commit WIP 被當「unrelated before deploy」掃走(2026-06-29 v8 投稿精靈 WIP 散落 5 stash 事件)。worktree 命令細節(state matrix、.git/info/exclude、STOP 條件)一律 defer .github/instructions/git.instructions.md § Isolated worktree,SKILL 只管「何時決策」。 runSubagent 同步但 return 常截斷——驗收看 git status/diff 不看回傳。
- worktree 並存期,任何「現況值」斷言先 grep 主 repo + worktree 兩邊對照:worktree(如
ttr-v8-worktree/)常領先主 repo 數個未 merge 改動;使用者跑 worktree dev server 時,畫面反映的是 worktree 而非主 repo。只讀主 repo 會把「主 repo 落後」誤判成「需要改 worktree」。推論表單/UI 實際 render 的 i18n 值,必須跟著元件 useTranslations(ns) 的 namespace 走到源頭——同名 key(如 fieldVenue 同時存在於 designSystem preview 與 eventIntake 兩個 namespace)只憑 grep 第一個命中行號就斷言會誤判。使用者對自己畫面的觀察優先於先前 summary-derived 結論;被質疑時第一動作是查證兩邊實體(grep 主+worktree、追元件 namespace),不是辯護原判斷(2026-07-01 Phase B2 fieldVenue namespace/codebase 誤判事件,詳見 history.md)。
Reference incident: 2026-06-25 — G3.0+G3.1 並行 session 漂移 baseline(pending 350→212→197)+ HEAD(60240df/631efb6),Engineer 中斷帶 live DB 變更但 code 未 commit(fix-forward commit 34bca54,Tester PASS 5/5)。詳見 architect history.md。
OCR Vision Array 欄位 — 三路徑 Sync Point(2026-05-26 教訓)
任何要在「OCR 上傳海報建活動」流程新增 array 結構欄位(如 co_organizers / sponsors / tags),必須同時改 4 處,缺一即 silent failure:
| 步驟 | 位置 | 說明 |
|---|
| 1 | web/app/api/admin/extract-from-image/route.ts Vision SYSTEM prompt | 加欄位定義 + 視覺位置提示(如「海報底部 credit block」「主辦資訊欄」)— Vision 模型對位置線索反應好於通用提示 |
| 2 | web/components/AdminEventTable.tsx ARRAY_FIELDS 集合 | 加欄位 key — 否則被 String(val) 強轉成字串污染 form state,TypeScript 不會報錯 |
| 3 | scraper/annotator.py SYSTEM_PROMPT | 加自然語句模式範例(如「○○との共催」「in cooperation with ○○」)— GPT-4o-mini 只認得條列式 label,自然語句要靠 few-shot 觸發 |
| 4 | web/components/AdminEventForm.tsx | 確認 form state 初始值 + 編輯 UI 存在 |
為什麼這是 Architect 必須掌握的 Guard: 該事件 schema 鏈完整(types.ts / DB / i18n / form state 都有 co_organizers),唯獨 Vision prompt + ARRAY_FIELDS + annotator prompt 三路徑漏抽,任何單點修補都無法解決 silent empty array 問題。Architect 在計畫中要明列 4 個 sync points,並標注「ARRAY_FIELDS 是隱性 sync point」。
Reference incident: 2026-05-26 — event 25e27de9 co_organizers / sponsors 三路徑漏(commits 280fdc4 + e54b925)。
Category Addition Checklist(新增分類的必備 6 步驟)
每次新增 Category 值,以下 6 個位置必須同一 commit 同步,缺一返工:
| 步驟 | 位置 | 說明 |
|---|
| 1 | web/lib/types.ts | Category union + CATEGORIES array + CATEGORY_GROUPS |
| 2 | scraper/annotator.py | VALID_CATEGORIES + SYSTEM_PROMPT enum string + definition |
| 3 | web/messages/{zh,en,ja}.json | categories.<key> + categoryDesc.<key>(三語各一) |
| 4 | web/lib/design/organicMotifs.tsx | case '<key>': 5 個 SVG 變體(缺少則 fallback 為預設 blob,無報錯)↳ 同時自動涵蓋 CategoryThumbnail UI + category OG image + event OG image(三者均動態呼叫 getSemanticSymbol(),無硬編碼列表) |
| 5 | web/app/api/admin/extract-from-image/route.ts + web/app/api/admin/annotate-event/route.ts | 兩個 GPT prompt 內的 category: 枚舉清單(靜默漂移風險最高:TS 不檢查 prompt 字串,GPT 缺值時會自創 health 等假分類) |
| 6 | Sync Guard 驗證 | python3 -c "from annotator import VALID_CATEGORIES, _check_category_sync; _check_category_sync()" pass |
步驟 4(縮圖)是最容易被遺漏的:TypeScript 不報錯、build 不失敗、只有在 /debug/motifs 頁才能看到 fallback blob。計畫中必須明確標注 Engineer 要加 case 到 organicMotifs.tsx。
步驟 5(GPT prompt 枚舉)2026-05-20 才被發現是同步位置:兩個 admin API 的 prompt 寫死了分類清單,types.ts 加新值時不會自動同步。GPT 看不到新值就會自創(例:types.ts 有 healthcare 但 prompt 缺 → GPT 回傳 health → 前端顯示 categories.health 生 key)。Architect 計畫必須將兩個 route.ts 明列為同步位置。
Reference incidents:
- 2026-05-16 —
design_craft、herbal、study_abroad 三分類在 annotator/i18n sync 後,縮圖 case 補加為獨立 commit,違反「同一 commit」原則。教訓:Architect 計畫從此明列步驟 4。
- 2026-05-20 — types.ts 38 值,兩個 admin API prompt 只列 18 值(缺 20 個含
healthcare),GPT 自創 health,前端顯示 raw i18n key(commits 997378c)。教訓:新增步驟 5。
- 2026-05-26 — 事件
25e27de9 顯示 categories.photography raw key。GPT 自創 photography 由 admin OCR route 寫入,繞過 annotator 的 _validate_categories()(commit 264afed)。教訓:見下方「Admin API enum 防禦」rule。
計畫起草前置動作(Required)
新增 Category / event_form 計畫起草前必跑:
grep -nE "CATEGORY_GROUPS|group_(arts|culture|sense|lifestyle|society)" web/lib/types.ts | head -20
憑記憶推測 group 名稱會撞「分類值與群組同名」陷阱(如 senses 是分類值、group_arts 是群組名,不存在 group_senses)。Reference: 2026-05-26 photography 計畫誤寫 group_senses 被 Tester 抓出。
Admin API enum 防禦(2026-05-26 教訓 — backlog item)
問題模式: GPT 在 admin OCR/annotate route 自創 enum 外的值(例:photography 不在 VALID_CATEGORIES 但寫入 DB → 前端 raw i18n key)。三道防線都擋不住:
- TypeScript:只檢查靜態型別,不檢查 runtime GPT 輸出
- DB CHECK:對
text[] 陣列元素無效(只能 CHECK 整個陣列)
- annotator
_validate_categories():只有 daily scraper 路徑會跑,admin route 不會
規則: 任何由 GPT 輸出 enum 欄位的 admin API route 必須在 server 端 intersect whitelist:
const VALID_CATEGORIES = new Set([...]);
const sanitized = (gpt_response.category ?? []).filter(c => VALID_CATEGORIES.has(c));
Architect 在「新增 enum 值」計畫外,遇到相關 admin route 修改時,應主動建議加白名單過濾器。Reference: 2026-05-26 event 25e27de9 categories.photography raw key(commit 264afed 修分類但未加過濾器,已建 backlog)。
Event Form Addition Checklist(新增 event_form 值的必備 6 步驟)
每次新增 event_form 值,以下 6 個位置必須同一 commit 同步:
| 步驟 | 位置 | 說明 |
|---|
| 1 | supabase/migrations/<NNN>_*.sql | events_event_form_check CHECK constraint(authoritative source) |
| 2 | scraper/annotator.py | VALID_EVENT_FORMS frozenset(L863 附近)+ EVENT FORM RULES prompt 區塊(L686 附近) |
| 3 | scraper/database.py | _VALID_EVENT_FORMS writer whitelist + _event_to_row() regression test;缺值時 scraper 輸出會在入庫前靜默被剝除 |
| 4 | web/lib/types.ts | EVENT_FORMS array / EventForm type,前台與 admin form 共同依賴 |
| 5 | web/app/api/admin/extract-from-image/route.ts + web/app/api/admin/annotate-event/route.ts | 兩個 GPT prompt 內的 event_form: 枚舉清單 |
| 6 | web/messages/{zh,en,ja}.json | eventForm.<key>(三語各一) |
Reference incidents:
- 2026-05-20 — migration 047 加入
broadcast/tasting/study_abroad 等新值並重命名舊值(concert→performance、lecture_seminar→lecture、film_screening→screening、festival→ 移除、sports→ 移除)。annotator.py 跟上了,但兩個 admin API prompt 留在 pre-047 命名 → OCR 回傳舊值 → DB CHECK 拒絕 → 管理画面保存 400 失敗(commit 9ecaae6)。教訓:建立此 checklist。
- 2026-06-04 — publication repair 證明前台還依賴
web/lib/types.ts EVENT_FORMS 與 web/messages/*.json 的 eventForm namespace。少同步任一處,會出現 raw key 或前後端 enum 漂移。
- 2026-07-11 — publication policy inventory 發現
scraper/database.py::_VALID_EVENT_FORMS 漏列既有 publication;scraper 即使正確輸出該值,_event_to_row() 仍會在 DB writer 層靜默剝除。教訓:writer whitelist 與 regression test 是第 6 個同步點。
🔁 Lesson-in-fix-commit Rule(避免 V-M-D ↔ docs update 循環)
問題模式:fix commit → V-M-D → docs commit → V-M-D 第二輪。每個邏輯變更跑兩次部署,浪費 token 又阻礙 session。
規則:Architect 設計計畫時,若預判修復會揭露新教訓(silent failure、GPT 污染、新 Guard、paired-handler 模式、第三方 API 行為),必須在計畫的 Verification 段明示:
「Engineer Step 3a 必做:在 commit fix 之前,將教訓寫入對應的 history.md / SKILL.md / agent.md,與 fix code 合併為單一 commit。」
對應稽核點:
- Engineer agent Step 3a(前置稽核)
- V-M-D Step 1a(推送前偵測未配對 docs 的 fix/feat commit)
不適用情境:cosmetic refactor、dependency bump、typo、純 i18n 字串調整、純 CSS 調整。
Supabase Python Client API Rules
order() 語法:.order("col", desc=True) — 不是 .order("col", ascending=False)(pandas 風格在此無效)。計畫中任何排序操作必須使用正確語法。
upsert vs update:既存 row 的部分更新必須用 .update().eq(),不可用 upsert(會觸發 INSERT fallback,撞 NOT NULL 約束)。
field_corrections.corrected_value は TEXT NOT NULL:qa_auto_fix.unlock_and_write() の lock_empty モードで corrected_value=None を渡すと HTTP 400 (23502) になる。必ず "" 番兵を使用すること(annotator FC guard は行の存在のみチェック)。lock_clean の list/dict 値は json.dumps(v, ensure_ascii=False) で文字列化する。Reference: _fc_value() helper in qa_auto_fix.py。
QA Heartbeat 自修復 Guard(field_corrections NOT NULL 番兵)
unlock_and_write() で field_corrections へ upsert する際の制約:
| mode | corrected_value に渡す値 | 理由 |
|---|
lock_clean(新値あり) | _fc_value(new_value) → list/dict は json.dumps()、str は str() | NOT NULL 対応 |
lock_empty(フィールドを NULL クリア) | "" (空文字列・番兵) | None は 23502 違反 |
unlock_only | FC row ごと DELETE | corrected_value 不要 |
なぜ "" 番兵で安全か:annotator.py の FC guard(L1136)は (event_id, field_name) ペアの存在のみチェックし、corrected_value の内容は参照しない。空文字列でも「ロック済み」と正しく判定される。
Partial-write リスク:lock_empty で events.update() 成功後に FC upsert が 400 エラーになると、イベントフィールドは NULL になるが FC ロックが存在しない中途半端な状態になる。_fc_value() helper で事前に型変換してこのリスクを防ぐこと。
Reference incident: 2026-05-25 — qa_heartbeat live run で 23 件が verify_failed(Error 23502)。lock_empty モードが corrected_value=None を渡していた。_fc_value() helper 追加後に解消。
Transient Failure Triage Guard(暫時性故障快速排除)
當使用者報「production 後台 / 客戶端寫入突然全部失效」且最近 24h 有 Vercel 部署,在深入結構性分析(migration / RLS / GRANT)之前先要求使用者執行 1 分鐘 DevTools 三點檢查:
- Network tab → 觸發那個操作 → 找對應 PATCH/POST 請求 → Request Headers:
- 無
authorization: Bearer ... → 客戶端 session 遺失(結構性 bug,深挖 auth callback / cookie 設定)
- 有 → 進下一步
- 同筆請求 Response body:
- 空陣列
[] 或空白 200 → RLS / expired JWT 過濾為 0 列(可能是暫時性,先請使用者重整再試)
- 4xx/5xx → 結構性錯誤,依 body 訊息深挖(如
42501 GRANT 缺失)
- Application → Cookies →
sb-<ref>-auth-token HttpOnly 欄:
- 打勾 → JS 讀不到 cookie,結構性 bug(auth helper 設錯)
- 未勾 → 正常
三點全綠 + 重整後恢復 = 暫時性 Vercel 滾動部署故障,不需動程式碼。 但應提案在 Engineer SKILL guard 加 0-row UPDATE 防護(.select("id") + data.length === 0 視為失敗)。
反面教訓(2026-05-15): 後台 events UPDATE 三項全失,第一輪假設 migration 069 漏 events 表 GRANT(錯誤),第二輪假設 ae9dc77 cookie 寫入問題(錯誤),準備寫新 migration。使用者實測 DevTools 三點全綠後判定暫時性。若一開始就先讓使用者跑三點檢查,可省 20 分鐘誤判時間。
Loading State Try/Finally Guard(loading state 必須用 try/finally 重置)
在審核任何包含 async button handler(handleConfirm、handleDismiss、handlePublish 等)的計畫或 PR 前,必須確認:
- 任何設定 loading state 的 async 函式,必須在
finally 塊中重置,不論例外或正常結束:
async function handleAction() {
setLoading(true);
try {
const result = await someServerAction();
} catch (err) {
console.error(err);
alert("操作に失敗しました。");
} finally {
setLoading(false);
}
}
- 外部 API 呼叫(GitHub、Google、LINE 等)必須加
AbortSignal.timeout(N),防止無限 hang:
const res = await fetch("https://api.github.com/...", {
signal: AbortSignal.timeout(10_000),
});
- 症狀識別:用戶點擊按鈕後 UI 卡在 loading 狀態,頁面重整才能恢復。根因通常是外部 API hang(fetch 永久 pending)+
finally 缺失的雙重故障。
- 設計計畫時,若 server action 中有外部 API 呼叫(
appendToHistoryFile、appendPendingRuleToSkill 等),必須在計畫中明確標注 Engineer 需加 AbortSignal.timeout。
Reference incident: 2026-05-17 — AdminReportsTable handleConfirm() 無 try/catch,b2e8b92 後累計 4 次 GitHub fetch,GitHub API hang 時按鈕永久卡死(commit 9319f57 修復)。
Multi-Session Stash Discipline(多線開發 Stash 紀律)
在審核任何多 session/subagent 平行開發工作流前,必須確認:
- Stash message 必須以
[STATE] 開頭:
[WIP] — 草稿,禁止合併
[READY] — 已驗證,可立即合併
[REVIEW] — 待人工確認
[BLOCKED] — 有外部依賴未就緒
./scripts/stash-status.sh list 是快速狀態總覽的入口,點 VMD agent 前應先執行。
- Promote 流程(
./scripts/stash-status.sh promote <N>):
- 自動檢查 working tree clean → fetch + rebase → pop → diff preview → commit → push prompt
- 任何步驟失敗即中止,stash 保留原狀
- VMD agent Step 0 自動攔截:VMD agent 啟動時會自動偵測
[READY] stash 並提示促銷,無需手動記得跑 stash-status。
- 3 天 STALE 警告:
[READY] stash 超過 3 天未合併,CLI 標記 ⚠ STALE——可能與 main 衝突或內容過時。
architect 設計 multi-session 任務計畫時,必須在「完成條件」中明確指定 stash state 標籤,例如:
完成後執行 git stash push -m "[READY] scraper/starcat: end_date fix"
Circular Merge Redirect Loop Guard(merged_into 循環 redirect 防護)
在審核任何 merger 輸出、或手動設定 merged_into_event_id 前,必須確認不形成循環:
- A → B → A、A → B → C → A 等任何循環都會讓
permanentRedirect() 無限觸發,瀏覽器收到 HTTP 308 loop,不停重載。
- 偵測指令(在
scraper/ 執行):
r = sb.table('events').select('id,merged_into_event_id').not_.is_('merged_into_event_id','null').execute()
merged_map = {e['id']: e['merged_into_event_id'] for e in r.data}
cycles = []
for start in merged_map:
visited = set(); cur = start
while cur in merged_map and cur not in visited:
visited.add(cur); cur = merged_map[cur]
if cur in visited: cycles.append(start)
print(f"{'✅ No cycles' if not cycles else f'⚠ {len(set(cycles))} cycle nodes'}")
- 修復方式:找到循環中「最不重要的節點」(通常是 gnews 二手來源),將其
merged_into_event_id = NULL。如有多個事件把此節點當 merge target,評估是否改指向真正的 canonical active 事件。
- 跨作品 merge 是警示訊號:
A(電影 X)→ B(電影 Y,不同片名)幾乎必然是 merger 錯誤,直接 NULL 斷開。
auto_qa 應加入此 cycle 偵測(待實作)。
Reference incident: 2026-05-15 — 57642851↔c8e813ae(赤い糸)二節點循環 + 84cb3ff3→2117c91e→a04e7ebb→84cb3ff3(台湾Filmake/めぐる面影)三節點循環,共 4 筆 DB 更新修復。
Weekly LINE Broadcast 系統設計節奏
在審核任何涉及 weekly_line_broadcast.py 的計畫前,必須先確認系統設計的執行時序:
- 時序:木曜(Thursday)CI 生成草稿 → 金曜(Friday)CI 發送。草稿 slug/title 日期應使用金曜發佈日,不是木曜生成日。
- 期間計算:發佈週報的期間為金曜 ~ 下下木曜(+13 天),不是 +6 天。
- 診斷 CI 失敗前:先讀
weekly_line_broadcast.py 的 cron 時序設計,不要急於本地補跑草稿,避免製造多餘的重複草稿。
- 多語言連結:broadcast 訊息中的事件連結必須直接嵌入語系路徑(
/{lang}/events/{id}),不可使用 /r/{id} 短連結(固定重導向至 /zh/)。
Reference incident: 2026-05-08 — 多重錯誤同時發生(Supabase order() bug、草稿重複、連結語系錯誤、日期範圍錯誤)。
Auto-Generate Promotion Checklist
When a plan includes promoting an auto-generated scraper (auto_scraper_status=success) to status=implemented, the plan must explicitly include all 5 steps:
- PR merged —
scraper/sources/<name>.py exists and is registered in scraper/main.py.
research_sources row: status = 'implemented'.
scraper_source_name = '<scraper key>' — MUST be filled or /admin/sources shows 0 events and cannot trigger Run Scraper (backend JOINs scraper_runs by this key). auto_generate does NOT fill this automatically.
- Smoke-test:
python main.py --dry-run --source <key> confirms events are found.
- For annual-subdomain sites (e.g.
YYYY.tiff-jp.net): replace hardcoded year with dynamic year resolution (follow redirect from root domain → fallback datetime.now().year).
Missing step 3 has no compile-time or runtime error — it silently appears as a 0-count row in the admin UI.
LINE Broadcast Query Guard
在審核任何涉及 weekly_line_broadcast.py(或未來任何 LINE push 腳本)的計畫前,必須確認:
_fetch_upcoming_events 必須過濾 annotation_status:只允許 annotated 或 reviewed 的事件進入廣播 pool。
.in_("annotation_status", ["annotated", "reviewed"])
- 不得假設
is_active=True 等於翻譯完整:新刮取的事件在 annotator 執行前 name_zh/name_en 為 NULL,annotation_status='pending'。若廣播在每日 pipeline 之前手動觸發,pending 事件會進入 pool,ZH/EN 訂閱者收到日文 fallback。
- 廣播 dry-run 後驗證 pool:確認
Fetched N upcoming events 中無 pending 事件(annotation_status 過濾後應比無過濾少幾筆)。
Reference incident: 2026-05-05 — 赤い糸 輪廻のひみつ 以日文出現在 ZH 週報,缺 annotation_status 過濾(commit 9b33ad3 後修正)。
Person Name Enrich English Guard
在審核任何涉及 enrich_person_names() 或人名翻譯邏輯的計畫前,必須確認:
- description_en 中的人名是英文音譯,不是片假名:GPT 翻譯時已將片假名 → 英文音譯(如
クー・チェンドン → Koo Kuan-Dong),片假名字串不在 desc_en 中,if ja_name in desc_en 永不命中。
description_en 必須走 _fix_person_names_gpt_en() GPT 路徑(鏡像 desc_zh 的 _fix_person_names_gpt)。已於 2026-05-05 修復;任何回退到 katakana direct-replace 的 PR 必須拒絕。
enrich_movie_titles / enrich_person_names 成功後必須自動鎖:透過 _lock_fields_via_corrections() upsert 進 field_corrections 表,避免下次 re-annotation 覆寫。
Reference incident: 2026-05-05 — event f970e4e3(月老)desc_en Koo Kuan-Dong 從 5/4 daily CI 後持續未修正;同事件多次手修又被 AI 覆寫,根因為缺 lock。
Known Person Map Guard(_KNOWN_PERSON_MAP 藝名/筆名覆寫機制)
在審核任何涉及 performer/director 翻譯、backfill_performer_i18n()、或新增 _KNOWN_PERSON_MAP 條目的計畫前,必須確認:
- GPT 無法可靠翻譯藝名/筆名:片假名
ギデンズ・コー 與漢字 九把刀 無語音對應關係(pen name),GPT 只能語音推測,必然失敗。所有已知藝名/筆名必須收錄進 _KNOWN_PERSON_MAP。
- 三語同時驗證:新增
_KNOWN_PERSON_MAP 條目時,ja/zh/en 三個值必須同時提供且驗證。資料來源優先序:eiga.com → 官方網站 → Wikipedia → 可靠第三方。
- 三個整合點全覆蓋:
_KNOWN_PERSON_MAP 生效位置:① 主 annotation loop(performer/director GPT 輸出覆寫)② performers[] 陣列逐元素檢查 ③ backfill_performer_i18n() Layer 0(已知名字跳過 GPT)。新增整合點時需三處同步。
- 翻譯規則(嚴格執行):
- 拉丁字母名 → 原樣保留,不翻譯
- CJK 漢字名無驗證來源 → 不翻譯(zh: 照抄漢字,en: 跳過 / NULL)
- 日文名無完整漢字 → 不翻譯成中文
- 片假名音譯 → 僅在有驗證來源時翻譯(
_KNOWN_PERSON_MAP 或 eiga.com lookup)
backfill_performer_i18n() 不可限定 is_active=True:非活躍事件同樣需要翻譯完整性。批次 backfill 腳本的 active 過濾需明確設計。
Reference incidents:
- 2026-05-09 —
ギデンズ・コー → 基登斯·高 (GPT 幻覺);正確 九把刀 / Giddens Ko。14 筆已驗證名人收錄 _KNOWN_PERSON_MAP,11 筆 DB 事件修正。
- 2026-05-09 — 46 筆非活躍事件因
is_active=True 過濾而缺翻譯,需一次性批次 backfill。
Performer Multilingual Fields Guard(performer_zh/en/director_zh/en)
在審核任何涉及 performer_zh、performer_en、director_zh、director_en 的計畫,或設計多語言表演者顯示邏輯時,必須確認:
- 欄位架構(migration 053/054):
performer TEXT:日文原名(供 ja locale)
performer_zh / performer_en TEXT:各語言名稱(GPT 填入或人工設定)
performers TEXT[]:所有具名表演者/發表者的陣列(支援多人)
director / director_zh / director_en:同上,用於導演
- locale 優先序:
zh → performer_zh || performer;en → performer_en || performer;ja → performer(不走翻譯欄位)
- AI翻譯標注規則:GPT 填入 performer_zh/en/director_zh/en 時,若該語言名稱未明確出現在來源文本,必須附加「(AI翻譯)」(如
黃以文(AI翻譯))。若來源中有該語言名稱,不加標注。
- academic performers[]:學術研討會(学会大会、研究大会、シンポジウム)中所有具名發表者(発表者/報告者/登壇者)必須列入
performers[],即使有 5 人以上。
- 手動設定必須鎖
field_corrections:performer_zh、performer_en、director_zh、director_en 手動修正必須同時 upsert 進 field_corrections,否則下次 re-annotation 覆寫。
- 新增人名欄位的 migration 模式:新增 performer/director 等人名欄位時,必須在同一 migration 中同時新增對應的
_zh/_en 欄位,避免二次 migration。
works.work_type 有效值:film | stage | exhibition | concert_tour | tv_drama | tv_variety | other。conference 不在允許清單,學術研討會用 other。
Reference incidents:
- 2026-05-08 — commit
65a50b9:SYSTEM_PROMPT 追加 AI 翻譯標記規則 + 學術大會 performers[] 填寫規則(Incidents A & B)
- 2026-05-08 — commit
191d939:migration 053 新增 performers TEXT[](Incident C)
- 2026-05-08 — commit
3822fb8:migration 054 新增 performer_zh, performer_en, director_zh, director_en(Incident D)
Mixed-Script Performer Name Guard(片假名+漢字複合名)
Annotator 的 _PERFORMER_INTRO_RE 使用 [\u4e00-\u9fff]{2,5} 純漢字 pattern,會將 カベルナリア 吉田 這類片假名筆名+漢字姓的複合名靜默截斷為 吉田。審核任何新 scraper 計畫,若來源頁面有結構化 instructor / 講師 / 登壇者欄位,必須確認 scraper 在 Event(performer=..., performers=[...]) 直接設定,而非依賴 annotator regex。Reference: wuext_waseda event 1be67e0f, 2026-05-16.
Multi-Session Course business_hours Guard
審核任何 multi-session 課程型 scraper(wuext_waseda、asahiculture 等)計畫前,必須確認 business_hours 在 scraper 層直接組裝(曜日 + 時間 + 全N回 + 個別開講日逐項列出),不留給 annotator。Annotator 只能抽取單一時間範圍(19:00〜20:30),無法保留曜日、N 回、跳週日期等資訊。Reference: wuext_waseda event 1be67e0f, 2026-05-16.
Organizer Multilingual Fields Guard(organizer_zh/en)
在審核任何涉及 organizer_zh、organizer_en 的計畫,或設計主辦方顯示邏輯時,必須確認:
- 欄位架構(migration 059):
organizer TEXT:日文原名(供 ja locale)
organizer_zh / organizer_en TEXT:各語言名稱(_KNOWN_ORGANIZER_MAP 或 GPT 翻譯填入)
- locale 優先序:
zh → organizer_zh || organizer;en → organizer_en || organizer;ja → organizer
_KNOWN_ORGANIZER_MAP:高頻主辦方(10 筆)hardcoded 在 annotator.py,確保翻譯品質。新增條目時 ja/zh/en 三語同時提供。
- 子事件繼承:annotator 主迴圈中,sub-event 自動繼承 parent 的 organizer_zh/en。
- 手動修正必須鎖
field_corrections:organizer_zh、organizer_en 手動修正必須同時 upsert 進 field_corrections。
i18n 文字欄位新增標準流程(已確立,第三次套用):
- Migration:ADD COLUMN
field_zh TEXT, field_en TEXT
- Annotator:SYSTEM_PROMPT schema + KNOWN_MAP + 翻譯邏輯 + 子事件繼承
- Scraper infra:
base.py Event dataclass + database.py _event_to_row() 映射
- Web:
types.ts interface + getEvent<Field>(event, locale) helper + page.tsx 渲染
- Backfill:KNOWN_MAP → kanji copy → GPT batch
日文漢字 ≠ 簡體中文判斷規則: 使用者反映「簡體字」時,先確認是 GPT 的 SC 輸出還是日文原文被顯示在非 ja 頁面。若為後者,正解是新增多語言欄位,非 SC→TC 轉換。
Reference incident: 2026-05-08 — commit 95c7ad8:migration 059 + annotator + web 全套。273/273 backfill。
SC→TC Mapping Maintenance Burden Guard
在審核任何涉及 _SIMP_TO_TRAD / _to_trad() 擴充的計畫時,必須留意:
- 手動映射表是打地鼠:表已從 ~50 筆成長到 300+ 筆仍不完整。每次 GPT-4o-mini 輸出新 SC 字,就需手動新增。
- 長期方案:評估 OpenCC 或完整 Unicode SC→TC 映射庫,一次解決完整性問題。在正式導入前,繼續維護手動表。
- 新增字時必須同步更新兩處:
annotator.py 的 _SIMP_TO_TRAD_RAW + auto_qa.py 的 SIMP_RE。
- DB patch:新增字後立即批量修正現有事件(scan all
*_zh fields + translate + FC lock)。
- 同步驗證指令(每次改
_SIMP_TO_TRAD_RAW / SIMP_RE / SC_ONLY 後必跑):偵測集(SIMP_RE、SC_ONLY)的每個字元都必須在 _SIMP_TO_TRAD_RAW 有映射,否則「偵測得到但 _to_trad() 修不掉」→ 無限 dismiss 循環(見下方 Three-Layer 反模式)。在 scraper/ venv 內執行:
python3 -c "
from annotator import _SIMP_TO_TRAD_RAW
from auto_qa import SIMP_RE, SC_ONLY
mapped = set(_SIMP_TO_TRAD_RAW)
simp = set(SIMP_RE.pattern) - {'[', ']'}
print('SIMP_RE 缺映射:', ''.join(sorted(simp - mapped)) or 'OK')
print('SC_ONLY 缺映射:', ''.join(sorted(SC_ONLY - mapped)) or 'OK')
"
理想兩行皆 OK;列出的任何字元 = 偵測得到但無法自動修復,必須補進 _SIMP_TO_TRAD_RAW 或自偵測集移除。
⚠️ 已知 backlog(2026-06-24 實測):SIMP_RE 缺 10 字、SC_ONLY 缺 110 字(union 113 唯一字元),偵測集長期領先映射表。新增映射時優先消化此 backlog;長期解仍是點 2 的 OpenCC。此 backlog 代表 commit 21b9c97(補 当/写/圆 三字)只解決冰山一角——只補真實資料出現的字,不代表偵測集與映射表已一致。
SC→TC Three-Layer Defence Model
SC→TC 防禦分三層,在審核任何涉及 _zh 欄位寫入路徑的計畫時,確認三層全覆蓋:
| Layer | 機制 | 位置 | 職責 |
|---|
| L1 — 預防 | _to_trad() on GPT output | annotator.py 主迴圈 | 捕捉 GPT 輸出的 SC 字元 |
| L2 — Chokepoint guard | _to_trad() on FC write | _lock_fields_via_corrections() | 捕捉所有寫入 FC 的 _zh 值(含 backfill、手動 upsert) |
| L3 — 偵測 + 修復 | SC_ONLY + fix_simplified() | auto_qa.py | 捕捉 L1/L2 遺漏的殘留 SC |
三層一致性規則:
- L1/L2 使用
_SIMP_TO_TRAD 字元映射(衍生自 _SIMP_TO_TRAD_RAW)
- L3 的
SC_ONLY 字元集必須是 _SIMP_TO_TRAD_RAW.keys() 的子集——不可包含不在映射表中的字元(否則偵測到但無法修復 → 無限 dismiss 循環)
- L3 的
fix_simplified() 掃描範圍必須與 _detect_simplified_chinese() 完全一致(目前 6 個 _zh 欄位)
反模式:
- ❌
SC_ONLY 包含 SC/TC 共用字元(如 征/蹈/零/蒙)→ 假陽性
- ❌
_SIMP_TO_TRAD_RAW 缺映射但 SC_ONLY 有該字元 → 偵測到但無法修復
- ❌
fix_simplified() 掃描範圍 < _detect_simplified_chinese() 掃描範圍 → 修復遺漏
Reference incidents: 2026-05-11 commits f7790a2, aa24400。
Reference incident: 2026-05-08 — commit 95b79ef:新增 9 字(诗/禅/图/猎/过/员/剧/别/于)。
Annotator name_ja Suffix Omission Guard
在調查任何「カテゴリバッジが表示されない」「カテゴリが DB に存在するのに画面に出ない」問題、または name_ja が期待と異なる場合に、必須確認:
name_ja と raw_title の diff を確認する:annotator は raw_title → name_ja 変換時に後置された分類語(「レポート」「告知」「詳細」「ご案内」等)を「タイトルの装飾」と判断して削除することがある。
- 脱落が発覚した場合の修正パターン:
sb.table("events").update({"name_ja": raw_title}).eq("id", eid).execute()
sb.table("field_corrections").upsert({
"event_id": eid, "field_name": "name_ja",
"corrected_value": json.dumps(raw_title, ensure_ascii=False)
}, on_conflict="event_id,field_name").execute()
- 「レポート」は削除対象になりやすい:報告記事/観覧記 の suffix「レポート」は annotator が装飾語と誤認して削除する典型ケース。
category: ['report'] が付与されていてもタイトルから「レポート」が消えることがある。
- デバッグ優先順序:① DB
category 確認 → ② messages/*.json i18n 確認 → ③ raw_title vs name_ja diff 確認 → ④ UI レンダリングロジック確認。
Reference incident: 2026-05-07 — f7ff56ca「台湾文化センター映画...トークイベント レポート」の name_ja から「レポート」が annotator によって削除されており、category: ['movie','lecture','report'] は正常だがタイトルバッジ調査で発覚。
Manual Translation Fix Persistence Guard
在審核任何直接 SQL UPDATE 翻譯欄位(name_zh / name_en / description_zh / description_en / performer)的計畫前,必須確認:
- 手動修正必同時鎖入
field_corrections:否則下次 annotation_status 翻回 pending 時,annotator 主迴圈用 GPT 重寫,所有人工修正瞬間蒸發。這是「修了又錯、錯了又修」迴歸鏈的根因。
- 正確 pattern:
from annotator import _get_supabase, _lock_fields_via_corrections
sb = _get_supabase()
sb.table("events").update({"name_zh": "月老"}).eq("id", eid).execute()
_lock_fields_via_corrections(sb, eid, {"name_zh": "月老"})
- enrich_ 函式自動鎖*:
enrich_movie_titles 與 enrich_person_names 成功 patch 後已自動 upsert field_corrections(2026-05-05 起)。手動修正不可漏這一步。
- 靜默
continue 是反 pattern:lookup 失敗必須 logger.warning,否則 CI log 看不到,錯誤翻譯靜默上線數日。
Reference incident: 2026-05-05 — event f970e4e3(月老)多次被修又被 AI 覆寫;今日同步補入 field_corrections 鎖定四個翻譯欄位後免疫。
Report Prefix Injection Guard(【レポート】 誤注入防護)
在審核任何涉及 report 分類注入、_inject_report_prefix() 邏輯、或手動觸發再 annotation 的計畫前,必須確認:
_inject_report_prefix() 只對兩種情況注入前綴(2026-05-22 修正後):
- 來源屬於
_HEADLINE_REWRITE_SOURCES(google_news_rss、nhk_rss、prtimes、walkerplus、note_creators):標題是新聞標題非活動名,GPT 分類 report 可信
raw_title 本身含有 _REPORT_TRIGGER_RE 關鍵字(レポート|レポ|報告|活動記録|開催記録|鑑賞記録|記録[||]|アーカイブ|recap|行ってきた|観てきた|鑑賞レポ|結果発表)
- peatix、eplus、doorkeeper 等爬蟲來源的
raw_title 是官方活動標題:GPT 可能把「ZINE Fes(ZINE 市集)」或「講演会」的部分面向誤分類為 report,此時 raw_title 不含 report 關鍵字,必須跳過前綴注入。
- 靜默污染特性:
report category 注入後 name_ja 加上 【レポート】;name_zh 加上 【活動報導】;name_en 加上 [Report] ;若無 FC lock,下次 re-annotation 會還原,但已對外顯示污染名稱。
- 防護修正(commit 7b2f821 後):
_src_is_rewrite = event.get("source_name") in _HEADLINE_REWRITE_SOURCES
_title_is_report = bool(_REPORT_TRIGGER_RE.search(event.get("raw_title") or ""))
if "report" in update_data.get("category", []) and (_src_is_rewrite or _title_is_report):
Reference incident: 2026-05-22 — event 6850265d(ZINE Fes 誠品生活日本橋)。GPT 分類 ['senses', 'workshop', 'lecture', 'report'],_inject_report_prefix() 對 peatix 爬蟲來源注入 【レポート】,name_ja/name_zh/name_en 三欄全被污染。修正:annotator.py 加入 _src_is_rewrite or _title_is_report 守衛 + FC lock name_ja/name_en/end_date。
Reference incident: 2026-06-13 — 大濛(霧のごとく)/ stranger f3554212。_REPORT_TRIGGER_RE 的寬泛 記録 命中票房文案「興行収入は…現在も記録を更新中」,_inject_keyword_categories() 誤注入 report,stranger name_ja 被污染成 【レポート】霧のごとく,全面 8 筆大濛上映場次卡在 error、首頁不顯示。修正(2026-06-16):記録 → composite terms 活動記録|開催記録|鑑賞記録|記録[||],並同步 SYSTEM_PROMPT report/recap 例外。
Re-annotation Date Clearing Guard(再 annotation 時的日期清除守護)
在手動觸發再 annotation(設定 annotation_status = 'pending' 並更新 raw_description)的操作前,必須確認:
- 必須同時清除
end_date(設為 None):annotator 的 "end_date": event.get("end_date") or annotation.get("end_date") 使用 Python or 邏輯——若 DB 中已有 end_date(即使是前次 GPT 錯誤推論的值),該值為 truthy,GPT 新推論值永遠不會被採用。
start_date 若也可能錯誤,一起清除:同理,若前次 annotation 從稀疏 raw_description 推論出錯誤的 start_date,再 annotation 也無法自動修正。
- 再 annotation 完成後,若日期正確,FC lock 兩個欄位:防止下次 scraper re-scrape 時 movie-extend 邏輯或 annotator 再次覆蓋。
- 標準操作 pattern:
sb.table("events").update({
"raw_description": new_raw,
"description_ja": new_desc,
"end_date": None,
"annotation_status": "pending",
}).eq("id", EID).execute()
Reference incident: 2026-05-22 — event 6850265d(ZINE Fes)end_date 在首次 annotation(從稀疏 raw_description)被設為 2026-05-22;手動更新 description_ja 後設 pending,但未清除 end_date → 再 annotation 時 event.get("end_date") = 2026-05-22(truthy)→ GPT 推論的 2026-05-23 被忽略 → 兩日活動只顯示一天。
Admin Form Component Prop Completeness Guard
在任何包含「新增 prop 到 shared form component」或「後台新增欄位」的計畫前,必須確認:
- Grep 所有 usage site:新增 prop 後執行
grep -r "AdminEventForm\|<ComponentName" web/components/ web/app/ 找出所有呼叫點,逐一確認新 prop 是否已傳入。TypeScript 若 prop 有 fallback default 不會報錯,靜默失敗難以發現。
- 後台 form 必須暴露 DB 所有可人工修正的欄位:新增 DB column 後,同步在
AdminEventForm.tsx 增加對應 input,否則管理員無法覆寫 AI 填錯的值。清單:
- 翻譯欄位:
name_*、description_*、selection_reason
- 結構欄位:
organizer、organizer_url、event_form、co_organizers、sponsors
- 語言支援:
primary_language、has_japanese_support、has_english_support、has_chinese_support
- performer(三語 i18n)
- TRACKED_FIELDS 必須包含新欄位:若欄位需觸發
annotation_status → reviewed,AdminEditClient.tsx 的 TRACKED_FIELDS 必須加入。陣列(string[])與布林值欄位需特別確認比對邏輯(不能用 ===,需深比對)。
- 陣列欄位雙向轉換 pattern:DB
string[] 欄位在 form 用 comma-separated string 表示;save 時轉換:
value.split(',').map(s => s.trim()).filter(Boolean)
讀取時轉換:(arr ?? []).join(', ')
Reference incident: 2026-05-05 — AdminEventForm 新增 tEventForm prop 後,AdminEventTable 呼叫時忘記傳入,TypeScript 無錯誤(commit 30999ea 補齊);同次 commit 補齊 organizer、event_form 等 8 個後台隱藏欄位。
Movie-Extend Invariant Guard
在審核任何修改 database.py _build_movie_extend_row()、新增 movie-extend 觸發分支、或擴張「對既存 row 部分更新」邏輯的計畫前,必須確認:
- 白名單欄位不可擴張至 P3.2 受保護欄位:
_build_movie_extend_row() 允許更新的欄位限定為 raw_description、business_hours、start_date、end_date、scraped_at、annotation_status(僅在 raw_description 變動時 flip 為 pending)。禁止新增 name_* / description_* / category / location_* / performer / organizer* / is_paid / price_* / event_* 任何欄位。新增白名單欄位的 PR 必須拒絕。
- 觸發條件は
existing_movie_state への登録のみ:key in existing_movie_state が唯一の発動条件。existing_movie_state は事前に DB 行の 'movie' ∈ category でフィルタ済み。e.category(incoming scraper event の category)を条件に入れてはいけない ——スクレイパーは annotator 前なので e.category = [] が常であり、and "movie" in (e.category or []) は条件として常に False になる(incident: 2026-05-20)。残りの条件:不在 blocked / reviewed / force_keys——これらは DB 行属性で判定するため問題なし。
- Partial 寫入必須用
.update().eq().eq() 而非 upsert:既存 row 確認存在後不可用 client.table("events").upsert(rows, on_conflict=...) ——supabase-py 會嘗試 INSERT fallback,撞 NOT NULL 約束(如 source_url)。詳見 engineer history 2026-05-05 — Partial-payload upsert violates NOT NULL constraints。
- 對處流程互斥檢查:計畫不可同時觸發 movie-extend 與
force_rescrape=true(後者全覆寫會吃掉 movie-extend 的 MIN(start_date) 保留語意)。如需 reviewed 電影更新場次,走 manual SQL + field_corrections 路徑,不可改 movie-extend 條件。
- 新類型擴張需獨立分支:演唱會巡演、巡迴展等「同 source_id 多檔期」需求,不可在
_build_movie_extend_row() 加 if-else,必須獨立 helper 並重新評估白名單欄位。
- 映画スクレイパーの end_date フォールバック:「終映日」未取得の場合
end_date = None のままでは _build_movie_extend_row() の MAX ロジックが機能しない(None fallback で old_end そのまま)。映画スクレイパーは「終映日」未取得時に end_date = start_date(当日)をフォールバックとして設定すること——毎日 MAX で端を延ばせる(incident: kyoto_cinema 2026-05-20)。
Reference incidents:
- 2026-05-05 — commit
8572104 引入 movie-extend;同 commit message 明示「by construction, extend rows touch zero P3.2-protected columns」與 .update() not upsert 的設計理由。
- 2026-05-20 — kyoto_cinema
end_date 固定在初日:e.category 参照バグ + end_date=None fallback 漏れ(commit a2f5828)。
Homepage Inline Card Divergence Guard
在審核任何修改「事件卡片視覺呈現」的計畫前(包含 location 顯示、徽章、日期格式、分類 chip、save 按鈕、報告按鈕等),必須確認:
- 首頁
web/app/[locale]/page.tsx 使用 inline list-style 渲染,不使用 EventCard.tsx:grep EventCard in page.tsx 會回傳 0 match。修改 EventCard.tsx 對首頁完全無效。
- 其他頁面(saved、category 列表、search)使用
EventCard.tsx:這些頁面共享元件。
- 修改卡片 UI 的計畫必同時列出兩個檔案:
web/components/EventCard.tsx(其他頁面)
web/app/[locale]/page.tsx(首頁 inline,行 ~290 附近的 events.map(...) 區塊)
- 共用邏輯必抽至
web/lib/:純函式(如 getCityLabel、extractCity、日期格式化)抽到 web/lib/<name>.ts,兩處 import;避免「修了一處忘了另一處」的迴歸。React component(如 chip 子元件)若值得共用可抽到 web/components/。
- 驗證 pattern(Vercel 部署後):
curl -s https://tokyotaiwanradar.com/zh | grep -c '<新元素 class 識別字>'
Reference incident: 2026-05-05 — commit 5a29c13 修 EventCard.tsx 城市徽章邏輯,但首頁完全無變化(首頁不用 EventCard)。commit 9f4b468 抽 web/lib/cityLabel.ts 共用 helper 後雙處同步生效。
Database Safety Rules
- NEVER batch-set
is_active = False based on end_date < today. Past events must remain is_active = True so users can view event history. Visibility for ended events is controlled by the frontend FilterBar ("顯示已結束活動" toggle), not by is_active.
is_active has exactly two legitimate write sources:
- Admin manually disables a specific event via the admin page.
merger.py deactivates a duplicate secondary event during merge.
- Any bulk UPDATE touching
is_active must be verified against these two sources before execution. If it does not match either, abort.
Annotator Backfill QA Rule
任何計畫包含 annotator.py --backfill-* 步驟時,必須在步驟清單中明確列出「backfill 後多語言欄位 QA 驗證」子步驟:
Incident: 2026-05-04 --backfill-tier1 導致 49 筆 selection_reason["ja"] 為中文,需人工腳本修正。
Scope
- State explicitly what is NOT in scope. Ambiguous scope = scope creep = breaking changes.
- List every affected file path explicitly — vague descriptions ("the scraper files") are not acceptable.
Docs Update Rule
/docs 是結構性文件,只在架構改動時需要更新。以下情況屬於架構性改動,計畫中必須明確包含「更新 docs/ARCHITECTURE.md 或 docs/SCRAPER_PIPELINE.md」步驟:
| 改動類型 | 需更新 |
|---|
| 新增或移除整個 CI workflow | ARCHITECTURE.md |
| 新增或移除 pipeline layer(auto_scraper、researcher 等) | SCRAPER_PIPELINE.md |
| 新增或移除 Supabase 整合點(LINE bot、新 webhook) | ARCHITECTURE.md |
新增 web/app/api/ 下的 API endpoint | ARCHITECTURE.md |
不屬於架構性改動(無需更新 docs):bug fix、單一 scraper 新增、i18n 修改、CSS 調整、新增個別 Supabase migration。
/docs 記錄的是「系統怎麼運作」,不是「系統現在的狀態」。不要在文件中寫入會每天變動的數字(scraper 數量、事件總數、migration 編號)。
Server Component + Realtime 分離模式 Guard
在任何包含「Server Component 中有 badge 或動態計數器」的 feature plan 中,必須明確標示:
⚠️ 此 badge / 計數器需要即時性嗎?若是,plan 必須包含「拆出 Client Component + Supabase Realtime 訂閱」步驟。
分離模式:
ParentComponent (Server Component)
└─ 查詢初始 count(SSR,一次性)
└─ <DynamicBadge initialCount={n} />(Client Component)
└─ Supabase Realtime 訂閱 INSERT + UPDATE 保持即時更新
強制規則:
- Server Component 的資料在 SSR 時固定,頁面渲染後不再更新。任何需要即時性的計數器 / badge,不得留在 Server Component 中。
- Client Component 接收
initialCount prop 作為 SSR 初始值,啟動後改由 Realtime 維護。
- 計畫中必須明確列出:哪個元件需要拆分、Realtime 訂閱哪個 table 的哪些事件(INSERT / UPDATE)。
無操作 Quality Section 應直接移除:
Quality check section 如果沒有對應的可執行 action(fix button / batch action),且數值永遠不清空(例如 archive cron 一天只跑一次的 expired-but-active),計畫不應包含此 section,已存在的應移除。只有數值能被操作清零的 check section 才值得顯示。
Reference incident: AdminTabNav badge(2026-05-02)—(commit 4a71258); expired-but-active section(commit cd4cc29).
Quality Check Design Rules
在任何包含「新增或修改 /admin/quality check 條件」的 feature plan 中,必須確認以下三點:
規則一:判斷欄位 = 詳情頁顯示欄位
Quality check 用哪個欄位 IS NULL 做判斷,該欄位必須是詳情頁實際 render 的欄位。
- 設計 check 前先查前端程式碼(
app/[locale]/events/[id]/page.tsx),確認「哪個欄位 null 才真正影響使用者體驗」。
- 錯誤範例:用
location_address IS NULL 做缺地點 check,但詳情頁顯示 location_name(commit b82849d → 80920ce)。
規則二:排除「天生無法填寫」的事件類型
設計每個 quality check 時,同步列出「哪些事件類型天生不需要此欄位」,並在 DB query 層排除:
| Check 類型 | 已知排除 | 排除原因 |
|---|
缺地點(location_name IS NULL) | source_name = 'gguide_tv' | 電視節目 |
缺地點(location_name IS NULL) | category 含 competition | 競賽/補助,全國性活動 |
若未排除 → flag 永遠無法清零 → 無意義的噪音。
排除語法(Supabase RPC):
.not('source_name', 'eq', 'gguide_tv')
.not('category', 'cs', '{"competition"}')
規則三:DB 層過濾優先於 client-side 過濾
所有 quality check 的排除條件必須推到 DB query(.not()),禁止在 JS 側 .filter() 排除。原因:DB 層過濾減少傳輸量,且排除邏輯集中在 query 中易於審查與維護。
Reference incident: 2026-05-02 quality page — gguide_tv 排除原為 JS client-side filter,後移至 DB query(commit 80920ce);competition 排除直接寫在 DB query(commit 4ca383a).
規則四:Client-Side Filter Prerequisites
撰寫任何依賴 DB 欄位的 client-side filter 前,必須確認以下三步驟:
- 欄位出現在
.select("...") 字串中:否則欄位值為 undefined,filter 條件永遠不成立,靜默通過所有資料(不報錯)。
- TypeScript interface 包含該欄位及正確型別(如
location_prefectures?: string[] | null)。
- 確認以上兩點後才撰寫 filter 邏輯。
反例(commit bf22756 之前):
if ((e.location_prefectures?.length ?? 0) > 1) return false;
正確做法:
.select("id, location_name, location_prefectures, ...")
interface QualityRow { location_prefectures?: string[] | null; ... }
if ((e.location_prefectures?.length ?? 0) > 1) return false;
Reference incident: 2026-05-02 — location_prefectures 未加入 select,多城市活動過濾靜默失效。
SSR Props Pass-Through Guard(Server 抓取資料必須傳入子 Component)
在審核任何 Server Component(page.tsx) 的 PR 前,若 page.tsx 抓取了資料並用 prop 傳給子 component,必須確認:
- page.tsx 抓取的每一份資料都有對應的 prop 傳入:若 page.tsx 抓取
worksList 但 <AdminEventTable> 沒有 initialWorks={worksList},資料完全浪費,子 component 會自己做一次 client-side 重複 fetch。
- 用戶即時操作(下拉、選單、自動完成)的資料不能依賴 client-side fetch:非同步 fetch 有 race condition——用戶在 fetch 完成前操作,看到空清單。
- 正確模式:
const { data: worksData } = await supabase.from("works").select(...);
const works = (worksData ?? []) as Work[];
return <AdminEventTable initialWorks={works} ... />;
const [works, setWorks] = useState<Work[]>(initialWorks);
useEffect(() => { }, []);
- 確認清單:在 page.tsx 搜尋所有
const { data: ... } = await supabase.from(...) 呼叫,對照子 component 的 Props interface 確認每份資料都有對應 prop。
Reference incident: 2026-05-16 — page.tsx 抓取 worksList 但未傳給 AdminEventTable,works state 初始化為 [],用戶開下拉時 fetch 未完成 → 空清單(work選項又不見了)。
QA Keyword Precision Guard(地名關鍵字子字串污染)
在審核任何修改 TAIWAN_VENUE_KEYWORDS(或類似地名比對清單)的 PR 前,必須確認:
- 禁用縮寫裸字串:
'新北' ⊂ '新北島'(大阪市住之江区);'台中' 可能出現在日本地名中。必須使用完整行政單位名:'新北市'、'台中市' 等。
- 新增前 grep 日本地名:對新關鍵字執行
grep -r "<keyword>" scraper/ 確認無日本地名誤觸。
- Dedup 失效機制:auto_qa dedup 僅在「事件
updated_at ≤ report confirmed_at」時跳過。每次 scraper upsert 更新 updated_at,即使 dismissed,下次 run 仍重新觸發——假陽性關鍵字無法靠 dismiss 解決,必須修正關鍵字本身。
Reference incident: 2026-05-05 — '新北' 匹配大阪市 新北島,event 371cf624 (GRAFFYHALL) 連續三次 auto_qa_taiwan_venue (commit 6b7174a)。
auto_qa False Positive Guard
在審核任何 auto_qa 偵測器的改動或新增 auto_qa_* 類型前,必須確認:
- 城市名誤報:
location_name 為純城市名(東京、大阪、岡山 等)時,新聞彙整類 source(google_news_rss、koryu 等)本就無法提供具體場地。auto_qa_missing_address 不應 flag 此類事件。維護 VAGUE_CITY_NAMES frozenset。
- 海外場地誤報:非日本場地(スイス、フランス 等)不在日本地址查核範圍內。維護
OVERSEAS_KEYWORDS tuple。
- 正常 flag 保留:有具體場地名(大學、美術館、○○ホール 等)但
location_address = NULL 的事件,仍應 flag。
- 偵測精準度原則:寧可少報,不可誤報——誤報會使管理員對回報系統失去信任。
Reference incident: 2026-05-04 — 13 筆 auto_qa_missing_address pending,其中 5 筆為城市名/海外場地誤報(commit 15c5b4b)。
RLS Cross-Status Query Guard
在任何涉及「SSR 頁面查詢關聯資料(父事件、鏈結實體)」的 feature plan 中,必須確認以下三點:
規則一:anon key 不讀非 active 資料
RLS "Public read events" policy 限制 anon key 只讀 is_active = true 的事件。若查詢目標(如父事件)被下架(is_active = false),anon key 查詢靜默回傳 null,不拋 error,難以察覺。
規則二:跨 active 狀態查詢必須用 service role key
若查詢的關聯資料可能處於 is_active = false 狀態(例如:父事件下架、存檔紀錄),必須在 Server Component / route handler 中用 service role key:
const adminClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const { data } = await adminClient
.from("events")
.select("id, name_ja, name_zh, name_en")
.eq("id", parentId)
.single()
強制限制:service role key 絕對不得暴露到 client-side。只在 Server Components 或 API route handlers 使用。
規則三:最小欄位原則
用 service role key 查詢關聯資料時,只 select 當前頁面真正需要的欄位(如 id, name_ja, name_zh, name_en),不得用 select("*") 避免洩漏敏感欄位。
Reference incident: 2026-05-02 — 父事件(台東祭)被設為 is_active = false 後,子事件詳情頁父事件連結消失;改用 service role key 後恢復(commit f5931e0)。
Admin UI Dashboard Necessity Check
Before planning any new admin page or dashboard column whose primary output is a count / status / health number, ask:
- 「這頁面真的會被點開嗎?」 — Admin UI requires manual navigation. If the signal isn't urgent enough to justify proactively visiting
/admin/..., it will be ignored.
- 「沒有 action 按鈕的純計數值得做嗎?」 — A dashboard column without a one-click fix / batch action / drill-down is visual decoration. If the user will read it and do nothing, the value is near zero.
- Is a passive push channel cheaper? — LINE message, weekly email, or auto-filed GitHub issue cost less than a new page and have higher retention (signal arrives without being requested).
Default preference order for monitoring features:
- Passive push (LINE / email / issue) → preferred for periodic health, budget, quality summaries.
- Existing page extension with actionable column (e.g. add a row to AdminEventTable that has a fix button) → acceptable when tightly coupled to existing workflow.
- New
/admin/<topic> page → only when the user explicitly asks for an interactive审查 surface (multi-row triage, manual selection, bulk action).
If a plan introduces a new admin page or count column with no associated action, document the rationale explicitly in the plan; otherwise propose the passive push variant first.
Reference incident: 2026-05-01 Tier 1 monitoring — /admin/quality page and /admin/stats SLA columns were planned, implemented, then撤銷 same week; only the LINE budget push (weekly_report.py) survived.
Annotator Scraper-Priority Guard
Before approving any change to annotator.py annotation field priority, verify:
- Scraper values always take precedence over GPT inference for factual fields:
start_date / end_date
location_name / location_address
business_hours
is_paid
- GPT only fills in when the scraper left the field empty (
None/null).
- Translation fields are always GPT-generated —
name_zh, name_en, description_*, location_name_zh/en, business_hours_zh/en.
name_ja special case: when name_ja_locked=true, the scraper's value is preserved verbatim. The source title may be in Japanese, Chinese, or English — name_ja is a field identifier, not a language constraint.
location_url — conditional write: GPT may extract it from raw_description text (schema prompt must say "extract from text only, no hallucination"). Write only when non-null (_loc_url = event.get("location_url") or _str(annotation.get("location_url"))); never write null back to DB — null would overwrite admin-entered values. This is a field shared between scraper/GPT extraction and admin manual entry. (commit fb568c4, 2026-05-02)
- The safe way to fix a GPT-overwritten date: prepend
開催日時: YYYY年MM月DD日 header to raw_description, then set annotation_status='pending' to trigger re-annotation.
Event Intake Alignment & Upgrade Guards (建立與標註活動防漂移守護)
在審核任何涉及使用者手動建活動、海報 OCR、或是前台標註活動(Annotate Web)功能的計畫或修改時,必須強制執行本三層守護守則:
1. 同步與中心化共享規則(Shared Modules Mandate)
- 絕對禁止各自撰寫相似表單處理:Admin 與 Owner 的創辦活動頁面(
web/components/AdminCreateClient.tsx 與 web/components/OwnerCreateClient.tsx)的表單欄位映射、成功與錯誤 UI 回饋、狀態機、讀秒動畫、雙區塊同位 notice 頁面以及標註 client action 必須 100% 共用 web/lib/eventIntakeClient.ts 的輔助函式。
- Byte-for-byte OCR 提示詞對齊:兩端的 OCR Vision GPT prompts(
extract-from-image/route.ts)在所有文字提示字、欄位清單、以及 Web-only glossary(例如特定的 記念講演会 -> 紀念演講/Commemorative Lecture)必須保持字元級同步,防止單邊擷取能力失衡。
2. 合併升級限制與來源信心閘門(Source-Confidence Gate)
在 Web 搜尋標註進行欄位合併覆寫(eventFieldMerge.ts)時,為避免「弱相關搜尋」產生的髒資料破壞 OCR 抓取的特徵,或直接洗掉已存在的值,必須套用以下雙信心階梯:
- 填充覆寫(Fill Null):若現有欄位為空(
null、""、[]),只要外部搜尋結果具有基本相關性即套用(要求 bestScore >= 3)。
- 升級覆寫(Upgrade Overwrite):若現有欄位非空(例如地址可能是不含郵遞區號的短縮字或簡體字,欲升級為 Geolocated 正確格式),外部搜尋來源必須是高可信度關聯(要求最高命中評分
bestScore >= 6)。若評分不足,禁止覆蓋現有非空欄位。
3. 本地手動指紋保護鎖(Manual-Value fingerprinted lock)
- 為了避免自動標註(Annotate Event Action)在後台回傳時直接蓋掉使用者在本 session 手動編輯 的精確調整,前端 form state 必須在
onChange 時記錄已手動修改的欄位鍵值(lockedFields: string[])。
- 當 API 返回標註結果進行 merge 覆寫時,必須排除
lockedFields 指紋列表中的所有欄位。這確保了「手動修改永久優先於 Annotate Web 自動補全」的安全保證。
Scraper Date Timezone Guard(爬蟲日期時區守護)
在審核任何 scraper 的 start_date/end_date 傳入邏輯前,必須確認:
- 禁止傳 JST-aware datetime:
datetime(..., tzinfo=timezone(timedelta(hours=9))) 傳入 Supabase 後以 UTC 儲存,JST+9 偏移導致日期倒退一天(2026-05-08T00:00:00+09:00 → 2026-05-07T15:00:00+00:00)。
- 正確模式 — UTC midnight:
start_date = jst_dt.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=timezone.utc)
start_date = jst_dt
- naive datetime 也有風險:
datetime 無 tzinfo 時 Supabase 依伺服器時區解讀(通常 UTC),一般安全,但不如明確設定 UTC midnight。
- 驗證:新 scraper dry-run 後,確認 DB 的
start_date 與來源頁面的日期完全一致。
Reference incident: 2026-05-07 — Stranger scraper f3554212 start_date 存為 2026-05-07T15:00:00+00:00(應為 2026-05-08),Vercel 顯示前一天(commit b7dc34f)。
Hallucination Scan Safety Guard
Before acting on any hallucination scan result (address/location not found in raw_description), verify:
- Scan result = suspicion only: An address absent from raw_description does NOT confirm hallucination. GPT correctly recalls well-known venues from training data.
- Always verify with Google Maps before editing: Search the venue name directly — takes 30 seconds. Do NOT infer address from venue name, neighborhood, or landmark associations.
- Venue name ≠ postal address:
MoN Takanawa (inside Takanawa Gateway City) has postal address 港区三田 — not 高輪. Station names, building brands, and postal addresses can differ.
- Incident: 2026-05-02 — architect changed correct GPT address
港区三田3-16-1 to wrong 港区高輪4-10-30 based on venue name reasoning. Reverted after user confirmation.
Universal Year-Anchor Guard(年份錨點注入 — 全來源)
在審核任何涉及 annotator 日期提取邏輯、或分析任何來源事件年份錯誤時,必須確認:
-
年份錨點注入必須覆蓋所有來源(非僅 gnews 類):
- 觸發條件:
scraped_at AND "記事配信日:" not in raw_desc(不限 source_name)
- 注入格式:
(記事配信日: YYYY-MM-DD)\n\n 前綴
- 必須在 gnews article fetch 之後注入:article fetch 會替換 raw_desc,丟失已有的年份錨點
-
兩類年份幻覺場景均被覆蓋:
- (a) raw_desc 完全無 4 位年份(e.g.
5月8日(金)公開)→ GPT 從訓練資料猜年,可能猜出過去年份
- (b) raw_desc 含誤導性年份(e.g.
2025年11月に公開を迎えた台湾国内での興行収入,指另一國的上映年)→ GPT 誤用該年份為日本活動年份
-
SYSTEM_PROMPT DATE Rule 7 必須引用 記事配信日:
- 規則文字含
(記事配信日: YYYY-MM-DD) → use that year as the reference year
-
field_corrections 鎖定已修正的年份:
- 若曾因幻覺設定錯誤年份,修正後必須同時鎖入
field_corrections(start_date, end_date),否則 re-annotation 重新覆寫
-
失效症狀:事件 start_date 年份比 scraped_at 年份差超過 1 年(可能是過去也可能是未來)
驗證命令:
grep -n "記事配信日\|scraped_at year anchor" scraper/annotator.py | head -10
Reference incidents:
- 2026-05-06 —
0d33b617 (gnews 熊本チップ・オデッセイ) scraped_at=2026-04-28 → annotated start_date=2024-04-01(應為 2026-04-12)
- 2026-05-07 —
dded67a6 (uplink_cinema 霧のごとく大濛) raw_desc 含「2025年11月に公開を迎えた台湾国内での」(台灣內地年份),GPT 幻覺 start_date=2025-05-08(應為 2026-05-08)
enrich_movie_titles Sub-Event Hallucination Guard
在審核任何涉及 enrich_movie_titles() 修改、或分析 gnews sub-event 電影標題錯誤的計畫前,必須確認:
-
gnews sub-event 的 name_ja 不可作為 eiga.com lookup 的標題來源:
- 若
source 在 _NEWS_MOVIE_SOURCES 且 bracket 命中來自 name_ja(非 raw_title),且事件有 parent_event_id → 必須 continue(跳過)並記錄 logger.warning。
- Sub-event 的
name_ja 是 GPT 從極薄語境(單句描述 + 文章全文)生成,極易幻覺電影名稱。
- 只信任
raw_title(scraper 直接捕獲)中的括號標題。
-
enrich_movie_titles select 查詢必須含 parent_event_id:
- Guard 邏輯需要讀取
parent_event_id,若 select 字串缺少此欄位,event.get("parent_event_id") 永遠 None,guard 靜默失效。
-
SYSTEM_PROMPT SUB-EVENT name_ja 規則的 CRITICAL 補丁:
- 規則已加入:若 sub-event 標題是描述性位置短語(e.g., "早稲田大学での上映会")且電影名稱未直接出現在該 sub-event 描述旁,禁止從文章其他段落推斷電影名稱。
- 核查點:SYSTEM_PROMPT 中的
SUB-EVENT name_ja 段落須包含 CRITICAL — DO NOT INFER MOVIE TITLES 文字。
-
根因機制(供調試參考):
- GPT 標注父事件時,同時識別多個 sub-events
- 含多部電影的文章中,GPT 可能把 A 電影的場館名稱配對到 B 電影的放映日期
- 場館描述性 sub-event(如 "早稲田大学での上映会")因無明確電影名稱,GPT 從文章語境推斷並幻覺
驗證命令(執行後確認 _title_from_raw 旗標存在):
grep -n "_title_from_raw\|skipping enrich for news sub-event" scraper/annotator.py
Reference incident: 2026-05-05 — d18339d5 (gnews_f9a2e51bc89a_sub3) raw_desc 只有 1 句話,GPT 幻覺 name_ja = "赤い糸 輪廻のひみつ"(月老),應為チップ・オデッセイ(造山者)場次。無 bracket → enrich_movie_titles 未鎖定,但 GPT 直接寫入 DB(annotation_status=annotated)且人工修正前無法自動偵測。
Cinema Series Sub-Event Sub_Events Guard
在審核任何涉及 ks_cinema(或其他系列頁電影場館來源)的 annotator 計畫,或分析同一電影出現多筆事件的問題前,必須確認:
- Annotator sub_events 規則有電影時段豁免:SYSTEM_PROMPT Rule 1 必須有明確 EXCEPTION 說明「電影類別的單一放映若只有多個時段(如
4/25~5/1 10:00、5/2~8 14:40),不建立 sub_events;改用 start_date=首日、end_date=尾日,時段細節放 business_hours」。
- 程式碼守衛存在:annotator.py 中
_cinema_sources = {"ks_cinema"},若 source_name in _cinema_sources AND source_id ends in _{digit} AND parent_event_id=None → sub_events = [](防止首次 race condition)。
- Race condition 已知:ks_cinema 系列頁 sub-events(
_0, _1, _2)在 scraper 首次執行時 _get_parent_uuid 查不到 parent(尚未 commit)→ parent_event_id=None。這是已知的架構限制;現有守衛已防止 annotator 誤生成 _sub1。
_sub1 不會被 merger 消除:同 source 的事件(ks_cinema → ks_cinema)被 Pass 1 source_name == source_name 跳過。若 DB 已有殘留 _sub1,需人工 deactivated_by_pass=admin_manual。
Quick Check(出現重複電影事件時):
r = sb.table("events").select("id,source_id,is_active,parent_event_id").ilike("source_id", "%_sub1%").eq("source_name","ks_cinema").execute()
sb.table("events").update({"is_active": False, "deactivated_by_pass": "admin_manual"}).in_("source_id", [e["source_id"] for e in r.data if e["is_active"]]).execute()
Reference incident: 2026-05-06 — 車頂上的玄天上帝(ks_cinema taiwan-filmake_2_sub1)因 SYSTEM_PROMPT 多時段規則 + race condition 生成 _sub1,出現 4 筆重複(其中 2 筆已被 merger 停用)。修復:停用 _sub1、SYSTEM_PROMPT 加豁免規則、annotator.py 加程式碼守衛。
Organizer Non-Hallucination Guard(annotator.py few-shot 污染防護)
在審核任何涉及 organizer 欄位,或評估 category_corrections few-shot 範例設計的計畫前,必須確認:
- GPT 返回的 organizer 必須出現在原始文本中:
_gpt_org_raw 在寫入 update_data 前,需確認 _gpt_org_raw in (raw_title + " " + raw_description)。不存在則丟棄(_guarded_organizer = None)並發出 WARNING log。
- few-shot 例子中的具名機構有污染風險:
category_corrections 的補正例若含具體主辦方名稱,GPT 可能對缺主辦人的其他活動 hallucinate 相同名稱。這是 named entity 的跨事件遷移效應。
- contamination 路徑:
category_corrections few-shot → SYSTEM_PROMPT 注入 → GPT hallucinate organizer → P0 保護鏈保存錯值 → 子事件繼承 → 雪球效應。
- organizer 必須走
_ai_or_existing():確保 P1(field_corrections)保護也覆蓋 organizer 欄位。
驗證指令(改動 annotator.py organizer 邏輯後執行):
import ast, pathlib
src = pathlib.Path("scraper/annotator.py").read_text()
assert "_gpt_org_raw" in src and "_source_text" in src and "Organizer hallucination detected" in src
print("Guard: OK")
Reference incident: 2026-05-06 — category_corrections 含 2 筆 セシリアママのHappy Table... few-shot 範例,導致 31 件 Peatix 活動被 hallucinate organizer = "セシリアママ"(commit fix(annotator): add organizer non-hallucination guard)。
Joint Distributor Split Guard(聯合配給商拆分守護)
在審核任何設定「配給」→ organizer 的案例,或分析 organizer 字串含「/」的事件前,必須確認:
- 「配給:A/B」中「/」代表聯合配給:A 和 B 是兩家獨立公司,不可整串存為 organizer(例如
"JAIHO/Stranger" 是錯誤的)。
- 正確拆分方式:排名先者(左邊)→
organizer,其餘 → co_organizers[]。
- 工具驗證:
if "/" in (organizer or "") or "/" in (organizer or ""):
parts = re.split(r"[//]", organizer)
organizer = parts[0].strip()
co_organizers = [p.strip() for p in parts[1:]]
- 同樣需鎖
field_corrections:organizer、co_organizers、organizer_type 手動修正後必須同時 upsert。
Reference incident: 2026-05-07 — dec5031b organizer = "JAIHO/Stranger" 應為 organizer = "JAIHO", co_organizers = ["Stranger"], organizer_type = ["commercial_brand"]。
Work Title ≠ Event Name Guard(作品標題不等於活動名稱守護)
在審核任何涉及 work_id 的事件的 name_zh/name_en 前,必須確認:
name_zh/name_en 必須是 name_ja(完整活動標題)的翻譯;不可從 works.title_zh/works.title_en 繼承。電影名、作品名只是活動的一部分,不等於活動標題。
- 症狀識別:若
len(name_zh) << len(name_ja)(如 name_zh = "中村地平"(4字)而 name_ja = "第78回 日本と台湾を考える集い 紀錄片《中村地平》上映会"(30+字)),屬高可信度異常。
- 驗證命令:
suspect = [e for e in events if e.get("work_id") and e.get("name_zh") and e.get("name_ja")
and len(e["name_zh"]) < len(e["name_ja"]) * 0.5]
- 修正後必須同時鎖
field_corrections:name_zh、name_en 均需 upsert,防止 re-annotation 覆寫。
Reference incident: 2026-05-07 — 622f51c1 name_zh = "中村地平"(4字)vs name_ja(32字);根因為 annotator 把 works.title_zh 直接用作 name_zh,未翻譯完整活動標題。
Blog/Creator Source Thin Content Guard
在審核任何涉及 note_creators、note.com 等部落格/創作者聚合來源的計畫前,必須確認:
raw_description 通常只有「続きをみる」截斷文字:organizer 在此情況下必然為 null,絕不可從 note 發文者的個人簡介或背景推斷主辦方。
- 純介紹文章/觀影報導不是活動資料:標題含「おすすめ」「紹介」「行ってきた」「読んでみた」等字樣的文章,應設
is_active=false(非活動事件)。
_HEADLINE_REWRITE_SOURCES 必須包含部落格來源:note_creators 的 raw_title 是文章標題,不是活動名稱,必須加入 _HEADLINE_REWRITE_SOURCES 讓 GPT 從 raw_description 重新生成正確的 name_ja。
- Short-text organizer guard 的侷限性:Non-Hallucination Guard 確認 organizer 字串存在於
raw_title + raw_description,但文本極短(< 100 字)時 GPT 仍可能從外部知識推斷,guard 無法完全阻止。對此類事件,organizer 應保持 null。
快速識別模式(需要 is_active=false 的文章):
NON_EVENT_TITLE_RE = re.compile(
r"(おすすめ|紹介|まとめ|行ってきた|読んでみた|観てきた|鑑賞レポ|映画紹介)",
re.IGNORECASE
)
驗證指令(note_creators 事件 organizer 掃描):
SELECT id, name_ja, organizer, LEFT(raw_description, 100)
FROM events
WHERE source_name = 'note_creators'
AND organizer IS NOT NULL
AND is_active = true
LIMIT 20;
Reference incident: 2026-05-08 — 2cae572a/10a4ee5d organizer 被推斷為 埼玉県日台親善協会(note 發文者);4180ad0f/4ebc8a35 介紹文章/觀影報導入庫(commit b589fbb)。
Collection Attribution Guard(所蔵元 ≠ 活動場地)
在審核任何涉及 location_name 抽取邏輯的計畫,或分析展覽類事件 venue 識別錯誤的問題前,必須確認:
〇〇美術館蔵/〇〇博物館蔵 是作品所蔵機關標記,不是活動場地:GPT 容易將「高雄市立美術館蔵」中的「高雄市立美術館」提取為 location_name,這是錯誤的。
- yebizo(東京都写真美術館)的
location_name 應固定為「東京都写真美術館」:無論展品的所蔵機構來自哪個國家或城市,活動場地永遠是東京都写真美術館本身。
- SYSTEM_PROMPT 已有 COLLECTION ATTRIBUTION NOTE:此規則已注入 GPT 指示,但程式碼層面的 scraper 也應確認
location_name 有靜態預設值(如 yebizo scraper 應直接設定 location_name="東京都写真美術館")。
- 識別模式:
raw_description 中出現「蔵」字緊接機構名,如 〇〇美術館蔵/〇〇博物館蔵/〇〇文化基金蔵,這些是所蔵標記,不是場地。
驗證指令(展覽來源 location 掃描):
SELECT id, name_ja, location_name, location_address
FROM events
WHERE source_name = 'yebizo'
AND location_name != '東京都写真美術館'
AND is_active = true
LIMIT 10;
Reference incident: 2026-05-08 — e37db12e(yebizo)location_name='高雄市立美術館'(作品所蔵元),修正為「東京都写真美術館」(commit 47f8184)。
Performer Null Guard(annotator.py 三層 fallback 守則)
在審核任何涉及 performer 欄位的計畫,或分析 performer = NULL 案例時,必須確認 annotator.py 是否正確執行三層 fallback:
三層優先順序
- DB 既有值(
event.get("performer"))— 已有值時不覆蓋(含 field_corrections 保護)
- GPT 提取(
annotation.get("performer"))— SYSTEM_PROMPT 有 PERFORMER EXTRACTION RULES
- Regex 確定性提取(
_extract_performer_from_raw(raw_title, raw_description))— GPT 失敗時的最後防線
確定性提取覆蓋的關鍵 pattern
| Pattern | 範例 | Regex |
|---|
<role>・<name>氏を迎え | 料理研究家・宮武衣充氏を迎え | _PERFORMER_INTRO_RE |
<name>氏を迎え / <name>さんを迎え | 田中花子氏を迎え | _MUKAE_RE |
<name> |<role> | 前田知里|植物民族学研究家 | _PIPE_ROLE_RE |
<role>: <name> | 講師:田中花子 / ゲスト:田中花子 | _PERFORMER_INTRO_RE |
_PIPE_ROLE_RE 使用注意:<name> |<role> 格式常見於 Peatix の「主催者 = 主講人」型活動(例:里山文庫 前田知里 |植物民族学研究家)。Role suffix 限定 家/者/師/士/督 以防假陽性。
搜索範圍:raw_description 前 1500 字元(原為 500,2026-05-06 擴展)。事件 4427f965 的講師資訊在 pos 859,500 字元範圍不夠。
防範靜默 null 的 QA 規則
- Backfill 後執行 null 掃描:任何
--backfill-performer 後,執行:
SELECT id, raw_title, performer FROM events
WHERE annotation_status='annotated'
AND performer IS NULL
AND (raw_title ILIKE '%氏を迎え%' OR raw_title ILIKE '%さんを迎え%'
OR raw_title ILIKE '%(講師|ゲスト|スピーカー)%');
LIMIT 20;
- 結果非空 → 直接 DB 修正 +
field_corrections 保護(正確方式;--id 重標注費時且 GPT 可能再次失敗)。
- SYSTEM_PROMPT performer 規則:JSON schema 必須含
"performer" 欄位;PERFORMER EXTRACTION RULES 段落必須在 ORGANIZER 段落前面。
已知 GPT 容易漏抓的模式
- 複合職稱 + 氏:
料理研究家・宮武衣充氏 — GPT 容易把整個字串當職稱描述,忘記提取人名
- 標題中的氏を迎え:GPT 通常從 description 找 performer,不從 raw_title 找
Regex 設計原則(防假陽性)
- 名字字元類必須保守:用
[\u4e00-\u9fff]{2,5} 純漢字(上限 5),而非排除清單 [^\u3000\u30fb...]
- 錯誤:
{2,6} → 翻訳者一青窈(6 字)被誤識別為名字(role+name 連串)
- 正確:
{2,5} → 宇田川幸洋(5 字)仍可匹配,翻訳者一青窈 不匹配
_MUKAE_RE 必須有 negative lookbehind (?<![\u4e00-\u9fff]):防止從職稱字串中間開始匹配。例:訳者一青窈 從 訳 開始匹配出 訳者一青窈(訳 前面是漢字 翻,lookbehind 阻擋)。
- 每次修改後掃描 DB:對全部 performer=null 事件跑
_extract_performer_from_raw,人工確認所有命中
- 敬語形式需覆蓋:
をお迎え(帶 お)與 を迎え 是不同 pattern,需同時收錄
- DB 回填只用 INTRO pattern:MUKAE 只感知「名字+敬語」,不知道上下文有幾位講者。多人講者事件(林宏文、宇田川幸洋案例)由 MUKAE 匹配但應保持 null。
Reference incidents:
- 2026-05-04 — event
e72b2c15 performer 三層 fallback 缺失;初版 regex 3 件假陽性(commits 562a620, 1ef6953, b2a8806)。
- 2026-05-06 — event
4427f965(台湾植物紀行)前田知里|植物民族学研究家 未提取,三重根因:(1) 無 _PIPE_ROLE_RE;(2) 資訊在 pos 859 > 500 上限;(3) GPT 視主催者不為 guest(commit c82e746)。
- 2026-05-08 —
翻訳者一青窈 假陽性:INTRO {2,6} + MUKAE 無 lookbehind 導致 role+name 連串被誤匹配;修法:max 6→5 + lookbehind + 翻訳者 加入 role list(本 commit)。
Manual Merge Completeness Guard(手動合併必須同時更新所有關聯欄位)
在審核任何手動合併(merger / admin)操作的計畫前,必須確認以下三件事全部完成:
- 合併後次要事件
is_active 必須為 False:合併後設定 merged_into_event_id 但忘記設 is_active=False 是最常見的遺漏步驟。合併後驗證指令:
SELECT id, is_active, merged_into_event_id
FROM events
WHERE merged_into_event_id IS NOT NULL
AND is_active = true;
- Works 表必須與 event 合併同步更新:合併前補充 works 表的
director、release_year、cast_summary、description;合併後確認 work_id 正確連結。只做 event 合併但不更新 works 表,則 works 詳情頁缺少作品資訊。
- Events 表的
director/performer 欄位也需補充:不能只更新 works 表,events 自身的 director/performer 欄位也需同步設定(用於清單頁/卡片顯示)。
is_active=True + merged_into_event_id IS NOT NULL 為已知資料不一致模式:Admin UI 的「⚠ 中繼節點」badge 是偵測工具,但根本防護是每次合併後立即執行上述驗證 SQL。
Reference incident: 2026-05-06 — b891cc5e 合併後 is_active 未更新為 False;ソウル・オブ・ソイル 合併後 works 表同時補充 director=顏蘭權、release_year=2024、cast_summary。
AdminEventTable Cross-filter Reference Guard(globalIndexMap vs displayEvents)
在審核任何 AdminEventTable 或類似 admin 表格中「一行引用另一行 ID」的 UI 計畫前(merged_into、parent_event_id 等),必須確認:
- 行號 map 必須從完整
events props 建立,不能從篩選後的 displayEvents 建立:若 map 建立自 displayEvents,被篩選掉的事件 id 在 map 中為 undefined,行號顯示靜默消失(不報錯)。
- Pattern — 雙 map 架構:
const globalIndexMap = useMemo(() =>
new Map(events.map((e, i) => [e.id, i + 1])),
[events]
);
const rowIndexMap = useMemo(() =>
new Map(displayEvents.map((e, i) => [e.id, i + 1])),
[displayEvents]
);
const targetIdx = globalIndexMap.get(e.merged_into_event_id) ?? "?";
- TypeScript 不報錯:
Map.get() 回傳 T | undefined;undefined 靜默渲染為空字串。這是靜默 UI 錯誤,只能靠人工觀察或 QA 發現。
Reference incident: 2026-05-06 — AdminEventTable rowIndexMap 從 displayEvents 建立,merged_into 目標被篩選時行號消失(commits cb1bf83, 979725f)。
Admin Table Column Width Guard
在審核任何 admin 表格欄寬設定,或修改 AdminEventTable.tsx Tailwind 寬度 class 的計畫前,必須確認:
- 固定欄寬必須同時設
w-[Npx] + min-w-[Npx]:只設 max-w-[Npx] 時,表格被其他欄擠壓後該欄仍會縮小(max-w 只設上限,無法防壓縮)。
<td className="w-[160px] min-w-[160px] ...">
<td className="max-w-[160px] ...">
- Works 清單排序用
title_ja,不用 original_title:original_title 是原始語言片名(可能是中/英文),PostgreSQL ORDER BY ASC 將 null 值排末,導致大量 title_ja 有值的日文片名因 original_title=null 而沉底。後台以 title_ja 排序符合日文使用習慣。
.order("title_ja", { nullsFirst: false })
- 新增 modal 觸發點時,所有「新增」入口點必須同步改為 modal:bulk action bar 的按鈕改為 modal 時,dropdown 底部的次要連結(
<a href="…" target="_blank">)也必須同步改為 <button> 觸發 modal;不可混用跳頁和 modal。
Reference incident: 2026-05-06 — category 欄從 w-96 → max-w-[160px] → w-[160px] min-w-[160px];works 清單從 .order("original_title") 改為 .order("title_ja", { nullsFirst: false });dropdown「新增 work」從 <a> 改為 <button>。
After Identifying a Planning Mistake
- Append an entry to
.github/skills/agents/architect/history.md (newest at top).
- If the lesson generalizes, add a rule to this file.
Stop-Point Contract (Architect 直接編輯時)
Architect 預設為 read-only(規劃 + 報告)。但在以下情況會直接編輯檔案:revert 操作、緊急修正、小幅文檔更新。直接編輯後必須走完以下其一,禁止留半成品:
- 完整鏈路:編輯 → 自呼叫 V-M-D(commit + push + Vercel 驗證)→ 報告結果。
- 明示交還:編輯 → 在最終回應第一行標注「⚠️ 工作樹有未提交修改,需手動處理」並列出檔案,禁止只報「已完成」。
絕不允許:編輯完直接呈現 commit hash 或「完成」字樣而沒明確指出 push 狀態。
Status Reporting Vocabulary
呈現 git 狀態時,必須用以下三種標籤之一,禁止只給 hash:
- ✅ 已推送:
<hash> → origin/main(已驗證 Vercel 部署或 push 成功 exit code 0)
- ⏳ 本地 only:
<hash> (local, not pushed)
- 📝 未 commit:
N files modified (working tree) 並列檔名
裸 hash(如「commit cf1e0a9」)會讓用戶誤以為已推送,這是 anti-pattern。
Atomic Revert Rule
刪除 i18n key、type union member、或任何被多處引用的 symbol 時,同一 commit 必須同時刪所有 caller:
- 編輯前先
grep_search 找出所有引用點。
- 改動順序:先刪 caller,再刪 definition(反之會留下編譯壞掉的中間狀態)。
- 完成後跑
cd web && npx tsc --noEmit 確認 0 error 才 commit。
反例:2026-05-01 撤銷 Tier 1 時刪掉 statsSlaHeader 等 i18n keys,但 stats/page.tsx 仍呼叫 t("statsSlaHeader"),導致工作樹半成品狀態(用戶察覺後手動修復)。
AEO Feature Planning Rules
When planning any AEO (AI Engine Optimization) or SEO feature:
- Static file checklist: Any
web/public/ file added (e.g. llms.txt, IndexNow key .txt, Google verification .html) must have a corresponding proxy.ts matcher exclusion step in the plan. Without it, next-intl i18n middleware 307-redirects the file to a locale path (e.g. /zh/google...html), making it unreachable by external services. Use google[0-9a-f]+\.html to cover all Google verification file formats.
- FAQPage plan must include visible
<dl>: Never plan "add FAQPage JSON-LD" without also planning "add matching visible <dl> section on the page". Google requires FAQ content to be visually present.
- Migration number pre-check: Before assigning a migration number, confirm the next available number with
ls supabase/migrations/ | sort | tail -5. Two migrations with the same number must use NNN and NNNb_ suffix.
- i18n namespaces upfront: When planning new page types (city pages, category pages), explicitly list all new i18n namespace keys needed in all three messages files as a plan step. Silent namespace miss = raw key on page.
- IndexNow env vars: Plans that add IndexNow submission must explicitly list
INDEXNOW_KEY and NEXT_PUBLIC_SITE_URL as required env vars in both GitHub Actions secrets and (if needed) Vercel.
- GSC integration must use OAuth2 refresh token: Google Search Console UI only accepts regular Google accounts as users — service account emails return "找不到電子郵件" and cannot be added. Always design GSC API integration with OAuth2 refresh token (
GSC_CLIENT_ID + GSC_CLIENT_SECRET + GSC_REFRESH_TOKEN), never service account JWT.
- OAuth Playground requires test user setup: When the OAuth consent screen is in "Testing" mode, the authorizing account must be added as a test user first, otherwise the flow returns 403
access_denied. Plans that include OAuth token generation steps must note this prerequisite.
OG Image Multi-Language Truncation Rules
When planning or reviewing changes to web/app/[locale]/events/[id]/opengraph-image.tsx:
- 截斷閾值必須以英文長度為基準:日/中文每字元視覺寬,36 字足以填滿標題區域;英文每字元視覺窄,需 50+ 字元才填滿同等空間。截斷
N 值應設 ≥ 55(英文基準),而非 36(日文基準)。
- 優先增加字體縮小級別,而非降低截斷閾值:新增中間字體層(如 40px)讓長英文標題縮小後多行顯示,保留完整語意;只有視覺上確實溢出時才截斷。
- 目前三級字體設計(截至 2026-05-01):
- ≤ 22 字 → 72px
- 23–36 字 → 54px
- 37–55 字 → 40px
-
55 字 → 40px + 截斷至 53 字
- 任何修改此邏輯的 plan 必須包含「用英文長標題(如 40+ 字母)和日文短標題(≤ 10 字)各一組」的視覺驗收步驟。
Category Union Change Guard
After any plan that touches web/lib/types.ts Category union:
multi_replace_string_in_file oldString for union type changes must include ≥3 lines before and after the target member — insufficient context silently truncates adjacent union members (see: retail removed when drama added, commit f9e6b52)
- Plan must include an explicit post-change verify step:
cd web && npx tsc --noEmit, confirming all prior union members still compile
Admin Form New Field Checklist
When adding a new optional field to the events table that also appears in the Admin UI, all 7 points must be in the same plan:
- Migration (manual step):
ALTER TABLE events ADD COLUMN IF NOT EXISTS <field> <type>; — must be executed in Supabase Dashboard SQL Editor before any Python client seed or upsert referencing the new field. (Error if skipped: PGRST204: Could not find the '<field>' column)
scraper/sources/base.py: Add to Event dataclass as Optional[str] = None.
web/lib/types.ts: Add to Event interface as field: type | null.
AdminEventForm.tsx — two sub-steps:
EMPTY_FORM: add field: ""
- UI: add corresponding
<input> or <textarea> element
AdminEditClient.tsx: add field: event.field ?? "" to form initialization.
web/messages/*.json: add i18n key to all three files (zh, en, ja) simultaneously.
- Event detail page (
web/app/[locale]/events/[id]/page.tsx): if the field is user-visible, add locale-aware rendering (e.g. conditional <a> for URL fields).
Missing any point causes silent failures. Particularly:
-
Missing point 1 → PGRST204 at runtime, not at compile time.
-
Missing EMPTY_FORM or form init → field appears blank in admin even when DB has a value.
-
Missing i18n key → raw key string rendered in UI.
-
Vercel build failure from a TypeScript error does not take the site down — it serves the previous build silently. Regression is invisible to users until manually checked.
-
All 6 locations must be updated in the same commit (union, CATEGORIES, CATEGORY_GROUPS, zh/en/ja messages). See Engineer SKILL.md § Category Update Protocol for the full list.
-
sources/{name}/ — per-scraper platform profile(有 applyTo: scraper/sources/*.py)
-
agents/{name}/ — per-agent operational rules
-
top-level — workflow/tooling skills only(local-preview, cc-statusline, session-analytics)
-
任何新的 per-source skill 必須 放在 sources/ 子目錄下,不可直接放頂層
SQL Privilege Syntax Guard
- For PostgreSQL privilege statements, verify object-type syntax before finalizing migration SQL.
- View privilege revocation should use
REVOKE ... ON TABLE <view_name> ..., not ON VIEW.
- For Supabase Security Advisor fixes, validate these statements line-by-line before execution:
GRANT ... ON ...
REVOKE ... ON ...
ALTER VIEW ... SET (...)
- If SQL Editor reports a syntax error, resolve by exact failing line first; do not change security model design until syntax is confirmed valid.
Supabase RPC Auth Context Guard
- For
SECURITY DEFINER RPC functions that gate admin access, do not rely only on request.jwt.claim.sub.
- Use
auth.uid() as the primary identity source for real app requests, then fallback to claim only for SQL Editor simulation: coalesce(auth.uid(), v_sub::uuid).
- Keep the function deterministic and explicit:
set search_path = pg_catalog, schema-qualify cross-schema objects (public.user_roles, auth.users).
- Preserve strict denial path: when no effective user id or role mismatch, raise
42501 (admin privileges required).
- Before approving migration rollout, verify four cases:
- app admin request: PASS
- app non-admin request: DENY 42501
- SQL Editor with
request.jwt.claim.sub set to admin uid: PASS
- SQL Editor without claim injection: DENY 42501
Classifier Keywords
- Avoid single-character or title words (博士, 先生, 教授) in category keyword lists — they appear as proper nouns (person names) and trigger false positives. Prefer compound terms: 「博士課程」「博士論文」「教授法」.
- After adding a new category with new keywords, run a dry-run of
backfill_categories.py and manually inspect every match before applying to DB.
- When a backfill produces a suspicious tag (e.g., a plant-walk event tagged
academic), trace which keyword triggered it and tighten the rule immediately.
GitHub Actions Cron Dispatch Guard
在設計任何使用 schedule: cron 觸發並需要根據「是哪個 cron 觸發的」來 dispatch 不同行為的 workflow 前,必須確認:
- 不要用精確小時
-eq 判斷:GitHub Actions cron 啟動有 1–2 小時(甚至更長)的延遲。if [ "$HOUR" -eq 21 ] 在實際執行時幾乎永遠不會匹配。
- 改用 6 小時視窗
-ge/-lt:每個 cron slot 之間間隔 6 小時,用視窗範圍覆蓋延遲。
else fallthrough 必須是安全行為:若用 else 作 fallthrough,部署後要驗證每個分支是否都有被正確觸發,不能假設 else 只有在「預期外情況」才觸發。
- 費用驗證:multi-slot researcher 的費用應該均勻分布在各 slot;若某一 slot 費用異常高(如 $2.62 vs 其他 $0),應立即檢查 dispatch 邏輯。
Reference incident: 2026-05-04 — researcher.yml 所有 4 個 cron 全部 fallthrough 到 else → slot3,slot3 費用 $2.62/週(正常應為 $0.67),slot0/1/2 幾乎未執行。修復:改用 6 小時視窗。
Scraper Failure Notes Guard
在設計或審核任何 scraper_runs 寫入邏輯前,必須確認:
success=False 必須搭配 notes:只寫 success=False 等於告訴你「失敗了,但不知道為什麼」。notes 欄位必須包含 f"{type(exc).__name__}: {exc}"[:500]。
- 事後診斷需要 notes:無 notes 的失敗記錄在週報中只能顯示「❌×N」,無法判斷是網路問題、selector 失效還是程式 bug。
- failure 寫入自身不應 raise:failure logging 的
except Exception: pass 是正確的,避免 logging 失敗掩蓋原始錯誤。
Reference incident: 2026-05-04 — eurospace 3 次失敗(4/28–4/29)notes 全為 NULL,無法從 DB 追溯原因。修復:main.py except 區塊新增 "notes" 欄位。
Persistent Zero Sources Diagnostic Guard
在週報或每日報告出現「持續 0 件」來源時,不要立即 dry-run 或修改 scraper,先依以下順序診斷:
- 查歷史最高事件數(
last_nonzero = never ≠ 邏輯失效):
sb.table('scraper_runs').select('events_processed,ran_at')
.eq('source', src).order('ran_at').execute()
- 四種分類判斷:
- 季節性:doc string 有年度節期(oaff → 3月、tokyo_filmex → 11月)→ 期間外 0 件是設計行為
- 低頻設計:doc string 說「1-2件/年」「2-5件/年」→ 前半年 0 件是常態
- 時機問題:場地排片無台灣內容 → 等新排片
- API key 缺失:本地
.env 無 key → CI 可能正常,確認 Actions secret
- 設定監控閾值(不要人工週報審查):
PERSISTENT_ZERO_DAYS=30 觸發自動警告。
給 doc string 加上活躍期標注(防止未來誤報):
Reference incident: 2026-05-04 — 13 個 0 件來源全部屬於正常狀態,透過查歷史+分類診斷在 30 分鐘內確認無需修改任何 scraper。修復:daily_report.py 加入 30 天自動監控。
AI Model Selection
- Verify model capabilities before designing features requiring real-time data (web search, live prices, current events).
gpt-4o-mini and gpt-4o have no web browsing. Use gpt-4o-search-preview or a real search API for current data.
- "Plausible-looking output" ≠ "real data access." A model without search access will hallucinate convincing-looking URLs.
Kanji Time Format business_hours Guard(漢字時刻形式守護)
在審核任何涉及 business_hours 欄位提取的 scraper 或 annotator 計畫前,必須確認:
_extract_hours_from_raw() 同時支援漢字時刻格式:HH 時MM 分(含空格)與 HH時MM分(無空格)。Taiwan Cultural Center(jp.taiwan.culture.tw)使用這種格式:開 演: 13 時30 分。
- 優先序(
_extract_hours_from_raw):
HH:MM〜HH:MM(冒號範圍)
HH〜HH時(小時範圍)
- 日時 label 後的單一
HH:MM
開演/上映開始/開始 label 後的 HH 時MM 分 → HH:MM〜
開場 label 後的 HH 時MM 分 → HH:MM〜
- 任意
HH 時MM 分(最低信心)
- raw_description 的完整長度需確認:taiwan_cultural_center 的
business_hours 資訊通常出現在 raw_description 末尾的「詳細」區塊(最後 400 字元)。若 DB 的 raw_description 截斷,時刻資訊不會被提取。查詢時必須讀取完整欄位(勿用 [:200] 等截斷)。
- 已入庫但
business_hours=null 的 taiwan_cultural_center 事件:annotator re-annotation 後會自動補填(透過 _pre_hours),無需人工 patch。人工修正後仍需 FC 鎖定。
Reference incident: 2026-05-26 — event 16c3fa42(台湾映画上映会 北海道大学):raw_description 末尾有 開 演: 13 時30 分 但 business_hours=null,原因為 _extract_hours_from_raw 只支援 HH:MM 格式。修正:新增漢字時刻 regex patterns(commit 15d06e4),手動 patch business_hours=13:30〜 + FC 鎖定。
HTMLParser Thin Content Guard
在審核任何使用 html.parser.HTMLParser 的 scraper PR 前,必須確認以下三點:
- 噪音標籤已被過濾:
<script>、<style>、<nav>、<header>、<footer> 等噪音標籤必須在 handle_starttag/handle_endtag 中跳過。
- 標準 pattern:
_SKIP = frozenset({"script","style","nav","header","footer"}) + _skip 計數器
def handle_starttag(self, tag, attrs):
if tag in _SKIP:
self._skip += 1