weflow-cli 1.8.2 → 1.10.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 (185) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1290 -0
  3. package/OPERATIONS.md +144 -11
  4. package/README.en.md +20 -4
  5. package/README.md +30 -5
  6. package/bin/weflow-cli-electron.cjs +2 -0
  7. package/bin/weflow-cli.ts +996 -118
  8. package/dist/bin/weflow-cli.js +1038 -146
  9. package/dist/bin/weflow-cli.js.map +1 -1
  10. package/dist/mcp-server/http.d.ts +14 -0
  11. package/dist/mcp-server/http.d.ts.map +1 -0
  12. package/dist/mcp-server/http.js +84 -0
  13. package/dist/mcp-server/http.js.map +1 -0
  14. package/dist/mcp-server/httpConfig.d.ts +50 -0
  15. package/dist/mcp-server/httpConfig.d.ts.map +1 -0
  16. package/dist/mcp-server/httpConfig.js +104 -0
  17. package/dist/mcp-server/httpConfig.js.map +1 -0
  18. package/dist/mcp-server/index.d.ts +13 -1
  19. package/dist/mcp-server/index.d.ts.map +1 -1
  20. package/dist/mcp-server/index.js +58 -374
  21. package/dist/mcp-server/index.js.map +1 -1
  22. package/dist/src/core/dbPathService.d.ts +12 -0
  23. package/dist/src/core/dbPathService.d.ts.map +1 -1
  24. package/dist/src/core/dbPathService.js +20 -3
  25. package/dist/src/core/dbPathService.js.map +1 -1
  26. package/dist/src/panel/server.d.ts.map +1 -1
  27. package/dist/src/panel/server.js +61 -1
  28. package/dist/src/panel/server.js.map +1 -1
  29. package/dist/src/services/assistantEval.d.ts +69 -0
  30. package/dist/src/services/assistantEval.d.ts.map +1 -1
  31. package/dist/src/services/assistantEval.js +270 -8
  32. package/dist/src/services/assistantEval.js.map +1 -1
  33. package/dist/src/services/assistantScenes.d.ts +81 -0
  34. package/dist/src/services/assistantScenes.d.ts.map +1 -0
  35. package/dist/src/services/assistantScenes.js +322 -0
  36. package/dist/src/services/assistantScenes.js.map +1 -0
  37. package/dist/src/services/assistantService.d.ts +33 -4
  38. package/dist/src/services/assistantService.d.ts.map +1 -1
  39. package/dist/src/services/assistantService.js +102 -6
  40. package/dist/src/services/assistantService.js.map +1 -1
  41. package/dist/src/services/assistantSkills.d.ts +94 -0
  42. package/dist/src/services/assistantSkills.d.ts.map +1 -0
  43. package/dist/src/services/assistantSkills.js +330 -0
  44. package/dist/src/services/assistantSkills.js.map +1 -0
  45. package/dist/src/services/assistantTools.d.ts +34 -1
  46. package/dist/src/services/assistantTools.d.ts.map +1 -1
  47. package/dist/src/services/assistantTools.js +931 -88
  48. package/dist/src/services/assistantTools.js.map +1 -1
  49. package/dist/src/services/assistantTrace.d.ts +4 -0
  50. package/dist/src/services/assistantTrace.d.ts.map +1 -1
  51. package/dist/src/services/assistantTrace.js +9 -0
  52. package/dist/src/services/assistantTrace.js.map +1 -1
  53. package/dist/src/services/configService.d.ts +9 -0
  54. package/dist/src/services/configService.d.ts.map +1 -1
  55. package/dist/src/services/configService.js +5 -2
  56. package/dist/src/services/configService.js.map +1 -1
  57. package/dist/src/services/messageQuery.d.ts +18 -0
  58. package/dist/src/services/messageQuery.d.ts.map +1 -1
  59. package/dist/src/services/messageQuery.js +29 -0
  60. package/dist/src/services/messageQuery.js.map +1 -1
  61. package/dist/src/services/outboundSend.d.ts +53 -0
  62. package/dist/src/services/outboundSend.d.ts.map +1 -0
  63. package/dist/src/services/outboundSend.js +90 -0
  64. package/dist/src/services/outboundSend.js.map +1 -0
  65. package/dist/src/services/pythonBridge.d.ts.map +1 -1
  66. package/dist/src/services/pythonBridge.js +1 -0
  67. package/dist/src/services/pythonBridge.js.map +1 -1
  68. package/dist/src/services/wechatClient.d.ts +125 -0
  69. package/dist/src/services/wechatClient.d.ts.map +1 -0
  70. package/dist/src/services/wechatClient.js +344 -0
  71. package/dist/src/services/wechatClient.js.map +1 -0
  72. package/dist/src/services/wechatMedia.d.ts +44 -0
  73. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  74. package/dist/src/services/wechatMedia.js +155 -0
  75. package/dist/src/services/wechatMedia.js.map +1 -0
  76. package/dist/src/services/wechatMessageService.d.ts +11 -0
  77. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  78. package/dist/src/services/wechatMessageService.js +55 -11
  79. package/dist/src/services/wechatMessageService.js.map +1 -1
  80. package/dist/src/types.d.ts +4 -0
  81. package/dist/src/types.d.ts.map +1 -1
  82. package/dist/src/utils/python.js +2 -2
  83. package/dist/src/utils/python.js.map +1 -1
  84. package/docs/BRANCHES.md +37 -0
  85. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  86. package/docs/DECISIONS.md +1624 -0
  87. package/docs/EXTENDING.md +98 -10
  88. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
  89. package/docs/HEALTH-CHECK.md +12 -7
  90. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  91. package/docs/MCP.md +58 -4
  92. package/docs/PROJECT_STATE.md +180 -11
  93. package/docs/RELEASING.md +115 -1
  94. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  95. package/logo.png +0 -0
  96. package/mcp-server/http.ts +108 -0
  97. package/mcp-server/httpConfig.ts +126 -0
  98. package/mcp-server/index.ts +359 -681
  99. package/package.json +5 -3
  100. package/resources/js/graph3d/NOTICE.txt +35 -0
  101. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  102. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  103. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  104. package/resources/js/graph3d/d3-octree.min.js +2 -0
  105. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  106. package/resources/js/graph3d/d3-timer.min.js +2 -0
  107. package/resources/js/graph3d/layout.mjs +63 -0
  108. package/resources/js/graph3d/three.min.js +6 -0
  109. package/resources/js/graph3d/viewer2d.js +448 -0
  110. package/resources/panel/ball-position.cjs +48 -2
  111. package/resources/panel/index.html +18 -4
  112. package/resources/panel/main.cjs +137 -5
  113. package/resources/panel/mascot-base.png +0 -0
  114. package/resources/panel/mascot-blink-1.png +0 -0
  115. package/resources/panel/mascot-blink-2.png +0 -0
  116. package/resources/panel/mascot-iris.png +0 -0
  117. package/resources/panel/mascot-lift-down-1.png +0 -0
  118. package/resources/panel/mascot-lift-down-2.png +0 -0
  119. package/resources/panel/mascot-lift-down-3.png +0 -0
  120. package/resources/panel/mascot-lift-down-4.png +0 -0
  121. package/resources/panel/mascot-lift-down-5.png +0 -0
  122. package/resources/panel/mascot-lift-down-6.png +0 -0
  123. package/resources/panel/mascot-lift-up-1.png +0 -0
  124. package/resources/panel/mascot-lift-up-2.png +0 -0
  125. package/resources/panel/mascot-lift-up-3.png +0 -0
  126. package/resources/panel/mascot-lift-up-4.png +0 -0
  127. package/resources/panel/mascot-lift-up-5.png +0 -0
  128. package/resources/panel/mascot-lift-up-6.png +0 -0
  129. package/resources/panel/mascot-peek.png +0 -0
  130. package/resources/panel/mascot-tickle-1.png +0 -0
  131. package/resources/panel/mascot-tickle-2.png +0 -0
  132. package/resources/panel/mascot-tickle-3.png +0 -0
  133. package/resources/panel/mascot-tickle-4.png +0 -0
  134. package/resources/panel/panel.css +205 -59
  135. package/resources/panel/preload.cjs +26 -0
  136. package/resources/panel/renderer.js +646 -17
  137. package/scripts/_utils.py +1203 -931
  138. package/scripts/backfill_articles.py +202 -21
  139. package/scripts/biz_daily.py +26 -34
  140. package/scripts/bonds.py +331 -0
  141. package/scripts/chat_notes.py +561 -470
  142. package/scripts/chat_stats.py +8 -7
  143. package/scripts/check_readme_commands.py +426 -0
  144. package/scripts/compile_wiki.py +187 -59
  145. package/scripts/contact_schema.py +339 -0
  146. package/scripts/data/wechat_emoji.json +225 -0
  147. package/scripts/export_chat_html.py +3 -1
  148. package/scripts/fav_notes.py +4 -1
  149. package/scripts/graph_2d.py +151 -0
  150. package/scripts/graph_3d.py +646 -0
  151. package/scripts/health_check.py +163 -26
  152. package/scripts/mcp_bridge.py +6 -7
  153. package/scripts/nt_decrypt.py +3 -3
  154. package/scripts/panel_frames.py +683 -0
  155. package/scripts/pipeline.py +21 -10
  156. package/scripts/quality_eval.py +5 -0
  157. package/scripts/rag_chat.py +33 -16
  158. package/scripts/reply_debt.py +26 -6
  159. package/scripts/semantic_search.py +130 -22
  160. package/scripts/vault_rag.py +42 -16
  161. package/scripts/vault_search.py +44 -8
  162. package/scripts/wechat_emoticon.py +6 -0
  163. package/scripts/wechat_voice.py +9 -0
  164. package/scripts/wiki_lint.py +124 -24
  165. package/src/core/dbPathService.ts +22 -4
  166. package/src/panel/server.ts +58 -1
  167. package/src/services/assistantEval.ts +323 -12
  168. package/src/services/assistantScenes.ts +342 -0
  169. package/src/services/assistantService.ts +107 -6
  170. package/src/services/assistantSkills.ts +364 -0
  171. package/src/services/assistantTools.ts +2452 -1640
  172. package/src/services/assistantTrace.ts +9 -0
  173. package/src/services/configService.ts +415 -403
  174. package/src/services/messageQuery.ts +29 -0
  175. package/src/services/outboundSend.ts +126 -0
  176. package/src/services/pythonBridge.ts +1 -0
  177. package/src/services/wechatClient.ts +380 -0
  178. package/src/services/wechatMedia.ts +166 -0
  179. package/src/services/wechatMessageService.ts +62 -11
  180. package/src/types.ts +4 -0
  181. package/src/utils/python.ts +2 -2
  182. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  183. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  184. package/dist/src/utils/wechatEmoji.js +0 -35
  185. package/dist/src/utils/wechatEmoji.js.map +0 -1
@@ -23,9 +23,11 @@ from pathlib import Path
23
23
  from collections import defaultdict, Counter
24
24
 
25
25
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
- from _utils import (CHAT_CARD_PREFIX, CONCEPT_DIRS, call_deepseek, # noqa: E402
27
- normalize_concept_name, SEPARATOR, get_api_key, load_config,
28
- parse_frontmatter, write_with_frontmatter)
26
+ from _utils import (CHAT_CARD_PREFIX, KNOWLEDGE_LINES, LinkNamer, call_deepseek, # noqa: E402
27
+ concept_link_target, concept_names, is_line_qualified,
28
+ line_for_relative_concepts, link_target_name, normalize_concept_name,
29
+ SEPARATOR, sibling_concepts_dir, get_api_key, load_config, parse_frontmatter,
30
+ write_with_frontmatter)
29
31
 
30
32
  # Default paths
31
33
  SOURCE_ROOT = 'output/biz-daily'
@@ -230,6 +232,103 @@ def fix_source_links(pages_dir: str, source_dir: str) -> dict:
230
232
  return {'pages': pages, 'links': links, 'names': len(mapping)}
231
233
 
232
234
 
235
+ # 「相关概念」那一段里的一行是 `- [[名字]]`(可能带 `|显示文本`)。
236
+ WIKILINK_RE = re.compile(r'\[\[([^\]]+)\]\]')
237
+
238
+
239
+ def _qualify_text(text, line_id, ambiguous, concept_page=True):
240
+ """把指向 `ambiguous` 里那些名字的链接改成带路径;返回 `(新文本, 改了几条)`。
241
+
242
+ **避开概念页的 `## 来源` 段**:那一段的链接指的是**卡片**(`- [[会话-甲]] — 标题`),
243
+ 把它改成概念页是另一回事。实测库里这两段的二义链接分布是"相关概念 462 / 来源 0",
244
+ 所以这条边界没有割掉任何该改的东西——但它挡住了"改错对象"这一类。
245
+ """
246
+ head, sep, tail = text, '', ''
247
+ if concept_page:
248
+ at = text.find('\n## 来源')
249
+ if at >= 0:
250
+ head, sep, tail = text[:at], '\n## 来源', text[at + len('\n## 来源'):]
251
+ changed = [0]
252
+
253
+ def repl(match):
254
+ raw = match.group(1)
255
+ name = link_target_name(raw)
256
+ if name in ambiguous and not is_line_qualified(raw):
257
+ changed[0] += 1
258
+ return '[[%s]]' % concept_link_target(line_id, name, ambiguous)
259
+ return match.group(0)
260
+
261
+ return WIKILINK_RE.sub(repl, head) + sep + tail, changed[0]
262
+
263
+
264
+ def qualify_links(out_dir, dry_run: bool = False) -> dict:
265
+ """把**跨线同名**的概念链接改成带路径的形式(`Chat/Concepts/DeepSeek|DeepSeek`)。
266
+
267
+ 为什么要有:一个 Obsidian 库里两条线各有一张同名页时,裸 `[[DeepSeek]]` 是二义的
268
+ (官方判据:会 deterministic 解析,但不保证命中你想要的那张)。用户 2026-10-10 选的
269
+ 修法是**同库内消歧**(不是把两条线拆成两个 Obsidian 库),所以链接按名字带上路径。
270
+
271
+ **只改"两条线都有"的名字**:只属于一条线的名字,裸写法本来就确定了;全库重写一遍
272
+ 4.9 万张页换不来任何解析上的差别,而那种 diff 没人看得完。
273
+
274
+ 扫哪些文件:两条线的概念目录、各自旁边的 `00-Overview.md`、以及聊天卡
275
+ (`Sources/Chat/*.md`,它们的 `- [[概念]] — 说明` 也是概念链接)。**实测**(2026-10-10)
276
+ 全库指向这 36 个名字的链接一共 539 条,全都落在这三类里(`001_Daily` / `002_Literature` /
277
+ `Sources/WeChat` 一条都没有),所以不扫其余层不会漏。
278
+
279
+ 认不出线(`--output` 指到库外)时**什么都不做**并说明原因:按猜出来的线改链接,
280
+ 会把链接指到错误的目录,而那不会报错。
281
+ """
282
+ out_dir = Path(out_dir)
283
+ namer = LinkNamer.for_out_dir(out_dir)
284
+ sibling = sibling_concepts_dir(out_dir)
285
+ other_line = (line_for_relative_concepts('/'.join(Path(sibling).parts[-2:]))
286
+ if sibling else None)
287
+ if not namer.line_id or not other_line:
288
+ return {'ok': False, 'reason': '这个输出目录不属于清单里的任何一条线(或找不到另一条线),'
289
+ '不猜着改'}
290
+ my_line = namer.line_id
291
+
292
+ ambiguous = concept_names(out_dir) & concept_names(sibling)
293
+ # 一张表列出**文件**(不是目录):`(路径, 它属于哪条线, 是不是概念页)`。
294
+ # 概念目录要展开成 `*.md`;00-Overview 与聊天卡各自是单个文件。
295
+ targets = []
296
+ for line_dir, line_id in ((out_dir, my_line), (Path(sibling), other_line)):
297
+ for page in sorted(line_dir.glob('*.md')):
298
+ targets.append((page, line_id, True))
299
+ overview = line_dir.parent / '00-Overview.md'
300
+ if overview.is_file():
301
+ targets.append((overview, line_id, False))
302
+ cards = out_dir.parent.parent / 'Sources' / 'Chat'
303
+ if cards.is_dir():
304
+ for card in sorted(cards.glob('*.md')):
305
+ targets.append((card, 'chat', False))
306
+
307
+ result = {'ok': True, 'ambiguous': len(ambiguous), 'files': 0, 'changed': 0,
308
+ 'pages': [], 'samples': [], 'errors': [], 'dryRun': bool(dry_run),
309
+ 'scanned': [str(t[0]) for t in targets]}
310
+ if not ambiguous:
311
+ return result # 没有同名就没有二义,什么都不用改
312
+ for path, line_id, is_concept_page in targets:
313
+ try:
314
+ text = path.read_text(encoding='utf-8')
315
+ except OSError as error:
316
+ # 读不出来的要**说出来**:静默跳过会让"已消歧"变成一句假话
317
+ result['errors'].append('%s: %s' % (path.name, error))
318
+ continue
319
+ updated, hits = _qualify_text(text, line_id, ambiguous, concept_page=is_concept_page)
320
+ result['files'] += 1
321
+ if not hits:
322
+ continue
323
+ result['changed'] += hits
324
+ result['pages'].append(path.name)
325
+ if len(result['samples']) < 5:
326
+ result['samples'].append('%s(%d 条)' % (path.name, hits))
327
+ if not dry_run:
328
+ path.write_text(updated, encoding='utf-8')
329
+ return result
330
+
331
+
233
332
  def collect_dangling_targets(pages_dir: str) -> Counter:
234
333
  """现有概念页提到、但**自己没有页**的概念 → 被多少张页提到。
235
334
 
@@ -524,64 +623,38 @@ def origin_tag(source_dir: str) -> str:
524
623
  CONCEPT_PAGES_WORKERS = 6
525
624
 
526
625
 
527
- def sibling_concept_dirs(out_dir: Path) -> list:
528
- """同一个库里**另一条线**的概念目录(有的话)。
626
+ def skip_note(skipped: int) -> str:
627
+ """跳过多少、为什么 —— 现在**只有一类原因**:本目录已有这张页(重跑,正常)。
529
628
 
530
- **一个概念名在库里只能有一张页。** 两张同名页会让 `[[DeepSeek]]` 在 Obsidian 里变成二义的
531
- ——它挑一张连上,另一张等同于断了,而**两边都不报错**。这正是聊天卡要加 `会话-` 前缀的原因;
532
- 概念页没有前缀可用,所以只能用「另一条线已有同名页就不再建」来保证名字唯一。2026-09-27 实测:
533
- 分开目录之后,聊天线立刻为 11 个文章线已有的概念各建了一张同名页,`wiki lint` 报"同名页 11 组"。
629
+ 2026-10-10 之前还有第二类"另一条线已有同名页,故意不建"(那个 `sibling_concept_dirs`
630
+ 已经被删掉),现在取消了:用户要求两个概念**命名空间分开**,两条线各留一张同名页是设计。
631
+ 代价写在该条 DECISIONS 里(Obsidian 里同名裸链接是二义的),取代 D-051 那一段。
534
632
 
535
- 只在这个输出目录**确实落在一条已知的知识库线上**时才去找:`--output` 指到库外的任意目录时,
536
- 那里没有"另一条线",也就没有什么可跳过的。
633
+ 留着这个函数(而不是在 `main()` 里内联一句 f-string)是因为它**能被测**:
634
+ 内联的那句改坏了不会有任何东西红。
537
635
  """
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
-
636
+ return f' 跳过 {skipped} 个已有概念' if skipped else ''
551
637
 
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
638
 
564
-
565
- def build_jobs(top_concepts: list, out_dir, other_dirs: list = ()) -> tuple:
639
+ def build_jobs(top_concepts: list, out_dir) -> tuple:
566
640
  """滤掉**已经有页**的概念,返回 `([(name, refs, out_file)], 跳过数)`。
567
641
 
642
+ **只按本目录判**:这是"重跑不重复建"。跨线同名**不再跳过** —— 两条线各有一张
643
+ `DeepSeek` 是设计(用户要求两个概念命名空间分开),跳过才是那个要让路的老行为。
644
+
568
645
  这一步必须在**提交给线程池之前**做。原来那版是串行循环里 `if out_file.exists(): continue`,
569
646
  顺序上天然不会为已存在的页花钱;一旦改成并发,"先提交、拿到结果再丢"就变成了**为一个
570
647
  已存在的页付一次费**——而且不报错,只体现在账单上。
571
648
  """
572
- jobs, skipped, elsewhere = [], 0, 0
649
+ jobs, skipped = [], 0
573
650
  for name, refs in top_concepts:
574
651
  safe_name = re.sub(r'[\\/:*?"<>|]', '_', name)[:60]
575
652
  out_file = out_dir / f'{safe_name}.md'
576
653
  if out_file.exists():
577
654
  skipped += 1
578
655
  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
656
  jobs.append((name, refs, out_file))
584
- return jobs, skipped, elsewhere
657
+ return jobs, skipped
585
658
 
586
659
 
587
660
  def all_card_dirs(card_dirs=None) -> list:
@@ -657,7 +730,7 @@ def replace_source_section(text: str, lines: list) -> str:
657
730
 
658
731
 
659
732
  def build_pages_from_cards(pages_dir: str, card_dirs=None, dry_run: bool = False,
660
- limit: int = 0) -> dict:
733
+ limit: int = 0, namer=None) -> dict:
661
734
  """**零模型**建页:定义直接用卡片里那句 `desc`,相关概念用共现。本地,不花钱。
662
735
 
663
736
  为什么可以不要模型:候补概念**全部只被一篇文章提到**,所以"综合多篇来源"没有用武之地
@@ -680,12 +753,22 @@ def build_pages_from_cards(pages_dir: str, card_dirs=None, dry_run: bool = False
680
753
 
681
754
  existing = {p.stem.casefold() for p in Path(pages_dir).glob('*.md')}
682
755
  out_dir = Path(pages_dir)
756
+ namer = namer or LinkNamer.for_out_dir(out_dir)
683
757
  today = time.strftime('%Y-%m-%d')
684
- built, skipped, unwritable, no_material, bad_source = [], 0, 0, 0, 0
758
+ # **只差大小写的两个概念名,在 Windows 上是同一个文件。** 原来的实现让后一个覆盖前一个:
759
+ # 一页**没了**,而 `built` 把两个都算了(实测 49,953 页里丢 53:AI 那批 44、这批 9)。
760
+ # 按"文件名(大小写折叠后)"分组 —— 一个文件一张页,其余写法进 `aliases`。这不是新规矩:
761
+ # `--merge-duplicates` 对"同一个概念的不同写法"做的就是这件事,`wiki_lint` 也一直认别名。
762
+ groups: dict = {}
685
763
  for name, refs in merged.items():
764
+ groups.setdefault(re.sub(r'[\/:*?"<>|]', '_', name)[:60].casefold(), []).append((name, refs))
765
+
766
+ built, skipped, unwritable, no_material, bad_source = [], 0, 0, 0, 0
767
+ for members in groups.values():
768
+ name, refs = members[0] # 组里第一个当正式名,其余进 aliases
686
769
  safe = re.sub(r'[\/:*?"<>|]', '_', name)[:60]
687
770
  if safe.casefold() in existing:
688
- skipped += 1
771
+ skipped += len(members)
689
772
  continue
690
773
  if '[' in name or ']' in name or name.endswith('.md'):
691
774
  # 同 `--refresh-sources`:这两类名字体检会报断链(`wiki_lint.resolve` 见到 `.md`
@@ -711,7 +794,8 @@ def build_pages_from_cards(pages_dir: str, card_dirs=None, dry_run: bool = False
711
794
  others = [x for x in per_card.get(ref['file'], []) if x != name][:5]
712
795
  body = '# %s\n\n%s\n' % (name, definition)
713
796
  if others:
714
- body += '\n## 相关概念\n\n' + ''.join('- [[%s]]\n' % o for o in others)
797
+ # `namer` 管跨线同名:两条线都有这个名字时链接要带路径(见 `_utils.LinkNamer`)
798
+ body += '\n## 相关概念\n\n' + ''.join('- [[%s]]\n' % namer(o) for o in others)
715
799
  body += '\n## 来源\n\n- [[%s]] %s %s\n' % (ref['file'], SEPARATOR, ref['title'])
716
800
  fm = {
717
801
  'title': '"%s"' % name,
@@ -723,6 +807,19 @@ def build_pages_from_cards(pages_dir: str, card_dirs=None, dry_run: bool = False
723
807
  'sources': [ref['file']],
724
808
  'summary_by': 'card',
725
809
  }
810
+ # 别名有两类来源,都是**指向这一页的名字**:
811
+ # 1. 需要净化/截断时保留的原名 —— 否则 Obsidian 里 `[[Qwen3.5:9B]]` 点不开
812
+ # `Qwen3.5_9B.md`,`wiki_lint` 也会把这页报成孤儿(2026-10-01 实测 28 张);
813
+ # 2. 同一批里只差大小写的另一种写法 —— 它们本来指向同一张页。
814
+ # 组里的写法也要过"写不得"那关:带方括号或以 `.md` 结尾的名字进了 aliases
815
+ # 只会给体检添一批假断链(与上面概念名那条同一个理由)。
816
+ aliases = [name] if safe != name else []
817
+ for other, _ in members[1:]:
818
+ if other in aliases or '[' in other or ']' in other or other.endswith('.md'):
819
+ continue
820
+ aliases.append(other)
821
+ if aliases:
822
+ fm['aliases'] = aliases
726
823
  if not dry_run:
727
824
  out_dir.mkdir(parents=True, exist_ok=True)
728
825
  write_with_frontmatter(str(out_dir / ('%s.md' % safe)), fm, body)
@@ -896,7 +993,7 @@ def refresh_sources(pages_dir: str, card_dirs=None, dry_run: bool = False) -> di
896
993
 
897
994
 
898
995
  def iter_concept_pages(jobs: list, api_key: str, workers: int = CONCEPT_PAGES_WORKERS,
899
- origin: str = ''):
996
+ origin: str = '', namer=None):
900
997
  """并发生成概念页,**按输入顺序**逐个产出 `((name, refs, out_file), result)`。
901
998
 
902
999
  生成器而不是列表,理由同 `article_notes.iter_concepts`:整批跑完才返回的话,
@@ -909,7 +1006,7 @@ def iter_concept_pages(jobs: list, api_key: str, workers: int = CONCEPT_PAGES_WO
909
1006
  def one(job):
910
1007
  name, refs, _ = job
911
1008
  try:
912
- return generate_concept(name, refs, api_key, origin=origin)
1009
+ return generate_concept(name, refs, api_key, origin=origin, namer=namer)
913
1010
  except Exception as error:
914
1011
  # **不许静默**。原来这里是 `except Exception: return None`,于是
915
1012
  # `reference_refs` 少给 `file`/`title` 造成的 KeyError 表现成
@@ -928,8 +1025,10 @@ def iter_concept_pages(jobs: list, api_key: str, workers: int = CONCEPT_PAGES_WO
928
1025
  yield job, result
929
1026
 
930
1027
 
931
- def generate_concept(name: str, refs: list[dict], api_key: str, origin: str = '') -> str | None:
1028
+ def generate_concept(name: str, refs: list[dict], api_key: str, origin: str = '',
1029
+ namer=None) -> str | None:
932
1030
  """Call DeepSeek to generate a concept Wiki page."""
1031
+ namer = namer or (lambda x: x)
933
1032
  # Build references section
934
1033
  ref_lines = build_ref_lines(refs)
935
1034
  ref_text = '\n'.join(ref_lines) if ref_lines else '(无详细信息)'
@@ -962,7 +1061,7 @@ def generate_concept(name: str, refs: list[dict], api_key: str, origin: str = ''
962
1061
  if related:
963
1062
  body_parts.append('## 相关概念\n\n')
964
1063
  for rc in related:
965
- body_parts.append(f'- [[{rc}]]\n')
1064
+ body_parts.append(f'- [[{namer(rc)}]]\n')
966
1065
  body_parts.append('\n')
967
1066
  body_parts.append('## 来源\n\n')
968
1067
  # **这一节不截断。** 截断是给**喂模型的参考行**留的预算(`build_ref_lines` 的 limit),
@@ -1074,6 +1173,8 @@ def main():
1074
1173
  help='只把已有页面的"来源"段补齐成**当前全部来源**(本地,不调用模型)')
1075
1174
  parser.add_argument('--pages-from-cards', action='store_true',
1076
1175
  help='**零模型**给还没有页的概念建页:定义取卡片里那句原话(本地,不花钱)')
1176
+ parser.add_argument('--qualify-links', action='store_true',
1177
+ help='把跨线同名的概念链接改成带路径的形式(本地重写,不调模型)')
1077
1178
  parser.add_argument('--merge-duplicates', action='store_true',
1078
1179
  help='把"规范化后同名"的多张页合并成一张(本地,不调模型;**会删页**)')
1079
1180
  parser.add_argument('--cards', action='append', default=None, metavar='DIR',
@@ -1111,6 +1212,25 @@ def main():
1111
1212
  % (result['dangling'], result['attempted'], result['generated'], result['skipped']))
1112
1213
  return
1113
1214
 
1215
+ if getattr(args, 'qualify_links', False):
1216
+ # 本地消歧:两条线同名的那些链接按名字带上路径(`Chat/Concepts/DeepSeek|DeepSeek`),
1217
+ # 免得 Obsidian 里那条裸链接命中另一条线的那张。**不调模型**,所以能随时重跑。
1218
+ dry = getattr(args, 'dry_run', False)
1219
+ result = qualify_links(args.output, dry_run=dry)
1220
+ if not result['ok']:
1221
+ print('没改任何东西:%s' % result['reason'], file=sys.stderr)
1222
+ sys.exit(1)
1223
+ head = '预览(一个字都没写)' if dry else '已消歧'
1224
+ print('%s:两条线同名 %d 个;扫了 %d 个文件,%d 张页上改了 %d 条链接'
1225
+ % (head, result['ambiguous'], result['files'], len(result['pages']), result['changed']))
1226
+ for line in result['samples']:
1227
+ print(' %s' % line)
1228
+ for bad in result['errors']:
1229
+ print(' [WARN] 读不出来:%s' % bad, file=sys.stderr)
1230
+ if not result['changed']:
1231
+ print(' 没有需要改的(要么本来就带路径,要么两条线没有同名概念)')
1232
+ return
1233
+
1114
1234
  if getattr(args, 'fix_source_links', False):
1115
1235
  # 本地修链接:同上,动的是本地就能算出来的东西,不必把 1,963 页重问一遍
1116
1236
  result = fix_source_links(args.output, args.source)
@@ -1129,8 +1249,12 @@ def main():
1129
1249
  print('这条通道要指定 --cards(一条线一条线地建),例如:', file=sys.stderr)
1130
1250
  print(' 文章线:--cards output/article-notes --cards output/fav-notes '
1131
1251
  '--cards output/user-notes', file=sys.stderr)
1132
- print(' 聊天线:--cards output/chat-notes --output '
1133
- 'output/wechat-vault/Chat/Concepts', file=sys.stderr)
1252
+ # 聊天线的输出目录**从清单取**,不写死:两条线各自一个根之后,写死的那份
1253
+ # 会指到一个已经不存在的地方,而它只是一句提示,没人会因此报错。
1254
+ chat = next((' %s/%s' % (root, concepts) for line_id, root, concepts in KNOWLEDGE_LINES
1255
+ if line_id == 'chat'), None)
1256
+ if chat:
1257
+ print(' 聊天线:--cards output/chat-notes --output%s' % chat, file=sys.stderr)
1134
1258
  sys.exit(1)
1135
1259
  result = build_pages_from_cards(args.output, cards,
1136
1260
  dry_run=getattr(args, 'dry_run', False))
@@ -1181,6 +1305,9 @@ def main():
1181
1305
  source_dir = args.source
1182
1306
  out_dir = Path(args.output)
1183
1307
  out_dir.mkdir(parents=True, exist_ok=True)
1308
+ # 跨线同名的链接要带路径(见 `_utils.LinkNamer`):**渲染点有三处**,所以这里建一次、
1309
+ # 一路传下去,而不是每处各判一遍(漏一处就在库里留下一批裸的二义链接)
1310
+ namer = LinkNamer.for_out_dir(out_dir)
1184
1311
 
1185
1312
  # Step 1: Scan
1186
1313
  print(f'=== Step 1: 扫描文章 ===')
@@ -1207,13 +1334,13 @@ def main():
1207
1334
  top_concepts = ranked[:args.limit]
1208
1335
  generated = 0
1209
1336
 
1210
- # 已有页在**花钱之前**滤掉(见 `build_jobs`:先提交再丢弃 = 为已存在的页付一次费)
1211
- # 另一条线的概念目录一起传进去:一个概念名在库里只能有一张页(见 `sibling_concept_dirs`)
1212
- jobs, skipped, elsewhere = build_jobs(top_concepts, out_dir, sibling_concept_dirs(out_dir))
1337
+ # 已有页在**花钱之前**滤掉(见 `build_jobs`:先提交再丢弃 = 为已存在的页付一次费)。
1338
+ # 只按本目录判:跨线同名照建(两条线是两个命名空间),不跳过的理由见 `build_jobs`。
1339
+ jobs, skipped = build_jobs(top_concepts, out_dir)
1213
1340
  done = 0
1214
1341
  # 边收边写:生成器一有结果就落盘,不等整批(见 `iter_concept_pages`)
1215
1342
  for (name, refs, out_file), result in iter_concept_pages(
1216
- jobs, api_key, args.workers, origin=origin_tag(args.source)):
1343
+ jobs, api_key, args.workers, origin=origin_tag(args.source), namer=namer):
1217
1344
  done += 1
1218
1345
  if not result:
1219
1346
  print(f' [ERR] {name} 没生成出来({done}/{len(jobs)})', file=sys.stderr)
@@ -1223,7 +1350,7 @@ def main():
1223
1350
  write_with_frontmatter(str(out_file), fm, body)
1224
1351
  generated += 1
1225
1352
 
1226
- note = skip_note(skipped, elsewhere)
1353
+ note = skip_note(skipped)
1227
1354
  if note:
1228
1355
  print(note)
1229
1356
  print(f' 生成 {generated} 个新概念')
@@ -1258,7 +1385,8 @@ def main():
1258
1385
  fm, _ = parse_frontmatter(cf.read_text(encoding='utf-8'))
1259
1386
  # **标题要先去引号再当键**:写出去的是 `title: "甲"`(YAML 安全),而这里的键是裸的 `甲`
1260
1387
  title = str(fm.get('title', cf.stem)).strip().strip('"')
1261
- index_lines.append(f'| {i+1} | [[{title}]] | {all_counts.get(title, 0)} |')
1388
+ # 索引表的链接同样要消歧(表里指向的也是**这一条线**的那张页)
1389
+ index_lines.append(f'| {i+1} | [[{namer(title)}]] | {all_counts.get(title, 0)} |')
1262
1390
 
1263
1391
  index_path = out_dir.parent / '00-Overview.md'
1264
1392
  with open(index_path, 'w', encoding='utf-8') as f: