Новый скрипт scripts/kb-objects-audit.py — еженедельный health-check vault'а: 1. Каждый projects/<dir>/README.md имеет валидный frontmatter (type/status/aliases) 2. Каждый онлайн-netbird-пир привязан к проекту через aliases или собственную карточку 3. Битые wiki-ссылки [[...]] не указывают в небытие Output: audit/YYYY-MM-DD-objects-audit.md со score (меньше = лучше). Первый запуск 2026-05-06: score=84 - 12/12 проектов с frontmatter ✓ - 3 online orphan-пира (DESKTOP-2IOQS54 Saransk, DESKTOP-AGBMLPN Helsinki, DESKTOP-HL0BB05 Lipetsk) - 26 битых wiki-ссылок выявлено Phase 6: dreaming включён (cron 0 3 * * *), recall promote'нул 17/39, weekly cron на promote. Phase 8: на 137 — minScore=0.4 в memorySearch.query, IDENTITY.md разводит двух Максимок, INFRASTRUCTURE.md переписан как навигатор по vault'у (не дубль). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
196 lines
7.3 KiB
Python
196 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
||
"""kb-objects-audit — еженедельный health-check vault'а.
|
||
|
||
Проверки:
|
||
1. Каждый онлайн-netbird-пир имеет привязку к проекту (через aliases в frontmatter
|
||
или собственную карточку). Orphans = клиентские машины без описания.
|
||
2. Каждый projects/<dir>/README.md имеет валидный frontmatter (type, status,
|
||
aliases как минимум).
|
||
3. Битые wiki-ссылки `[[...]]` в vault'е (указывают в небытие).
|
||
4. Дубли по нечёткому совпадению заголовков (опасные близнецы).
|
||
|
||
Output: audit/YYYY-MM-DD-objects-audit.md (каждый запуск перезаписывает дневной).
|
||
|
||
Запуск ручной или cron weekly:
|
||
cd ~/knowledge-base && python3 scripts/kb-objects-audit.py
|
||
"""
|
||
from datetime import date
|
||
from pathlib import Path
|
||
import json
|
||
import re
|
||
import sys
|
||
|
||
VAULT = Path(__file__).resolve().parent.parent
|
||
OBJECTS_MAP = VAULT / "audit/objects-map.json"
|
||
|
||
REQUIRED_FRONTMATTER_KEYS = ["type", "status"]
|
||
PROJECT_FRONTMATTER_KEYS = ["type", "status", "aliases"]
|
||
|
||
|
||
def load_map() -> list:
|
||
if not OBJECTS_MAP.exists():
|
||
sys.exit(f"FATAL: нет {OBJECTS_MAP.relative_to(VAULT)} — запусти scripts/kb-objects-map.py сначала")
|
||
return json.loads(OBJECTS_MAP.read_text())
|
||
|
||
|
||
def parse_fm(text: str) -> dict:
|
||
m = re.match(r"^---\n(.+?)\n---\n", text, re.S)
|
||
if not m:
|
||
return {}
|
||
fm = {}
|
||
for line in m.group(1).splitlines():
|
||
if ":" not in line or line.startswith("#"):
|
||
continue
|
||
k, _, v = line.partition(":")
|
||
fm[k.strip()] = v.strip()
|
||
return fm
|
||
|
||
|
||
def find_md_files() -> list[Path]:
|
||
out = []
|
||
for p in VAULT.rglob("*.md"):
|
||
rel = p.relative_to(VAULT)
|
||
if any(part.startswith(".") for part in rel.parts):
|
||
continue
|
||
if rel.parts[0] in ("audit",):
|
||
# audit-отчёты сами не аудируются
|
||
if "archive" in rel.parts:
|
||
continue
|
||
out.append(p)
|
||
return out
|
||
|
||
|
||
def check_project_frontmatter(objects: list) -> list[str]:
|
||
issues = []
|
||
for o in objects:
|
||
if o["type"] != "project" or not o["file"]:
|
||
continue
|
||
path = VAULT / o["file"]
|
||
if not path.exists():
|
||
issues.append(f"- `{o['id']}`: file missing — `{o['file']}`")
|
||
continue
|
||
fm = parse_fm(path.read_text())
|
||
missing = [k for k in PROJECT_FRONTMATTER_KEYS if k not in fm]
|
||
if missing:
|
||
issues.append(f"- `{o['id']}`: frontmatter missing {missing} — `{o['file']}`")
|
||
return issues
|
||
|
||
|
||
def check_broken_wikilinks(files: list[Path]) -> list[tuple[str, str, str]]:
|
||
"""Возвращает [(source, link, reason)] для битых [[...]] ссылок."""
|
||
issues = []
|
||
all_basenames = {f.stem for f in files}
|
||
all_relpaths = {str(f.relative_to(VAULT)).replace(".md", "") for f in files}
|
||
pat = re.compile(r"\[\[([^\]\|#]+?)(?:\||#|\])")
|
||
for f in files:
|
||
try:
|
||
text = f.read_text()
|
||
except Exception:
|
||
continue
|
||
for m in pat.finditer(text):
|
||
target = m.group(1).strip().rstrip("/")
|
||
if not target:
|
||
continue
|
||
# Allow absolute by relpath, or basename match, or relative-resolved
|
||
if target in all_relpaths:
|
||
continue
|
||
base = target.split("/")[-1]
|
||
if base in all_basenames:
|
||
continue
|
||
# try relative to source
|
||
src_dir = f.parent.relative_to(VAULT)
|
||
resolved = str(src_dir / target).replace("./", "")
|
||
if resolved in all_relpaths:
|
||
continue
|
||
issues.append((str(f.relative_to(VAULT)), m.group(0), "→ нет такого файла"))
|
||
return issues
|
||
|
||
|
||
def check_orphans(objects: list) -> list[dict]:
|
||
return [o for o in objects if o["type"] == "netbird-only"
|
||
and o["netbird_peers"] and o["netbird_peers"][0].get("online", True)]
|
||
|
||
|
||
def main() -> None:
|
||
objects = load_map()
|
||
files = find_md_files()
|
||
|
||
fm_issues = check_project_frontmatter(objects)
|
||
orphans = check_orphans(objects)
|
||
wiki_issues = check_broken_wikilinks(files)
|
||
|
||
# счётчики
|
||
n_proj = sum(1 for o in objects if o["type"] == "project")
|
||
n_proj_with_fm = n_proj - len(fm_issues)
|
||
n_orphan_online = len(orphans)
|
||
n_wiki_broken = len(wiki_issues)
|
||
score = len(fm_issues) * 5 + n_orphan_online * 2 + n_wiki_broken * 3
|
||
today = date.today().isoformat()
|
||
out = VAULT / f"audit/{today}-objects-audit.md"
|
||
|
||
md = [
|
||
"---",
|
||
f"date: {today}",
|
||
"type: audit",
|
||
"source: scripts/kb-objects-audit.py",
|
||
"tags: [audit, objects, frontmatter, links]",
|
||
f"score: {score}",
|
||
"---",
|
||
"",
|
||
f"# KB objects audit — {today}",
|
||
"",
|
||
f"**Score (меньше = лучше): `{score}`**",
|
||
"",
|
||
f"- Проектов с frontmatter: **{n_proj_with_fm}/{n_proj}** ({len(fm_issues)} проблем)",
|
||
f"- NetBird online-пиров без проектной карточки: **{n_orphan_online}**",
|
||
f"- Битых wiki-ссылок `[[...]]`: **{n_wiki_broken}**",
|
||
"",
|
||
]
|
||
|
||
md.extend(["## Frontmatter в projects/", ""])
|
||
if fm_issues:
|
||
md.extend(fm_issues)
|
||
else:
|
||
md.append("✅ все проекты имеют валидный frontmatter")
|
||
md.append("")
|
||
|
||
md.extend(["## Online netbird-пиры без проектной карточки",
|
||
"",
|
||
"Эти пиры онлайн в NetBird, но не привязаны ни к одной projects/-странице. ",
|
||
"Бот не сможет ответить «найди X» осмысленно — нет файла или alias.",
|
||
"",
|
||
"Лечение: либо создать stub в `projects/<slug>/README.md` (см. `projects/lipki/` как образец), ",
|
||
"либо добавить имя пира как полную строку в `aliases` подходящего проекта.",
|
||
"",
|
||
"| NetBird-имя | IP | OS | Город |",
|
||
"|---|---|---|---|"])
|
||
if orphans:
|
||
for o in orphans:
|
||
p = o["netbird_peers"][0]
|
||
md.append(f"| `{p['name']}` | {p['ip']} | {p.get('os','')} | {p.get('city','')} |")
|
||
else:
|
||
md.append("| — | — | — | — |")
|
||
md.append("")
|
||
md.append("✅ все онлайн-пиры покрыты")
|
||
md.append("")
|
||
|
||
md.extend(["## Битые wiki-ссылки", ""])
|
||
if wiki_issues:
|
||
for src, link, reason in wiki_issues[:50]:
|
||
md.append(f"- [{src}]({src}) — `{link}` {reason}")
|
||
if len(wiki_issues) > 50:
|
||
md.append(f"- ... ещё {len(wiki_issues)-50} (truncated до 50)")
|
||
else:
|
||
md.append("✅ битых ссылок не найдено")
|
||
md.append("")
|
||
|
||
out.write_text("\n".join(md))
|
||
print(f"Wrote {out.relative_to(VAULT)} (score={score})")
|
||
print(f" frontmatter issues: {len(fm_issues)}")
|
||
print(f" orphan online peers: {n_orphan_online}")
|
||
print(f" broken wiki links: {n_wiki_broken}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|