Initial commit
This commit is contained in:
174
scripts/validate.py
Executable file
174
scripts/validate.py
Executable file
@@ -0,0 +1,174 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user