weflow-cli 1.7.0 → 1.8.1

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 (200) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +2108 -2
  3. package/OPERATIONS.md +810 -1
  4. package/README.en.md +96 -10
  5. package/README.md +34 -9
  6. package/bin/weflow-cli.ts +937 -28
  7. package/dist/bin/weflow-cli.js +955 -28
  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 +25 -6
  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 +599 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +217 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1116 -59
  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/services/wereadService.d.ts +33 -6
  100. package/dist/src/services/wereadService.d.ts.map +1 -1
  101. package/dist/src/services/wereadService.js +27 -2
  102. package/dist/src/services/wereadService.js.map +1 -1
  103. package/dist/src/utils/atomicWrite.d.ts +4 -0
  104. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  105. package/dist/src/utils/atomicWrite.js +60 -0
  106. package/dist/src/utils/atomicWrite.js.map +1 -0
  107. package/dist/src/utils/dateRange.d.ts +15 -0
  108. package/dist/src/utils/dateRange.d.ts.map +1 -1
  109. package/dist/src/utils/dateRange.js +47 -0
  110. package/dist/src/utils/dateRange.js.map +1 -1
  111. package/dist/src/utils/text.d.ts +18 -0
  112. package/dist/src/utils/text.d.ts.map +1 -0
  113. package/dist/src/utils/text.js +20 -0
  114. package/dist/src/utils/text.js.map +1 -0
  115. package/docs/AI_INTERFACE.md +2 -0
  116. package/docs/BRANCHES.md +13 -4
  117. package/docs/DECISIONS.md +1424 -0
  118. package/docs/EXTENDING.md +187 -0
  119. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  120. package/docs/MCP.md +23 -4
  121. package/docs/PROJECT_STATE.md +210 -9
  122. package/docs/SETUP.md +22 -0
  123. package/docs/SYNC_CONTRACT.md +31 -3
  124. package/mcp-server/index.ts +681 -657
  125. package/package.json +84 -83
  126. package/resources/panel/ball-position.cjs +153 -0
  127. package/resources/panel/index.html +46 -0
  128. package/resources/panel/main.cjs +483 -0
  129. package/resources/panel/mascot-happy.png +0 -0
  130. package/resources/panel/mascot-sorry.png +0 -0
  131. package/resources/panel/mascot-thinking.png +0 -0
  132. package/resources/panel/mascot-tired.png +0 -0
  133. package/resources/panel/mascot.png +0 -0
  134. package/resources/panel/package.json +7 -0
  135. package/resources/panel/panel.css +415 -0
  136. package/resources/panel/preload.cjs +79 -0
  137. package/resources/panel/quick-menu.cjs +197 -0
  138. package/resources/panel/renderer.js +470 -0
  139. package/resources/panel/tray-menu.cjs +40 -0
  140. package/resources/panel/tray.png +0 -0
  141. package/scripts/_utils.py +425 -54
  142. package/scripts/annual_report.py +4 -2
  143. package/scripts/article_notes.py +423 -0
  144. package/scripts/backfill_articles.py +438 -0
  145. package/scripts/biz_daily.py +590 -105
  146. package/scripts/chat_notes.py +470 -0
  147. package/scripts/classify_daily.py +10 -18
  148. package/scripts/compile_wiki.py +1060 -46
  149. package/scripts/create_reading_notes.py +199 -14
  150. package/scripts/decide.py +253 -0
  151. package/scripts/draft_reply.py +671 -0
  152. package/scripts/export_chat_html.py +35 -33
  153. package/scripts/extract_todos.py +31 -5
  154. package/scripts/fav_notes.py +318 -0
  155. package/scripts/fix_topics.py +2 -1
  156. package/scripts/generate_ai_report.py +214 -9
  157. package/scripts/generate_html.py +18 -3
  158. package/scripts/generate_review.py +2 -2
  159. package/scripts/jev_client.py +294 -0
  160. package/scripts/jev_probe.py +440 -0
  161. package/scripts/nt_common.py +82 -0
  162. package/scripts/nt_decrypt.py +1755 -1460
  163. package/scripts/pipeline.py +27 -2
  164. package/scripts/promote_ideas.py +19 -4
  165. package/scripts/quality_eval.py +707 -0
  166. package/scripts/rag_chat.py +4 -4
  167. package/scripts/read_image.py +187 -0
  168. package/scripts/reply_debt.py +488 -0
  169. package/scripts/route_cards.py +453 -0
  170. package/scripts/semantic_search.py +208 -49
  171. package/scripts/user_notes.py +315 -0
  172. package/scripts/vault_rag.py +13 -7
  173. package/scripts/vault_search.py +21 -4
  174. package/scripts/wiki_lint.py +429 -0
  175. package/src/core/appMsgFormat.ts +117 -0
  176. package/src/core/ntCore.ts +27 -2
  177. package/src/core/sqlcipherCore.ts +5 -61
  178. package/src/core/wcdbCore.ts +6 -2
  179. package/src/panel/client.ts +49 -0
  180. package/src/panel/endpoint.ts +144 -0
  181. package/src/panel/launch.ts +101 -0
  182. package/src/panel/server.ts +424 -0
  183. package/src/panel/userId.ts +72 -0
  184. package/src/services/assistantDaemon.ts +57 -9
  185. package/src/services/assistantEval.ts +611 -0
  186. package/src/services/assistantMemory.ts +413 -57
  187. package/src/services/assistantPrivacy.ts +25 -6
  188. package/src/services/assistantRouter.ts +191 -0
  189. package/src/services/assistantService.ts +681 -74
  190. package/src/services/assistantTools.ts +1149 -58
  191. package/src/services/assistantTrace.ts +175 -0
  192. package/src/services/chatService.ts +6 -2
  193. package/src/services/configService.ts +403 -385
  194. package/src/services/pythonBridge.ts +139 -0
  195. package/src/services/syncService.ts +37 -9
  196. package/src/services/wechatMessageService.ts +73 -7
  197. package/src/services/wereadService.ts +43 -7
  198. package/src/utils/atomicWrite.ts +56 -0
  199. package/src/utils/dateRange.ts +46 -0
  200. package/src/utils/text.ts +20 -0
@@ -0,0 +1,470 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 聊天知识卡 — 把一段时间的会话变成 Wiki 的输入。
4
+
5
+ 用法:
6
+ # 先看代价(读本地库、**不调用任何模型**):几个会话、多少字会出境
7
+ python scripts/chat_notes.py --days 30 --dry-run --json
8
+
9
+ # 真的生成(会把每个会话的对话发给 DeepSeek)
10
+ python scripts/chat_notes.py --days 30 --yes
11
+
12
+ # 再聚成概念页(那一步早就支持 --source,不用改它)
13
+ python scripts/compile_wiki.py --source output/chat-notes
14
+
15
+ 产出: output/chat-notes/<会话名>.md —— **每个会话一张卡**,覆盖最近 `--days` 天。
16
+
17
+ ## 为什么这么切(而不是新写一套知识库)
18
+
19
+ `compile_wiki.py` 那条路吃的是"带 frontmatter + `[[wikilink]]` 的 markdown",它负责聚合概念、
20
+ 生成概念页、写反向来源。文章线(`output/biz-daily`)就是喂给它 `--source`。所以这里**只做产出卡**,
21
+ 聚合、概念页、反链全部复用现成的那条路——同一件事不写第二套实现。
22
+
23
+ 三种形状就这么落下来:
24
+ - **按会话的知识卡** = 卡本身(`## 摘要/时间线/主题与人物/欠着什么`);
25
+ - **主题汇总** = 卡里 `[[主题]]` 聚出来的概念页;
26
+ - **人物/事件时间线** = 卡里 `[[人名]] — 那时候发生了什么` 聚出来的概念页(每条描述就是一处事件)。
27
+
28
+ ## 两条不能松的纪律
29
+
30
+ 1. **`[[…]]` 由本脚本渲染,不由模型自由写**。模型返回结构化列表(主题/人物 + 一句话),
31
+ 这里拼成 `[[名字]] — 描述`。否则模型在正文里随手写一个 `[[…]]`,就会变成一个凭空冒出来的
32
+ 概念(`compile_wiki` 的 `scan_articles` 收正文里**所有** wikilink)。
33
+ 2. **只依据给定的对话**。提示词里写死"没提到的不写、时间不许推断",摘要与时间线照原样收,
34
+ 细节缺口就留空——宁可少写,不要补一个看起来合理的事实。
35
+ """
36
+ import argparse
37
+ import json
38
+ import os
39
+ import re
40
+ import sys
41
+ from datetime import datetime, timedelta, timezone
42
+ from pathlib import Path
43
+
44
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
45
+ from _utils import (CHAT_CARD_PREFIX, SEPARATOR, call_deepseek, decrypt_lock, # noqa: E402
46
+ get_api_key, load_config,
47
+ note_path as _shared_note_path, plain, write_with_frontmatter)
48
+ from reply_debt import collect_conversations, format_line # noqa: E402
49
+
50
+ import nt_decrypt # noqa: E402
51
+
52
+ TZ = timezone(timedelta(hours=8))
53
+ OUTPUT_ROOT = 'output/chat-notes'
54
+ DEFAULT_DAYS = 30
55
+ # 分页的**页大小**。以前这个值就是"每个会话取多少条"的上限(120),而那是错的:
56
+ # 实测窗口内 138 个会话共 **8 万条**,而它只送了 **9,010 条(约 19%)**——每个会话
57
+ # 无论多大都只看最新 120 条,`--days` 那个窗口因此几乎不起作用(先取 120 条再按天筛,
58
+ # 那 120 条必然在窗口内)。现在按 offset **分页取到取完为止**。
59
+ NOTE_MESSAGES = 2000
60
+ # 单个会话的取数上限——只是个防跑飞的保险,不是常态。为什么敢开这么大:
61
+ # **用户的要求是"同一个群聊必须在同一个上下文"**(切块会让模型看不到前因后果),
62
+ # 而实测 API 接受 **1,000,006** token 的提示词;单个会话最大(3 万条)约 **78 万 token**。
63
+ # 撞到它要**报出来**,不能静默截断。
64
+ MAX_MESSAGES_PER_CONVERSATION = 60000
65
+ # 每张卡最多出几话题/几人。**原来是 6**——那时每个会话只看 120 条,6 条勉强够;
66
+ # 现在正文全进来了(一个群可能 3 万条、几百人),6 个明显是抽样。
67
+ PER_CARD_ITEMS = 20
68
+ # 输出的 token 上限。**2000 太小了**:正文全进来之后,一个会话要产出的 JSON
69
+ # (摘要 + 时间线 + 最多 20 个话题/人物)能到四千多 token——实测 34 个会话里绝大多数
70
+ # 是 `finish_reason=length`,JSON 断在半路,而报出来的原因是"模型没返回可用 JSON",
71
+ # 看不出是被截断。实测 16000 被接受。
72
+ MAX_OUTPUT_TOKENS = 16000
73
+ # 输入的字数预算。实测该模型上下文 **1,048,576 token**,而最容易踩线的是那个 3 万条的群。
74
+ # 按保守的 1.5 字符/token 换算,并给输出留出 MAX_OUTPUT_TOKENS,定在下面这个数。
75
+ # **超了就从最旧的丢,并报出丢了几条**——不许静默截断。
76
+ PROMPT_CHAR_BUDGET = 1450000
77
+
78
+
79
+ def trim_to_budget(lines, budget=PROMPT_CHAR_BUDGET):
80
+ """超预算就从**最旧的**开始丢,返回 `(保留的 lines, 丢掉的条数)`。
81
+
82
+ 丢最旧的而不是最新的:知识卡要回答的是"最近这段在聊什么",而丢最新的会让时间线
83
+ 断在中间——最近的对话也恰恰是用户最可能关心的。
84
+ """
85
+ total = len('\n'.join(lines))
86
+ if total <= budget:
87
+ return lines, 0
88
+ dropped = 0
89
+ while dropped < len(lines) and total > budget:
90
+ total -= len(lines[dropped]) + 1
91
+ dropped += 1
92
+ return lines[dropped:], dropped
93
+ # 每条消息进提示词的字数上限。这是**本功能自己的口径**(知识卡要的是内容,不是起草那种短句),
94
+ # 与 `draft_reply.DRAFT_MSG_CHARS=160` 是两件事,别互相套用。
95
+ NOTE_MSG_CHARS = 300
96
+ MAX_CONVERSATIONS = 40
97
+ # 太薄的会话不值得产卡:几句话的对话写出来的"知识卡"只会是"只说了两句话",
98
+ # 而它**照样会占掉一次模型调用、并往概念页里灌一条没有信息量的来源**。
99
+ # 借自 WeKnora 那道"内容太少就拒绝调用 LLM"的闸门(`wiki_ingest_batch.go:1286`)。
100
+ MIN_MESSAGES_FOR_CARD = 3
101
+ MIN_CHARS_FOR_CARD = 60
102
+
103
+ NOTE_PROMPT = """你在为一个人整理他和某个会话最近 {days} 天的聊天,产出一张**知识卡**。
104
+
105
+ 会话:{name}
106
+ 材料(时间从早到晚,「我」是我,其他是对方;这是全部材料,**没有别的来源**):
107
+
108
+ {convo}
109
+
110
+ 请只依据上面的材料,返回一个 JSON 对象(不要加任何解释、不要用代码块围栏):
111
+ {{
112
+ "summary": "这段对话在谈什么、进展到哪(2-4 句,只写材料里有的)",
113
+ "timeline": [{{"when": "材料里出现的时间,如 09-20 或 上周三", "what": "那件事,一句话"}}],
114
+ "topics": [{{"name": "话题名(2-8 字,不要带书名号引号)", "desc": "关于这个话题,这段对话说了什么(一句话)"}}],
115
+ "people": [{{"name": "这段对话里提到的**人**的名字", "desc": "关于这个人,这段对话说了什么(一句话)"}}],
116
+ "owed": "这段对话里谁欠着谁什么、或下次该怎么开口;没有就写空字符串"
117
+ }}
118
+
119
+ 要求:
120
+ 1. **你是编译者,不是作者**。只写材料里有的:没提到的话题/人不要列;时间不许推断,
121
+ 材料里没有具体时间的事件就不进 timeline;**材料里相互矛盾的地方照实并列**,不要替它们调和成一句。
122
+ 2. topics 与 people 各不超过 {per_card} 条,**按重要度排序**;人名只收真实的人(不要把公众号、机构当人)。
123
+ 3. 每条 desc 都要是**这句话/这段对话**关于它的说法,不要写成百科定义。
124
+ 4. 全部用中文,不要 markdown 语法(不要 #、*、-)。
125
+ (第 1 条与"编译者不是作者"的措辞借自 Tencent/WeKnora 的 wiki 提示词,
126
+ `third-party/WeKnora/internal/agent/prompts_wiki.go`。)
127
+ """
128
+
129
+
130
+ def note_path(out_root, name):
131
+ """本功能的兜底名(共用实现见 `_utils.note_path`)"""
132
+ return _shared_note_path(out_root, name, fallback='未命名会话')
133
+
134
+
135
+ def parse_note(raw):
136
+ """把模型返回的东西解成结构化卡片。解不出来返回 None(**不猜**)。"""
137
+ if not isinstance(raw, str) or not raw.strip():
138
+ return None
139
+ text = raw.strip()
140
+ # 容忍模型套了代码块或加了几句前言:取第一个 {...} 块
141
+ start, end = text.find('{'), text.rfind('}')
142
+ if start < 0 or end <= start:
143
+ return None
144
+ try:
145
+ data = json.loads(text[start:end + 1])
146
+ except Exception:
147
+ return None
148
+ if not isinstance(data, dict) or not str(data.get('summary') or '').strip():
149
+ return None
150
+
151
+ def items(key, limit):
152
+ out = []
153
+ for item in data.get(key) or []:
154
+ if not isinstance(item, dict):
155
+ continue
156
+ # 概念名里也不许带方括号:它要被拼进 `[[…]]`,名字里再有一个就毁了链接
157
+ item_name = plain(item.get('name')).strip().strip('[]')
158
+ desc = plain(item.get('desc')).strip()
159
+ if item_name and desc:
160
+ out.append({'name': item_name, 'desc': desc})
161
+ if len(out) >= limit:
162
+ break
163
+ return out
164
+
165
+ timeline = []
166
+ for item in data.get('timeline') or []:
167
+ if isinstance(item, dict) and str(item.get('what') or '').strip():
168
+ timeline.append({'when': plain(item.get('when')).strip(),
169
+ 'what': plain(item['what']).strip()})
170
+ return {
171
+ 'summary': plain(data['summary']).strip(),
172
+ 'timeline': timeline[:20],
173
+ 'topics': items('topics', PER_CARD_ITEMS),
174
+ 'people': items('people', PER_CARD_ITEMS),
175
+ 'owed': plain(data.get('owed')).strip(),
176
+ }
177
+
178
+
179
+ def render_note(note, name, days, updated, messages):
180
+ """卡片 → (frontmatter, body)。
181
+
182
+ frontmatter 的键是**消费者定的**:`compile_wiki.scan_articles` 读 `title`/`source`/`topic`/`tags`。
183
+ body 的 `## AI 摘要` 标题也是它定的(`_extract_summary` 就找这一段)。
184
+ """
185
+ frontmatter = {
186
+ 'title': f'{name}',
187
+ 'type': 'chat-card',
188
+ 'source': name,
189
+ 'topic': '聊天',
190
+ 'tags': ['聊天', '知识卡'],
191
+ 'updated': updated,
192
+ 'window': f'最近 {days} 天 / {messages} 条',
193
+ }
194
+
195
+ parts = ['# %s\n' % name, '## AI 摘要\n', note['summary'] + '\n']
196
+ if note['timeline']:
197
+ parts.append('## 时间线\n')
198
+ for item in note['timeline']:
199
+ when = ('%s ' % item['when']) if item['when'] else ''
200
+ parts.append('- %s%s' % (when, item['what']))
201
+ parts.append('')
202
+ if note['topics'] or note['people']:
203
+ # **这一节是唯一出现 `[[…]]` 的地方**(见文件头纪律),而且由这里拼、不由模型写。
204
+ # 类型用**小标题**分,不塞进行尾——那行破折号后面的话是要喂给概念页的原料
205
+ # (`compile_wiki` 取的是整行剩余部分),多一个「(话题)」就把它污染了。
206
+ parts.append('## 主题与人物\n')
207
+ for label, key in (('话题', 'topics'), ('人', 'people')):
208
+ if not note[key]:
209
+ continue
210
+ parts.append('### %s\n' % label)
211
+ for item in note[key]:
212
+ parts.append('- [[%s]] %s %s' % (item['name'], SEPARATOR, item['desc']))
213
+ parts.append('')
214
+ if note['owed']:
215
+ parts.append('## 欠着什么\n')
216
+ parts.append(note['owed'] + '\n')
217
+ return frontmatter, '\n'.join(parts)
218
+
219
+
220
+ def build_note_prompt(name, lines, days):
221
+ return NOTE_PROMPT.format(name=name, days=days, convo='\n'.join(lines),
222
+ per_card=PER_CARD_ITEMS)
223
+
224
+
225
+ def worth_a_card(messages, chars, min_messages=MIN_MESSAGES_FOR_CARD,
226
+ min_chars=MIN_CHARS_FOR_CARD):
227
+ """这个会话够不够给一张卡——不够就跳过,**并报出来**(不是静默丢弃)。
228
+
229
+ 两个阈值都是"别浪费一次调用、也别往概念页里灌没信息量的来源"的实用闸门,
230
+ 不是"重要/不重要"的判断。跳过的会话名会出现在结果里,方便你决定要不要放宽。
231
+ """
232
+ return messages >= min_messages and chars >= min_chars
233
+
234
+
235
+ def within_window(messages, cutoff):
236
+ """窗口内、且**方向明确**的消息。
237
+
238
+ `get_messages` 只能给"最近 N 条",没有时间区间参数,所以窗口要在这里自己筛。
239
+ 方向为 `None` 的(`isSend` 缺失)也丢掉:这一行的标签是「我」还是对方全靠它,
240
+ 拿不准的话那一行就只能瞎标——宁可少一条,不要给模型的材料里混一句标错的。
241
+ """
242
+ return [m for m in messages
243
+ if int(m.get('createTime') or 0) >= cutoff and m.get('isSend') is not None]
244
+
245
+
246
+ def fetch_all_messages(conns, talker, name_map, own_wxid):
247
+ """**把一个会话的消息取完**(分页),返回 `(messages, truncated)`。
248
+
249
+ `get_messages` 是"最近 N 条 + offset"的形式,所以一页一页往回翻直到取空。
250
+ 返回的 `truncated` 为真表示撞到了 `MAX_MESSAGES_PER_CONVERSATION`——那种情况要
251
+ **报出来**,不能装作读全了。
252
+
253
+ 为什么非要取完:用户的要求是"同一个群聊必须在同一个上下文"。切块省事,但模型
254
+ 看不到前因后果,写出来的时间线和人物关系会失真。
255
+ """
256
+ out, offset = [], 0
257
+ while len(out) < MAX_MESSAGES_PER_CONVERSATION:
258
+ page = nt_decrypt.get_messages(
259
+ conns, talker, NOTE_MESSAGES, offset=offset,
260
+ name_map=name_map, own_wxid=own_wxid).get('messages') or []
261
+ if not page:
262
+ break
263
+ out.extend(page)
264
+ offset += len(page)
265
+ if len(page) < NOTE_MESSAGES:
266
+ break
267
+ return out, len(out) >= MAX_MESSAGES_PER_CONVERSATION
268
+
269
+
270
+ def copy_to_vault(out_root, vault_root):
271
+ """把聊天卡拷进 Vault 的 `Sources/Chat/`,**文件名带 `会话-` 前缀**。
272
+
273
+ 为什么要带前缀:聊天卡的**名字就是会话名**(`白马非马`、`老表亲戚群`),而概念页
274
+ 也可能叫同一个名字。两个文件同名时 `[[白马非马]]` 在 Obsidian 里是二义的——
275
+ 那会把**已经解析得了的链接弄坏**(实测:概念页里有 30 条来源链接正指向自己)。
276
+
277
+ 为什么要拷进来:不拷的话,129 张卡(时间线、人物、"欠着什么")在 Vault 里完全
278
+ 看不见,而且概念页那 **1,176 条**来源链接全都指不到东西。人看知识库时只能看到
279
+ "概念",看不到产生它的会话。
280
+ """
281
+ cards = sorted(Path(out_root).glob('*.md'))
282
+ target_dir = Path(vault_root) / 'Sources' / 'Chat'
283
+ target_dir.mkdir(parents=True, exist_ok=True)
284
+ copied = 0
285
+ for card in cards:
286
+ if card.name.startswith('README'):
287
+ continue
288
+ target = target_dir / (CHAT_CARD_PREFIX + card.name)
289
+ target.write_bytes(card.read_bytes())
290
+ copied += 1
291
+ return {'copied': copied, 'dir': str(target_dir)}
292
+
293
+
294
+ def collect(out_root, days, limit):
295
+ """读本地库:最近有动静的会话 + 每个会话窗口内的对话。**不调用任何模型。**"""
296
+ config = load_config()
297
+ db = config.get('ntDbPath', '')
298
+ if not db:
299
+ print('配置里没有 ntDbPath,先运行 weflow-cli init', file=sys.stderr)
300
+ return None, 'no-db'
301
+ conns = nt_decrypt.connect_message_shards(
302
+ db, decrypt_lock(config.get('ntKey', '')), config.get('ntSalt', ''),
303
+ # 口令的回退链与 `draft_reply.py` 那条路逐字一致:两处读出不同的库才是怪事
304
+ decrypt_lock(config.get('favPassphrase') or config.get('decryptKey') or ''))
305
+ if not conns:
306
+ print('无法打开消息数据库,请检查密钥(weflow-cli check)', file=sys.stderr)
307
+ return None, 'no-conn'
308
+ try:
309
+ name_map = {}
310
+ contact_db = nt_decrypt.find_contact_db_path(db)
311
+ if contact_db:
312
+ name_map = nt_decrypt.load_contact_names(
313
+ contact_db, decrypt_lock(config.get('contactKey', '')),
314
+ config.get('contactSalt', ''))
315
+ picked = collect_conversations(conns, name_map, config.get('wxid', ''), days, limit)
316
+ cutoff = int((datetime.now(TZ) - timedelta(days=days)).timestamp())
317
+ cards = []
318
+ thin = []
319
+ for item in picked:
320
+ messages, truncated = fetch_all_messages(conns, item['talker'], name_map,
321
+ config.get('wxid', ''))
322
+ in_window = within_window(messages, cutoff)
323
+ if not in_window:
324
+ continue
325
+ lines = [format_line(m, NOTE_MSG_CHARS) for m in reversed(in_window)]
326
+ # 超上下文预算就从最旧的丢(见 `trim_to_budget`),丢了几条要写进卡里
327
+ lines, dropped = trim_to_budget(lines)
328
+ chars = len('\n'.join(lines))
329
+ if not worth_a_card(len(lines), chars):
330
+ thin.append({'name': item['name'], 'messages': len(lines), 'chars': chars})
331
+ continue
332
+ cards.append({'talker': item['talker'], 'name': item['name'],
333
+ 'messages': len(lines), 'chars': chars, 'lines': lines,
334
+ 'truncated': truncated, 'dropped': dropped})
335
+ return {'cards': cards, 'skipped': thin, 'outRoot': out_root}, None
336
+ finally:
337
+ for conn in conns:
338
+ conn.close()
339
+
340
+
341
+ def exit_code(written: int) -> int:
342
+ """**部分成功算成功**(退出码 0),失败清单是数据不是进程状态。
343
+
344
+ 原来写的是"只要有失败就 exit 1"——于是"3 个会话里 2 张卡写出来了"被上层当成
345
+ **整体失败**,连脚本自己那份写着原因的 JSON 都被丢掉(CLI 只报一句退出码)。
346
+ 只有**一张都没写出来**才算失败。
347
+ """
348
+ return 0 if written else 1
349
+
350
+
351
+ def main():
352
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
353
+ parser = argparse.ArgumentParser(description='聊天知识卡(把会话变成 Wiki 的输入;只产文本,不发送)')
354
+ parser.add_argument('--days', type=int, default=DEFAULT_DAYS, help='看最近多少天(默认 30)')
355
+ parser.add_argument('--limit', type=int, default=MAX_CONVERSATIONS,
356
+ help='最多处理几个会话(默认 %d)' % MAX_CONVERSATIONS)
357
+ parser.add_argument('--out', default=OUTPUT_ROOT, help='卡片输出目录')
358
+ parser.add_argument('--dry-run', action='store_true', help='只报要发多少给模型,不调用')
359
+ parser.add_argument('--yes', action='store_true', help='确认调用云端模型')
360
+ parser.add_argument('--vault-root', default='output/wechat-vault',
361
+ help='Vault 根目录(--vault-copy 用)')
362
+ parser.add_argument('--vault-copy', action='store_true',
363
+ help='把聊天卡拷进 Vault 的 Sources/Chat(本地,不调模型)')
364
+ parser.add_argument('--json', action='store_true')
365
+ args = parser.parse_args()
366
+
367
+ if args.days < 1:
368
+ print(json.dumps({'success': False, 'error': '--days 要 ≥ 1'}))
369
+ return 1
370
+
371
+ collected, failure = collect(args.out, args.days, args.limit)
372
+ if collected is None:
373
+ if args.json:
374
+ print(json.dumps({'success': False, 'error': failure}))
375
+ return 1
376
+ cards = collected['cards']
377
+ skipped = collected.get('skipped') or []
378
+
379
+ if args.dry_run:
380
+ preview = {'success': True, 'dryRun': True, 'action': 'chat-notes',
381
+ 'days': args.days, 'conversations': len(cards),
382
+ 'messages': sum(c['messages'] for c in cards),
383
+ 'stateChars': sum(c['chars'] for c in cards),
384
+ 'skipped': len(skipped),
385
+ 'skippedNames': [s['name'] for s in skipped][:20],
386
+ 'model': 'DeepSeek(生成)', 'calls': len(cards),
387
+ 'readsLocalData': True, 'invokesAI': False, 'writesFiles': True,
388
+ 'note': '每个会话一次调用;卡片写到 %s,之后用 compile_wiki --source 聚合' % args.out}
389
+ if args.json:
390
+ print(json.dumps(preview, ensure_ascii=False, indent=2))
391
+ else:
392
+ print('会话 %d 个、消息 %d 条、%d 字符会发给 DeepSeek(每会话一次调用)'
393
+ % (len(cards), preview['messages'], preview['stateChars']))
394
+ if skipped:
395
+ print('另有 %d 个会话太薄(<%d 条或 <%d 字)不产卡:%s'
396
+ % (len(skipped), MIN_MESSAGES_FOR_CARD, MIN_CHARS_FOR_CARD,
397
+ '、'.join(s['name'] for s in skipped[:8])))
398
+ print('卡片写到 %s;之后跑 python scripts/compile_wiki.py --source %s 聚成概念页'
399
+ % (args.out, args.out))
400
+ return 0
401
+
402
+ if not cards:
403
+ if args.json:
404
+ print(json.dumps({'success': False, 'error': '最近 %d 天没有够得上产卡的会话' % args.days,
405
+ 'skipped': len(skipped)}))
406
+ else:
407
+ print('最近 %d 天没有够得上产卡的会话(%d 个太薄)' % (args.days, len(skipped)),
408
+ file=sys.stderr)
409
+ return 1
410
+
411
+ if not args.yes:
412
+ print('会把上面 %d 个会话的 %d 条消息发给 DeepSeek。加 --yes 确认。'
413
+ % (len(cards), sum(c['messages'] for c in cards)), file=sys.stderr)
414
+ return 1
415
+
416
+ config = load_config()
417
+ api_key = get_api_key(config)
418
+ if not api_key:
419
+ print('没有配置 deepseekApiKey(weflow-cli config set deepseekApiKey "...")', file=sys.stderr)
420
+ return 1
421
+
422
+ updated = datetime.now(TZ).strftime('%Y-%m-%d %H:%M')
423
+ written, failed = [], []
424
+ for card in cards:
425
+ prompt = build_note_prompt(card['name'], card['lines'], args.days)
426
+ try:
427
+ # 2000 而不是 1200:长对话要产出的 JSON(摘要+时间线+话题+人物)中文很吃 token,
428
+ # 被截断的 JSON 解不出来,而失败清单只说「没返回可用 JSON」——查不出是哪种失败。
429
+ raw = call_deepseek(prompt, api_key, max_tokens=MAX_OUTPUT_TOKENS, timeout=600)
430
+ except Exception as error:
431
+ failed.append({'name': card['name'], 'reason': '调用失败:%s' % error})
432
+ continue
433
+ note = parse_note(raw)
434
+ if note is None:
435
+ # **不猜**:模型没按形状返回,就说这张卡没生成,不拿半成品糊上去
436
+ # **带上现场**:末尾一截原文能直接看出是「被截断」还是「答成了散文」——
437
+ # 只写「没返回可用 JSON」的话,只能靠猜(而猜错就会去调错的东西)
438
+ failed.append({'name': card['name'], 'reason': '模型没返回可用 JSON',
439
+ 'rawTail': str(raw)[-200:] if raw else '(空)'})
440
+ continue
441
+ path = note_path(args.out, card['name'])
442
+ path.parent.mkdir(parents=True, exist_ok=True)
443
+ frontmatter, body = render_note(note, card['name'], args.days, updated, card['messages'])
444
+ write_with_frontmatter(str(path), frontmatter, body)
445
+ written.append({'name': card['name'], 'file': str(path),
446
+ 'topics': len(note['topics']), 'people': len(note['people'])})
447
+
448
+ if getattr(args, 'vault_copy', False):
449
+ sync = copy_to_vault(args.out, args.vault_root)
450
+ print(' 入 Vault: %d 张卡 → %s' % (sync['copied'], sync['dir']))
451
+
452
+ result = {'success': True, 'action': 'chat-notes', 'days': args.days,
453
+ 'model': 'DeepSeek(生成)', 'sendsNothing': True, 'writesNothingRemote': True,
454
+ 'written': written, 'failed': failed, 'skipped': skipped,
455
+ 'conversations': len(cards), 'calls': len(cards)}
456
+ if args.json:
457
+ print(json.dumps(result, ensure_ascii=False, indent=2))
458
+ else:
459
+ for item in written:
460
+ print('✓ %s → %s(话题 %d、人物 %d)'
461
+ % (item['name'], item['file'], item['topics'], item['people']))
462
+ for item in failed:
463
+ print('✗ %s:%s' % (item['name'], item['reason']), file=sys.stderr)
464
+ print('\n共 %d 张卡;接着跑 python scripts/compile_wiki.py --source %s'
465
+ % (len(written), args.out))
466
+ return exit_code(len(written))
467
+
468
+
469
+ if __name__ == '__main__':
470
+ sys.exit(main())
@@ -14,24 +14,14 @@ from pathlib import Path
14
14
  # 公共工具
15
15
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
16
  from _utils import (
17
+ get_api_key,
18
+ TOPICS, DEFAULT_TOPIC,
17
19
  call_ai, parse_frontmatter, write_with_frontmatter,
18
20
  DEFAULT_USER_PROFILE, generate_action_suggestion, load_config
19
21
  )
20
22
 
21
23
  OUTPUT_ROOT = 'output/biz-daily'
22
- TOPICS = ['AI', '学术', '新闻', '文学', '投资', '政治']
23
-
24
- AD_PATTERNS = [
25
- re.compile(r'在小说阅读器读本章\s*'),
26
- re.compile(r'在小说阅读器中沉浸阅读\s*'),
27
- re.compile(r'去阅读\s*'),
28
- re.compile(r'Scan to Follow\s*'),
29
- re.compile(r'轻触阅读原文\s*'),
30
- re.compile(r'预览时标签不可点\s*'),
31
- re.compile(r'继续滑动看下一个\s*'),
32
- re.compile(r'\[.*?\]\(javascript:void\(0\);\)'),
33
- re.compile(r'\n{4,}'),
34
- ]
24
+
35
25
 
36
26
  INTEREST_PROMPT = """对以下AI领域文章生成深度解析:
37
27
 
@@ -138,8 +128,10 @@ def generate_batch_action_suggestions(articles_data: list[dict], api_key: str, p
138
128
 
139
129
 
140
130
  def clean_ads(text: str) -> str:
141
- for pat in AD_PATTERNS:
142
- text = pat.sub('', text)
131
+ # 那几样界面残留(阅读器文字、下划线长串、原创标记)用共用的一份:
132
+ # `_utils.strip_wx_ads`。这里只留日报专属的几条。
133
+ from _utils import strip_wx_ads
134
+ text = strip_wx_ads(text)
143
135
  text = re.sub(r'\n来源:[^\n]+\n编辑:[^\n]+\n校对:[^\n]+\n校审:[^\n]+', '', text)
144
136
  text = re.sub(r'\n>/ [^\n]+', '', text)
145
137
  text = re.sub(r'\n{4,}', '\n\n\n', text)
@@ -154,7 +146,7 @@ def extract_topic_from_file(content: str) -> str:
154
146
  # Fallback: legacy text-based parsing
155
147
  m = re.search(r'> 主题:(\S+)', content)
156
148
  if m: return m.group(1)
157
- return '学术'
149
+ return DEFAULT_TOPIC
158
150
 
159
151
 
160
152
  def extract_tags_from_file(content: str) -> list[str]:
@@ -180,7 +172,7 @@ def main():
180
172
  api_key = args.api_key or ''
181
173
  # 本地引擎不需要 api_key
182
174
  if engine in ('deepseek', 'claude') and not api_key:
183
- api_key = os.environ.get('DEEPSEEK_API_KEY', '') or config.get('deepseekApiKey', '')
175
+ api_key = os.environ.get('DEEPSEEK_API_KEY', '') or get_api_key(config)
184
176
  if not api_key:
185
177
  print(f'[ERROR] --engine {engine} 需要 API key。请通过 --api-key、环境变量或配置文件提供')
186
178
  sys.exit(1)
@@ -221,7 +213,7 @@ def main():
221
213
  if topic not in TOPICS:
222
214
  for t in TOPICS:
223
215
  if t in topic: topic = t; break
224
- else: topic = '学术'
216
+ else: topic = DEFAULT_TOPIC
225
217
  topic_map[fpath] = topic
226
218
 
227
219
  dist = Counter(topic_map.values())