weflow-cli 1.9.0 → 1.11.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.
- package/ARCHITECTURE.md +62 -26
- package/CHANGELOG.md +1051 -0
- package/OPERATIONS.md +99 -13
- package/README.en.md +14 -5
- package/README.md +23 -8
- package/bin/weflow-cli.ts +364 -40
- package/dist/bin/weflow-cli.js +381 -69
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +19 -1
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +12 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +20 -3
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +35 -0
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantService.d.ts +19 -0
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +20 -0
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +27 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +169 -35
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts +2 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/exportService.d.ts +43 -0
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +116 -11
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +18 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +29 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +125 -0
- package/dist/src/services/wechatClient.d.ts.map +1 -0
- package/dist/src/services/wechatClient.js +344 -0
- package/dist/src/services/wechatClient.js.map +1 -0
- package/dist/src/services/wechatMedia.d.ts +44 -0
- package/dist/src/services/wechatMedia.d.ts.map +1 -0
- package/dist/src/services/wechatMedia.js +155 -0
- package/dist/src/services/wechatMedia.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +11 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +55 -11
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/types.d.ts +4 -0
- package/dist/src/types.d.ts.map +1 -1
- package/docs/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1274 -0
- package/docs/EXTENDING.md +69 -7
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +27 -0
- package/docs/HEALTH-CHECK.md +12 -7
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
- package/docs/MCP.md +2 -2
- package/docs/PROJECT_STATE.md +64 -5
- package/docs/RELEASING.md +128 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/index.ts +18 -3
- package/package.json +5 -3
- package/resources/js/graph3d/NOTICE.txt +35 -0
- package/resources/js/graph3d/d3-binarytree.min.js +2 -0
- package/resources/js/graph3d/d3-dispatch.min.js +2 -0
- package/resources/js/graph3d/d3-force-3d.min.js +2 -0
- package/resources/js/graph3d/d3-octree.min.js +2 -0
- package/resources/js/graph3d/d3-quadtree.min.js +2 -0
- package/resources/js/graph3d/d3-timer.min.js +2 -0
- package/resources/js/graph3d/layout.mjs +63 -0
- package/resources/js/graph3d/three.min.js +6 -0
- package/resources/js/graph3d/viewer2d.js +448 -0
- package/resources/panel/index.html +12 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-lift-down-1.png +0 -0
- package/resources/panel/mascot-lift-down-2.png +0 -0
- package/resources/panel/mascot-lift-down-3.png +0 -0
- package/resources/panel/mascot-lift-down-4.png +0 -0
- package/resources/panel/mascot-lift-down-5.png +0 -0
- package/resources/panel/mascot-lift-down-6.png +0 -0
- package/resources/panel/mascot-lift-up-1.png +0 -0
- package/resources/panel/mascot-lift-up-2.png +0 -0
- package/resources/panel/mascot-lift-up-3.png +0 -0
- package/resources/panel/mascot-lift-up-4.png +0 -0
- package/resources/panel/mascot-lift-up-5.png +0 -0
- package/resources/panel/mascot-lift-up-6.png +0 -0
- package/resources/panel/mascot-tickle-1.png +0 -0
- package/resources/panel/mascot-tickle-2.png +0 -0
- package/resources/panel/mascot-tickle-3.png +0 -0
- package/resources/panel/mascot-tickle-4.png +0 -0
- package/resources/panel/panel.css +111 -0
- package/resources/panel/renderer.js +480 -8
- package/scripts/_utils.py +1203 -977
- package/scripts/biz_daily.py +25 -10
- package/scripts/chat_notes.py +15 -5
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +163 -57
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/export_chat_html.py +346 -6
- package/scripts/fav_notes.py +4 -1
- package/scripts/graph_2d.py +151 -0
- package/scripts/graph_3d.py +646 -0
- package/scripts/health_check.py +163 -26
- package/scripts/nt_decrypt.py +3 -3
- package/scripts/panel_frames.py +683 -0
- package/scripts/pipeline.py +21 -10
- package/scripts/quality_eval.py +5 -0
- package/scripts/rag_chat.py +33 -16
- package/scripts/reply_debt.py +26 -6
- package/scripts/semantic_search.py +212 -58
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wiki_lint.py +94 -17
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +33 -0
- package/src/services/assistantService.ts +26 -0
- package/src/services/assistantTools.ts +2452 -2319
- package/src/services/configService.ts +5 -2
- package/src/services/exportService.ts +129 -12
- package/src/services/messageQuery.ts +29 -0
- package/src/services/wechatClient.ts +380 -0
- package/src/services/wechatMedia.ts +166 -0
- package/src/services/wechatMessageService.ts +62 -11
- package/src/types.ts +4 -0
- package/dist/src/utils/wechatEmoji.d.ts +0 -5
- package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
- package/dist/src/utils/wechatEmoji.js +0 -35
- package/dist/src/utils/wechatEmoji.js.map +0 -1
package/scripts/compile_wiki.py
CHANGED
|
@@ -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,
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
528
|
-
"""
|
|
626
|
+
def skip_note(skipped: int) -> str:
|
|
627
|
+
"""跳过多少、为什么 —— 现在**只有一类原因**:本目录已有这张页(重跑,正常)。
|
|
529
628
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
分开目录之后,聊天线立刻为 11 个文章线已有的概念各建了一张同名页,`wiki lint` 报"同名页 11 组"。
|
|
629
|
+
2026-10-10 之前还有第二类"另一条线已有同名页,故意不建"(那个 `sibling_concept_dirs`
|
|
630
|
+
已经被删掉),现在取消了:用户要求两个概念**命名空间分开**,两条线各留一张同名页是设计。
|
|
631
|
+
代价写在该条 DECISIONS 里(Obsidian 里同名裸链接是二义的),取代 D-051 那一段。
|
|
534
632
|
|
|
535
|
-
|
|
536
|
-
|
|
633
|
+
留着这个函数(而不是在 `main()` 里内联一句 f-string)是因为它**能被测**:
|
|
634
|
+
内联的那句改坏了不会有任何东西红。
|
|
537
635
|
"""
|
|
538
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = ''
|
|
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
|
-
|
|
1155
|
-
|
|
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
|
-
#
|
|
1234
|
-
jobs, skipped
|
|
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
|
|
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
|
-
|
|
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:
|