(系列:《把 Claude 練成專家:30 天打造可驗證的 Agent Skills》)
昨日回顧
Day 13 把失敗政策寫回 SKILL.md:OFFICIAL、UNCONFIRMED、INSUFFICIENT_INPUT、CONFIGURATION_ERROR、CONFLICT 各自有固定的 next_step,而且 OFFICIAL 也要帶著 verified_at 與 review_after 往下游傳。今天處理這兩個日期背後的問題:誰負責更新它們、多久更新一次、沒人更新時系統要怎麼反應。
來源紀錄不是寫一次就好
Day 8 建立的來源 registry 讓分類器可以離線、確定性地判斷一個網址是否命中官方來源。但 registry 裡的每一筆紀錄,都只是「某人在某天看過某個證據」的快照。
真實世界會變:
如果紀錄沒有到期概念,一筆三年前確認過的 host 會永遠被當成官方。這不是小瑕疵,而是把過期資訊包裝成「已驗證」。
stale-by-default:沒人維護就降級
今天最重要的規則只有一句:
A source record is trusted only until review_after. After that date, treat it as stale and classify matches as UNCONFIRMED until the record is revalidated.
注意方向。預設是「過期就不信」,而不是「過期但先照舊用,等有空再檢查」。前者在沒人維護時會變得保守,後者會在沒人維護時悄悄變得危險。
分類器實作只要多一個日期比較:
def record_is_fresh(record: dict, today: date) -> bool:
if record["state"] != "active":
return False
return today <= date.fromisoformat(record["review_after"])
today 一律由呼叫端明確傳入,不在函式內呼叫 date.today()。這樣 pytest 與 eval 才能固定日期重現結果,Day 10 的確定性原則不會被時間打破。
紀錄欄位的最小集合
把 freshness 需要的欄位寫進 schema:
{
"id": "source:example-primary",
"host": "tickets.example.com",
"state": "active",
"evidence_url": "https://www.example.org/official-ticketing",
"verified_at": "2026-09-01",
"verified_by": "maintainer:alex",
"review_after": "2026-10-01",
"history": []
}
每個欄位都有用途:
state:active、revoked 兩種,不用 boolean,以免之後要加狀態時破壞相容evidence_url:重驗時要回去看的原始證據,而不是 host 本身verified_at:最後一次人工或流程確認的日期verified_by:誰確認的,方便追問review_after:過了這天就視為 stalehistory:每次變更的紀錄,下面會說明Schema 也要檢查日期關係:
def validate_dates(record: dict) -> list[str]:
errors = []
v = date.fromisoformat(record["verified_at"])
r = date.fromisoformat(record["review_after"])
if r <= v:
errors.append(f"{record['id']}: review_after must be after verified_at")
if (r - v).days > MAX_REVIEW_DAYS:
errors.append(f"{record['id']}: review window exceeds {MAX_REVIEW_DAYS} days")
return errors
MAX_REVIEW_DAYS 防止有人為了省事把 review_after 設成十年後。
重驗週期怎麼定
不是每筆紀錄都需要同樣的頻率。可以依風險分級:
review_policy:
high: # 付款、登入、個資輸入入口
max_review_days: 30
medium: # 資訊頁、官方公告
max_review_days: 90
low: # 靜態說明文件
max_review_days: 180
原則是:越接近交易、登入或個資輸入的入口,重驗週期越短。紀錄加上 risk 欄位後,schema 檢查就用對應的上限。
重驗流程要寫成步驟
重驗不是「再看一眼覺得沒問題」。SKILL.md 或維護文件要寫明步驟:
Revalidation steps:
1. Open evidence_url directly. Do not start from the recorded host.
2. Confirm the page still names the same host as an official channel.
3. If confirmed, set verified_at to today, set review_after within the risk window, append a history entry.
4. If the evidence page is gone or names a different host, set state to revoked and append a history entry with the reason.
5. If the evidence is ambiguous, leave the record stale. Do not extend review_after.
第 1 步很關鍵。從被驗證的 host 出發去找證據,等於讓對方自己證明自己。證據必須來自獨立的官方來源頁。
第 5 步也很重要:不確定時,不要延長日期。讓它維持過期,分類器自然會回傳 UNCONFIRMED。
撤銷和刪除不一樣
當一個 host 不再是官方來源,直覺做法是刪掉那筆紀錄。但刪除會造成兩個問題:
所以撤銷用狀態表示:
{
"id": "source:example-legacy",
"host": "old-tickets.example.com",
"state": "revoked",
"revoked_at": "2026-09-15",
"revoked_reason": "Official page no longer lists this host",
"history": [
{"date": "2026-03-01", "action": "verified", "by": "maintainer:alex"},
{"date": "2026-09-15", "action": "revoked", "by": "maintainer:sam"}
]
}
分類器遇到 revoked 紀錄時,不回傳 OFFICIAL,也不當成完全陌生。Day 13 的規則已經涵蓋:一筆 active、一筆 revoked 指向同一 host 時回傳 CONFLICT;只有 revoked 時回傳 UNCONFIRMED,並在 evidence 附上撤銷紀錄 ID,讓使用者訊息可以說明「這個網址過去曾是官方,但目前已不再列出」。
audit history 是 append-only
history 只能新增,不能修改或刪除既有項目。CI 可以檢查這件事:
def check_history_append_only(old: dict, new: dict) -> list[str]:
errors = []
for rid, old_rec in old.items():
new_rec = new.get(rid)
if new_rec is None:
errors.append(f"{rid}: record deleted; use state=revoked")
continue
old_h = old_rec.get("history", [])
if new_rec.get("history", [])[:len(old_h)] != old_h:
errors.append(f"{rid}: history rewritten")
return errors
在 PR 裡比對 base branch 和 head branch 的 registry,就能擋下「順手刪掉舊紀錄」或「改寫過去的確認日期」。
離線檢查和線上監控要分開
freshness 維護很容易被做成「每次執行都即時連到 evidence_url 確認」。這樣做會讓核心分類器依賴網路,結果隨時間與網路狀況變動,Day 12 刻意保持離線的 eval 也會失去意義。
所以拆成兩層:
核心分類器(離線、確定性)
輸入:網址、registry、執行日期
輸出:status、evidence、日期
不連網
維護監控(線上、非同步)
定期打開 evidence_url
發現變化時開 issue 或 PR
不直接改分類結果
監控只能「提出變更」,不能「直接生效」。它發現 evidence 頁面消失時,開一個 PR 把 state 改成 revoked,經過 review 與 CI 再合併。這樣所有結果仍可追溯到 registry 的某個版本。
CI 裡的 freshness 報告
除了擋下錯誤,CI 也可以產生一份到期報告,讓維護者知道接下來要處理哪些紀錄:
def freshness_report(records: list[dict], today: date, warn_days: int = 14) -> dict:
report = {"stale": [], "expiring_soon": [], "fresh": 0}
for r in records:
if r["state"] != "active":
continue
days_left = (date.fromisoformat(r["review_after"]) - today).days
if days_left < 0:
report["stale"].append(r["id"])
elif days_left <= warn_days:
report["expiring_soon"].append(r["id"])
else:
report["fresh"] += 1
return report
這份報告要不要讓 CI 失敗,是一個政策選擇。我的建議是:
否則任何無關的 PR 都會因為別人負責的紀錄過期而被卡住。
新增 eval 案例
把 freshness 行為加進 Day 11 的 JSONL eval,並固定執行日期:
{"id": "fresh-active", "today": "2026-09-20", "url": "https://tickets.example.com/e/1", "expect": {"status": "OFFICIAL"}}
{"id": "stale-active", "today": "2026-10-05", "url": "https://tickets.example.com/e/1", "expect": {"status": "UNCONFIRMED", "reason": "stale_record"}}
{"id": "boundary-day", "today": "2026-10-01", "url": "https://tickets.example.com/e/1", "expect": {"status": "OFFICIAL"}}
{"id": "revoked-only", "today": "2026-09-20", "url": "https://old-tickets.example.com/e/1", "expect": {"status": "UNCONFIRMED", "reason": "revoked_record"}}
boundary-day 確認 review_after 當天仍有效,避免有人把 <= 改成 < 而沒人發現。
今天完成的驗收標準
[ ] 每筆紀錄都有 verified_at、verified_by、review_after
[ ] review_after 晚於 verified_at,且不超過風險等級上限
[ ] 過期紀錄預設降級為 UNCONFIRMED
[ ] 執行日期由呼叫端傳入,不在函式內取今天
[ ] 重驗從 evidence_url 開始,不從 host 開始
[ ] 不確定時不延長日期
[ ] 撤銷用 state=revoked,不刪除紀錄
[ ] history append-only,CI 會檢查
[ ] 線上監控只開 PR,不直接改結果
[ ] eval 涵蓋 fresh、stale、邊界日與 revoked
明天預告
Day 15 處理 Skill 的觸發與範圍:description 要怎麼寫,才能讓 Claude 在該用時載入 Skill、不該用時保持安靜,並用 eval 驗證觸發是否準確。