weflow-cli 1.6.4 → 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 (205) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1966 -0
  3. package/OPERATIONS.md +808 -0
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +1245 -26
  7. package/dist/bin/weflow-cli.js +1349 -72
  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 +43 -0
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +31 -0
  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 +13 -0
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +23 -0
  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/messageQuery.d.ts +35 -0
  88. package/dist/src/services/messageQuery.d.ts.map +1 -1
  89. package/dist/src/services/messageQuery.js +61 -0
  90. package/dist/src/services/messageQuery.js.map +1 -1
  91. package/dist/src/services/pythonBridge.d.ts +41 -0
  92. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  93. package/dist/src/services/pythonBridge.js +119 -0
  94. package/dist/src/services/pythonBridge.js.map +1 -0
  95. package/dist/src/services/syncService.d.ts +127 -0
  96. package/dist/src/services/syncService.d.ts.map +1 -0
  97. package/dist/src/services/syncService.js +263 -0
  98. package/dist/src/services/syncService.js.map +1 -0
  99. package/dist/src/services/syncState.d.ts +113 -0
  100. package/dist/src/services/syncState.d.ts.map +1 -0
  101. package/dist/src/services/syncState.js +214 -0
  102. package/dist/src/services/syncState.js.map +1 -0
  103. package/dist/src/services/wechatMessageService.d.ts +25 -0
  104. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  105. package/dist/src/services/wechatMessageService.js +69 -7
  106. package/dist/src/services/wechatMessageService.js.map +1 -1
  107. package/dist/src/utils/atomicWrite.d.ts +4 -0
  108. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  109. package/dist/src/utils/atomicWrite.js +60 -0
  110. package/dist/src/utils/atomicWrite.js.map +1 -0
  111. package/dist/src/utils/dateRange.d.ts +15 -0
  112. package/dist/src/utils/dateRange.d.ts.map +1 -1
  113. package/dist/src/utils/dateRange.js +47 -0
  114. package/dist/src/utils/dateRange.js.map +1 -1
  115. package/dist/src/utils/text.d.ts +18 -0
  116. package/dist/src/utils/text.d.ts.map +1 -0
  117. package/dist/src/utils/text.js +20 -0
  118. package/dist/src/utils/text.js.map +1 -0
  119. package/docs/AI_INTERFACE.md +2 -0
  120. package/docs/DECISIONS.md +1484 -20
  121. package/docs/EXTENDING.md +187 -0
  122. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
  123. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  124. package/docs/MCP.md +23 -4
  125. package/docs/PROJECT_STATE.md +299 -96
  126. package/docs/SETUP.md +22 -0
  127. package/docs/SYNC_CONTRACT.md +180 -0
  128. package/mcp-server/index.ts +681 -657
  129. package/package.json +84 -83
  130. package/resources/panel/ball-position.cjs +153 -0
  131. package/resources/panel/index.html +46 -0
  132. package/resources/panel/main.cjs +447 -0
  133. package/resources/panel/mascot-focus.png +0 -0
  134. package/resources/panel/mascot-happy.png +0 -0
  135. package/resources/panel/mascot-sorry.png +0 -0
  136. package/resources/panel/mascot-tired.png +0 -0
  137. package/resources/panel/mascot.png +0 -0
  138. package/resources/panel/package.json +7 -0
  139. package/resources/panel/panel.css +407 -0
  140. package/resources/panel/preload.cjs +72 -0
  141. package/resources/panel/quick-menu.cjs +197 -0
  142. package/resources/panel/renderer.js +441 -0
  143. package/resources/panel/tray-menu.cjs +40 -0
  144. package/resources/panel/tray.png +0 -0
  145. package/scripts/_utils.py +403 -54
  146. package/scripts/annual_report.py +4 -2
  147. package/scripts/article_notes.py +423 -0
  148. package/scripts/backfill_articles.py +438 -0
  149. package/scripts/biz_daily.py +590 -105
  150. package/scripts/chat_notes.py +470 -0
  151. package/scripts/classify_daily.py +10 -18
  152. package/scripts/compile_wiki.py +1059 -46
  153. package/scripts/create_reading_notes.py +199 -14
  154. package/scripts/decide.py +253 -0
  155. package/scripts/draft_reply.py +671 -0
  156. package/scripts/export_chat_html.py +212 -46
  157. package/scripts/extract_todos.py +31 -5
  158. package/scripts/fav_notes.py +318 -0
  159. package/scripts/fix_topics.py +2 -1
  160. package/scripts/generate_ai_report.py +214 -9
  161. package/scripts/generate_html.py +18 -3
  162. package/scripts/generate_review.py +2 -2
  163. package/scripts/jev_client.py +294 -0
  164. package/scripts/jev_probe.py +440 -0
  165. package/scripts/nt_common.py +82 -0
  166. package/scripts/nt_decrypt.py +1755 -1352
  167. package/scripts/pipeline.py +27 -2
  168. package/scripts/promote_ideas.py +19 -4
  169. package/scripts/quality_eval.py +707 -0
  170. package/scripts/rag_chat.py +4 -4
  171. package/scripts/read_image.py +187 -0
  172. package/scripts/reply_debt.py +488 -0
  173. package/scripts/route_cards.py +453 -0
  174. package/scripts/semantic_search.py +208 -49
  175. package/scripts/user_notes.py +315 -0
  176. package/scripts/vault_rag.py +13 -7
  177. package/scripts/vault_search.py +21 -4
  178. package/scripts/wiki_lint.py +397 -0
  179. package/src/core/appMsgFormat.ts +117 -0
  180. package/src/core/ntCore.ts +67 -0
  181. package/src/core/sqlcipherCore.ts +5 -61
  182. package/src/core/wcdbCore.ts +6 -2
  183. package/src/panel/client.ts +49 -0
  184. package/src/panel/endpoint.ts +144 -0
  185. package/src/panel/launch.ts +101 -0
  186. package/src/panel/server.ts +424 -0
  187. package/src/panel/userId.ts +72 -0
  188. package/src/services/assistantDaemon.ts +57 -9
  189. package/src/services/assistantEval.ts +611 -0
  190. package/src/services/assistantMemory.ts +413 -57
  191. package/src/services/assistantPrivacy.ts +18 -4
  192. package/src/services/assistantRouter.ts +191 -0
  193. package/src/services/assistantService.ts +671 -74
  194. package/src/services/assistantTools.ts +1118 -57
  195. package/src/services/assistantTrace.ts +175 -0
  196. package/src/services/chatService.ts +30 -0
  197. package/src/services/configService.ts +403 -385
  198. package/src/services/messageQuery.ts +97 -0
  199. package/src/services/pythonBridge.ts +139 -0
  200. package/src/services/syncService.ts +363 -0
  201. package/src/services/syncState.ts +275 -0
  202. package/src/services/wechatMessageService.ts +73 -7
  203. package/src/utils/atomicWrite.ts +56 -0
  204. package/src/utils/dateRange.ts +46 -0
  205. package/src/utils/text.ts +20 -0
@@ -26,6 +26,39 @@ TOPIC_ORDER = ['AI', '学术', '新闻', '文学', '投资', '政治']
26
26
 
27
27
  # ====== V2 模板 ======
28
28
 
29
+ # 阅读笔记里"相关文章"那一段的查询。**单独抽出来,是因为它有两个使用方**:模板(写新笔记)
30
+ # 与 `refresh_dataview_blocks()`(重写老笔记)。写成两份就会漂移,而漂移的表现是
31
+ # "新笔记的查询是对的、老笔记的还是坏的"——不报错。
32
+ #
33
+ # 三处修正(2026-09-27,都是实测出来的坏查询):
34
+ # - `SORT date DESC` → `SORT published DESC`:笔记里**没有 `date` 这个字段**(是 `published`),
35
+ # 原查询排不出来。
36
+ # - 加了 `file.name != this.file.name`:否则一篇笔记的"相关文章"里会列出它自己。
37
+ # - 首列从 `rating` 改成 `published`:`rating` 是留给用户打分的,全库默认空;
38
+ # 拿它当首列,表格十行全是空格。
39
+ # - `contains(hasTopic, "…")` → **`contains(string(hasTopic), "…")`**:笔记里写的是
40
+ # `hasTopic: [[新闻]]`,而 YAML 把它解析成**嵌套列表** `[['新闻']]`——对它做
41
+ # `contains(…, "新闻")` 是在拿字符串比一个子列表。外面套一层 `string()` 对形状不敏感
42
+ # (字符串、列表、链接都成立),代价是万一有主题名互为子串会多匹配——本库的六个主题
43
+ # (AI/学术/新闻/文学/投资/政治)之间没有这种关系。
44
+ RELATED_QUERY = '''```dataview
45
+ TABLE published AS 日期, source AS 来源, rating
46
+ FROM "002_Literature"
47
+ WHERE contains(string(hasTopic), "{topic}") AND file.name != this.file.name
48
+ SORT published DESC
49
+ LIMIT 10
50
+ ```'''
51
+
52
+ # 日记里"概念连接"那一段的查询。**判日期必须用 `published`(文章日期),不能用 `created`**:
53
+ # `created` 是**生成这篇笔记的日期**,而一次回填会把几万篇的 `created` 全写成同一天
54
+ # (实测:25,676 篇全是 2026-09-26)。用 `created` 判,结果是 175 天的日记全空、
55
+ # 唯独生成那天把两万多篇一次性列出来。
56
+ DAILY_QUERY = '''```dataview
57
+ LIST
58
+ FROM "002_Literature"
59
+ WHERE published = date("{date}")
60
+ ```'''
61
+
29
62
  NOTE_TEMPLATE = '''---
30
63
  title: "{title}"
31
64
  aliases: [{aliases}]
@@ -120,13 +153,7 @@ reading-progress: 0
120
153
 
121
154
  ## 📊 相关文章
122
155
 
123
- ```dataview
124
- TABLE rating, importance, reading-progress
125
- FROM "002_Literature"
126
- WHERE contains(hasTopic, "{topic}")
127
- SORT date DESC
128
- LIMIT 10
129
- ```
156
+ {related_query}
130
157
 
131
158
  ---
132
159
 
@@ -177,11 +204,7 @@ energy:
177
204
 
178
205
  ## 🔗 概念连接
179
206
 
180
- ```dataview
181
- LIST
182
- FROM "002_Literature"
183
- WHERE created = date({date})
184
- ```
207
+ {daily_query}
185
208
 
186
209
  ---
187
210
 
@@ -204,7 +227,16 @@ VAULT_DIRS = [
204
227
  '005_Reference/Tools',
205
228
  '005_Reference/Methods',
206
229
  '006_Projects',
207
- '007_Wiki/Concepts',
230
+ # **这里原先写的是 `007_Wiki/Concepts`,2026-09-27 改成顶层的 `Wiki/Concepts`。**
231
+ # 概念页一直实际写在顶层(`compile_wiki.OUTPUT_ROOT`,另有 `vault_rag` / `vault_search` /
232
+ # `wiki_lint` / 助手的知识检索 / CLI 两个选项共 6 处读那里),模板却声明在 007_Wiki,
233
+ # 于是每次 init 都建出一个**永远空着**的 `007_Wiki/`——用户在 Obsidian 里看到它,
234
+ # 得到的结论是"知识库没更新"。一条路径写在两处就会这样:不报错,只是一个目录永远空着。
235
+ # `test/compile_wiki_test.py` 现在钉住两者必须一致。
236
+ # **两个知识库各一个目录**(用户 2026-09-27 要求分开):`Wiki/Concepts` 是公众号文章线的,
237
+ # `Chat/Concepts` 是聊天线的。这里的清单要与 `_utils.CONCEPT_DIRS` 一致——有测试盯着。
238
+ 'Wiki/Concepts',
239
+ 'Chat/Concepts',
208
240
  '008_MOC',
209
241
  '999_Archive',
210
242
  '_attachments',
@@ -243,6 +275,11 @@ def extract_summary(body: str, max_len=300) -> str:
243
275
  line = line.replace('[[', '').replace(']]', '')
244
276
  if line:
245
277
  lines.append(line)
278
+ # **界面残留要在这里清掉**:正文来自微信,带着阅读器自己的文字与文末的原创标记。
279
+ # 实测 1633 篇里 330 篇的摘要在这一步之前就混进了它(写笔记这条路此前没清洗)。
280
+ from _utils import strip_wx_ads
281
+ lines = [strip_wx_ads(line) for line in lines]
282
+ lines = [line for line in lines if line]
246
283
  summary = ' '.join(lines[:10])
247
284
  if len(summary) > max_len:
248
285
  summary = summary[:max_len] + '...'
@@ -311,20 +348,167 @@ def create_reading_note(article_path, vault_path, date_str):
311
348
  source=source, url=url, local_source=local_source, published=published,
312
349
  topic=topic, topic_tag=topic_tag,
313
350
  summary=summary, concepts=concepts,
351
+ # 查询要先自己 format 好再传进去:`str.format` **不会**回头再扫一遍替换进去的值,
352
+ # 直接传带 `{topic}` 的原文,那对大括号会原样留在笔记里。
353
+ related_query=RELATED_QUERY.format(topic=topic),
314
354
  )
315
355
  note_path.write_text(note_content, encoding='utf-8')
316
356
  return 'created', title
317
357
 
318
358
 
359
+ DV_BLOCK_RE = re.compile(r'```dataview\b.*?```', re.DOTALL)
360
+
361
+
362
+ def refresh_dataview_blocks(vault: str) -> dict:
363
+ """把**已有**笔记里的 dataview 查询重写成上面那两份当前模板。**本地重算,不调用模型。**
364
+
365
+ 为什么非要有它:`create_reading_note()` 遇到已存在的笔记是 `skip`,所以改模板只影响
366
+ **以后**生成的笔记——库里已有的那批会永远停在旧查询上。2026-09-27 修那两处坏查询时,
367
+ 库里已经有 25,852 个文件带着它们(阅读笔记 25,676 + 日记 176),光改模板一个都修不到。
368
+
369
+ 用哪一份按笔记的位置定:
370
+ - `001_Daily/<日期>.md` → 日记那份,日期取自文件名;
371
+ - `002_Literature/**` → 阅读笔记那份,主题取自 frontmatter 的 `topic`/`hasTopic`。
372
+
373
+ **判不出来就不动**(没主题、没查询块),并分类计数报出来——不猜、不静默。
374
+ """
375
+ vault_path = Path(vault)
376
+ targets = []
377
+ daily_dir = vault_path / '001_Daily'
378
+ if daily_dir.is_dir():
379
+ targets += [('daily', p, p.stem) for p in sorted(daily_dir.glob('*.md'))]
380
+ literature = vault_path / '002_Literature'
381
+ if literature.is_dir():
382
+ targets += [('note', p, None) for p in sorted(literature.rglob('*.md'))]
383
+
384
+ rewritten, no_block, skipped, topicless = [], [], [], []
385
+ for kind, path, value in targets:
386
+ try:
387
+ content = path.read_text(encoding='utf-8')
388
+ except (OSError, UnicodeDecodeError):
389
+ # 读不出来就**不碰它**并计数。`UnicodeDecodeError` 要一起接住:它不是 OSError,
390
+ # 漏了它就会让一个坏文件把整趟重写打断——而这一趟要处理两万多个文件。
391
+ skipped.append(path.name)
392
+ continue
393
+ if kind == 'note':
394
+ frontmatter, _ = parse_frontmatter(content)
395
+ # **必须先取 `raw[0]`**。笔记里写的是 `hasTopic: [[新闻]]`,YAML 把它解析成
396
+ # **嵌套列表** `[['新闻']]`;直接 `str()` 整个列表会得到 `"['[新闻]']"`,
397
+ # 剥掉方括号就成了 `"'[新闻]'"`——一个带着引号和方括号的主题值被塞进查询,
398
+ # 而且**不报错**,表格只是永远空着。2026-09-27 我第一版就是这么写的,
399
+ # 25,669 篇被写坏。
400
+ #
401
+ # 这段与 `compile_wiki.article_topic` 是同一条规则。**没有直接调它**,是因为
402
+ # `create_reading_notes` 刻意不引 `_utils`(本仓库记过:加了导入边会让命令能否
403
+ # 运行取决于调用方的工作目录),而 `compile_wiki` 会传递地引入它。
404
+ raw = frontmatter.get('topic') or frontmatter.get('hasTopic') or ''
405
+ if isinstance(raw, (list, tuple)):
406
+ raw = raw[0] if raw else ''
407
+ value = str(raw).strip().strip('[]').strip()
408
+ # **主题为空也照改,只是单独计数**:那 7 篇(实测)本来就是空主题,
409
+ # 它们的查询里写着 `hasTopic, ""`。跳过它们等于让它们继续留着
410
+ # `SORT date` 那个排不出来的写法——而"语法对、结果如实为空"比
411
+ # "语法错、看不出为什么"要好。计数是给人看的:这 7 篇确实没有主题。
412
+ if not value:
413
+ topicless.append(path.name)
414
+ fresh = RELATED_QUERY.format(topic=value)
415
+ else:
416
+ fresh = DAILY_QUERY.format(date=value)
417
+ if not DV_BLOCK_RE.search(content):
418
+ no_block.append(path.name)
419
+ continue
420
+ updated = DV_BLOCK_RE.sub(lambda _match: fresh, content, count=1)
421
+ if updated != content:
422
+ path.write_text(updated, encoding='utf-8')
423
+ rewritten.append(path.name)
424
+ return {'rewritten': rewritten, 'noBlock': no_block,
425
+ 'skipped': skipped, 'topicless': topicless}
426
+
427
+
428
+ SUMMARY_BLOCK_RE = re.compile(r'(##\s*[^\n]*摘要\s*\n)(.*?)(?=\n##\s|\Z)', re.DOTALL)
429
+
430
+
431
+ def clean_summaries(vault: str) -> dict:
432
+ """把已有笔记**摘要段**里的微信界面残留洗掉。本地,不调模型。
433
+
434
+ 为什么需要单独一趟:清洗是**读时做**的(`create_reading_notes` 生成摘要时才调
435
+ `strip_wx_ads`),所以**那条逻辑生效之前写好的笔记永远不会被清** —— 实测 25,676 篇
436
+ 里有 1,484 篇的摘要带着 `继续滑动看下一个`、`去阅读`、`轻触阅读原文` 这些**页面按钮
437
+ 文字**(不是文章内容)。改生成逻辑只影响以后写的笔记,与 `--refresh-queries` 同一个
438
+ 道理。好消息是不会有新的:新写的本来就干净。
439
+
440
+ **只动摘要段,正文一个字不碰。** 正文是原文,清它等于改素材 —— 而残留对正文的害处
441
+ 只是"翻原文时看着乱",不值这个代价。
442
+ """
443
+ from _utils import _AD_PHRASES, strip_wx_ads
444
+ # **只扫阅读笔记那一层**(`002_Literature`),与 `refresh_dataview_blocks` 同一套目录。
445
+ # 第一版写的是 `Path(vault).rglob('*.md')` —— 那会扫**整个库**,实测把 `Sources/` 的
446
+ # 10,949 篇**原文**也洗了(2026-09-28)。那些原文是素材,清洗它们不是这条命令的职责:
447
+ # 下游生成读的是笔记(`create_reading_notes` 已经读过原文并清过摘要),所以动素材
448
+ # 只有"翻原文时看着干净"这一个好处,却要付"改素材"的代价。
449
+ cleaned, untouched, skipped = [], [], []
450
+ root = Path(vault) / '002_Literature'
451
+ for path in sorted(root.rglob('*.md')):
452
+ try:
453
+ text = path.read_text(encoding='utf-8')
454
+ except (OSError, UnicodeDecodeError):
455
+ skipped.append(path.name)
456
+ continue
457
+ match = SUMMARY_BLOCK_RE.search(text)
458
+ if not match:
459
+ continue
460
+ segment = match.group(2)
461
+ # **判据必须是"真的含界面短语",不能是"文本变了"**:`strip_wx_ads` 末尾还有空白
462
+ # 规整与 `.strip()`,任何首尾带空白的段落都会"变",拿它当判据会全量重写(2026-09-28
463
+ # 我在测量时正好踩过这一脚:判据恒真,于是报出 25,676 篇全部命中)。
464
+ if not any(phrase in segment for phrase in _AD_PHRASES):
465
+ untouched.append(path.name)
466
+ continue
467
+ fresh = strip_wx_ads(segment)
468
+ if fresh == segment:
469
+ untouched.append(path.name)
470
+ continue
471
+ path.write_text(text[:match.start(2)] + fresh + text[match.end(2):], encoding='utf-8')
472
+ cleaned.append(path.name)
473
+ return {'cleaned': cleaned, 'untouched': untouched, 'skipped': skipped}
474
+
475
+
319
476
  def main():
320
477
  sys.stdout.reconfigure(encoding='utf-8', errors='replace')
321
478
  import argparse
322
479
  parser = argparse.ArgumentParser(description='创建 Obsidian 阅读笔记 V2')
323
- parser.add_argument('--date', required=True, help='日期 YYYY-MM-DD')
480
+ parser.add_argument('--date', help='日期 YYYY-MM-DD(--refresh-queries 时不需要)')
324
481
  parser.add_argument('--source', default=DEFAULT_SOURCE, help='文章目录')
325
482
  parser.add_argument('--vault', default=DEFAULT_VAULT, help='Vault 目录')
483
+ parser.add_argument('--clean-summaries', action='store_true',
484
+ help='只把已有笔记摘要段里的微信界面残留洗掉(本地,不调模型)')
485
+ parser.add_argument('--refresh-queries', action='store_true',
486
+ help='只做这件事:把已有笔记里的 dataview 查询重写成当前模板(本地,不调模型)')
326
487
  args = parser.parse_args()
327
488
 
489
+ if args.clean_summaries:
490
+ # 本地清洗:动的是本地就能算出来的东西,不必把两万多篇重问一遍
491
+ result = clean_summaries(args.vault)
492
+ print('清洗摘要:%d 篇;本来就干净、没动的 %d 篇;读不出来的 %d 篇'
493
+ % (len(result['cleaned']), len(result['untouched']), len(result['skipped'])))
494
+ for name in result['skipped'][:5]:
495
+ print(' 跳过:', name[:70])
496
+ return
497
+
498
+ if args.refresh_queries:
499
+ result = refresh_dataview_blocks(args.vault)
500
+ print('重写 %d 篇;没有查询块、没动的 %d 篇;读不出来的 %d 篇'
501
+ % (len(result['rewritten']), len(result['noBlock']), len(result['skipped'])))
502
+ if result['topicless']:
503
+ print(' 其中主题为空(照改,但表会是空的): %d 篇' % len(result['topicless']))
504
+ for name in result['skipped'][:5]:
505
+ print(' 跳过:', name[:70])
506
+ return
507
+
508
+ if not args.date:
509
+ print('[ERROR] 要 --date(或用 --refresh-queries 只重写查询)')
510
+ sys.exit(1)
511
+
328
512
  date_dir = os.path.join(args.source, args.date)
329
513
  if not os.path.isdir(date_dir):
330
514
  print(f'[ERROR] 目录不存在: {date_dir}')
@@ -361,6 +545,7 @@ def main():
361
545
  daily_content = DAILY_TEMPLATE.format(
362
546
  date=args.date, total=total, created=created,
363
547
  reading_list='\n'.join(titles[:20]) if titles else '(无)',
548
+ daily_query=DAILY_QUERY.format(date=args.date),
364
549
  )
365
550
  daily_path.write_text(daily_content, encoding='utf-8')
366
551
  print(f' 日记: {daily_path}')
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env python3
2
+ """本机判断层 —— 一个 state、一批类型化问题、一次调用,返回带概率的答案。
3
+
4
+ **它解决的不是"判断得更好",是"判断得起"。** 调用方自己的模型当然也能判断,
5
+ 但那是按 token 收费、按次往返、还要把散文解析回结构。这里一次请求问 20 个问题
6
+ 实测约 1 秒,且问题数几乎不影响成本(2 个 0.84s / 12 个 0.91s),所以
7
+ **"给 N 个条目各打一批标签"这件事第一次变得便宜**。
8
+
9
+ 典型用法是一个 Agent 在批量处理时把判断外包出来:
10
+
11
+ echo '{"state": "...", "questions": {"相关": {"type": "noul",
12
+ "instructions": "这段是否与大气污染有关?"}}}' | python scripts/decide.py
13
+
14
+ python scripts/decide.py --request req.json
15
+ python scripts/decide.py --request req.json --dry-run # 只校验不调用
16
+
17
+ 输出(stdout,JSON):
18
+
19
+ {"success": true, "model": "jev-1.13.0", "answers": {...},
20
+ "usage": {"input_tokens": 1200, "output_tokens": 60}, "costUsd": 5.04e-05}
21
+
22
+ 请求格式**本地先校验**:形状不对就在本机报错,不让它变成一个来自服务端的 422——
23
+ 那种错误只告诉你"哪里不对"里最不重要的那部分。
24
+
25
+ state : 字符串 / JSON 对象 / 数组(共享给所有问题)
26
+ questions : {名字: {type, instructions, criteria}}
27
+ choice → criteria 是对象 {选项名: 何时选它}
28
+ score → criteria 是有序数组,**位置即分值,从 0 开始**
29
+ noul → 不需要 criteria(可选 {true, false})
30
+
31
+ 批量模式把最常用的那层展开做掉了——一个 glob × 一批问题,自动变成 N×M 个问题:
32
+
33
+ python scripts/decide.py --over "scripts/*.py" --ask "会写本地文件吗" --ask "会出网吗" --yes
34
+
35
+ **这是出境调用**:state 会被发到 api.typesafe.ai。读不读本地由输入方式决定:
36
+ `--request`/stdin **完全由调用方给**,命令自己不读任何文件;`--over` 会读匹配到的
37
+ 文件(所以它是一条读本地数据的路径,`capabilities` 里也如实分开写了)。
38
+ """
39
+ import argparse
40
+ import glob
41
+ import json
42
+ import os
43
+ import sys
44
+ import urllib.error
45
+
46
+ sys.path.insert(0, __import__('os').path.dirname(__import__('os').path.abspath(__file__)))
47
+ from jev_client import INPUT_USD_PER_TOKEN, JevError, create_client # noqa: E402
48
+
49
+ QUESTION_TYPES = ('choice', 'score', 'noul')
50
+
51
+
52
+ def expand_batch(patterns, asks, max_chars, limit):
53
+ """把「一批文件 × 一批问题」展开成一次请求的 state 与 questions。
54
+
55
+ 问题名是 `f<序号>|q<序号>` 而不是把文件名编进去:文件名里有 `|`、空格、中文标点,
56
+ 编进去之后调用方要靠解析字符串才能还原。序号 + 响应里的 `batch.files` 是显式的映射。
57
+
58
+ **喂多少字符要能看见**:实测同一批问题,只喂每个文件前 14 行和喂前 1500 字符,
59
+ 与基准的一致率从 77.3% 变成 88.6%——证据量决定上限。所以它由参数给出,
60
+ 并且写进返回里,而不是藏在实现里。
61
+ """
62
+ paths = []
63
+ for pattern in patterns:
64
+ paths.extend(glob.glob(pattern, recursive=True))
65
+ # 排序 + 去重:同一批文件两次跑应该展开成同样的顺序,否则问题名对不上。
66
+ paths = sorted({p for p in paths if os.path.isfile(p)})
67
+ if limit:
68
+ paths = paths[:limit]
69
+ if not paths:
70
+ return None, None, None, '没有匹配到任何文件:%s' % ', '.join(patterns)
71
+
72
+ blocks = []
73
+ for index, path in enumerate(paths):
74
+ try:
75
+ with open(path, encoding='utf-8', errors='replace') as handle:
76
+ body = handle.read()
77
+ except OSError as error:
78
+ return None, None, None, '读不到 %s:%s' % (path, error)
79
+ blocks.append('【f%d】%s\n%s' % (index, os.path.basename(path), body[:max_chars]))
80
+
81
+ names = [os.path.basename(p) for p in paths]
82
+ state = ('下面是 %d 个文件,每个只给出前 %d 个字符:\n\n%s'
83
+ % (len(paths), max_chars, '\n\n'.join(blocks)))
84
+ questions = {}
85
+ for index, name in enumerate(names):
86
+ for ask_index, instruction in enumerate(asks):
87
+ questions['f%d|q%d' % (index, ask_index)] = {
88
+ 'type': 'noul',
89
+ 'instructions': '【f%d】(%s)%s' % (index, name, instruction),
90
+ }
91
+ return state, questions, {'files': names, 'asks': list(asks),
92
+ 'maxChars': max_chars}, None
93
+
94
+
95
+ def validate_request(payload):
96
+ """返回 (state, questions, error_message)。error 非空时调用方应当拒绝。
97
+
98
+ 宁可在这里啰嗦,也不要把一个形状不对的请求发出去换一个 422:
99
+ 服务端的校验错误只说得出"哪个字段不合法",说不出"你本来想干什么"。
100
+ """
101
+ if not isinstance(payload, dict):
102
+ return None, None, '请求必须是一个 JSON 对象'
103
+ state = payload.get('state')
104
+ if state is None or (isinstance(state, str) and not state.strip()):
105
+ return None, None, '缺少 state(字符串 / 对象 / 数组都可以,但不能为空)'
106
+ if not isinstance(state, (str, dict, list)):
107
+ return None, None, 'state 只能是字符串、JSON 对象或数组'
108
+
109
+ questions = payload.get('questions')
110
+ if not isinstance(questions, dict) or not questions:
111
+ return None, None, 'questions 必须是一个非空对象:{名字: {type, ...}}'
112
+
113
+ for name, question in questions.items():
114
+ if not isinstance(question, dict):
115
+ return None, None, '问题 %r 必须是一个对象' % name
116
+ kind = question.get('type')
117
+ if kind not in QUESTION_TYPES:
118
+ return None, None, ('问题 %r 的 type 必须是 %s 之一,收到 %r'
119
+ % (name, '/'.join(QUESTION_TYPES), kind))
120
+ criteria = question.get('criteria')
121
+ if kind == 'choice':
122
+ if not isinstance(criteria, dict) or not criteria:
123
+ return None, None, ('choice 问题 %r 需要非空 criteria 对象 '
124
+ '{选项名: 何时选它}' % name)
125
+ elif kind == 'score':
126
+ # 位置即分值,只有一个档位的话 score 恒等于 0,毫无信息量。
127
+ if not isinstance(criteria, list) or len(criteria) < 2:
128
+ return None, None, ('score 问题 %r 的 criteria 需要至少两个有序档位'
129
+ '(位置即分值)' % name)
130
+ elif criteria is not None and not isinstance(criteria, dict):
131
+ return None, None, 'noul 问题 %r 的 criteria 若要给,必须是对象' % name
132
+ return state, questions, None
133
+
134
+
135
+ def read_request(args):
136
+ if args.request and args.request != '-':
137
+ try:
138
+ with open(args.request, encoding='utf-8') as handle:
139
+ return handle.read()
140
+ except OSError as error:
141
+ raise SystemExit('读不到请求文件:%s' % error)
142
+ return sys.stdin.read()
143
+
144
+
145
+ def main():
146
+ parser = argparse.ArgumentParser(
147
+ description='本机判断层:一个 state + 一批类型化问题,一次调用返回带概率的答案')
148
+ parser.add_argument('--request', help='请求 JSON 的路径,或 - 表示读 stdin(默认读 stdin)')
149
+ parser.add_argument('--over', action='append', default=[], metavar='GLOB',
150
+ help='批量模式:对匹配到的每个文件问 --ask 里的每个问题(可重复)')
151
+ parser.add_argument('--ask', action='append', default=[], metavar='TEXT',
152
+ help='批量模式下的一个是非题,逐文件展开(可重复)')
153
+ parser.add_argument('--max-chars', type=int, default=1500, metavar='N',
154
+ help='批量模式下每个文件喂多少字符(默认 1500;它决定上限)')
155
+ parser.add_argument('--limit', type=int, default=50, metavar='N',
156
+ help='批量模式最多取多少个文件(默认 50,0 表示不限)')
157
+ parser.add_argument('--model', default='', help='模型别名,默认 jev-latest')
158
+ parser.add_argument('--dry-run', action='store_true', help='只校验并回显请求,不调用')
159
+ args = parser.parse_args()
160
+
161
+ batch_meta = None
162
+ if args.over:
163
+ if not args.ask:
164
+ print(json.dumps({'success': False, 'code': 'INVALID_REQUEST',
165
+ 'error': '批量模式需要至少一个 --ask(否则没有问题可问)'},
166
+ ensure_ascii=False))
167
+ return 2
168
+ state, questions, batch_meta, error = expand_batch(
169
+ args.over, args.ask, args.max_chars, args.limit)
170
+ if error:
171
+ print(json.dumps({'success': False, 'code': 'NO_FILES', 'error': error},
172
+ ensure_ascii=False))
173
+ return 2
174
+ payload = {'state': state, 'questions': questions}
175
+ elif args.request or not sys.stdin.isatty():
176
+ raw = read_request(args)
177
+ if not raw.strip():
178
+ print(json.dumps({'success': False, 'code': 'EMPTY_REQUEST',
179
+ 'error': 'stdin 或 --request 里没有内容'}, ensure_ascii=False))
180
+ return 2
181
+ try:
182
+ payload = json.loads(raw)
183
+ except json.JSONDecodeError as error:
184
+ print(json.dumps({'success': False, 'code': 'INVALID_JSON',
185
+ 'error': '请求不是合法 JSON: %s' % error}, ensure_ascii=False))
186
+ return 2
187
+ else:
188
+ print(json.dumps({'success': False, 'code': 'EMPTY_REQUEST',
189
+ 'error': '没有输入:用 --request/--over,或把请求接到 stdin'},
190
+ ensure_ascii=False))
191
+ return 2
192
+
193
+ state, questions, error = validate_request(payload)
194
+ if error:
195
+ print(json.dumps({'success': False, 'code': 'INVALID_REQUEST',
196
+ 'error': error}, ensure_ascii=False))
197
+ return 2
198
+
199
+ size = len(state) if isinstance(state, str) else len(
200
+ json.dumps(state, ensure_ascii=False))
201
+
202
+ if args.dry_run:
203
+ body = {
204
+ 'success': True, 'dryRun': True, 'action': 'decide',
205
+ 'questions': list(questions), 'questionCount': len(questions),
206
+ 'stateChars': size,
207
+ # 批量模式下这两件事必须报出来:读了本地什么、每份喂了多少(决定上限)。
208
+ 'readsLocalData': bool(args.over),
209
+ 'invokesAI': True,
210
+ }
211
+ if batch_meta:
212
+ body['batch'] = batch_meta
213
+ body['fileCount'] = len(batch_meta['files'])
214
+ body['maxChars'] = batch_meta['maxChars']
215
+ print(json.dumps(body, ensure_ascii=False))
216
+ return 0
217
+
218
+ client = create_client(model=args.model) if args.model else create_client()
219
+ if client is None:
220
+ print(json.dumps({'success': False, 'code': 'NO_KEY',
221
+ 'error': '缺少 TypeSafe key:weflow-cli config set '
222
+ 'typesafeApiKey "..."'}, ensure_ascii=False))
223
+ return 2
224
+
225
+ try:
226
+ answers, usage = client.decide(state, questions)
227
+ except JevError as err:
228
+ print(json.dumps({'success': False, 'code': 'DECIDE_FAILED',
229
+ 'error': str(err)}, ensure_ascii=False))
230
+ return 1
231
+ except urllib.error.URLError as err: # 兜底:客户端应当已经包好了
232
+ print(json.dumps({'success': False, 'code': 'DECIDE_FAILED',
233
+ 'error': str(err)}, ensure_ascii=False))
234
+ return 1
235
+
236
+ tokens = usage.get('input_tokens') or 0
237
+ body = {
238
+ 'success': True,
239
+ 'model': client.model,
240
+ 'answers': answers,
241
+ 'usage': usage,
242
+ # 把成本一起回给调用方:一个 Agent 要能自己决定"再问一批"划不划算。
243
+ 'costUsd': round(tokens * INPUT_USD_PER_TOKEN, 8),
244
+ }
245
+ if batch_meta:
246
+ # 序号到文件/问题的映射由这里给出,调用方不必去解析问题名。
247
+ body['batch'] = batch_meta
248
+ print(json.dumps(body, ensure_ascii=False))
249
+ return 0
250
+
251
+
252
+ if __name__ == '__main__':
253
+ sys.exit(main())