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
@@ -6,6 +6,9 @@
6
6
  # 构建索引(首次或增量)
7
7
  python scripts/semantic_search.py build
8
8
 
9
+ # 窗口可调(默认聊天 90 天 / 日报 30 天;要"整段历史"就给大值)
10
+ python scripts/semantic_search.py build --days 3650 --article-days 365
11
+
9
12
  # 搜索(有索引用向量,否则关键词 fallback)
10
13
  python scripts/semantic_search.py search "有人推荐过遥感的工具吗" --top-k 10
11
14
 
@@ -15,21 +18,39 @@
15
18
  输出: JSON 格式
16
19
  """
17
20
 
18
- import sys, os, json, hashlib, argparse
21
+ import sys, os, json, hashlib, re, argparse
19
22
  from datetime import datetime, timezone, timedelta
20
23
  from pathlib import Path
21
24
 
22
- try:
23
- import numpy as np
24
- except ImportError:
25
- print(json.dumps({"error": "请安装: pip install numpy"}))
26
- sys.exit(1)
25
+ # 重依赖一律懒加载(照 nt_decrypt.require_sqlcipher 的做法):
26
+ # 关键词检索与重排不需要它们,缺了也不该让整份模块 import 就 sys.exit——
27
+ # 那样连不碰向量库的代码路径都用不了,CI 里也没法测任何东西。
28
+ np = None
29
+ sqlcipher = None
27
30
 
28
- try:
29
- from sqlcipher3 import dbapi2 as sqlcipher
30
- except ImportError:
31
- print(json.dumps({"error": "请安装: pip install sqlcipher3"}))
32
- sys.exit(1)
31
+
32
+ def require_numpy():
33
+ global np
34
+ if np is not None:
35
+ return np
36
+ try:
37
+ import numpy as _np
38
+ except ImportError:
39
+ raise RuntimeError('需要 numpy: pip install numpy')
40
+ np = _np
41
+ return np
42
+
43
+
44
+ def require_sqlcipher():
45
+ global sqlcipher
46
+ if sqlcipher is not None:
47
+ return sqlcipher
48
+ try:
49
+ from sqlcipher3 import dbapi2 as _sqlcipher
50
+ except ImportError:
51
+ raise RuntimeError('需要 sqlcipher3: pip install sqlcipher3')
52
+ sqlcipher = _sqlcipher
53
+ return sqlcipher
33
54
 
34
55
  try:
35
56
  import urllib.request
@@ -38,7 +59,8 @@ except:
38
59
  pass
39
60
 
40
61
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
- from _utils import load_config, decrypt_lock
62
+ from _utils import load_config, decrypt_lock, get_dashscope_key
63
+ from jev_client import create_client
42
64
 
43
65
  OUTPUT_ROOT = 'output'
44
66
  INDEX_DIR = Path(OUTPUT_ROOT) / '.semantic_index'
@@ -99,6 +121,7 @@ def get_embeddings(texts: list[str], api_key: str) -> list[list[float]]:
99
121
  # ====== Data Collection ======
100
122
 
101
123
  def open_db(db_path, key_hex, salt_hex):
124
+ sqlcipher = require_sqlcipher()
102
125
  raw_key = f"x'{key_hex}{salt_hex}'"
103
126
  conn = sqlcipher.connect(db_path)
104
127
  c = conn.cursor()
@@ -171,20 +194,27 @@ def collect_chat_messages(conn, name_map, days=90):
171
194
  return items
172
195
 
173
196
 
174
- def collect_articles():
175
- """Collect articles from biz-daily."""
197
+ def collect_articles(article_days=30):
198
+ """Collect articles from biz-daily.
199
+
200
+ `article_days` = 取最近多少个**日期目录**(原来写死 30)。可调是必要的:
201
+ 这个窗口决定了"知识库里有多少文章",而它原先只写在常量里,用户看不到也改不了。
202
+ """
176
203
  items = []
177
204
  daily_dir = Path(OUTPUT_ROOT) / 'biz-daily'
178
205
  if not daily_dir.exists():
179
206
  return items
180
207
 
181
- for date_dir in sorted(daily_dir.iterdir(), reverse=True)[:30]: # Last 30 days
208
+ for date_dir in sorted(daily_dir.iterdir(), reverse=True)[:article_days]:
182
209
  if not date_dir.is_dir():
183
210
  continue
184
211
  for topic_dir in date_dir.iterdir():
185
212
  if not topic_dir.is_dir():
186
213
  continue
187
- for f in topic_dir.glob(' marriage*.md'):
214
+ # 这里原先是 topic_dir.glob(' marriage*.md')——前导空格加 marriage 前缀,
215
+ # 在真实的「公众号-标题.md」命名下一个都匹配不到。后果是 collect_articles()
216
+ # 永远返回空表,**语义索引里从来没有任何文章**,只有聊天记录,而且不报错。
217
+ for f in topic_dir.glob('*.md'):
188
218
  if f.name == 'README.md':
189
219
  continue
190
220
  try:
@@ -217,9 +247,17 @@ def collect_articles():
217
247
 
218
248
  # ====== Index Operations ======
219
249
 
220
- def build_index(api_key: str, full: bool = False):
221
- """Build or update the semantic index."""
250
+ def build_index(api_key: str, full: bool = False, days: int = 90, article_days: int = 30):
251
+ """Build or update the semantic index.
252
+
253
+ `days` / `article_days` 决定这个索引**记多久**:它们原先写死在两处调用里
254
+ (聊天 90 天、日报 30 天),用户既看不见也改不了——而"个人知识库只有三个月记忆"
255
+ 正是那个写死的数字造成的。现在由 CLI 显式传进来(见 `search-index --days / --article-days`),
256
+ 默认值保持不变,**不悄悄改变代价**。
257
+ """
258
+ np = require_numpy()
222
259
  INDEX_DIR.mkdir(parents=True, exist_ok=True)
260
+ print(f"窗口: 聊天最近 {days} 天 / 日报最近 {article_days} 天", file=sys.stderr)
223
261
 
224
262
  # Load existing index
225
263
  existing_ids = set()
@@ -253,13 +291,13 @@ def build_index(api_key: str, full: bool = False):
253
291
  items = []
254
292
  try:
255
293
  conn = open_db(nt_db, nt_key, nt_salt)
256
- chat_items = collect_chat_messages(conn, name_map, days=90)
294
+ chat_items = collect_chat_messages(conn, name_map, days=days)
257
295
  items.extend(chat_items)
258
296
  conn.close()
259
297
  except Exception as e:
260
298
  print(f"[WARN] 聊天消息收集失败: {e}", file=sys.stderr)
261
299
 
262
- article_items = collect_articles()
300
+ article_items = collect_articles(article_days=article_days)
263
301
  items.extend(article_items)
264
302
 
265
303
  # Filter new items
@@ -267,7 +305,8 @@ def build_index(api_key: str, full: bool = False):
267
305
  print(f"总数据: {len(items)} 条, 新数据: {len(new_items)} 条", file=sys.stderr)
268
306
 
269
307
  if not new_items:
270
- return {"status": "up_to_date", "total": len(items)}
308
+ return {"status": "up_to_date", "total": len(items),
309
+ "chatDays": days, "articleDays": article_days}
271
310
 
272
311
  # Generate embeddings
273
312
  print("生成 embeddings...", file=sys.stderr)
@@ -275,7 +314,8 @@ def build_index(api_key: str, full: bool = False):
275
314
  embeddings = get_embeddings(texts, api_key)
276
315
 
277
316
  if not embeddings or all(e == [0.0] * EMBEDDING_DIM for e in embeddings):
278
- return {"error": "Embedding 生成失败,请检查 API key"}
317
+ return {"error": "Embedding 生成失败,请检查 API key",
318
+ "chatDays": days, "articleDays": article_days}
279
319
 
280
320
  # Load existing vectors
281
321
  if VECTORS_FILE.exists() and not full:
@@ -303,23 +343,38 @@ def build_index(api_key: str, full: bool = False):
303
343
  "status": "success",
304
344
  "total": len(meta),
305
345
  "new": len(new_items),
346
+ "chatDays": days,
347
+ "articleDays": article_days,
306
348
  }
307
349
 
308
350
 
309
- def keyword_search(query: str, top_k: int = 10):
310
- """Simple keyword fallback: scan articles + messages for keyword matches."""
351
+ def keyword_search(query: str, top_k: int = 10, root=None):
352
+ """Simple keyword fallback: scan articles + messages for keyword matches.
353
+
354
+ `root` 只为可测而参数化(默认就是日报目录)。它原先硬编码成相对路径,
355
+ 于是这条路径只能靠 chdir 才能测,也就一直没被测过——而它正是检索不可用时
356
+ 真正跑着的那条(本机索引从未构建过)。
357
+ """
311
358
  results = []
312
359
  keywords = query.lower().split()
313
360
 
314
361
  # Scan biz-daily articles
315
- biz_dir = Path('output/biz-daily')
362
+ biz_dir = Path(root or 'output/biz-daily')
316
363
  if biz_dir.exists():
317
364
  for md_file in sorted(biz_dir.rglob('*.md'), reverse=True):
318
- if md_file.name == 'README.md' or md_file.name.startswith('.'):
319
- continue
365
+ # **只收真文章。** 日报目录里还躺着 <天>/README.md、<天>/行动建议.md
366
+ # 这类**生成产物**,它们会被当成检索结果返回(实测搜到过
367
+ # 「行动建议 — 2026-08-30」)。而它们**两种深度都有**:既在日期目录下、
368
+ # 也在主题目录下(实测各 4 个),所以按路径判断是不够的——我第一版就是这么
369
+ # 写的,然后拿一个"看不见产物"的 top_k 去验证,误以为修好了。
370
+ #
371
+ # 判据用内容:真文章的 frontmatter 里一定有 url。
372
+ # `quality_eval.read_article` 用的是同一条。
320
373
  try:
321
374
  content = md_file.read_text(encoding='utf-8')[:5000]
322
- except:
375
+ except OSError:
376
+ continue
377
+ if not re.search(r'^url:\s*\S', content[:800], re.M):
323
378
  continue
324
379
  score = sum(content.lower().count(kw) for kw in keywords)
325
380
  if score > 0:
@@ -350,11 +405,99 @@ def keyword_search(query: str, top_k: int = 10):
350
405
  return results[:top_k]
351
406
 
352
407
 
353
- def search(query: str, api_key: str, top_k: int = 10):
408
+ # 送进重排的候选数。检索是粗筛("语义相近"),重排是细判("真的回答了问题吗"),
409
+ # 所以池子要比 top_k 大,才有可换的空间。
410
+ RERANK_CANDIDATES = 20
411
+ RERANK_EXCERPT_CHARS = 300
412
+
413
+
414
+ def build_rerank_questions(count):
415
+ """**一次请求问 N 个候选**——这是 Jev 最适合"重排"的地方。
416
+
417
+ 实测 2 个问题 0.84s、12 个问题 0.91s(`state` 才是开销大头),所以给 20 条候选
418
+ 各问一个是 0.9 秒量级,不是 20 次往返。问题编号与候选编号必须严格对应:
419
+ 错位了不会报错,只会把最相关的排到最后——所以每条候选在 state 里都带【候选k】标签。
420
+ """
421
+ return {
422
+ 'c%d' % i: {'type': 'noul',
423
+ 'instructions': '【候选%d】是否直接回答了上面的问题,'
424
+ '或含有回答它所需的关键信息?仅话题相近不算。' % i}
425
+ for i in range(count)
426
+ }
427
+
428
+
429
+ def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
430
+ """用**一次**决策请求给检索结果重排。
431
+
432
+ 没有客户端、候选太少、或调用失败 → 原样返回(也就是这个函数还不存在时的行为)。
433
+ 这是刻意的:重排是加分项,不该让检索本身失败。
434
+
435
+ `score` 保持原义(余弦相似度或关键词命中数),新的分数放在 `rerankScore` 里,
436
+ 与 `relevanceScore`/`includeScore` 同一套只增字段的做法——下游可能在展示 score。
437
+ """
438
+ if client is None or len(results) < 2:
439
+ return results
440
+
441
+ pool = results[:pool_size]
442
+ lines = []
443
+ for i, item in enumerate(pool):
444
+ excerpt = (item.get('text') or '').strip().replace(chr(10), ' ')
445
+ lines.append('【候选%d】%s|%s' % (i, item.get('title') or item.get('source') or '',
446
+ excerpt[:RERANK_EXCERPT_CHARS]))
447
+ state = '问题:%s\n\n候选:\n%s' % (query, chr(10).join(lines))
448
+ questions = build_rerank_questions(len(pool))
449
+ # 同问两次的一致性检查:哪一条最直接地回答了问题。逐条打分与这个单选对不上,
450
+ # 通常意味着编号被模型搞混了——那正是重排最危险、也最不容易察觉的失效方式。
451
+ questions['best'] = {
452
+ 'type': 'choice',
453
+ 'instructions': '哪一条候选最直接地回答了上面的问题?',
454
+ 'criteria': {'候选%d' % i: (item.get('title') or None)
455
+ for i, item in enumerate(pool)},
456
+ }
457
+
458
+ try:
459
+ answers, _usage = client.decide(state, questions)
460
+ except Exception as error:
461
+ print(' [WARN] 重排失败(%s),保持原检索顺序:%s' % (type(error).__name__, error))
462
+ return results
463
+
464
+ scored = []
465
+ for i, item in enumerate(pool):
466
+ value = (answers.get('c%d' % i) or {}).get('noul')
467
+ try:
468
+ value = float(value)
469
+ except (TypeError, ValueError):
470
+ value = None
471
+ scored.append((value, i, item))
472
+
473
+ if all(value is None for value, _i, _item in scored):
474
+ print(' [WARN] 重排没有返回任何可用分数,保持原检索顺序')
475
+ return results
476
+
477
+ # 没拿到分数的按原序垫在最后,而不是当成 0 分——"未知"和"不相关"不是一回事。
478
+ scored.sort(key=lambda triple: (triple[0] is None, -(triple[0] or 0.0), triple[1]))
479
+ best = (answers.get('best') or {}).get('choice')
480
+ top = scored[0]
481
+ if best is not None and best != '候选%d' % top[1]:
482
+ print(' [WARN] 重排自检不一致:逐条打分最高的是候选%d,单选却答 %s'
483
+ % (top[1], best))
484
+
485
+ ordered = []
486
+ for value, i, item in scored:
487
+ entry = dict(item)
488
+ if value is not None:
489
+ entry['rerankScore'] = round(value, 3)
490
+ ordered.append(entry)
491
+ ordered.extend(results[pool_size:])
492
+ return ordered
493
+
494
+
495
+ def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = True):
354
496
  """Semantic search with keyword fallback."""
355
497
  # Try embedding-based search first
356
498
  if VECTORS_FILE.exists() and META_FILE.exists():
357
499
  try:
500
+ np = require_numpy()
358
501
  vectors = np.load(VECTORS_FILE)
359
502
  meta = json.loads(META_FILE.read_text(encoding='utf-8'))
360
503
  query_embeddings = get_embeddings([query], api_key)
@@ -362,34 +505,40 @@ def search(query: str, api_key: str, top_k: int = 10):
362
505
  query_vec = np.array(query_embeddings[0], dtype=np.float32)
363
506
  query_vec = query_vec / np.linalg.norm(query_vec)
364
507
  similarities = np.dot(vectors, query_vec)
365
- top_indices = np.argsort(similarities)[::-1][:top_k]
508
+ # 先取一个比重排池更大的候选集,重排才有可以换的空间。
509
+ pool_size = max(top_k, RERANK_CANDIDATES) if rerank_results else top_k
510
+ top_indices = np.argsort(similarities)[::-1][:pool_size]
366
511
  results = []
367
512
  for idx in top_indices:
368
513
  item = meta[idx]
369
514
  results.append({**item, 'score': float(similarities[idx])})
370
- return results
515
+ if rerank_results:
516
+ results = rerank(query, results, create_client())
517
+ return results[:top_k]
371
518
  except:
372
519
  pass
373
520
 
374
521
  # Fallback: keyword search
375
- return keyword_search(query, top_k)
522
+ results = keyword_search(query, max(top_k, RERANK_CANDIDATES) if rerank_results else top_k)
523
+ if rerank_results:
524
+ # 关键词打分比余弦更粗,重排在这里的收益反而更大。
525
+ results = rerank(query, results, create_client())
526
+ return results[:top_k]
376
527
 
377
- results = []
378
- for idx in top_indices:
379
- item = meta[idx]
380
- results.append({
381
- **item,
382
- "score": float(similarities[idx]),
383
- })
384
528
 
385
- return {
386
- "query": query,
387
- "total": len(meta),
388
- "results": results,
389
- }
529
+ # ====== Main ======
390
530
 
531
+ def add_window_args(parser):
532
+ """索引窗口(两个子命令共用)。
533
+
534
+ 默认值与原先把死的数字**完全一致**(聊天 90 / 日报 30)——加参数不是改代价,
535
+ 只是把原来改不了的数字变成改得了的。要建"整段历史"的知识库就显式给一个大值。
536
+ """
537
+ parser.add_argument('--days', type=int, default=90,
538
+ help='聊天记录收最近多少天(默认 90)')
539
+ parser.add_argument('--article-days', type=int, default=30,
540
+ help='日报文章收最近多少个日期目录(默认 30)')
391
541
 
392
- # ====== Main ======
393
542
 
394
543
  def main():
395
544
  sys.stdout.reconfigure(encoding='utf-8', errors='replace')
@@ -400,31 +549,41 @@ def main():
400
549
  p = subparsers.add_parser('build')
401
550
  p.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
402
551
  p.add_argument('--full', action='store_true', help='全量重建')
552
+ add_window_args(p)
403
553
 
404
554
  # update
405
555
  p = subparsers.add_parser('update')
406
556
  p.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
557
+ add_window_args(p)
407
558
 
408
559
  # search
409
560
  p = subparsers.add_parser('search')
410
561
  p.add_argument('query', nargs='?')
411
562
  p.add_argument('--api-key', help='DeepSeek API key(向量搜索时需要,关键词 fallback 不需要)')
412
563
  p.add_argument('--top-k', type=int, default=10)
564
+ p.add_argument('--no-rerank', action='store_true',
565
+ help='不调用决策模型重排,只按向量/关键词相似度返回(回退到引入重排之前)')
413
566
 
414
567
  args = parser.parse_args()
568
+ # 手动跑时的第二道闸门(CLI 那条路已经按 1-36500 校验过):0 天等于建一个空库,
569
+ # 那结果看起来"建好了",实际什么都没有——这种"看起来成功"的失败要挡住
570
+ if getattr(args, 'days', 90) < 1 or getattr(args, 'article_days', 30) < 1:
571
+ json_output({'error': '--days 与 --article-days 都要 ≥ 1(0 天等于索引一个空库)'})
572
+ return
415
573
  config = load_config()
416
- api_key = args.api_key or os.environ.get('DASHSCOPE_API_KEY', '') or config.get('dashscopeApiKey', '')
574
+ api_key = args.api_key or os.environ.get('DASHSCOPE_API_KEY', '') or get_dashscope_key(config)
417
575
 
418
576
  if args.command == 'build':
419
- result = build_index(api_key, full=args.full)
577
+ result = build_index(api_key, full=args.full, days=args.days, article_days=args.article_days)
420
578
  elif args.command == 'update':
421
- result = build_index(api_key, full=False)
579
+ result = build_index(api_key, full=False, days=args.days, article_days=args.article_days)
422
580
  elif args.command == 'search':
423
581
  query = args.query or os.environ.get('WEFLOW_SEARCH_QUERY', '')
424
582
  if not query:
425
583
  result = {"error": "missing search query"}
426
584
  else:
427
- result = search(query, api_key, top_k=args.top_k)
585
+ result = search(query, api_key, top_k=args.top_k,
586
+ rerank_results=not args.no_rerank)
428
587
  else:
429
588
  result = {"error": f"未知命令: {args.command}"}
430
589