# -*- coding: utf-8 -*- """ check-content.py — 知识库内容质量检查(只管文风与出处,不管结构) 为什么单独一个文件: kb.py lint 查的是「结构对不对」(死链、孤儿、缺字段)。结构对了不等于内容能用。 这个脚本查的是「内容能不能直接拿去做课」——AI 腔、长句、没出处的数字。 两者职责不同,混在一起以后没人敢改。参照 HowToLiveBetter 的 tools/check-plain.mjs。 怎么用: python 90-工具/check-content.py 列出全部问题,有问题退出码 1(CI / 提交前用) python 90-工具/check-content.py --stat 只按类型计数,不列明细 python 90-工具/check-content.py --path 10-知识库/topics 只查某个目录 查三样: ① AI 腔 元叙述、空转加强词、段尾升华、万能开头。词表见 HARD / SOFT。 ② 长句 超过 60 字的句子。读者是做课的同事,不是审稿人。 ③ 没出处的数字 带 万/亿/%/倍/元 的数字,同一段落里必须能找到脚注、双链或「来源」字样。 做课翻车都翻在数字上,结构再完整,一个凭空来的 42% 就毁一门课。 不查什么(故意不查,免得误报多了没人看): - 「3 天 2 晚」「40 人」这类课程设计描述,它们不是统计口径,本来也不需要出处 - 「第 3 节第 5 条」这类交叉引用 - 代码块、表格、引用块、标题行 """ import sys, re, json, argparse from pathlib import Path if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") ROOT = Path(__file__).resolve().parent.parent WIKI = ROOT / "10-知识库" DIRS = ["sources", "topics", "entities", "modules"] MAX_SENT = 60 # ① AI 腔:硬伤,见到就改。只收确实反复出现的,宁漏勿滥。 HARD = [ (r"本(页|文|节|章|条|条目)(主要)?(介绍|讲|说|阐述)", "元叙述"), (r"^以上(就)?是", "元叙述"), (r"(这|本)(一)?(条|页|节)(主要)?(讲|说|解决)", "元叙述"), (r"值得注意的是", "空转加强词"), (r"不难发现|不言而喻|众所周知", "空转加强词"), (r"总而言之|综上所述|总的来说|归根结底", "空转加强词"), (r"本质上(就是|是)", "空转加强词"), (r"(在当今|在当前)[^。]{0,20}(背景|形势|环境|时代)下", "万能开头"), (r"随着[^。]{0,15}的(不断|快速|飞速|持续)(发展|进步|推进)", "万能开头"), (r"让我们一起|未来可期|相信在不久的将来|共同拥抱", "段尾升华"), ] # ② 比喻与套话:建议改,不进退出码。它们在商业语境里有时是正常词,机器判断不了。 SOFT = ["组合拳", "最后一公里", "护城河", "抓住抓手", "顶层设计", "颗粒度对齐", "形成闭环", "打通", "赋能", "生态位", "飞轮"] # ③ 需要出处的数字:只认统计口径的单位 NUM_RE = re.compile(r"(\d[\d,]*(?:\.\d+)?)\s*(万|亿|%|%|倍|元)") SRC_MARK = re.compile(r"\[\^|\[\[|来源|出处|http|注:|待核实|推测") SENT_SPLIT = re.compile(r"[。!?;\n]+") def iter_pages(base: Path): for d in DIRS: p = base / d if not p.exists(): continue for f in sorted(p.rglob("*.md")): if f.name.startswith("_"): continue yield f def strip_front_matter(text: str): if text.startswith("---"): m = re.match(r"^---\r?\n.*?\r?\n---\r?\n?", text, re.S) if m: return text[m.end():] return text def check_file(f: Path): """返回 [(kind, lineno, detail)]""" raw = f.read_text(encoding="utf-8", errors="replace") body = strip_front_matter(raw) offset = raw[:len(raw) - len(body)].count("\n") lines = body.splitlines() out = [] in_code = False para_buf, para_start = [], 0 # 段落缓冲,用于数字出处判断 def flush_para(): if not para_buf: return text = " ".join(para_buf) has_src = bool(SRC_MARK.search(text)) for m in NUM_RE.finditer(text): if not has_src: out.append(("数字无出处", para_start, f"{m.group(1)} {m.group(2)}")) for i, line in enumerate(lines): ln = offset + i + 1 s = line.strip() if s.startswith("```"): in_code = not in_code flush_para(); para_buf = [] continue if in_code: continue if not s: flush_para(); para_buf = [] continue # 跳过不查的行 if s.startswith("#") or s.startswith("|") or s.startswith(">") or s.startswith("