ワンクリックで
Planning principles, model selection, and scope rules for the Architect agent
npx skills add https://github.com/TuiTuiKoan/Tokyo_Taiwan_Radar --skill architectこのコマンドをClaude Codeにコピー&ペーストしてスキルをインストール
Planning principles, model selection, and scope rules for the Architect agent
npx skills add https://github.com/TuiTuiKoan/Tokyo_Taiwan_Radar --skill architectこのコマンドをClaude Codeにコピー&ペーストしてスキルをインストール
Implementation rules for database migrations, Python scrapers, and Next.js web for the Engineer agent
BaseScraper contract, field rules, and Peatix-specific conventions for the Scraper Expert agent
版元ドットコム Playwright scraper for Taiwan-related books (Publication Intel v3.1)
河出書房新社 RDF/RSS 1.0 scraper for Taiwan-related books and events (Publication Intel v3.1)
NDL OpenSearch API scraper for Taiwan-related books (Publication Intel v3.1)
BaseScraper contract, field rules, and Peatix-specific conventions for the Scraper Expert agent
| name | architect |
| description | Planning principles, model selection, and scope rules for the Architect agent |
| applyTo | .github/agents/architect.agent.md |
Read this at the start of every session before producing any plan.
web/ UI 計畫,優先採用本站既有 design system / design token 元件,不得預設用原生 HTML control。若已有 DesignSelect、dialog、button、input、badge 等對應元件,計畫中必須明列使用它;只有在設計系統無法表達互動語意時,才考慮新增原生 fallback。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。select(...).execute() 載入「保護判斷資料」(例如 field_corrections、blacklist、mapping)時,必須先驗證是否被預設分頁截斷。最少要做三件事:
first_page_count 與 count='exact'.range(offset, offset+999) 全量掃描一次確認總筆數< 20 個事件 → 一律改為「一次性手動 patch」或「prompt-only 修法」,不做成 daily routine20–100 個事件 → 一次性 backfill 腳本,跑完 archive,不接 CI> 100 個事件且持續累積 → 才考慮做成 daily CI step
違反這條會徒增 CI 時間 + 檔案維護成本,且 Plan Critic 會擋下。Reference: 2026-05-26 enrich_organizers.py 計畫瘦身(5 Phase → 3 Phase)。任何要在「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 值,以下 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:
design_craft、herbal、study_abroad 三分類在 annotator/i18n sync 後,縮圖 case 補加為獨立 commit,違反「同一 commit」原則。教訓:Architect 計畫從此明列步驟 4。healthcare),GPT 自創 health,前端顯示 raw i18n key(commits 997378c)。教訓:新增步驟 5。25e27de9 顯示 categories.photography raw key。GPT 自創 photography 由 admin OCR route 寫入,繞過 annotator 的 _validate_categories()(commit 264afed)。教訓:見下方「Admin API enum 防禦」rule。新增 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 抓出。
問題模式: GPT 在 admin OCR/annotate route 自創 enum 外的值(例:photography 不在 VALID_CATEGORIES 但寫入 DB → 前端 raw i18n key)。三道防線都擋不住:
text[] 陣列元素無效(只能 CHECK 整個陣列)_validate_categories():只有 daily scraper 路徑會跑,admin route 不會規則: 任何由 GPT 輸出 enum 欄位的 admin API route 必須在 server 端 intersect whitelist:
const VALID_CATEGORIES = new Set([...]); // 與 types.ts CATEGORIES 同步
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 值,以下 4 個位置必須同一 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 | web/app/api/admin/extract-from-image/route.ts + web/app/api/admin/annotate-event/route.ts | 兩個 GPT prompt 內的 event_form: 枚舉清單 |
| 4 | web/messages/{zh,en,ja}.json | eventForms.<key>(三語各一) |
Reference incident: 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。
問題模式: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。」
對應稽核點:
不適用情境:cosmetic refactor、dependency bump、typo、純 i18n 字串調整、純 CSS 調整。
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。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 追加後に解消。
當使用者報「production 後台 / 客戶端寫入突然全部失效」且最近 24h 有 Vercel 部署,在深入結構性分析(migration / RLS / GRANT)之前先要求使用者執行 1 分鐘 DevTools 三點檢查:
authorization: Bearer ... → 客戶端 session 遺失(結構性 bug,深挖 auth callback / cookie 設定)[] 或空白 200 → RLS / expired JWT 過濾為 0 列(可能是暫時性,先請使用者重整再試)42501 GRANT 缺失)sb-<ref>-auth-token HttpOnly 欄:
三點全綠 + 重整後恢復 = 暫時性 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 分鐘誤判時間。
在審核任何包含 async button handler(handleConfirm、handleDismiss、handlePublish 等)的計畫或 PR 前,必須確認:
finally 塊中重置,不論例外或正常結束:
async function handleAction() {
setLoading(true);
try {
const result = await someServerAction();
// handle result
} catch (err) {
console.error(err);
alert("操作に失敗しました。");
} finally {
setLoading(false);
}
}
AbortSignal.timeout(N),防止無限 hang:
const res = await fetch("https://api.github.com/...", {
signal: AbortSignal.timeout(10_000),
});
finally 缺失的雙重故障。appendToHistoryFile、appendPendingRuleToSkill 等),必須在計畫中明確標注 Engineer 需加 AbortSignal.timeout。Reference incident: 2026-05-17 — AdminReportsTable handleConfirm() 無 try/catch,b2e8b92 後累計 4 次 GitHub fetch,GitHub API hang 時按鈕永久卡死(commit 9319f57 修復)。
在審核任何多 session/subagent 平行開發工作流前,必須確認:
[STATE] 開頭:
[WIP] — 草稿,禁止合併[READY] — 已驗證,可立即合併[REVIEW] — 待人工確認[BLOCKED] — 有外部依賴未就緒./scripts/stash-status.sh list 是快速狀態總覽的入口,點 VMD agent 前應先執行。./scripts/stash-status.sh promote <N>):
[READY] stash 並提示促銷,無需手動記得跑 stash-status。[READY] stash 超過 3 天未合併,CLI 標記 ⚠ STALE——可能與 main 衝突或內容過時。architect 設計 multi-session 任務計畫時,必須在「完成條件」中明確指定 stash state 標籤,例如:
完成後執行
git stash push -m "[READY] scraper/starcat: end_date fix"
在審核任何 merger 輸出、或手動設定 merged_into_event_id 前,必須確認不形成循環:
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'}")
merged_into_event_id = NULL。如有多個事件把此節點當 merge target,評估是否改指向真正的 canonical active 事件。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.py 的計畫前,必須先確認系統設計的執行時序:
weekly_line_broadcast.py 的 cron 時序設計,不要急於本地補跑草稿,避免製造多餘的重複草稿。/{lang}/events/{id}),不可使用 /r/{id} 短連結(固定重導向至 /zh/)。Reference incident: 2026-05-08 — 多重錯誤同時發生(Supabase order() bug、草稿重複、連結語系錯誤、日期範圍錯誤)。
When a plan includes promoting an auto-generated scraper (auto_scraper_status=success) to status=implemented, the plan must explicitly include all 5 steps:
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.python main.py --dry-run --source <key> confirms events are found.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.
在審核任何涉及 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。Fetched N upcoming events 中無 pending 事件(annotation_status 過濾後應比無過濾少幾筆)。Reference incident: 2026-05-05 — 赤い糸 輪廻のひみつ 以日文出現在 ZH 週報,缺 annotation_status 過濾(commit 9b33ad3 後修正)。
在審核任何涉及 enrich_person_names() 或人名翻譯邏輯的計畫前,必須確認:
クー・チェンドン → 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 藝名/筆名覆寫機制)在審核任何涉及 performer/director 翻譯、backfill_performer_i18n()、或新增 _KNOWN_PERSON_MAP 條目的計畫前,必須確認:
ギデンズ・コー 與漢字 九把刀 無語音對應關係(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)。新增整合點時需三處同步。_KNOWN_PERSON_MAP 或 eiga.com lookup)backfill_performer_i18n() 不可限定 is_active=True:非活躍事件同樣需要翻譯完整性。批次 backfill 腳本的 active 過濾需明確設計。Reference incidents:
ギデンズ・コー → 基登斯·高 (GPT 幻覺);正確 九把刀 / Giddens Ko。14 筆已驗證名人收錄 _KNOWN_PERSON_MAP,11 筆 DB 事件修正。is_active=True 過濾而缺翻譯,需一次性批次 backfill。在審核任何涉及 performer_zh、performer_en、director_zh、director_en 的計畫,或設計多語言表演者顯示邏輯時,必須確認:
performer TEXT:日文原名(供 ja locale)performer_zh / performer_en TEXT:各語言名稱(GPT 填入或人工設定)performers TEXT[]:所有具名表演者/發表者的陣列(支援多人)director / director_zh / director_en:同上,用於導演zh → performer_zh || performer;en → performer_en || performer;ja → performer(不走翻譯欄位)黃以文(AI翻譯))。若來源中有該語言名稱,不加標注。performers[],即使有 5 人以上。field_corrections:performer_zh、performer_en、director_zh、director_en 手動修正必須同時 upsert 進 field_corrections,否則下次 re-annotation 覆寫。_zh/_en 欄位,避免二次 migration。works.work_type 有效值:film | stage | exhibition | concert_tour | tv_drama | tv_variety | other。conference 不在允許清單,學術研討會用 other。Reference incidents:
65a50b9:SYSTEM_PROMPT 追加 AI 翻譯標記規則 + 學術大會 performers[] 填寫規則(Incidents A & B)191d939:migration 053 新增 performers TEXT[](Incident C)3822fb8:migration 054 新增 performer_zh, performer_en, director_zh, director_en(Incident D)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 課程型 scraper(wuext_waseda、asahiculture 等)計畫前,必須確認 business_hours 在 scraper 層直接組裝(曜日 + 時間 + 全N回 + 個別開講日逐項列出),不留給 annotator。Annotator 只能抽取單一時間範圍(19:00〜20:30),無法保留曜日、N 回、跳週日期等資訊。Reference: wuext_waseda event 1be67e0f, 2026-05-16.
在審核任何涉及 organizer_zh、organizer_en 的計畫,或設計主辦方顯示邏輯時,必須確認:
organizer TEXT:日文原名(供 ja locale)organizer_zh / organizer_en TEXT:各語言名稱(_KNOWN_ORGANIZER_MAP 或 GPT 翻譯填入)zh → organizer_zh || organizer;en → organizer_en || organizer;ja → organizer_KNOWN_ORGANIZER_MAP:高頻主辦方(10 筆)hardcoded 在 annotator.py,確保翻譯品質。新增條目時 ja/zh/en 三語同時提供。field_corrections:organizer_zh、organizer_en 手動修正必須同時 upsert 進 field_corrections。i18n 文字欄位新增標準流程(已確立,第三次套用):
field_zh TEXT, field_en TEXTbase.py Event dataclass + database.py _event_to_row() 映射types.ts interface + getEvent<Field>(event, locale) helper + page.tsx 渲染日文漢字 ≠ 簡體中文判斷規則: 使用者反映「簡體字」時,先確認是 GPT 的 SC 輸出還是日文原文被顯示在非 ja 頁面。若為後者,正解是新增多語言欄位,非 SC→TC 轉換。
Reference incident: 2026-05-08 — commit 95c7ad8:migration 059 + annotator + web 全套。273/273 backfill。
在審核任何涉及 _SIMP_TO_TRAD / _to_trad() 擴充的計畫時,必須留意:
annotator.py 的 _SIMP_TO_TRAD_RAW + auto_qa.py 的 SIMP_RE。*_zh fields + translate + FC lock)。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 |
三層一致性規則:
_SIMP_TO_TRAD 字元映射(衍生自 _SIMP_TO_TRAD_RAW)SC_ONLY 字元集必須是 _SIMP_TO_TRAD_RAW.keys() 的子集——不可包含不在映射表中的字元(否則偵測到但無法修復 → 無限 dismiss 循環)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 字(诗/禅/图/猎/过/员/剧/别/于)。
在調查任何「カテゴリバッジが表示されない」「カテゴリが DB に存在するのに画面に出ない」問題、または name_ja が期待と異なる場合に、必須確認:
name_ja と raw_title の diff を確認する:annotator は raw_title → name_ja 変換時に後置された分類語(「レポート」「告知」「詳細」「ご案内」等)を「タイトルの装飾」と判断して削除することがある。# name_ja を raw_title 通りに復元 + FC ロック
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()
category: ['report'] が付与されていてもタイトルから「レポート」が消えることがある。category 確認 → ② messages/*.json i18n 確認 → ③ raw_title vs name_ja diff 確認 → ④ UI レンダリングロジック確認。Reference incident: 2026-05-07 — f7ff56ca「台湾文化センター映画...トークイベント レポート」の name_ja から「レポート」が annotator によって削除されており、category: ['movie','lecture','report'] は正常だがタイトルバッジ調査で発覚。
在審核任何直接 SQL UPDATE 翻譯欄位(name_zh / name_en / description_zh / description_en / performer)的計畫前,必須確認:
field_corrections:否則下次 annotation_status 翻回 pending 時,annotator 主迴圈用 GPT 重寫,所有人工修正瞬間蒸發。這是「修了又錯、錯了又修」迴歸鏈的根因。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_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 分類注入、_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|行ってきた|観てきた|鑑賞レポ|結果発表)raw_title 是官方活動標題:GPT 可能把「ZINE Fes(ZINE 市集)」或「講演会」的部分面向誤分類為 report,此時 raw_title 不含 report 關鍵字,必須跳過前綴注入。report category 注入後 name_ja 加上 【レポート】;name_zh 加上 【活動報導】;name_en 加上 [Report] ;若無 FC lock,下次 re-annotation 會還原,但已對外顯示污染名稱。_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):
# inject prefix
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。
在手動觸發再 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 也無法自動修正。sb.table("events").update({
"raw_description": new_raw,
"description_ja": new_desc,
"end_date": None, # ← MUST clear to allow GPT re-inference
# start_date: None if also suspect
"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 被忽略 → 兩日活動只顯示一天。
在任何包含「新增 prop 到 shared form component」或「後台新增欄位」的計畫前,必須確認:
grep -r "AdminEventForm\|<ComponentName" web/components/ web/app/ 找出所有呼叫點,逐一確認新 prop 是否已傳入。TypeScript 若 prop 有 fallback default 不會報錯,靜默失敗難以發現。AdminEventForm.tsx 增加對應 input,否則管理員無法覆寫 AI 填錯的值。清單:
name_*、description_*、selection_reasonorganizer、organizer_url、event_form、co_organizers、sponsorsprimary_language、has_japanese_support、has_english_support、has_chinese_supportannotation_status → reviewed,AdminEditClient.tsx 的 TRACKED_FIELDS 必須加入。陣列(string[])與布林值欄位需特別確認比對邏輯(不能用 ===,需深比對)。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 個後台隱藏欄位。
在審核任何修改 database.py _build_movie_extend_row()、新增 movie-extend 觸發分支、或擴張「對既存 row 部分更新」邏輯的計畫前,必須確認:
_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 行属性で判定するため問題なし。.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。force_rescrape=true(後者全覆寫會吃掉 movie-extend 的 MIN(start_date) 保留語意)。如需 reviewed 電影更新場次,走 manual SQL + field_corrections 路徑,不可改 movie-extend 條件。_build_movie_extend_row() 加 if-else,必須獨立 helper 並重新評估白名單欄位。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:
8572104 引入 movie-extend;同 commit message 明示「by construction, extend rows touch zero P3.2-protected columns」與 .update() not upsert 的設計理由。end_date 固定在初日:e.category 参照バグ + end_date=None fallback 漏れ(commit a2f5828)。在審核任何修改「事件卡片視覺呈現」的計畫前(包含 location 顯示、徽章、日期格式、分類 chip、save 按鈕、報告按鈕等),必須確認:
web/app/[locale]/page.tsx 使用 inline list-style 渲染,不使用 EventCard.tsx:grep EventCard in page.tsx 會回傳 0 match。修改 EventCard.tsx 對首頁完全無效。EventCard.tsx:這些頁面共享元件。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/。curl -s https://tokyotaiwanradar.com/zh | grep -c '<新元素 class 識別字>'
# 期望非 0,若為 0 → 修了 EventCard 但首頁 inline 沒同步
Reference incident: 2026-05-05 — commit 5a29c13 修 EventCard.tsx 城市徽章邏輯,但首頁完全無變化(首頁不用 EventCard)。commit 9f4b468 抽 web/lib/cityLabel.ts 共用 helper 後雙處同步生效。
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:
merger.py deactivates a duplicate secondary event during merge.is_active must be verified against these two sources before execution. If it does not match either, abort.任何計畫包含 annotator.py --backfill-* 步驟時,必須在步驟清單中明確列出「backfill 後多語言欄位 QA 驗證」子步驟:
selection_reason["ja"]:必須含假名(平假名或片假名);無假名表示語言污染SELECT id, source_name, (selection_reason->>'ja') as ja_text
FROM events
WHERE selection_reason->>'ja' IS NOT NULL
AND selection_reason->>'ja' !~ '[ぁ-んァ-ン]'
LIMIT 20;
Incident: 2026-05-04 --backfill-tier1 導致 49 筆 selection_reason["ja"] 為中文,需人工腳本修正。
/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 中有 badge 或動態計數器」的 feature plan 中,必須明確標示:
⚠️ 此 badge / 計數器需要即時性嗎?若是,plan 必須包含「拆出 Client Component + Supabase Realtime 訂閱」步驟。
分離模式:
ParentComponent (Server Component)
└─ 查詢初始 count(SSR,一次性)
└─ <DynamicBadge initialCount={n} />(Client Component)
└─ Supabase Realtime 訂閱 INSERT + UPDATE 保持即時更新
強制規則:
initialCount prop 作為 SSR 初始值,啟動後改由 Realtime 維護。無操作 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).
在任何包含「新增或修改 /admin/quality check 條件」的 feature plan 中,必須確認以下三點:
Quality check 用哪個欄位 IS NULL 做判斷,該欄位必須是詳情頁實際 render 的欄位。
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"}')
所有 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).
撰寫任何依賴 DB 欄位的 client-side filter 前,必須確認以下三步驟:
.select("...") 字串中:否則欄位值為 undefined,filter 條件永遠不成立,靜默通過所有資料(不報錯)。location_prefectures?: string[] | null)。反例(commit bf22756 之前):
// ❌ location_prefectures 不在 select 字串 → 欄位 = undefined → 過濾靜默無效
if ((e.location_prefectures?.length ?? 0) > 1) return false;
正確做法:
// Step 1: 確認 select 含欄位
.select("id, location_name, location_prefectures, ...")
// Step 2: interface 宣告型別
interface QualityRow { location_prefectures?: string[] | null; ... }
// Step 3: 才寫 filter 邏輯
if ((e.location_prefectures?.length ?? 0) > 1) return false;
Reference incident: 2026-05-02 — location_prefectures 未加入 select,多城市活動過濾靜默失效。
在審核任何 Server Component(page.tsx) 的 PR 前,若 page.tsx 抓取了資料並用 prop 傳給子 component,必須確認:
worksList 但 <AdminEventTable> 沒有 initialWorks={worksList},資料完全浪費,子 component 會自己做一次 client-side 重複 fetch。// page.tsx(Server Component)
const { data: worksData } = await supabase.from("works").select(...);
const works = (worksData ?? []) as Work[];
return <AdminEventTable initialWorks={works} ... />;
// AdminEventTable(Client Component)
const [works, setWorks] = useState<Work[]>(initialWorks); // 立即可用
useEffect(() => { /* refresh after creation */ }, []); // 僅作刷新用
const { data: ... } = await supabase.from(...) 呼叫,對照子 component 的 Props interface 確認每份資料都有對應 prop。Reference incident: 2026-05-16 — page.tsx 抓取 worksList 但未傳給 AdminEventTable,works state 初始化為 [],用戶開下拉時 fetch 未完成 → 空清單(work選項又不見了)。
在審核任何修改 TAIWAN_VENUE_KEYWORDS(或類似地名比對清單)的 PR 前,必須確認:
'新北' ⊂ '新北島'(大阪市住之江区);'台中' 可能出現在日本地名中。必須使用完整行政單位名:'新北市'、'台中市' 等。grep -r "<keyword>" scraper/ 確認無日本地名誤觸。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 偵測器的改動或新增 auto_qa_* 類型前,必須確認:
location_name 為純城市名(東京、大阪、岡山 等)時,新聞彙整類 source(google_news_rss、koryu 等)本就無法提供具體場地。auto_qa_missing_address 不應 flag 此類事件。維護 VAGUE_CITY_NAMES frozenset。OVERSEAS_KEYWORDS tuple。location_address = NULL 的事件,仍應 flag。Reference incident: 2026-05-04 — 13 筆 auto_qa_missing_address pending,其中 5 筆為城市名/海外場地誤報(commit 15c5b4b)。
在任何涉及「SSR 頁面查詢關聯資料(父事件、鏈結實體)」的 feature plan 中,必須確認以下三點:
RLS "Public read events" policy 限制 anon key 只讀 is_active = true 的事件。若查詢目標(如父事件)被下架(is_active = false),anon key 查詢靜默回傳 null,不拋 error,難以察覺。
若查詢的關聯資料可能處於 is_active = false 狀態(例如:父事件下架、存檔紀錄),必須在 Server Component / route handler 中用 service role key:
// Server Component only — 不可傳到 client-side
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)。
Before planning any new admin page or dashboard column whose primary output is a count / status / health number, ask:
/admin/..., it will be ignored.Default preference order for monitoring features:
/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.
Before approving any change to annotator.py annotation field priority, verify:
start_date / end_datelocation_name / location_addressbusiness_hoursis_paidNone/null).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)開催日時: YYYY年MM月DD日 header to raw_description, then set annotation_status='pending' to trigger re-annotation.在審核任何 scraper 的 start_date/end_date 傳入邏輯前,必須確認:
datetime(..., tzinfo=timezone(timedelta(hours=9))) 傳入 Supabase 後以 UTC 儲存,JST+9 偏移導致日期倒退一天(2026-05-08T00:00:00+09:00 → 2026-05-07T15:00:00+00:00)。# CORRECT — 保留日曆日期,強制 UTC tzinfo
start_date = jst_dt.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=timezone.utc)
# WRONG — JST-aware datetime 傳入 Supabase
start_date = jst_dt # tzinfo=JST, Supabase 轉成前一天 UTC
datetime 無 tzinfo 時 Supabase 依伺服器時區解讀(通常 UTC),一般安全,但不如明確設定 UTC midnight。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)。
Before acting on any hallucination scan result (address/location not found in raw_description), verify:
MoN Takanawa (inside Takanawa Gateway City) has postal address 港区三田 — not 高輪. Station names, building brands, and postal addresses can differ.港区三田3-16-1 to wrong 港区高輪4-10-30 based on venue name reasoning. Reverted after user confirmation.在審核任何涉及 annotator 日期提取邏輯、或分析任何來源事件年份錯誤時,必須確認:
年份錨點注入必須覆蓋所有來源(非僅 gnews 類):
scraped_at AND "記事配信日:" not in raw_desc(不限 source_name)(記事配信日: YYYY-MM-DD)\n\n 前綴兩類年份幻覺場景均被覆蓋:
5月8日(金)公開)→ GPT 從訓練資料猜年,可能猜出過去年份2025年11月に公開を迎えた台湾国内での興行収入,指另一國的上映年)→ GPT 誤用該年份為日本活動年份SYSTEM_PROMPT DATE Rule 7 必須引用 記事配信日:
(記事配信日: YYYY-MM-DD) → use that year as the reference yearfield_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
# 確認不含 "_gnews_sources_needing_year" 的限制條件
Reference incidents:
0d33b617 (gnews 熊本チップ・オデッセイ) scraped_at=2026-04-28 → annotated start_date=2024-04-01(應為 2026-04-12)dded67a6 (uplink_cinema 霧のごとく大濛) raw_desc 含「2025年11月に公開を迎えた台湾国内での」(台灣內地年份),GPT 幻覺 start_date=2025-05-08(應為 2026-05-08)在審核任何涉及 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。name_ja 是 GPT 從極薄語境(單句描述 + 文章全文)生成,極易幻覺電影名稱。raw_title(scraper 直接捕獲)中的括號標題。enrich_movie_titles select 查詢必須含 parent_event_id:
parent_event_id,若 select 字串缺少此欄位,event.get("parent_event_id") 永遠 None,guard 靜默失效。SYSTEM_PROMPT SUB-EVENT name_ja 規則的 CRITICAL 補丁:
SUB-EVENT name_ja 段落須包含 CRITICAL — DO NOT INFER MOVIE TITLES 文字。根因機制(供調試參考):
驗證命令(執行後確認 _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)且人工修正前無法自動偵測。
在審核任何涉及 ks_cinema(或其他系列頁電影場館來源)的 annotator 計畫,或分析同一電影出現多筆事件的問題前,必須確認:
4/25~5/1 10:00、5/2~8 14:40),不建立 sub_events;改用 start_date=首日、end_date=尾日,時段細節放 business_hours」。_cinema_sources = {"ks_cinema"},若 source_name in _cinema_sources AND source_id ends in _{digit} AND parent_event_id=None → sub_events = [](防止首次 race condition)。_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(出現重複電影事件時):
# Check if _sub1 events exist for the film
r = sb.table("events").select("id,source_id,is_active,parent_event_id").ilike("source_id", "%_sub1%").eq("source_name","ks_cinema").execute()
# Deactivate if found
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 欄位,或評估 category_corrections few-shot 範例設計的計畫前,必須確認:
_gpt_org_raw 在寫入 update_data 前,需確認 _gpt_org_raw in (raw_title + " " + raw_description)。不存在則丟棄(_guarded_organizer = None)並發出 WARNING log。category_corrections 的補正例若含具體主辦方名稱,GPT 可能對缺主辦人的其他活動 hallucinate 相同名稱。這是 named entity 的跨事件遷移效應。category_corrections few-shot → SYSTEM_PROMPT 注入 → GPT hallucinate organizer → P0 保護鏈保存錯值 → 子事件繼承 → 雪球效應。_ai_or_existing():確保 P1(field_corrections)保護也覆蓋 organizer 欄位。驗證指令(改動 annotator.py organizer 邏輯後執行):
# 確認 guard 存在
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")
# DB 掃描:找到 organizer 但 raw_text 不含 organizer 名稱的事件
# (需在 DB 層做,此為人工 SQL)
# SELECT id, name_ja, organizer, LEFT(raw_description, 200)
# FROM events
# WHERE organizer IS NOT NULL
# AND annotation_status = 'annotated'
# AND raw_description NOT ILIKE '%' || organizer || '%'
# AND raw_title NOT ILIKE '%' || organizer || '%'
# LIMIT 20;
Reference incident: 2026-05-06 — category_corrections 含 2 筆 セシリアママのHappy Table... few-shot 範例,導致 31 件 Peatix 活動被 hallucinate organizer = "セシリアママ"(commit fix(annotator): add organizer non-hallucination guard)。
在審核任何設定「配給」→ organizer 的案例,或分析 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_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+字)),屬高可信度異常。# 查所有有 work_id 且 name_zh 長度 < name_ja 長度 50% 的事件
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,未翻譯完整活動標題。
在審核任何涉及 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。raw_title + raw_description,但文本極短(< 100 字)時 GPT 仍可能從外部知識推斷,guard 無法完全阻止。對此類事件,organizer 應保持 null。快速識別模式(需要 is_active=false 的文章):
NON_EVENT_TITLE_RE = re.compile(
r"(おすすめ|紹介|まとめ|行ってきた|読んでみた|観てきた|鑑賞レポ|映画紹介)",
re.IGNORECASE
)
# raw_title 命中 → 標為非活動文章
驗證指令(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;
-- 所有 note_creators 事件的 organizer 應為 NULL
Reference incident: 2026-05-08 — 2cae572a/10a4ee5d organizer 被推斷為 埼玉県日台親善協会(note 發文者);4180ad0f/4ebc8a35 介紹文章/觀影報導入庫(commit b589fbb)。
在審核任何涉及 location_name 抽取邏輯的計畫,或分析展覽類事件 venue 識別錯誤的問題前,必須確認:
〇〇美術館蔵/〇〇博物館蔵 是作品所蔵機關標記,不是活動場地:GPT 容易將「高雄市立美術館蔵」中的「高雄市立美術館」提取為 location_name,這是錯誤的。location_name 應固定為「東京都写真美術館」:無論展品的所蔵機構來自哪個國家或城市,活動場地永遠是東京都写真美術館本身。location_name 有靜態預設值(如 yebizo scraper 應直接設定 location_name="東京都写真美術館")。raw_description 中出現「蔵」字緊接機構名,如 〇〇美術館蔵/〇〇博物館蔵/〇〇文化基金蔵,這些是所蔵標記,不是場地。驗證指令(展覽來源 location 掃描):
-- 確認 yebizo 事件 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 欄位的計畫,或分析 performer = NULL 案例時,必須確認 annotator.py 是否正確執行三層 fallback:
event.get("performer"))— 已有值時不覆蓋(含 field_corrections 保護)annotation.get("performer"))— SYSTEM_PROMPT 有 PERFORMER EXTRACTION RULES_extract_performer_from_raw(raw_title, raw_description))— GPT 失敗時的最後防線| 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 字元範圍不夠。
--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;
field_corrections 保護(正確方式;--id 重標注費時且 GPT 可能再次失敗)。"performer" 欄位;PERFORMER EXTRACTION RULES 段落必須在 ORGANIZER 段落前面。料理研究家・宮武衣充氏 — GPT 容易把整個字串當職稱描述,忘記提取人名[\u4e00-\u9fff]{2,5} 純漢字(上限 5),而非排除清單 [^\u3000\u30fb...]
{2,6} → 翻訳者一青窈(6 字)被誤識別為名字(role+name 連串){2,5} → 宇田川幸洋(5 字)仍可匹配,翻訳者一青窈 不匹配_MUKAE_RE 必須有 negative lookbehind (?<![\u4e00-\u9fff]):防止從職稱字串中間開始匹配。例:訳者一青窈 從 訳 開始匹配出 訳者一青窈(訳 前面是漢字 翻,lookbehind 阻擋)。_extract_performer_from_raw,人工確認所有命中をお迎え(帶 お)與 を迎え 是不同 pattern,需同時收錄Reference incidents:
e72b2c15 performer 三層 fallback 缺失;初版 regex 3 件假陽性(commits 562a620, 1ef6953, b2a8806)。4427f965(台湾植物紀行)前田知里|植物民族学研究家 未提取,三重根因:(1) 無 _PIPE_ROLE_RE;(2) 資訊在 pos 859 > 500 上限;(3) GPT 視主催者不為 guest(commit c82e746)。翻訳者一青窈 假陽性:INTRO {2,6} + MUKAE 無 lookbehind 導致 role+name 連串被誤匹配;修法:max 6→5 + lookbehind + 翻訳者 加入 role list(本 commit)。在審核任何手動合併(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;
-- 應為空結果;非空 = 資料不一致
director、release_year、cast_summary、description;合併後確認 work_id 正確連結。只做 event 合併但不更新 works 表,則 works 詳情頁缺少作品資訊。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 或類似 admin 表格中「一行引用另一行 ID」的 UI 計畫前(merged_into、parent_event_id 等),必須確認:
events props 建立,不能從篩選後的 displayEvents 建立:若 map 建立自 displayEvents,被篩選掉的事件 id 在 map 中為 undefined,行號顯示靜默消失(不報錯)。// globalIndexMap — 從完整 events prop 建立,不受 filter 影響
const globalIndexMap = useMemo(() =>
new Map(events.map((e, i) => [e.id, i + 1])),
[events]
);
// rowIndexMap — 從 displayEvents 建立,用於顯示當前篩選下的行號
const rowIndexMap = useMemo(() =>
new Map(displayEvents.map((e, i) => [e.id, i + 1])),
[displayEvents]
);
// merged_into 目標可能被篩選掉 → 優先用 globalIndexMap
const targetIdx = globalIndexMap.get(e.merged_into_event_id) ?? "?";
Map.get() 回傳 T | undefined;undefined 靜默渲染為空字串。這是靜默 UI 錯誤,只能靠人工觀察或 QA 發現。Reference incident: 2026-05-06 — AdminEventTable rowIndexMap 從 displayEvents 建立,merged_into 目標被篩選時行號消失(commits cb1bf83, 979725f)。
在審核任何 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] ...">
title_ja,不用 original_title:original_title 是原始語言片名(可能是中/英文),PostgreSQL ORDER BY ASC 將 null 值排末,導致大量 title_ja 有值的日文片名因 original_title=null 而沉底。後台以 title_ja 排序符合日文使用習慣。
.order("title_ja", { nullsFirst: false })
<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>。
.github/skills/agents/architect/history.md (newest at top).Architect 預設為 read-only(規劃 + 報告)。但在以下情況會直接編輯檔案:revert 操作、緊急修正、小幅文檔更新。直接編輯後必須走完以下其一,禁止留半成品:
絕不允許:編輯完直接呈現 commit hash 或「完成」字樣而沒明確指出 push 狀態。
呈現 git 狀態時,必須用以下三種標籤之一,禁止只給 hash:
<hash> → origin/main(已驗證 Vercel 部署或 push 成功 exit code 0)<hash> (local, not pushed)N files modified (working tree) 並列檔名裸 hash(如「commit cf1e0a9」)會讓用戶誤以為已推送,這是 anti-pattern。
刪除 i18n key、type union member、或任何被多處引用的 symbol 時,同一 commit 必須同時刪所有 caller:
grep_search 找出所有引用點。cd web && npx tsc --noEmit 確認 0 error 才 commit。反例:2026-05-01 撤銷 Tier 1 時刪掉 statsSlaHeader 等 i18n keys,但 stats/page.tsx 仍呼叫 t("statsSlaHeader"),導致工作樹半成品狀態(用戶察覺後手動修復)。
When planning any AEO (AI Engine Optimization) or SEO feature:
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.<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.ls supabase/migrations/ | sort | tail -5. Two migrations with the same number must use NNN and NNNb_ suffix.INDEXNOW_KEY and NEXT_PUBLIC_SITE_URL as required env vars in both GitHub Actions secrets and (if needed) Vercel.GSC_CLIENT_ID + GSC_CLIENT_SECRET + GSC_REFRESH_TOKEN), never service account JWT.access_denied. Plans that include OAuth token generation steps must note this prerequisite.When planning or reviewing changes to web/app/[locale]/events/[id]/opengraph-image.tsx:
N 值應設 ≥ 55(英文基準),而非 36(日文基準)。55 字 → 40px + 截斷至 53 字
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)cd web && npx tsc --noEmit, confirming all prior union members still compileWhen 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:
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: ""<input> or <textarea> elementAdminEditClient.tsx: add field: event.field ?? "" to form initialization.web/messages/*.json: add i18n key to all three files (zh, en, ja) simultaneously.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/ 子目錄下,不可直接放頂層
REVOKE ... ON TABLE <view_name> ..., not ON VIEW.GRANT ... ON ...REVOKE ... ON ...ALTER VIEW ... SET (...)SECURITY DEFINER RPC functions that gate admin access, do not rely only on request.jwt.claim.sub.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).set search_path = pg_catalog, schema-qualify cross-schema objects (public.user_roles, auth.users).42501 (admin privileges required).request.jwt.claim.sub set to admin uid: PASSbackfill_categories.py and manually inspect every match before applying to DB.academic), trace which keyword triggered it and tighten the rule immediately.在設計任何使用 schedule: cron 觸發並需要根據「是哪個 cron 觸發的」來 dispatch 不同行為的 workflow 前,必須確認:
-eq 判斷:GitHub Actions cron 啟動有 1–2 小時(甚至更長)的延遲。if [ "$HOUR" -eq 21 ] 在實際執行時幾乎永遠不會匹配。-ge/-lt:每個 cron slot 之間間隔 6 小時,用視窗範圍覆蓋延遲。else fallthrough 必須是安全行為:若用 else 作 fallthrough,部署後要驗證每個分支是否都有被正確觸發,不能假設 else 只有在「預期外情況」才觸發。Reference incident: 2026-05-04 — researcher.yml 所有 4 個 cron 全部 fallthrough 到 else → slot3,slot3 費用 $2.62/週(正常應為 $0.67),slot0/1/2 幾乎未執行。修復:改用 6 小時視窗。
在設計或審核任何 scraper_runs 寫入邏輯前,必須確認:
success=False 必須搭配 notes:只寫 success=False 等於告訴你「失敗了,但不知道為什麼」。notes 欄位必須包含 f"{type(exc).__name__}: {exc}"[:500]。except Exception: pass 是正確的,避免 logging 失敗掩蓋原始錯誤。Reference incident: 2026-05-04 — eurospace 3 次失敗(4/28–4/29)notes 全為 NULL,無法從 DB 追溯原因。修復:main.py except 區塊新增 "notes" 欄位。
在週報或每日報告出現「持續 0 件」來源時,不要立即 dry-run 或修改 scraper,先依以下順序診斷:
last_nonzero = never ≠ 邏輯失效):
sb.table('scraper_runs').select('events_processed,ran_at')
.eq('source', src).order('ran_at').execute()
# 計算 max_events 和 last_nonzero_date
.env 無 key → CI 可能正常,確認 Actions secretPERSISTENT_ZERO_DAYS=30 觸發自動警告。給 doc string 加上活躍期標注(防止未來誤報):
# Active period: Oct–Nov (festival announcement); returns [] outside festival year
Reference incident: 2026-05-04 — 13 個 0 件來源全部屬於正常狀態,透過查歷史+分類診斷在 30 分鐘內確認無需修改任何 scraper。修復:daily_report.py 加入 30 天自動監控。
gpt-4o-mini and gpt-4o have no web browsing. Use gpt-4o-search-preview or a real search API for current data.在審核任何涉及 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時(小時範圍)HH:MM開演/上映開始/開始 label 後的 HH 時MM 分 → HH:MM〜開場 label 後的 HH 時MM 分 → HH:MM〜HH 時MM 分(最低信心)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 鎖定。
在審核任何使用 html.parser.HTMLParser 的 scraper PR 前,必須確認以下三點:
<script>、<style>、<nav>、<header>、<footer> 等噪音標籤必須在 handle_starttag/handle_endtag 中跳過。
_SKIP = frozenset({"script","style","nav","header","footer"}) + _skip 計數器def handle_starttag(self, tag, attrs):
if tag in _SKIP:
self._skip += 1
def handle_endtag(self, tag):
if tag in _SKIP and self._skip > 0:
self._skip -= 1
def handle_data(self, data):
if self._skip == 0:
self._buf.append(data)
len(text) > 0:JS/CSS 代碼也是非空文字,但對業務邏輯無用。需確認業務關鍵字(如 日時、場所)是否存在於提取文字中。Reference incident: 2026-05-04 hakusuisha _T HTMLParser 未過濾 script/nav,■日時: 出現在 2000 字元之後 → raw_description 無效(commit 4784266)。
在審核任何 scraper 先 prepend 前綴到 raw_description 再對整份文字做 regex 搜索的邏輯前,必須確認:
開催日時: YYYY年MM月DD日,而後又用匹配 開催日時 的 regex 搜索整份文字,命中的是自己注入的前綴而非頁面原文的 ■日時:HH:MM〜HH:MM。_TIME_RE 只匹配 HH:MM〜HH:MM,不匹配 開催日時: YYYY年MM月DD日)
b. 限定搜索範圍到前綴之後(text[len(prefix):])
c. 在 prepend 之前先完成所有 regex 搜索,保存結果,再 prepend■日時:/会場:/主催:)在字元預算內。建議下限 8000 字元。Reference incident: 2026-05-04 hakusuisha — _JITSU_RE 命中 scraper 自注入的 開催日時: 前綴,business_hours 永遠 null(commit a0292a2)。
在審核任何 auto-generated 或人工撰寫的 scraper,其 FIELD_SELECTORS["date"] 或 listing page 日期提取邏輯時,必須確認以下兩點:
span.note、time.published、.date 等 selector 抓到的可能是記事公開日(YYYY.MM.DD),而非活動日。需實際檢視 listing page HTML 確認語意。日時: 標籤提取:若 listing page 日期語意不可靠,必須從 detail 頁的 日時:、開催日時:、■日時: 等結構化標籤提取。
日時:prepend 開催日時: YYYY年MM月DD日 到 raw_description日時(公告/新聞文):prepend (記事投稿日: YYYY年MM月DD日) 年份錨點scraper/sources/hakusuisha.py → _extract_event_dates(detail_text, card_year)Reference incident: 2026-05-04 hakusuisha FIELD_SELECTORS["date"] = "span.note" 抓取記事公開日而非活動日(commit b3708e1)。
auto_generate で生成された Layer B scraper の FIELD_SELECTORS["date"] をレビューする際は:
span.note 等の「投稿日」要素を指している場合は、detail ページの 日時: ラベルから抽出するロジックを追加すること。start_date 誤植は annotator では修正できない。scraper が非 null の誤値をセットすると、annotator の event.get("start_date") or GPT チェーンは GPT 値を無視する(or は falsy 値のみ置換)。根本修正は scraper 側のみ。_extract_event_dates(detail_text, card_year) — 日時: ラベルから start/end を抽出する 3 パターン対応関数。同様の問題を持つサイトには同パターンを適用すること。在審核任何觸及 annotation_status = 'reviewed' 保護邏輯的計畫或 PR 前,必須明確區分以下兩種情境:
category、start_date、end_date、name_ja(若 name_ja_locked)等欄位,reviewed 狀態應阻止 GPT 重新覆蓋。NULL 的 business_hours、location_name(若原本就空)等欄位,reviewed 狀態不應阻止確定性(非 GPT)邏輯補填。設計準則:
--fix-reviewed 模式應支援「空值補填」——只有當欄位目前為 null/空 時才寫入,有值則跳過。reviewed 事件的補填,因為不會產生幻覺。Reference incident: 2026-05-04 business_hours=NULL 因 reviewed 狀態保護永遠不修復(commit 54a20d7)。
location_name = 'オンライン', location_address = 'オンライン'. Both columns must be set; neither should be NULL. DB also requires location_address_zh = '線上', location_address_en = 'Online'.Event object. Use _ONLINE_RE pattern: r'(?:online|オンライン|ライブ配信|配信のみ|[Zz][Oo][Oo][Mm])'.location=online filter queries location_name ILIKE '%オンライン%' (location_address is redundant for filtering but must still be set).location=other_japan filter must exclude online events via BOTH location_name NOT ILIKE '%オンライン%' AND location_address NOT ILIKE '%オンライン%'.AdminEventTable.tsx other_japan filter must also check !addr.includes('オンライン') before accepting the event.'オンライン(Zoom)' must be canonicalized to 'オンライン'.location_name = '電視頻道', location_address = null. TV programmes have no physical address — do not attempt to fill location_address.gguide_tv scraper must always set location_name = '電視頻道' regardless of the actual channel name (tvk1, BS朝日1, etc.). Storing raw channel names causes them to be treated as venue names and creates false positives in other_japan filtering and the quality page address check.location=other_japan filter must exclude TV events via location_name NOT ILIKE '%電視頻道%'. Add this alongside the existing オンライン exclusion.source_name = 'gguide_tv' and location_name ILIKE '%電視頻道%'.other_japan filter exclusion AND the quality page whitelist.Pre-flight check: Before adding a new location filter option, run
SELECT count(*) FROM events WHERE location_name = '<value>' AND is_active = trueto confirm there are enough matching events. A location option with zero or near-zero results is an invalid option and should not be added (or must be removed). Thetvfilter was added and later removed becauselocation_nameno longer matched — always verify DB data format against the actual scraper output first.
When adding a new location filter type (e.g., a new region or a new special category like tv), update ALL 6 of the following in the same commit:
location_name value in the relevant scraper(s)web/app/[locale]/page.tsx — Add the new filter branch with the correct Supabase query; update other_japan to exclude the new type if applicableweb/messages/{zh,en,ja}.json — Add the new i18n key to ALL THREE files simultaneouslyweb/components/FilterBar.tsx — Add the new <option> to the location selectweb/components/AdminEventTable.tsx — Update BOTH getFiltered AND sourceCountMap with the new filter logic; add the <option> to the admin select; update other_japan exclusionweb/app/[locale]/admin/quality/page.tsx) — If the new type has no physical address, add it to the「缺地址」whitelistMissing any one of these causes: filter mismatch (items appear in wrong section), missing translation (raw key shown), or quality false positives.
address column (e.g. 台北市…). No prefix guard or .startswith() check is needed — Taiwan city names are not substrings of Japanese place names.ilike '%城市名%' for matching; do NOT use location_name equality — Taiwan events use the raw address column.OVERSEAS_MARKERS must stay in sync between page.tsx and AdminEventTable.tsx. The canonical 16-city list: 台北、台中、高雄、台南、新竹、嘉義、花蓮、台東、基隆、宜蘭、桃園、屏東、南投、彰化、雲林、澎湖.other_japan — these are physically separate geographic categories.LOCATION\n\nOnline event (single line, no address group). The two-part regex LOCATION\n\n(.+)\n\n(.+) will NOT match — always add a separate loc_online_m check BEFORE the two-part regex.is_confirmed_online flag immediately on match and skip all CSS and regex address fallbacks — description body text often mentions a venue as a conditional/secondary option and must never be used as location_address.location_name = 'オンライン', location_address = 'オンライン'.location_address = 'オンライン', NOT None.backfill_locations.py or the annotator. Always verify against the official source website first.location_address in a scraper must include a comment citing the verification URL and date, e.g.:
# Verified: https://jp.taiwan.culture.tw/cp.aspx?n=362 (2026-04-26)
location_address = "東京都港区虎ノ門1-1-12 虎ノ門ビル2階"
fetch_webpage on the official source URL before drawing any conclusion.backfill_locations.py has run on a source with a known fixed address, audit those DB records — AI-generated translations may contain hallucinated street numbers.enrich_addresses.py batch fills are AI-generated and NOT verified: GPT-4o-mini fills location_address / _zh / _en for events with a venue name but no address. These must be treated as unverified estimates. Known failure: MoN Takanawa (新場館) was filled with 東京都港区高輪4-10-30 (incorrect) instead of 東京都港区高輪2-21-2 (2026-05-01). After any batch fill run, manually spot-check records from high-profile partner venues (SSFF, TAICCA co-hosted venues, etc.) against their official access pages (会場・アクセス section).python3 -c "import os, re; [print(f+':'+str(i)+':'+l.strip()) for root,_,files in os.walk('web') for f in files if f.endswith('.tsx') for i,l in enumerate(open(os.path.join(root,f)).readlines(),1) if re.search(r'[\u4e00-\u9fff\u3040-\u30ff]',l) and not any(p in l for p in ['t(','tFilters(','tCat(','tEvent(','getEvent','MARKERS','//',"'//"])]" 2>/dev/nulluseTranslations() (React hook rules). Either move the const inside the component function, or pass the translation function as a parameter.When a feature spans both GitHub Actions and Vercel (e.g., LINE broadcast runs in GitHub Actions; LINE webhook runs on Vercel), each platform needs its own copy of every required secret.
GitHub Actions secrets ≠ Vercel environment variables. They are completely separate systems and do not share values automatically.
Both of the following must be set in both platforms before the feature goes live:
| Variable | GitHub Actions Secrets | Vercel Env Vars |
|---|---|---|
LINE_CHANNEL_TOKEN | ✅ (for broadcast) | ✅ (for webhook signature) |
LINE_CHANNEL_SECRET | ✅ (for broadcast) | ✅ (for webhook signature) |
Setting a secret in only one platform silently breaks the other side. Webhook 401 failures are especially hard to detect because LINE does not retry failed webhook deliveries — events are permanently lost.
In the Verification section of any plan involving both CI (GitHub Actions) and web hosting (Vercel), explicitly list:
Issues: write + Metadata: readrepo scopescraper/update_source.py)docs/GITHUB_TOKEN_SYNC_CHECKLIST.md, .github/instructions/token-rotation.instructions.md).github/agents/researcher.agent.md).github/SECRETS_LIFECYCLE.md)&) to coexist with the canonical Issues: write + Metadata: read.docs/GITHUB_TOKEN_SYNC_CHECKLIST.md is the single source of truth for the GITHUB_TOKEN sync checklist..github/TOKEN_SYNC_CHECKLIST.md), convert it to a redirect-style stub that points to the docs source.scraper/.env is ignored by git (git check-ignore -v scraper/.env)github_pat_xxx)messages/*.json files simultaneously — never add to just zh.json.getTranslations("admin"), check if it also needs getTranslations("general") for shared strings (footer, error banners).web/messages/*.json。如果 AI 在同一 commit 中捆綁了翻譯修改,必須 split commit 或手動 revert 翻譯部分。git status 確認 staging area 只有預期的 file,再執行 git add。若其他檔案意外出現在 index(第一欄為 M 而非空格),代表舊的修改已 staged,需分開 commit。.git/hooks/pre-commit 已加入 i18n regression guard。若 staged 的 messages/*.json 刪減了任何 key,commit 會被攔截並列出缺失 key。可用 git commit --no-verify 強制繞過,但必須有充分理由。python3 -c "import json; a=set(json.load(open('web/messages/zh.json')).keys()); b=set(json.load(open('web/messages/en.json')).keys()); c=set(json.load(open('web/messages/ja.json')).keys()); print('zh-en diff:', a-b); print('zh-ja diff:', a-c)"`
git log --oneline --since="3 days ago" -- 'web/messages/*.json' 逐一檢查可疑 commit 的 diff(git show <hash> -- 'web/messages/*.json' | grep '^-')。categories namespace 中的 group_ 標籤(group_arts/group_lifestyle/group_knowledge/group_society/group_archive)和晚期新增的子分類(competition/indigenous/history/urban/workshop)是歷史上最常被意外洗掉的 key,每次 web 功能發布前必須確認這些 key 存在。Reference incident: 2026-05-07 — commit 694a363 將 web/messages/*.json 的 197 個 key(organizer, eventForm, admin 各段落)意外刪除,因為 /tmp/fix_sources.py 已把 messages 修改寫入 staging index,而後續的 git add <3 files> + git commit 捲走了所有 staged 變更。
reviewed 狀態的活動不應有 name_zh = NULL 或 name_en = NULL。若有,後台 AdminEventTable 會顯示紅色 ⚠ 徽章提醒管理員。reviewed。完整欄位清單:name_zh、name_en(必要);description_zh、description_en(建議)。annotator.py 的 --fix-reviewed 旗標可自動修復缺少翻譯的 reviewed 活動(僅補翻譯欄位,保留 category 和 annotation_status = "reviewed")。python annotator.py --fix-reviewed,作為背景防護網。annotation_status 流程的功能時,必須考慮 reviewed 活動跳出翻譯流程的問題。SECURITY DEFINER RPC or privilege-critical migration, establish a four-quadrant verification matrix:
request.jwt.claim.sub = '<admin_uuid>')027_smoke_test.sql) with temp tables to avoid manual UUID copy-paste errors.027_VERIFICATION_REPORT.md) documenting:
當設計新的自動化 pipeline(auto-research、auto-generate、heartbeat PR 等)時:
scraper.yml。scraper.yml 是事件抓取主流程,任何非必要的步驟失敗都不應中斷每日爬蟲排程。auto-research.yml、auto-generate.yml)。workflow_dispatch 以便手動觸發,並支援 dry_run input。在設計或重啟「heartbeat PR 自動建立(auto-generated scraper PR)」pipeline 之前,以下三個先決條件必須全部滿足:
generate.py 在 spec 生成時把 sample HTML 直接送入 LLM prompt。外部網站的 HTML 可能包含惡意 comment(如 <!-- SYSTEM: ignore previous instructions -->),操控 LLM 輸出惡意程式碼,再自動 commit 進 repo。
解法(必須先實作):在 HTML 進入 LLM 前先 sanitize:
from bs4 import BeautifulSoup, Comment
def sanitize_html(raw: str) -> str:
soup = BeautifulSoup(raw, "html.parser")
for tag in soup(["script", "style", "meta", "link", "noscript", "iframe"]):
tag.decompose()
for comment in soup.find_all(string=lambda t: isinstance(t, Comment)):
comment.extract()
return str(soup)
此 sanitize 步驟必須在 _fetch_sample_html() 回傳前執行,不能只在 spec 生成時執行。
目前 sandbox 只檢查 events_found >= 1,不足以確認品質。heartbeat pipeline 啟用前須加入:
source_id 穩定性:連續兩次執行 source_id 值不變start_date 非空且非 fallback 至今日(排除以發布日代替活動日的情形)多個 auto-generated PR 同時修改 scraper/main.py 的 SCRAPERS 列表,merge 時必定衝突。解法之一:每個 PR 僅新增一行,並在 PR description 中標示唯一的插入位置(如「在 peatix.py 之後」)。
此規則的後果:在三個條件都滿足之前,auto-generate.yml 不應啟用 --create-pr 或 heartbeat 模式,只允許 dry-run + sandbox 驗證。
sources/ directory against SCRAPERS list in scraper/main.py to find unregistered source files.
comm -23 <(find sources/ -name '*.py' | xargs -I {} basename {} .py | grep -v '^__' | sort) <(grep 'Scraper()' scraper/main.py | sed 's/.*\(.*\)Scraper().*/\1/' | sort)SCRAPERS and run python main.py --dry-run --source <name> to verify event count is non-zero or document expected reason (offline season, no Taiwan matches, festival in October).official_url from the film detail page using one of:
/ticket/ or /purchase/ (domain-agnostic)official_url extraction to an existing scraper, immediately backfill validation:
--dry-run and manually inspect first 5 eventsname_ja (Japanese title) regardless of current request locale. Google Search ranks results by search query locale, not result locale — using a locale-specific name variable will cause wrong film matches and incorrect official_url extraction.When plans involve multiple similar tasks or iterative fixes, guide the user toward these batching patterns to avoid unnecessary tool overhead:
See .github/skills/session-analytics/SKILL.md for the full anti-pattern catalogue and efficiency thresholds.
When designing agent workflows that need one-click handoff buttons:
.prompt.md vs .agent.md Distinction.prompt.md — One-off tasks invoked via / command or skill menus. No persistent role, no tool restrictions per task. Use for: "Generate test cases", "Create README", "Summarize metrics"..agent.md — Persistent agent persona with role, tools, and instructions. Use for: long-running workflows, role-based tool restrictions, or handoff chains. Can be invoked via agent picker or as handoff target..agent.md files — the agent: field in handoffs: must reference an .agent.md file's name, NOT a .prompt.md filename.handoffs:
- label: "🔧 Button text"
agent: AgentNameFromFile # Must match .agent.md name exactly
prompt: "Chinese instruction" # Pre-filled when user clicks
send: false # Optional, default false
model: "Claude Sonnet 4.5 (copilot)" # Optional, inherits agent default if omitted
⚠️ Critical:
user-invocable: falseblocks handoff buttons from appearing. Even though the VS Code docs describe it as "hide from agent picker only", in practice the handoff button does not render in the source agent's response when the target hasuser-invocable: false.
Handoff target agents must use the default (omit user-invocable entirely, or set true):
---
name: My Handoff Agent
description: "Brief role description"
tools: [read, search, execute, web] # Minimal necessary tools
---
If you want the agent hidden from the manual picker AND still reachable via handoff buttons,
this combination does NOT currently work — user-invocable: false suppresses both.
The only way to keep it out of the picker while allowing handoff is to use user-invocable: true
and instruct users not to invoke it manually (via the description field).
Reference incident: 2026-05-14 — update-history-agent.agent.md and validate-merge-deploy.agent.md
had user-invocable: false. Handoff buttons were silently invisible in all 7 source agents.
Fixed by removing user-invocable: false (commit 6188653).
send: true — ⚠️ Do NOT use(VS Code 行為已改變)現行 VS Code 行為(2026-05-14 以後):
send: true:按下按鈕開新 chat,prompt 出現在 input 欄等待使用者確認後按 Enter。✅ 推薦。send: true:按下按鈕 prompt 立即 auto-fire,使用者無法審閱或編輯。❌ 不建議。不要在 handoff 加 send: true:
handoffs:
- label: "🔧 Button text"
agent: AgentNameFromFile
prompt: "Chinese instruction" # prompt 會出現在 input 欄,等待使用者確認
# 不加 send: true
歷史備忘:2026-05-14 之前,沒有
send: true時 input 欄是空的(問題 B),所以加了send: true。之後 VS Code 行為改變,send: true變成 auto-fire。commits4f1dd6c(加入)→aa3f615+2463547(移除)記錄這段轉變。
name: in frontmatter must match the handoff agent: field exactly (case-sensitive).prompt: field with clear Chinese task description to ensure context transfer.user-invocable: false on handoff targets — this silently breaks all buttons pointing to that agent.send: true on handoffs that have a prompt: field, or the prompt won't appear.Developer: Reload Window, then verify buttons appear in the response area after a message.預算層——scraper/weekly_report.py(LINE 週報)為全站唯一資源使用監控來源:
WEEKLY_OPENAI_USD_WARN = 5.0、WEEKLY_DEEPL_CHARS_WARN = 100_000、MONTHLY_BUDGET_USD = 20.0。weekly_report.py 常數。新增任何 /admin/ 子頁面時,header 必須使用完整 tab nav 而非「← 返回管理後台」連結:
getTranslations("admin") 取得所有 tab 標籤的 i18n 翻譯。web/app/[locale]/admin/aeo/page.tsx 或 events/page.tsx)。messages/*.json 中的 admin namespace。反例:2026-05-01 aeo 頁面原本只有「← 返回管理後台」連結,後來在 commit 5cae991 才補齊完整 tab nav。計劃階段就應強制要求。
When designing any feature that runs LLM-generated or otherwise untrusted Python in a subprocess (auto-scraper codegen, plugin execution, etc.):
PATH / HOME / PYTHONUNBUFFERED / PLAYWRIGHT_BROWSERS_PATH / TMPDIR / LANG / LC_ALL. Never pop known secret keys (SUPABASE_*, OPENAI_API_KEY, GITHUB_TOKEN, LINE_*) from a copied env — any future .env addition will silently leak. Allowlist is fail-closed; blacklist requires constant maintenance.try/finally AND atexit.register(cleanup). try/finally covers normal + exception paths; atexit covers SIGKILL / unhandled exit. One alone is insufficient for codegen artifacts (e.g. _auto_<name>.py shimmed for subprocess import).SCRAPERS), open PRs, or write to user-facing DB tables (events). Only the source's own status row may be updated. Activation must live in a separate phase / commit / reviewer to keep the unsafe→safe boundary auditable.Reference incident: 2026-05-01 commit a0606fe (auto-scraper Phase 2). Pre-implementation review chose allowlist after enumerating future .env additions.
Any code path that gates on LLM cost (per-call budget, daily ceiling, abort-on-overspend) hardcodes pricing constants that drift over time. Architect session checklist when reviewing such code:
gpt-4o → gpt-4.1).INPUT_USD_PER_1M / OUTPUT_USD_PER_1M across scrapers. Current home: scraper/auto_scraper/generate.py ($2.50/1M input, $10.00/1M output, default budget $1.50/source).When reviewing any plan whose prompt copy says things like "matching the schema (provided)", "following the spec attached", "as listed below", verify the referenced artifact is actually injected into the LLM messages array — not merely cited by name.
grep the prompt text for the file/schema name, then trace every reference to confirm: (a) the file is read at runtime, (b) its contents are concatenated into a system or user message, (c) the injection point sits BEFORE any place where the LLM is asked to use it.spec.json-style outputs, also enumerate critical fields explicitly in the prompt body (belt + braces). Schema injection alone is not enough — the LLM ignores schema details when verbose.spec_schema.json but the schema was never loaded. Three retries omitted base_url. Fix in b6e1768.Plan-review checklist line: "Every prompt-referenced artifact (schema, sample, examples) is verified to actually appear in the messages array."
For any LLM-generated artifact that references real-world identifiers (CSS selectors, file paths, function names, API endpoints, package names, environment variables), add a fast pre-validation step that confirms the reference exists before downstream consumption.
.event-card, .user-list-item, getUserById) at high rates, especially when the reference base is large or the LLM is verbose..event-card matches 0 elements in sample HTML; available repeating elements: li.article-list (12), article.post (4)") so the next LLM call can correct itself._validate_selectors_against_html() added before sandbox spawn. Zepp Tokyo / Fukuoka Now batch1 wasted $0.04 each on sandbox-failed; batch2 fast-failed in <100ms with no Playwright spawn.Pattern catalogue for future LLM-generated artifacts:
| Artifact | Validation step | Tool |
|---|---|---|
| CSS selectors | BeautifulSoup.select() count ≥ 1 against sample HTML | bs4 |
| File paths in repo | Path(...).exists() | pathlib |
| Python function/class names | ast.parse + symbol walk | ast |
| URLs | HEAD request, expect 2xx/3xx | requests |
| Env var references | os.environ.get(...) is not None | os |
When a function returns different shapes for success/failure paths, instrumentation (cost, retry count, elapsed time, token usage) must be in a finally block or shared mutable accumulator — not after the success-only return.
cost_usd=0.0 and retries=0 on failed runs even though logs prove multiple LLM calls happened.accumulator = {"cost": 0.0, "retries": 0} at function scope; mutate in every retry branch; persist in finally regardless of exit path.在審核任何動到 merger.py、works 表、parent_event_id / merged_into_event_id 欄位、或新增 / 修改 Pass 1–N 邏輯的 PR 前,必須確認:
cd scraper && python merger.py --dry-run 2>&1 | tail -20
驗收標準:
Done: N pair(s)/orphan(s) would be mergedTracebackPass X done 或 Pass X: ... handled56b0ad2 → 27c21a7 → 79a5c40 三個 merger 改動,全部沒跑 dry-run,導致 02:11 cron 噴 32s。規則:當天每動 merger 一次都重跑 dry-run,不要連續 commit 不驗證。work_id、merged_into_event_id 等)必須掃所有 Pass:每一個 Pass 的 query / filter / skip 條件都要明確處理新欄位,不可假設「跟其他 Pass 一樣」。google_news_rss 等 _NEWS_SOURCES)必須在所有 Pass 各自驗證:5/5 失敗根因即「sub-event 處理只覆蓋 Pass 1/2 沒覆蓋 Pass 3」(fix ab3bd9e)。Reference incident: 2026-05-05 19:52 + 2026-05-06 02:11 — Run Merger 連續兩次失敗。根因:56b0ad2 / 27c21a7 / 79a5c40 加入 works skip 與 merged_into_event_id badge logic,沒處理 google_news_rss sub-events 的特殊形式。修復 ab3bd9e(5/6 13:27)+ 5f98b3b 又補 Pass 5。
在審核任何包含 location_name 或 location_address 的 annotator 修改、或任何新 scraper 的 location 欄位邏輯前,必須確認以下四點:
location_address ≠ location_name:兩者相同是地址抽取失敗的標誌。SYSTEM_PROMPT 必須明示「identical 時保持 null」。auto_qa_address_is_venue_name 偵測器持續監控此情況。○○S.C. 森のまち広場、○○ビル2階 大会議室、○○ホール内 スタジオA 等複合場地名,地址 geocode 對象是親設施,不是子空間。annotator SYSTEM_PROMPT 的 LOCATION ADDRESS RULE 需要有 PARENT VENUE ADDRESS RULE 段落。location_address = location_name:annotator 的 _ai_or_existing() 保護邏輯在 DB 欄位非 null 時會保留 scraper 寫入的錯誤值,不覆蓋。因此 scraper 若直接把 venue name 複製到 address,annotator 的規則完全無效。正確做法:scraper 使用 _ADDR_RE(〒 或 prefecture+city+street pattern)從 raw text 抽取真實地址;找不到則設 None。auto_qa_address_is_venue_name(location_address == location_name)必須存在於 auto_qa.py 的 QA_TYPES 中,且由 run() 呼叫。Reference incidents:
878660a0 iwafu — 流山おおたかの森S.C. 森のまち広場 scraper 直接設 location_address = place_val(venue name),導致 annotator 的 PARENT VENUE ADDRESS RULE 完全無效。修復:iwafu.py 改為 _ADDR_RE 抽取真實地址,找不到設 None。auto_qa_address_is_venue_name 偵測器加入 auto_qa.py。在審核任何 scraper 的 _ADDR_RE(或類似地址抽取 regex)定義前,必須確認:
(?:...)? 省略可能にする:東京都|...府|...県 を必須にすると、港区芝公園3-2(プレフィックスなし)のように公式サイトが都道府県を省略した住所がサイレントに None になる。[市区町村] を必須アンカーとして設ける:prefecture optional にする代わりに [^\s]{1,4}[市区町村] を必須にすることで false positive を防ぐ。body_text を住所フォールバックとして活用する:公式サイトを fetch する scraper では、_ADDR_RE を main_text に適用して失敗した場合、公式サイト body_text にも適用する。helper 関数は body_text を戻り値に含めること(iwafu は _fetch_official_organizer_info を 3-tuple 化済み)。location_address = None かつ location_prefectures = None のまま入庫 → annotator が正しく推定できず、Vercel 地図リンクも生成されない。CI では気づきにくい。Reference incident: 2026-05-15 — 屋台湾フェス2026 iwafu_1137442、location_address=None / location_prefectures=None。根因は _ADDR_RE の都道府県必須プレフィックスと official_body_text の未活用(commit 修正済み)。
在審核任何使用 Contentful CDA API 的 scraper 前,必須確認:
scheduleStartsOn 可能為 YYYY-01-xx(財年佔位符),不代表實際開展日期。Contentful 使用整個 1 月(1/1 至 1/31)作為佔位,不限 Jan 1。start_date 的月份 = 1(start_date.month == 1),從 URL slug 末尾 /YYYY-MM-DD 提取真實日期。day == 1:已觀察到 2026-01-15 也是佔位符(events 977da793, e7cf2a51)。正確條件:start_date.month == 1。name, start_date, slug,確認無 January 佔位符。Reference incidents:
month == 1 才完整覆蓋 (commit 7df9f56)。在審核任何新 scraper 的關鍵字 URL 參數過濾前,必須確認:
_is_taiwan_relevant() 檢查,防止 server 行為無聲改變。台湾大学、淡江大学、国立台湾師範大学 等)出現在作者略歷中,不代表活動內容與台灣相關。需用 regex 排除後再計 keyword count。_AUTHOR_BIO_RE = re.compile(r'台湾[・・]?(?:大学|淡江|国立|師範|政治|成功|交通|中山|清華)')
def _is_taiwan_relevant(title: str, description: str) -> bool:
if any(kw in (title or "") for kw in TAIWAN_KEYWORDS):
return True
excerpt = (description or "")[:500]
if sum(excerpt.count(kw) for kw in TAIWAN_KEYWORDS) < 2:
return False
cleaned = _AUTHOR_BIO_RE.sub("", excerpt)
return any(kw in cleaned for kw in TAIWAN_KEYWORDS)
Reference incident: 2026-05-07 — bookandbeer ?keyword=台湾 被 server 靜默忽略;初版無 client filter → 所有事件進 DB。進階修正排除作者略歷誤判 (commits 7df9f56, e1ab468)。
Reference incident: 2026-05-07 — tsutaya_portal _is_taiwan_relevant() 全文搜索導致 5 件アーティスト略歴偽陽性入庫(artist bio pos 586–1634)。修正:title 全文 + description[:500] に限定(commit c3ae92a)。
在審核任何 RSS-based scraper 的 start_date 提取邏輯前,必須確認:
# WRONG
start_date = _extract_start_date(article_text or description_plain, pub_date)
# CORRECT
start_date = _extract_start_date(article_text, pub_date) if article_text else None
start_date = None + universal year-anchor = 最佳 fallback:annotator 的 (記事配信日: YYYY-MM-DD) 前綴已確保年份正確。start_date < today。Reference incident: 2026-05-07 — gnews RSS snippet fallback 造成錯誤 start_date;health_check gnews_suspect 對未來日期誤報 (commit 1c0f69a)。
在審核任何 scraper/main.py 的 commit 前,必須確認:
git diff HEAD -- scraper/main.py | grep "^-.*Scraper()" | wc -l
# 若 > 0 → 必須確認每個刪除都是有意的
grep -c "Scraper()" scraper/main.py 值只可增加或維持,不可下降。Reference incident: 2026-05-08 — commit 694a363 做 import 重排,意外刪除 WalkerplusScraper、BigRomanticRecordsScraper、WasedaIclScraper、TsutayaPortalScraper(與 2026-05-04 045d1fa 同型)。
在審核任何美術館/博物館類 scraper 的 PR 前,必須確認:
organizer 欄位已設定:若 scraper 只有 venue 資訊,應設 organizer = venue_name。raw_description header 包含 主催: 行:確保 GPT annotator 有明確主辦信號,不靠推斷。event_form 已設定:展覽類 scraper 應 hardcode event_form = ["exhibition"];Reference incident: 2026-05-05 — tokyoartbeat organizer 未設 → GPT 幻想 横浜美術館;event_form 未設且已 reviewed → 永遠空 (commit a1e58a9)。
在審核任何涉及建立 works 記錄或批次映射電影中文片名的計畫前,必須確認:
lookup_movie_titles(name_ja):scraper/movie_title_lookup.py 已有完整的 eiga.com 查詢 pipeline,能從 原題または英題 欄位取得正確的中文/英文片名。批次腳本必須先對每一筆 work 的 title_ja 呼叫此函式,取得 (name_zh, name_en)。(None, None) 的片名需人工查證:eiga.com 未收錄的片名才需用維基百科、台灣電影網、IMDb 交叉驗證。驗證來源優先順序:
zh.wikipedia.org/wiki/<片名>)taiwancinema.bamid.gov.tw)imdb.com/title/<id>)導演你有病 → 超低予算ムービー大作戦)。GPT 直譯必然產生看似合理的虛構片名。field_corrections 鎖定前必須確認值正確:一旦用錯誤值 upsert field_corrections,enrich_movie_titles() 的 _human_protected 邏輯會永遠保護該錯誤值,自動修正 pipeline 完全失效。from movie_title_lookup import lookup_movie_titles
zh, en = lookup_movie_titles(title_ja)
if zh: # eiga.com 有結果 → 使用
work['title_zh'] = zh
work['title_en'] = en or work.get('title_en')
else: # eiga.com 無結果 → 標記待人工確認
work['_needs_manual_check'] = True
Reference incident: 2026-05-05 — 超低予算ムービー大作戦 被 GPT 直譯為 超低預算電影大作戰。eiga.com 上有正確答案 原題:導演你有病 Out of Nowhere,但批次腳本未呼叫 lookup_movie_titles(),直接用 GPT 結果寫入並鎖定 field_corrections,阻斷了自動修正 pipeline。
在審核任何 _oneoff_*.py 或 batch 修復腳本的計畫前,必須確認:
post_batch_enrich(event_ids):annotator.py 的共用函式,自動執行電影片名 eiga.com lookup + field_corrections 鎖定,避免 GPT 直譯幻覺。name_zh/name_en:改用 lookup_movie_titles(name_ja) 取得正確片名。field_corrections 只能鎖定經驗證的值:未經 eiga.com 或人工確認的值,不可 upsert 進 field_corrections。post_batch_enrich 後執行 python annotator.py --enrich-person-names。post_batch_enrich 的實作位置:scraper/annotator.py,在 enrich_person_names() 之後、backfill_tier1_events() 之前。Reference incidents:
_oneoff_fix_movies.py 跳過 lookup_movie_titles(),導致 超低予算ムービー大作戦 被 GPT 直譯為虛構片名。field_corrections。在審核任何涉及 auto_research.py 閾值或 not-viable 判定的計畫前,必須確認:
SCORE_PROMOTE_THRESHOLD = 0.70 是「自動昇格」的閾值,不是「viable/not-viable」的分界:score 0.30–0.69 的來源標記為 assessed,留人工決定。人工可手動設為 researched。not-viable:LLM 明確找不到任何台灣關聯性 → 真正 not-viable。researched,即使 score 偏低:
not-viable:正確行為是 assessed(保留人工決定權)。not-viable/skipped,否則浪費 API token。Reference incident: 2026-05-06 — score 0.30–0.70 的 5 筆來源(98/110/144/178/191)被 not-viable 阻擋,並發現 4 筆重複 candidate(251/252/257/260)。手動批量修正。根因:誤將自動昇格閾值當作 viable/not-viable 判斷標準。
在審核任何涉及主辦方聚合、場地報表,或使用 organizer_id/venue_id FK 欄位的計畫前,必須確認:
events.organizer/events.location_name 保留為稽核用途:報表與聚合使用 FK 欄位(organizer_id/venue_id),事件詳情頁仍顯示原始文字欄位。不可直接改動 organizer 文字欄。_populate_entity_fks() 在 upsert_events() 後自動執行:新刮取的事件會自動比對 organizers.aliases 和 venues.aliases 解析 FK。若 migration 050 未套用,gracefully no-op(不報錯)。backfill_entities.py 必須在 migration 050 套用後執行:先用 _oneoff_review_organizer_clusters.py 預覽聚類結果(SequenceMatcher ≥ 0.92),人工確認後再執行 backfill。organizers/venues 表新增一行(canonical_name_ja + aliases),FK 解析管道自動處理後續事件。works.work_type 包含 tv_drama 和 tv_variety(migration 051):設計涉及電視劇或綜藝的 work entity 時直接使用;不需「other」fallback。Reference: migration 050_entity_tables.sql、051_works_tv_drama.sql(commit 913b7a2)。
在審核任何涉及 google_news_rss sub-events(source_id 含 _sub)的 merger 邏輯前,必須確認:
_sub 事件,導致 gnews 電影場次永遠不被 ks_cinema 等官方來源吸收。_gnews_base_id 守衛:同一篇文章的 sub-events(例 gnews_abc_sub1、gnews_abc_sub2)代表同場次的不同場,不可彼此合併;比對 base ID 相同時跳過。work_id 的 news event 已被 Pass 1 按名稱相似度處理,不再以日期+地點做第二次合併(避免 false positive)。enrich-person-names 後執行第二次 merger:同日爬取後的新 sub-events 也能在當天完成合併。Reference: commits ab3bd9e(gnews sub-events in all passes)、5f98b3b(Pass 5 same-work_id dedup)。
在審核任何涉及 GPT enrichment 函式(enrich_person_names、enrich_movie_titles)或 auto_qa --fix 的計畫前,必須確認:
_to_trad():enrich_person_names() 的 GPT 回傳值必須通過模組層級的 _to_trad() 函式,防止 SC 字元重新引入。auto_qa --fix 修正後必須鎖 field_corrections:fix_simplified() 轉換完畢後呼叫 _lock_fields_via_corrections(),防止下次 re-annotation 再覆寫。_SIMP_TO_TRAD 字元映射表為模組層級:不可放在函式內(否則 enrichment 函式呼叫不到)。Reference: commits 239cb19(enrich SC guard)、6e21c52(auto_qa lock)。
在審核任何使用 workflow_run trigger 的 notify workflow 前,必須確認:
workflow_run + job 層級 if: 的 failure 語意:當 if: 條件為 false,整個 workflow run 的結論是 failure("No jobs were run"),不是 skipped。若 notify workflow 本身在監控清單 workflows: 裡,它的 failure 會再次觸發自身,形成無限迴圈。if: 加入自我排除條件:
if: >
github.event.workflow_run.conclusion == 'failure' &&
github.event.workflow_run.name != '<本 workflow 名稱>'
workflows: 移除:若 workflows: 明確列舉被監控的 workflow,確保列表不包含本 workflow 自身的名稱。skipped 不等於 failure:workflow 本體的結論 skipped 由 GitHub 定義;job 層級 if: false 的結論是 failure(workflow 有執行,但沒有任何 job 跑)。Reference incident: 2026-05-06 — workflow-failure-notify.yml 自我觸發 → 無限迴圈 → 垃圾通知郵件(commit 266daa1)。
在建立任何新的定期(非每日)workflow 或 health_check 相關改動前,必須確認:
health_check.py 的 NON_DAILY_SOURCES 必須包含所有非每日 source:每新增一個非每日 cron workflow,立即在同一 commit 更新 NON_DAILY_SOURCES。
NON_DAILY_SOURCES: frozenset[str] = frozenset({
"weekly_broadcast",
# <new_non_daily_source>,
})
NON_DAILY_SOURCES 的 source = health_check 認為應每日執行:若 7 天內有執行記錄但今天沒跑,health_check 每天回報 missing,直到修復。NON_DAILY_SOURCES,在對應的 workflow yml 加上 comment 標注告警視窗。Reference incident: 2026-05-06 — weekly_broadcast 因 NON_DAILY_SOURCES = frozenset() 為空,被 health_check 每天誤報 missing(commit 7df9f56)。
在審核任何新增或修改 .github/workflows/*.yml 的計畫前,必須確認:
if: 欄位只允許單行雙引號字串:
if: | 或 if: >-(block scalar)→ GitHub Actions YAML 解析器不支援 → parse error。if: "condition1 && condition2"(全部在雙引號內,單行)&& 連接,不可換行。run: | 區塊內不可包含 [{...}] inline 模式:
[{"type": "text", "text": ...}](flow sequence + nested flow mapping)在 run: | 區塊內會被 GitHub Actions YAML 解析器誤判為 nested mapping → parse error。# ❌ 不可(將被誤判為 nested mapping)
run: |
echo '{"k": [{"type":"text"}]}' | jq .
# ✅ 正確(先賦值給 shell 變數)
run: |
JQ_FILTER='{"k": [{"type":"text"}]}'
echo "$JQ_FILTER" | jq .
連續 parse error 時的應對順序:
if: |)→ 改單行雙引號字串。if: >-)→ 同上。[{...}] in run: |)→ 賦值給 shell 變數。Reference incidents: 2026-05-07 — commits 0b5ba72、c38ddd5、b9a462c:workflow-failure-notify.yml 連續三次 YAML parse error,依序為 if: | → if: >- → if: "...",加上 jq filter 賦值變數才全部解決。
lookup_movie_titles() 3-tuple + eiga.com /jump/?u= 解析)在審核任何擴充 lookup_movie_titles() 或 enrich_movie_titles() 的計畫前,必須確認:
lookup_movie_titles() 回傳 (name_zh, name_en, official_url) 3-tuple。official_url 從 eiga.com detail page 的 オフィシャルサイト /jump/?u=<URL-encoded> 解析(urllib.parse.parse_qs)。enrich_movie_titles() 寫入 official_url 時,只在 event 目前 official_url 為 null/falsy 才寫入,不可覆寫已存在值(否則可能蓋掉已 FC-lock 的人工值)。_lock_fields_via_corrections() upsert 進 field_corrections,與其他 enrich_* 函式一致。annotator.py 共 3 處:~line 1892, 1902, 2468)。漏更新會 silent break(ValueError: too many values to unpack)。Reference incident: 2026-05-12 — Phase A 實作;3 個呼叫點全部更新後上線。
對 raw_description 含「4K」「リバイバル」「リマスター」「デジタル修復」「○周年」等重映信號的電影類事件,annotator/lookup pipeline 提取的 official_url 不可信任——eiga.com 收錄的多半是原作頁,新版發行的官方網站(如 4K 重映專屬站)detail page 常沒有 オフィシャルサイト jump link。
判斷信號:
遇到時的補救流程:
<原片名> 公式サイト」或「<原片名> 4K」。works 表,方便日後其他場次共用。Reference incident: 2026-05-12 — ヤンヤン 4K 重映實際官網為 yi-yi.jp,但 eiga.com 詳情頁無 jump link;初次 enrich 寫入錯誤 yiyi-movie.jp,需手動修正並 FC 鎖定。
official_url ≠ source_url)在審核任何新 scraper 或既有 scraper 修改的計畫前,必須確認 official_url 的設定方式與 source 性質一致:
official_url 不可 fallback 到 source_url:
tokyoartbeat、peatix、doorkeeper、connpass、eplus、livepocket、kokuchpro、walkerplus、arukikata、prtimes、ftip、google_news_rss、nhk_rss、note_creatorssource_url = aggregator 頁面(保留作 audit trail)official_url = 從頁面 body 提取的主辦方一手 URL;提取不到時必須為 Nonesource_url = official_url_extracted — 破壞 audit trail(已於 ftip 2026-05-10 處理)official_url = ... or source_url — CMS 欄位為空時靜默汚染(已於 tokyoartbeat 2026-05-16 處理)source_url 自體即官方頁,可明示設定 official_url=url):
taiwan_cultural_center、taiwan_matsuri、koryu、taioan_dokyokai、taiwan_kyokai、asahiculture、各 cinema scrapergrep -rn "official_url.*or source_url\|official_url=source_url" scraper/sources/
# 期待結果:0 件
official_url 汚染後 UI「公式サイト」按鈕指回 aggregator 而非主辦方頁面,使用者無法到達真正的活動資訊源。Reference incidents:
ftip.py source_url 被 official_url 覆寫,破壞 FTIP audit trail(commits ab771e2 → 7c34788)。tokyoartbeat.py line 124 or source_url fallback 汚染 event 74ee6d89(共時的星叢)official_url;DB 修正 + FC 鎖定,scraper 改為 or None。在審核任何「場館型父事件 + 多場次 sub-event」結構(YCAM、新文芸坐、シネマート新宿、ks_cinema 等)的 business_hours 設定計畫前,必須確認:
business_hours = 場館營業時間:開館時間、休館日、固定休業日(例:「10:00–20:00、火曜休館」)。business_hours = 場次時刻:一場放映/演出一行(例:「4/20(土)14:00〜」)。business_hours / business_hours_zh / business_hours_en 一起。反模式: 父事件 business_hours 寫成「2026年4月15日〜4月26日 各場次..」(放映期間摘要)→ user 看不到場館的實際開門時間。
Reference incident: 2026-05-12 — YCAM 6801814c 父事件 business_hours 原為放映期間摘要,修正為場館營業時間「10:00–20:00、週二休館」;場次時刻全部移至 sub-events。
在審核任何引入 Google Cloud API(特別是 Custom Search、Translation、Vision 等需要 GCP project 啟用的 API)的計畫前,必須確認:
403 PERMISSION_DENIED 不會發生(即使帳單綁定、API enable、key 無限制,仍可能因組織政策或 project metadata 而被擋)。Reference incident: 2026-05-12 — movie_title_lookup Phase B 嘗試 Google Custom Search API fallback;帳單已綁定、API enable、key 無限制、等待 5+ 分鐘、換新 key、改 endpoint 都試過,持續 403 PERMISSION_DENIED,無法在使用者端解決。Phase B 程式碼保留為 graceful-skip 結構,但不再規劃用 GCP API 作 fallback。
在審核任何 DROP CONSTRAINT IF EXISTS + ADD CONSTRAINT … CHECK 組合的 migration 前,必須確認:
SELECT unnest(event_form) AS val, count(*) FROM events
WHERE event_form IS NOT NULL GROUP BY val ORDER BY count DESC;
ERROR: 23514 migration 失敗。EXPLAIN 或只 SELECT 檢查。Reference incident: 2026-05-15 — migration 047 study_abroad(1 筆)不在新清單,觸發 23514。
在設計任何 organizer_type 批次補值腳本前,必須確認:
source_name)對應的機構性質高度一致['unknown']):
organizer 欄位為空organizer 為縮寫代號(如 RTC、湾.味)無法對應已知機構note_creators、RSS snippet)依 Blog/Creator Source Guardorganizer_type 是 text[],必須傳 Python list(['civic_group']),不可傳字串organizer_type 不在 TRACKED_FIELDS,補值後不需寫 field_correctionsReference incident: 2026-05-15 — gguide_tv(media)、cinema 來源(independent_venue)、wuext_waseda(academic)等批次推斷後 organizer_type 非 unknown 從 70% 提升至 92.1%。