kb.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970
  1. # -*- coding: utf-8 -*-
  2. """
  3. kb.py — 做课系统知识库工具链
  4. 所有"客观事实"由本脚本负责:文件指纹、差异计算、关系图、索引重建、健康检查。
  5. 所有"主观判断"由 skill(LLM)负责:抽取、归纳、写页、写摘要。
  6. 设计原则:
  7. 1. 脚本不碰内容语义,只维护结构。
  8. 2. 幂等:任何命令重复执行结果一致。
  9. 3. 可回滚:删除先进 _archive,不物理销毁。
  10. 用法:
  11. python kb.py status
  12. python kb.py scan [--json]
  13. python kb.py mark <源相对路径> --pages a.md,b.md
  14. python kb.py retract <源相对路径>
  15. python kb.py move <旧路径> <新路径>
  16. python kb.py graph
  17. python kb.py lint [--json]
  18. """
  19. import sys, os, json, re, hashlib, shutil, argparse, subprocess
  20. from pathlib import Path
  21. from datetime import datetime, timezone, timedelta
  22. if hasattr(sys.stdout, "reconfigure"):
  23. sys.stdout.reconfigure(encoding="utf-8", errors="replace")
  24. ROOT = Path(__file__).resolve().parent.parent
  25. RAW = ROOT / "00-原材料"
  26. WIKI = ROOT / "10-知识库"
  27. META = WIKI / "_meta"
  28. ARCHIVE = WIKI / "_archive"
  29. MANIFEST = META / "manifest.json"
  30. DERIV = META / "derivations.jsonl"
  31. CHANGELOG = META / "changelog.jsonl"
  32. CHANGELOG_MD = META / "CHANGELOG.md"
  33. REBUILD = META / "rebuild_queue.jsonl"
  34. CST = timezone(timedelta(hours=8))
  35. DIRS = ["sources", "topics", "entities", "modules"]
  36. # 原料目录里需要无视的噪声
  37. NOISE_NAMES = {"desktop.ini", "thumbs.db", ".ds_store", ".gitkeep"}
  38. NOISE_TAIL = (".tmp", ".crdownload", ".part", ".download", ".swp", ".lock")
  39. # ---------------------------------------------------------------- 基础工具
  40. def now() -> str:
  41. return datetime.now(CST).isoformat(timespec="seconds")
  42. def rel(p: Path, base: Path = None) -> str:
  43. """统一成 POSIX 风格相对路径,作为全库唯一键。"""
  44. return p.relative_to(base or RAW).as_posix()
  45. def sha256(p: Path, chunk: int = 1 << 20) -> str:
  46. h = hashlib.sha256()
  47. with p.open("rb") as f:
  48. for blk in iter(lambda: f.read(chunk), b""):
  49. h.update(blk)
  50. return h.hexdigest()
  51. def short_id(key: str) -> str:
  52. return "src_" + hashlib.sha1(key.encode("utf-8")).hexdigest()[:8]
  53. def is_noise(p: Path) -> bool:
  54. n = p.name
  55. if n.startswith(".") or n.startswith("~$"):
  56. return True
  57. if n.lower() in NOISE_NAMES:
  58. return True
  59. return n.lower().endswith(NOISE_TAIL)
  60. def load_json(p: Path, default):
  61. if p.exists():
  62. try:
  63. return json.loads(p.read_text(encoding="utf-8"))
  64. except Exception:
  65. pass
  66. return default
  67. def dump_json(p: Path, obj):
  68. p.parent.mkdir(parents=True, exist_ok=True)
  69. p.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
  70. def append_jsonl(p: Path, row: dict):
  71. p.parent.mkdir(parents=True, exist_ok=True)
  72. with p.open("a", encoding="utf-8") as f:
  73. f.write(json.dumps(row, ensure_ascii=False) + "\n")
  74. def read_jsonl(p: Path) -> list:
  75. if not p.exists():
  76. return []
  77. out = []
  78. for line in p.read_text(encoding="utf-8").splitlines():
  79. line = line.strip()
  80. if line:
  81. try:
  82. out.append(json.loads(line))
  83. except Exception:
  84. pass
  85. return out
  86. def log_human(op: str, subject: str, detail: str = ""):
  87. """人读日志,与 changelog.jsonl 双写。
  88. 统一前缀是刻意设计:`grep '^## \\[' CHANGELOG.md | tail -5` 就能取最近 5 条,
  89. 不需要任何工具。这是 Karpathy llm-wiki 里 log.md 的做法,被验证过很好用。
  90. """
  91. if not CHANGELOG_MD.exists():
  92. CHANGELOG_MD.write_text(
  93. "# 变更日志\n\n"
  94. "> 只增不改。统一前缀便于 shell 检索:\n"
  95. "> `grep '^## \\[' CHANGELOG.md | tail -5`\n\n",
  96. encoding="utf-8")
  97. stamp = datetime.now(CST).strftime("%Y-%m-%d %H:%M")
  98. block = f"## [{stamp}] {op} | {subject}\n"
  99. if detail:
  100. block += detail.rstrip() + "\n"
  101. with CHANGELOG_MD.open("a", encoding="utf-8") as f:
  102. f.write(block + "\n")
  103. # ------------------------------------------------------------ frontmatter
  104. def split_fm(text: str):
  105. """返回 (dict, body)。不依赖 pyyaml,只支持本库用到的子集。"""
  106. if not text.startswith("---"):
  107. return {}, text
  108. m = re.match(r"^---\r?\n(.*?)\r?\n---\r?\n?", text, re.S)
  109. if not m:
  110. return {}, text
  111. raw, body = m.group(1), text[m.end():]
  112. data, key = {}, None
  113. for line in raw.splitlines():
  114. if not line.strip() or line.lstrip().startswith("#"):
  115. continue
  116. if re.match(r"^\s+-\s+", line) and key:
  117. if not isinstance(data.get(key), list):
  118. data[key] = []
  119. data[key].append(_scalar(line.strip()[2:]))
  120. continue
  121. km = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
  122. if km:
  123. key, val = km.group(1), km.group(2).strip()
  124. if val == "":
  125. data[key] = []
  126. elif val.startswith("[") and val.endswith("]"):
  127. inner = val[1:-1].strip()
  128. data[key] = [_scalar(x) for x in inner.split(",")] if inner else []
  129. else:
  130. data[key] = _scalar(val)
  131. return data, body
  132. def _scalar(s: str):
  133. s = s.strip()
  134. if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'":
  135. return s[1:-1]
  136. if s.lower() in ("true", "false"):
  137. return s.lower() == "true"
  138. return s
  139. def dump_fm(fm: dict) -> str:
  140. order = ["id", "type", "title", "summary", "evidence", "tags", "sources",
  141. "related", "status", "stale", "stale_reason", "created", "updated"]
  142. keys = [k for k in order if k in fm] + [k for k in fm if k not in order]
  143. lines = ["---"]
  144. for k in keys:
  145. v = fm[k]
  146. if isinstance(v, list):
  147. if not v:
  148. lines.append(f"{k}: []")
  149. else:
  150. lines.append(f"{k}:")
  151. lines += [f" - {json.dumps(str(x), ensure_ascii=False)}" for x in v]
  152. elif isinstance(v, bool):
  153. lines.append(f"{k}: {'true' if v else 'false'}")
  154. else:
  155. lines.append(f"{k}: {json.dumps(str(v), ensure_ascii=False)}")
  156. lines.append("---")
  157. return "\n".join(lines) + "\n"
  158. def read_page(p: Path):
  159. text = p.read_text(encoding="utf-8", errors="replace")
  160. fm, body = split_fm(text)
  161. return fm, body
  162. def write_page(p: Path, fm: dict, body: str):
  163. p.parent.mkdir(parents=True, exist_ok=True)
  164. p.write_text(dump_fm(fm) + body, encoding="utf-8")
  165. # ---------------------------------------------------------------- scan
  166. def manifest_skeleton():
  167. return {"version": 2, "root": "00-原材料", "updated_at": None,
  168. "files": {}, "tombstones": []}
  169. def collect_raw() -> dict:
  170. """返回 {relpath: {size, mtime_ns, sha256}},大文件用 size+mtime 短路。"""
  171. man = load_json(MANIFEST, manifest_skeleton())
  172. known = man.get("files", {})
  173. out = {}
  174. if not RAW.exists():
  175. return out
  176. for p in sorted(RAW.rglob("*")):
  177. if not p.is_file() or is_noise(p):
  178. continue
  179. r = rel(p)
  180. st = p.stat()
  181. old = known.get(r, {})
  182. if old.get("size") == st.st_size and old.get("mtime_ns") == st.st_mtime_ns and old.get("sha256"):
  183. out[r] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns,
  184. "sha256": old["sha256"], "fast": True}
  185. else:
  186. out[r] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns,
  187. "sha256": sha256(p), "fast": False}
  188. return out
  189. def cmd_scan(args):
  190. man = load_json(MANIFEST, manifest_skeleton())
  191. known = man.get("files", {})
  192. cur = collect_raw()
  193. new_keys = sorted(set(cur) - set(known))
  194. gone_keys = sorted(set(known) - set(cur))
  195. common = sorted(set(cur) & set(known))
  196. changed = [k for k in common if cur[k]["sha256"] != known[k].get("sha256")]
  197. # 移动识别:内容 hash 相同、路径不同
  198. gone_by_hash = {}
  199. for k in gone_keys:
  200. h = known[k].get("sha256")
  201. if h:
  202. gone_by_hash.setdefault(h, []).append(k)
  203. new_by_hash = {}
  204. for k in new_keys:
  205. new_by_hash.setdefault(cur[k]["sha256"], []).append(k)
  206. moved, consumed_new, consumed_gone = [], set(), set()
  207. for h, news in new_by_hash.items():
  208. gones = gone_by_hash.get(h, [])
  209. if len(news) == 1 and len(gones) == 1: # 只在能唯一配对时认定移动
  210. moved.append({"from": gones[0], "to": news[0], "sha256": h})
  211. consumed_new.add(news[0]); consumed_gone.add(gones[0])
  212. result = {
  213. "generated_at": now(),
  214. "new": [k for k in new_keys if k not in consumed_new],
  215. "changed": changed,
  216. "deleted": [k for k in gone_keys if k not in consumed_gone],
  217. "moved": moved,
  218. "unchanged": len(common) - len(changed),
  219. "details": {k: {"size": cur[k]["size"]} for k in
  220. [*new_keys, *changed] if k in cur},
  221. }
  222. if args.json:
  223. print(json.dumps(result, ensure_ascii=False, indent=2))
  224. return
  225. print(f"扫描 {RAW.name}/ @{result['generated_at']}")
  226. print(f" 新增 {len(result['new'])} · 变更 {len(result['changed'])} · "
  227. f"删除 {len(result['deleted'])} · 移动 {len(result['moved'])} · "
  228. f"未变 {result['unchanged']}")
  229. for tag, key in (("NEW", "new"), ("CHANGED", "changed"), ("DELETED", "deleted")):
  230. for k in result[key]:
  231. print(f" [{tag}] {k}")
  232. for m in result["moved"]:
  233. print(f" [MOVED] {m['from']} -> {m['to']}")
  234. if not any(result[k] for k in ("new", "changed", "deleted", "moved")):
  235. print(" 库已是最新,无需同步。")
  236. # ---------------------------------------------------------------- mark
  237. def cmd_mark(args):
  238. man = load_json(MANIFEST, manifest_skeleton())
  239. cur = collect_raw()
  240. r = args.path
  241. if r not in cur:
  242. print(f"[ERR] 原材料中不存在:{r}")
  243. return 1
  244. pages = [x.strip().replace("\\", "/") for x in (args.pages or "").split(",") if x.strip()]
  245. rec = man["files"].get(r, {})
  246. rec.update({"size": cur[r]["size"], "mtime_ns": cur[r]["mtime_ns"],
  247. "sha256": cur[r]["sha256"], "id": rec.get("id") or short_id(r),
  248. "status": "ingested", "ingested_at": now(),
  249. "pages": sorted(set(rec.get("pages", []) + pages))})
  250. man["files"][r] = rec
  251. man["updated_at"] = now()
  252. dump_json(MANIFEST, man)
  253. append_jsonl(CHANGELOG, {"ts": now(), "op": "ingest", "source": r,
  254. "pages": pages, "sha256": rec["sha256"][:12]})
  255. log_human("ingest", r, "\n".join(f"- 派生 `{x}`" for x in pages))
  256. print(f"[OK] 已登记 {r} -> {len(pages)} 页")
  257. # ---------------------------------------------------------------- retract
  258. def _backlinks(target_keys: set) -> dict:
  259. """扫描全库,返回 {被链页面: [引用它的页面]}。用于级联失效。"""
  260. links = {}
  261. for d in DIRS:
  262. for p in (WIKI / d).rglob("*.md"):
  263. if p.name.startswith("_"):
  264. continue
  265. fm, body = read_page(p)
  266. for name in set(re.findall(r"\[\[([^\]|#]+)", body)):
  267. links.setdefault(name.strip(), []).append(rel(p, WIKI))
  268. return links
  269. def cmd_retract(args):
  270. """源文件被删除后回滚知识库。三种情形:独占页归档 / 共享页降级 / 级联标记。"""
  271. src = args.path.replace("\\", "/")
  272. man = load_json(MANIFEST, manifest_skeleton())
  273. if src not in man["files"]:
  274. print(f"[ERR] manifest 中无此源:{src}")
  275. return 1
  276. archived, degraded, cascade = [], [], []
  277. touched_stems = set()
  278. for d in DIRS:
  279. for p in sorted((WIKI / d).rglob("*.md")):
  280. if p.name.startswith("_"):
  281. continue
  282. fm, body = read_page(p)
  283. srcs = fm.get("sources") or []
  284. if not isinstance(srcs, list):
  285. srcs = [srcs]
  286. if src not in srcs:
  287. continue
  288. stamped = datetime.now(CST).strftime("%Y%m%d-%H%M%S")
  289. if len(srcs) == 1:
  290. ARCHIVE.mkdir(parents=True, exist_ok=True)
  291. dest = ARCHIVE / f"{p.stem}.{stamped}.md"
  292. fm["status"] = "archived"
  293. fm["stale_reason"] = f"源文件已删除:{src}"
  294. fm["updated"] = now()
  295. write_page(dest, fm, body)
  296. p.unlink()
  297. archived.append(rel(dest, WIKI))
  298. touched_stems.add(p.stem)
  299. else:
  300. fm["sources"] = [s for s in srcs if s != src]
  301. fm["stale"] = True
  302. fm["stale_reason"] = f"少了一个来源({src}),需重新合成"
  303. fm["updated"] = now()
  304. write_page(p, fm, body)
  305. degraded.append(rel(p, WIKI))
  306. append_jsonl(REBUILD, {"ts": now(), "page": rel(p, WIKI),
  307. "reason": "source_removed", "source": src})
  308. touched_stems.add(p.stem)
  309. # 级联:引用了已归档页面的页面 -> 标记待复核
  310. if touched_stems:
  311. back = _backlinks(touched_stems)
  312. for stem in touched_stems:
  313. for ref in back.get(stem, []):
  314. fp = WIKI / ref
  315. if not fp.exists():
  316. continue
  317. fm, body = read_page(fp)
  318. if fm.get("stale"):
  319. continue
  320. fm["stale"] = True
  321. fm["stale_reason"] = f"引用的页面 {stem} 已因源删除而失效"
  322. fm["updated"] = now()
  323. write_page(fp, fm, body)
  324. cascade.append(ref)
  325. rec = man["files"].pop(src, {})
  326. man.setdefault("tombstones", []).append({"path": src, "removed_at": now(),
  327. "sha256": rec.get("sha256"),
  328. "pages": rec.get("pages", [])})
  329. man["updated_at"] = now()
  330. dump_json(MANIFEST, man)
  331. append_jsonl(CHANGELOG, {"ts": now(), "op": "retract", "source": src,
  332. "archived": archived, "degraded": degraded,
  333. "cascade": sorted(set(cascade))})
  334. log_human("retract", src,
  335. "\n".join([f"- 归档 `{x}`" for x in archived] +
  336. [f"- 降级待重建 `{x}`" for x in degraded] +
  337. [f"- 级联标记 `{x}`" for x in sorted(set(cascade))]) or "- 无派生页")
  338. print(f"[OK] 回滚 {src}")
  339. print(f" 归档页 {len(archived)} · 降级为待重建 {len(degraded)} · 级联标记 {len(set(cascade))}")
  340. for x in archived:
  341. print(f" ARCHIVE {x}")
  342. for x in degraded:
  343. print(f" DEGRADE {x}")
  344. for x in sorted(set(cascade)):
  345. print(f" CASCADE {x}")
  346. if not archived and not degraded:
  347. print(" (该源未派生任何页面,仅清理 manifest)")
  348. def cmd_move(args):
  349. """源文件移动:sha 相同,只改 manifest 和所有 frontmatter 引用。"""
  350. old, new = args.old.replace("\\", "/"), args.new.replace("\\", "/")
  351. man = load_json(MANIFEST, manifest_skeleton())
  352. if old not in man["files"]:
  353. print(f"[ERR] manifest 中无此源:{old}")
  354. return 1
  355. man["files"][new] = man["files"].pop(old)
  356. man["updated_at"] = now()
  357. dump_json(MANIFEST, man)
  358. n = 0
  359. for d in DIRS:
  360. for p in (WIKI / d).rglob("*.md"):
  361. fm, body = read_page(p)
  362. srcs = fm.get("sources")
  363. if isinstance(srcs, list) and old in srcs:
  364. fm["sources"] = [new if s == old else s for s in srcs]
  365. fm["updated"] = now()
  366. write_page(p, fm, body)
  367. n += 1
  368. append_jsonl(CHANGELOG, {"ts": now(), "op": "move", "from": old, "to": new})
  369. log_human("move", f"{old} -> {new}", f"- 更新引用 {n} 页")
  370. print(f"[OK] 移动登记 {old} -> {new},更新引用 {n} 页")
  371. # ---------------------------------------------------------------- graph
  372. def all_pages():
  373. out = []
  374. for d in DIRS:
  375. base = WIKI / d
  376. if not base.exists():
  377. continue
  378. for p in sorted(base.rglob("*.md")):
  379. if p.name.startswith("_"):
  380. continue
  381. fm, body = read_page(p)
  382. out.append({"path": rel(p, WIKI), "stem": p.stem, "fm": fm,
  383. "body": body, "dir": d})
  384. return out
  385. def cmd_graph(args):
  386. pages = all_pages()
  387. edges = []
  388. index_map = {p["stem"]: p for p in pages}
  389. for p in pages:
  390. fm = p["fm"]
  391. for s in (fm.get("sources") or []):
  392. edges.append({"kind": "derives", "from": str(s), "to": p["path"]})
  393. for name in re.findall(r"\[\[([^\]|#]+)", p["body"]):
  394. tgt = index_map.get(name.strip())
  395. if tgt:
  396. edges.append({"kind": "links", "from": p["path"], "to": tgt["path"]})
  397. DERIV.write_text("".join(json.dumps(e, ensure_ascii=False) + "\n" for e in edges),
  398. encoding="utf-8")
  399. # 重建各目录 _index.md
  400. ts = now()
  401. titles = {"sources": "来源页", "topics": "主题页", "entities": "实体页",
  402. "modules": "课程模块"}
  403. for d in DIRS:
  404. group = [p for p in pages if p["dir"] == d]
  405. lines = [f"# {titles[d]}索引\n",
  406. f"> 自动生成于 {ts},共 {len(group)} 条。请勿手改,改页面 frontmatter 后重跑 `kb.py graph`。\n"]
  407. if not group:
  408. lines.append("_(暂无内容)_\n")
  409. else:
  410. lines.append("| 页面 | 一句话 | 标签 | 源数 | 更新 | 状态 |")
  411. lines.append("|---|---|---|---|---|---|")
  412. for p in sorted(group, key=lambda x: x["path"]):
  413. fm = p["fm"]
  414. flag = "⚠️stale" if fm.get("stale") else (fm.get("status") or "ok")
  415. lines.append("| [{}]({}) | {} | {} | {} | {} | {} |".format(
  416. fm.get("title") or p["stem"], p["path"],
  417. (fm.get("summary") or "").replace("|", "/"),
  418. ", ".join(fm.get("tags") or []) or "-",
  419. len(fm.get("sources") or []),
  420. str(fm.get("updated") or "")[:10], flag))
  421. (WIKI / d / "_index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
  422. print(f"[OK] 关系图重建:{len(pages)} 页,{len(edges)} 条边 -> {rel(DERIV, ROOT)}")
  423. for d in DIRS:
  424. print(f" {d}/_index.md ({len([p for p in pages if p['dir']==d])} 条)")
  425. # ---------------------------------------------------------------- lint
  426. def cmd_lint(args):
  427. pages = all_pages()
  428. stems = {p["stem"] for p in pages}
  429. aliases = set()
  430. for p in pages:
  431. for a in (p["fm"].get("aliases") or []):
  432. aliases.add(str(a))
  433. man = load_json(MANIFEST, manifest_skeleton())
  434. live_sources = set(man["files"].keys())
  435. inbound = {}
  436. for p in pages:
  437. for name in re.findall(r"\[\[([^\]|#]+)", p["body"]):
  438. inbound.setdefault(name.strip(), []).append(p["path"])
  439. issues = []
  440. # 源文件路径 -> 该源的 source 页所标证据等级(用于木桶校验)
  441. # 字母序 A<B<C<D,越靠后越弱,所以「最弱」取 max
  442. src_ev = {}
  443. for p in pages:
  444. if p["fm"].get("type") == "source" and p["fm"].get("evidence"):
  445. for s in (p["fm"].get("sources") or []):
  446. ev = p["fm"]["evidence"]
  447. src_ev[s] = max(src_ev[s], ev) if s in src_ev else ev
  448. for p in pages:
  449. fm = p["fm"]
  450. if not fm:
  451. issues.append(("NO_FM", p["path"], "缺少 frontmatter"))
  452. continue
  453. for f in ("title", "type", "summary"):
  454. if not fm.get(f):
  455. issues.append(("MISSING_FIELD", p["path"], f"缺少字段 {f}"))
  456. ev = fm.get("evidence")
  457. if not ev:
  458. issues.append(("MISSING_EVIDENCE", p["path"], "未标证据等级 A/B/C/D"))
  459. elif ev not in ("A", "B", "C", "D"):
  460. issues.append(("BAD_EVIDENCE", p["path"], f"证据等级非法:{ev}"))
  461. else:
  462. if fm.get("type") in ("topic", "module"):
  463. lows = [src_ev[s] for s in (fm.get("sources") or []) if s in src_ev]
  464. weakest = max(lows) if lows else None # 字母最大 = 证据最弱
  465. if weakest and ev < weakest:
  466. issues.append(("EVIDENCE_INFLATED", p["path"],
  467. f"标了 {ev},但来源最低只有 {weakest}(合成不提升证据强度)"))
  468. if fm.get("type") == "module" and ev == "D":
  469. issues.append(("D_IN_MODULE", p["path"], "D 级内容不许进课件"))
  470. if len(p["body"].strip()) < 100:
  471. issues.append(("THIN", p["path"], f"正文过短({len(p['body'].strip())} 字)"))
  472. for s in (fm.get("sources") or []):
  473. if s not in live_sources:
  474. issues.append(("DANGLING_SOURCE", p["path"], f"引用了不存在的源:{s}"))
  475. for name in set(re.findall(r"\[\[([^\]|#]+)", p["body"])):
  476. if name.strip() not in stems and name.strip() not in aliases:
  477. issues.append(("DEAD_LINK", p["path"], f"[[{name.strip()}]] 无对应页"))
  478. if not (fm.get("sources") or []) and not inbound.get(p["stem"]):
  479. issues.append(("ORPHAN", p["path"], "无来源且无入链"))
  480. if fm.get("stale"):
  481. issues.append(("STALE", p["path"], fm.get("stale_reason") or "待重建"))
  482. if fm.get("type") == "source":
  483. src = (fm.get("sources") or [None])[0]
  484. if src and man["files"].get(src, {}).get("sha256") and \
  485. fm.get("source_sha") and fm["source_sha"] != man["files"][src]["sha256"]:
  486. issues.append(("SOURCE_DRIFT", p["path"], "源文件已变更,页面需重新整理"))
  487. pend = read_jsonl(REBUILD)
  488. summary = {
  489. "pages": len(pages), "sources": len(live_sources),
  490. "issues": len(issues),
  491. "by_kind": {},
  492. "rebuild_queue": len(pend),
  493. "score": max(0, 100 - len(issues) * 3 - len(pend) * 2),
  494. }
  495. for k, _, _ in issues:
  496. summary["by_kind"][k] = summary["by_kind"].get(k, 0) + 1
  497. if args.json:
  498. print(json.dumps({"summary": summary,
  499. "issues": [{"kind": k, "page": p, "detail": d} for k, p, d in issues]},
  500. ensure_ascii=False, indent=2))
  501. return
  502. print(f"健康分 {summary['score']}/100 · {len(pages)} 页 · {len(live_sources)} 源 · "
  503. f"{len(issues)} 问题 · {len(pend)} 项待重建")
  504. if issues:
  505. print("\n按类型:")
  506. for k, v in sorted(summary["by_kind"].items(), key=lambda x: -x[1]):
  507. print(f" {k:<18} {v}")
  508. print("\n明细:")
  509. for k, p, d in issues:
  510. print(f" [{k}] {p} — {d}")
  511. else:
  512. print("未发现问题。")
  513. # ---------------------------------------------------------------- status
  514. def cmd_status(args):
  515. man = load_json(MANIFEST, manifest_skeleton())
  516. pages = all_pages()
  517. pend = read_jsonl(REBUILD)
  518. logs = read_jsonl(CHANGELOG)
  519. cur = collect_raw()
  520. pending_new = set(cur) - set(man["files"])
  521. pending_gone = set(man["files"]) - set(cur)
  522. stale = [p["path"] for p in pages if p["fm"].get("stale")]
  523. print("=" * 56)
  524. print(" 做课系统 · 知识库状态")
  525. print("=" * 56)
  526. print(f" 根目录 {ROOT}")
  527. print(f" 最近同步 {man.get('updated_at') or '从未'}")
  528. print(f" 原料文件 {len(cur)} 个(已整理 {len(man['files'])})")
  529. print(f" 知识页 {len(pages)} 页")
  530. for d in DIRS:
  531. print(f" {d:<10} {len([p for p in pages if p['dir']==d])}")
  532. print(f" 待处理新增 {len(pending_new)}")
  533. print(f" 待处理删除 {len(pending_gone)}")
  534. print(f" 待重建 {len(pend)}")
  535. print(f" 陈旧页 {len(stale)}")
  536. print(f" 历史操作 {len(logs)} 条")
  537. if pending_new:
  538. print("\n 未入库:")
  539. for x in sorted(pending_new)[:12]:
  540. print(f" + {x}")
  541. if len(pending_new) > 12:
  542. print(f" ... 还有 {len(pending_new)-12} 个")
  543. if pending_gone:
  544. print("\n 源已消失(待回滚):")
  545. for x in sorted(pending_gone)[:12]:
  546. print(f" - {x}")
  547. if stale:
  548. print("\n 陈旧页:")
  549. for x in stale[:12]:
  550. print(f" ! {x}")
  551. print("=" * 56)
  552. if pending_new or pending_gone or pend:
  553. print(" → 建议运行:kb-sync")
  554. else:
  555. print(" → 库是干净的")
  556. # ---------------------------------------------------------------- guide
  557. def cmd_guide(args):
  558. """给 AI 的自描述入口(对应 llmwiki 的 guide 工具)。
  559. 为什么要它:每次新会话,AI 都要重建对这套系统的认知。让它读 README 加 8 个
  560. SKILL.md,要烧掉几万 token,读完还不一定知道"现在该干什么"。
  561. 这个命令一次给全:这是什么、铁律是什么、当前什么状态、下一步做什么。
  562. """
  563. man = load_json(MANIFEST, manifest_skeleton())
  564. pages = all_pages()
  565. cur = collect_raw()
  566. pend_new = sorted(set(cur) - set(man["files"]))
  567. pend_gone = sorted(set(man["files"]) - set(cur))
  568. stale = [p["path"] for p in pages if p["fm"].get("stale")]
  569. nreb = len(read_jsonl(REBUILD))
  570. print("=" * 60)
  571. print(" 做课系统 · 给接手者的指南")
  572. print("=" * 60)
  573. print("""
  574. 【这是什么】
  575. 一条单向管道:
  576. 00-原材料/ ──你整理──▶ 10-知识库/ ──取材──▶ 20-课程产出/
  577. (只进不改) (全 md,你维护) (交付物)
  578. 人是资料策展人,你是维护者。人不写知识库,你写。
  579. 【三条铁律】
  580. 1. 差异问脚本,别凭记忆 —— 判断哪些文件是新的,永远跑 kb.py scan
  581. 2. 脚本管事实,你管语义 —— 页数、hash、关系图都问脚本
  582. 3. 补料不许编,取材必标出处 —— 这是两条方向相反的规则,见 AGENTS.md
  583. 【该走哪条路】
  584. 开工第一件事 → kb.py pull (对齐远端,报出别人推了什么)
  585. 丢进了新资料 / 删了源文件 → 30-技能/kb-sync (先 scan 再分流)
  586. 查点东西 → 30-技能/kb-query (好答案记得沉淀回库)
  587. 要做一门课 → 30-技能/course-outline → course-slides
  588. 库看着有点乱 → kb.py check (结构 + 内容一起过)
  589. 改页面格式或加规则 → 10-知识库/_meta/约定.md(改完登记起因)
  590. 收工 → GIT.md 的提交规范 (不推等于白干)
  591. manifest 被 git 搞冲突了 → kb.py repair (从磁盘和页面反推)
  592. 【格式硬约束】(细节见 约定.md)
  593. 每页 frontmatter 必填:type / title / summary / evidence / sources
  594. evidence 只能是 A/B/C/D,且 topic、module 页不得高于其来源最低级
  595. sources 填原材料相对路径,一字不差 —— 这是删除回滚的唯一依据
  596. """)
  597. print("-" * 60)
  598. print("【当前状态】")
  599. print(f" 原料 {len(cur)} 个(已整理 {len(man['files'])})· "
  600. f"知识页 {len(pages)} 页 · 最近同步 {man.get('updated_at') or '从未'}")
  601. for d in DIRS:
  602. n = len([p for p in pages if p["dir"] == d])
  603. if n:
  604. print(f" {d:<10} {n}")
  605. print(f" 待处理新增 {len(pend_new)} · 待处理删除 {len(pend_gone)} · "
  606. f"待重建 {nreb} · 陈旧页 {len(stale)}")
  607. print("\n【下一步】")
  608. todo = []
  609. if pend_new:
  610. todo.append(f"有 {len(pend_new)} 个新源未入库 → kb-sync")
  611. if pend_gone:
  612. todo.append(f"有 {len(pend_gone)} 个源已消失 → kb-sync 的 retract 分支")
  613. if nreb:
  614. todo.append(f"有 {nreb} 页待重建 → 读 _meta/rebuild_queue.jsonl")
  615. if stale:
  616. todo.append(f"有 {len(stale)} 页被标 stale → 处理完记得清标记")
  617. if not pages:
  618. todo.append("库还是空的 → 把资料丢进 00-原材料/,然后说「同步知识库」")
  619. if not todo:
  620. todo.append("库是干净的 → 可以说「做一门课」或「查 XX」")
  621. for t in todo:
  622. print(f" · {t}")
  623. print("=" * 60)
  624. # ---------------------------------------------------------------- pull
  625. def git(*args):
  626. """在仓库根跑 git。Windows 下 git 输出可能是 GBK,两种都试。"""
  627. r = subprocess.run(["git", *args], cwd=str(ROOT), capture_output=True,
  628. text=True, encoding="utf-8", errors="replace")
  629. if r.returncode != 0 and r.stderr and "codec" in r.stderr.lower():
  630. r = subprocess.run(["git", *args], cwd=str(ROOT), capture_output=True,
  631. text=True, encoding="gbk", errors="replace")
  632. return r
  633. def cmd_pull(args):
  634. """远端优先:先拉最新,再告诉你有多少新料要整理。
  635. 设计取向:这个库是要写入的,所以不能像只读知识库那样"拉临时副本用完就扔"。
  636. 正确做法是本地留全量,每次开工先 pull —— pull 只传增量,比全量下载快得多。
  637. """
  638. if git("rev-parse", "--is-inside-work-tree").returncode != 0:
  639. print("[ERR] 这里不是 git 仓库,无法从远端拉取")
  640. print(" 如果是新建的库,先 git init 并配置 origin")
  641. return 1
  642. remotes = git("remote").stdout.split()
  643. if not remotes:
  644. print("[!] 没有配置远端,当前只有本地版本控制")
  645. print(" 配置:git remote add origin <你的本地 git 服务地址>")
  646. print(" 然后照常跑 kb.py scan")
  647. return 0
  648. remote = args.remote or remotes[0]
  649. branch = git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() or "main"
  650. # 工作区不干净就别拉,免得跟 rebase 打架
  651. dirty = [x for x in git("status", "--porcelain").stdout.splitlines() if x.strip()]
  652. if dirty and not args.force:
  653. print(f"[!] 工作区有 {len(dirty)} 处未提交改动,先处理再拉:")
  654. for d in dirty[:8]:
  655. print(f" {d}")
  656. if len(dirty) > 8:
  657. print(f" ... 还有 {len(dirty)-8} 处")
  658. print("\n 提交:git add -A && git commit -m \"sync: ...\"")
  659. print(" 或暂存:git stash")
  660. print(f" 或强制拉(会 rebase 上去):kb.py pull --force")
  661. return 1
  662. before = git("rev-parse", "HEAD").stdout.strip()
  663. print(f"从 {remote}/{branch} 拉取…")
  664. r = git("pull", "--rebase", remote, branch)
  665. out = (r.stdout + r.stderr).strip()
  666. if r.returncode != 0:
  667. print("[ERR] 拉取失败:")
  668. for line in out.splitlines()[:12]:
  669. print(" ", line)
  670. if "conflict" in out.lower() or "CONFLICT" in out:
  671. print("\n 有冲突。解法见 GIT.md「冲突怎么解」:")
  672. print(" manifest.json → git checkout --ours 后跑 kb.py repair")
  673. print(" */_index.md → git checkout --theirs 后跑 kb.py graph")
  674. return 1
  675. after = git("rev-parse", "HEAD").stdout.strip()
  676. if before == after:
  677. print("已是最新,没有新提交。")
  678. return cmd_scan(argparse.Namespace(json=False))
  679. # 报远端这次带来了什么
  680. diff = git("diff", "--name-status", "-M", f"{before}..{after}", "--", "00-原材料/").stdout
  681. add, mod, dele, mov = [], [], [], []
  682. for line in diff.splitlines():
  683. parts = line.split("\t")
  684. if len(parts) < 2:
  685. continue
  686. st, path = parts[0], parts[-1]
  687. if st.startswith("A"):
  688. add.append(path)
  689. elif st.startswith("M"):
  690. mod.append(path)
  691. elif st.startswith("D"):
  692. dele.append(path)
  693. elif st.startswith("R"):
  694. mov.append((parts[1], parts[2]))
  695. n_commits = git("rev-list", "--count", f"{before}..{after}").stdout.strip()
  696. print(f"拉到 {n_commits} 个新提交。原材料变化:"
  697. f"新增 {len(add)} · 修改 {len(mod)} · 删除 {len(dele)} · 移动 {len(mov)}")
  698. for x in add:
  699. print(f" [NEW] {x}")
  700. for x in mod:
  701. print(f" [CHANGED] {x}")
  702. for x in dele:
  703. print(f" [DELETED] {x}")
  704. for a, b in mov:
  705. print(f" [MOVED] {a} -> {b}")
  706. if any(x.lower().endswith((".pdf", ".mp4", ".mov", ".zip")) for x in add):
  707. print("\n 提示:新增了可能很大的文件。如果仓库用了 git-lfs,")
  708. print(" 确认 lfs 对象已拉下来(git lfs pull),否则整理时读不到内容。")
  709. # 页面上可能也带了变化(别人整理的结果)
  710. page_diff = git("diff", "--stat", f"{before}..{after}", "--", "10-知识库/").stdout
  711. if page_diff.strip():
  712. print(f"\n 知识库页面也有更新:")
  713. for line in page_diff.strip().splitlines()[-6:]:
  714. print(f" {line.strip()}")
  715. print("\n 下一步:让模型按 kb-sync 的流程处理上面这些变化。")
  716. return 0
  717. # ---------------------------------------------------------------- repair
  718. def cmd_repair(args):
  719. """从磁盘现状 + 页面 frontmatter 反推 manifest。
  720. 什么时候用:
  721. 1. git 合并把 manifest.json 搞冲突了
  722. 2. manifest 被误删、或从别处只拷了页面没拷 _meta
  723. 3. 换机器接手
  724. 能恢复什么:每个源的指纹、它派生了哪些页、已整理/未整理状态。
  725. 恢复不了什么:整理的具体时间(只能留空)、删除过的源记录(tombstone 会丢)。
  726. """
  727. cur = collect_raw()
  728. derived = {}
  729. # 活跃页 + 归档页都算 —— 归档页的 sources 仍然说明"这个源整理过"
  730. scan_roots = [WIKI / d for d in DIRS]
  731. if ARCHIVE.exists():
  732. scan_roots.append(ARCHIVE)
  733. for root in scan_roots:
  734. if not root.exists():
  735. continue
  736. for p in sorted(root.rglob("*.md")):
  737. if p.name.startswith("_"):
  738. continue
  739. fm, _ = read_page(p)
  740. for s in (fm.get("sources") or []):
  741. near = p if p.parent != ARCHIVE else ARCHIVE / p.name
  742. derived.setdefault(str(s), []).append(rel(near, WIKI))
  743. old = load_json(MANIFEST, manifest_skeleton())
  744. files, orphan_disk, orphan_pages = {}, [], []
  745. for r, meta in cur.items():
  746. ps = sorted(set(derived.pop(r, [])))
  747. prev = old.get("files", {}).get(r, {})
  748. files[r] = {
  749. "size": meta["size"], "mtime_ns": meta["mtime_ns"],
  750. "sha256": meta["sha256"], "id": prev.get("id") or short_id(r),
  751. "status": "ingested" if ps else "pending",
  752. "ingested_at": prev.get("ingested_at"),
  753. "pages": ps,
  754. }
  755. if not ps:
  756. orphan_disk.append(r)
  757. # 页面引用了磁盘上不存在的源
  758. for s, ps in derived.items():
  759. orphan_pages.extend(ps)
  760. man = {"version": 2, "root": "00-原材料", "updated_at": now(),
  761. "files": files, "tombstones": old.get("tombstones", [])}
  762. dump_json(MANIFEST, man)
  763. log_human("repair", f"重建 manifest:{len(files)} 个源",
  764. f"- 已整理 {len(files)-len(orphan_disk)} 个,未整理 {len(orphan_disk)} 个")
  765. print(f"[OK] manifest 已重建:{len(files)} 个源")
  766. print(f" 已整理 {len(files)-len(orphan_disk)} · 未整理 {len(orphan_disk)}")
  767. if orphan_disk:
  768. print(f"\n 磁盘上有、但没派生任何页(下次同步会当新的处理):")
  769. for x in orphan_disk[:10]:
  770. print(f" · {x}")
  771. if len(orphan_disk) > 10:
  772. print(f" ... 还有 {len(orphan_disk)-10} 个")
  773. if orphan_pages:
  774. print(f"\n ⚠️ 有 {len(set(orphan_pages))} 个页面引用了磁盘上已不存在的源")
  775. print(f" → 跑 kb.py lint 看 DANGLING_SOURCE,再决定恢复文件还是 retract")
  776. # ---------------------------------------------------------------- check
  777. def cmd_check(args):
  778. """聚合器:结构检查(本文件)+ 内容检查(check-content.py)。
  779. 两个检查器各自独立可跑,这里是给"提交前一次过"用的。"""
  780. cmd_lint(argparse.Namespace(json=False))
  781. print("\n" + "-" * 56)
  782. script = ROOT / "90-工具" / "check-content.py"
  783. if not script.exists():
  784. print("(未找到 check-content.py,跳过内容检查)")
  785. return 0
  786. r = subprocess.run([sys.executable, str(script)],
  787. capture_output=True, text=True,
  788. encoding="utf-8", errors="replace")
  789. print(r.stdout.strip() or r.stderr.strip()[:800])
  790. return 0 if r.returncode == 0 else 1
  791. # ---------------------------------------------------------------- main
  792. def main():
  793. ap = argparse.ArgumentParser(prog="kb", description="做课系统知识库工具链")
  794. sub = ap.add_subparsers(dest="cmd")
  795. sub.add_parser("status", help="总览")
  796. sub.add_parser("guide", help="给 AI 的自描述指南")
  797. s = sub.add_parser("scan", help="扫描原料目录,输出差异")
  798. s.add_argument("--json", action="store_true")
  799. m = sub.add_parser("mark", help="登记某源已整理")
  800. m.add_argument("path")
  801. m.add_argument("--pages", default="")
  802. r = sub.add_parser("retract", help="源删除后回滚")
  803. r.add_argument("path")
  804. mv = sub.add_parser("move", help="登记源文件移动")
  805. mv.add_argument("old")
  806. mv.add_argument("new")
  807. sub.add_parser("graph", help="重建关系图与索引")
  808. l = sub.add_parser("lint", help="结构健康检查")
  809. l.add_argument("--json", action="store_true")
  810. sub.add_parser("check", help="全量检查:结构 + 内容")
  811. sub.add_parser("repair", help="从磁盘和页面反推 manifest(git 冲突后用)")
  812. pl = sub.add_parser("pull", help="从远端拉最新并报告原材料变化")
  813. pl.add_argument("--remote", default=None)
  814. pl.add_argument("--force", action="store_true", help="工作区有改动也拉")
  815. args = ap.parse_args()
  816. fn = {"status": cmd_status, "guide": cmd_guide, "scan": cmd_scan, "mark": cmd_mark,
  817. "retract": cmd_retract, "move": cmd_move, "graph": cmd_graph,
  818. "lint": cmd_lint, "check": cmd_check, "repair": cmd_repair,
  819. "pull": cmd_pull}.get(args.cmd)
  820. if not fn:
  821. ap.print_help()
  822. return 0
  823. return fn(args) or 0
  824. if __name__ == "__main__":
  825. sys.exit(main())