weflow-cli 1.9.0 → 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 (128) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +948 -0
  3. package/OPERATIONS.md +65 -11
  4. package/README.en.md +14 -5
  5. package/README.md +21 -6
  6. package/bin/weflow-cli.ts +348 -39
  7. package/dist/bin/weflow-cli.js +363 -68
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.d.ts.map +1 -1
  10. package/dist/mcp-server/index.js +19 -1
  11. package/dist/mcp-server/index.js.map +1 -1
  12. package/dist/src/core/dbPathService.d.ts +12 -0
  13. package/dist/src/core/dbPathService.d.ts.map +1 -1
  14. package/dist/src/core/dbPathService.js +20 -3
  15. package/dist/src/core/dbPathService.js.map +1 -1
  16. package/dist/src/panel/server.d.ts.map +1 -1
  17. package/dist/src/panel/server.js +35 -0
  18. package/dist/src/panel/server.js.map +1 -1
  19. package/dist/src/services/assistantService.d.ts +19 -0
  20. package/dist/src/services/assistantService.d.ts.map +1 -1
  21. package/dist/src/services/assistantService.js +20 -0
  22. package/dist/src/services/assistantService.js.map +1 -1
  23. package/dist/src/services/assistantTools.d.ts +27 -1
  24. package/dist/src/services/assistantTools.d.ts.map +1 -1
  25. package/dist/src/services/assistantTools.js +169 -35
  26. package/dist/src/services/assistantTools.js.map +1 -1
  27. package/dist/src/services/configService.d.ts +2 -0
  28. package/dist/src/services/configService.d.ts.map +1 -1
  29. package/dist/src/services/configService.js +3 -2
  30. package/dist/src/services/configService.js.map +1 -1
  31. package/dist/src/services/messageQuery.d.ts +18 -0
  32. package/dist/src/services/messageQuery.d.ts.map +1 -1
  33. package/dist/src/services/messageQuery.js +29 -0
  34. package/dist/src/services/messageQuery.js.map +1 -1
  35. package/dist/src/services/wechatClient.d.ts +125 -0
  36. package/dist/src/services/wechatClient.d.ts.map +1 -0
  37. package/dist/src/services/wechatClient.js +344 -0
  38. package/dist/src/services/wechatClient.js.map +1 -0
  39. package/dist/src/services/wechatMedia.d.ts +44 -0
  40. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  41. package/dist/src/services/wechatMedia.js +155 -0
  42. package/dist/src/services/wechatMedia.js.map +1 -0
  43. package/dist/src/services/wechatMessageService.d.ts +11 -0
  44. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  45. package/dist/src/services/wechatMessageService.js +55 -11
  46. package/dist/src/services/wechatMessageService.js.map +1 -1
  47. package/dist/src/types.d.ts +4 -0
  48. package/dist/src/types.d.ts.map +1 -1
  49. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  50. package/docs/DECISIONS.md +1274 -0
  51. package/docs/EXTENDING.md +69 -7
  52. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
  53. package/docs/HEALTH-CHECK.md +12 -7
  54. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  55. package/docs/MCP.md +2 -2
  56. package/docs/PROJECT_STATE.md +62 -5
  57. package/docs/RELEASING.md +115 -1
  58. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  59. package/logo.png +0 -0
  60. package/mcp-server/index.ts +18 -3
  61. package/package.json +5 -3
  62. package/resources/js/graph3d/NOTICE.txt +35 -0
  63. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  64. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  65. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  66. package/resources/js/graph3d/d3-octree.min.js +2 -0
  67. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  68. package/resources/js/graph3d/d3-timer.min.js +2 -0
  69. package/resources/js/graph3d/layout.mjs +63 -0
  70. package/resources/js/graph3d/three.min.js +6 -0
  71. package/resources/js/graph3d/viewer2d.js +448 -0
  72. package/resources/panel/index.html +12 -0
  73. package/resources/panel/mascot-blink-1.png +0 -0
  74. package/resources/panel/mascot-blink-2.png +0 -0
  75. package/resources/panel/mascot-lift-down-1.png +0 -0
  76. package/resources/panel/mascot-lift-down-2.png +0 -0
  77. package/resources/panel/mascot-lift-down-3.png +0 -0
  78. package/resources/panel/mascot-lift-down-4.png +0 -0
  79. package/resources/panel/mascot-lift-down-5.png +0 -0
  80. package/resources/panel/mascot-lift-down-6.png +0 -0
  81. package/resources/panel/mascot-lift-up-1.png +0 -0
  82. package/resources/panel/mascot-lift-up-2.png +0 -0
  83. package/resources/panel/mascot-lift-up-3.png +0 -0
  84. package/resources/panel/mascot-lift-up-4.png +0 -0
  85. package/resources/panel/mascot-lift-up-5.png +0 -0
  86. package/resources/panel/mascot-lift-up-6.png +0 -0
  87. package/resources/panel/mascot-tickle-1.png +0 -0
  88. package/resources/panel/mascot-tickle-2.png +0 -0
  89. package/resources/panel/mascot-tickle-3.png +0 -0
  90. package/resources/panel/mascot-tickle-4.png +0 -0
  91. package/resources/panel/panel.css +111 -0
  92. package/resources/panel/renderer.js +480 -8
  93. package/scripts/_utils.py +1203 -977
  94. package/scripts/biz_daily.py +25 -10
  95. package/scripts/chat_notes.py +15 -5
  96. package/scripts/check_readme_commands.py +426 -0
  97. package/scripts/compile_wiki.py +163 -57
  98. package/scripts/contact_schema.py +339 -0
  99. package/scripts/data/wechat_emoji.json +225 -0
  100. package/scripts/fav_notes.py +4 -1
  101. package/scripts/graph_2d.py +151 -0
  102. package/scripts/graph_3d.py +646 -0
  103. package/scripts/health_check.py +163 -26
  104. package/scripts/nt_decrypt.py +3 -3
  105. package/scripts/panel_frames.py +683 -0
  106. package/scripts/pipeline.py +21 -10
  107. package/scripts/quality_eval.py +5 -0
  108. package/scripts/rag_chat.py +33 -16
  109. package/scripts/reply_debt.py +26 -6
  110. package/scripts/semantic_search.py +130 -22
  111. package/scripts/vault_rag.py +42 -16
  112. package/scripts/vault_search.py +44 -8
  113. package/scripts/wechat_emoticon.py +6 -0
  114. package/scripts/wiki_lint.py +94 -17
  115. package/src/core/dbPathService.ts +22 -4
  116. package/src/panel/server.ts +33 -0
  117. package/src/services/assistantService.ts +26 -0
  118. package/src/services/assistantTools.ts +2452 -2319
  119. package/src/services/configService.ts +5 -2
  120. package/src/services/messageQuery.ts +29 -0
  121. package/src/services/wechatClient.ts +380 -0
  122. package/src/services/wechatMedia.ts +166 -0
  123. package/src/services/wechatMessageService.ts +62 -11
  124. package/src/types.ts +4 -0
  125. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  126. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  127. package/dist/src/utils/wechatEmoji.js +0 -35
  128. 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,6 +753,7 @@ 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
758
  # **只差大小写的两个概念名,在 Windows 上是同一个文件。** 原来的实现让后一个覆盖前一个:
685
759
  # 一页**没了**,而 `built` 把两个都算了(实测 49,953 页里丢 53:AI 那批 44、这批 9)。
@@ -720,7 +794,8 @@ def build_pages_from_cards(pages_dir: str, card_dirs=None, dry_run: bool = False
720
794
  others = [x for x in per_card.get(ref['file'], []) if x != name][:5]
721
795
  body = '# %s\n\n%s\n' % (name, definition)
722
796
  if others:
723
- 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)
724
799
  body += '\n## 来源\n\n- [[%s]] %s %s\n' % (ref['file'], SEPARATOR, ref['title'])
725
800
  fm = {
726
801
  'title': '"%s"' % name,
@@ -918,7 +993,7 @@ def refresh_sources(pages_dir: str, card_dirs=None, dry_run: bool = False) -> di
918
993
 
919
994
 
920
995
  def iter_concept_pages(jobs: list, api_key: str, workers: int = CONCEPT_PAGES_WORKERS,
921
- origin: str = ''):
996
+ origin: str = '', namer=None):
922
997
  """并发生成概念页,**按输入顺序**逐个产出 `((name, refs, out_file), result)`。
923
998
 
924
999
  生成器而不是列表,理由同 `article_notes.iter_concepts`:整批跑完才返回的话,
@@ -931,7 +1006,7 @@ def iter_concept_pages(jobs: list, api_key: str, workers: int = CONCEPT_PAGES_WO
931
1006
  def one(job):
932
1007
  name, refs, _ = job
933
1008
  try:
934
- return generate_concept(name, refs, api_key, origin=origin)
1009
+ return generate_concept(name, refs, api_key, origin=origin, namer=namer)
935
1010
  except Exception as error:
936
1011
  # **不许静默**。原来这里是 `except Exception: return None`,于是
937
1012
  # `reference_refs` 少给 `file`/`title` 造成的 KeyError 表现成
@@ -950,8 +1025,10 @@ def iter_concept_pages(jobs: list, api_key: str, workers: int = CONCEPT_PAGES_WO
950
1025
  yield job, result
951
1026
 
952
1027
 
953
- 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:
954
1030
  """Call DeepSeek to generate a concept Wiki page."""
1031
+ namer = namer or (lambda x: x)
955
1032
  # Build references section
956
1033
  ref_lines = build_ref_lines(refs)
957
1034
  ref_text = '\n'.join(ref_lines) if ref_lines else '(无详细信息)'
@@ -984,7 +1061,7 @@ def generate_concept(name: str, refs: list[dict], api_key: str, origin: str = ''
984
1061
  if related:
985
1062
  body_parts.append('## 相关概念\n\n')
986
1063
  for rc in related:
987
- body_parts.append(f'- [[{rc}]]\n')
1064
+ body_parts.append(f'- [[{namer(rc)}]]\n')
988
1065
  body_parts.append('\n')
989
1066
  body_parts.append('## 来源\n\n')
990
1067
  # **这一节不截断。** 截断是给**喂模型的参考行**留的预算(`build_ref_lines` 的 limit),
@@ -1096,6 +1173,8 @@ def main():
1096
1173
  help='只把已有页面的"来源"段补齐成**当前全部来源**(本地,不调用模型)')
1097
1174
  parser.add_argument('--pages-from-cards', action='store_true',
1098
1175
  help='**零模型**给还没有页的概念建页:定义取卡片里那句原话(本地,不花钱)')
1176
+ parser.add_argument('--qualify-links', action='store_true',
1177
+ help='把跨线同名的概念链接改成带路径的形式(本地重写,不调模型)')
1099
1178
  parser.add_argument('--merge-duplicates', action='store_true',
1100
1179
  help='把"规范化后同名"的多张页合并成一张(本地,不调模型;**会删页**)')
1101
1180
  parser.add_argument('--cards', action='append', default=None, metavar='DIR',
@@ -1133,6 +1212,25 @@ def main():
1133
1212
  % (result['dangling'], result['attempted'], result['generated'], result['skipped']))
1134
1213
  return
1135
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
+
1136
1234
  if getattr(args, 'fix_source_links', False):
1137
1235
  # 本地修链接:同上,动的是本地就能算出来的东西,不必把 1,963 页重问一遍
1138
1236
  result = fix_source_links(args.output, args.source)
@@ -1151,8 +1249,12 @@ def main():
1151
1249
  print('这条通道要指定 --cards(一条线一条线地建),例如:', file=sys.stderr)
1152
1250
  print(' 文章线:--cards output/article-notes --cards output/fav-notes '
1153
1251
  '--cards output/user-notes', file=sys.stderr)
1154
- print(' 聊天线:--cards output/chat-notes --output '
1155
- '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)
1156
1258
  sys.exit(1)
1157
1259
  result = build_pages_from_cards(args.output, cards,
1158
1260
  dry_run=getattr(args, 'dry_run', False))
@@ -1203,6 +1305,9 @@ def main():
1203
1305
  source_dir = args.source
1204
1306
  out_dir = Path(args.output)
1205
1307
  out_dir.mkdir(parents=True, exist_ok=True)
1308
+ # 跨线同名的链接要带路径(见 `_utils.LinkNamer`):**渲染点有三处**,所以这里建一次、
1309
+ # 一路传下去,而不是每处各判一遍(漏一处就在库里留下一批裸的二义链接)
1310
+ namer = LinkNamer.for_out_dir(out_dir)
1206
1311
 
1207
1312
  # Step 1: Scan
1208
1313
  print(f'=== Step 1: 扫描文章 ===')
@@ -1229,13 +1334,13 @@ def main():
1229
1334
  top_concepts = ranked[:args.limit]
1230
1335
  generated = 0
1231
1336
 
1232
- # 已有页在**花钱之前**滤掉(见 `build_jobs`:先提交再丢弃 = 为已存在的页付一次费)
1233
- # 另一条线的概念目录一起传进去:一个概念名在库里只能有一张页(见 `sibling_concept_dirs`)
1234
- 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)
1235
1340
  done = 0
1236
1341
  # 边收边写:生成器一有结果就落盘,不等整批(见 `iter_concept_pages`)
1237
1342
  for (name, refs, out_file), result in iter_concept_pages(
1238
- jobs, api_key, args.workers, origin=origin_tag(args.source)):
1343
+ jobs, api_key, args.workers, origin=origin_tag(args.source), namer=namer):
1239
1344
  done += 1
1240
1345
  if not result:
1241
1346
  print(f' [ERR] {name} 没生成出来({done}/{len(jobs)})', file=sys.stderr)
@@ -1245,7 +1350,7 @@ def main():
1245
1350
  write_with_frontmatter(str(out_file), fm, body)
1246
1351
  generated += 1
1247
1352
 
1248
- note = skip_note(skipped, elsewhere)
1353
+ note = skip_note(skipped)
1249
1354
  if note:
1250
1355
  print(note)
1251
1356
  print(f' 生成 {generated} 个新概念')
@@ -1280,7 +1385,8 @@ def main():
1280
1385
  fm, _ = parse_frontmatter(cf.read_text(encoding='utf-8'))
1281
1386
  # **标题要先去引号再当键**:写出去的是 `title: "甲"`(YAML 安全),而这里的键是裸的 `甲`
1282
1387
  title = str(fm.get('title', cf.stem)).strip().strip('"')
1283
- 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)} |')
1284
1390
 
1285
1391
  index_path = out_dir.parent / '00-Overview.md'
1286
1392
  with open(index_path, 'w', encoding='utf-8') as f: