175 lines
5.7 KiB
Python
Executable File
175 lines
5.7 KiB
Python
Executable File
#!/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())
|