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
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 收藏知识卡 — 把微信收藏里的文章变成知识卡,喂给 wiki compile。
4
+
5
+ 用法:
6
+ python scripts/fav_notes.py --dry-run --json # 看代价(只读本地;有哪些要联网抓也报出来)
7
+ python scripts/fav_notes.py --limit 10 --yes # 先试水 10 条
8
+ python scripts/compile_wiki.py --source output/fav-notes
9
+
10
+ ## 为什么收藏值得单独一条来源
11
+
12
+ 日报那条线吃的是"你今天读了什么",而**收藏是你明确留下的**——两者不是一回事:
13
+ 收藏里常有日报窗口之外的文章,而且"我主动存过"本身就是兴趣的最强信号。
14
+ (另外这条线自带一个好处:收藏的文章多半日报已经抓过,`fetch_article_cached` 会命中缓存,
15
+ 所以联网这一步常常是零成本。)
16
+
17
+ ## 与另外两条线的关系
18
+
19
+ 同一形状、同一消费者(`wiki_compile` 的 `--source`):文章线 `article_notes.py`、
20
+ 对话线 `chat_notes.py`、收藏线就是本脚本。共用的件在 `_utils.py`。
21
+
22
+ **一处不同,必须写清**:文章的卡里"摘要"是**照抄**原笔记的,而收藏记录里**没有摘要**
23
+ (只有标题/来源/链接)——所以这条线的摘要**是模型写的**。卡片里会标出来
24
+ (frontmatter 的 `summary_by: model`),免得它看起来像抄来的原文。
25
+ """
26
+ import argparse
27
+ import json
28
+ import os
29
+ import sys
30
+ from datetime import datetime, timezone, timedelta
31
+ from pathlib import Path
32
+
33
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
+ from _utils import (SEPARATOR, call_deepseek, decrypt_lock, get_api_key, load_config, # noqa: E402
35
+ note_path, plain, strip_wx_ads, write_with_frontmatter)
36
+
37
+ TZ = timezone(timedelta(hours=8))
38
+ OUTPUT_ROOT = 'output/fav-notes'
39
+ DEFAULT_LIMIT = 10
40
+ MAX_ARTICLE_CHARS = 3000
41
+ MIN_ARTICLE_CHARS = 200
42
+ LINK_CHARS = 6 * 1024 * 1024 # 单篇正文上限(缓存来的 markdown 一般远小于它)
43
+
44
+ FAV_PROMPT = """你是知识库的**编译者,不是作者**。下面是一篇用户**收藏**的公众号文章。
45
+
46
+ 请只依据它,返回一个 JSON 对象(不要解释、不要代码块围栏):
47
+ {{"summary": "这篇文章在说什么(2-3 句,只写文章里有的)",
48
+ "concepts": [{{"name": "概念名(2-12 字,不要带书名号引号方括号)", "desc": "这篇文章关于它说了什么(一句话,具体)"}}]}}
49
+
50
+ 要求:
51
+ 1. **只写文章里有的**。没讲到的不许补;拿不准的宁可不写。
52
+ 2. `concepts` 2-6 条、按重要度排序;**概念要具体**("MCP 协议"可以,"技术"不行),
53
+ 也不是分类词("AI"、"新闻"这种主题标签不算概念)。
54
+ 3. 每条 desc 是"这篇文章关于它的说法",不是百科定义。
55
+ 4. 全部中文,不要 markdown 语法(不要 #、*、-)。
56
+
57
+ 文章:{title}(来源:{source})
58
+
59
+ 正文:
60
+ {body}
61
+ """
62
+
63
+
64
+ def read_favorites(limit: int):
65
+ """读收藏列表(本地、只读)。返回 (条目列表, 错误原因)。"""
66
+ import nt_decrypt
67
+ config = load_config()
68
+ fav_db = config.get('favDbPath', '')
69
+ if not fav_db:
70
+ return [], '配置里没有 favDbPath(收藏库还没连上)'
71
+ got = nt_decrypt.get_favorites(fav_db, decrypt_lock(config.get('favKey', '')), limit=limit)
72
+ if isinstance(got, dict) and got.get('error'):
73
+ return [], str(got['error'])
74
+ items = got.get('favorites') if isinstance(got, dict) else got
75
+ return list(items or []), None
76
+
77
+
78
+ def local_text(item: dict) -> str:
79
+ """收藏**自带**的正文(笔记/文本类有,文章类没有)。**不碰网络。**
80
+
81
+ 与 `favorite_text` 分开,是为了让 `--dry-run` 能说清"哪几条要联网抓"而**不去抓**:
82
+ 预览是只读的(它的说明里写着"只读本地"),自己去打网络就名不副实了——我第一版
83
+ 就是这么写的,跑 dry-run 时看见它在抓文章才发现。
84
+ """
85
+ for key in ('desc', 'content', 'summary'):
86
+ text = strip_wx_ads(str(item.get(key) or '')).strip()
87
+ if len(text) >= MIN_ARTICLE_CHARS:
88
+ return text
89
+ return ''
90
+
91
+
92
+ def classify_source(item: dict) -> str:
93
+ """这条收藏的正文从哪来(**只看本地信息**):local / fetch / none。"""
94
+ if local_text(item):
95
+ return 'local'
96
+ return 'fetch' if str(item.get('link') or '').strip() else 'none'
97
+
98
+
99
+ def favorite_text(item: dict, fetch=None):
100
+ """一条收藏的正文:本地文本优先,没有(或太短)就抓链接。返回 (正文, 来源说明)。
101
+
102
+ 收藏分两种:笔记/文本类**自带内容**(`desc`/`content`),文章类只有 `link`。
103
+ 抓取复用日报那条线的 `fetch_article_cached`——不重写第二份(那套微信 UA 绕 WAF、
104
+ gzip、重试是踩出来的),而且**带缓存**:日报抓过的文章这里零网络。
105
+
106
+ `fetch` 是**可注入的口子**:默认走 `biz_daily.fetch_article_cached`,测试传桩进来——
107
+ 否则单元测试会真去联网(我第一版就是这么写的,跑测试时才发现)。
108
+ """
109
+ for key in ('desc', 'content', 'summary'):
110
+ text = strip_wx_ads(str(item.get(key) or '')).strip()
111
+ if len(text) >= MIN_ARTICLE_CHARS:
112
+ return text, 'local'
113
+ link = str(item.get('link') or '').strip()
114
+ if not link:
115
+ return '', 'no-text'
116
+ if fetch is None:
117
+ try:
118
+ import biz_daily
119
+ fetch = biz_daily.fetch_article_cached
120
+ except Exception as error:
121
+ return '', 'fetch-failed: %s' % type(error).__name__
122
+ try:
123
+ got = fetch(link)
124
+ except Exception as error:
125
+ return '', 'fetch-failed: %s' % type(error).__name__
126
+ if not got:
127
+ return '', 'fetch-failed'
128
+ text, cached = got
129
+ if not text:
130
+ return '', 'fetch-failed'
131
+ return strip_wx_ads(str(text))[:LINK_CHARS], ('cache' if cached else 'network')
132
+
133
+
134
+ def parse_note(raw: str):
135
+ """解出 `{summary, concepts}`。解不出来返回 None(**不猜**)。"""
136
+ if not isinstance(raw, str) or not raw.strip():
137
+ return None
138
+ text = raw.strip()
139
+ start, end = text.find('{'), text.rfind('}')
140
+ if start < 0 or end <= start:
141
+ return None
142
+ try:
143
+ data = json.loads(text[start:end + 1])
144
+ except Exception:
145
+ return None
146
+ if not isinstance(data, dict) or not isinstance(data.get('concepts'), list):
147
+ return None
148
+ summary = plain(data.get('summary')).strip()
149
+ if not summary:
150
+ return None
151
+ concepts = []
152
+ for item in data['concepts']:
153
+ if not isinstance(item, dict):
154
+ continue
155
+ name = plain(item.get('name')).strip().strip('[]')
156
+ desc = plain(item.get('desc')).strip()
157
+ if name and desc:
158
+ concepts.append({'name': name, 'desc': desc})
159
+ if len(concepts) >= 6:
160
+ break
161
+ return {'summary': summary, 'concepts': concepts}
162
+
163
+
164
+ def build_card(item: dict, note: dict, body_len: int, saved: str, updated: str):
165
+ """卡片 → (frontmatter, body)。摘要这次**是模型写的**,所以标出来。"""
166
+ title = str(item.get('title') or '无标题').strip()
167
+ source = str(item.get('source_name') or '').strip()
168
+ frontmatter = {
169
+ 'title': title,
170
+ 'type': 'fav-card',
171
+ 'source': source or '收藏',
172
+ 'topic': '收藏',
173
+ 'tags': ['收藏'],
174
+ # **收藏时间**不是发布时间——别把它叫 published,那是另一回事
175
+ 'saved': saved,
176
+ 'url': str(item.get('link') or ''),
177
+ 'summary_by': 'model', # 这条线的摘要不是抄的,标出来
178
+ 'bodyChars': body_len,
179
+ 'updated': updated,
180
+ }
181
+ parts = ['# %s\n' % title, '## AI 摘要\n', note['summary'] + '\n']
182
+ if note['concepts']:
183
+ parts.append('## 概念\n')
184
+ for concept in note['concepts']:
185
+ parts.append('- [[%s]] %s %s' % (concept['name'], SEPARATOR, concept['desc']))
186
+ parts.append('')
187
+ return frontmatter, '\n'.join(parts)
188
+
189
+
190
+ def already_carded(item: dict, out_dir: str) -> bool:
191
+ """这条收藏已经有卡了吗(按标题落成的文件名)。"""
192
+ title = str(item.get('title') or '').strip()
193
+ return bool(title) and note_path(out_dir, title).exists()
194
+
195
+
196
+ def exit_code(written: int) -> int:
197
+ """部分成功算成功——与另外两条线同一个约定。"""
198
+ return 0 if written else 1
199
+
200
+
201
+ def main():
202
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
203
+ parser = argparse.ArgumentParser(description='收藏知识卡(把收藏变成喂给 wiki compile 的卡)')
204
+ parser.add_argument('--limit', type=int, default=DEFAULT_LIMIT,
205
+ help='最多处理几条收藏(默认 %d)' % DEFAULT_LIMIT)
206
+ parser.add_argument('--out', default=OUTPUT_ROOT, help='卡片输出目录')
207
+ parser.add_argument('--refresh', action='store_true', help='已有卡的也重做')
208
+ parser.add_argument('--dry-run', action='store_true', help='只报要发多少给模型,不调用')
209
+ parser.add_argument('--yes', action='store_true', help='确认调用云端模型')
210
+ parser.add_argument('--json', action='store_true')
211
+ args = parser.parse_args()
212
+
213
+ if args.limit < 1:
214
+ print(json.dumps({'success': False, 'error': '--limit 要 ≥ 1'}))
215
+ return 1
216
+
217
+ favorites, error = read_favorites(args.limit)
218
+ if error:
219
+ message = '读收藏失败:%s' % error
220
+ print(json.dumps({'success': False, 'error': message}, ensure_ascii=False)
221
+ if args.json else message)
222
+ return 1
223
+
224
+ runnable, thin, existing = [], [], []
225
+ for item in favorites:
226
+ if args.refresh or not already_carded(item, args.out):
227
+ runnable.append(item)
228
+ else:
229
+ existing.append(item)
230
+
231
+ if args.dry_run:
232
+ # **只看本地信息**:预览是只读的,不替用户去打网络(抓取留给真跑那一步)
233
+ counts = {'local': 0, 'fetch': 0, 'none': 0}
234
+ for item in runnable:
235
+ counts[classify_source(item)] += 1
236
+ preview = {
237
+ 'success': True, 'dryRun': True, 'action': 'fav-notes',
238
+ 'favorites': len(favorites), 'calls': len(runnable),
239
+ 'alreadyCarded': len(existing), 'noText': counts['none'],
240
+ 'textSource': {'local': counts['local'], 'needsFetch': counts['fetch']},
241
+ 'model': 'DeepSeek(生成)', 'readsLocalData': True, 'fetchArticleUrls': False,
242
+ 'invokesAI': False,
243
+ 'note': '每条一次调用;正文优先用收藏自带的文本,文章类走日报那条线的抓取(带缓存)'
244
+ '——抓取发生在真跑时,预览不联网',
245
+ }
246
+ if args.json:
247
+ print(json.dumps(preview, ensure_ascii=False, indent=2))
248
+ else:
249
+ print('收藏 %d 条,会调用模型 %d 次(已有卡 %d 条跳过)'
250
+ % (len(favorites), len(runnable), len(existing)))
251
+ print('正文:自带 %d 条、要联网抓 %d 条、取不到 %d 条(预览不联网,抓取在 --yes 之后)'
252
+ % (counts['local'], counts['fetch'], counts['none']))
253
+ return 0
254
+
255
+ if not runnable:
256
+ message = '没有要处理的收藏(%d 条都已有卡)' % len(favorites)
257
+ print(json.dumps({'success': False, 'error': message}, ensure_ascii=False)
258
+ if args.json else message, file=sys.stdout if args.json else sys.stderr)
259
+ return 1
260
+
261
+ if not args.yes:
262
+ print('会把 %d 条收藏的正文发给 DeepSeek(每条一次调用)。加 --yes 确认。' % len(runnable),
263
+ file=sys.stderr)
264
+ return 1
265
+
266
+ config = load_config()
267
+ api_key = get_api_key(config)
268
+ if not api_key:
269
+ print('没有配置 deepseekApiKey(weflow-cli config set deepseekApiKey "...")', file=sys.stderr)
270
+ return 1
271
+
272
+ updated = datetime.now(TZ).strftime('%Y-%m-%d %H:%M')
273
+ written, failed = [], []
274
+ for item in runnable:
275
+ text, how = favorite_text(item)
276
+ title = str(item.get('title') or '无标题').strip()
277
+ if not text:
278
+ failed.append({'title': title[:30], 'reason': '取不到正文(%s)' % how})
279
+ continue
280
+ saved = ''
281
+ if item.get('update_time'):
282
+ saved = datetime.fromtimestamp(int(item['update_time']), tz=TZ).strftime('%Y-%m-%d')
283
+ prompt = FAV_PROMPT.format(title=title, source=item.get('source_name') or '未知',
284
+ body=text[:MAX_ARTICLE_CHARS])
285
+ try:
286
+ raw = call_deepseek(prompt, api_key, max_tokens=2000, timeout=120)
287
+ except Exception as error:
288
+ failed.append({'title': title[:30], 'reason': '调用失败:%s' % error})
289
+ continue
290
+ note = parse_note(raw)
291
+ if note is None:
292
+ failed.append({'title': title[:30], 'reason': '模型没返回可用 JSON',
293
+ 'rawTail': str(raw)[-200:] if raw else '(空)'})
294
+ continue
295
+ target = note_path(args.out, title)
296
+ target.parent.mkdir(parents=True, exist_ok=True)
297
+ frontmatter, body = build_card(item, note, len(text), saved, updated)
298
+ write_with_frontmatter(str(target), frontmatter, body)
299
+ written.append({'title': title[:30], 'file': str(target),
300
+ 'concepts': len(note['concepts']), 'textFrom': how})
301
+
302
+ result = {'success': True, 'action': 'fav-notes', 'model': 'DeepSeek(生成)',
303
+ 'sendsNothing': True, 'written': written, 'failed': failed,
304
+ 'alreadyCarded': len(existing), 'favorites': len(favorites), 'calls': len(runnable)}
305
+ if args.json:
306
+ print(json.dumps(result, ensure_ascii=False, indent=2))
307
+ else:
308
+ for item in written:
309
+ print('✓ %s(%d 个概念,正文来自 %s)' % (item['title'], item['concepts'], item['textFrom']))
310
+ for item in failed:
311
+ print('✗ %s:%s' % (item['title'], item['reason']), file=sys.stderr)
312
+ print('\n共 %d 张卡;接着跑 python scripts/compile_wiki.py --source %s'
313
+ % (len(written), args.out))
314
+ return exit_code(len(written))
315
+
316
+
317
+ if __name__ == '__main__':
318
+ sys.exit(main())
@@ -5,8 +5,9 @@ from pathlib import Path
5
5
 
6
6
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
7
7
  from biz_daily import _guess_topic
8
+ from _utils import TOPICS
9
+
8
10
 
9
- TOPICS = ['AI', '学术', '新闻', '文学', '投资', '政治']
10
11
 
11
12
  def fix_date(date_str: str):
12
13
  sys.stdout.reconfigure(encoding='utf-8', errors='replace')
@@ -23,7 +23,8 @@ from pathlib import Path
23
23
  from collections import Counter
24
24
 
25
25
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
- from _utils import create_engine, parse_frontmatter
26
+ from _utils import (create_engine, parse_frontmatter, TOPICS, TOPIC_CRITERIA,
27
+ load_config, excluded_topics)
27
28
 
28
29
  SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
29
30
  ROOT_DIR = os.path.dirname(SCRIPTS_DIR)
@@ -31,7 +32,7 @@ SOURCE_ROOT = os.path.join(ROOT_DIR, 'output', 'biz-daily')
31
32
  OUTPUT_DIR = os.path.join(ROOT_DIR, 'output', 'ai-reports')
32
33
 
33
34
  TZ = timezone(timedelta(hours=8))
34
- TOPICS = ['AI', '学术', '新闻', '文学', '投资', '政治']
35
+
35
36
  FOCUS_TOPIC = 'AI'
36
37
 
37
38
 
@@ -89,7 +90,168 @@ REPORT_PROMPT = """你是一位为忙碌研究生写作的「深度阅读策展
89
90
  # DATA LOADING
90
91
  # ======================================================================
91
92
 
92
- def load_articles_from_date(date_str: str) -> list[dict]:
93
+ # 「该不该进今天的日报」的切点。**暂定且未经校准**:Jev 问的是
94
+ # `worth_including`(含可直接用上的具体内容吗),返回 0~1 的概率,
95
+ # 原始值存在 frontmatter 的 `includeScore` 里,改这个数不用重跑日报。
96
+ INCLUDE_THRESHOLD = 0.5
97
+
98
+
99
+ # 「拿不太准」的区间。收录分落在中间,意味着这次收还是不收都说得过去——
100
+ # 报告不该把这种条目当成确定的事报出去。
101
+ UNCERTAIN_BAND = (0.35, 0.65)
102
+ TOPIC_CONFIDENT = 0.7
103
+ UNCERTAIN_LIST_LIMIT = 8
104
+
105
+
106
+ def _num(value):
107
+ """把 includeScore 之类的概率读成 float;读不出返回 None。
108
+
109
+ 要认字符串:`.articles.json` 里是数字,md 兜底路径读出来是 YAML 标量文本。
110
+ """
111
+ if value is None:
112
+ return None
113
+ try:
114
+ return float(value)
115
+ except (TypeError, ValueError):
116
+ return None
117
+
118
+
119
+ def _score(article):
120
+ """读 includeScore。`.articles.json` 里是数字,md 兜底路径读出来是字符串。"""
121
+ value = article.get('includeScore')
122
+ if value is None:
123
+ return None
124
+ try:
125
+ return float(value)
126
+ except (TypeError, ValueError):
127
+ return None
128
+
129
+
130
+ def build_uncertainty_section(included, everything, include_all=False, exclude=()) -> str:
131
+ """报告末尾那一段「我拿不准的」。**本地算,不经 LLM** —— 让模型去描述自己的不确定,
132
+ 等于又要它生成一段可能被编圆的散文。
133
+
134
+ 两种不确定分开说,因为处理方式不同:
135
+ * 收录卡在阈值上 —— 收与不收都说得过去,你看到的是这次掷硬币的结果;
136
+ * 主题置信度低 —— 它可能被分到了错误的那一栏,位置都可能是错的。
137
+
138
+ **没有概率字段时不能沉默。** 早期生成的文章没有 includeScore,那一段的缺席会被
139
+ 读成"这份报告哪里都很确定",而事实是"它没告诉你"。所以那种情况要明说。
140
+ """
141
+ scored = [a for a in everything if _score(a) is not None]
142
+ if not scored:
143
+ return (chr(10) + '---' + chr(10) + chr(10) + '## 我拿不准的' + chr(10) + chr(10)
144
+ + '本期文章没有概率字段(生成早于该字段引入),所以**这份报告无法告诉你'
145
+ '它哪里不确定**——不能把上面的结论读成"都已经过确认"。' + chr(10))
146
+
147
+ low, high = UNCERTAIN_BAND
148
+ in_ids = {id(a) for a in included}
149
+
150
+ def band(article):
151
+ value = _score(article)
152
+ return value is not None and low <= value <= high
153
+
154
+ # 这一段三份清单都不提被排除的主题:否则"不想看新闻"的同一份报告,
155
+ # 会在这一栏把新闻的标题和分数原样列出来。
156
+ borderline_in = [a for a in included
157
+ if band(a) and not is_excluded_topic(a, exclude)]
158
+ # 注意:这一栏装的是**没收**的文章,被排除的天然满足"没收"——
159
+ # 把 exclude 交给 admits 反而会让它们更容易被列进来(我第一版就是这么错的)。
160
+ # 这里要的是"因为分数而没收",所以按排除集滤掉。
161
+ borderline_out = [] if include_all else [a for a in everything
162
+ if not admits(a, include_all) and band(a)
163
+ and not is_excluded_topic(a, exclude)]
164
+ low_confidence = [a for a in included
165
+ if isinstance(a.get('topicConfidence'), (int, float))
166
+ and a['topicConfidence'] < TOPIC_CONFIDENT
167
+ and not is_excluded_topic(a, exclude)]
168
+
169
+ lines = [chr(10) + '---' + chr(10), '## 我拿不准的' + chr(10),
170
+ '这两处不是漏掉,是**判断本身不确定**。列出来,免得被当成定论。' + chr(10)]
171
+
172
+ total_borderline = len(borderline_in) + len(borderline_out)
173
+ if total_borderline:
174
+ if include_all:
175
+ # 收录判据没启用,"收与不收"这个区分根本不存在——照抄阈值两侧的措辞
176
+ # 会让人以为这里做过一次筛选。
177
+ lines.append('**收录分落在边界上的 %d 篇**(本次未启用收录判据,全部收录)'
178
+ '——这些分数本来就说不准:%s' % (total_borderline, chr(10)))
179
+ else:
180
+ lines.append('**收录卡在阈值上的 %d 篇**(收了 %d 篇、没收 %d 篇)——'
181
+ '收与不收都说得过去:%s'
182
+ % (total_borderline, len(borderline_in),
183
+ len(borderline_out), chr(10)))
184
+ listed = borderline_in + borderline_out
185
+ for article in listed[:UNCERTAIN_LIST_LIMIT]:
186
+ kept = '' if include_all else (' · 收' if id(article) in in_ids else ' · 没收')
187
+ lines.append('- %s(收录分 %.2f%s · 主题 %s)'
188
+ % (article.get('title', '(无标题)'), _score(article), kept,
189
+ article.get('topic', '?')))
190
+ if total_borderline > UNCERTAIN_LIST_LIMIT:
191
+ lines.append('- 另有 %d 篇同样卡在边界,未列。' % (total_borderline - UNCERTAIN_LIST_LIMIT))
192
+ lines.append('')
193
+
194
+ if low_confidence:
195
+ lines.append('**主题可能归错的 %d 篇**(主题置信度低于 %.1f,分栏位置可能不对):%s'
196
+ % (len(low_confidence), TOPIC_CONFIDENT, chr(10)))
197
+ for article in low_confidence[:UNCERTAIN_LIST_LIMIT]:
198
+ lines.append('- %s(标为 %s · 置信 %.2f)'
199
+ % (article.get('title', '(无标题)'), article.get('topic', '?'),
200
+ article['topicConfidence']))
201
+ if len(low_confidence) > UNCERTAIN_LIST_LIMIT:
202
+ lines.append('- 另有 %d 篇同样置信偏低,未列。' % (len(low_confidence) - UNCERTAIN_LIST_LIMIT))
203
+ lines.append('')
204
+
205
+ if not total_borderline and not low_confidence:
206
+ lines.append('本期没有落在边界上的条目——收录分与主题置信度都不在含糊区间。' + chr(10))
207
+
208
+ lines.append('> 这些数字来自决策模型,**没有金标准校准过**;概率在阈值附近还会抖'
209
+ '(同一批数据两次跑会有出入),别细究 0.48 与 0.52 的区别。')
210
+ lines.append('')
211
+ return chr(10).join(lines)
212
+
213
+
214
+ def is_excluded_topic(article: dict, exclude=()) -> bool:
215
+ """用户是不是明说了不要这个主题。
216
+
217
+ 单独抽出来是因为它有**两个方向相反**的用处:
218
+ `admits` 靠它判"不要",而「我拿不准的」那一栏靠它判"别列出来"——
219
+ 那一栏装的恰好是**没收**的文章,被排除的天然满足"没收"。
220
+ 两处各写一遍早晚会写反,事实上第一版就写反了。
221
+ """
222
+ return (article.get('topic') or '') in (exclude or ())
223
+
224
+
225
+ def admits(article: dict, include_all: bool = False, exclude=()) -> bool:
226
+ """这篇文章该不该收录。
227
+
228
+ 焦点主题永远收。其余看 `includeScore`——那是**专门为这个问题**问出来的概率,
229
+ 比拿 `relevance != '高'` 顶替准:相关度量的是"对读者的实用价值",回答不了
230
+ "今天该不该收它",阈值怎么调都是在调一个问错了的题。
231
+
232
+ 没有 `includeScore` 的是这个字段出现之前的产物,退回旧规则,行为不变。
233
+
234
+ 这里要认字符串:`.articles.json` 里的值是数字,但 md 兜底路径读出来的是
235
+ YAML 标量文本(`'0.8'`)。只认 `int/float` 的话,那条路径上这个新字段
236
+ 就会被静默忽略——测出来正是如此。
237
+ """
238
+ # 排除是**正交**的一维,所以放在 include_all 前面:用户明说不要的主题,
239
+ # 连 --include-all 都不该让它冒出来。
240
+ if is_excluded_topic(article, exclude):
241
+ return False
242
+ if include_all or article.get('topic') == FOCUS_TOPIC:
243
+ return True
244
+ score = article.get('includeScore')
245
+ if score is not None:
246
+ try:
247
+ return float(score) >= INCLUDE_THRESHOLD
248
+ except (TypeError, ValueError):
249
+ # 值不是数字(乱改过的产物)。退回旧规则,别让报告生成在这里炸掉。
250
+ pass
251
+ return article.get('relevance') == '高'
252
+
253
+ def load_articles_from_date(date_str: str, include_all: bool = False,
254
+ exclude=()) -> list[dict]:
93
255
  json_path = Path(SOURCE_ROOT) / date_str / '.articles.json'
94
256
  if json_path.exists():
95
257
  try:
@@ -98,7 +260,26 @@ def load_articles_from_date(date_str: str) -> list[dict]:
98
260
  arts = data.get('articles', data) if isinstance(data, dict) else data
99
261
  for a in arts:
100
262
  a.setdefault('date', date_str)
101
- return arts
263
+ # 这里原先直接 return,不筛。于是「非焦点主题只收相关度高的文章」
264
+ # 这条规则**只在下面的 MD 兜底路径里存在**,而 .articles.json 才是
265
+ # 正常走的那条路 —— 规则因此从未真正生效过。
266
+ kept = [a for a in arts if admits(a, include_all, exclude)]
267
+ if len(kept) != len(arts):
268
+ # 说清**实际**用的是哪条规则:有收录分的按概率筛,早于该字段的产物
269
+ # 退回旧规则。无脑写"阈值 includeScore >= x",在后一种情况下是撒谎。
270
+ parsed = [_num(a.get('includeScore')) for a in arts]
271
+ scored = sum(1 for v in parsed if v is not None)
272
+ if scored == 0:
273
+ print(f' 收录筛选: {len(arts)} 篇 -> {len(kept)} 篇'
274
+ f'(这些产物没有收录分,退回旧的 relevance == 高 规则)')
275
+ elif scored < len(arts):
276
+ print(f' 收录筛选: {len(arts)} 篇 -> {len(kept)} 篇'
277
+ f'({scored} 篇按 includeScore >= {INCLUDE_THRESHOLD},'
278
+ f'其余 {len(arts) - scored} 篇按旧规则)')
279
+ else:
280
+ print(f' 收录筛选: {len(arts)} 篇 -> {len(kept)} 篇'
281
+ f'(阈值 includeScore >= {INCLUDE_THRESHOLD})')
282
+ return kept
102
283
  except Exception:
103
284
  pass
104
285
  # 回退:扫描 MD 文件
@@ -119,8 +300,7 @@ def load_articles_from_date(date_str: str) -> list[dict]:
119
300
  if not fm:
120
301
  continue
121
302
  relevance = fm.get('relevance', '中')
122
- # 非 AI 主题只收录相关度"高"的文章
123
- if topic != FOCUS_TOPIC and relevance != '高':
303
+ if not admits({**fm, 'topic': topic}, include_all, exclude):
124
304
  continue
125
305
  # AI 主题读取完整正文(最多 5000 字),其他主题读取摘要
126
306
  if topic == FOCUS_TOPIC:
@@ -148,11 +328,13 @@ def load_articles_from_date(date_str: str) -> list[dict]:
148
328
  return articles
149
329
 
150
330
 
151
- def load_articles_range(from_date: date, to_date: date) -> list[dict]:
331
+ def load_articles_range(from_date: date, to_date: date, include_all: bool = False,
332
+ exclude=()) -> list[dict]:
152
333
  all_articles = []
153
334
  cur = from_date
154
335
  while cur <= to_date:
155
- all_articles.extend(load_articles_from_date(cur.strftime('%Y-%m-%d')))
336
+ all_articles.extend(load_articles_from_date(cur.strftime('%Y-%m-%d'), include_all,
337
+ exclude))
156
338
  cur += timedelta(days=1)
157
339
  return all_articles
158
340
 
@@ -290,6 +472,11 @@ def main():
290
472
  parser.add_argument('--to', dest='to_date', help='结束日期 YYYY-MM-DD,默认今天')
291
473
  parser.add_argument('--output', help='输出文件路径(默认 output/ai-reports/)')
292
474
  parser.add_argument('--dry-run', action='store_true', help='不调用 AI,仅输出统计骨架')
475
+ parser.add_argument('--exclude-topics', default='',
476
+ help='不在报告里出现的主题,逗号分隔(如 新闻,投资);'
477
+ '展示层开关:不重抓、不改产物,只影响这份报告收谁')
478
+ parser.add_argument('--include-all', action='store_true',
479
+ help='不筛选,收录当天全部文章(回退到引入收录判据之前的行为)')
293
480
  args = parser.parse_args()
294
481
 
295
482
  # 日期解析
@@ -314,7 +501,11 @@ def main():
314
501
  print(f'🔧 引擎: {args.engine}' + (' (dry-run)' if args.dry_run else ''))
315
502
 
316
503
  # 加载文章
317
- articles = load_articles_range(from_date, to_date)
504
+ exclude = excluded_topics(load_config(), args.exclude_topics,
505
+ protected=(FOCUS_TOPIC,))
506
+ if exclude:
507
+ print('🚫 已排除主题: ' + '/'.join(t for t in TOPICS if t in exclude))
508
+ articles = load_articles_range(from_date, to_date, args.include_all, exclude)
318
509
  if not articles:
319
510
  print(f'\n❌ 未找到 {date_label} 的文章。请先运行:')
320
511
  print(f' python scripts/biz_daily.py')
@@ -353,6 +544,20 @@ def main():
353
544
  # 生成报告
354
545
  t0 = time.time()
355
546
  report = generate_report(articles, engine, date_label, dry_run=args.dry_run)
547
+ # 不确定那一段在本地算:它必须与"模型有没有写、写得漂不漂亮"无关。
548
+ # 另外单独加载一次全集——被收录判据筛掉的边界条目也要能看见,
549
+ # 否则"我拿不准"只报告了收进来的那一半。
550
+ try:
551
+ everything = load_articles_range(from_date, to_date, include_all=True,
552
+ exclude=exclude)
553
+ except Exception:
554
+ everything = articles
555
+ section = build_uncertainty_section(articles, everything, include_all=args.include_all,
556
+ exclude=exclude)
557
+ report += section
558
+ uncertain_count = section.count(chr(10) + '- ')
559
+ if uncertain_count:
560
+ print(f' 不确定条目: {uncertain_count} 条已列入报告末尾')
356
561
  elapsed = time.time() - t0
357
562
 
358
563
  with open(out_path, 'w', encoding='utf-8') as f:
@@ -14,6 +14,9 @@ SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
14
14
  PROJECT_ROOT = os.path.dirname(SCRIPTS_DIR)
15
15
  SOURCE_ROOT = os.path.join(PROJECT_ROOT, 'output', 'biz-daily')
16
16
 
17
+ sys.path.insert(0, SCRIPTS_DIR)
18
+ from _utils import load_config, excluded_topics # noqa: E402
19
+
17
20
 
18
21
  def _inline_marked_js(html_path: str):
19
22
  """Replace CDN marked.js with local inline copy to eliminate network dependency."""
@@ -84,10 +87,16 @@ def get_body_preview(text: str, max_chars=200) -> str:
84
87
  return preview
85
88
 
86
89
 
87
- def collect_articles(date_dir: str) -> dict:
88
- """收集所有文章,按主题分组。"""
90
+ def collect_articles(date_dir: str, exclude=()) -> dict:
91
+ """收集所有文章,按主题分组。
92
+
93
+ `exclude` 里的主题**整个目录都不扫**(不是扫完再滤):读者页和 AI 报告共用同一份
94
+ 排除集,两边才不会一个说排掉了、另一个还在列。展示层开关,md 产物不动。
95
+ """
89
96
  topics = {}
90
97
  for topic in TOPIC_ORDER:
98
+ if topic in exclude:
99
+ continue
91
100
  topic_dir = Path(date_dir) / topic
92
101
  if not topic_dir.is_dir():
93
102
  continue
@@ -1597,6 +1606,8 @@ def main():
1597
1606
  parser = argparse.ArgumentParser(description='生成公众号日报 HTML 页面')
1598
1607
  parser.add_argument('--date', help='日期 YYYY-MM-DD, 默认今天')
1599
1608
  parser.add_argument('--output', help='输出路径(默认在日报目录下的 index.html)')
1609
+ parser.add_argument('--exclude-topics', default='',
1610
+ help='不展示的主题,逗号分隔(如 新闻,投资);只影响这一页,不改产物')
1600
1611
  args = parser.parse_args()
1601
1612
 
1602
1613
  if args.date:
@@ -1612,7 +1623,11 @@ def main():
1612
1623
  print(f'[ERROR] 目录不存在: {date_dir}')
1613
1624
  sys.exit(1)
1614
1625
 
1615
- topics = collect_articles(date_dir)
1626
+ exclude = excluded_topics(load_config(), args.exclude_topics,
1627
+ protected=(TOPIC_ORDER[0],)) # 第一项=这份日报的主体
1628
+ if exclude:
1629
+ print('🚫 已排除主题: ' + '/'.join(t for t in TOPIC_ORDER if t in exclude))
1630
+ topics = collect_articles(date_dir, exclude)
1616
1631
  if not topics:
1617
1632
  print(f'[ERROR] 未找到任何文章')
1618
1633
  sys.exit(1)