weflow-cli 1.6.4 → 1.8.0

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 (205) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1966 -0
  3. package/OPERATIONS.md +808 -0
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +1245 -26
  7. package/dist/bin/weflow-cli.js +1349 -72
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +43 -0
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +31 -0
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +13 -0
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +23 -0
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/messageQuery.d.ts +35 -0
  88. package/dist/src/services/messageQuery.d.ts.map +1 -1
  89. package/dist/src/services/messageQuery.js +61 -0
  90. package/dist/src/services/messageQuery.js.map +1 -1
  91. package/dist/src/services/pythonBridge.d.ts +41 -0
  92. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  93. package/dist/src/services/pythonBridge.js +119 -0
  94. package/dist/src/services/pythonBridge.js.map +1 -0
  95. package/dist/src/services/syncService.d.ts +127 -0
  96. package/dist/src/services/syncService.d.ts.map +1 -0
  97. package/dist/src/services/syncService.js +263 -0
  98. package/dist/src/services/syncService.js.map +1 -0
  99. package/dist/src/services/syncState.d.ts +113 -0
  100. package/dist/src/services/syncState.d.ts.map +1 -0
  101. package/dist/src/services/syncState.js +214 -0
  102. package/dist/src/services/syncState.js.map +1 -0
  103. package/dist/src/services/wechatMessageService.d.ts +25 -0
  104. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  105. package/dist/src/services/wechatMessageService.js +69 -7
  106. package/dist/src/services/wechatMessageService.js.map +1 -1
  107. package/dist/src/utils/atomicWrite.d.ts +4 -0
  108. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  109. package/dist/src/utils/atomicWrite.js +60 -0
  110. package/dist/src/utils/atomicWrite.js.map +1 -0
  111. package/dist/src/utils/dateRange.d.ts +15 -0
  112. package/dist/src/utils/dateRange.d.ts.map +1 -1
  113. package/dist/src/utils/dateRange.js +47 -0
  114. package/dist/src/utils/dateRange.js.map +1 -1
  115. package/dist/src/utils/text.d.ts +18 -0
  116. package/dist/src/utils/text.d.ts.map +1 -0
  117. package/dist/src/utils/text.js +20 -0
  118. package/dist/src/utils/text.js.map +1 -0
  119. package/docs/AI_INTERFACE.md +2 -0
  120. package/docs/DECISIONS.md +1484 -20
  121. package/docs/EXTENDING.md +187 -0
  122. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
  123. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  124. package/docs/MCP.md +23 -4
  125. package/docs/PROJECT_STATE.md +299 -96
  126. package/docs/SETUP.md +22 -0
  127. package/docs/SYNC_CONTRACT.md +180 -0
  128. package/mcp-server/index.ts +681 -657
  129. package/package.json +84 -83
  130. package/resources/panel/ball-position.cjs +153 -0
  131. package/resources/panel/index.html +46 -0
  132. package/resources/panel/main.cjs +447 -0
  133. package/resources/panel/mascot-focus.png +0 -0
  134. package/resources/panel/mascot-happy.png +0 -0
  135. package/resources/panel/mascot-sorry.png +0 -0
  136. package/resources/panel/mascot-tired.png +0 -0
  137. package/resources/panel/mascot.png +0 -0
  138. package/resources/panel/package.json +7 -0
  139. package/resources/panel/panel.css +407 -0
  140. package/resources/panel/preload.cjs +72 -0
  141. package/resources/panel/quick-menu.cjs +197 -0
  142. package/resources/panel/renderer.js +441 -0
  143. package/resources/panel/tray-menu.cjs +40 -0
  144. package/resources/panel/tray.png +0 -0
  145. package/scripts/_utils.py +403 -54
  146. package/scripts/annual_report.py +4 -2
  147. package/scripts/article_notes.py +423 -0
  148. package/scripts/backfill_articles.py +438 -0
  149. package/scripts/biz_daily.py +590 -105
  150. package/scripts/chat_notes.py +470 -0
  151. package/scripts/classify_daily.py +10 -18
  152. package/scripts/compile_wiki.py +1059 -46
  153. package/scripts/create_reading_notes.py +199 -14
  154. package/scripts/decide.py +253 -0
  155. package/scripts/draft_reply.py +671 -0
  156. package/scripts/export_chat_html.py +212 -46
  157. package/scripts/extract_todos.py +31 -5
  158. package/scripts/fav_notes.py +318 -0
  159. package/scripts/fix_topics.py +2 -1
  160. package/scripts/generate_ai_report.py +214 -9
  161. package/scripts/generate_html.py +18 -3
  162. package/scripts/generate_review.py +2 -2
  163. package/scripts/jev_client.py +294 -0
  164. package/scripts/jev_probe.py +440 -0
  165. package/scripts/nt_common.py +82 -0
  166. package/scripts/nt_decrypt.py +1755 -1352
  167. package/scripts/pipeline.py +27 -2
  168. package/scripts/promote_ideas.py +19 -4
  169. package/scripts/quality_eval.py +707 -0
  170. package/scripts/rag_chat.py +4 -4
  171. package/scripts/read_image.py +187 -0
  172. package/scripts/reply_debt.py +488 -0
  173. package/scripts/route_cards.py +453 -0
  174. package/scripts/semantic_search.py +208 -49
  175. package/scripts/user_notes.py +315 -0
  176. package/scripts/vault_rag.py +13 -7
  177. package/scripts/vault_search.py +21 -4
  178. package/scripts/wiki_lint.py +397 -0
  179. package/src/core/appMsgFormat.ts +117 -0
  180. package/src/core/ntCore.ts +67 -0
  181. package/src/core/sqlcipherCore.ts +5 -61
  182. package/src/core/wcdbCore.ts +6 -2
  183. package/src/panel/client.ts +49 -0
  184. package/src/panel/endpoint.ts +144 -0
  185. package/src/panel/launch.ts +101 -0
  186. package/src/panel/server.ts +424 -0
  187. package/src/panel/userId.ts +72 -0
  188. package/src/services/assistantDaemon.ts +57 -9
  189. package/src/services/assistantEval.ts +611 -0
  190. package/src/services/assistantMemory.ts +413 -57
  191. package/src/services/assistantPrivacy.ts +18 -4
  192. package/src/services/assistantRouter.ts +191 -0
  193. package/src/services/assistantService.ts +671 -74
  194. package/src/services/assistantTools.ts +1118 -57
  195. package/src/services/assistantTrace.ts +175 -0
  196. package/src/services/chatService.ts +30 -0
  197. package/src/services/configService.ts +403 -385
  198. package/src/services/messageQuery.ts +97 -0
  199. package/src/services/pythonBridge.ts +139 -0
  200. package/src/services/syncService.ts +363 -0
  201. package/src/services/syncState.ts +275 -0
  202. package/src/services/wechatMessageService.ts +73 -7
  203. package/src/utils/atomicWrite.ts +56 -0
  204. package/src/utils/dateRange.ts +46 -0
  205. package/src/utils/text.ts +20 -0
@@ -1,26 +1,48 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- 概念图谱编译 — 扫描文章的 [[Wikilinks]] → 聚合 → DeepSeek 生成概念页。
3
+ 概念图谱编译 — 扫描材料的 [[Wikilinks]] → 聚合 → DeepSeek 生成概念页。
4
4
 
5
5
  用法:
6
- python scripts/compile_wiki.py --api-key <key> [--limit 20] [--source output/biz-daily]
6
+ python scripts/compile_wiki.py --api-key <key> [--limit 20] [--source <材料目录>]
7
+
8
+ **源的硬要求:材料里必须有"概念形状"的链接**(`[[概念]] — 关于它说了什么`)。
9
+ 没有它就聚合不出任何概念,而这个脚本会照实说 `No articles with wikilinks found` 然后退出。
10
+
11
+ 两个目录都**不**满足这个要求,各自的坑不同(2026-09-26 实测):
12
+ - `output/biz-daily`(老默认值):2231 个 .md,抽样 300 个**一个 wikilink 都没有** ✗;
13
+ - `output/wechat-vault/002_Literature`(Vault 里的文章笔记):1633 篇里正文链接只有两类——
14
+ **1631 条与自己的主题同名**(`> - **主题**: [[AI]]`)和 `## 🔗 关联网络` 里的**路径互链**。
15
+ "其它"候选概念 **0 条**:这批笔记**没有概念那一节**。
16
+
17
+ 所以要文章线产出概念页,**还得有一道"从文章里提炼概念"的步骤**(对话线已经有:
18
+ `chat_notes.py` 产出的卡自带 `[[话题]]/[[人名]]`)。在那之前,本脚本只在对话线上有效。
7
19
  """
8
20
  import sys, os, json, re, time, hashlib
21
+ from concurrent.futures import ThreadPoolExecutor
9
22
  from pathlib import Path
10
23
  from collections import defaultdict, Counter
11
24
 
12
25
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
- from _utils import call_deepseek, parse_frontmatter, write_with_frontmatter
26
+ from _utils import (CHAT_CARD_PREFIX, CONCEPT_DIRS, call_deepseek, # noqa: E402
27
+ SEPARATOR, get_api_key, load_config, parse_frontmatter,
28
+ write_with_frontmatter)
14
29
 
15
30
  # Default paths
16
31
  SOURCE_ROOT = 'output/biz-daily'
17
32
  OUTPUT_ROOT = 'output/wechat-vault/Wiki/Concepts'
18
33
 
34
+ # 并发数。与 `article_notes.CONCEPT_WORKERS`、`biz_daily` 那三个取同一个值:
35
+ # 量的都是同一个上游的同一个延迟。
36
+ #
37
+ # **必须定义在使用它的函数之前**:它被用作默认参数值,而默认参数在**定义时**求值——
38
+ # 放在后面就是一个 import 期就炸的 NameError(2026-09-27 踩过)。
39
+ CONCEPT_PAGES_WORKERS = 6
40
+
19
41
  CONCEPT_PROMPT = """为概念生成 Wiki 知识页。
20
42
 
21
43
  概念名:{name}
22
44
 
23
- 参考来源(来自公众号文章):
45
+ 参考来源(每条是"某篇材料 + 它关于这个概念说的那句话"):
24
46
  {references}
25
47
 
26
48
  请按格式返回:
@@ -38,7 +60,308 @@ tag1, tag2, tag3
38
60
  【相关概念】
39
61
  概念A, 概念B, 概念C
40
62
 
41
- 要求:定义精准,要点简洁(每条≤30字),标签2-3个,相关概念2-4个。"""
63
+ 要求:定义精准,要点简洁(每条≤30字),标签2-3个,相关概念2-4个。
64
+
65
+ **若参考行标注为「人物」**:这一页写的是**人**——聊天里出现过的联系人、对话者或提到的人。
66
+ 按材料写他与用户的关系和往来,**不要**把它当成同名的事物、作品、名人或典故。
67
+ (实测:一个昵称叫「白马非马」的联系人,曾被按字面写成公孙龙的那个哲学命题——
68
+ 材料里明明写着"对话对方,准备面试",所以这里必须说清。)"""
69
+
70
+
71
+ # 笔记里**不是概念**的那几节:它们是"笔记之间的关系",而且链接的名字是**路径**
72
+ # (实测:`## 🔗 关联网络` 里写的是 `- [[AI/某篇标题.md]]`)。
73
+ RELATION_SECTIONS = ('## 🔗 关联网络', '## 关联网络', '## 📊 相关文章', '## 相关文章')
74
+
75
+ _SECTION_RE = r'^##\s+%s\s*$'
76
+
77
+
78
+ def concept_body(body: str) -> str:
79
+ """去掉"关系"那几节之后的正文(只在里面找概念链接)。"""
80
+ lines = body.split('\n')
81
+ kept, dropping = [], False
82
+ for line in lines:
83
+ if line.strip().startswith('## '):
84
+ dropping = any(line.strip().startswith(s) for s in RELATION_SECTIONS)
85
+ if not dropping:
86
+ kept.append(line)
87
+ return '\n'.join(kept)
88
+
89
+
90
+ def concept_links(body: str, topic: str = '') -> list[tuple]:
91
+ """正文里**真正算概念**的 `[[wikilink]]`。
92
+
93
+ 实测真库一篇笔记,正文里的 `[[…]]` 有三类,**只有第三类是概念**:
94
+ - `> - **主题**: [[AI]]` —— 主题是**元数据**(就是 `hasTopic` 那份),不是概念;
95
+ - `## 🔗 关联网络` 里的 `- [[AI/某篇标题.md]]` —— 笔记之间的引用,**名字是路径**;
96
+ - `- [[MCP 协议]] — 它把时间线开放给 Agent 操作` —— 这才是概念,**破折号后那句才是原料**。
97
+
98
+ 不滤的代价是实测过的:1631 篇聚出来的引用榜首是「新闻(752) / 政治(388) / 学术(341)」
99
+ 外加两条 `.md` 路径。照那个跑 `--limit 20`,就是拿二十次模型调用去生成这种"概念页",
100
+ 再写进你的 Vault。
101
+ """
102
+ body = concept_body(body)
103
+ links = []
104
+ for name, desc in re.findall(r'\[\[([^\]]+)\]\](?:\s*—?\s*([^\n]+))?', body):
105
+ name, desc = name.strip(), desc.strip()
106
+ # 路径形状的一律不要(`a/b.md`、`x.md`):那是笔记引用,不是概念
107
+ if not name or '/' in name or name.endswith('.md'):
108
+ continue
109
+ # 与自己的主题同名的一律不要:主题是分类,不是概念
110
+ if topic and name == topic:
111
+ continue
112
+ # **纯符号/表情/单字不算概念。** 实测聊天线提出来过 `🐊`、`🤓`、`🥬 + 🔴`、`: D`、`D`
113
+ # 这种——它们会各自长出一张页,在图上就是几个没有意义的孤立点。
114
+ # 判据是"至少两个汉字或字母数字":`AI`/`MCP` 这类两字符的照收。
115
+ if len(re.findall(r'[一-鿿A-Za-z0-9]', name)) < 2:
116
+ continue
117
+ links.append((name, desc))
118
+ return links
119
+
120
+
121
+ # 聊天卡把这两类**分开写**(`### 话题` 与 `### 人`)。聚合时丢掉这个区分是有代价的,
122
+ # 实测:`白马非马` 是个联系人的昵称,而模型拿到"对话对方,准备面试"这类材料之后,
123
+ # 仍然按名字把它写成了公孙龙那个典故——**先验盖过了材料**。带上"这是个人",
124
+ # 那一页才会写成一个人。
125
+ KIND_SECTIONS = (('### 人', '人物'), ('### 话题', '话题'))
126
+
127
+
128
+ def link_kinds(body: str) -> dict:
129
+ """`[[名字]]` → 它出现在哪个小节里(`人物` / `话题`)。不在这些小节的就不给。
130
+
131
+ 只认卡片实际用的那两个小节;别的来源线(文章卡)没有这个概念,返回空字典,
132
+ 于是"没标注"与"标为话题"分得开——后者是**知道**它是话题。
133
+ """
134
+ kinds = {}
135
+ current = ''
136
+ for line in body.split('\n'):
137
+ stripped = line.strip()
138
+ matched = False
139
+ for marker, kind in KIND_SECTIONS:
140
+ if stripped.startswith(marker):
141
+ current = kind
142
+ matched = True
143
+ break
144
+ if not matched and (stripped.startswith('## ') or stripped.startswith('### ')):
145
+ current = ''
146
+ if not current:
147
+ continue
148
+ for name in re.findall(r'\[\[([^\]\|]+)\]\]', line):
149
+ kinds.setdefault(name, current)
150
+ return kinds
151
+
152
+
153
+ def source_link_target(frontmatter: dict, card_path) -> str:
154
+ """卡片 → 它在 Vault 里对应的**阅读笔记**的链接名。
155
+
156
+ **为什么不能直接用卡片自己的文件名**:卡片叫 `{日期}-{完整标题}.md`,而阅读笔记叫
157
+ `{日期}-{标题截到 50 字}.md`(`create_reading_notes.safe_filename`)。标题短的(实测
158
+ 98%)两者恰好相同,于是链接**碰巧**能解析;标题一长就对不上——实测 5,470 条来源链接里
159
+ 有 125 条因此指向一个不存在的名字。
160
+
161
+ 卡片里本来就记着来源笔记的相对路径(`from`,由 `article_notes.build_card` 写入),
162
+ 取它的文件名才是**正确的那一个**。没有 `from` 的老卡片退回卡片自己的名字(保持原行为)。
163
+ """
164
+ origin = str(frontmatter.get('from') or '').strip()
165
+ if origin:
166
+ return Path(origin).stem
167
+ return Path(card_path).stem
168
+
169
+
170
+ def build_source_name_map(source_dir: str) -> dict:
171
+ """卡片文件名 → 它对应的阅读笔记文件名。**只收两者不同的那些。**
172
+
173
+ 不同的原因只有一个:标题超过 50 字(`safe_filename` 会截断)。所以这张表很小,
174
+ 而它就是"已生成的概念页里那些断链"需要的全部信息。
175
+ """
176
+ mapping = {}
177
+ for md_file in Path(source_dir).rglob('*.md'):
178
+ if md_file.name == 'README.md':
179
+ continue
180
+ try:
181
+ frontmatter, _ = parse_frontmatter(md_file.read_text(encoding='utf-8'))
182
+ except Exception:
183
+ continue
184
+ # **聊天卡在 Vault 里带前缀**(`Sources/Chat/会话-<会话名>.md`):卡名就是会话名,
185
+ # 而概念页可能同名,两个同名文件会让 `[[名字]]` 二义。所以这里也要跟着改,
186
+ # 否则概念页的"来源"链接永远指不到那张卡(实测 1,176 条)。
187
+ if Path(source_dir).name == 'chat-notes':
188
+ mapping[md_file.stem] = CHAT_CARD_PREFIX + md_file.stem
189
+ continue
190
+ target = source_link_target(frontmatter, md_file)
191
+ if target != md_file.stem:
192
+ mapping[md_file.stem] = target
193
+ return mapping
194
+
195
+
196
+ # "来源"段那一行的形状:`- [[名字]] — 标题`。**破折号是判据**——`## 相关概念` 那一段
197
+ # 也是 `- [[名字]]`,但没有后面的 ` — `。
198
+ SOURCE_LINK_RE = re.compile(r'^- \[\[([^\]]+)\]\] — ', re.M)
199
+
200
+
201
+ def fix_source_links(pages_dir: str, source_dir: str) -> dict:
202
+ """把已有概念页"来源"段里对不上的链接改成正确的那一个。**本地,不调模型。**
203
+
204
+ 为什么需要单独一趟:上面 `scan_articles` 的修只影响**以后**生成的概念页,而库里已经有
205
+ 1,963 张带着旧链接——实测 125 条指不到任何文件。重跑 `wiki compile` 也能修,但那要
206
+ 重花一遍模型的钱、还会把已经写好的页面随机重写一遍;这里只改那几行链接。
207
+ """
208
+ mapping = build_source_name_map(source_dir)
209
+ pages, links = [], 0
210
+ for page in sorted(Path(pages_dir).glob('*.md')):
211
+ text = page.read_text(encoding='utf-8')
212
+ hits = [0]
213
+
214
+ def replace(match):
215
+ raw = match.group(1)
216
+ # **后缀要去掉**:库里其他所有链接(笔记互链、日记、MOC)都是不带 `.md` 的写法,
217
+ # 而这里原来带。带后缀能不能解析我不确定,不带是确定的写法——所以统一成不带。
218
+ name = raw[:-3] if raw.lower().endswith('.md') else raw
219
+ # 卡片名与阅读笔记名在标题超长时不同,查表换成对的那个(见 `source_link_target`)
220
+ fixed = mapping.get(name, name)
221
+ if fixed != raw:
222
+ hits[0] += 1
223
+ return '- [[%s]] — ' % fixed
224
+
225
+ updated = SOURCE_LINK_RE.sub(replace, text)
226
+ if hits[0]:
227
+ page.write_text(updated, encoding='utf-8')
228
+ pages.append(page.name)
229
+ links += hits[0]
230
+ return {'pages': pages, 'links': links, 'names': len(mapping)}
231
+
232
+
233
+ def collect_dangling_targets(pages_dir: str) -> Counter:
234
+ """现有概念页提到、但**自己没有页**的概念 → 被多少张页提到。
235
+
236
+ 这就是图谱里那些"悬空节点"的来源,也是 `## 相关概念` 那些线指向的另一端。
237
+ 2026-09-27 实测:3,680 个名字、4,633 条链接,其中被 ≥2 张页提到的有 516 个。
238
+
239
+ **它们大多建不出来**:那些名字是模型写页面时自己引入的,卡片语料里根本没有
240
+ (`提示工程` 被 27 张页指向,而卡片语料里 0 条;`深度学习` 只有 1 条,低于
241
+ `--min-refs 2`)。所以降 `--min-refs` 解决不了——降到 1 会建 20,957 页,
242
+ 而这批 0 引用的一个都还建不到。参考材料只能取自**提到它的那些页面**。
243
+ """
244
+ pages = Path(pages_dir)
245
+ # **大小写不敏感**:Windows 上 `[[Claude Code]]` 能解析到 `claude code.md`,
246
+ # 用大小写敏感的集合去判就会把 129 张页的 `Claude Code` 误报成"悬空"
247
+ # (2026-09-27 实测:那一版的第一名就是它,而它其实有页)。
248
+ have = {p.stem.lower() for p in pages.glob('*.md')}
249
+ counts = Counter()
250
+ for page in pages.glob('*.md'):
251
+ text = page.read_text(encoding='utf-8')
252
+ start = text.find('## 相关概念')
253
+ if start < 0:
254
+ continue
255
+ body = text[start:]
256
+ end = body.find('\n## ', 1)
257
+ if end > 0:
258
+ body = body[:end]
259
+ for target in re.findall(r'\[\[([^\]\|]+)\]', body):
260
+ name = target[:-3] if target.endswith('.md') else target
261
+ if name and name.lower() not in have:
262
+ counts[name] += 1
263
+ return counts
264
+
265
+
266
+ def load_card_texts(card_dirs=None) -> list[dict]:
267
+ """把所有卡片读进内存:`[(名字, 主题, 标题, 正文)]`。
268
+
269
+ **卡片不大**(实测 7,119 张共约 12 MB),一次读完再按字面找,比"一个概念扫一遍
270
+ 七千张卡"快得多。
271
+ """
272
+ import glob as _glob
273
+ dirs = card_dirs or sorted(p for p in _glob.glob('output/*-notes') if os.path.isdir(p))
274
+ cards = []
275
+ for directory in dirs:
276
+ for path in Path(directory).rglob('*.md'):
277
+ try:
278
+ frontmatter, body = parse_frontmatter(path.read_text(encoding='utf-8'))
279
+ except Exception:
280
+ continue
281
+ cards.append({'name': path.stem,
282
+ 'topic': str(frontmatter.get('topic') or frontmatter.get('hasTopic') or ''),
283
+ 'title': str(frontmatter.get('title') or path.stem),
284
+ # `file` 是"来源"段那条链接的目标:**阅读笔记的名字**,
285
+ # 不是卡片的(见 `source_link_target`)。`generate_concept`
286
+ # 会读 `r['file']` 与 `r['title']`——少了它们就是 KeyError。
287
+ 'file': source_link_target(frontmatter, path),
288
+ 'source': str(frontmatter.get('source') or ''),
289
+ 'body': body})
290
+ return cards
291
+
292
+
293
+ def mention_refs(cards: list, name: str, limit: int = 5, width: int = 160) -> list[dict]:
294
+ """按**字面**在卡片正文里找提到这个概念的地方,取它所在的句子当参考材料。
295
+
296
+ **为什么不能用"提到它的那些概念页的定义句"**:那些页面只写了 `- [[提示工程]]`
297
+ 一个光名字,关于这个概念一个字都没说;拿它们的自我定义去生成 `提示工程` 的页,
298
+ 等于让模型凭空编。2026-09-27 我第一版就是这么写的,写完发现材料完全对不上。
299
+
300
+ 真实材料在**卡片正文**里:实测 `提示工程` 被 2 张卡、`知识蒸馏` 被 3 张卡在正文里
301
+ 提到过——只是从没成为结构化的 `[[概念]] — desc` 引用,所以 `compile_wiki` 一直不
302
+ 知道它们。这里把那句话连同前后半个窗口取出来,那才是"某篇材料关于它说了什么"。
303
+ """
304
+ refs = []
305
+ for card in cards:
306
+ # **只在摘要里认,不在全文里认。** 实测差别很大:全文按字面找会命中广告词、
307
+ # 图片 URL、结构碎片(`AI对齐` 取到"提升超多好礼!",`大模型应用` 取到一段 URL),
308
+ # 而摘要讲什么、文章才是在讲什么。
309
+ summary = _extract_summary(card['body']) or ''
310
+ index = summary.find(name)
311
+ if index < 0:
312
+ continue
313
+ start = max(0, index - width // 2)
314
+ snippet = summary[start:index + len(name) + width // 2]
315
+ # 摘要本身是从微信正文抄来的,会夹着图片/链接的 markdown
316
+ snippet = re.sub(r'!\[[^\]]*\]\([^)]*\)', '', snippet)
317
+ snippet = re.sub(r'https?://\S+', '', snippet)
318
+ snippet = re.sub(r'\s+', ' ', snippet).strip()
319
+ if len(snippet) < 10:
320
+ continue
321
+ # `file`/`title` 必须一起给:`generate_concept` 拿它们写"来源"段,
322
+ # 缺了就是 KeyError,而那个异常会被并发包装层吞掉(表现为"生成 0 页、无报错")。
323
+ refs.append({'desc': snippet, 'summary': '', 'topic': card['topic'].strip('[]'),
324
+ 'file': card['file'], 'title': card['title'],
325
+ 'source': card['source']})
326
+ if len(refs) >= limit:
327
+ break
328
+ return refs
329
+
330
+
331
+ def build_dangling_pages(pages_dir: str, api_key: str, top: int,
332
+ workers: int = CONCEPT_PAGES_WORKERS, origin: str = '') -> dict:
333
+ """给"被页面提到最多、却自己没有页"的概念建页。**这条通道的参考材料来自页面本身。**
334
+
335
+ 为什么不能靠 `scan_articles`:那些名字是模型写页面时引入的,卡片语料里大多没有
336
+ (见 `collect_dangling_targets`)。所以这里直接用**提到它的那些页面的定义句**当参考行
337
+ ——与卡片那条 `[[概念]] — desc` 是同一个用途。
338
+ """
339
+ counts = collect_dangling_targets(pages_dir)
340
+ cards = load_card_texts()
341
+ jobs, skipped = [], 0
342
+ for name, _times in counts.most_common(top):
343
+ safe_name = re.sub(r'[\\/:*?"<>|]', '_', name)[:60]
344
+ out_file = Path(pages_dir) / f'{safe_name}.md'
345
+ if out_file.exists():
346
+ skipped += 1
347
+ continue
348
+ # **材料按字面从卡片正文里找**(见 `mention_refs`);找不到就不建——
349
+ # 没有材料还建页,就是让模型编。
350
+ refs = mention_refs(cards, name)
351
+ if not refs:
352
+ skipped += 1
353
+ continue
354
+ jobs.append((name, refs, out_file))
355
+
356
+ generated = 0
357
+ for (name, refs, out_file), result in iter_concept_pages(jobs, api_key, workers, origin=origin):
358
+ if not result:
359
+ continue
360
+ fm, body = result
361
+ write_with_frontmatter(str(out_file), fm, body)
362
+ generated += 1
363
+ return {'dangling': len(counts), 'attempted': len(jobs),
364
+ 'generated': generated, 'skipped': skipped}
42
365
 
43
366
 
44
367
  def scan_articles(source_dir: str) -> list[dict]:
@@ -55,18 +378,20 @@ def scan_articles(source_dir: str) -> list[dict]:
55
378
 
56
379
  fm, body = parse_frontmatter(content)
57
380
 
58
- # Extract [[wikilinks]] with optional descriptions
59
- wiki_pattern = re.findall(r'\[\[([^\]]+)\]\](?:\s*—?\s*([^\n]+))?', body)
60
- wikilinks = [(name.strip(), desc.strip()) for name, desc in wiki_pattern]
381
+ # Extract [[wikilinks]] with optional descriptions(过滤规则见 `concept_links`)
382
+ kinds = link_kinds(body)
383
+ wikilinks = [(name, desc, kinds.get(name, ''))
384
+ for name, desc in concept_links(body, article_topic(fm))]
61
385
 
62
386
  if not wikilinks:
63
387
  continue
64
388
 
65
389
  articles.append({
66
- 'file': str(md_file.relative_to(source_dir)),
390
+ # **用阅读笔记的名字**,不是卡片的(见 `source_link_target`)
391
+ 'file': source_link_target(fm, md_file),
67
392
  'title': fm.get('title', md_file.stem),
68
393
  'source': fm.get('source', ''),
69
- 'topic': fm.get('topic', ''),
394
+ 'topic': article_topic(fm),
70
395
  'tags': fm.get('tags', []),
71
396
  'summary': _extract_summary(body),
72
397
  'wikilinks': wikilinks,
@@ -74,9 +399,36 @@ def scan_articles(source_dir: str) -> list[dict]:
74
399
  return articles
75
400
 
76
401
 
402
+ # 认哪些小节算"摘要"。
403
+ #
404
+ # **`📋 摘要` 是 Vault 里那 1633 篇笔记实际用的标题**(由 `create_reading_notes.py` 写),
405
+ # 而这里的正则原来只认 `AI 摘要` / `深度解析`——它一直靠下面那个"正文第一段"的兜底
406
+ # **碰巧**读到同一段。兜底能用,但那是运气:笔记前面多一行别的东西(一行来源、一句引用之外的
407
+ # 普通文本),摘要就会静默变成那一行,而概念页会照着它生成。
408
+ SUMMARY_HEADINGS = ('AI 摘要', '深度解析', '📋 摘要', '摘要')
409
+ _SUMMARY_RE = re.compile(
410
+ r'## (?:%s)\s*\n+(.+?)(?=\n\n##|\n\n---|\Z)' % '|'.join(re.escape(h) for h in SUMMARY_HEADINGS),
411
+ re.DOTALL)
412
+
413
+
414
+ def article_topic(frontmatter: dict) -> str:
415
+ """这篇材料的主题。
416
+
417
+ 两个键都认:`topic`(新写的笔记用它,如 `chat_notes`)与 **`hasTopic`**(Vault 里那批用它,
418
+ 而且是**给你 Obsidian 的 dataview 查询用的**——`create_reading_notes.py:43` 写 `hasTopic: [[AI]]`,
419
+ 查询里 `WHERE contains(hasTopic, "AI")`)。所以**改读的、不改写的**:一改字段名,你库里的
420
+ 查询就全断了;而读 `hasTopic` 还能让现有 1633 篇**立刻**有主题。值可能是 `[[AI]]` / `AI`,
421
+ 两种都拆成 `AI`。
422
+ """
423
+ raw = frontmatter.get('topic') or frontmatter.get('hasTopic') or ''
424
+ if isinstance(raw, (list, tuple)):
425
+ raw = raw[0] if raw else ''
426
+ return str(raw).strip().strip('[]').strip()
427
+
428
+
77
429
  def _extract_summary(body: str) -> str:
78
430
  """Extract the AI summary section from article body."""
79
- m = re.search(r'## (?:AI 摘要|深度解析)\n\n(.+?)(?=\n\n##|\n\n---|\Z)', body, re.DOTALL)
431
+ m = _SUMMARY_RE.search(body)
80
432
  if m:
81
433
  return m.group(1).strip()[:500]
82
434
  # Fallback: first paragraph after metadata
@@ -93,26 +445,492 @@ def aggregate_concepts(articles: list[dict]) -> dict[str, list[dict]]:
93
445
  concept_map = defaultdict(list)
94
446
  for art in articles:
95
447
  seen = set()
96
- for name, desc in art['wikilinks']:
448
+ for item in art['wikilinks']:
449
+ name, desc = item[0], item[1]
450
+ # 兼容两种形状:老的两元组(没有 kind)与新的三元组
451
+ kind = item[2] if len(item) > 2 else ''
97
452
  if name in seen:
98
453
  continue
99
454
  seen.add(name)
100
455
  concept_map[name].append({
101
456
  'title': art['title'],
102
457
  'source': art['source'],
458
+ 'topic': art.get('topic', ''),
103
459
  'summary': art['summary'],
104
460
  'desc': desc,
461
+ 'kind': kind,
105
462
  'file': art['file'],
106
463
  })
107
464
  return dict(concept_map)
108
465
 
109
466
 
110
- def generate_concept(name: str, refs: list[dict], api_key: str) -> str | None:
467
+ def build_ref_lines(refs: list[dict], limit: int = 5) -> list[str]:
468
+ """每个概念喂给模型的参考行。
469
+
470
+ **优先用 `desc`(这条 wikilink 关于该概念写的那句话),没有才退回 note 摘要。**
471
+ 这两者一直都被 `aggregate_concepts` 收着,但生成时只用了摘要——后果是一个被 20 篇提到的
472
+ 概念,拿到的却是 5 篇**泛泛的**摘要,关于它自己反倒没说什么。人物页尤其吃这个亏:
473
+ 每条的 `desc` 就是"那时候发生了什么",那是时间线的原料。
474
+
475
+ `limit=5`:参考条数多了会把提示词撑长,而模型对第 6 条以后的边际收益很小。
476
+ """
477
+ lines = []
478
+ for ref in refs[:limit]:
479
+ detail = ref.get('desc') or ref.get('summary') or ''
480
+ # 主题也带上:**收集了就要用**。原来 `topic` 被收进文章字典之后一次都没被读过,
481
+ # 于是"按主题分"这件事根本无从谈起;带上它,模型才知道这些来源属于同一个领域。
482
+ where = f'{ref["source"]} · {ref["topic"]}' if ref.get('topic') else ref['source']
483
+ # **把"这是个人还是话题"带给模型**:不写的话,像 `白马非马` 这种与典故同名的联系人
484
+ # 会被写成一个哲学命题(实测发生过——材料明明写着"对话对方,准备面试")。
485
+ if ref.get('kind'):
486
+ where = f'{ref["kind"]} · {where}'
487
+ lines.append(f'- [{ref["title"]}]({where}):{detail[:150]}')
488
+ return lines
489
+
490
+
491
+ def rank_concepts(concept_map: dict, min_refs: int = 1) -> list:
492
+ """按被引用的篇数排序,并滤掉"不够格"的。
493
+
494
+ **为什么要有这个闸门**:只被一篇提到的概念,多半是那篇新闻里的人名/机构/单次事件——
495
+ 给它们逐条写页不是知识库,是剪报。实测 120 篇材料聚出 387 个概念,其中只有 **37 个**
496
+ 被 ≥2 篇提到(`--min-refs 2` 就是拿这条实测数据定的默认建议值)。
497
+ """
498
+ ranked = sorted(concept_map.items(), key=lambda item: len(item[1]), reverse=True)
499
+ if min_refs > 1:
500
+ ranked = [(name, refs) for name, refs in ranked if len(refs) >= min_refs]
501
+ return ranked
502
+
503
+
504
+ # 一条来源(`--source` 目录名)→ 它派生出来的页该带什么来源标签。
505
+ # 借自那个考公库:它把私密内容单放 `private/`,README 里也点明"家庭/健康/情绪类内容注意隐私"。
506
+ # 我们的库里**公开文章与私人对话是混在一起的**——用嵌套标签分开,`tag:#来源/聊天`
507
+ # 就能一眼看出哪些页该当私密内容对待(要分享、要截图、要导出时用得上)。
508
+ SOURCE_TAGS = {
509
+ 'article-notes': '来源/文章',
510
+ 'chat-notes': '来源/聊天',
511
+ 'fav-notes': '来源/收藏',
512
+ 'user-notes': '来源/我的笔记',
513
+ }
514
+
515
+
516
+ def origin_tag(source_dir: str) -> str:
517
+ """`--source` 指向哪个产卡目录 → 来源标签。认不出来就不加(不猜)。"""
518
+ name = Path(str(source_dir)).name
519
+ return SOURCE_TAGS.get(name, '')
520
+
521
+
522
+ # 并发数。与 `article_notes.CONCEPT_WORKERS`、`biz_daily` 那三个取同一个值:
523
+ # 量的都是同一个上游的同一个延迟。
524
+ CONCEPT_PAGES_WORKERS = 6
525
+
526
+
527
+ def sibling_concept_dirs(out_dir: Path) -> list:
528
+ """同一个库里**另一条线**的概念目录(有的话)。
529
+
530
+ **一个概念名在库里只能有一张页。** 两张同名页会让 `[[DeepSeek]]` 在 Obsidian 里变成二义的
531
+ ——它挑一张连上,另一张等同于断了,而**两边都不报错**。这正是聊天卡要加 `会话-` 前缀的原因;
532
+ 概念页没有前缀可用,所以只能用「另一条线已有同名页就不再建」来保证名字唯一。2026-09-27 实测:
533
+ 分开目录之后,聊天线立刻为 11 个文章线已有的概念各建了一张同名页,`wiki lint` 报"同名页 11 组"。
534
+
535
+ 只在这个输出目录**确实落在一条已知的知识库线上**时才去找:`--output` 指到库外的任意目录时,
536
+ 那里没有"另一条线",也就没有什么可跳过的。
537
+ """
538
+ # **两级**:两条线都是 `<库>/<线>/Concepts`(`Wiki/Concepts`、`Chat/Concepts`),
539
+ # 库根是 `out_dir.parent.parent`。写成一级的话候选目录恒不存在(`<库>/Chat/Wiki/Concepts`),
540
+ # 于是这段静默失效、同名页照建 —— 2026-09-27 第一次就是这么写的,跑完那 11 张又回来了。
541
+ root = out_dir.parent.parent
542
+ mine = out_dir.resolve()
543
+ found = []
544
+ for relative in CONCEPT_DIRS:
545
+ candidate = root / relative
546
+ if candidate.resolve() == mine or not candidate.is_dir():
547
+ continue
548
+ found.append(candidate)
549
+ return found
550
+
551
+
552
+ def skip_note(skipped: int, elsewhere: int) -> str:
553
+ """跳过多少、分别为什么 —— **两类原因要分开说**。
554
+
555
+ 本目录已有 = 重跑(正常);另一条线已有同名页 = 故意不重复建(`sibling_concept_dirs`)。
556
+ 合成一句"跳过 N 个"的话,看到的人会以为全是重跑,而这 11 个正是分开目录之后新增的那一类。
557
+ 写成函数是为了**能被测**:留在 `main()` 里的一句 f-string,改坏了不会有任何东西红。
558
+ """
559
+ if not skipped and not elsewhere:
560
+ return ''
561
+ where = f'(其中 {elsewhere} 个在另一条线已有同名页)' if elsewhere else ''
562
+ return f' 跳过 {skipped + elsewhere} 个已有概念{where}'
563
+
564
+
565
+ def build_jobs(top_concepts: list, out_dir, other_dirs: list = ()) -> tuple:
566
+ """滤掉**已经有页**的概念,返回 `([(name, refs, out_file)], 跳过数)`。
567
+
568
+ 这一步必须在**提交给线程池之前**做。原来那版是串行循环里 `if out_file.exists(): continue`,
569
+ 顺序上天然不会为已存在的页花钱;一旦改成并发,"先提交、拿到结果再丢"就变成了**为一个
570
+ 已存在的页付一次费**——而且不报错,只体现在账单上。
571
+ """
572
+ jobs, skipped, elsewhere = [], 0, 0
573
+ for name, refs in top_concepts:
574
+ safe_name = re.sub(r'[\\/:*?"<>|]', '_', name)[:60]
575
+ out_file = out_dir / f'{safe_name}.md'
576
+ if out_file.exists():
577
+ skipped += 1
578
+ continue
579
+ # 另一条线已经有同名页 —— 跳过,理由见 `sibling_concept_dirs`
580
+ if any((other / f'{safe_name}.md').exists() for other in other_dirs):
581
+ elsewhere += 1
582
+ continue
583
+ jobs.append((name, refs, out_file))
584
+ return jobs, skipped, elsewhere
585
+
586
+
587
+ def all_card_dirs(card_dirs=None) -> list:
588
+ """产卡目录。默认**所有** `output/*-notes` —— 与 `count_cards_per_concept` 同一口径。
589
+
590
+ **一个都没有就报错退出,不静默通过**:它用的是仓库相对路径,换个工作目录跑就匹配不到,
591
+ 于是"每一页都没有目标" → 报告说"改了 0 页",看起来像成功了。那个失败模式比不做更坏。
592
+ """
593
+ import glob as _glob
594
+ dirs = [str(d) for d in card_dirs] if card_dirs else sorted(
595
+ p for p in _glob.glob('output/*-notes') if os.path.isdir(p))
596
+ if not dirs:
597
+ raise SystemExit('没有找到任何产卡目录(output/*-notes)—— 请在仓库根目录下运行')
598
+ return dirs
599
+
600
+
601
+ def iter_source_links(text: str) -> list:
602
+ """读出「来源」段里每一行的名字。**宽容读取**,因为 `SOURCE_LINK_RE` 读不全。
603
+
604
+ 实测库里 7,061 条来源行里有 20 条它匹配不到,全是标题里带方括号的
605
+ (`- [[2026-03-05-[TGRS]利用…]] — …`):那个正则的 `[^\]]+` 在第一个 `]`
606
+ 就停下,再要求紧跟 `]] — ` 于是整条不匹配。拿它判"这一行已经有了",结果是
607
+ **每次重跑都再追加一遍**,幂等性当场就破。
608
+
609
+ `- [[` 是**四个**字符:写成 `line[3:]` 会多读一个 `[`,于是每一条现有来源都
610
+ "匹配不上" —— 实测那一个小错造出过 6,721 条假缺口,把整份报告带偏(2026-09-28 踩的)。
611
+ """
612
+ names = []
613
+ for line in text.split('\n'):
614
+ if not line.startswith('- [['):
615
+ continue
616
+ sep = line.find(']] — ')
617
+ if sep != -1:
618
+ names.append(line[4:sep])
619
+ return names
620
+
621
+
622
+ def merged_concept_refs(card_dirs=None) -> tuple:
623
+ """跨所有产卡目录合并出 `概念名 → refs`,以及 `卡片名 → 该写进链接的名字`。
624
+
625
+ **合并而不是只看一条线**,理由与 `count_cards_per_concept` 相同:收藏线与"我的笔记"线
626
+ 同样往 `Wiki/Concepts` 写,只看一个目录会把它们的来源当成"找不到出处的旧行"。
627
+
628
+ 映射表是**必需**的:聊天卡没有 `from` 字段,聚合出来的是裸会话名,而库里是
629
+ `Sources/Chat/会话-<名字>.md` —— 直接写 `r['file']` 会造出 129 条断链。
630
+ """
631
+ merged, mapping = {}, {}
632
+ for d in all_card_dirs(card_dirs):
633
+ mapping.update(build_source_name_map(d))
634
+ for name, refs in aggregate_concepts(scan_articles(d)).items():
635
+ merged.setdefault(name, []).extend(refs)
636
+ return merged, mapping
637
+
638
+
639
+ SOURCE_SECTION = '## 来源'
640
+
641
+
642
+ def replace_source_section(text: str, lines: list) -> str:
643
+ """只重写「## 来源」那一节,别的一个字不动。找不到那一节就原样返回。"""
644
+ head = text.find(SOURCE_SECTION)
645
+ if head == -1:
646
+ return text
647
+ start = text.find('\n''\n', head)
648
+ if start == -1:
649
+ return text
650
+ nxt = text.find('\n' + '## ', start)
651
+ tail = text[nxt:] if nxt != -1 else ''
652
+ return text[:head] + SOURCE_SECTION + '\n\n' + '\n'.join(lines) + '\n' + tail
653
+
654
+
655
+ NORM_STRIP_RE = re.compile(r'[\s\-_·、,.。::/\\()()\[\]【】"\’]+')
656
+
657
+
658
+ def normalize_concept_name(name: str) -> str:
659
+ """判「这两个名字是不是同一个概念」用的规范形。
660
+
661
+ 去空格、连字符、下划线、标点,转小写,再去掉英文复数尾 —— 实测这样能把
662
+ `GPT 5.6`/`GPT-5.6`/`GPT5.6`、`AI 工具`/`AI工具`、`AI skill`/`AI skills` 收到一起。
663
+
664
+ **它也会把 `news`/`new` 收到一起**,所以它只用来**提议**合并;真正的裁决是
665
+ "留来源最多的那张页",而且删之前还有 `--dry-run` 可以看名单。
666
+ """
667
+ stripped = NORM_STRIP_RE.sub('', name.casefold().strip())
668
+ return re.sub(r'(?<=[a-z])s$', '', stripped)
669
+
670
+
671
+ def build_pages_from_cards(pages_dir: str, card_dirs=None, dry_run: bool = False,
672
+ limit: int = 0) -> dict:
673
+ """**零模型**建页:定义直接用卡片里那句 `desc`,相关概念用共现。本地,不花钱。
674
+
675
+ 为什么可以不要模型:候补概念**全部只被一篇文章提到**,所以"综合多篇来源"没有用武之地
676
+ —— 模型能做的只是把那一句改写通顺,而用户对那 ¥31 的评价是"有点贵"。`desc` 是**原文
677
+ 摘的**,对只被提过一次的概念比改写更可靠。
678
+
679
+ 相关概念用**共现**(同一张卡里还出现了哪些概念,实测中位 3 个):那是有据的边 —— 同一
680
+ 篇文章里一起出现 —— 不是模型想出来的。代价是页里少一节「关键要点」,而那一节本来就是
681
+ 模型自己发挥的。
682
+
683
+ frontmatter 里写 `summary_by: card`:**标明定义是卡片里的原话,不是模型写的**(与
684
+ `user_notes` 那条 `summary_by: model` 是同一个惯例 —— 一段机器/他处来的文字被当成
685
+ "这里本来就有的说法",是这类页最该防的事)。
686
+ """
687
+ merged, mapping = merged_concept_refs(card_dirs)
688
+ per_card = {}
689
+ for d in all_card_dirs(card_dirs):
690
+ for article in scan_articles(d):
691
+ per_card[article['file']] = [w[0] for w in article['wikilinks']]
692
+
693
+ existing = {p.stem.casefold() for p in Path(pages_dir).glob('*.md')}
694
+ out_dir = Path(pages_dir)
695
+ today = time.strftime('%Y-%m-%d')
696
+ built, skipped, unwritable, no_material, bad_source = [], 0, 0, 0, 0
697
+ for name, refs in merged.items():
698
+ safe = re.sub(r'[\/:*?"<>|]', '_', name)[:60]
699
+ if safe.casefold() in existing:
700
+ skipped += 1
701
+ continue
702
+ if '[' in name or ']' in name or name.endswith('.md'):
703
+ # 同 `--refresh-sources`:这两类名字体检会报断链(`wiki_lint.resolve` 见到 `.md`
704
+ # 就把它当完整文件名),写进去只是多一批假断链
705
+ unwritable += 1
706
+ continue
707
+ ref = refs[0]
708
+ source_name = str(ref.get('file') or '')
709
+ if '[' in source_name or ']' in source_name or source_name.endswith('.md'):
710
+ # **来源名也要过"写不得"这关**,不只是概念名。这一页**唯一**的来源就指不到东西
711
+ # (理由同 `--refresh-sources`:`wiki_lint.resolve` 见到 `.md` 会把它当完整文件名,
712
+ # 带方括号的连 Obsidian 也解析不了),建出来就是一条断链 —— 整页跳过。
713
+ # 第一版只检查了概念名,lint 抓出 5 条断链(2026-09-28)。
714
+ bad_source += 1
715
+ continue
716
+ definition = str(ref.get('desc') or '').strip()
717
+ if not definition:
718
+ summary = str(ref.get('summary') or '').strip()
719
+ definition = (summary.split('。')[0] + '。') if summary else ''
720
+ if not definition:
721
+ no_material += 1
722
+ continue
723
+ others = [x for x in per_card.get(ref['file'], []) if x != name][:5]
724
+ body = '# %s\n\n%s\n' % (name, definition)
725
+ if others:
726
+ body += '\n## 相关概念\n\n' + ''.join('- [[%s]]\n' % o for o in others)
727
+ body += '\n## 来源\n\n- [[%s]] %s %s\n' % (ref['file'], SEPARATOR, ref['title'])
728
+ fm = {
729
+ 'title': '"%s"' % name,
730
+ 'type': 'concept',
731
+ 'tags': ['知识/概念'] + list(source_kinds_for([ref['file']], card_dirs)),
732
+ 'verified': False,
733
+ 'created': today,
734
+ 'topics': sorted({str(x['topic']) for x in refs if x.get('topic')}),
735
+ 'sources': [ref['file']],
736
+ 'summary_by': 'card',
737
+ }
738
+ if not dry_run:
739
+ out_dir.mkdir(parents=True, exist_ok=True)
740
+ write_with_frontmatter(str(out_dir / ('%s.md' % safe)), fm, body)
741
+ built.append(name)
742
+ if limit and len(built) >= limit:
743
+ break
744
+ return {'built': len(built), 'skipped': skipped, 'unwritable': unwritable,
745
+ 'badSource': bad_source, 'noMaterial': no_material, 'sample': built[:6]}
746
+
747
+
748
+ def merge_duplicate_pages(pages_dir: str, dry_run: bool = False) -> dict:
749
+ """把「规范化后同名」的多张概念页合并成一张。本地,不调模型,可重跑。
750
+
751
+ 改的是**图谱的形状**:`GPT 5.6` / `GPT-5.6` / `GPT5.6` 在图谱上是三个节点,而它们讲的
752
+ 是同一件事。用户的原话是"有的本质上是一样的,但弄成不一样…… 我要确保图谱的有效性,
753
+ 而不是像垃圾那样越堆越多"。实测库里 75 组、156 张页(合并后 75 张)。
754
+
755
+ 顺序重要:
756
+
757
+ 1. 留**来源最多**的那张当主(`claude code` 411 条 vs `ClaudeCode` 7 条);
758
+ 2. 其余的名字写进主页的 `aliases` —— 那 **233 条指向旧名字的链接因此一条都不断**,
759
+ 不必去改别的任何文件(这是整个方案能这么便宜的原因);
760
+ 3. 其余页的来源行并进主页(按名字判重);
761
+ 4. 删掉其余那几张。
762
+
763
+ 第 2 步要写 frontmatter,而 `--refresh-sources` 刻意不写 —— 两件事不冲突:这里只加
764
+ `aliases` 一个键,`sources` 由 `parse_frontmatter` 原样带过去。
765
+ """
766
+ groups = {}
767
+ for page in sorted(Path(pages_dir).glob('*.md')):
768
+ groups.setdefault(normalize_concept_name(page.stem), []).append(page)
769
+
770
+ def source_count(page):
771
+ return len(iter_source_links(page.read_text(encoding='utf-8')))
772
+
773
+ merged, removed, aliased, moved = 0, [], 0, 0
774
+ examples = []
775
+ for _key, group in sorted(groups.items()):
776
+ if len(group) < 2:
777
+ continue
778
+ # 留来源最多的当主;平手按名字排——**别让文件系统的顺序决定谁活下来**
779
+ group.sort(key=lambda p: (-source_count(p), p.stem))
780
+ main, others = group[0], group[1:]
781
+
782
+ text = main.read_text(encoding='utf-8')
783
+ fm, body = parse_frontmatter(text)
784
+ aliases = [str(a) for a in (fm.get('aliases') or [])]
785
+ keep, have = [], set()
786
+ for line in text.split('\n'):
787
+ if line.startswith('- [[') and ']] ' + SEPARATOR + ' ' in line:
788
+ name = line[4:line.find(']] ' + SEPARATOR + ' ')]
789
+ if name not in have:
790
+ have.add(name)
791
+ keep.append(line)
792
+ for other in others:
793
+ other_text = other.read_text(encoding='utf-8')
794
+ for line in other_text.split('\n'):
795
+ if line.startswith('- [[') and ']] ' + SEPARATOR + ' ' in line:
796
+ name = line[4:line.find(']] ' + SEPARATOR + ' ')]
797
+ if name not in have:
798
+ have.add(name)
799
+ keep.append(line)
800
+ moved += 1
801
+ other_fm, _ = parse_frontmatter(other_text)
802
+ for name in [other.stem] + [str(x) for x in (other_fm.get('aliases') or [])]:
803
+ if name != main.stem and name not in aliases:
804
+ aliases.append(name)
805
+ aliased += 1
806
+ removed.append(other.name)
807
+ if not dry_run:
808
+ other.unlink()
809
+ merged += 1
810
+ examples.append({'main': main.stem, 'absorbed': [o.stem for o in others]})
811
+ if not dry_run:
812
+ fm['aliases'] = sorted(aliases)
813
+ write_with_frontmatter(str(main), fm,
814
+ replace_source_section(body, sorted(keep)) if keep else body)
815
+ return {'groups': merged, 'removed': removed, 'aliased': aliased,
816
+ 'movedSources': moved, 'examples': examples[:8]}
817
+
818
+
819
+ def refresh_sources(pages_dir: str, card_dirs=None, dry_run: bool = False) -> dict:
820
+ """把已有概念页的「来源」段补齐成**当前的全部来源**。本地,不调模型。
821
+
822
+ 为什么要有这一趟:实测拉新文章后图谱只"长新节点",**老页一个字节都不动** —— 新文章
823
+ 再讲到 `DeepSeek`,那一页的来源也不会多一条。而 `OPERATIONS.md` 里写着"概念页是累积的、
824
+ 卡是快照、概念是账本"。这一趟是让那句话成真。
825
+
826
+ **只改正文,不动 frontmatter。** `sources:` 与正文是**故意不一样**的两套名字:
827
+ 正文要 Obsidian 能解析(聊天线写 `会话-<名字>`,指向 `Sources/Chat/` 里那张卡),
828
+ 而 `sources:` 要 `source_kinds_for` 能在 `output/<线>/<卡名>.md` 找到(聊天线是
829
+ **不带**前缀的卡名)。同步任何一边都会让 `来源/聊天` 这类标签静默失效 —— 实测改
830
+ frontmatter 会让聊天线的可推断性从 100% 掉到 0,而标签只增不减,**看起来仍然是对的**。
831
+
832
+ 只增不删:页里现有、而当前卡片里找不到出处的行照原样留着(可能是别的线来的),只报数。
833
+ 顺序用 `sorted()` 定成**规范形式**(与历史无关、幂等;实测这样只多动 29 张页)。
834
+ """
835
+ merged, mapping = merged_concept_refs(card_dirs)
836
+ by_case = {}
837
+ for name in merged:
838
+ # 库里存在 `claude code.md` 而概念名是 `Claude Code`:精确匹配会让那 408 条无处可去,
839
+ # 而且没人会知道。`collect_dangling_targets` 在同一个坑上栽过一次。
840
+ by_case.setdefault(name.casefold(), name)
841
+
842
+ changed, added, old_unknown, bracketed, dotmd, reordered = [], 0, 0, 0, 0, 0
843
+ unmatched = []
844
+ for page in sorted(Path(pages_dir).glob('*.md')):
845
+ text = page.read_text(encoding='utf-8')
846
+ concept = by_case.get(page.stem.casefold())
847
+ refs = merged.get(concept) if concept else None
848
+ if not refs:
849
+ unmatched.append(page.name)
850
+ continue
851
+
852
+ # 现有行:**原样保留那一行的文本**(标题在内),只有新追加的才需要重新拼标题
853
+ keep = {}
854
+ for line in text.split('\n'):
855
+ if line.startswith('- [[') and ']] — ' in line:
856
+ keep.setdefault(line[4:line.find(']] — ')], line)
857
+
858
+ want, want_set, by_name = [], set(), {}
859
+ for r in refs:
860
+ name = mapping.get(r['file'], r['file'])
861
+ by_name.setdefault(name, r)
862
+ if name not in want_set:
863
+ want_set.add(name)
864
+ want.append(name)
865
+ old_unknown += sum(1 for n in keep if n not in want_set)
866
+
867
+ fresh = {}
868
+ for name in want:
869
+ if name in keep:
870
+ continue
871
+ if '[' in name or ']' in name:
872
+ # 连 Obsidian 都解析不了(名字里带方括号),写进去只会多一条断链
873
+ bracketed += 1
874
+ continue
875
+ if name.endswith('.md'):
876
+ # 标题**本身**以 `.md` 结尾(实测 1 条)。写进去会让 `wiki_lint.resolve` 把
877
+ # 它当成"完整文件名"直接去查(`wiki_lint.py:123`),而库里那张卡的真名是
878
+ # `….md.md` —— 于是体检从 0 断链变成 1 条。跳过它,只报数。
879
+ dotmd += 1
880
+ continue
881
+ fresh[name] = '- [[%s]] — %s' % (name, by_name[name]['title'])
882
+
883
+ final = [v for _, v in sorted({**keep, **fresh}.items())]
884
+ if final == list(keep.values()):
885
+ continue # 已经是规范形态,一个字都不写
886
+ if not fresh:
887
+ reordered += 1
888
+ changed.append(page.name)
889
+ added += len(fresh)
890
+ if not dry_run:
891
+ page.write_text(replace_source_section(text, final), encoding='utf-8')
892
+ return {'pages': len(changed), 'added': added, 'reordered': reordered,
893
+ 'oldUnknown': old_unknown, 'bracketed': bracketed, 'dotmd': dotmd,
894
+ 'unmatched': len(unmatched), 'names': len(mapping), 'sample': changed[:5]}
895
+
896
+
897
+ def iter_concept_pages(jobs: list, api_key: str, workers: int = CONCEPT_PAGES_WORKERS,
898
+ origin: str = ''):
899
+ """并发生成概念页,**按输入顺序**逐个产出 `((name, refs, out_file), result)`。
900
+
901
+ 生成器而不是列表,理由同 `article_notes.iter_concepts`:整批跑完才返回的话,
902
+ 几千次调用期间磁盘上一页都没有,进程一死全部白花。调用方边收边写。
903
+
904
+ 串行那条路保留原来的 `time.sleep(0.5)`:那是**按篇节流**,只有一次只有一个请求时才
905
+ 有意义。并发那条路不再 sleep——同时有 `workers` 个请求在飞,本身就是另一种节奏,
906
+ 再加一层按篇等待只会把并发收益吃回去。
907
+ """
908
+ def one(job):
909
+ name, refs, _ = job
910
+ try:
911
+ return generate_concept(name, refs, api_key, origin=origin)
912
+ except Exception as error:
913
+ # **不许静默**。原来这里是 `except Exception: return None`,于是
914
+ # `reference_refs` 少给 `file`/`title` 造成的 KeyError 表现成
915
+ # "尝试 158 个、生成 0 页、**无任何报错**"(2026-09-27 实测,查了一阵子)。
916
+ # 一次调用失败不该拖垮整批,但必须留下痕迹。
917
+ print(' [ERR] %s: %s: %s' % (name, type(error).__name__, error), file=sys.stderr)
918
+ return None
919
+
920
+ if workers <= 1:
921
+ for job in jobs:
922
+ yield job, one(job)
923
+ time.sleep(0.5)
924
+ return
925
+ with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
926
+ for job, result in zip(jobs, pool.map(one, jobs)):
927
+ yield job, result
928
+
929
+
930
+ def generate_concept(name: str, refs: list[dict], api_key: str, origin: str = '') -> str | None:
111
931
  """Call DeepSeek to generate a concept Wiki page."""
112
932
  # Build references section
113
- ref_lines = []
114
- for r in refs[:5]: # max 5 references
115
- ref_lines.append(f'- [{r["title"]}]({r["source"]}):{r["summary"][:150]}')
933
+ ref_lines = build_ref_lines(refs)
116
934
  ref_text = '\n'.join(ref_lines) if ref_lines else '(无详细信息)'
117
935
 
118
936
  prompt = CONCEPT_PROMPT.format(name=name, references=ref_text)
@@ -146,33 +964,214 @@ def generate_concept(name: str, refs: list[dict], api_key: str) -> str | None:
146
964
  body_parts.append(f'- [[{rc}]]\n')
147
965
  body_parts.append('\n')
148
966
  body_parts.append('## 来源\n\n')
149
- for r in refs[:5]:
967
+ # **这一节不截断。** 截断是给**喂模型的参考行**留的预算(`build_ref_lines` 的 limit),
968
+ # 而「来源」这一节是账本:`--refresh-sources` 会把已有页补成全量,新页若不跟着写全,
969
+ # 两种页就是两个口径 —— 那正是当初"页里最多 5 条、索引里写着 301"那个缺口的来源。
970
+ for r in refs:
150
971
  body_parts.append(f'- [[{r["file"]}]] — {r["title"]}\n')
151
972
 
152
973
  # Frontmatter
153
- source_files = [r['file'] for r in refs[:5]]
974
+ # 与正文同源同序:`--refresh-sources` 判"要不要写盘"就是看这两者一致不一致
975
+ source_files = [r['file'] for r in refs]
154
976
  today = time.strftime('%Y-%m-%d')
155
977
  fm = {
156
978
  'title': f'"{name}"',
157
979
  'type': 'concept',
158
- 'tags': tags,
980
+ # 嵌套标签(借自那个考公脑库的做法):`tag:#知识/概念` 一键筛出所有知识页,
981
+ # 而模型给的分类词继续当第二层用
982
+ 'tags': ['知识/概念'] + ([origin] if origin else []) + list(tags),
983
+ # **未经人工核验**:这些页是模型生成的。写出来,免得它被当成定论
984
+ # (与那个考公库 README 里"未核验的信息必须明确标注"是同一条纪律)
985
+ 'verified': False,
159
986
  'created': today,
987
+ # 这个概念的来源都来自哪些主题——按主题翻知识库时用得上
988
+ 'topics': sorted({r['topic'] for r in refs if r.get('topic')}),
160
989
  'sources': source_files,
161
990
  }
162
991
 
163
992
  return fm, ''.join(body_parts)
164
993
 
165
994
 
995
+ def count_cards_per_concept(card_dirs=None) -> dict:
996
+ """每个概念被**多少张卡片**提到——跨**所有**产卡目录数。
997
+
998
+ 为什么不能用本次运行的 `concept_map`:索引页是每次 compile 都重写的,而三条线(文章/对话/收藏)
999
+ 各跑一次 compile。用本次的 map 的话,索引会**轮流被覆盖成"只看到最后那条源"的视角**——
1000
+ 实测到的症状是:收藏线产出的概念在索引里显示"引用数 0",而它看着只是"没人引用"。
1001
+ """
1002
+ import glob as _glob
1003
+ counts = {}
1004
+ dirs = card_dirs or sorted(p for p in _glob.glob('output/*-notes') if os.path.isdir(p))
1005
+ for directory in dirs:
1006
+ for path in Path(directory).rglob('*.md'):
1007
+ try:
1008
+ _, body = parse_frontmatter(path.read_text(encoding='utf-8'))
1009
+ except Exception:
1010
+ continue
1011
+ for name in {n.strip() for n in re.findall(r'\[\[([^\]]+)\]\]', body) if n.strip()}:
1012
+ counts[name] = counts.get(name, 0) + 1
1013
+ return counts
1014
+
1015
+
1016
+ def source_kinds_for(page_sources, card_dirs=None) -> list:
1017
+ """这一页的来源卡片住在哪几类目录里 → 该带的来源标签(本地推断,不猜)。
1018
+
1019
+ `sources` 里记的是**卡片文件名**(相对各自产卡目录),所以拿它在 `output/*-notes`
1020
+ 里一找就知道这张页是文章派生的、聊天派生的,还是两者都有。
1021
+ """
1022
+ import glob as _glob
1023
+ dirs = [Path(p) for p in (card_dirs or sorted(p for p in _glob.glob('output/*-notes')
1024
+ if os.path.isdir(p)))]
1025
+ kinds = set()
1026
+ for name in page_sources or []:
1027
+ for directory in dirs:
1028
+ if (directory / str(name)).exists():
1029
+ tag = SOURCE_TAGS.get(directory.name)
1030
+ if tag:
1031
+ kinds.add(tag)
1032
+ break
1033
+ return sorted(kinds)
1034
+
1035
+
1036
+ def relabel_pages(out_dir, card_dirs=None) -> int:
1037
+ """给**已经生成好的**页补上标签与"未核验"标记。**本地、不调用模型。**
1038
+
1039
+ 与 `article_notes --refresh-summaries` 同一个路数:frontmatter 是本地就能算出来的东西,
1040
+ 规则一变不必把 51 个概念重问一遍。只在缺的时候补(幂等)。
1041
+
1042
+ 来源标签(`来源/聊天` 那类)也从**已有信息**推出来:`sources` 里记的是卡片文件名,
1043
+ 去 `output/*-notes` 里一找就知道它住在哪一类目录。
1044
+ """
1045
+ changed = 0
1046
+ for path in sorted(Path(out_dir).glob('*.md')):
1047
+ if path.name == '00-Overview.md':
1048
+ continue
1049
+ content = path.read_text(encoding='utf-8')
1050
+ frontmatter, body = parse_frontmatter(content)
1051
+ tags = [t for t in (frontmatter.get('tags') or []) if t != '知识/概念']
1052
+ wanted = ['知识/概念'] + source_kinds_for(frontmatter.get('sources'), card_dirs) + tags
1053
+ if wanted == list(frontmatter.get('tags') or []) and 'verified' in frontmatter:
1054
+ continue
1055
+ frontmatter['tags'] = wanted
1056
+ frontmatter['verified'] = False
1057
+ write_with_frontmatter(str(path), frontmatter, body)
1058
+ changed += 1
1059
+ return changed
1060
+
1061
+
166
1062
  def main():
167
1063
  sys.stdout.reconfigure(encoding='utf-8', errors='replace')
168
1064
  import argparse
169
1065
  parser = argparse.ArgumentParser(description='概念图谱编译')
170
1066
  parser.add_argument('--api-key', help='DeepSeek API key (或环境变量 DEEPSEEK_API_KEY)')
171
1067
  parser.add_argument('--limit', type=int, default=20, help='最多生成概念数 (默认20)')
1068
+ parser.add_argument('--relabel', action='store_true',
1069
+ help='只给已有页面补标签与未核验标记(本地,不调用模型)')
1070
+ parser.add_argument('--fix-source-links', action='store_true',
1071
+ help='只修已有页面"来源"段里指不到文件的链接(本地,不调用模型)')
1072
+ parser.add_argument('--refresh-sources', action='store_true',
1073
+ help='只把已有页面的"来源"段补齐成**当前全部来源**(本地,不调用模型)')
1074
+ parser.add_argument('--pages-from-cards', action='store_true',
1075
+ help='**零模型**给还没有页的概念建页:定义取卡片里那句原话(本地,不花钱)')
1076
+ parser.add_argument('--merge-duplicates', action='store_true',
1077
+ help='把"规范化后同名"的多张页合并成一张(本地,不调模型;**会删页**)')
1078
+ parser.add_argument('--cards', action='append', default=None, metavar='DIR',
1079
+ help='产卡目录,可重复;默认所有 output/*-notes(与索引的引用数同一口径)')
1080
+ parser.add_argument('--dry-run', action='store_true',
1081
+ help='只报告会改什么,一个字都不写')
1082
+ parser.add_argument('--from-pages', type=int, default=0, metavar='N',
1083
+ help='给"被页面提到最多却没有页"的前 N 个概念建页(参考材料取自页面本身)')
1084
+ parser.add_argument('--min-refs', type=int, default=1,
1085
+ help='至少被几篇材料提到才建页(默认 1;2 能滤掉新闻里的一次性实体)')
1086
+ parser.add_argument('--workers', type=int, default=CONCEPT_PAGES_WORKERS,
1087
+ help='并发生成几页(默认 %d);1 = 串行且按篇节流' % CONCEPT_PAGES_WORKERS)
172
1088
  parser.add_argument('--source', default=SOURCE_ROOT, help='文章目录')
173
1089
  parser.add_argument('--output', default=OUTPUT_ROOT, help='概念页输出目录')
174
1090
  args = parser.parse_args()
175
1091
 
1092
+ if getattr(args, 'relabel', False):
1093
+ # 本地重贴标签:不必有 key、不必调模型
1094
+ changed = relabel_pages(args.output)
1095
+ print('重贴标签:%d 张页面' % changed)
1096
+ return
1097
+
1098
+ if getattr(args, 'from_pages', 0):
1099
+ # 给图谱里那些"悬空节点"的另一端建页——它们不在卡片语料里,材料只能按字面从
1100
+ # 卡片摘要里找(见 `mention_refs`)。
1101
+ #
1102
+ # 这里**不能引用 `api_key`**:它要到下面才被赋值,而这是同一个函数作用域——
1103
+ # 引用一个尚未赋值的局部变量是 UnboundLocalError(2026-09-27 踩过)。
1104
+ key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '') or get_api_key(load_config())
1105
+ if not key:
1106
+ print('这条通道要调模型,需要 key(--api-key 或 DEEPSEEK_API_KEY)', file=sys.stderr)
1107
+ sys.exit(1)
1108
+ result = build_dangling_pages(args.output, key, args.from_pages, args.workers)
1109
+ print('悬空目标 %d 个;本次尝试 %d 个、生成 %d 页、跳过 %d 个'
1110
+ % (result['dangling'], result['attempted'], result['generated'], result['skipped']))
1111
+ return
1112
+
1113
+ if getattr(args, 'fix_source_links', False):
1114
+ # 本地修链接:同上,动的是本地就能算出来的东西,不必把 1,963 页重问一遍
1115
+ result = fix_source_links(args.output, args.source)
1116
+ print('修来源链接:%d 张页面、%d 条链接(可对照的名字 %d 个)'
1117
+ % (len(result['pages']), result['links'], result['names']))
1118
+ return
1119
+
1120
+ if getattr(args, 'pages_from_cards', False):
1121
+ # 零模型建页:定义是卡片里的原话、相关概念是共现,都不经过模型。本地、免费、可重跑。
1122
+ #
1123
+ # **必须显式给 `--cards`**:`--refresh-sources` 是跨全部产卡目录的(它补的是已有页的边,
1124
+ # 越全越好),但**建页不一样** —— 不指定的话聊天线的概念会被建进 `Wiki/Concepts`,
1125
+ # 而两条线是两个知识库(用户 2026-09-27 明确要求分开)。一条线一条线地建。
1126
+ cards = getattr(args, 'cards', None)
1127
+ if not cards:
1128
+ print('这条通道要指定 --cards(一条线一条线地建),例如:', file=sys.stderr)
1129
+ print(' 文章线:--cards output/article-notes --cards output/fav-notes '
1130
+ '--cards output/user-notes', file=sys.stderr)
1131
+ print(' 聊天线:--cards output/chat-notes --output '
1132
+ 'output/wechat-vault/Chat/Concepts', file=sys.stderr)
1133
+ sys.exit(1)
1134
+ result = build_pages_from_cards(args.output, cards,
1135
+ dry_run=getattr(args, 'dry_run', False))
1136
+ head = '预览(一个字都没写)' if getattr(args, 'dry_run', False) else '已建'
1137
+ print('%s:%d 张(跳过已有 %d、概念名写不得 %d、来源指不到 %d、没材料 %d)'
1138
+ % (head, result['built'], result['skipped'], result['unwritable'],
1139
+ result['badSource'], result['noMaterial']))
1140
+ if result['sample']:
1141
+ print(' 例:', '、'.join(result['sample'][:4]))
1142
+ return
1143
+
1144
+ if getattr(args, 'merge_duplicates', False):
1145
+ # 合并"同一个概念的多个写法":改的是图谱的形状(少几个节点),不调模型。
1146
+ # **它会删页**,所以 `--dry-run` 在这里尤其该用(名单先看一遍)。
1147
+ result = merge_duplicate_pages(args.output, dry_run=getattr(args, 'dry_run', False))
1148
+ head = '预览(一个字都没写)' if getattr(args, 'dry_run', False) else '已合并'
1149
+ print('%s:%d 组(删掉 %d 张页、并入 %d 条来源、写了 %d 个别名让旧链接不断)'
1150
+ % (head, result['groups'], len(result['removed']), result['movedSources'],
1151
+ result['aliased']))
1152
+ for ex in result['examples'][:6]:
1153
+ print(' %s ← %s' % (ex['main'], '、'.join(ex['absorbed'])))
1154
+ return
1155
+
1156
+ if getattr(args, 'refresh_sources', False):
1157
+ # 本地补来源:让**已有页**也能长出新边(新文章提到老概念时,那一页的来源要多一条)。
1158
+ # 与 `--fix-source-links` 同族:都不调模型,都只动来源段。区别是那个**只改行首**、
1159
+ # 这个**会追加行**(并且把顺序规范化)。
1160
+ result = refresh_sources(args.output, getattr(args, 'cards', None),
1161
+ dry_run=getattr(args, 'dry_run', False))
1162
+ head = '预览(一个字都没写)' if getattr(args, 'dry_run', False) else '已刷新'
1163
+ print('%s:%d 张页面有变化(新增 %d 条来源,其中 %d 张只是顺序变了)'
1164
+ % (head, result['pages'], result['added'], result['reordered']))
1165
+ print(' 产卡目录映射表 %d 条;找不到出处的旧来源行 %d 条(**保留不动**)'
1166
+ % (result['names'], result['oldUnknown']))
1167
+ print(' 名字写不得因此没追加的 %d 条(带方括号 %d + 以 .md 结尾 %d);'
1168
+ '当前卡片里没有对应概念的页 %d 张'
1169
+ % (result['bracketed'] + result['dotmd'], result['bracketed'],
1170
+ result['dotmd'], result['unmatched']))
1171
+ if result['sample']:
1172
+ print(' 例:', '、'.join(result['sample'][:3]))
1173
+ return
1174
+
176
1175
  api_key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '')
177
1176
  if not api_key:
178
1177
  print('[ERROR] 需要 DeepSeek API key')
@@ -194,7 +1193,10 @@ def main():
194
1193
  # Step 2: Aggregate
195
1194
  print(f'\n=== Step 2: 聚合概念 ===')
196
1195
  concept_map = aggregate_concepts(articles)
197
- ranked = sorted(concept_map.items(), key=lambda x: len(x[1]), reverse=True)
1196
+ before = len(concept_map)
1197
+ ranked = rank_concepts(concept_map, args.min_refs)
1198
+ if args.min_refs > 1:
1199
+ print(' 按 --min-refs %d 过滤:%d → %d 个概念' % (args.min_refs, before, len(ranked)))
198
1200
  print(f' 共 {len(ranked)} 个概念(限制 TOP {args.limit})')
199
1201
  for i, (name, refs) in enumerate(ranked[:10]):
200
1202
  print(f' {i+1}. [[{name}]] — {len(refs)} 篇文章引用')
@@ -204,47 +1206,58 @@ def main():
204
1206
  top_concepts = ranked[:args.limit]
205
1207
  generated = 0
206
1208
 
207
- # Load existing concepts to skip
208
- skipped = 0
209
- for name, refs in top_concepts:
210
- safe_name = re.sub(r'[\\/:*?"<>|]', '_', name)[:60]
211
- out_file = out_dir / f'{safe_name}.md'
212
-
213
- if out_file.exists():
214
- skipped += 1
215
- print(f' [SKIP] {name} (已存在)')
1209
+ # 已有页在**花钱之前**滤掉(见 `build_jobs`:先提交再丢弃 = 为已存在的页付一次费)
1210
+ # 另一条线的概念目录一起传进去:一个概念名在库里只能有一张页(见 `sibling_concept_dirs`)
1211
+ jobs, skipped, elsewhere = build_jobs(top_concepts, out_dir, sibling_concept_dirs(out_dir))
1212
+ done = 0
1213
+ # 边收边写:生成器一有结果就落盘,不等整批(见 `iter_concept_pages`)
1214
+ for (name, refs, out_file), result in iter_concept_pages(
1215
+ jobs, api_key, args.workers, origin=origin_tag(args.source)):
1216
+ done += 1
1217
+ if not result:
1218
+ print(f' [ERR] {name} 没生成出来({done}/{len(jobs)})', file=sys.stderr)
216
1219
  continue
1220
+ print(f' [{done}/{len(jobs)}] {name} ({len(refs)} 引用)')
1221
+ fm, body = result
1222
+ write_with_frontmatter(str(out_file), fm, body)
1223
+ generated += 1
217
1224
 
218
- print(f' [{generated+1}/{args.limit}] {name} ({len(refs)} 引用)...')
219
- result = generate_concept(name, refs, api_key)
220
- if result:
221
- fm, body = result
222
- write_with_frontmatter(str(out_file), fm, body)
223
- generated += 1
224
- time.sleep(0.5)
225
-
226
- if skipped:
227
- print(f' 跳过 {skipped} 个已有概念')
1225
+ note = skip_note(skipped, elsewhere)
1226
+ if note:
1227
+ print(note)
228
1228
  print(f' 生成 {generated} 个新概念')
229
1229
 
230
1230
  # Step 4: Index
231
1231
  print(f'\n=== Step 4: 生成索引 ===')
232
1232
  concept_files = sorted(out_dir.glob('*.md'))
1233
+ # 这一页是**哪条线**的索引:写死 `Wiki/Concepts` 的话,聊天线生成的索引会把图谱筛选框指到文章线去
1234
+ vault_relative = '/'.join(out_dir.parts[-2:])
233
1235
  index_lines = [
234
- '# 概念索引',
1236
+ f'# 概念索引({vault_relative})',
235
1237
  '',
236
1238
  f'共 {len(concept_files)} 个概念 | 生成时间:{time.strftime("%Y-%m-%d %H:%M")}',
237
1239
  '',
1240
+ '**这一页是生成的**:重跑 `weflow-cli wiki compile` 会覆盖它,也会覆盖每个概念页。'
1241
+ '要改内容,改上游的材料(卡片)再重跑,别在笔记里直接改——手改会在下次导出时消失。',
1242
+ '',
1243
+ '**这些页由模型生成、未经人工核验**(每个概念页的 frontmatter 里 `verified: false`)。'
1244
+ '它们可以当线索用,别当成定论。',
1245
+ '',
1246
+ '**看关系图谱请带筛选**:这个库里有上万条材料,直接开全局图谱会卡住。'
1247
+ f'只想看知识页,把图谱左上角的筛选框填成 `path:"{vault_relative}"`;'
1248
+ '更顺手的日常用法是**局部图谱**(打开任意一页 → 右上角更多 →「局部图谱」),只加载邻接节点,秒开。',
1249
+ '',
238
1250
  '| # | 概念 | 引用数 |',
239
1251
  '|---|------|--------|',
240
1252
  ]
1253
+ # 跨所有产卡目录数引用(见 `count_cards_per_concept` 的注释:不能用本次的 concept_map)
1254
+ all_counts = count_cards_per_concept()
1255
+ concept_files.sort(key=lambda p: str(parse_frontmatter(p.read_text(encoding='utf-8'))[0].get('title', p.stem)))
241
1256
  for i, cf in enumerate(concept_files):
242
- with open(cf, 'r', encoding='utf-8') as f:
243
- content = f.read()
244
- fm, _ = parse_frontmatter(content)
245
- title = fm.get('title', cf.stem)
246
- count = len(concept_map.get(title, []))
247
- index_lines.append(f'| {i+1} | [[{title}]] | {count} |')
1257
+ fm, _ = parse_frontmatter(cf.read_text(encoding='utf-8'))
1258
+ # **标题要先去引号再当键**:写出去的是 `title: "甲"`(YAML 安全),而这里的键是裸的 `甲`
1259
+ title = str(fm.get('title', cf.stem)).strip().strip('"')
1260
+ index_lines.append(f'| {i+1} | [[{title}]] | {all_counts.get(title, 0)} |')
248
1261
 
249
1262
  index_path = out_dir.parent / '00-Overview.md'
250
1263
  with open(index_path, 'w', encoding='utf-8') as f: