Files
ai-dist/scripts/validate.py
2026-07-20 15:26:30 +08:00

175 lines
5.7 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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<date>\d{4}-\d{2}-\d{2})-(?P<number>\d{3})-"
r"(?P<slug>[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())