#!/usr/bin/env python3 """Validate the structure and evidence links of the AI distillate.""" from __future__ import annotations import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent LEARNINGS = ROOT / "learnings" REQUIRED_FILES = ( ROOT / "README.md", ROOT / "AGENTS.md", ROOT / "TASTE.md", LEARNINGS / "README.md", ) RECORD_NAME = re.compile( r"^(?P\d{4}-\d{2}-\d{2})-(?P\d{3})-" r"(?P[a-z0-9][a-z0-9-]*)\.md$" ) RECORD_HEADINGS = ( "# ", "## 原始输入", "## 内容蒸馏", "## Taste 信号", "### 明确偏好", "### 推断信号", "### 领域知识", "## 边界与反例", "## 可执行影响", "## 画像更新", ) PROFILE_LABELS = ("状态", "适用范围", "规则", "证据", "边界") PROFILE_STATUSES = {"确认", "暂定", "有条件", "已取代"} EVIDENCE_LINK = re.compile(r"\]\((learnings/[^)#]+\.md)(?:#[^)]+)?\)") def parse_front_matter(text: str) -> dict[str, str] | None: if not text.startswith("---\n"): return None closing = text.find("\n---\n", 4) if closing == -1: return None fields: dict[str, str] = {} for line in text[4:closing].splitlines(): if ":" in line and not line.startswith((" ", "\t")): key, value = line.split(":", 1) fields[key.strip()] = value.strip() return fields def validate_record(record: Path) -> list[str]: errors: list[str] = [] match = RECORD_NAME.fullmatch(record.name) if match is None: return [f"{record.relative_to(ROOT)}: 文件名不符合约定"] text = record.read_text(encoding="utf-8") fields = parse_front_matter(text) if fields is None: return [f"{record.relative_to(ROOT)}: 缺少完整的 YAML front matter"] expected_id = record.name[:14] required_fields = {"id", "date", "status", "tags", "sources", "references"} missing_fields = sorted(required_fields - fields.keys()) if missing_fields: errors.append( f"{record.relative_to(ROOT)}: 缺少字段 {', '.join(missing_fields)}" ) if fields.get("id") != expected_id: errors.append( f"{record.relative_to(ROOT)}: id 应为 {expected_id}" ) if fields.get("date") != match.group("date"): errors.append( f"{record.relative_to(ROOT)}: date 应与文件名日期一致" ) if fields.get("status") not in {"distilled", "needs-evidence"}: errors.append( f"{record.relative_to(ROOT)}: status 必须是 distilled 或 needs-evidence" ) lines = text.splitlines() for heading in RECORD_HEADINGS: if heading == "# ": if not any(line.startswith("# ") for line in lines): errors.append(f"{record.relative_to(ROOT)}: 缺少一级标题") elif heading not in lines: errors.append(f"{record.relative_to(ROOT)}: 缺少标题 {heading}") return errors def validate_profile() -> list[str]: profile_path = ROOT / "TASTE.md" text = profile_path.read_text(encoding="utf-8") marker = "## 当前画像" if marker not in text: return ["TASTE.md: 缺少‘当前画像’部分"] current = text.split(marker, 1)[1].strip() placeholder = "尚无通过 `学习:xxx` 沉淀的条目。" if current == placeholder: return [] if placeholder in current: return ["TASTE.md: 已有画像条目时应删除空画像占位文字"] starts = list(re.finditer(r"(?m)^### (.+)$", current)) if not starts: return ["TASTE.md: 当前画像必须使用三级标题组织条目"] errors: list[str] = [] for index, start in enumerate(starts): end = starts[index + 1].start() if index + 1 < len(starts) else len(current) title = start.group(1) entry = current[start.end():end] values: dict[str, str] = {} for label in PROFILE_LABELS: match = re.search(rf"(?m)^- {re.escape(label)}:(.+)$", entry) if match is None: errors.append(f"TASTE.md / {title}: 缺少‘{label}’") else: values[label] = match.group(1).strip() if "状态" in values and values["状态"] not in PROFILE_STATUSES: errors.append(f"TASTE.md / {title}: 状态值无效") if "证据" in values and EVIDENCE_LINK.search(values["证据"]) is None: errors.append(f"TASTE.md / {title}: 证据必须链接到 learnings/ 记录") return errors def validate_evidence_links() -> list[str]: text = (ROOT / "TASTE.md").read_text(encoding="utf-8") marker = "## 当前画像" if marker in text: text = text.split(marker, 1)[1] errors: list[str] = [] for relative_path in EVIDENCE_LINK.findall(text): if not (ROOT / relative_path).is_file(): errors.append(f"TASTE.md: 证据链接不存在:{relative_path}") return errors def main() -> int: errors = [ f"缺少必要文件:{path.relative_to(ROOT)}" for path in REQUIRED_FILES if not path.is_file() ] if errors: for error in errors: print(f"ERROR: {error}", file=sys.stderr) return 1 records = sorted( path for path in LEARNINGS.glob("*.md") if path.name != "README.md" ) for record in records: errors.extend(validate_record(record)) errors.extend(validate_profile()) errors.extend(validate_evidence_links()) if errors: for error in errors: print(f"ERROR: {error}", file=sys.stderr) return 1 print(f"OK: {len(records)} learning record(s), profile structure is valid") return 0 if __name__ == "__main__": raise SystemExit(main())