weflow-cli 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1949 -1
  3. package/OPERATIONS.md +774 -1
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +934 -27
  7. package/dist/bin/weflow-cli.js +952 -27
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +19 -2
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +11 -1
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +1 -1
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +5 -2
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/pythonBridge.d.ts +41 -0
  88. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  89. package/dist/src/services/pythonBridge.js +119 -0
  90. package/dist/src/services/pythonBridge.js.map +1 -0
  91. package/dist/src/services/syncService.d.ts +8 -2
  92. package/dist/src/services/syncService.d.ts.map +1 -1
  93. package/dist/src/services/syncService.js +26 -7
  94. package/dist/src/services/syncService.js.map +1 -1
  95. package/dist/src/services/wechatMessageService.d.ts +25 -0
  96. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  97. package/dist/src/services/wechatMessageService.js +69 -7
  98. package/dist/src/services/wechatMessageService.js.map +1 -1
  99. package/dist/src/utils/atomicWrite.d.ts +4 -0
  100. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  101. package/dist/src/utils/atomicWrite.js +60 -0
  102. package/dist/src/utils/atomicWrite.js.map +1 -0
  103. package/dist/src/utils/dateRange.d.ts +15 -0
  104. package/dist/src/utils/dateRange.d.ts.map +1 -1
  105. package/dist/src/utils/dateRange.js +47 -0
  106. package/dist/src/utils/dateRange.js.map +1 -1
  107. package/dist/src/utils/text.d.ts +18 -0
  108. package/dist/src/utils/text.d.ts.map +1 -0
  109. package/dist/src/utils/text.js +20 -0
  110. package/dist/src/utils/text.js.map +1 -0
  111. package/docs/AI_INTERFACE.md +2 -0
  112. package/docs/DECISIONS.md +1424 -0
  113. package/docs/EXTENDING.md +187 -0
  114. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  115. package/docs/MCP.md +23 -4
  116. package/docs/PROJECT_STATE.md +299 -98
  117. package/docs/SETUP.md +22 -0
  118. package/docs/SYNC_CONTRACT.md +31 -3
  119. package/mcp-server/index.ts +681 -657
  120. package/package.json +84 -83
  121. package/resources/panel/ball-position.cjs +153 -0
  122. package/resources/panel/index.html +46 -0
  123. package/resources/panel/main.cjs +447 -0
  124. package/resources/panel/mascot-focus.png +0 -0
  125. package/resources/panel/mascot-happy.png +0 -0
  126. package/resources/panel/mascot-sorry.png +0 -0
  127. package/resources/panel/mascot-tired.png +0 -0
  128. package/resources/panel/mascot.png +0 -0
  129. package/resources/panel/package.json +7 -0
  130. package/resources/panel/panel.css +407 -0
  131. package/resources/panel/preload.cjs +72 -0
  132. package/resources/panel/quick-menu.cjs +197 -0
  133. package/resources/panel/renderer.js +441 -0
  134. package/resources/panel/tray-menu.cjs +40 -0
  135. package/resources/panel/tray.png +0 -0
  136. package/scripts/_utils.py +403 -54
  137. package/scripts/annual_report.py +4 -2
  138. package/scripts/article_notes.py +423 -0
  139. package/scripts/backfill_articles.py +438 -0
  140. package/scripts/biz_daily.py +590 -105
  141. package/scripts/chat_notes.py +470 -0
  142. package/scripts/classify_daily.py +10 -18
  143. package/scripts/compile_wiki.py +1059 -46
  144. package/scripts/create_reading_notes.py +199 -14
  145. package/scripts/decide.py +253 -0
  146. package/scripts/draft_reply.py +671 -0
  147. package/scripts/export_chat_html.py +35 -33
  148. package/scripts/extract_todos.py +31 -5
  149. package/scripts/fav_notes.py +318 -0
  150. package/scripts/fix_topics.py +2 -1
  151. package/scripts/generate_ai_report.py +214 -9
  152. package/scripts/generate_html.py +18 -3
  153. package/scripts/generate_review.py +2 -2
  154. package/scripts/jev_client.py +294 -0
  155. package/scripts/jev_probe.py +440 -0
  156. package/scripts/nt_common.py +82 -0
  157. package/scripts/nt_decrypt.py +1755 -1460
  158. package/scripts/pipeline.py +27 -2
  159. package/scripts/promote_ideas.py +19 -4
  160. package/scripts/quality_eval.py +707 -0
  161. package/scripts/rag_chat.py +4 -4
  162. package/scripts/read_image.py +187 -0
  163. package/scripts/reply_debt.py +488 -0
  164. package/scripts/route_cards.py +453 -0
  165. package/scripts/semantic_search.py +208 -49
  166. package/scripts/user_notes.py +315 -0
  167. package/scripts/vault_rag.py +13 -7
  168. package/scripts/vault_search.py +21 -4
  169. package/scripts/wiki_lint.py +397 -0
  170. package/src/core/appMsgFormat.ts +117 -0
  171. package/src/core/ntCore.ts +27 -2
  172. package/src/core/sqlcipherCore.ts +5 -61
  173. package/src/core/wcdbCore.ts +6 -2
  174. package/src/panel/client.ts +49 -0
  175. package/src/panel/endpoint.ts +144 -0
  176. package/src/panel/launch.ts +101 -0
  177. package/src/panel/server.ts +424 -0
  178. package/src/panel/userId.ts +72 -0
  179. package/src/services/assistantDaemon.ts +57 -9
  180. package/src/services/assistantEval.ts +611 -0
  181. package/src/services/assistantMemory.ts +413 -57
  182. package/src/services/assistantPrivacy.ts +18 -4
  183. package/src/services/assistantRouter.ts +191 -0
  184. package/src/services/assistantService.ts +671 -74
  185. package/src/services/assistantTools.ts +1118 -57
  186. package/src/services/assistantTrace.ts +175 -0
  187. package/src/services/chatService.ts +6 -2
  188. package/src/services/configService.ts +403 -385
  189. package/src/services/pythonBridge.ts +139 -0
  190. package/src/services/syncService.ts +37 -9
  191. package/src/services/wechatMessageService.ts +73 -7
  192. package/src/utils/atomicWrite.ts +56 -0
  193. package/src/utils/dateRange.ts +46 -0
  194. package/src/utils/text.ts +20 -0
@@ -0,0 +1,423 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 文章知识卡 — 从 Vault 里的文章笔记里提炼「概念」,喂给 wiki compile。
4
+
5
+ 用法:
6
+ python scripts/article_notes.py --dry-run --json # 看代价(只读本地、零出境)
7
+ python scripts/article_notes.py --limit 30 --yes # 先试水 30 篇
8
+ python scripts/compile_wiki.py --source output/article-notes
9
+
10
+ ## 为什么需要这一步(实测出来的)
11
+
12
+ `wiki compile` 要的是「概念形状」的链接(`[[概念]] — 关于它说了什么`)。把 Vault 里那 1633 篇
13
+ 文章笔记**全量扫过**之后发现:正文里的 `[[…]]` 只有两类——与自己主题同名的那条
14
+ (`> - **主题**: [[AI]]`)和 `## 🔗 关联网络` 里的**路径互链**。"其它"候选概念 **0 条**:
15
+ **这批笔记没有概念那一节**。所以文章线此前无论怎么跑 compile 都产不出概念页。
16
+
17
+ 这一步就是把缺的那节补上:每篇一次模型调用,**只问概念**。摘要不重新生成——原笔记里
18
+ 已经有一段摘要了,照抄进卡片即可(少一次生成就少一次编造的机会)。
19
+
20
+ ## 与对话线的关系
21
+
22
+ 同一形状、同一消费者(`compile_wiki`):`chat_notes.py` 产对话卡,本脚本产文章卡,
23
+ 两者都进 `output/*-notes/`,都靠 `compile_wiki --source` 聚成概念页。共用的件
24
+ (`plain` / `note_path` / `SEPARATOR`)在 `_utils.py` 里一份。
25
+ """
26
+ import argparse
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ from concurrent.futures import ThreadPoolExecutor
32
+ from datetime import datetime, timezone, timedelta
33
+ from pathlib import Path
34
+
35
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
36
+ from _utils import (SEPARATOR, call_deepseek, get_api_key, load_config, # noqa: E402
37
+ note_path, plain, parse_frontmatter, strip_wx_ads,
38
+ write_with_frontmatter)
39
+ import compile_wiki # noqa: E402 (用它的 concept_body / _extract_summary,同一份实现)
40
+
41
+ TZ = timezone(timedelta(hours=8))
42
+ SOURCE_ROOT = 'output/wechat-vault/002_Literature'
43
+ OUTPUT_ROOT = 'output/article-notes'
44
+ DEFAULT_LIMIT = 30
45
+ # 进提示词的字数上限。文章笔记本身已经是提炼过的一千多字,3000 足够,超出只是保险。
46
+ MAX_ARTICLE_CHARS = 3000
47
+ # 太短的不值得一次调用(与对话线那道闸门同一个理由,阈值按文章的量级定)
48
+ MIN_ARTICLE_CHARS = 200
49
+ # 并发数。和 `biz_daily` 那三个(`JEV_WORKERS` / `SUMMARY_WORKERS` / `IMAGE_WORKERS`)
50
+ # 取同一个值:它们量的都是同一个上游的同一个延迟。实测 8 并发 319 张/分钟。
51
+ CONCEPT_WORKERS = 6
52
+
53
+ CONCEPT_PROMPT = """你是知识库的**编译者,不是作者**。下面是一篇公众号文章笔记。
54
+
55
+ 请只依据它,回报这篇文章涉及的**概念**——概念是可被反复引用的东西(某个方法、工具、事件、
56
+ 现象、理论、人物),**不是分类词**("AI"、"新闻"、"学术"这种主题标签不算概念,它们已经在
57
+ frontmatter 里了),也不是文章标题。
58
+
59
+ 只返回一个 JSON 对象(不要解释、不要代码块围栏):
60
+ {{"concepts": [{{"name": "概念名(2-12 字,不要带书名号引号方括号)", "desc": "这篇文章关于它说了什么(一句话,具体)"}}]}}
61
+
62
+ 要求:
63
+ 1. **只写文章里有的**。没讲到的不许补;拿不准的宁可不列。
64
+ 2. 2-6 条,**按重要度排序**;概念名要具体("MCP 协议"可以,"技术"不行)。
65
+ 3. 每条 desc 是"这篇文章关于它的说法",不是百科定义。
66
+ 4. 全部中文,不要 markdown 语法(不要 #、*、-)。
67
+
68
+ 文章:{title}(来源:{source}|主题:{topic})
69
+
70
+ 正文:
71
+ {body}
72
+ """
73
+
74
+
75
+ def already_carded(article: dict, out_dir: str) -> bool:
76
+ """这篇文章已经有卡了吗(按卡片的 mtime 比原笔记新)。
77
+
78
+ **默认增量**:不然每跑一次都把同样的文章重问一遍——三十篇就是三十次白花的调用,
79
+ 一千六百篇就是一整轮的钱。原笔记改了(mtime 更新)就重做 ✓;提炼规则变了则用
80
+ `--refresh` 强制重做(规则变不在文件时间上体现,只能靠人喊)。
81
+ """
82
+ card = note_path(out_dir, article['path'].stem)
83
+ if not card.exists():
84
+ return False
85
+ try:
86
+ return card.stat().st_mtime >= article['path'].stat().st_mtime
87
+ except OSError:
88
+ return False
89
+
90
+
91
+ def worth_concepts(body: str, min_chars: int = MIN_ARTICLE_CHARS) -> bool:
92
+ """这篇够不够提炼——不够就跳过,**并报出来**(不是静默丢)。
93
+
94
+ 太短的多半是"标题党残页"或抓取不完整:写出来的卡片只会是空壳,白占一次调用,
95
+ 还往概念页里灌一条没有信息量的来源。与对话线那道闸门同一个理由。
96
+ """
97
+ return len(body.strip()) >= min_chars
98
+
99
+
100
+ def list_articles(source_root: str, limit: int, since: str = '', until: str = '',
101
+ topics: list = None) -> list:
102
+ """按发布时间倒序取前 N 篇(文件名前缀就是日期,frontmatter 里也有 published)。
103
+
104
+ `topics` 给了就只留这些主题的。**过滤在截断之前**——`--limit 500` 的语义是
105
+ "500 篇 AI 文章",不是"先取最新 500 篇、再从中挑出 AI";后者会让 limit 与主题
106
+ 互相拉扯(越往后翻越挑不满),而且不报错。
107
+ """
108
+ root = Path(source_root)
109
+ if not root.exists():
110
+ return []
111
+ found = []
112
+ for path in root.rglob('*.md'):
113
+ if path.name == 'README.md':
114
+ continue
115
+ try:
116
+ frontmatter, body = parse_frontmatter(path.read_text(encoding='utf-8'))
117
+ except Exception:
118
+ continue
119
+ found.append({
120
+ 'path': path,
121
+ 'rel': str(path.relative_to(root)).replace('\\', '/'),
122
+ 'title': str(frontmatter.get('title') or path.stem),
123
+ 'source': str(frontmatter.get('source') or ''),
124
+ 'topic': compile_wiki.article_topic(frontmatter),
125
+ 'tags': frontmatter.get('tags') or [],
126
+ 'published': str(frontmatter.get('published') or (path.name[:10] if re.match(r'\d{4}-\d{2}-\d{2}', path.name) else '')),
127
+ 'body': body,
128
+ })
129
+ # 时间窗口:按 `published` 过滤(ISO 日期串比大小就行)。**为什么要它**:
130
+ # "把 9 月份的解析一遍"是人的说法,而 `--limit` 只能表达"最近 N 篇"——
131
+ # 用 limit 去凑月份,要么漏掉要么带上隔壁月份的。
132
+ if since:
133
+ found = [item for item in found if item['published'] >= since]
134
+ if until:
135
+ found = [item for item in found if item['published'] <= until]
136
+ if topics:
137
+ wanted = {t.strip().casefold() for t in topics if str(t).strip()}
138
+ found = [item for item in found if str(item['topic']).strip().casefold() in wanted]
139
+ found.sort(key=lambda item: item['published'], reverse=True)
140
+ return found[:limit] if limit else found
141
+
142
+
143
+ def build_card(article: dict, concepts: list, updated: str):
144
+ """卡片 → (frontmatter, body)。
145
+
146
+ - `## AI 摘要` 那一段**照抄**原笔记的摘要(消费者 `compile_wiki` 就找这一节);
147
+ - `[[…]]` 只出现在 `## 概念` 那一节、**由这里渲染**(不由模型写,见 `_utils.plain` 的注释)。
148
+ """
149
+ frontmatter = {
150
+ 'title': article['title'],
151
+ 'type': 'article-card',
152
+ 'source': article['source'],
153
+ 'topic': article['topic'],
154
+ 'tags': list(article['tags'])[:4] or ['文章'],
155
+ 'published': article['published'],
156
+ # 溯源:这张卡是从哪篇笔记里提炼的
157
+ 'from': article['rel'],
158
+ 'updated': updated,
159
+ }
160
+ parts = ['# %s\n' % article['title'], '## AI 摘要\n']
161
+ # 原笔记的摘要直接从微信正文来的,可能带着界面残留(实测 20% 的笔记如此)。
162
+ # 卡是「照抄」摘要的,所以**抄之前要擦一遍**——否则那串东西会跟着卡进概念页。
163
+ parts.append(strip_wx_ads(compile_wiki._extract_summary(article['body']))
164
+ or '(原笔记没有摘要小节)')
165
+ parts.append('')
166
+ if concepts:
167
+ parts.append('## 概念\n')
168
+ for item in concepts:
169
+ parts.append('- [[%s]] %s %s' % (item['name'], SEPARATOR, item['desc']))
170
+ parts.append('')
171
+ return frontmatter, '\n'.join(parts)
172
+
173
+
174
+ def parse_concepts(raw: str) -> list:
175
+ """解出概念列表。解不出来返回 None(**不猜**),空列表表示"这篇确实没有概念"。"""
176
+ if not isinstance(raw, str) or not raw.strip():
177
+ return None
178
+ text = raw.strip()
179
+ start, end = text.find('{'), text.rfind('}')
180
+ if start < 0 or end <= start:
181
+ return None
182
+ try:
183
+ data = json.loads(text[start:end + 1])
184
+ except Exception:
185
+ return None
186
+ if not isinstance(data, dict) or not isinstance(data.get('concepts'), list):
187
+ return None
188
+ out = []
189
+ for item in data['concepts']:
190
+ if not isinstance(item, dict):
191
+ continue
192
+ name = plain(item.get('name')).strip().strip('[]')
193
+ desc = plain(item.get('desc')).strip()
194
+ if name and desc:
195
+ out.append({'name': name, 'desc': desc})
196
+ if len(out) >= 6:
197
+ break
198
+ return out
199
+
200
+
201
+ def refresh_summaries(out_dir: str, source_root: str) -> dict:
202
+ """只重写卡片里的 `## AI 摘要` 那一节——**本地重算,不调用任何模型**。
203
+
204
+ 卡里的摘要本来就是从原笔记**照抄**的(见 `build_card`),所以原笔记或清理规则一变,
205
+ 它能本地重算,不必再花一次调用。`from` 那栏记着来源,按它找回原笔记;
206
+ 找不到就跳过(如实报数),**不猜**。
207
+ """
208
+ rewritten, missing = [], []
209
+ for card in sorted(Path(out_dir).glob('*.md')):
210
+ content = card.read_text(encoding='utf-8')
211
+ frontmatter, body = parse_frontmatter(content)
212
+ origin = str(frontmatter.get('from') or '')
213
+ source = Path(source_root) / origin if origin else None
214
+ if not origin or not source or not source.exists():
215
+ missing.append(card.name)
216
+ continue
217
+ _, source_body = parse_frontmatter(source.read_text(encoding='utf-8'))
218
+ fresh = strip_wx_ads(compile_wiki._extract_summary(source_body)) or '(原笔记没有摘要小节)'
219
+ head, sep, tail = body.partition('## AI 摘要')
220
+ if not sep:
221
+ missing.append(card.name)
222
+ continue
223
+ rest = tail.split('\n## ', 1)
224
+ rebuilt = head + '## AI 摘要\n\n' + fresh + '\n\n' + ('## ' + rest[1] if len(rest) > 1 else '')
225
+ if rebuilt != body:
226
+ write_with_frontmatter(str(card), frontmatter, rebuilt)
227
+ rewritten.append(card.name)
228
+ return {'rewritten': rewritten, 'missing': missing}
229
+
230
+
231
+ def build_prompt(article: dict) -> str:
232
+ """一篇笔记 → 提示词。抽出来是为了让并发版与串行版**喂给模型的是同一个串**。"""
233
+ return CONCEPT_PROMPT.format(
234
+ title=article['title'], source=article['source'] or '未知',
235
+ topic=article['topic'] or '未分类',
236
+ # 喂给模型的正文也擦一遍:界面残留被当成「文章内容」读进去,会污染概念
237
+ body=strip_wx_ads(compile_wiki.concept_body(article['body']))[:MAX_ARTICLE_CHARS])
238
+
239
+
240
+ def iter_concepts(articles: list, api_key: str, workers: int = CONCEPT_WORKERS):
241
+ """并发问概念,**按输入顺序一篇一篇地产出** `(article, raw, error)`。
242
+
243
+ 返回的是**生成器**,不是列表——这一点是踩出来的。第一版写成
244
+ `list(pool.map(...))`:它把整批问完才返回,于是 5,852 篇跑着的十几分钟里磁盘上
245
+ **一张卡都没有**(实测:8 分钟卡片数纹丝不动),进程一死全部白花。串行版是从第一篇
246
+ 就开始写的,并发版不许在这件事上退化。
247
+
248
+ **只把"问"并行,"写"仍旧串行。** 实测:一次调用约 1.2 秒(纯粹等网络),而写一张
249
+ md 是毫秒级——并发写没有任何收益,只会让"哪张卡是谁写的"变难查。实测速率:
250
+ 串行 49 张/分钟,8 并发 319 张/分钟(6.5 倍),**花的钱一样**(token 数不变)。
251
+
252
+ **顺序由 `pool.map` 保证**:调用方靠位置把回答配回文章,换成 `as_completed`
253
+ 就会把甲的答案写进乙的卡里——卡片照样生成、格式照样对,只有内容错位。
254
+
255
+ 失败**不吞**:逐篇产出错误字符串,由调用方走**与串行版完全相同**的那条记录路径。
256
+ 两版的失败清单必须长得一样,否则"有几篇没做出来"这件事在两版之间不可比。
257
+ """
258
+ def one(article):
259
+ try:
260
+ return call_deepseek(build_prompt(article), api_key, max_tokens=800, timeout=90), None
261
+ except Exception as error:
262
+ return None, str(error)
263
+
264
+ if workers <= 1:
265
+ for article in articles:
266
+ raw, error = one(article)
267
+ yield article, raw, error
268
+ return
269
+ with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
270
+ # 用 `with` 保住线程池:生成器被消费完之前不能关掉它,否则 `map` 后面的
271
+ # 那些 future 会连同池子一起消失。
272
+ for article, (raw, error) in zip(articles, pool.map(one, articles)):
273
+ yield article, raw, error
274
+
275
+
276
+ def exit_code(written: int) -> int:
277
+ """**部分成功算成功**(退出码 0),失败清单是数据不是进程状态。
278
+
279
+ 原来写的是"只要有失败就 exit 1"——于是"3 个会话里 2 张卡写出来了"被上层当成
280
+ **整体失败**,连脚本自己那份写着原因的 JSON 都被丢掉(CLI 只报一句退出码)。
281
+ 只有**一张都没写出来**才算失败。
282
+ """
283
+ return 0 if written else 1
284
+
285
+
286
+ def main():
287
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
288
+ parser = argparse.ArgumentParser(description='文章知识卡(从文章笔记提炼概念,喂给 wiki compile)')
289
+ parser.add_argument('--source', default=SOURCE_ROOT, help='文章笔记目录')
290
+ parser.add_argument('--out', default=OUTPUT_ROOT, help='卡片输出目录')
291
+ parser.add_argument('--since', default='', help='只看这个日期之后的(YYYY-MM-DD)')
292
+ parser.add_argument('--until', default='', help='只看这个日期之前的(YYYY-MM-DD)')
293
+ parser.add_argument('--topic', action='append', default=[],
294
+ help='只做这些主题(可重复或逗号分隔,如 --topic AI);不传=全部')
295
+ parser.add_argument('--workers', type=int, default=CONCEPT_WORKERS,
296
+ help='并发问几篇(默认 %d)。只并行"问",写仍是串行' % CONCEPT_WORKERS)
297
+ parser.add_argument('--limit', type=int, default=DEFAULT_LIMIT,
298
+ help='最多处理几篇(默认 %d,按发布时间倒序)' % DEFAULT_LIMIT)
299
+ parser.add_argument('--dry-run', action='store_true', help='只报要发多少给模型,不调用')
300
+ parser.add_argument('--refresh', action='store_true',
301
+ help='已有卡的也重做(默认增量:只做还没卡的)')
302
+ parser.add_argument('--refresh-summaries', action='store_true',
303
+ help='只按原笔记重算已有卡片的摘要(本地,不调用模型)')
304
+ parser.add_argument('--yes', action='store_true', help='确认调用云端模型')
305
+ parser.add_argument('--json', action='store_true')
306
+ args = parser.parse_args()
307
+
308
+ if args.limit < 1:
309
+ print(json.dumps({'success': False, 'error': '--limit 要 ≥ 1'}))
310
+ return 1
311
+ if args.workers < 1:
312
+ print(json.dumps({'success': False, 'error': '--workers 要 ≥ 1'}) if args.json
313
+ else '--workers 要 ≥ 1')
314
+ return 1
315
+
316
+ if args.refresh_summaries:
317
+ # 摘要本来就是照抄的,所以能本地重算:清理规则一变,不必再花一次调用
318
+ result = refresh_summaries(args.out, args.source)
319
+ if args.json:
320
+ print(json.dumps({'success': True, 'action': 'article-notes.refresh',
321
+ 'model': None, 'invokesAI': False, **result},
322
+ ensure_ascii=False, indent=2))
323
+ else:
324
+ print('重写了 %d 张卡的摘要' % len(result['rewritten']))
325
+ if result['missing']:
326
+ print('找不到来源或没有摘要小节,跳过 %d 张' % len(result['missing']), file=sys.stderr)
327
+ return 0
328
+
329
+ # 主题可以写成 `--topic AI --topic 学术`,也可以 `--topic AI,学术`——两种都收
330
+ topics = [part.strip() for value in args.topic
331
+ for part in str(value).split(',') if part.strip()]
332
+ articles = list_articles(args.source, args.limit, since=args.since, until=args.until,
333
+ topics=topics)
334
+ if not articles:
335
+ message = '在 %s 下没找到 .md 笔记' % args.source
336
+ print(json.dumps({'success': False, 'error': message}) if args.json else message,
337
+ file=sys.stdout if args.json else sys.stderr)
338
+ return 1
339
+
340
+ runnable, thin, existing = [], [], []
341
+ for article in articles:
342
+ if worth_concepts(article['body']):
343
+ # **默认增量**:已经有卡(且比原笔记新)的不再重复问一遍
344
+ (runnable if args.refresh or not already_carded(article, args.out) else existing).append(article)
345
+ else:
346
+ thin.append(article)
347
+
348
+ if args.dry_run:
349
+ preview = {
350
+ 'success': True, 'dryRun': True, 'action': 'article-notes',
351
+ 'source': args.source, 'topics': topics, 'articles': len(runnable),
352
+ 'workers': args.workers,
353
+ 'chars': sum(len(a['body']) for a in runnable),
354
+ 'skipped': len(thin), 'skippedTitles': [a['title'][:24] for a in thin][:10],
355
+ 'alreadyCarded': len(existing),
356
+ 'model': 'DeepSeek(生成)', 'calls': len(runnable),
357
+ 'readsLocalData': True, 'invokesAI': False, 'writesFiles': True,
358
+ 'note': '每篇一次调用,**只问概念**(摘要照抄原笔记);卡片写到 %s' % args.out,
359
+ }
360
+ if args.json:
361
+ print(json.dumps(preview, ensure_ascii=False, indent=2))
362
+ else:
363
+ print('%d 篇、约 %d 字会发给 DeepSeek(每篇一次调用,只问概念)'
364
+ % (len(runnable), preview['chars']))
365
+ if existing:
366
+ print('已有卡、跳过 %d 篇(要重做加 --refresh)' % len(existing))
367
+ if thin:
368
+ print('另有 %d 篇太短(<%d 字)跳过' % (len(thin), MIN_ARTICLE_CHARS))
369
+ print('卡片写到 %s;之后跑 python scripts/compile_wiki.py --source %s' % (args.out, args.out))
370
+ return 0
371
+
372
+ if not runnable:
373
+ print('没有够得上提炼的笔记(%d 篇都太短)' % len(thin), file=sys.stderr)
374
+ return 1
375
+
376
+ if not args.yes:
377
+ print('会把 %d 篇文章发给 DeepSeek(每篇一次调用)。加 --yes 确认。' % len(runnable),
378
+ file=sys.stderr)
379
+ return 1
380
+
381
+ config = load_config()
382
+ api_key = get_api_key(config)
383
+ if not api_key:
384
+ print('没有配置 deepseekApiKey(weflow-cli config set deepseekApiKey "...")', file=sys.stderr)
385
+ return 1
386
+
387
+ updated = datetime.now(TZ).strftime('%Y-%m-%d %H:%M')
388
+ written, failed = [], []
389
+ # 边收边写:生成器一有结果就落盘,不等整批。见 `iter_concepts` 的注释。
390
+ for article, raw, error in iter_concepts(runnable, api_key, args.workers):
391
+ if error:
392
+ failed.append({'title': article['title'][:30], 'reason': '调用失败:%s' % error})
393
+ continue
394
+ concepts = parse_concepts(raw)
395
+ if concepts is None:
396
+ failed.append({'title': article['title'][:30], 'reason': '模型没返回可用 JSON'})
397
+ continue
398
+ target = note_path(args.out, article['path'].stem)
399
+ target.parent.mkdir(parents=True, exist_ok=True)
400
+ frontmatter, body = build_card(article, concepts, updated)
401
+ write_with_frontmatter(str(target), frontmatter, body)
402
+ written.append({'title': article['title'][:30], 'file': str(target), 'concepts': len(concepts)})
403
+
404
+ result = {
405
+ 'success': True, 'action': 'article-notes', 'model': 'DeepSeek(生成)',
406
+ 'sendsNothing': True, 'written': written, 'failed': failed,
407
+ 'skipped': [{'title': a['title'][:30], 'chars': len(a['body'])} for a in thin],
408
+ 'articles': len(runnable), 'calls': len(runnable),
409
+ }
410
+ if args.json:
411
+ print(json.dumps(result, ensure_ascii=False, indent=2))
412
+ else:
413
+ for item in written:
414
+ print('✓ %s(%d 个概念)→ %s' % (item['title'], item['concepts'], item['file']))
415
+ for item in failed:
416
+ print('✗ %s:%s' % (item['title'], item['reason']), file=sys.stderr)
417
+ print('\n共 %d 张卡;接着跑 python scripts/compile_wiki.py --source %s'
418
+ % (len(written), args.out))
419
+ return exit_code(len(written))
420
+
421
+
422
+ if __name__ == '__main__':
423
+ sys.exit(main())