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.
- package/ARCHITECTURE.md +62 -26
- package/CHANGELOG.md +1290 -0
- package/OPERATIONS.md +144 -11
- package/README.en.md +20 -4
- package/README.md +30 -5
- package/bin/weflow-cli-electron.cjs +2 -0
- package/bin/weflow-cli.ts +996 -118
- package/dist/bin/weflow-cli.js +1038 -146
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/http.d.ts +14 -0
- package/dist/mcp-server/http.d.ts.map +1 -0
- package/dist/mcp-server/http.js +84 -0
- package/dist/mcp-server/http.js.map +1 -0
- package/dist/mcp-server/httpConfig.d.ts +50 -0
- package/dist/mcp-server/httpConfig.d.ts.map +1 -0
- package/dist/mcp-server/httpConfig.js +104 -0
- package/dist/mcp-server/httpConfig.js.map +1 -0
- package/dist/mcp-server/index.d.ts +13 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +58 -374
- 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 +61 -1
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +69 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +270 -8
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantScenes.d.ts +81 -0
- package/dist/src/services/assistantScenes.d.ts.map +1 -0
- package/dist/src/services/assistantScenes.js +322 -0
- package/dist/src/services/assistantScenes.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +33 -4
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +102 -6
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantSkills.d.ts +94 -0
- package/dist/src/services/assistantSkills.d.ts.map +1 -0
- package/dist/src/services/assistantSkills.js +330 -0
- package/dist/src/services/assistantSkills.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +34 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +931 -88
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +4 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -1
- package/dist/src/services/assistantTrace.js +9 -0
- package/dist/src/services/assistantTrace.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +5 -2
- package/dist/src/services/configService.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/outboundSend.d.ts +53 -0
- package/dist/src/services/outboundSend.d.ts.map +1 -0
- package/dist/src/services/outboundSend.js +90 -0
- package/dist/src/services/outboundSend.js.map +1 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -1
- package/dist/src/services/pythonBridge.js +1 -0
- package/dist/src/services/pythonBridge.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/dist/src/utils/python.js +2 -2
- package/dist/src/utils/python.js.map +1 -1
- package/docs/BRANCHES.md +37 -0
- package/docs/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1624 -0
- package/docs/EXTENDING.md +98 -10
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -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 +58 -4
- package/docs/PROJECT_STATE.md +180 -11
- package/docs/RELEASING.md +115 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/http.ts +108 -0
- package/mcp-server/httpConfig.ts +126 -0
- package/mcp-server/index.ts +359 -681
- 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/ball-position.cjs +48 -2
- package/resources/panel/index.html +18 -4
- package/resources/panel/main.cjs +137 -5
- package/resources/panel/mascot-base.png +0 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-iris.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-peek.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 +205 -59
- package/resources/panel/preload.cjs +26 -0
- package/resources/panel/renderer.js +646 -17
- package/scripts/_utils.py +1203 -931
- package/scripts/backfill_articles.py +202 -21
- package/scripts/biz_daily.py +26 -34
- package/scripts/bonds.py +331 -0
- package/scripts/chat_notes.py +561 -470
- package/scripts/chat_stats.py +8 -7
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +187 -59
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/export_chat_html.py +3 -1
- 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/mcp_bridge.py +6 -7
- 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 +130 -22
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wechat_voice.py +9 -0
- package/scripts/wiki_lint.py +124 -24
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +58 -1
- package/src/services/assistantEval.ts +323 -12
- package/src/services/assistantScenes.ts +342 -0
- package/src/services/assistantService.ts +107 -6
- package/src/services/assistantSkills.ts +364 -0
- package/src/services/assistantTools.ts +2452 -1640
- package/src/services/assistantTrace.ts +9 -0
- package/src/services/configService.ts +415 -403
- package/src/services/messageQuery.ts +29 -0
- package/src/services/outboundSend.ts +126 -0
- package/src/services/pythonBridge.ts +1 -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/src/utils/python.ts +2 -2
- 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,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
|
-
|
|
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 +=
|
|
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
|
-
|
|
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 = ''
|
|
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
|
-
|
|
1133
|
-
|
|
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
|
-
#
|
|
1212
|
-
jobs, skipped
|
|
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
|
|
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
|
-
|
|
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:
|