iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Claude AI

把 Claude 練成專家:30 天打造可驗證的 Agent Skills系列 第 9

Day 09|實作 sources loader:設定錯誤要先爆炸

  • 分享至 

  • xImage
  •  

昨日回顧

Day 8 完成了 check_domain.py 的分類主流程:解析 URL、正規化 host、精確比對來源,再依日期與狀態輸出固定 JSON。但那篇先假設 load_sources() 已經可靠。今天補上這個缺口,讓 references/sources.md 在啟動時就接受完整驗證。

為什麼 loader 不是一個小函式

來源 registry 是分類器的信任根。若 YAML 壞掉、欄位漏寫或兩條規則重疊,最危險的做法不是程式直接停止,而是吞掉錯誤,繼續把所有網址標成 UNCONFIRMED

那會把「設定已損壞」偽裝成「網址沒有命中」。操作者看到的是正常 JSON,卻不知道整個信任根已經失效。

所以 loader 的契約只有兩種結果:

  1. 回傳一組完整、無歧義的來源紀錄。
  2. 丟出清楚的設定錯誤,讓程式以非零狀態結束。

不提供半成功模式。

先抽出 fenced YAML

sources.md 同時是給人讀的文件與給程式讀的資料。loader 只接受一個標記為 yaml 的 fenced block:

import re

FENCED_YAML = re.compile(
    r"```(?:yaml|yml)\s*\n(?P<body>.*?)\n```",
    re.DOTALL | re.IGNORECASE,
)


def extract_yaml(markdown: str) -> str:
    blocks = [m.group("body") for m in FENCED_YAML.finditer(markdown)]
    if len(blocks) != 1:
        raise SourceConfigError(
            f"expected exactly one fenced YAML block, found {len(blocks)}"
        )
    return blocks[0]

「剛好一個」是刻意的限制。沒有 block 代表資料缺失;有兩個 block 則無法知道哪一個才是正式 registry。不要猜,也不要把兩份資料偷偷合併。

解析後先檢查頂層形狀

import yaml


def parse_registry(yaml_text: str) -> list[dict]:
    try:
        data = yaml.safe_load(yaml_text)
    except yaml.YAMLError as exc:
        raise SourceConfigError(f"invalid YAML: {exc}") from exc

    if not isinstance(data, dict):
        raise SourceConfigError("registry root must be a mapping")

    sources = data.get("sources")
    if not isinstance(sources, list) or not sources:
        raise SourceConfigError("sources must be a non-empty list")

    return sources

一定要用 safe_load。來源檔是資料,不應有機會建構任意 Python 物件。

逐筆驗證必要欄位

REQUIRED_FIELDS = {
    "id",
    "host",
    "match",
    "evidence_url",
    "verified_at",
    "review_after",
    "status",
}

MATCH_MODES = {"exact", "include_subdomains"}
STATUSES = {"active", "inactive", "revoked"}


def validate_source(raw: dict, index: int) -> dict:
    if not isinstance(raw, dict):
        raise SourceConfigError(f"sources[{index}] must be a mapping")

    missing = sorted(REQUIRED_FIELDS - raw.keys())
    if missing:
        raise SourceConfigError(
            f"sources[{index}] missing fields: {', '.join(missing)}"
        )

    if raw["match"] not in MATCH_MODES:
        raise SourceConfigError(
            f"sources[{index}].match has unsupported value"
        )

    if raw["status"] not in STATUSES:
        raise SourceConfigError(
            f"sources[{index}].status has unsupported value"
        )

    return raw

錯誤要指出索引與欄位,但不要把整份設定或任何憑證印到 log。可診斷不等於無限制輸出。

日期必須有順序

PyYAML 可能直接把 ISO 日期解析成 date,也可能因資料格式而得到字串。先統一轉型,再檢查關係:

from datetime import date


def as_date(value: object, path: str) -> date:
    if isinstance(value, date):
        return value
    if isinstance(value, str):
        try:
            return date.fromisoformat(value)
        except ValueError as exc:
            raise SourceConfigError(f"{path} must be YYYY-MM-DD") from exc
    raise SourceConfigError(f"{path} must be a date")


def validate_dates(source: dict, index: int) -> dict:
    verified = as_date(source["verified_at"], f"sources[{index}].verified_at")
    review = as_date(source["review_after"], f"sources[{index}].review_after")

    if review < verified:
        raise SourceConfigError(
            f"sources[{index}].review_after precedes verified_at"
        )

    return {**source, "verified_at": verified, "review_after": review}

這裡不判斷來源是否過期。過期是 Day 8 分類器根據 today 做的執行期決策;日期格式錯誤或時間倒置才是 loader 的設定錯誤。

正規化 host 也要發生在載入期

registry 裡的 host 不該帶 scheme、path、port 或萬用字元:


def normalize_source_host(value: object, path: str) -> str:
    if not isinstance(value, str) or not value.strip():
        raise SourceConfigError(f"{path} must be a non-empty host")

    host = value.rstrip(".").lower().encode("idna").decode("ascii")

    if any(token in host for token in ("://", "/", ":", "*")):
        raise SourceConfigError(f"{path} must contain only a hostname")

    labels = host.split(".")
    if len(labels) < 2 or any(not label for label in labels):
        raise SourceConfigError(f"{path} is not a valid hostname")

    return host

這讓 Day 8 的 host_matches() 可以只處理已正規化資料,不必每次分類都重新猜測設定作者的意圖。

拒絕重複 ID 與重複規則


def validate_uniqueness(sources: list[dict]) -> None:
    seen_ids: set[str] = set()
    seen_rules: set[tuple[str, str]] = set()

    for source in sources:
        if source["id"] in seen_ids:
            raise SourceConfigError(f"duplicate source id: {source['id']}")
        seen_ids.add(source["id"])

        rule = (source["host"], source["match"])
        if rule in seen_rules:
            raise SourceConfigError(
                f"duplicate source rule: {source['match']} {source['host']}"
            )
        seen_rules.add(rule)

重複紀錄不能靠檔案順序決定。即使兩筆目前內容相同,未來也可能只更新其中一筆,製造不一致。

檢查規則重疊

重複不等於全部的衝突。以下兩筆雖然不同,卻會同時命中 pay.tickets.example.com

sources:
  - id: example-root
    host: tickets.example.com
    match: include_subdomains
    evidence_url: https://organizer.example/sources/tickets
    verified_at: 2026-09-01
    review_after: 2026-12-01
    status: active
  - id: example-pay
    host: pay.tickets.example.com
    match: exact
    evidence_url: https://organizer.example/sources/pay
    verified_at: 2026-09-01
    review_after: 2026-12-01
    status: active

用一個小函式判斷兩條規則是否可能同時命中:


def covers(rule: dict, host: str) -> bool:
    return (
        host == rule["host"]
        or rule["match"] == "include_subdomains"
        and host.endswith("." + rule["host"])
    )


def validate_no_overlap(sources: list[dict]) -> None:
    for i, left in enumerate(sources):
        for right in sources[i + 1:]:
            if covers(left, right["host"]) or covers(right, left["host"]):
                raise SourceConfigError(
                    f"overlapping source rules: {left['id']} and {right['id']}"
                )

第一版選擇拒絕所有重疊,而不是設計優先順序。優先順序會增加隱性狀態,也讓修改 registry 的人更難預測結果。

組合成單一入口

from pathlib import Path


def load_sources(path: str | Path) -> list[dict]:
    markdown = Path(path).read_text(encoding="utf-8")
    yaml_text = extract_yaml(markdown)
    raw_sources = parse_registry(yaml_text)

    sources = []
    for index, raw in enumerate(raw_sources):
        source = validate_source(raw, index)
        source = validate_dates(source, index)
        source = {
            **source,
            "host": normalize_source_host(
                source["host"], f"sources[{index}].host"
            ),
        }
        sources.append(source)

    validate_uniqueness(sources)
    validate_no_overlap(sources)
    return sources

這個函式只在所有檢查通過後回傳。任何一步失敗,都不會留下可供分類器繼續使用的半成品。

命令列要 fail fast

import sys


def main() -> int:
    try:
        sources = load_sources("references/sources.md")
    except (OSError, SourceConfigError) as exc:
        print(f"source configuration error: {exc}", file=sys.stderr)
        return 2

    result = check(args.url, sources, args.today)
    print(json.dumps(result, ensure_ascii=False, sort_keys=True))
    return 0

設定錯誤走 stderr 與 exit code 2;網址輸入不足仍是分類結果 INSUFFICIENT_INPUT。這兩條失敗路徑不可混在一起。

今天先測的八個案例

1. 沒有 fenced YAML
2. 同時有兩個 fenced YAML
3. YAML 語法錯誤
4. 缺少 required field
5. 日期格式錯誤或 review_after 早於 verified_at
6. 不支援的 match/status
7. 重複 ID 或重複規則
8. include_subdomains 與更深層規則重疊

每個案例都應在分類任何 URL 之前失敗。再加一個完整有效的 fixture,確認 loader 回傳的 host 已正規化、日期已轉型,且紀錄順序保持不變。

明天預告

Day 10 開始寫 pytest,先把 URL 正規化、精確比對、stale/revoked 與設定衝突固定成可重跑的回歸測試。


上一篇
Day 08|實作 check_domain.py:讓「官方」由程式決定
下一篇
Day 10|用 pytest 鎖住分類器:日期固定,邊界才不會漂
系列文
把 Claude 練成專家:30 天打造可驗證的 Agent Skills10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言