# 城市活動蒐集器 維護手冊（兼 DESIGN）

最後更新：<日期>（extract 單筆 LLM 失敗改成跳過該筆並記 `extract_skipped`，不再讓整天的 run 失敗）。前次部署基準：`<commit>`；本次版本以 repository 最新 commit 為準。

> 去個資版說明：來源站名、路徑中的使用者名稱、主機名、VPN 網址、通知 topic、本機 LLM 型號已改成佔位；各來源的解析細節有節錄。

## 目的與資料流

本專案是本機個人用的城市活動蒐集器。scraper 只做低頻公開資料 IO，輸出 JSONL；ingest 集中進行 LLM 抽取、規則正規化、去重與 SQLite 儲存；enrich 回頭抓各活動自己的頁面補 description 與一句話 summary；最後產生靜態 HTML、選擇性送通知，並發佈到 VPN 內（Caddy）與公網（Cloudflare Pages）兩個管道。

```text
scrapers ──async collect──> data/staging/<run_id>/*.jsonl
                                  │
                                  v
extract ─> normalize ─> tag(LLM) ─> dedup ─> store(SQLite)
                                                │
                              enrich(HTTP) ─> summarize(LLM)
                                                │
                                      report HTML ─┬─> ntfy
                                                   ├─> Caddy sites（VPN 內，自動）
                                                   └─> Cloudflare Pages（公網，自動）
```

專案絕對路徑是 `~/projects/events`。所有下列指令均從此目錄執行。

## 目錄地圖

- `run.py`：唯一 CLI 入口。
- `src/<pkg>/scraper_base.py`：plugin 契約、registry、網域節流、HTTP 快取、RSS、robots helper。
- `src/<pkg>/scrapers/`：一個來源一個檔案；`_common.py` 是共用解析 helper。
- `src/<pkg>/normalize/`：日期與會場的純規則模組。
- `src/<pkg>/ingest.py`：五個 ingest 子階段與 `summarize_events()`。
- `src/<pkg>/enrich.py`：抓活動頁面補 description，不碰 LLM。
- `src/<pkg>/store.py`：SQLite schema 與資料存取。
- `config/`：來源、查詢、會場、TAG 與整合設定。
- `data/`：cache、staging、processed 與 `events.sqlite3`，皆為執行產物。
- `logs/`：`app.jsonl` 與失敗證物。
- `report/`：自包含 `index.html` 與 30 日 archive。
- `tests/fixtures/`：不連網的真實頁面快照。
- `deploy/`：已安裝的 user-level systemd 與 Caddy 設定來源；變更後需重新 install/reload。

核心只用 Python 3.12 標準函式庫。設定檔以 JSON 語法保存，但 JSON 是合法 YAML；若改成一般 YAML 語法，需自行安裝 PyYAML。

## 常用指令

```bash
cd ~/projects/events

python3 run.py collect
python3 run.py collect --only src_a,src_b --since 2026-08-01
python3 run.py ingest --run-id <run_id>
python3 run.py ingest --run-id <run_id> --stage normalize
python3 run.py reevaluate            # 只重跑 TAG，只處理未過期活動
python3 run.py reevaluate --force    # 忽略 TAG 快取
python3 run.py enrich
python3 run.py enrich --limit 120 --no-summarize
python3 run.py summarize
python3 run.py report
python3 run.py notify --kind digest
python3 run.py notify --kind alert
python3 run.py notify --test
python3 run.py all

python3 run.py doctor src_b
python3 run.py doctor src_b --replay
python3 run.py probe src_b --limit 20      # 抓活網站但不寫庫、不通知、不叫 LLM；自動修復的驗證步驟
python3 run.py promote-fixture <run_id> src_b
python3 run.py new-scraper example_source

PYTHONPATH=src python3 -m unittest discover -s tests -v
PYTHONPATH=src python3 evals/run_extract_eval.py
PYTHONPATH=src python3 evals/run_dedup_eval.py
PYTHONPATH=src python3 evals/run_tag_eval.py
```

`--stage` 只跑指定 ingest 階段，前一階段的 `data/processed/<run_id>/<stage>.jsonl` 必須已存在。一般情況不要加 `--stage`。

## Git 工作流程

見 `AGENTS.md`「Git 工作流程」（直接在 `master` 提交、提交前跑 unittest、預設不 push）。

## 不變量（INVARIANTS）

- scraper 不得 import LLM client 或 `store` 模組。
- scraper 不得自行 `sleep`、重試、寫檔或解析日期；只能使用 `Context`。
- scraper 不得在程式碼中寫死搜尋關鍵字；只能呼叫 `ctx.queries()`。
- 日期解析只能使用 `normalize/dates.py` 的規則，不得改用 LLM。
- LLM 抽取只能輸出 `date_text_raw`，不得輸出解析後日期。
- 不得用無頭瀏覽器繞 challenge、輪替 IP、解 CAPTCHA 或偽裝瀏覽器 UA。
- 同一來源連續三次 403/429 必須停用，不得換手法重試。
- 不得擅自提高 `load_profile: gentle` 的頻率或 request cap。
- `PARSE_EMPTY` 不得當成功；必須保留 failure artifact。
- 不得讓單一 scraper 失敗中止其他來源。
- 不得直接修改 `~/projects/INFRA.md` 或既有 infra unit、topic、路由。
- 不得開 feature branch 或 PR；所有提交直接進 `master`。

## 新增一個 scraper

1. 先讀目標站 `robots.txt`，並依序檢查 API、RSS/Atom、iCal、HTML、PDF、全文。
2. 執行 `python3 run.py new-scraper <id>`。它建立 Python 骨架、fixture 目錄，並在 `config/sources.yaml` 附加預設停用的樣板；接著確認並編輯該項。
3. 實作 `collect()`；所有網路請求走 `ctx.get()` 或 `ctx.feed()`。
4. 若需要查詢詞，只改 `config/queries.yaml` binding。
5. 放三份真實快照到 `tests/fixtures/<id>/`，寫固定期望值測試。
6. 跑單元測試，再執行 `python3 run.py collect --only <id>`。

完整可複製範例：

```python
from datetime import UTC, datetime
from typing import AsyncIterator

from <pkg>.models import RawRecord
from <pkg>.scraper_base import Context, Scraper, register


@register
class ExampleScraper(Scraper):
    id = "example"
    categories = ["other"]
    access = "rss"

    async def collect(self, ctx: Context) -> AsyncIterator[RawRecord]:
        for url in ctx.queries() or [ctx.config["base_url"]]:
            for entry in await ctx.feed(url):
                yield RawRecord(
                    source_id=self.id,
                    source_url=entry.link,
                    fetched_at=datetime.now(UTC),
                    kind="text",
                    raw_text=f"{entry.title}\n{entry.summary}",
                    hints={"categories": self.categories},
                )
```

對應 `config/sources.yaml` 項目：

```json
{
  "id": "example",
  "name": "Example",
  "tier": 2,
  "categories": ["other"],
  "access": "rss",
  "base_url": "https://example.invalid/feed/",
  "enabled": false,
  "fetch": {"interval": "1d", "rate_limit_s": 2.0, "daily_request_cap": 10, "load_profile": "normal"}
}
```

## Runbook：某來源壞掉了

1. 從通知訊息記下 source、error type、run ID 與 artifact path。
2. 執行 `python3 run.py doctor <source>` 看最近 10 次筆數、HTTP 與設定。
3. 執行 `python3 run.py doctor <source> --replay`，確認失敗能在完全離線下重現。replay 只重新解析保存的第一個 `response.html`、寫進暫存 DB，不會動 `source_runs`（以前會，害排程把來源當成剛跑過而跳掉）。要抓活動頁的來源離線 replay 一定是 `PARSE_EMPTY`，要驗現場用 `probe`。
4. 只改該來源 parser。不要放寬全域節流，不要改用瀏覽器繞阻擋。
5. 再跑 `--replay`，直到不再是 `PARSE_EMPTY`/`SCHEMA_DRIFT`。
6. 執行 `python3 run.py promote-fixture <run_id> <source>` 保存回歸樣本並補 expected/test。
7. 執行 `PYTHONPATH=src python3 -m unittest discover -s tests -v`。
8. 最後才執行一次 `python3 run.py collect --only <source>` 做低頻真站驗收。
9. 若來源因連續 403/429 被停用，先人工確認站方意圖；不得自動解封後反覆試探。

已實測案例：某來源因絕對 URL 改版產出 0 筆；用保存的 `response.html` 修正後，`doctor --replay` 離線產出 95 筆。

已實測案例：另一來源的 `PARSE_EMPTY` 不是 parser 走針，而是站台換成 SPA——保存的 `response.html` 去掉 script 只剩 2KB 空 DOM。這類站不要硬解 HTML：先看 `robots.txt` 公告的 sitemap 拿到活動 URL，再讀各頁 server-render 的 `<meta og:title>` 與 `<meta og:description>`。

通知有兩種。`--kind digest` 列出所有尚未通知、且尚未結束的活動，最早開始的排前面；超過 `DIGEST_MAX_LINES`（30）行時只列前 30 筆並附上「還有 N 筆，見網頁」，但**全部**都會被標記為已通知，所以溢出的部分不會在下一次摘要重複出現。`--kind alert` 只在售票日（今天或明天）或申請截止日（三天內）時發送。

註：`enabled: false` 或連續 403/429 被自動停用的來源不會再送健康通知。停用等於承認它不會再跑，留著每日「來源仍異常」只是永遠不會停的雜訊；重新啟用後第一次成功會送出「來源恢復」。

## 資料模型與 DB schema

`RawRecord` 是 scraper 唯一輸出。`kind=structured` 使用 `fields`，`kind=text` 使用 `raw_text`。scraper 的日期欄只放 `date_text_raw`。

`Event` 包含 ID、原始/正規標題、多個 `Session`、LLM TAG、主辦、票價、售票日、申請截止、官網、來源、信心、seen/notified 時間與 `merged_into`。`description` 與 `summary` **不在 `Event` dataclass 上**——它們屬於 enrich，`_event_from_dict()` 會把它們 pop 掉，`upsert_event()` 也用具名欄位把它們排除在 INSERT 與 ON CONFLICT 之外，所以重新 ingest 同一個活動永遠不會洗掉花了一次 HTTP request 才拿到的描述。

SQLite 位於 `data/events.sqlite3`：

- `events`：canonical event；sessions/category 是 JSON；`description`／`summary` 是 enrichment 欄位。
- `event_source`：來源 URL、抓取時間與 matched query。
- `runs`、`source_runs`、`fetch_log`、`llm_log`：執行與可觀測性。
- `source_health`：ok/broken 轉態與去抖動通知時間。
- `enrich_attempts`：抓不到 description 的重試計數；`attempts >= 3` 就不再選它，成功一次即刪除該列。

去重不刪除原始 staging。標題 3-gram Jaccard ≥ 0.75 直接合併；0.35–0.75 只有在 LLM 啟用時才仲裁。

去重分兩層，缺一不可：`_deduplicate` 只看**單次 run** 的 rows，所以同一篇文章在兩天被抽取兩次會存成兩個 event——標題與 `official_url` 完全相同，只因為 LLM 兩次給的會場字串不同而算出不同的 `event_id`。跨 run 的合併靠 `_consolidate_stored()`（純規則、不呼叫 LLM），過去它**只在 `reevaluate` 裡跑**，而每日 pipeline 從不呼叫 reevaluate，於是報表就出現兩張幾乎一樣的卡片。現在 `ingest_run()` 在 store 階段之後會呼叫 `_consolidate_after_store()`。

**沒有評分系統**。活動不打分、不排名，篩選與排序完全由 TAG、日期與前置排除規則決定；早期的評分機制已整個移除。舊資料庫在 `Store` 開啟時由 `_drop_removed_columns()` 自動 `ALTER TABLE ... DROP COLUMN`，不需要手動遷移。

## description 與 summary

**TAG 曾經只能看標題猜。** tag prompt 原本只拿到 `title`／`organizer`／`venues`，所以塞滿了「不要從看不懂的名稱腦補」「空陣列是正確答案」這類防禦規則。大多數來源的列表頁沒有任何散文。

`enrich.py` 補上這個缺口：對還沒有 description、且 `official_url` 非空的未合併活動，抓該活動自己的頁面。排序是**沒有 TAG 的優先**，因為看不懂的標題正是它要修的東西。每次執行有頁數上限（`DEFAULT_LIMIT = 30`），存量分多次補完，任何站台看到的量都不會比前一天突增。

- **並行方式**：依網域分組，網域之間並行，同網域內序列化。`Context.get()` 本來就是每網域一把鎖加 2 秒節流，所以不需要另外寫並行控制。
- **每網域一個 `Context`**：因為 `Context.robots()` 會把該站的 crawl-delay 寫回自己的 `fetch_config`，共用一個 Context 會讓最慢的站拖累全部網域。
- **HTML 清理**：`meta_description()` 先取 `og:description`／`<meta name=description>` 當開頭，`page_text()` 再砍掉 script/style/svg/nav/header/footer/aside/form，把 block tag 換成換行，然後**丟掉長度不足 12 字的行**——廣告與選單去 tag 之後幾乎都變成短碎片，這一條就濾掉大半，不需要每站寫 parser。
- **失敗處理**：robots 不允許、HTTP 錯誤、或清理後不足 40 字都記進 `enrich_attempts`，三次後放棄。`CAP_REACHED` 是預算問題不是壞頁面，所以直接跳出且**不**累加次數。
- **只抓還沒結束的活動**：報表本來就跳過 `end < today`，抓已結束的活動純粹浪費 request。

`summarize_events()`（ingest 側，因為不變量規定 collect 側不得 import LLM client）把 description 交給本機 LLM 壓成一句話，寫進 `summary`。**空字串是有效答案**：描述沒講出標題以外的東西時就存空字串，卡片不顯示該行，而且不會每次執行都重問一次。

輸出有兩道確定性防護，因為靠 prompt 措辭壓不住本地模型：

- **語言**：來源是外語時，模型約 9% 的機率直接用該語言回答。用字元集佔比判定，命中就把那句話交給 `translate()` 翻譯。**不要改成「重問一次」**：實測重問後多數仍是外語，而那些句子本身都是合格的摘要，丟掉等於親手把卡片上唯一有價值的資訊刪成空白。翻譯是比「摘要＋翻譯」窄得多的任務，本地模型做得到。翻譯仍失敗時**保留原文**，一句外語說明勝過空白。
- **標題回音**：`_echoes_title()` 只在摘要正規化後**等於或被包含於**標題，或是標題加一兩個字（長度 ≤ 1.3 倍）時才算回音。早期版本用 `title in summary` 會誤殺：標題是常用詞時會把有實質內容的摘要清掉。

**共用頁防呆**：同一個 description 被 3 筆以上活動共用（大型展的多個子展抓到同一張總覽頁）就視為通用頁——`Store.shared_descriptions()` 找出它們，`_tag()` 把這些活動的 description 當成 None 再送 LLM，報表則把它們的 summary 清空、卡片顯示「來源頁面為多個活動共用，無法摘要」。

**標題維持原樣，不要把分類塞進 title。** `event_id = sha1(title_norm|start_date|venue)`，改動標題生成等於重發所有 event_id，現有的 description、summary 與 TAG 快取全部變孤兒；而且標題是對照官方頁與搜尋用的識別碼。說明由 summary 負責，這是刻意的分工。

**卡片不留白**：沒有摘要時顯示原因，而不是空一行。理由由 `report.summary_note()` 在 render 時從資料算出，**不寫進 `summary` 欄位**——狀態訊息不是摘要，寫進去會污染資料而且會過期。

## TAG（節錄）

`config/tags.yaml` 是允許的 TAG 與定義的封閉清單。定義是**寫給本機小模型看的**，不是寫給人看的：每個 TAG 的 `description` 是「一句話定義＋一到兩個是非題」，`includes` 是它會在標題裡看到的關鍵字，`excludes` 一律寫成「情況（改用 X）」，最容易被多給的情況寫「絕對不給」。不要用箭頭或「任一個是 → TAG」這種模型會誤讀成指令的寫法——實測本機模型會因此把幾乎所有活動都塞進同一個 TAG。tag 的 system prompt 在 `llm.py`，bench 直接 import 它，所以量到的就是 production 送的東西。

TAG 的判準寫成一題是非題（例：「展出的是商品還是作品」分開 `展示會` 與 `藝文展`；「內容專業、遠離一般人」才是 `工業商業`）。使用者要的某些 TAG 是「一顆開關」（想看時開、不想看時關），所以寧可收得寬。

修改 TAG 後執行 `python3 run.py reevaluate`，它只重跑 TAG 且只處理尚未過期的活動；TAG 快取的 key 是 `(event_id, tags.yaml 的 version, 模型)`，`--force` 會忽略快取。改定義之前先跑 `evals/tag_bench/`：`cases.json` 近百筆凍結輸入的 LLM 案例，分組（核心拆分／同名異物／description 誤導／新 TAG／回歸），報告寫到 `reports/<日期>_v<version>.md`（不覆蓋）。**只在明確調整 TAG 時才跑**（約 3 分鐘）。跑之前先 `selfcheck.py` 確認伺服器健康。

## 設定檔

- `config/sources.yaml`：plugin id、tier、分類、入口、interval、rate、cap、load profile。修改後重跑 collect。
- `config/queries.yaml`：來源查詢關鍵字與 source binding。
- `config/tags.yaml`：TAG 封閉清單、定義與版本；修改後執行 `reevaluate --force`。
- `config/settings.yaml` 的 `llm.cache_bust`（**目前 `true`**）：開啟後每次 LLM 呼叫的 system prompt 前面加一個隨機 nonce，讓本機推論伺服器不命中 prefix cache，每次多約 1 秒 prefill。伺服器改參數後 `selfcheck.py` 雖然 PASS，但實測命中快取時答案仍有系統性偏移，所以 production 維持開啟。
- `config/venues.yaml`：會場 alias、座標、車站與通勤時間。
- `config/gentle_sources.md`：gentle 來源與原因。

## 報表的篩選（節錄）

篩選分兩層，順序固定：**先前置排除，剩下的才進 TAG 篩選**。前置排除是一組具名規則的 checkbox，定義在 `report.py` 的 `PREFILTER_RULES`；規則的命中結果在產生報表時就算好寫進 `data-rules`，前端只做字串比對。新增規則只要在 list 後面加一筆，HTML 與 JS 都是泛用的。

TAG 篩選是三態按鈕（不限 → 只看 → 排除）。虛擬 TAG `長期`（任一 session 超過 7 天）與 `未分類` 由 `report.py` 從資料算，不經 LLM、不進 `tags.yaml`。

日期快捷鍵的區間在產生報表時就算好寫進 `data-from`／`data-to`，前端不做日期運算。使用者的選擇存在瀏覽器 `localStorage`（改變篩選形狀時要換 key）。

「不感興趣」隱藏：每張卡片有 `✕`，靠 `data-id`（＝SQLite 的 `event_id`）辨認，所以每天重新產生報表也認得同一張卡。VPN 版透過一個極小的 API 跨裝置同步；公網版沒有 API，標記只存在該瀏覽器（給別人看的那份不該讓任何人改得到你的偏好）。之後任何「個人化」功能都走同一條：VPN 版經 API，公網版退回瀏覽器本機或乾脆不提供；VPN 已經是身分邊界，公網要同步就得加登入，不值得。

## 部署與發佈

**改了報表（`report.py`、樣式、JS、`report/`）之後一律跑 `deploy/publish.sh`**：它重新產生 `report/`、複製到 Caddy 目錄、上傳 Cloudflare Pages，再把兩邊的 `index.html` 抓回來跟本機比對，任何一邊不一致就失敗。這是為了杜絕「只發了一邊」或「改了沒發」——過去多次發生。

報表有兩個發佈管道，互相獨立、內容相同：

1. **VPN 內（自動）**：user-level systemd service 在每次 pipeline 跑完後，把 `report/` 複製到 `PUBLISH_DIR`（infra Caddy 的 sites 目錄）。只有 VPN 內的裝置看得到。
2. **公網（自動）**：Cloudflare Pages。`deploy/events.service` 的 `ExecStartPost` 在 `run.py all` 結束後執行 `deploy/publish-pages.sh`，它讀 `~/.config/cloudflare-pages/.env`、把 nvm 的 node 加進 PATH（systemd user service 沒有 shell 的 PATH）、用 wrangler 上傳。部署失敗會讓整個 unit 失敗，`events-failure@` 就會發告警；VPN 版那時已經更新了。

- 改了 `deploy/*.service` 之後要 `cp` 到 `~/.config/systemd/user/` 並 `systemctl --user daemon-reload`；用 `systemd-run --user --wait --collect --pipe deploy/publish-pages.sh` 可以在 systemd 的環境下單獨測部署腳本。
- 對 Pages 的管理操作一律走 wrangler。
- wrangler 會在執行目錄留下 `.wrangler/` 快取，已加入 `.gitignore`。
- 報表內容全部來自公開來源，但公網等於對所有人可見；若之後報表加入任何個人化內容，先把它從公網管道拿掉。

## unit 失敗後自動修復

本專案的版本在 `deploy/failure_autofix.sh`，由 `events-failure@.service`（`OnFailure=`）帶 unit 名呼叫。

流程（順序不可改）：

1. **先發失敗通知**（告警 topic、high），在任何修復動作之前——修復路徑壞掉不能害使用者收不到失敗通知。
2. **flock**：兩個 unit 同時失敗只開一個修復 session，搶不到鎖的只發「未嘗試」通知就結束。
3. **重複失敗守門**（script 層，不靠模型）：找同一 unit 最近一次 autofix log 的 `VERDICT`；若不是 `fixed`，看之後有沒有**不帶 `Autofix:` trailer 的人工 commit**。一個都沒有 → 判定使用者還沒回來看：寫一個 `VERDICT: skipped` 的 log、發 high 通知「重複失敗，未再嘗試」、exit 0，不啟動 claude。使用者任何一個 commit 就會重置。
4. **跑一次 `claude -p`**，不重試：`timeout 30m claude -p "$PROMPT" --permission-mode acceptEdits --allowedTools "Bash"`。acceptEdits 能改檔、Bash 能看 journal／抓活頁面／跑測試／commit，其他工具在無人模式下直接被拒。transcript 存 `data/logs/`，超過 30 天的自動刪。
5. **一定再發第二則通知**：修復完成／未修復／異常結束（timeout、CLI 找不到），附 transcript 尾段與完整 log 路徑。

Prompt 要點：先讀 STATUS.md／README.md；說明本專案哪些動作會花 LLM 時間、寫庫、發佈或通知；塞入最近五次嘗試的 VERDICT，同樣問題已試過就直接 not-fixed；預期情境 A（網站活著但頁面格式變了 → 自己抓活頁面比對、只改那一個來源的 scraper、把新格式存成 fixture）與 B（爬太激進 → 只調那一個來源的 delay／backoff）；放棄條件（網站掛／被擋、憑證、本機 LLM 掛、要動共用程式碼／加依賴／重構／看不清根因，改完實測仍失敗要 `git checkout --` 還原）；硬規則（不跑主流程、不重跑 unit、不停用來源、不 push、只 commit 該次修復的檔案、commit message 最後一行 `Autofix: <unit>`）；結尾固定一行 `VERDICT: fixed|not-fixed — <一到三句>`。

**驗證指令 `run.py probe <source_id> --limit 20`**：單元測試用的是錄好的 fixture，通過不代表活網站沒問題，所以修完必須跑這個有界的實測。它走正常 scraper 抓活網站，但寫進暫存目錄裡的一次性資料庫與 log：不留 `source_runs`、不寫 staging、不通知、不叫 LLM，抓到 N 筆就停。非 ok／degraded 時 exit 2。

## 已知陷阱（節錄）

- **本機推論伺服器的 prefix cache 會被汙染**（用實驗證實）：幾個加速選項同開時，共用的長 prompt 前綴命中快取後回傳與內容無關、temperature 0 還不穩定的 TAG。伺服器本身的一切寫在 `~/projects/INFRA.md`，不在本專案。任何 TAG 評測前先跑 `selfcheck.py`；我曾因此報了好幾輪無效的評測數字。
- 日期解析：兩個日期用逗號分隔是兩個 span，波浪號才是 range；但 ingest 隨後把**相鄰或重疊**的日期併成一個 session。`〆切`、`発売`、`受付` 附近日期不是活動日。日期可省年/月；跨年只在前月接近年尾、後月接近年初時推進一年。
- 巡迴展是一個 Event、多個 Session，不得按城市拆 Event。**同一來源、同名、同會場、不同日期**的列表行是一個 Event 多個 Session，直接合併，不經 LLM 仲裁。
- `gentle` 至少 5 秒、每日最多 8 次、列表深度，不得提升。
- 本機 LLM 必須 `temperature=0`、關閉 thinking，並使用 native structured outputs；extract guided schema 只輸出原始日期。
- Production 的 LLM `batch_size` 固定為 1。較高並行度曾觸發 GPU 錯誤；未先完成 GPU 負載驗證前不得調高。
- **extract 階段一筆 LLM 失敗只丟那一筆。** 模型對某一筆連續三次把字串寫到 token 上限沒收尾，`JSONDecodeError` 一路傳到 CLI 讓整個 `all` exit 1：collect 已完成但報表、摘要、公網全沒出。現在接住例外、記 `extract_skipped` 後跳過該筆。這是暫時性的模型失控，重放同一筆就正常，所以不加重試次數。
- TAG guided output 只讓 LLM 回傳封閉 TAG 陣列；稽核理由由程式依結果產生。不要重新加入自由文字 reason，少數輸入會使本機模型重複輸出直到截斷。
- `config/*.yaml` 目前是 JSON-compatible YAML，勿混用一般 YAML 語法，除非部署環境已有 PyYAML。
- **`Event` dataclass 不帶 `description`／`summary`，任何 `_event_dict(_event_from_dict(row))` 的來回都會靜默丟掉它們。** 踩過兩次，最嚴重的一次：`reevaluate` 先 `_consolidate_stored()` 再 `_tag()`，整輪 `--force` 重算都在沒有描述的情況下跑——幾百次 LLM 呼叫、上百筆活動被洗成無 TAG，而指令回報 `status: ok`。改動任何經過 `Event` 來回的路徑時要重新確認。
- **不得從 worker thread 碰 sqlite 連線。** `Context._sync_get()` 跑在 `asyncio.to_thread` 裡，直接在那裡寫 `fetch_log` 會噴 `bad parameter or other API misuse`。fetch log 因此緩衝，由 `get()` 的 `finally` 在 loop thread flush。
- 某些站的 `robots.txt` 用萬用字元擋 RSS。Python 的 `urllib.robotparser` 不支援萬用字元會誤判為允許，所以那些來源改走 HTML 清單。
- 點分隔日期 token 強制要有西元年，不要放寬成 `\d{1,2}\.\d{1,2}`，否則版本號與小數會被讀成日期。
- **`normalize_venue("")` 曾經回傳某個大會場**：比對是雙向包含，空字串被每個 alias 包含，最長的 alias 勝出。現在 fold 後不足 3 字直接回 `None`。
- **enrich 的候選挑選曾被過期活動塞滿**：先用 SQL 抓候選再在 Python 端過濾過期，原本只抓 `limit × 4` 筆就停，某來源一次帶進上百筆已結束活動全擠在前面，150 筆待補的活動只選到 7 筆。現在改成分頁往下撈直到湊滿 `limit` 或撈完。
- `clean_text` 會整段丟掉 `<svg>`；某來源的標題含 `<svg><title>…</title>`，少了這條就會混進活動名稱。
- 前置排除目前只有一條，刻意保持手工逐條新增。等規則多到互相干擾再改成可設定的機制。

## 文件維護規則

見 `AGENTS.md`「文件維護」：專案相關的發現一律寫進 repo（本檔是設計與操作細節、`STATUS.md` 是現況），不留在 AI 助手的 memory 或對話裡。
