weflow-cli 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1949 -1
  3. package/OPERATIONS.md +774 -1
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +934 -27
  7. package/dist/bin/weflow-cli.js +952 -27
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +19 -2
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +11 -1
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +1 -1
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +5 -2
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/pythonBridge.d.ts +41 -0
  88. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  89. package/dist/src/services/pythonBridge.js +119 -0
  90. package/dist/src/services/pythonBridge.js.map +1 -0
  91. package/dist/src/services/syncService.d.ts +8 -2
  92. package/dist/src/services/syncService.d.ts.map +1 -1
  93. package/dist/src/services/syncService.js +26 -7
  94. package/dist/src/services/syncService.js.map +1 -1
  95. package/dist/src/services/wechatMessageService.d.ts +25 -0
  96. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  97. package/dist/src/services/wechatMessageService.js +69 -7
  98. package/dist/src/services/wechatMessageService.js.map +1 -1
  99. package/dist/src/utils/atomicWrite.d.ts +4 -0
  100. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  101. package/dist/src/utils/atomicWrite.js +60 -0
  102. package/dist/src/utils/atomicWrite.js.map +1 -0
  103. package/dist/src/utils/dateRange.d.ts +15 -0
  104. package/dist/src/utils/dateRange.d.ts.map +1 -1
  105. package/dist/src/utils/dateRange.js +47 -0
  106. package/dist/src/utils/dateRange.js.map +1 -1
  107. package/dist/src/utils/text.d.ts +18 -0
  108. package/dist/src/utils/text.d.ts.map +1 -0
  109. package/dist/src/utils/text.js +20 -0
  110. package/dist/src/utils/text.js.map +1 -0
  111. package/docs/AI_INTERFACE.md +2 -0
  112. package/docs/DECISIONS.md +1424 -0
  113. package/docs/EXTENDING.md +187 -0
  114. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  115. package/docs/MCP.md +23 -4
  116. package/docs/PROJECT_STATE.md +299 -98
  117. package/docs/SETUP.md +22 -0
  118. package/docs/SYNC_CONTRACT.md +31 -3
  119. package/mcp-server/index.ts +681 -657
  120. package/package.json +84 -83
  121. package/resources/panel/ball-position.cjs +153 -0
  122. package/resources/panel/index.html +46 -0
  123. package/resources/panel/main.cjs +447 -0
  124. package/resources/panel/mascot-focus.png +0 -0
  125. package/resources/panel/mascot-happy.png +0 -0
  126. package/resources/panel/mascot-sorry.png +0 -0
  127. package/resources/panel/mascot-tired.png +0 -0
  128. package/resources/panel/mascot.png +0 -0
  129. package/resources/panel/package.json +7 -0
  130. package/resources/panel/panel.css +407 -0
  131. package/resources/panel/preload.cjs +72 -0
  132. package/resources/panel/quick-menu.cjs +197 -0
  133. package/resources/panel/renderer.js +441 -0
  134. package/resources/panel/tray-menu.cjs +40 -0
  135. package/resources/panel/tray.png +0 -0
  136. package/scripts/_utils.py +403 -54
  137. package/scripts/annual_report.py +4 -2
  138. package/scripts/article_notes.py +423 -0
  139. package/scripts/backfill_articles.py +438 -0
  140. package/scripts/biz_daily.py +590 -105
  141. package/scripts/chat_notes.py +470 -0
  142. package/scripts/classify_daily.py +10 -18
  143. package/scripts/compile_wiki.py +1059 -46
  144. package/scripts/create_reading_notes.py +199 -14
  145. package/scripts/decide.py +253 -0
  146. package/scripts/draft_reply.py +671 -0
  147. package/scripts/export_chat_html.py +35 -33
  148. package/scripts/extract_todos.py +31 -5
  149. package/scripts/fav_notes.py +318 -0
  150. package/scripts/fix_topics.py +2 -1
  151. package/scripts/generate_ai_report.py +214 -9
  152. package/scripts/generate_html.py +18 -3
  153. package/scripts/generate_review.py +2 -2
  154. package/scripts/jev_client.py +294 -0
  155. package/scripts/jev_probe.py +440 -0
  156. package/scripts/nt_common.py +82 -0
  157. package/scripts/nt_decrypt.py +1755 -1460
  158. package/scripts/pipeline.py +27 -2
  159. package/scripts/promote_ideas.py +19 -4
  160. package/scripts/quality_eval.py +707 -0
  161. package/scripts/rag_chat.py +4 -4
  162. package/scripts/read_image.py +187 -0
  163. package/scripts/reply_debt.py +488 -0
  164. package/scripts/route_cards.py +453 -0
  165. package/scripts/semantic_search.py +208 -49
  166. package/scripts/user_notes.py +315 -0
  167. package/scripts/vault_rag.py +13 -7
  168. package/scripts/vault_search.py +21 -4
  169. package/scripts/wiki_lint.py +397 -0
  170. package/src/core/appMsgFormat.ts +117 -0
  171. package/src/core/ntCore.ts +27 -2
  172. package/src/core/sqlcipherCore.ts +5 -61
  173. package/src/core/wcdbCore.ts +6 -2
  174. package/src/panel/client.ts +49 -0
  175. package/src/panel/endpoint.ts +144 -0
  176. package/src/panel/launch.ts +101 -0
  177. package/src/panel/server.ts +424 -0
  178. package/src/panel/userId.ts +72 -0
  179. package/src/services/assistantDaemon.ts +57 -9
  180. package/src/services/assistantEval.ts +611 -0
  181. package/src/services/assistantMemory.ts +413 -57
  182. package/src/services/assistantPrivacy.ts +18 -4
  183. package/src/services/assistantRouter.ts +191 -0
  184. package/src/services/assistantService.ts +671 -74
  185. package/src/services/assistantTools.ts +1118 -57
  186. package/src/services/assistantTrace.ts +175 -0
  187. package/src/services/chatService.ts +6 -2
  188. package/src/services/configService.ts +403 -385
  189. package/src/services/pythonBridge.ts +139 -0
  190. package/src/services/syncService.ts +37 -9
  191. package/src/services/wechatMessageService.ts +73 -7
  192. package/src/utils/atomicWrite.ts +56 -0
  193. package/src/utils/dateRange.ts +46 -0
  194. package/src/utils/text.ts +20 -0
@@ -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)
@@ -9,7 +9,7 @@ import sys, os, json, re, time, subprocess
9
9
  from pathlib import Path
10
10
 
11
11
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
- from _utils import create_engine, parse_frontmatter
12
+ from _utils import create_engine, parse_frontmatter, DEFAULT_TOPIC
13
13
 
14
14
  DAILY_PROMPT = """你是知识管理助手,基于今日文章内容生成学习日报。
15
15
 
@@ -67,7 +67,7 @@ def scan_today_articles(target_date: str, source='output/biz-daily') -> list[dic
67
67
  articles.append({
68
68
  'title': fm.get('title', md_file.stem),
69
69
  'source': fm.get('source', ''),
70
- 'topic': fm.get('topic', '学术'),
70
+ 'topic': fm.get('topic') or DEFAULT_TOPIC,
71
71
  'tags': fm.get('tags', []),
72
72
  'summary': summary,
73
73
  })
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env python3
2
+ """Jev(TypeSafe System One)决策客户端 —— **只做判断,不生成文本**。
3
+
4
+ 日报现在把"主题 + 相关度"塞在同一个散文 prompt 里让 LLM 顺手答,再用两条正则抠回来。
5
+ 实测(60 篇真实中文文章)结果是:`relevance` 全库 2199/2201 都是默认值「中」,
6
+ `tags` 全库都是 `[主题名]`,`topic` 靠关键词兜底猜。这个模块把**判断**换成类型化的
7
+ 决策调用:输入 state,返回带概率的 choice/score/noul,没有自由文本需要解析。
8
+
9
+ 契约不是转述的,来自服务自己的 OpenAPI(`GET https://api.typesafe.ai/openapi.json`)
10
+ 加上实测:
11
+
12
+ POST https://api.typesafe.ai/v1/systemone Authorization: Bearer <key>
13
+ request {model, state, questions}
14
+ response {model, answers, usage}
15
+
16
+ choice: criteria 是**对象** {选项名: 何时选它},答案 {type, choice, confidence, probabilities}
17
+ score : criteria 是**有序数组**,位置即分值(零索引),答案多一个 score + legend
18
+ noul : 不需要 criteria,答案 {type, noul} —— **没有 confidence**
19
+
20
+ 失败取向:**本模块一律抛 `JevError`**(fail-loud),由调用方决定退到哪条老路
21
+ (fail-soft)。分类被问不出来,不该让整天的日报崩掉;但客户端自己不许把失败
22
+ 伪装成一个看起来正常的答案。
23
+ """
24
+ import json
25
+ import os
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+
30
+ ENDPOINT = 'https://api.typesafe.ai/v1/systemone'
31
+ MODELS_ENDPOINT = 'https://api.typesafe.ai/v1/models'
32
+ DEFAULT_MODEL = 'jev-latest'
33
+ # 瞬时故障:官方参考实现(AI SDK 的 evaluate)默认对它们重试 2 次,这里同样。
34
+ # 实测遇到过 529 `system_overloaded`——服务 2026-09-15 才发布,被打满是常态。
35
+ RETRY_STATUS = frozenset({429, 500, 502, 503, 504, 529})
36
+ INPUT_USD_PER_TOKEN = 0.042 / 1_000_000 # 输出 token 目前不计费
37
+
38
+ # 分类法(列表 + 每类的判据)只有一份,在 `_utils` 里。这里直接引用它,
39
+ # 所以提示词那条路和决策模型这条路**判的是同一套定义**——回退时不会换标准。
40
+ from _utils import TOPIC_CRITERIA, RELEVANCE_NAMES # noqa: E402 (scripts/ 在 sys.path 上)
41
+
42
+ # **顺序即语义**:score.criteria 零索引,第一个元素是 0 分。
43
+ # 两件事必须分开:判据文本是**给模型看**的(照抄 TOPIC_PROMPT 里对高/中/低的定义,
44
+ # 少了这段说明模型就没有判档依据),而落盘的值必须是**裸的三个字**——下游
45
+ # generate_ai_report.py:123、enrich_backlinks.py:75,87、generate_html.py:817-820
46
+ # 都在做字面量比较。名字表在 `_utils`(词表与 TOPICS 同处),下面这份判据文本
47
+ # 按下标与它一一对应(有测试钉住),所以两者都不能重排。
48
+ RELEVANCE_LEVELS = [
49
+ '低:信息性阅读(纯新闻/娱乐/文学)',
50
+ '中:有启发性(思路/趋势/跨领域技术)',
51
+ '高:可直接用于科研(新工具/新方法/数据源/代码库)',
52
+ ]
53
+
54
+
55
+ class JevError(RuntimeError):
56
+ """调用或契约出问题。消息面向人,**不含 API key**。"""
57
+
58
+
59
+ def _check_choice(key, question, answer):
60
+ """对 `choice` 答案做硬校验:不合法就抛 `JevError`,**不放行**。
61
+
62
+ 四条断言(概率的键集 == criteria 的键集、取值都在 [0,1]、和 ≈ 1、`choice`
63
+ 就是 argmax)来自 browser-use × TypeSafe 官方示范仓库的 `validate_choice`;
64
+ 我按它挑的这四条原样实现,并把**实测**过的真实响应作为基准(`jev-1.13.0`:
65
+ `{"看电影":0.0,"散步":1.0,"睡觉":0.0}` + `choice="散步"`,四条全过)。
66
+
67
+ 为什么值得在客户端做:契约漂移的失效方式不是报错,而是**选错了但看起来正常**。
68
+ `argmax` 那条尤其关键——我们此前踩过一个同型的坑:把 `noul`(概率浮点)当布尔
69
+ 读,每个问题都读成"否",而输出还把责任推给了模型。
70
+ """
71
+ if question.get('type') != 'choice':
72
+ # noul / score 没有"criteria ↔ probabilities"这层对应关系,不该被这套检查误伤。
73
+ # `decide` 已经按类型过滤,这里再挡一次是为了让这个函数单独调用也安全。
74
+ return
75
+ criteria = question.get('criteria') or {}
76
+ probs = answer.get('probabilities')
77
+ if not isinstance(probs, dict):
78
+ raise JevError('%s:choice 答案里没有 probabilities:%s'
79
+ % (key, json.dumps(answer, ensure_ascii=False)[:200]))
80
+ if criteria:
81
+ missing = sorted(set(criteria) - set(probs))
82
+ extra = sorted(set(probs) - set(criteria))
83
+ if missing or extra:
84
+ raise JevError('%s:probabilities 的键与 criteria 不一致(缺 %s,多 %s)'
85
+ % (key, missing, extra))
86
+ out_of_range = {k: v for k, v in probs.items()
87
+ if not isinstance(v, (int, float)) or isinstance(v, bool)
88
+ or not 0.0 <= float(v) <= 1.0}
89
+ if out_of_range:
90
+ raise JevError('%s:概率不在 [0,1]:%s' % (key, out_of_range))
91
+ total = sum(float(v) for v in probs.values())
92
+ if probs and abs(total - 1.0) > 0.02:
93
+ raise JevError('%s:概率和是 %.3f,不是 1(差得太多说明契约变了)' % (key, total))
94
+ picked = answer.get('choice')
95
+ if criteria and picked not in criteria:
96
+ raise JevError('%s:choice=%r 不在 criteria 里' % (key, picked))
97
+ if probs:
98
+ top = max(probs, key=lambda k: probs[k])
99
+ if picked != top:
100
+ raise JevError('%s:choice=%r 不是概率的 argmax(%r 才是)——'
101
+ '这种情况最危险,它看起来是个正常答案'
102
+ % (key, picked, top))
103
+
104
+
105
+ def resolve_key(explicit='', config=None):
106
+ """key 的取值顺序:显式传入 → `TYPESAFE_API_KEY` → 配置里(自动解密)。
107
+
108
+ 不落盘、不打印。配置那条走 `_utils.decrypt_lock`,所以 `config set` 写的
109
+ 机器绑定密文能直接用。
110
+ """
111
+ key = (explicit or os.environ.get('TYPESAFE_API_KEY') or '').strip()
112
+ if key:
113
+ return key
114
+ try:
115
+ import _utils
116
+ return (getattr(_utils, 'get_typesafe_key')(config) or '').strip()
117
+ except Exception:
118
+ # 配置读不到、密文解不开(比如 config.json 是从别的机器拷来的)——
119
+ # 回空串让调用方走老路,而不是把日报打挂。
120
+ return ''
121
+
122
+
123
+ def score_to_relevance(score):
124
+ """把 `score` 期望值映射回「低/中/高」。
125
+
126
+ criteria 是零索引的有序量表,所以 `score` 落在 [0, len-1] 区间里。
127
+ 用 `int(score + 0.5)` 取最近档而不是 `round()`:Python 的 round 是银行家舍入,
128
+ `round(0.5)` 得 0、`round(1.5)` 得 2,边界会跳档。
129
+
130
+ 阈值是**暂定**的(没有金标准可校准),所以原始分要一起落盘(`relevanceScore`),
131
+ 将来重新校准不用重跑整天的日报。
132
+ """
133
+ if score is None:
134
+ raise JevError('score 答案缺少 score 字段')
135
+ index = int(float(score) + 0.5)
136
+ index = max(0, min(len(RELEVANCE_NAMES) - 1, index))
137
+ return RELEVANCE_NAMES[index]
138
+
139
+
140
+ def build_questions(topics, criteria=None):
141
+ """与 `TOPIC_PROMPT` 等价的类型化版本:一个 choice + 一个 score + 一个 noul。
142
+
143
+ `worth_including` 是**回答一个以前没人问过的问题**。日报原来拿 `relevance != '高'`
144
+ 当收录门,而那是在用"对读者的实用价值"回答"该不该进今天的日报"——两件事不一样,
145
+ 阈值怎么调都调不准,因为问题本身错了。多问一个问题的边际成本近零(实测 2 个问题
146
+ 0.84s,12 个问题 0.91s,`state` 才是开销大头),所以这个判断基本是白送的。
147
+
148
+ 这里刻意不放"探测型"问题(比如"是不是科研论文"):问完没人读,就是
149
+ `COVER_STATE` 那种"算了就扔",不如不加。
150
+ """
151
+ table = criteria or TOPIC_CRITERIA
152
+ # 判据必须覆盖传入的每个主题:choice 的 criteria 就是选项集合本身,
153
+ # 少一个选项,模型就永远选不到它。
154
+ choice_criteria = {topic: table.get(topic, topic) for topic in topics}
155
+ return {
156
+ 'topic': {'type': 'choice',
157
+ 'instructions': '这篇文章属于哪个主题?',
158
+ 'criteria': choice_criteria},
159
+ 'relevance': {'type': 'score',
160
+ 'instructions': '对「环境科学研究生,做计算机与环境交叉」的实用价值有多高?',
161
+ 'criteria': list(RELEVANCE_LEVELS)},
162
+ 'worth_including': {
163
+ 'type': 'noul',
164
+ 'instructions': '这篇文章含有读者今天就能用上的具体内容'
165
+ '(新工具/新方法/数据源/代码库/可复现的结论),'
166
+ '而不是仅有信息性的新闻、观点或生活随笔?'},
167
+ }
168
+
169
+
170
+ class JevClient:
171
+ """一次请求问多个问题,共享同一份 state。"""
172
+
173
+ def __init__(self, api_key, model=DEFAULT_MODEL, timeout=120):
174
+ if not api_key:
175
+ raise JevError('缺少 TypeSafe API key')
176
+ self.api_key = api_key
177
+ self.model = model
178
+ self.timeout = timeout
179
+ # 最近一次响应里**实际服务的模型**(如 `jev-1.13.0`),不是请求的别名。
180
+ self.last_model = None
181
+
182
+ def _post(self, url, payload=None, attempts=3, backoff=0.8):
183
+ """带重试的请求。
184
+
185
+ 退避故意很短(最坏约 2.4s):调用方是**逐篇**分类日报的,服务整体过载时
186
+ 长退避只会把一整天的日报拖成几十分钟,而那时调用方本来就该退回老路。
187
+ """
188
+ last_error = None
189
+ for attempt in range(attempts):
190
+ try:
191
+ return self._post_once(url, payload)
192
+ except JevError as error:
193
+ if attempt == attempts - 1 or not getattr(error, 'retryable', False):
194
+ raise
195
+ last_error = error
196
+ time.sleep(backoff * (2 ** attempt))
197
+ raise last_error # pragma: no cover - 循环内已覆盖所有出口
198
+
199
+ def _post_once(self, url, payload=None):
200
+ data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode('utf-8')
201
+ request = urllib.request.Request(
202
+ url, data=data, method='POST' if data else 'GET',
203
+ headers={'Content-Type': 'application/json',
204
+ 'Authorization': f'Bearer {self.api_key}'})
205
+ try:
206
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
207
+ return json.loads(response.read())
208
+ except urllib.error.HTTPError as error:
209
+ detail = ''
210
+ try:
211
+ # 截断:别把整个响应体(可能很长)带进日志。
212
+ detail = error.read().decode('utf-8', 'replace')[:400]
213
+ except Exception:
214
+ pass
215
+ if error.code in (401, 403):
216
+ raise JevError(f'鉴权被拒(HTTP {error.code})。key 可能不对或已过期:{detail}')
217
+ if error.code == 422:
218
+ # 422 说明我理解的契约和服务端不一致,是最值得看的一种失败。
219
+ raise JevError(f'请求被拒(HTTP 422),契约与预期不符:{detail}')
220
+ failure = JevError(f'HTTP {error.code}: {detail}')
221
+ failure.retryable = error.code in RETRY_STATUS
222
+ raise failure
223
+ except urllib.error.URLError as error:
224
+ failure = JevError(f'连不上 {url}: {error.reason}')
225
+ failure.retryable = True # 连接层失败通常是瞬时的
226
+ raise failure
227
+
228
+ def models(self):
229
+ """可用别名。用它可以确认 `--model` 没写错。"""
230
+ return self._post(MODELS_ENDPOINT).get('models', [])
231
+
232
+ def decide(self, state, questions):
233
+ """返回 (answers, usage)。state 可以是字符串、JSON 对象或数组。
234
+
235
+ `usage` 里除了 token 数还带 `model`——那是**实际服务的模型**(实测
236
+ `jev-1.13.0`),不是你请求的别名(`jev-latest`)。落盘它,是为了让
237
+ "线上跑的和我测过的不是同一个东西"这件事**可见**,而不是等到结果不对
238
+ 才去猜。
239
+
240
+ **每个 choice 答案都过一遍 `_check_choice`**:键集、取值域、概率和、
241
+ argmax 全对不上就抛 `JevError`。契约漂移应当当场炸——漂移的失效方式是
242
+ "看起来正常但选错了",那正是最难发现的。
243
+ """
244
+ body = self._post(ENDPOINT, {'model': self.model,
245
+ 'state': state, 'questions': questions})
246
+ answers = body.get('answers')
247
+ if not isinstance(answers, dict):
248
+ raise JevError(f'响应里没有 answers:{json.dumps(body, ensure_ascii=False)[:300]}')
249
+ for key, question in (questions or {}).items():
250
+ if isinstance(question, dict) and question.get('type') == 'choice':
251
+ _check_choice(key, question, answers.get(key) or {})
252
+ usage = dict(body.get('usage') or {})
253
+ if body.get('model'):
254
+ usage['model'] = body['model']
255
+ self.last_model = body['model']
256
+ return answers, usage
257
+
258
+ def decide_article(self, title, body, topics, criteria=None, max_chars=4000):
259
+ """给一篇文章定主题与相关度。**问不出来就抛 `JevError`。**
260
+
261
+ state 只放生产环境真会给模型的东西(标题 + 正文),不放日期、来源、
262
+ 也不放 frontmatter 里的 topic/relevance —— 那些是答案的味道。
263
+ """
264
+ state = f'标题:{title}\n\n正文:\n{(body or "")[:max_chars]}'
265
+ answers, usage = self.decide(state, build_questions(topics, criteria))
266
+
267
+ topic_answer = answers.get('topic') or {}
268
+ relevance_answer = answers.get('relevance') or {}
269
+ picked = topic_answer.get('choice')
270
+ if picked not in topics:
271
+ # criteria 就是 topics,正常不该发生。真发生了说明契约变了,
272
+ # 与其把一个陌生主题写进 frontmatter 污染下游,不如报错让调用方回退。
273
+ raise JevError(f'返回的主题不在候选里:{picked!r}')
274
+
275
+ score = relevance_answer.get('score')
276
+ return {
277
+ 'topic': picked,
278
+ 'topicConfidence': topic_answer.get('confidence'),
279
+ 'topicProbabilities': topic_answer.get('probabilities'),
280
+ 'relevance': score_to_relevance(score),
281
+ 'relevanceScore': score,
282
+ # 「该不该收录」的原始概率。落盘而不是在这儿切成布尔:切点是暂定的,
283
+ # 留着原始值,改阈值不用重跑。
284
+ 'includeScore': (answers.get('worth_including') or {}).get('noul'),
285
+ 'usage': usage,
286
+ }
287
+
288
+
289
+ def create_client(api_key='', model=DEFAULT_MODEL, timeout=120, config=None):
290
+ """拿到 key 就返回客户端,没有就返回 None(调用方据此走老路)。"""
291
+ key = resolve_key(api_key, config)
292
+ if not key:
293
+ return None
294
+ return JevClient(key, model=model, timeout=timeout)