weflow-cli 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/OPERATIONS.md +42 -6
  3. package/README.en.md +3 -3
  4. package/README.md +3 -3
  5. package/bin/weflow-cli.ts +3 -1
  6. package/dist/bin/weflow-cli.js +3 -1
  7. package/dist/bin/weflow-cli.js.map +1 -1
  8. package/dist/src/panel/server.js +1 -1
  9. package/dist/src/panel/server.js.map +1 -1
  10. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  11. package/dist/src/services/assistantPrivacy.js +7 -2
  12. package/dist/src/services/assistantPrivacy.js.map +1 -1
  13. package/dist/src/services/assistantService.d.ts.map +1 -1
  14. package/dist/src/services/assistantService.js +10 -1
  15. package/dist/src/services/assistantService.js.map +1 -1
  16. package/dist/src/services/assistantTools.d.ts +12 -0
  17. package/dist/src/services/assistantTools.d.ts.map +1 -1
  18. package/dist/src/services/assistantTools.js +29 -3
  19. package/dist/src/services/assistantTools.js.map +1 -1
  20. package/dist/src/services/wereadService.d.ts +33 -6
  21. package/dist/src/services/wereadService.d.ts.map +1 -1
  22. package/dist/src/services/wereadService.js +27 -2
  23. package/dist/src/services/wereadService.js.map +1 -1
  24. package/docs/BRANCHES.md +13 -4
  25. package/docs/PROJECT_STATE.md +299 -299
  26. package/package.json +1 -1
  27. package/resources/panel/main.cjs +43 -7
  28. package/resources/panel/mascot-thinking.png +0 -0
  29. package/resources/panel/panel.css +424 -407
  30. package/resources/panel/preload.cjs +79 -72
  31. package/resources/panel/renderer.js +470 -441
  32. package/scripts/_utils.py +23 -1
  33. package/scripts/backfill_articles.py +438 -438
  34. package/scripts/compile_wiki.py +18 -17
  35. package/scripts/wiki_lint.py +429 -397
  36. package/src/panel/server.ts +424 -424
  37. package/src/services/assistantPrivacy.ts +7 -2
  38. package/src/services/assistantService.ts +11 -1
  39. package/src/services/assistantTools.ts +33 -3
  40. package/src/services/wereadService.ts +43 -7
  41. package/resources/panel/mascot-focus.png +0 -0
@@ -1,397 +1,429 @@
1
- #!/usr/bin/env python3
2
- """
3
- 知识库体检 — 概念页的死链、孤儿与空页。**全本地:不联网、不调用任何模型。**
4
-
5
- 用法:
6
- python scripts/wiki_lint.py # 人看的报告
7
- python scripts/wiki_lint.py --json # 机器读的
8
-
9
- 查四样(前两样就是 D-049 里记着"没做"的那两件事):
10
- - **死链**:页面里的 `[[X]]` 指向一个不存在的页面。概念页之间互链,以及 `## 来源` 那节
11
- 指回卡片——卡片被删或改名,链接就断了,而**看页面本身看不出来**。
12
- - **孤儿**:没有任何页面链接到它。多半意味着它聚出来的概念名跟别处对不上
13
- (同一个东西写成两个名字),是"该合并"的信号。
14
- - **空页**:没有定义也没有要点(正文几乎为空)。模型那次返回不完整时会留下这种。
15
- - **同名**:两页标题相同(文件名不同)——会把同一个概念劈成两份。
16
-
17
- 为什么值得有:生成那一步(`wiki compile`)**不会**告诉你这些。它按概念名写文件,
18
- 写完就算成功;而"写进去的东西连不连得起来"是另一件事。
19
- """
20
- import argparse
21
- import json
22
- import os
23
- import re
24
- import sys
25
- from pathlib import Path
26
-
27
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
- from _utils import CONCEPT_DIRS, parse_frontmatter # noqa: E402
29
-
30
- DEFAULT_PAGES_DIR = 'output/wechat-vault/Wiki/Concepts'
31
- VAULT_ROOT = 'output/wechat-vault'
32
- # 体检要看的**全部**概念页目录(Vault 相对路径那份清单在 `_utils.CONCEPT_DIRS`)。
33
- VAULT_CONCEPT_DIRS = tuple(str(Path(VAULT_ROOT) / d) for d in CONCEPT_DIRS)
34
- # 链接可能指向的「材料」层(与卡片目录一起参与可解析性判断)
35
- MATERIAL_DIRS = tuple(str(Path(VAULT_ROOT) / d) for d in
36
- ('002_Literature', '001_Daily', 'Sources/Chat', 'Sources/WeChat'))
37
- # 卡片在哪(`## 来源` 那节的 `[[<相对路径>.md]]` 要从这些目录里找)。
38
- # **用通配而不是写死目录名**:今天是文章线与对话线,明天再加一条来源(收藏线就是这么加的),
39
- # 写死的话体检会**静默漏算**那条线的入链——而漏算的表现是"每张新页都是孤儿"。
40
- CARD_DIRS = tuple(str(path) for path in sorted(Path('output').glob('*-notes'))) or (
41
- 'output/article-notes', 'output/chat-notes')
42
- # 少于这个字数就算空页(概念页有定义+要点,正常几百字)
43
- MIN_BODY_CHARS = 80
44
-
45
- _LINK = re.compile(r'\[\[([^\]]+)\]\]')
46
-
47
-
48
- def compile_frontmatter_topic(frontmatter: dict) -> str:
49
- """页面的 `topics`(生成时写进去的)取第一个——体检只关心它退不退化。"""
50
- raw = frontmatter.get('topics') or frontmatter.get('topic') or ''
51
- if isinstance(raw, (list, tuple)):
52
- raw = raw[0] if raw else ''
53
- return str(raw).strip().strip('[]').strip()
54
-
55
-
56
- def extract_links(body: str) -> list:
57
- """页面里所有 `[[…]]` 的名字。
58
-
59
- **故意不过滤**(不像生成那侧会滤掉路径与主题词):体检查的是"链接连不连得起来",
60
- 滤掉就等于把断掉的链接藏起来——那正好是它要找的东西。
61
- """
62
- return [name.strip() for name in _LINK.findall(body or '') if name.strip()]
63
-
64
-
65
- def links_by_section(body: str) -> list:
66
- """每条链接连同它所在的 `## 小节` —— 因为**两种死链的意义完全不同**。
67
-
68
- `## 相关概念` 里的链接是模型提出的"这个概念还该跟谁连",而只有被引用最多的那些
69
- 才真的建了页。所以那一节里的"还没有页"是**扩张候选**,不是坏掉的东西;
70
- 而 `## 来源` 里的 `[[<卡片>.md]]` 断了就是**真断了**(卡片被删或改名)。
71
- 一视同仁地报"40 条死链",看的人只会以为这里烂掉了。
72
- """
73
- section, out = '', []
74
- for line in (body or '').split('\n'):
75
- if line.strip().startswith('## '):
76
- section = line.strip()[3:].strip()
77
- continue
78
- for name in _LINK.findall(line):
79
- name = name.strip()
80
- if name:
81
- out.append((section, name))
82
- return out
83
-
84
-
85
- def collect_card_stems(card_dirs) -> set:
86
- """卡片文件名(不带 `.md`)。`## 来源` 那节的链接就是这些名字。"""
87
- stems = set()
88
- for directory in card_dirs:
89
- root = Path(directory)
90
- if root.exists():
91
- stems |= {p.stem for p in root.rglob('*.md')}
92
- return stems
93
-
94
-
95
- def collect_cards(card_dirs) -> list:
96
- """卡片里的链接——它们才是概念页**真正的**入链(每张卡都链着它提炼出的概念)。"""
97
- links = []
98
- for directory in card_dirs:
99
- root = Path(directory)
100
- if not root.exists():
101
- continue
102
- for path in root.rglob('*.md'):
103
- try:
104
- _, body = parse_frontmatter(path.read_text(encoding='utf-8'))
105
- except Exception:
106
- continue
107
- links.extend(extract_links(body))
108
- return links
109
-
110
-
111
- def resolve(name: str, page_stems: set, card_dirs, card_stems=None) -> bool:
112
- """这个链接指向的东西存在吗。
113
-
114
- - 带路径或以 `.md` 结尾的 → 是**来源卡片**,去卡片目录里找;
115
- - 否则 → ⚠️ **既可能是概念,也可能是卡片**。
116
-
117
- 这里原来写的是"裸名字就是概念",而 `## 来源` 那节的链接**就是裸的卡片名**
118
- (`compile_wiki --fix-source-links` 把 `.md` 去掉了,与全库其余链接统一)。
119
- 于是那条判据把 7,872 条**在 Obsidian 里解析得好好的**链接全报成了断链。
120
-
121
- `card_stems` 为空时退回旧行为,好让调用方不必一次性全改。
122
- """
123
- if name.endswith('.md') or '/' in name:
124
- target = name if name.endswith('.md') else name + '.md'
125
- return any((Path(d) / target).exists() for d in card_dirs)
126
- if name in page_stems:
127
- return True
128
- return name in (card_stems or set())
129
-
130
-
131
- RELATED_SECTIONS = ('相关概念', '相关主题', '相关')
132
-
133
-
134
- def inspect(pages: list, exists, card_links=()) -> dict:
135
- """纯函数:给定页面、"链接是否存在"的判据、以及卡片里的入链,报出问题。
136
-
137
- - `broken`:**真断了的**链接——指向不存在的卡片(卡片删了/改名了);
138
- - `aspirational`:`## 相关概念` 那节里指向"还没建页"的概念——**扩张候选,不是错误**;
139
- - `orphans`:连卡片都没提到过它的页面(卡片链接也算入链,否则每张页都会被误报成孤儿);
140
- - `empty`:没有定义也没有要点;
141
- - `duplicateTitles`:同名页。
142
- """
143
- stems = {page['stem'] for page in pages}
144
- inbound = {stem: 0 for stem in stems}
145
- for name in card_links: # 卡片 → 概念,这是主要入链
146
- if not (name.endswith('.md') or '/' in name) and name in inbound:
147
- inbound[name] += 1
148
- broken, aspirational, empty = [], [], []
149
- for page in pages:
150
- for section, name in links_by_section(page['body']):
151
- if exists(name):
152
- if not (name.endswith('.md') or '/' in name) and name in inbound:
153
- inbound[name] += 1
154
- continue
155
- entry = {'page': page['stem'], 'target': name, 'section': section}
156
- if any(section.startswith(prefix) for prefix in RELATED_SECTIONS):
157
- aspirational.append(entry)
158
- else:
159
- broken.append(entry)
160
- if len(page['body'].strip()) < MIN_BODY_CHARS:
161
- empty.append({'page': page['stem'], 'chars': len(page['body'].strip())})
162
- orphans = sorted(stem for stem, count in inbound.items() if count == 0)
163
- titles = {}
164
- for page in pages:
165
- titles.setdefault(page['title'] or page['stem'], []).append(page['stem'])
166
- duplicates = {title: stems for title, stems in titles.items() if len(stems) > 1}
167
- return {'pages': len(pages), 'broken': broken, 'aspirational': aspirational,
168
- 'orphans': orphans, 'empty': empty, 'duplicateTitles': duplicates,
169
- 'degenerateFields': degenerate_fields(pages),
170
- 'nearDuplicates': near_duplicate_titles(pages)}
171
-
172
-
173
- # 一个字段有 ≥ 这么多页、且某一个值占了 ≥ 这个比例,就当成"退化了"(等于没携带信息)
174
- DEGENERATE_MIN_PAGES = 5
175
- DEGENERATE_RATIO = 0.8
176
-
177
-
178
- def longest_common_run(left: str, right: str) -> int:
179
- """两串的最长公共子串长度(中文里"同一件事被转发多次"的信号)。"""
180
- if not left or not right:
181
- return 0
182
- previous = [0] * (len(right) + 1)
183
- best = 0
184
- for i in range(1, len(left) + 1):
185
- current = [0] * (len(right) + 1)
186
- for j in range(1, len(right) + 1):
187
- if left[i - 1] == right[j - 1]:
188
- current[j] = previous[j - 1] + 1
189
- best = max(best, current[j])
190
- previous = current
191
- return best
192
-
193
-
194
- DUP_RUN_CHARS = 5 # 公共子串至少这么长
195
- DUP_COVER_RATIO = 0.4 # 而且占较短标题的这么一大部分
196
-
197
-
198
- def near_duplicate_titles(pages: list) -> list:
199
- """标题高度相似的页——**同一件事被多个公众号转发**的痕迹。只报告,不自动去重。
200
-
201
- 这条判据**有两处已知的失效,都是 2026-09-28 量出来的**,改判据前先看这里:
202
-
203
- 1. **它抓不住它自己当初的动机用例**。写这个函数是因为《打虎!陈勇被查》与
204
- 《中建集团副总经理陈勇被查》是同一件事 —— 但那是"同一人、换了标题",两串的最长公共
205
- 子串只有 4 字("陈勇被查"),**任何子串判据都抓不到**。当年那段注释把这条写成了
206
- "实测那批里有一组四条",是**不成立的**:实测那四个人两两之间只有 2 对满足判据。
207
- 2. **它在今天的规模上退化成了噪音**。判据(公共子串 ≥5 字、且占较短标题 ≥40%)是
208
- 5,062 张页、长中文新闻标题时定的;现在 22,374 张页里有很多**短名字**
209
- (`AI Agent开发框架` 与 `生物信息学LLM Agent综述` 共享 "Agent"),于是报出
210
- **424,697 组**,前几条全是这类巧合。这个数不是"转发灌水",是判据失去了分辨力。
211
-
212
- 抽 20 组复核判据本身都成立(没有假阳性),所以问题在**门槛**不在实现。要收紧的话,
213
- "规范化后一个标题包含另一个(短的 ≥8 字)"实测 1,234 组、看着都是真的相似
214
- (`世界人工智能大会` / `2026世界人工智能大会`),代价是**换一类漏检**(同样抓不到第 1 条
215
- 那种换标题的写法)。哪个更合用于"关系图谱该不该合并",得由人定 —— 所以这里不动,
216
- 只把失效写在看得见的地方。
217
- """
218
- # **按 5-gram 分桶,而不是两两比。** 判据是"最长公共子串 ≥ DUP_RUN_CHARS",那**等价于**
219
- # "两者共享一个这么长的子串" —— 所以只把共享某个 5-gram 的页放进同一个桶里两两比,
220
- # **结果与全量两两比完全一样**,只是不再做那 2.5 亿次注定失败的比较。
221
- # 实测:5,062 张页全量要 1-2 分钟;22,445 张是 20 倍的对数(几十分钟),分桶后几秒。
222
- index = {}
223
- for page in pages:
224
- name = page['title'] or page['stem']
225
- if len(name) < DUP_RUN_CHARS:
226
- # 比 5 字符还短的名字不可能满足"公共子串 ≥5"(子串不可能比它自己长),
227
- # 原判据对它们也从不命中
228
- continue
229
- for gram in {name[i:i + DUP_RUN_CHARS] for i in range(len(name) - DUP_RUN_CHARS + 1)}:
230
- index.setdefault(gram, []).append(page)
231
- groups, seen = [], set()
232
- for bucket in index.values():
233
- for i, left in enumerate(bucket):
234
- for right in bucket[i + 1:]:
235
- pair = (id(left), id(right))
236
- if pair in seen:
237
- continue # 一对可能共享多个 5-gram,会落进多个桶
238
- seen.add(pair)
239
- a, b = left['title'] or left['stem'], right['title'] or right['stem']
240
- run = longest_common_run(a, b)
241
- if run >= DUP_RUN_CHARS and run >= DUP_COVER_RATIO * min(len(a), len(b)):
242
- groups.append([a, b])
243
- return groups
244
-
245
-
246
- def degenerate_fields(pages: list) -> dict:
247
- """**退化字段**:取值几乎只有一种。
248
-
249
- 实测:39 张概念页的 `topics` **全是「学术」**——上游那个主题分类器把最近这批语料
250
- 全扔进一个桶(另一处独立测量:标题像新闻的 224 篇里 40 篇也被标成「学术」)。
251
- 一个字段整列同一个值,看起来像"有元数据",实际什么也没说;而**它会被照着信**。
252
- 这条检查的作用不是修分类器(那是日报那条线的事),而是让这种退化**在页面上看得见**。
253
- """
254
- if len(pages) < DEGENERATE_MIN_PAGES:
255
- return {}
256
- out = {}
257
- # 只查**概念页上真有、而且"看起来像元数据"**的字段。别去查 `source`——概念页用的是复数
258
- # `sources`,查一个不存在的字段只会报出一句"(全空)",那是拿噪音换注意力。
259
- for field in ('topic',):
260
- values = [str(page.get(field) or '').strip() for page in pages]
261
- values = [value for value in values if value]
262
- if not values:
263
- continue
264
- counts = {}
265
- for value in values:
266
- counts[value] = counts.get(value, 0) + 1
267
- top, count = max(counts.items(), key=lambda item: item[1])
268
- if count / len(pages) >= DEGENERATE_RATIO:
269
- out[field] = {'值': top, '页数': count, '总页数': len(pages)}
270
- return out
271
-
272
-
273
- def resolvable_names(pages: list) -> set:
274
- """体检认为"指向它就算解析成功"的所有名字:页的 stem **加上别名**。
275
-
276
- `--merge-duplicates` 把 `GPT-5.6` 并进 `GPT 5.6` 时,就是靠 aliases 让旧链接不断的
277
- (实测库里 98 条),Obsidian 认它。体检要是不认,会把这些名字报成"还没建页、
278
- 建议再跑 compile 提高 --limit" —— 而它们已经有页了,那是**误导**。
279
- """
280
- names = set()
281
- for page in pages:
282
- names.add(page['stem'])
283
- names |= set(page.get('aliases') or [])
284
- return names
285
-
286
-
287
- def collect(pages_dir: str, card_dirs) -> list:
288
- pages = []
289
- for path in sorted(Path(pages_dir).glob('*.md')):
290
- if path.name == '00-Overview.md': # 索引页不是概念,它引用的东西另算
291
- continue
292
- frontmatter, body = parse_frontmatter(path.read_text(encoding='utf-8'))
293
- raw_aliases = frontmatter.get('aliases') or []
294
- if isinstance(raw_aliases, str):
295
- raw_aliases = [x for x in raw_aliases.strip('[]').split(',')]
296
- pages.append({'stem': path.stem,
297
- 'topic': compile_frontmatter_topic(frontmatter),
298
- 'title': str(frontmatter.get('title') or '').strip('"'),
299
- # **别名也算"指向这一页的名字"**:`--merge-duplicates` 把
300
- # `GPT-5.6` 并进 `GPT 5.6` 时就是靠 aliases 让旧链接不断的,
301
- # Obsidian 认它。体检不认的话,会把这些名字报成"还没建页、
302
- # 建议再跑 compile" —— 而它们已经有页了。
303
- 'aliases': [str(a).strip().strip('"').strip("'") for a in raw_aliases],
304
- 'links': extract_links(body),
305
- 'body': body})
306
- return pages
307
-
308
-
309
- def main():
310
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
311
- parser = argparse.ArgumentParser(description='知识库体检(本地,不联网、不调用模型)')
312
- # **默认体检全部概念页目录**(文章一个、聊天一个,见 `_utils.CONCEPT_DIRS`)。
313
- # 这条一旦只读一个,分出去的那一半就会"体检通过"——因为它根本没被看。
314
- parser.add_argument('--dir', action='append', default=[],
315
- help='概念页目录(可重复;默认 %s)'
316
- % '、'.join(VAULT_CONCEPT_DIRS))
317
- parser.add_argument('--json', action='store_true')
318
- args = parser.parse_args()
319
-
320
- dirs = args.dir or VAULT_CONCEPT_DIRS
321
- existing = [d for d in dirs if Path(d).exists()]
322
- if not existing:
323
- message = ('没有概念页(%s 都不存在)——先跑 article-notes / chat-notes,'
324
- '再跑 wiki compile' % '、'.join(dirs))
325
- print(json.dumps({'success': False, 'error': message}, ensure_ascii=False)
326
- if args.json else message)
327
- return 1
328
-
329
- pages = [page for d in existing for page in collect(d, CARD_DIRS)]
330
- card_links = collect_cards(CARD_DIRS)
331
- # **链接可能指向材料,而不是卡片。** `## 来源` 指的是「这一页是从哪来的」:
332
- # 文章线指向 `002_Literature` 里的阅读笔记,聊天线指向 `Sources/Chat` 里的卡。
333
- # 只查卡片目录会把这类全报成断链——实测 1,950 条,而它们在 Obsidian 里都是好的
334
- # (两边文件名本来就不同:卡片保留完整标题,阅读笔记截到 50 字)。
335
- card_stems = collect_card_stems(tuple(CARD_DIRS) + MATERIAL_DIRS)
336
- report = inspect(pages,
337
- lambda name: resolve(name, resolvable_names(pages), CARD_DIRS, card_stems),
338
- card_links)
339
- report['success'] = True
340
- report['pagesDir'] = existing
341
- report['cardLinks'] = len(card_links)
342
-
343
- if args.json:
344
- print(json.dumps(report, ensure_ascii=False, indent=2))
345
- return 0
346
-
347
- print('概念页 %d 张(卡片侧入链 %d 条)' % (report['pages'], report['cardLinks']))
348
- if report['broken']:
349
- print('\n**断链 %d 条**(指向不存在的卡片——卡片删了或改名了):' % len(report['broken']))
350
- for item in report['broken'][:10]:
351
- print(' %s → %s' % (item['page'], item['target']))
352
- print(' 改名字就统一,删掉了就把那行去掉')
353
- if report['orphans']:
354
- print('\n孤儿页 %d 张(连卡片都没提到过它):' % len(report['orphans']))
355
- for name in report['orphans'][:10]:
356
- print(' %s' % name)
357
- print(' 多半意味着它已经不被任何来源支持了')
358
- if report['empty']:
359
- print('\n空页 %d 张(没有定义或要点):' % len(report['empty']))
360
- for item in report['empty'][:10]:
361
- print(' %s(%d 字)' % (item['page'], item['chars']))
362
- print(' 重新跑一次 wiki compile 通常就好了(那次模型返回不完整)')
363
- if report['duplicateTitles']:
364
- print('\n同名页 %d 组:' % len(report['duplicateTitles']))
365
- for title, stems in list(report['duplicateTitles'].items())[:5]:
366
- print(' %s ← %s' % (title, '、'.join(stems)))
367
- if report['aspirational']:
368
- names = sorted({item['target'] for item in report['aspirational']})
369
- print('\n还没建页的相关概念 %d 个(**扩张候选,不是错误**):%s'
370
- % (len(names), '、'.join(names[:12])))
371
- print(' 想要它们就再跑 wiki compile(提高 --limit),或把它们当下一步的线索')
372
- if report.get('nearDuplicates'):
373
- count = len(report['nearDuplicates'])
374
- # 这行必须带劝阻:判据在今天的规模上几乎对谁都成立(2026-09-28 实测 424,697 组,
375
- # 前几条是 `AI Agent开发框架` / `生物信息学LLM Agent综述` 这种共享 "Agent" 的巧合)。
376
- # 不写清楚,这个数会被当成"有 42 万对重复该合并"。
377
- print('\n标题高度相似的页 %d 组(**判据偏松,规模一大就失去分辨力,当成线索别当结论**):'
378
- % count)
379
- for pair in report['nearDuplicates'][:4]:
380
- print(' %s / %s' % (pair[0][:28], pair[1][:28]))
381
- if count > 10000:
382
- print(' 判据是"公共子串 ≥5 字且占较短标题 ≥40%",短名字一多就会互相命中;'
383
- '真要按它去重,先把门槛收紧(见 wiki_lint.near_duplicate_titles 的说明)')
384
- else:
385
- print(' 只报告不去重:不同公众号的写法可能各有信息,删哪张该由人定')
386
- if report.get('degenerateFields'):
387
- print('\n退化字段(整列几乎同一个值,等于没携带信息):')
388
- for field, info in report['degenerateFields'].items():
389
- print(' %s: %s(%s/%s 页)' % (field, info['值'], info['页数'], info.get('总页数', report['pages'])))
390
- print(' 多半是上游分类器把整批语料扔进了一个桶——不改分类器的话,别信这个字段')
391
- if not any((report['broken'], report['orphans'], report['empty'], report['duplicateTitles'])):
392
- print('\n没有需要修的问题(断链/孤儿/空页/同名)')
393
- return 0
394
-
395
-
396
- if __name__ == '__main__':
397
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ """
3
+ 知识库体检 — 概念页的死链、孤儿与空页。**全本地:不联网、不调用任何模型。**
4
+
5
+ 用法:
6
+ python scripts/wiki_lint.py # 人看的报告
7
+ python scripts/wiki_lint.py --json # 机器读的
8
+
9
+ 查四样(前两样就是 D-049 里记着"没做"的那两件事):
10
+ - **死链**:页面里的 `[[X]]` 指向一个不存在的页面。概念页之间互链,以及 `## 来源` 那节
11
+ 指回卡片——卡片被删或改名,链接就断了,而**看页面本身看不出来**。
12
+ - **孤儿**:没有任何页面链接到它。多半意味着它聚出来的概念名跟别处对不上
13
+ (同一个东西写成两个名字),是"该合并"的信号。
14
+ - **空页**:没有定义也没有要点(正文几乎为空)。模型那次返回不完整时会留下这种。
15
+ - **同名**:两页标题相同(文件名不同)——会把同一个概念劈成两份。
16
+
17
+ 为什么值得有:生成那一步(`wiki compile`)**不会**告诉你这些。它按概念名写文件,
18
+ 写完就算成功;而"写进去的东西连不连得起来"是另一件事。
19
+ """
20
+ import argparse
21
+ import json
22
+ import os
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+ from _utils import CONCEPT_DIRS, normalize_concept_name, parse_frontmatter # noqa: E402
29
+
30
+ DEFAULT_PAGES_DIR = 'output/wechat-vault/Wiki/Concepts'
31
+ VAULT_ROOT = 'output/wechat-vault'
32
+ # 体检要看的**全部**概念页目录(Vault 相对路径那份清单在 `_utils.CONCEPT_DIRS`)。
33
+ VAULT_CONCEPT_DIRS = tuple(str(Path(VAULT_ROOT) / d) for d in CONCEPT_DIRS)
34
+ # 链接可能指向的「材料」层(与卡片目录一起参与可解析性判断)
35
+ MATERIAL_DIRS = tuple(str(Path(VAULT_ROOT) / d) for d in
36
+ ('002_Literature', '001_Daily', 'Sources/Chat', 'Sources/WeChat'))
37
+ # 卡片在哪(`## 来源` 那节的 `[[<相对路径>.md]]` 要从这些目录里找)。
38
+ # **用通配而不是写死目录名**:今天是文章线与对话线,明天再加一条来源(收藏线就是这么加的),
39
+ # 写死的话体检会**静默漏算**那条线的入链——而漏算的表现是"每张新页都是孤儿"。
40
+ CARD_DIRS = tuple(str(path) for path in sorted(Path('output').glob('*-notes'))) or (
41
+ 'output/article-notes', 'output/chat-notes')
42
+ # 少于这个字数就算空页(概念页有定义+要点,正常几百字)
43
+ MIN_BODY_CHARS = 80
44
+
45
+ _LINK = re.compile(r'\[\[([^\]]+)\]\]')
46
+
47
+
48
+ def compile_frontmatter_topic(frontmatter: dict) -> str:
49
+ """页面的 `topics`(生成时写进去的)取第一个——体检只关心它退不退化。"""
50
+ raw = frontmatter.get('topics') or frontmatter.get('topic') or ''
51
+ if isinstance(raw, (list, tuple)):
52
+ raw = raw[0] if raw else ''
53
+ return str(raw).strip().strip('[]').strip()
54
+
55
+
56
+ def extract_links(body: str) -> list:
57
+ """页面里所有 `[[…]]` 的名字。
58
+
59
+ **故意不过滤**(不像生成那侧会滤掉路径与主题词):体检查的是"链接连不连得起来",
60
+ 滤掉就等于把断掉的链接藏起来——那正好是它要找的东西。
61
+ """
62
+ return [name.strip() for name in _LINK.findall(body or '') if name.strip()]
63
+
64
+
65
+ def links_by_section(body: str) -> list:
66
+ """每条链接连同它所在的 `## 小节` —— 因为**两种死链的意义完全不同**。
67
+
68
+ `## 相关概念` 里的链接是模型提出的"这个概念还该跟谁连",而只有被引用最多的那些
69
+ 才真的建了页。所以那一节里的"还没有页"是**扩张候选**,不是坏掉的东西;
70
+ 而 `## 来源` 里的 `[[<卡片>.md]]` 断了就是**真断了**(卡片被删或改名)。
71
+ 一视同仁地报"40 条死链",看的人只会以为这里烂掉了。
72
+ """
73
+ section, out = '', []
74
+ for line in (body or '').split('\n'):
75
+ if line.strip().startswith('## '):
76
+ section = line.strip()[3:].strip()
77
+ continue
78
+ for name in _LINK.findall(line):
79
+ name = name.strip()
80
+ if name:
81
+ out.append((section, name))
82
+ return out
83
+
84
+
85
+ def collect_card_stems(card_dirs) -> set:
86
+ """卡片文件名(不带 `.md`)。`## 来源` 那节的链接就是这些名字。"""
87
+ stems = set()
88
+ for directory in card_dirs:
89
+ root = Path(directory)
90
+ if root.exists():
91
+ stems |= {p.stem for p in root.rglob('*.md')}
92
+ return stems
93
+
94
+
95
+ def collect_cards(card_dirs) -> list:
96
+ """卡片里的链接——它们才是概念页**真正的**入链(每张卡都链着它提炼出的概念)。"""
97
+ links = []
98
+ for directory in card_dirs:
99
+ root = Path(directory)
100
+ if not root.exists():
101
+ continue
102
+ for path in root.rglob('*.md'):
103
+ try:
104
+ _, body = parse_frontmatter(path.read_text(encoding='utf-8'))
105
+ except Exception:
106
+ continue
107
+ links.extend(extract_links(body))
108
+ return links
109
+
110
+
111
+ def resolve(name: str, page_stems: set, card_dirs, card_stems=None) -> bool:
112
+ """这个链接指向的东西存在吗。
113
+
114
+ - 带路径或以 `.md` 结尾的 → 是**来源卡片**,去卡片目录里找;
115
+ - 否则 → ⚠️ **既可能是概念,也可能是卡片**。
116
+
117
+ 这里原来写的是"裸名字就是概念",而 `## 来源` 那节的链接**就是裸的卡片名**
118
+ (`compile_wiki --fix-source-links` 把 `.md` 去掉了,与全库其余链接统一)。
119
+ 于是那条判据把 7,872 条**在 Obsidian 里解析得好好的**链接全报成了断链。
120
+
121
+ `card_stems` 为空时退回旧行为,好让调用方不必一次性全改。
122
+ """
123
+ if name.endswith('.md') or '/' in name:
124
+ target = name if name.endswith('.md') else name + '.md'
125
+ return any((Path(d) / target).exists() for d in card_dirs)
126
+ if name in page_stems:
127
+ return True
128
+ return name in (card_stems or set())
129
+
130
+
131
+ RELATED_SECTIONS = ('相关概念', '相关主题', '相关')
132
+
133
+
134
+ def inspect(pages: list, exists, card_links=()) -> dict:
135
+ """纯函数:给定页面、"链接是否存在"的判据、以及卡片里的入链,报出问题。
136
+
137
+ - `broken`:**真断了的**链接——指向不存在的卡片(卡片删了/改名了);
138
+ - `aspirational`:`## 相关概念` 那节里指向"还没建页"的概念——**扩张候选,不是错误**;
139
+ - `orphans`:连卡片都没提到过它的页面(卡片链接也算入链,否则每张页都会被误报成孤儿);
140
+ - `empty`:没有定义也没有要点;
141
+ - `duplicateTitles`:同名页;
142
+ - `nearDuplicates`:`{'sameNode': [...], 'contained': [...]}` 两档,可信度差一个量级
143
+ —— 见 `near_duplicate_titles`。
144
+ """
145
+ stems = {page['stem'] for page in pages}
146
+ inbound = {stem: 0 for stem in stems}
147
+ for name in card_links: # 卡片 → 概念,这是主要入链
148
+ if not (name.endswith('.md') or '/' in name) and name in inbound:
149
+ inbound[name] += 1
150
+ broken, aspirational, empty = [], [], []
151
+ for page in pages:
152
+ for section, name in links_by_section(page['body']):
153
+ if exists(name):
154
+ if not (name.endswith('.md') or '/' in name) and name in inbound:
155
+ inbound[name] += 1
156
+ continue
157
+ entry = {'page': page['stem'], 'target': name, 'section': section}
158
+ if any(section.startswith(prefix) for prefix in RELATED_SECTIONS):
159
+ aspirational.append(entry)
160
+ else:
161
+ broken.append(entry)
162
+ if len(page['body'].strip()) < MIN_BODY_CHARS:
163
+ empty.append({'page': page['stem'], 'chars': len(page['body'].strip())})
164
+ orphans = sorted(stem for stem, count in inbound.items() if count == 0)
165
+ titles = {}
166
+ for page in pages:
167
+ titles.setdefault(page['title'] or page['stem'], []).append(page['stem'])
168
+ duplicates = {title: stems for title, stems in titles.items() if len(stems) > 1}
169
+ return {'pages': len(pages), 'broken': broken, 'aspirational': aspirational,
170
+ 'orphans': orphans, 'empty': empty, 'duplicateTitles': duplicates,
171
+ 'degenerateFields': degenerate_fields(pages),
172
+ 'nearDuplicates': near_duplicate_titles(pages)}
173
+
174
+
175
+ # 一个字段有 ≥ 这么多页、且某一个值占了 ≥ 这个比例,就当成"退化了"(等于没携带信息)
176
+ DEGENERATE_MIN_PAGES = 5
177
+ DEGENERATE_RATIO = 0.8
178
+
179
+
180
+ DUP_MIN_NAME = 8 # 规范化后短于这个长度的名字不参与判重
181
+ DUP_COVER_RATIO = 0.6 # 「一个包含另一个」时,短的那个至少要占长的这个比例
182
+ DUP_HUB_LIMIT = 5 # 短名字已经被这么多别的名字包含 → 它是枢纽,不是重复
183
+
184
+
185
+ def near_duplicate_titles(pages: list) -> dict:
186
+ """同一件事被写成两个概念名的候选。**只报告,不自动去重。**
187
+
188
+ 返回两档,因为这两档的**可信度差一个量级**,混在一起报就是把判断推给读的人:
189
+
190
+ - `sameNode`:**规范化之后同名**,也就是只差空格/连字符/大小写/英文复数尾
191
+ (`GLM 5.1` vs `GLM-5.1`、`AI 编程` vs `AI编程`)。抽 24 组人工看过,24 组都是同一
192
+ 个节点。判据就是 `_utils.normalize_concept_name` —— 与 `compile_wiki
193
+ --merge-duplicates` **同一份实现**,所以这份名单是那个工具真会合并的那些。
194
+ - `contained`:一个名字是另一个**加了限定**(`Agent Harness` ⊂ `Agent Harness研讨会`)。
195
+ 人工抽样的精度大约一半:另有相当一部分是"主概念 vs 它的子话题"
196
+ (`国家自然科学基金` / `国家自然科学基金申请书`),**不该合并**。清单给人看,别自动执行。
197
+
198
+ ## 判据换过一次,旧的那条是被量掉的
199
+
200
+ 2026-09-28 之前用的是"最长公共子串 ≥5 字且占较短标题 ≥40%"。它在 5,062 张页、长中文
201
+ 新闻标题时是合理的,到 22,374 张页时**报出 424,697 组**:`AI Agent开发框架` 与
202
+ `生物信息学LLM Agent综述` 共享 "Agent" 就算一对。抽 20 组复核,判据本身条条成立 ——
203
+ 也就是说问题在门槛,不在实现,而这个门槛已经分辨不出任何东西了。
204
+
205
+ 同一批抽样还否掉了另外两个方向,记在这里免得有人重走:
206
+
207
+ - **继续用"长公共子串"**:`Claude Code 上下文窗口` 与 `Claude Code 联网能力` 共享
208
+ "Claude Code" 六个字,可它们是同一个产品的**不同概念**。共享产品名不等于同一件事,
209
+ 这一类占了误报的大头,靠调比例参数分不开。
210
+ - **"一个包含另一个"不加限制**:`DeepSeek` 被 `DeepSeek 融资`、`DeepSeek-V4 涨价`……
211
+ 一个个裹进去。这是**枢纽**(实测 `agent` 被 731 个名字包含、`模型` 643 个),不是重复。
212
+ 所以加了 `DUP_HUB_LIMIT`:短名字已经被 ≥5 个别名包含时,它当枢纽看待,不成对。
213
+ """
214
+ # **按目录分开比。** 两个概念目录是**两个知识库**(D-051),同一个名字两边各有一张页是
215
+ # 设计使然、不是重复;`compile_wiki --merge-duplicates` 也是按目录跑的,永远动不了跨线的
216
+ # 组。2026-09-28 实测:合并跑完后还剩 2 组(`AI 工具`、`GLORIA`)全是跨线的 —— 而报告
217
+ # 写着"这些就是合并会合并的",是句假话。跨线同名另有 `duplicateTitles` 那一节在报。
218
+ named = sorted({(p.get('dir', ''), p['title'] or p['stem']) for p in pages})
219
+ same, contained = [], []
220
+ for where in sorted({w for w, _ in named}):
221
+ by_norm = {}
222
+ for w, name in named:
223
+ if w != where:
224
+ continue
225
+ key = normalize_concept_name(name)
226
+ if key:
227
+ by_norm.setdefault(key, []).append(name)
228
+
229
+ # **按 8-gram 分桶,而不是两两比。** 两档判据都要求"共享一个 ≥8 字的子串"(同名是
230
+ # 全等,包含是共享短的那一个),所以只在共享 8-gram 的页之间比,**结果与全量两两比
231
+ # 逐组相同**,只是不再做那 2.5 亿次注定失败的比较。分桶宽度必须 ≤ 判据的最小长度,
232
+ # 改判据时这条要跟着改 —— `test/wiki_lint_test.py` 用随机数据对拍钉着它。
233
+ index = {}
234
+ for key in by_norm:
235
+ if len(key) < DUP_MIN_NAME:
236
+ continue
237
+ for gram in {key[i:i + DUP_MIN_NAME] for i in range(len(key) - DUP_MIN_NAME + 1)}:
238
+ index.setdefault(gram, []).append(key)
239
+
240
+ # **枢纽计数**:某名字被多少个**同目录**的别的名字包含。用 8-gram 桶算,不是 O(n²)——
241
+ # `k` 被 `other` 包含时 `k` 的前 8 个字必然是 `other` 的一个 8-gram,只看那个桶就够。
242
+ hubs = {}
243
+ for key in by_norm:
244
+ if len(key) < DUP_MIN_NAME:
245
+ continue
246
+ head = key[:DUP_MIN_NAME]
247
+ hubs[key] = sum(1 for other in index.get(head, [])
248
+ if other != key and key in other)
249
+
250
+ # 第一档按**组**报,不按对:一个规范形可能有三四种写法
251
+ # (`Academic Research Skills` / `AcademicResearchSkills` / `academic-research-skills`),
252
+ # 报成两两配对会让人看不出它们是同一个节点。
253
+ same += [sorted(set(raws)) for _, raws in sorted(by_norm.items())
254
+ if len(set(raws)) > 1]
255
+
256
+ seen = set()
257
+ for bucket in index.values():
258
+ for i, left in enumerate(bucket):
259
+ for right in bucket[i + 1:]:
260
+ pair = (left, right) if left <= right else (right, left)
261
+ if pair in seen:
262
+ continue # 一对可能共享多个 8-gram,会落进多个桶
263
+ seen.add(pair)
264
+ short, long_ = pair
265
+ if len(short) < DUP_MIN_NAME or len(short) < DUP_COVER_RATIO * len(long_):
266
+ continue
267
+ if short not in long_ or hubs.get(short, 0) >= DUP_HUB_LIMIT:
268
+ continue
269
+ contained.append([by_norm[short][0], by_norm[long_][0]])
270
+ same.sort() # 按名字排,不按规范形 —— 报告是给人看的
271
+ contained.sort()
272
+ return {'sameNode': same, 'contained': contained}
273
+
274
+
275
+ def degenerate_fields(pages: list) -> dict:
276
+ """**退化字段**:取值几乎只有一种。
277
+
278
+ 实测:39 张概念页的 `topics` **全是「学术」**——上游那个主题分类器把最近这批语料
279
+ 全扔进一个桶(另一处独立测量:标题像新闻的 224 篇里 40 篇也被标成「学术」)。
280
+ 一个字段整列同一个值,看起来像"有元数据",实际什么也没说;而**它会被照着信**。
281
+ 这条检查的作用不是修分类器(那是日报那条线的事),而是让这种退化**在页面上看得见**。
282
+ """
283
+ if len(pages) < DEGENERATE_MIN_PAGES:
284
+ return {}
285
+ out = {}
286
+ # 只查**概念页上真有、而且"看起来像元数据"**的字段。别去查 `source`——概念页用的是复数
287
+ # `sources`,查一个不存在的字段只会报出一句"(全空)",那是拿噪音换注意力。
288
+ for field in ('topic',):
289
+ values = [str(page.get(field) or '').strip() for page in pages]
290
+ values = [value for value in values if value]
291
+ if not values:
292
+ continue
293
+ counts = {}
294
+ for value in values:
295
+ counts[value] = counts.get(value, 0) + 1
296
+ top, count = max(counts.items(), key=lambda item: item[1])
297
+ if count / len(pages) >= DEGENERATE_RATIO:
298
+ out[field] = {'值': top, '页数': count, '总页数': len(pages)}
299
+ return out
300
+
301
+
302
+ def resolvable_names(pages: list) -> set:
303
+ """体检认为"指向它就算解析成功"的所有名字:页的 stem **加上别名**。
304
+
305
+ `--merge-duplicates` 把 `GPT-5.6` 并进 `GPT 5.6` 时,就是靠 aliases 让旧链接不断的
306
+ (实测库里 98 条),Obsidian 认它。体检要是不认,会把这些名字报成"还没建页、
307
+ 建议再跑 compile 提高 --limit" —— 而它们已经有页了,那是**误导**。
308
+ """
309
+ names = set()
310
+ for page in pages:
311
+ names.add(page['stem'])
312
+ names |= set(page.get('aliases') or [])
313
+ return names
314
+
315
+
316
+ def collect(pages_dir: str, card_dirs) -> list:
317
+ pages = []
318
+ for path in sorted(Path(pages_dir).glob('*.md')):
319
+ if path.name == '00-Overview.md': # 索引页不是概念,它引用的东西另算
320
+ continue
321
+ frontmatter, body = parse_frontmatter(path.read_text(encoding='utf-8'))
322
+ raw_aliases = frontmatter.get('aliases') or []
323
+ if isinstance(raw_aliases, str):
324
+ raw_aliases = [x for x in raw_aliases.strip('[]').split(',')]
325
+ pages.append({'stem': path.stem,
326
+ # 判重按目录分开比(两个目录是两个知识库),所以要记住这张页住哪儿
327
+ 'dir': str(pages_dir),
328
+ 'topic': compile_frontmatter_topic(frontmatter),
329
+ 'title': str(frontmatter.get('title') or '').strip('"'),
330
+ # **别名也算"指向这一页的名字"**:`--merge-duplicates` 把
331
+ # `GPT-5.6` 并进 `GPT 5.6` 时就是靠 aliases 让旧链接不断的,
332
+ # Obsidian 认它。体检不认的话,会把这些名字报成"还没建页、
333
+ # 建议再跑 compile" —— 而它们已经有页了。
334
+ 'aliases': [str(a).strip().strip('"').strip("'") for a in raw_aliases],
335
+ 'links': extract_links(body),
336
+ 'body': body})
337
+ return pages
338
+
339
+
340
+ def main():
341
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
342
+ parser = argparse.ArgumentParser(description='知识库体检(本地,不联网、不调用模型)')
343
+ # **默认体检全部概念页目录**(文章一个、聊天一个,见 `_utils.CONCEPT_DIRS`)。
344
+ # 这条一旦只读一个,分出去的那一半就会"体检通过"——因为它根本没被看。
345
+ parser.add_argument('--dir', action='append', default=[],
346
+ help='概念页目录(可重复;默认 %s)'
347
+ % '、'.join(VAULT_CONCEPT_DIRS))
348
+ parser.add_argument('--json', action='store_true')
349
+ args = parser.parse_args()
350
+
351
+ dirs = args.dir or VAULT_CONCEPT_DIRS
352
+ existing = [d for d in dirs if Path(d).exists()]
353
+ if not existing:
354
+ message = ('没有概念页(%s 都不存在)——先跑 article-notes / chat-notes,'
355
+ '再跑 wiki compile' % '、'.join(dirs))
356
+ print(json.dumps({'success': False, 'error': message}, ensure_ascii=False)
357
+ if args.json else message)
358
+ return 1
359
+
360
+ pages = [page for d in existing for page in collect(d, CARD_DIRS)]
361
+ card_links = collect_cards(CARD_DIRS)
362
+ # **链接可能指向材料,而不是卡片。** `## 来源` 指的是「这一页是从哪来的」:
363
+ # 文章线指向 `002_Literature` 里的阅读笔记,聊天线指向 `Sources/Chat` 里的卡。
364
+ # 只查卡片目录会把这类全报成断链——实测 1,950 条,而它们在 Obsidian 里都是好的
365
+ # (两边文件名本来就不同:卡片保留完整标题,阅读笔记截到 50 字)。
366
+ card_stems = collect_card_stems(tuple(CARD_DIRS) + MATERIAL_DIRS)
367
+ report = inspect(pages,
368
+ lambda name: resolve(name, resolvable_names(pages), CARD_DIRS, card_stems),
369
+ card_links)
370
+ report['success'] = True
371
+ report['pagesDir'] = existing
372
+ report['cardLinks'] = len(card_links)
373
+
374
+ if args.json:
375
+ print(json.dumps(report, ensure_ascii=False, indent=2))
376
+ return 0
377
+
378
+ print('概念页 %d 张(卡片侧入链 %d 条)' % (report['pages'], report['cardLinks']))
379
+ if report['broken']:
380
+ print('\n**断链 %d 条**(指向不存在的卡片——卡片删了或改名了):' % len(report['broken']))
381
+ for item in report['broken'][:10]:
382
+ print(' %s → %s' % (item['page'], item['target']))
383
+ print(' 改名字就统一,删掉了就把那行去掉')
384
+ if report['orphans']:
385
+ print('\n孤儿页 %d 张(连卡片都没提到过它):' % len(report['orphans']))
386
+ for name in report['orphans'][:10]:
387
+ print(' %s' % name)
388
+ print(' 多半意味着它已经不被任何来源支持了')
389
+ if report['empty']:
390
+ print('\n空页 %d 张(没有定义或要点):' % len(report['empty']))
391
+ for item in report['empty'][:10]:
392
+ print(' %s(%d 字)' % (item['page'], item['chars']))
393
+ print(' 重新跑一次 wiki compile 通常就好了(那次模型返回不完整)')
394
+ if report['duplicateTitles']:
395
+ print('\n同名页 %d 组:' % len(report['duplicateTitles']))
396
+ for title, stems in list(report['duplicateTitles'].items())[:5]:
397
+ print(' %s ← %s' % (title, '、'.join(stems)))
398
+ if report['aspirational']:
399
+ names = sorted({item['target'] for item in report['aspirational']})
400
+ print('\n还没建页的相关概念 %d 个(**扩张候选,不是错误**):%s'
401
+ % (len(names), '、'.join(names[:12])))
402
+ print(' 想要它们就再跑 wiki compile(提高 --limit),或把它们当下一步的线索')
403
+ near = report.get('nearDuplicates') or {}
404
+ if near.get('sameNode'):
405
+ # 这一档是**可以直接执行的**:判据与 `compile_wiki --merge-duplicates` 是同一份实现,
406
+ # 所以名单上的每一组都是那个工具真会合并的。
407
+ print('\n同一个节点的不同写法 %d 组(规范化后同名,`wiki compile --merge-duplicates` '
408
+ '会合并的就是这些):' % len(near['sameNode']))
409
+ for group in near['sameNode'][:5]:
410
+ print(' %s' % ' / '.join(n[:30] for n in group))
411
+ print(' 想合并就跑 `wiki compile --merge-duplicates`(先 `--dry-run` 看名单)')
412
+ if near.get('contained'):
413
+ print('\n一个名字是另一个加了限定 %d 组(**线索,不是结论**):' % len(near['contained']))
414
+ for pair in near['contained'][:5]:
415
+ print(' %s / %s' % (pair[0][:30], pair[1][:30]))
416
+ print(' 人工抽样大约一半是真重复,另一半是"主概念 vs 它的子话题"'
417
+ '(`国家自然科学基金` / `国家自然科学基金申请书`)——那些不该合并,删哪张由人定')
418
+ if report.get('degenerateFields'):
419
+ print('\n退化字段(整列几乎同一个值,等于没携带信息):')
420
+ for field, info in report['degenerateFields'].items():
421
+ print(' %s: %s(%s/%s 页)' % (field, info['值'], info['页数'], info.get('总页数', report['pages'])))
422
+ print(' 多半是上游分类器把整批语料扔进了一个桶——不改分类器的话,别信这个字段')
423
+ if not any((report['broken'], report['orphans'], report['empty'], report['duplicateTitles'])):
424
+ print('\n没有需要修的问题(断链/孤儿/空页/同名)')
425
+ return 0
426
+
427
+
428
+ if __name__ == '__main__':
429
+ sys.exit(main())