weflow-cli 1.5.0 → 1.6.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 (331) hide show
  1. package/ARCHITECTURE.md +105 -0
  2. package/CHANGELOG.md +110 -0
  3. package/CONTRIBUTING.md +45 -0
  4. package/LICENSE +21 -21
  5. package/OPERATIONS.md +272 -0
  6. package/README.en.md +281 -0
  7. package/README.md +333 -149
  8. package/SECURITY.md +47 -0
  9. package/bin/weflow-cli.ts +4032 -583
  10. package/dist/bin/weflow-cli.js +4296 -593
  11. package/dist/bin/weflow-cli.js.map +1 -1
  12. package/dist/mcp-server/index.js +178 -236
  13. package/dist/mcp-server/index.js.map +1 -1
  14. package/dist/src/core/dbPathService.d.ts +15 -1
  15. package/dist/src/core/dbPathService.d.ts.map +1 -1
  16. package/dist/src/core/dbPathService.js +177 -19
  17. package/dist/src/core/dbPathService.js.map +1 -1
  18. package/dist/src/core/keyService.d.ts +8 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -1
  20. package/dist/src/core/keyService.js +127 -22
  21. package/dist/src/core/keyService.js.map +1 -1
  22. package/dist/src/core/ntCore.d.ts +59 -1
  23. package/dist/src/core/ntCore.d.ts.map +1 -1
  24. package/dist/src/core/ntCore.js +188 -64
  25. package/dist/src/core/ntCore.js.map +1 -1
  26. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  27. package/dist/src/core/sqlcipherCore.js +18 -1
  28. package/dist/src/core/sqlcipherCore.js.map +1 -1
  29. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  30. package/dist/src/core/wcdbCore.js +3 -6
  31. package/dist/src/core/wcdbCore.js.map +1 -1
  32. package/dist/src/services/assistantDaemon.d.ts +19 -0
  33. package/dist/src/services/assistantDaemon.d.ts.map +1 -0
  34. package/dist/src/services/assistantDaemon.js +117 -0
  35. package/dist/src/services/assistantDaemon.js.map +1 -0
  36. package/dist/src/services/assistantMemory.d.ts +36 -0
  37. package/dist/src/services/assistantMemory.d.ts.map +1 -0
  38. package/dist/src/services/assistantMemory.js +182 -0
  39. package/dist/src/services/assistantMemory.js.map +1 -0
  40. package/dist/src/services/assistantPrivacy.d.ts +25 -0
  41. package/dist/src/services/assistantPrivacy.d.ts.map +1 -0
  42. package/dist/src/services/assistantPrivacy.js +77 -0
  43. package/dist/src/services/assistantPrivacy.js.map +1 -0
  44. package/dist/src/services/assistantRouting.d.ts +18 -0
  45. package/dist/src/services/assistantRouting.d.ts.map +1 -0
  46. package/dist/src/services/assistantRouting.js +68 -0
  47. package/dist/src/services/assistantRouting.js.map +1 -0
  48. package/dist/src/services/assistantService.d.ts +37 -0
  49. package/dist/src/services/assistantService.d.ts.map +1 -0
  50. package/dist/src/services/assistantService.js +284 -0
  51. package/dist/src/services/assistantService.js.map +1 -0
  52. package/dist/src/services/assistantTools.d.ts +24 -0
  53. package/dist/src/services/assistantTools.d.ts.map +1 -0
  54. package/dist/src/services/assistantTools.js +582 -0
  55. package/dist/src/services/assistantTools.js.map +1 -0
  56. package/dist/src/services/chatService.d.ts +44 -0
  57. package/dist/src/services/chatService.d.ts.map +1 -1
  58. package/dist/src/services/chatService.js +105 -4
  59. package/dist/src/services/chatService.js.map +1 -1
  60. package/dist/src/services/configService.d.ts +55 -0
  61. package/dist/src/services/configService.d.ts.map +1 -1
  62. package/dist/src/services/configService.js +147 -6
  63. package/dist/src/services/configService.js.map +1 -1
  64. package/dist/src/services/evidenceService.d.ts +30 -0
  65. package/dist/src/services/evidenceService.d.ts.map +1 -0
  66. package/dist/src/services/evidenceService.js +98 -0
  67. package/dist/src/services/evidenceService.js.map +1 -0
  68. package/dist/src/services/exportService.d.ts +13 -20
  69. package/dist/src/services/exportService.d.ts.map +1 -1
  70. package/dist/src/services/exportService.js +113 -47
  71. package/dist/src/services/exportService.js.map +1 -1
  72. package/dist/src/services/initKeyService.d.ts +19 -0
  73. package/dist/src/services/initKeyService.d.ts.map +1 -0
  74. package/dist/src/services/initKeyService.js +80 -0
  75. package/dist/src/services/initKeyService.js.map +1 -0
  76. package/dist/src/services/messageContract.d.ts +23 -0
  77. package/dist/src/services/messageContract.d.ts.map +1 -0
  78. package/dist/src/services/messageContract.js +49 -0
  79. package/dist/src/services/messageContract.js.map +1 -0
  80. package/dist/src/services/messageQuery.d.ts +7 -0
  81. package/dist/src/services/messageQuery.d.ts.map +1 -0
  82. package/dist/src/services/messageQuery.js +30 -0
  83. package/dist/src/services/messageQuery.js.map +1 -0
  84. package/dist/src/services/wechat-formatter.js +14 -14
  85. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  86. package/dist/src/services/wechatMessageService.js +31 -3
  87. package/dist/src/services/wechatMessageService.js.map +1 -1
  88. package/dist/src/services/whitelistService.d.ts +52 -4
  89. package/dist/src/services/whitelistService.d.ts.map +1 -1
  90. package/dist/src/services/whitelistService.js +183 -17
  91. package/dist/src/services/whitelistService.js.map +1 -1
  92. package/dist/src/types.d.ts +20 -0
  93. package/dist/src/types.d.ts.map +1 -1
  94. package/dist/src/utils/dateRange.d.ts +15 -0
  95. package/dist/src/utils/dateRange.d.ts.map +1 -0
  96. package/dist/src/utils/dateRange.js +48 -0
  97. package/dist/src/utils/dateRange.js.map +1 -0
  98. package/dist/src/utils/mcpSecurity.d.ts +5 -0
  99. package/dist/src/utils/mcpSecurity.d.ts.map +1 -0
  100. package/dist/src/utils/mcpSecurity.js +39 -0
  101. package/dist/src/utils/mcpSecurity.js.map +1 -0
  102. package/dist/src/utils/packageRoot.d.ts +2 -0
  103. package/dist/src/utils/packageRoot.d.ts.map +1 -0
  104. package/dist/src/utils/packageRoot.js +16 -0
  105. package/dist/src/utils/packageRoot.js.map +1 -0
  106. package/dist/src/utils/python.d.ts +2 -0
  107. package/dist/src/utils/python.d.ts.map +1 -0
  108. package/dist/src/utils/python.js +72 -0
  109. package/dist/src/utils/python.js.map +1 -0
  110. package/dist/src/utils/pythonProcessEnv.d.ts +3 -0
  111. package/dist/src/utils/pythonProcessEnv.d.ts.map +1 -0
  112. package/dist/src/utils/pythonProcessEnv.js +46 -0
  113. package/dist/src/utils/pythonProcessEnv.js.map +1 -0
  114. package/dist/src/utils/talkerUtils.d.ts +4 -1
  115. package/dist/src/utils/talkerUtils.d.ts.map +1 -1
  116. package/dist/src/utils/talkerUtils.js +9 -9
  117. package/dist/src/utils/talkerUtils.js.map +1 -1
  118. package/dist/src/utils/wechatEmoji.d.ts +5 -0
  119. package/dist/src/utils/wechatEmoji.d.ts.map +1 -0
  120. package/dist/src/utils/wechatEmoji.js +35 -0
  121. package/dist/src/utils/wechatEmoji.js.map +1 -0
  122. package/docs/AI_INTERFACE.md +189 -0
  123. package/docs/BRANCHES.md +87 -0
  124. package/docs/DATA_CONTRACT.md +74 -0
  125. package/docs/DECISIONS.md +270 -0
  126. package/docs/DEEPSEEK_V4_FLASH/346/226/275/345/267/245/346/226/207/346/241/243.md +234 -0
  127. package/docs/EVIDENCE_GUIDE.md +96 -0
  128. package/docs/MCP.md +95 -0
  129. package/docs/PARTNERS.md +41 -0
  130. package/docs/PROJECT_STATE.md +96 -0
  131. package/docs/ROADMAP.md +191 -0
  132. package/docs/SETUP.md +103 -0
  133. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  134. package/docs/images/weflow-architecture.png +0 -0
  135. package/docs/images/weflow-architecture.svg +65 -0
  136. package/mcp-server/index.ts +169 -269
  137. package/package.json +81 -72
  138. package/requirements-3x.txt +4 -0
  139. package/requirements-voice.txt +15 -0
  140. package/requirements.txt +9 -0
  141. package/resources/js/marked.min.js +69 -0
  142. package/resources/wechat-emoji/666.png +0 -0
  143. package/resources/wechat-emoji/Awesome.png +0 -0
  144. package/resources/wechat-emoji/Concerned.png +0 -0
  145. package/resources/wechat-emoji/Cry.png +0 -0
  146. package/resources/wechat-emoji/Emm.png +0 -0
  147. package/resources/wechat-emoji/Facepalm.png +0 -0
  148. package/resources/wechat-emoji/Grin.png +0 -0
  149. package/resources/wechat-emoji/OK.png +0 -0
  150. package/resources/wechat-emoji/Respect.png +0 -0
  151. package/resources/wechat-emoji/Sick.png +0 -0
  152. package/resources/wechat-emoji/Sleep.png +0 -0
  153. package/resources/wechat-emoji/Smile.png +0 -0
  154. package/resources/wechat-emoji//344/272/262/344/272/262.png +0 -0
  155. package/resources/wechat-emoji//344/276/277/344/276/277.png +0 -0
  156. package/resources/wechat-emoji//345/201/267/347/254/221.png +0 -0
  157. package/resources/wechat-emoji//345/202/262/346/205/242.png +0 -0
  158. package/resources/wechat-emoji//345/206/215/350/247/201.png +0 -0
  159. package/resources/wechat-emoji//345/207/213/350/260/242.png +0 -0
  160. package/resources/wechat-emoji//345/212/240/346/262/271.png +0 -0
  161. package/resources/wechat-emoji//345/213/276/345/274/225.png +0 -0
  162. package/resources/wechat-emoji//345/217/221/345/221/206.png +0 -0
  163. package/resources/wechat-emoji//345/217/221/346/200/222.png +0 -0
  164. package/resources/wechat-emoji//345/217/221/346/212/226.png +0 -0
  165. package/resources/wechat-emoji//345/217/257/346/200/234.png +0 -0
  166. package/resources/wechat-emoji//345/217/263/345/223/274/345/223/274.png +0 -0
  167. package/resources/wechat-emoji//345/217/271/346/260/224.png +0 -0
  168. package/resources/wechat-emoji//345/220/203/347/223/234.png +0 -0
  169. package/resources/wechat-emoji//345/220/210/345/215/201.png +0 -0
  170. package/resources/wechat-emoji//345/220/220.png +0 -0
  171. package/resources/wechat-emoji//345/221/262/347/211/231.png +0 -0
  172. package/resources/wechat-emoji//345/222/222/351/252/202.png +0 -0
  173. package/resources/wechat-emoji//345/222/226/345/225/241.png +0 -0
  174. package/resources/wechat-emoji//345/223/207.png +0 -0
  175. package/resources/wechat-emoji//345/225/244/351/205/222.png +0 -0
  176. package/resources/wechat-emoji//345/230/230.png +0 -0
  177. package/resources/wechat-emoji//345/230/264/345/224/207.png +0 -0
  178. package/resources/wechat-emoji//345/230/277/345/223/210.png +0 -0
  179. package/resources/wechat-emoji//345/233/247.png +0 -0
  180. package/resources/wechat-emoji//345/233/260.png +0 -0
  181. package/resources/wechat-emoji//345/235/217/347/254/221.png +0 -0
  182. package/resources/wechat-emoji//345/244/247/345/223/255.png +0 -0
  183. package/resources/wechat-emoji//345/244/251/345/225/212.png +0 -0
  184. package/resources/wechat-emoji//345/244/252/351/230/263.png +0 -0
  185. package/resources/wechat-emoji//345/244/261/346/234/233.png +0 -0
  186. package/resources/wechat-emoji//345/245/270/347/254/221.png +0 -0
  187. package/resources/wechat-emoji//345/245/275/347/232/204.png +0 -0
  188. package/resources/wechat-emoji//345/247/224/345/261/210.png +0 -0
  189. package/resources/wechat-emoji//345/256/263/347/276/236.png +0 -0
  190. package/resources/wechat-emoji//345/260/264/345/260/254.png +0 -0
  191. package/resources/wechat-emoji//345/272/206/347/245/235.png +0 -0
  192. package/resources/wechat-emoji//345/274/261.png +0 -0
  193. package/resources/wechat-emoji//345/274/272.png +0 -0
  194. package/resources/wechat-emoji//345/276/227/346/204/217.png +0 -0
  195. package/resources/wechat-emoji//345/276/256/347/254/221.png +0 -0
  196. package/resources/wechat-emoji//345/277/203/347/242/216.png +0 -0
  197. package/resources/wechat-emoji//345/277/253/345/223/255/344/272/206.png +0 -0
  198. package/resources/wechat-emoji//346/201/220/346/203/247.png +0 -0
  199. package/resources/wechat-emoji//346/202/240/351/227/262.png +0 -0
  200. package/resources/wechat-emoji//346/203/212/346/201/220.png +0 -0
  201. package/resources/wechat-emoji//346/203/212/350/256/266.png +0 -0
  202. package/resources/wechat-emoji//346/204/211/345/277/253.png +0 -0
  203. package/resources/wechat-emoji//346/206/250/347/254/221.png +0 -0
  204. package/resources/wechat-emoji//346/211/223/350/204/270.png +0 -0
  205. package/resources/wechat-emoji//346/212/223/347/213/202.png +0 -0
  206. package/resources/wechat-emoji//346/212/240/351/274/273.png +0 -0
  207. package/resources/wechat-emoji//346/212/261/346/213/263.png +0 -0
  208. package/resources/wechat-emoji//346/213/245/346/212/261.png +0 -0
  209. package/resources/wechat-emoji//346/213/263/345/244/264.png +0 -0
  210. package/resources/wechat-emoji//346/215/202/350/204/270.png +0 -0
  211. package/resources/wechat-emoji//346/217/241/346/211/213.png +0 -0
  212. package/resources/wechat-emoji//346/222/207/345/230/264.png +0 -0
  213. package/resources/wechat-emoji//346/223/246/346/261/227.png +0 -0
  214. package/resources/wechat-emoji//346/225/262/346/211/223.png +0 -0
  215. package/resources/wechat-emoji//346/227/240/350/257/255.png +0 -0
  216. package/resources/wechat-emoji//346/227/272/346/237/264.png +0 -0
  217. package/resources/wechat-emoji//346/231/225.png +0 -0
  218. package/resources/wechat-emoji//346/234/210/344/272/256.png +0 -0
  219. package/resources/wechat-emoji//346/234/272/346/231/272.png +0 -0
  220. package/resources/wechat-emoji//346/261/227.png +0 -0
  221. package/resources/wechat-emoji//346/265/201/346/263/252.png +0 -0
  222. package/resources/wechat-emoji//347/202/270/345/274/271.png +0 -0
  223. package/resources/wechat-emoji//347/203/237/350/212/261.png +0 -0
  224. package/resources/wechat-emoji//347/210/206/347/253/271.png +0 -0
  225. package/resources/wechat-emoji//347/210/261/345/277/203.png +0 -0
  226. package/resources/wechat-emoji//347/214/252/345/244/264.png +0 -0
  227. package/resources/wechat-emoji//347/216/253/347/221/260.png +0 -0
  228. package/resources/wechat-emoji//347/224/237/347/227/205.png +0 -0
  229. package/resources/wechat-emoji//347/226/221/351/227/256.png +0 -0
  230. package/resources/wechat-emoji//347/231/274.png +0 -0
  231. package/resources/wechat-emoji//347/231/275/347/234/274.png +0 -0
  232. package/resources/wechat-emoji//347/232/261/347/234/211.png +0 -0
  233. package/resources/wechat-emoji//347/235/241.png +0 -0
  234. package/resources/wechat-emoji//347/240/264/346/266/225/344/270/272/347/254/221.png +0 -0
  235. package/resources/wechat-emoji//347/244/274/347/211/251.png +0 -0
  236. package/resources/wechat-emoji//347/244/276/344/274/232/347/244/276/344/274/232.png +0 -0
  237. package/resources/wechat-emoji//347/246/217.png +0 -0
  238. package/resources/wechat-emoji//347/254/221/350/204/270.png +0 -0
  239. package/resources/wechat-emoji//347/272/242/345/214/205.png +0 -0
  240. package/resources/wechat-emoji//347/277/273/347/231/275/347/234/274.png +0 -0
  241. package/resources/wechat-emoji//350/200/266.png +0 -0
  242. package/resources/wechat-emoji//350/203/234/345/210/251.png +0 -0
  243. package/resources/wechat-emoji//350/204/270/347/272/242.png +0 -0
  244. package/resources/wechat-emoji//350/211/262.png +0 -0
  245. package/resources/wechat-emoji//350/213/246/346/266/251.png +0 -0
  246. package/resources/wechat-emoji//350/217/234/345/210/200.png +0 -0
  247. package/resources/wechat-emoji//350/233/213/347/263/225.png +0 -0
  248. package/resources/wechat-emoji//350/241/260.png +0 -0
  249. package/resources/wechat-emoji//350/243/202/345/274/200.png +0 -0
  250. package/resources/wechat-emoji//350/256/251/346/210/221/347/234/213/347/234/213.png +0 -0
  251. package/resources/wechat-emoji//350/260/203/347/232/256.png +0 -0
  252. package/resources/wechat-emoji//350/267/263/350/267/263.png +0 -0
  253. package/resources/wechat-emoji//350/275/254/345/234/210.png +0 -0
  254. package/resources/wechat-emoji//351/204/231/350/247/206.png +0 -0
  255. package/resources/wechat-emoji//351/227/255/345/230/264.png +0 -0
  256. package/resources/wechat-emoji//351/230/264/351/231/251.png +0 -0
  257. package/resources/wechat-emoji//351/232/276/350/277/207.png +0 -0
  258. package/resources/wechat-emoji//351/252/267/351/253/205.png +0 -0
  259. package/resources/wechat-emoji//351/274/223/346/216/214.png +0 -0
  260. package/scripts/_batch_link_sources.py +73 -73
  261. package/scripts/_utils.py +29 -0
  262. package/scripts/annual_report.py +796 -796
  263. package/scripts/auto_tag.py +128 -128
  264. package/scripts/biz_daily.py +210 -53
  265. package/scripts/chat_report.py +10 -0
  266. package/scripts/chat_stats.py +702 -702
  267. package/scripts/classify_daily.py +17 -11
  268. package/scripts/create_reading_notes.py +370 -370
  269. package/scripts/daily_stats.py +132 -0
  270. package/scripts/enrich_backlinks.py +163 -163
  271. package/scripts/export_chat_html.py +1704 -107
  272. package/scripts/extract_todos.py +445 -404
  273. package/scripts/fav_server.py +119 -12
  274. package/scripts/fix_topics.py +111 -111
  275. package/scripts/generate_ai_report.py +1 -1
  276. package/scripts/generate_html.py +597 -277
  277. package/scripts/inspection_records.py +354 -0
  278. package/scripts/mcp_bridge.py +398 -398
  279. package/scripts/nt_decrypt.py +693 -48
  280. package/scripts/pipeline.py +27 -19
  281. package/scripts/promote_all.py +312 -310
  282. package/scripts/promote_ideas.py +299 -297
  283. package/scripts/rag_chat.py +194 -191
  284. package/scripts/semantic_search.py +435 -431
  285. package/scripts/sync_fav.py +201 -146
  286. package/scripts/sync_weread.py +192 -192
  287. package/scripts/vault_rag.py +140 -139
  288. package/scripts/vault_search.py +141 -141
  289. package/scripts/wechat_emoji.py +132 -0
  290. package/scripts/wechat_emoticon.py +433 -0
  291. package/scripts/wechat_image.py +258 -0
  292. package/scripts/wechat_voice.py +327 -0
  293. package/src/core/dbPathService.ts +163 -18
  294. package/src/core/keyService.ts +137 -25
  295. package/src/core/ntCore.ts +202 -61
  296. package/src/core/sqlcipherCore.ts +17 -1
  297. package/src/core/wcdbCore.ts +3 -7
  298. package/src/services/assistantDaemon.ts +111 -0
  299. package/src/services/assistantMemory.ts +193 -0
  300. package/src/services/assistantPrivacy.ts +87 -0
  301. package/src/services/assistantRouting.ts +92 -0
  302. package/src/services/assistantService.ts +295 -0
  303. package/src/services/assistantTools.ts +549 -0
  304. package/src/services/chatService.ts +133 -4
  305. package/src/services/configService.ts +194 -6
  306. package/src/services/evidenceService.ts +129 -0
  307. package/src/services/exportService.ts +113 -48
  308. package/src/services/initKeyService.ts +83 -0
  309. package/src/services/messageContract.ts +77 -0
  310. package/src/services/messageQuery.ts +38 -0
  311. package/src/services/wechat-formatter.ts +348 -348
  312. package/src/services/wechatMessageService.ts +25 -3
  313. package/src/services/wereadService.ts +308 -308
  314. package/src/services/whitelistService.ts +209 -22
  315. package/src/types.ts +20 -0
  316. package/src/utils/dateRange.ts +58 -0
  317. package/src/utils/mcpSecurity.ts +39 -0
  318. package/src/utils/packageRoot.ts +14 -0
  319. package/src/utils/python.ts +73 -0
  320. package/src/utils/pythonProcessEnv.ts +45 -0
  321. package/src/utils/talkerUtils.ts +14 -2
  322. package/dist/src/utils/errors.d.ts +0 -25
  323. package/dist/src/utils/errors.d.ts.map +0 -1
  324. package/dist/src/utils/errors.js +0 -43
  325. package/dist/src/utils/errors.js.map +0 -1
  326. package/dist/src/utils/pythonRunner.d.ts +0 -27
  327. package/dist/src/utils/pythonRunner.d.ts.map +0 -1
  328. package/dist/src/utils/pythonRunner.js +0 -62
  329. package/dist/src/utils/pythonRunner.js.map +0 -1
  330. package/src/utils/errors.ts +0 -42
  331. package/src/utils/pythonRunner.ts +0 -81
@@ -1,431 +1,435 @@
1
- #!/usr/bin/env python3
2
- """
3
- 全局语义搜索 — 基于阿里云百炼 Embedding API (text-embedding-v4) + NumPy。
4
-
5
- 用法:
6
- # 构建索引(首次或增量)
7
- python scripts/semantic_search.py build
8
-
9
- # 搜索(有索引用向量,否则关键词 fallback)
10
- python scripts/semantic_search.py search "有人推荐过遥感的工具吗" --top-k 10
11
-
12
- # 增量更新(只处理新数据)
13
- python scripts/semantic_search.py update
14
-
15
- 输出: JSON 格式
16
- """
17
-
18
- import sys, os, json, hashlib, argparse
19
- from datetime import datetime, timezone, timedelta
20
- from pathlib import Path
21
-
22
- try:
23
- import numpy as np
24
- except ImportError:
25
- print(json.dumps({"error": "请安装: pip install numpy"}))
26
- sys.exit(1)
27
-
28
- try:
29
- from sqlcipher3 import dbapi2 as sqlcipher
30
- except ImportError:
31
- print(json.dumps({"error": "请安装: pip install sqlcipher3"}))
32
- sys.exit(1)
33
-
34
- try:
35
- import urllib.request
36
- import urllib.parse
37
- except:
38
- pass
39
-
40
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
- from _utils import load_config, decrypt_lock
42
-
43
- OUTPUT_ROOT = 'output'
44
- INDEX_DIR = Path(OUTPUT_ROOT) / '.semantic_index'
45
- VECTORS_FILE = INDEX_DIR / 'vectors.npy'
46
- META_FILE = INDEX_DIR / 'meta.json'
47
- EMBEDDING_DIM = 1024 # 阿里云 text-embedding-v4
48
- BATCH_SIZE = 10 # 阿里云 text-embedding-v4 单次最多 10 条
49
- TZ = timezone(timedelta(hours=8))
50
-
51
-
52
- def json_output(data):
53
- print(json.dumps(data, ensure_ascii=False, indent=2))
54
-
55
-
56
- # ====== Embedding API ======
57
-
58
- def get_embeddings(texts: list[str], api_key: str) -> list[list[float]]:
59
- """Call 阿里云百炼 embedding API (OpenAI-compatible)."""
60
- if not texts:
61
- return []
62
-
63
- url = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
64
- headers = {
65
- "Authorization": f"Bearer {api_key}",
66
- "Content-Type": "application/json",
67
- }
68
-
69
- all_embeddings = []
70
- for i in range(0, len(texts), BATCH_SIZE):
71
- batch = texts[i:i + BATCH_SIZE]
72
- data = {
73
- "model": "text-embedding-v4",
74
- "input": batch,
75
- }
76
-
77
- req = urllib.request.Request(
78
- url,
79
- data=json.dumps(data).encode('utf-8'),
80
- headers=headers,
81
- )
82
-
83
- try:
84
- with urllib.request.urlopen(req, timeout=120) as resp:
85
- result = json.loads(resp.read().decode('utf-8'))
86
- embeddings = [item['embedding'] for item in result['data']]
87
- all_embeddings.extend(embeddings)
88
- except Exception as e:
89
- err_body = ''
90
- if hasattr(e, 'read'):
91
- try: err_body = e.read().decode()[:300]
92
- except: pass
93
- print(f"[WARN] Embedding API batch {i//BATCH_SIZE + 1} 失败: {e} {err_body}", file=sys.stderr)
94
- all_embeddings.extend([[0.0] * EMBEDDING_DIM for _ in batch])
95
-
96
- return all_embeddings
97
-
98
-
99
- # ====== Data Collection ======
100
-
101
- def open_db(db_path, key_hex, salt_hex):
102
- raw_key = f"x'{key_hex}{salt_hex}'"
103
- conn = sqlcipher.connect(db_path)
104
- c = conn.cursor()
105
- c.execute(f'PRAGMA key = "{raw_key}";')
106
- c.execute("SELECT count(*) FROM sqlite_master")
107
- return conn
108
-
109
-
110
- def get_name_map(contact_db, contact_key, contact_salt):
111
- name_map = {}
112
- if not contact_db or not contact_key or not os.path.exists(contact_db):
113
- return name_map
114
- try:
115
- conn = open_db(contact_db, contact_key, contact_salt)
116
- c = conn.cursor()
117
- c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), username) FROM contact")
118
- for r in c.fetchall():
119
- name_map[r[0]] = r[1]
120
- conn.close()
121
- except:
122
- pass
123
- return name_map
124
-
125
-
126
- def collect_chat_messages(conn, name_map, days=90):
127
- """Collect chat messages for indexing."""
128
- c = conn.cursor()
129
- now = datetime.now(TZ)
130
- start_ts = int((now - timedelta(days=days)).timestamp())
131
-
132
- try:
133
- c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
134
- sessions = [r[0] for r in c.fetchall()]
135
- except:
136
- c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
137
- sessions = [r[0] for r in c.fetchall()]
138
-
139
- items = []
140
- for talker in sessions[:100]: # Limit to avoid too many
141
- tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
142
- try:
143
- c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
144
- if c.fetchone()[0] == 0:
145
- continue
146
-
147
- c.execute(f'''
148
- SELECT create_time, real_sender_id, message_content
149
- FROM "{tbl}"
150
- WHERE create_time >= ?
151
- ORDER BY create_time DESC
152
- LIMIT 500
153
- ''', (start_ts,))
154
-
155
- for ts, sender, content in c.fetchall():
156
- if not content or not isinstance(content, str) or len(content) < 10:
157
- continue
158
- sender_name = name_map.get(sender, sender) if sender else name_map.get(talker, talker)
159
- dt = datetime.fromtimestamp(ts, tz=TZ)
160
- items.append({
161
- "id": f"chat:{talker}:{ts}",
162
- "type": "chat",
163
- "talker": name_map.get(talker, talker),
164
- "sender": sender_name,
165
- "time": dt.strftime('%Y-%m-%d %H:%M'),
166
- "text": content[:500],
167
- })
168
- except:
169
- pass
170
-
171
- return items
172
-
173
-
174
- def collect_articles():
175
- """Collect articles from biz-daily."""
176
- items = []
177
- daily_dir = Path(OUTPUT_ROOT) / 'biz-daily'
178
- if not daily_dir.exists():
179
- return items
180
-
181
- for date_dir in sorted(daily_dir.iterdir(), reverse=True)[:30]: # Last 30 days
182
- if not date_dir.is_dir():
183
- continue
184
- for topic_dir in date_dir.iterdir():
185
- if not topic_dir.is_dir():
186
- continue
187
- for f in topic_dir.glob(' marriage*.md'):
188
- if f.name == 'README.md':
189
- continue
190
- try:
191
- content = f.read_text(encoding='utf-8')
192
- # Extract title and body
193
- lines = content.split('\n')
194
- title = ''
195
- body_start = 0
196
- for i, line in enumerate(lines):
197
- if line.startswith('title:'):
198
- title = line.split(':', 1)[1].strip().strip('"')
199
- if line.startswith('## 正文'):
200
- body_start = i + 1
201
- break
202
- body = '\n'.join(lines[body_start:body_start + 50]) if body_start else content[:1000]
203
- if title:
204
- items.append({
205
- "id": f"article:{f.relative_to(daily_dir)}",
206
- "type": "article",
207
- "title": title,
208
- "date": date_dir.name,
209
- "topic": topic_dir.name,
210
- "text": f"{title}\n{body[:500]}",
211
- })
212
- except:
213
- pass
214
-
215
- return items
216
-
217
-
218
- # ====== Index Operations ======
219
-
220
- def build_index(api_key: str, full: bool = False):
221
- """Build or update the semantic index."""
222
- INDEX_DIR.mkdir(parents=True, exist_ok=True)
223
-
224
- # Load existing index
225
- existing_ids = set()
226
- if not full and META_FILE.exists():
227
- meta = json.loads(META_FILE.read_text(encoding='utf-8'))
228
- existing_ids = {item['id'] for item in meta}
229
- print(f"现有索引: {len(existing_ids)} 条", file=sys.stderr)
230
-
231
- # Collect data
232
- print("收集数据...", file=sys.stderr)
233
-
234
- # Load DB
235
- config = load_config()
236
- nt_db = config.get('ntDbPath', '')
237
- if not nt_db:
238
- return {"error": "未初始化,请先运行 weflow-cli init"}
239
-
240
- nt_key = decrypt_lock(config.get('ntKey', ''))
241
- nt_salt = config.get('ntSalt', '')
242
-
243
- # Get name map
244
- msg_dir = os.path.dirname(nt_db.replace('\\', '/'))
245
- wxid_dir = os.path.dirname(os.path.dirname(msg_dir))
246
- contact_db = os.path.join(wxid_dir, 'db_storage', 'contact', 'contact.db')
247
- contact_key_enc = config.get('contactKey', '')
248
- contact_salt = config.get('contactSalt', '')
249
- contact_key = decrypt_lock(contact_key_enc) if contact_key_enc else ''
250
- name_map = get_name_map(contact_db, contact_key, contact_salt)
251
-
252
- # Collect items
253
- items = []
254
- try:
255
- conn = open_db(nt_db, nt_key, nt_salt)
256
- chat_items = collect_chat_messages(conn, name_map, days=90)
257
- items.extend(chat_items)
258
- conn.close()
259
- except Exception as e:
260
- print(f"[WARN] 聊天消息收集失败: {e}", file=sys.stderr)
261
-
262
- article_items = collect_articles()
263
- items.extend(article_items)
264
-
265
- # Filter new items
266
- new_items = [item for item in items if item['id'] not in existing_ids]
267
- print(f"总数据: {len(items)} 条, 新数据: {len(new_items)} 条", file=sys.stderr)
268
-
269
- if not new_items:
270
- return {"status": "up_to_date", "total": len(items)}
271
-
272
- # Generate embeddings
273
- print("生成 embeddings...", file=sys.stderr)
274
- texts = [item['text'] for item in new_items]
275
- embeddings = get_embeddings(texts, api_key)
276
-
277
- if not embeddings or all(e == [0.0] * EMBEDDING_DIM for e in embeddings):
278
- return {"error": "Embedding 生成失败,请检查 API key"}
279
-
280
- # Load existing vectors
281
- if VECTORS_FILE.exists() and not full:
282
- vectors = np.load(VECTORS_FILE)
283
- meta = json.loads(META_FILE.read_text(encoding='utf-8'))
284
- else:
285
- vectors = np.empty((0, EMBEDDING_DIM), dtype=np.float32)
286
- meta = []
287
-
288
- # Append new vectors
289
- new_vectors = np.array(embeddings, dtype=np.float32)
290
- vectors = np.vstack([vectors, new_vectors]) if vectors.size else new_vectors
291
- meta.extend(new_items)
292
-
293
- # Normalize vectors for cosine similarity
294
- norms = np.linalg.norm(vectors, axis=1, keepdims=True)
295
- norms[norms == 0] = 1 # Avoid division by zero
296
- vectors = vectors / norms
297
-
298
- # Save
299
- np.save(VECTORS_FILE, vectors)
300
- META_FILE.write_text(json.dumps(meta, ensure_ascii=False), encoding='utf-8')
301
-
302
- return {
303
- "status": "success",
304
- "total": len(meta),
305
- "new": len(new_items),
306
- }
307
-
308
-
309
- def keyword_search(query: str, top_k: int = 10):
310
- """Simple keyword fallback: scan articles + messages for keyword matches."""
311
- results = []
312
- keywords = query.lower().split()
313
-
314
- # Scan biz-daily articles
315
- biz_dir = Path('output/biz-daily')
316
- if biz_dir.exists():
317
- 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
320
- try:
321
- content = md_file.read_text(encoding='utf-8')[:5000]
322
- except:
323
- continue
324
- score = sum(content.lower().count(kw) for kw in keywords)
325
- if score > 0:
326
- title = content.split('\n')[0].lstrip('# ').strip() if content.startswith('#') else md_file.stem
327
- results.append({
328
- 'title': title,
329
- 'source': str(md_file.relative_to(biz_dir)),
330
- 'score': score,
331
- 'text': content[:300].strip(),
332
- })
333
-
334
- # Scan chat messages from mcp_bridge
335
- try:
336
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
337
- from mcp_bridge import search_messages as _search_msg
338
- msg_results = _search_msg(query, 50)
339
- for r in msg_results.get('results', [])[:top_k]:
340
- results.append({
341
- 'title': f"[{r.get('time','')}] {r.get('talker','')} > {r.get('sender','')}",
342
- 'source': '微信聊天',
343
- 'score': len(query),
344
- 'text': r.get('content', '')[:200],
345
- })
346
- except:
347
- pass
348
-
349
- results.sort(key=lambda x: -x['score'])
350
- return results[:top_k]
351
-
352
-
353
- def search(query: str, api_key: str, top_k: int = 10):
354
- """Semantic search with keyword fallback."""
355
- # Try embedding-based search first
356
- if VECTORS_FILE.exists() and META_FILE.exists():
357
- try:
358
- vectors = np.load(VECTORS_FILE)
359
- meta = json.loads(META_FILE.read_text(encoding='utf-8'))
360
- query_embeddings = get_embeddings([query], api_key)
361
- if query_embeddings and not all(v == 0 for v in query_embeddings[0]):
362
- query_vec = np.array(query_embeddings[0], dtype=np.float32)
363
- query_vec = query_vec / np.linalg.norm(query_vec)
364
- similarities = np.dot(vectors, query_vec)
365
- top_indices = np.argsort(similarities)[::-1][:top_k]
366
- results = []
367
- for idx in top_indices:
368
- item = meta[idx]
369
- results.append({**item, 'score': float(similarities[idx])})
370
- return results
371
- except:
372
- pass
373
-
374
- # Fallback: keyword search
375
- return keyword_search(query, top_k)
376
-
377
- results = []
378
- for idx in top_indices:
379
- item = meta[idx]
380
- results.append({
381
- **item,
382
- "score": float(similarities[idx]),
383
- })
384
-
385
- return {
386
- "query": query,
387
- "total": len(meta),
388
- "results": results,
389
- }
390
-
391
-
392
- # ====== Main ======
393
-
394
- def main():
395
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
396
- parser = argparse.ArgumentParser()
397
- subparsers = parser.add_subparsers(dest='command')
398
-
399
- # build
400
- p = subparsers.add_parser('build')
401
- p.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
402
- p.add_argument('--full', action='store_true', help='全量重建')
403
-
404
- # update
405
- p = subparsers.add_parser('update')
406
- p.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
407
-
408
- # search
409
- p = subparsers.add_parser('search')
410
- p.add_argument('query')
411
- p.add_argument('--api-key', help='DeepSeek API key(向量搜索时需要,关键词 fallback 不需要)')
412
- p.add_argument('--top-k', type=int, default=10)
413
-
414
- args = parser.parse_args()
415
- config = load_config()
416
- api_key = args.api_key or os.environ.get('DASHSCOPE_API_KEY', '') or config.get('dashscopeApiKey', '')
417
-
418
- if args.command == 'build':
419
- result = build_index(api_key, full=args.full)
420
- elif args.command == 'update':
421
- result = build_index(api_key, full=False)
422
- elif args.command == 'search':
423
- result = search(args.query, api_key, top_k=args.top_k)
424
- else:
425
- result = {"error": f"未知命令: {args.command}"}
426
-
427
- json_output(result)
428
-
429
-
430
- if __name__ == '__main__':
431
- main()
1
+ #!/usr/bin/env python3
2
+ """
3
+ 全局语义搜索 — 基于阿里云百炼 Embedding API (text-embedding-v4) + NumPy。
4
+
5
+ 用法:
6
+ # 构建索引(首次或增量)
7
+ python scripts/semantic_search.py build
8
+
9
+ # 搜索(有索引用向量,否则关键词 fallback)
10
+ python scripts/semantic_search.py search "有人推荐过遥感的工具吗" --top-k 10
11
+
12
+ # 增量更新(只处理新数据)
13
+ python scripts/semantic_search.py update
14
+
15
+ 输出: JSON 格式
16
+ """
17
+
18
+ import sys, os, json, hashlib, argparse
19
+ from datetime import datetime, timezone, timedelta
20
+ from pathlib import Path
21
+
22
+ try:
23
+ import numpy as np
24
+ except ImportError:
25
+ print(json.dumps({"error": "请安装: pip install numpy"}))
26
+ sys.exit(1)
27
+
28
+ try:
29
+ from sqlcipher3 import dbapi2 as sqlcipher
30
+ except ImportError:
31
+ print(json.dumps({"error": "请安装: pip install sqlcipher3"}))
32
+ sys.exit(1)
33
+
34
+ try:
35
+ import urllib.request
36
+ import urllib.parse
37
+ except:
38
+ pass
39
+
40
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
+ from _utils import load_config, decrypt_lock
42
+
43
+ OUTPUT_ROOT = 'output'
44
+ INDEX_DIR = Path(OUTPUT_ROOT) / '.semantic_index'
45
+ VECTORS_FILE = INDEX_DIR / 'vectors.npy'
46
+ META_FILE = INDEX_DIR / 'meta.json'
47
+ EMBEDDING_DIM = 1024 # 阿里云 text-embedding-v4
48
+ BATCH_SIZE = 10 # 阿里云 text-embedding-v4 单次最多 10 条
49
+ TZ = timezone(timedelta(hours=8))
50
+
51
+
52
+ def json_output(data):
53
+ print(json.dumps(data, ensure_ascii=False, indent=2))
54
+
55
+
56
+ # ====== Embedding API ======
57
+
58
+ def get_embeddings(texts: list[str], api_key: str) -> list[list[float]]:
59
+ """Call 阿里云百炼 embedding API (OpenAI-compatible)."""
60
+ if not texts:
61
+ return []
62
+
63
+ url = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
64
+ headers = {
65
+ "Authorization": f"Bearer {api_key}",
66
+ "Content-Type": "application/json",
67
+ }
68
+
69
+ all_embeddings = []
70
+ for i in range(0, len(texts), BATCH_SIZE):
71
+ batch = texts[i:i + BATCH_SIZE]
72
+ data = {
73
+ "model": "text-embedding-v4",
74
+ "input": batch,
75
+ }
76
+
77
+ req = urllib.request.Request(
78
+ url,
79
+ data=json.dumps(data).encode('utf-8'),
80
+ headers=headers,
81
+ )
82
+
83
+ try:
84
+ with urllib.request.urlopen(req, timeout=120) as resp:
85
+ result = json.loads(resp.read().decode('utf-8'))
86
+ embeddings = [item['embedding'] for item in result['data']]
87
+ all_embeddings.extend(embeddings)
88
+ except Exception as e:
89
+ err_body = ''
90
+ if hasattr(e, 'read'):
91
+ try: err_body = e.read().decode()[:300]
92
+ except: pass
93
+ print(f"[WARN] Embedding API batch {i//BATCH_SIZE + 1} 失败: {e} {err_body}", file=sys.stderr)
94
+ all_embeddings.extend([[0.0] * EMBEDDING_DIM for _ in batch])
95
+
96
+ return all_embeddings
97
+
98
+
99
+ # ====== Data Collection ======
100
+
101
+ def open_db(db_path, key_hex, salt_hex):
102
+ raw_key = f"x'{key_hex}{salt_hex}'"
103
+ conn = sqlcipher.connect(db_path)
104
+ c = conn.cursor()
105
+ c.execute(f'PRAGMA key = "{raw_key}";')
106
+ c.execute("SELECT count(*) FROM sqlite_master")
107
+ return conn
108
+
109
+
110
+ def get_name_map(contact_db, contact_key, contact_salt):
111
+ name_map = {}
112
+ if not contact_db or not contact_key or not os.path.exists(contact_db):
113
+ return name_map
114
+ try:
115
+ conn = open_db(contact_db, contact_key, contact_salt)
116
+ c = conn.cursor()
117
+ c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), username) FROM contact")
118
+ for r in c.fetchall():
119
+ name_map[r[0]] = r[1]
120
+ conn.close()
121
+ except:
122
+ pass
123
+ return name_map
124
+
125
+
126
+ def collect_chat_messages(conn, name_map, days=90):
127
+ """Collect chat messages for indexing."""
128
+ c = conn.cursor()
129
+ now = datetime.now(TZ)
130
+ start_ts = int((now - timedelta(days=days)).timestamp())
131
+
132
+ try:
133
+ c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
134
+ sessions = [r[0] for r in c.fetchall()]
135
+ except:
136
+ c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
137
+ sessions = [r[0] for r in c.fetchall()]
138
+
139
+ items = []
140
+ for talker in sessions[:100]: # Limit to avoid too many
141
+ tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
142
+ try:
143
+ c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
144
+ if c.fetchone()[0] == 0:
145
+ continue
146
+
147
+ c.execute(f'''
148
+ SELECT create_time, real_sender_id, message_content
149
+ FROM "{tbl}"
150
+ WHERE create_time >= ?
151
+ ORDER BY create_time DESC
152
+ LIMIT 500
153
+ ''', (start_ts,))
154
+
155
+ for ts, sender, content in c.fetchall():
156
+ if not content or not isinstance(content, str) or len(content) < 10:
157
+ continue
158
+ sender_name = name_map.get(sender, sender) if sender else name_map.get(talker, talker)
159
+ dt = datetime.fromtimestamp(ts, tz=TZ)
160
+ items.append({
161
+ "id": f"chat:{talker}:{ts}",
162
+ "type": "chat",
163
+ "talker": name_map.get(talker, talker),
164
+ "sender": sender_name,
165
+ "time": dt.strftime('%Y-%m-%d %H:%M'),
166
+ "text": content[:500],
167
+ })
168
+ except:
169
+ pass
170
+
171
+ return items
172
+
173
+
174
+ def collect_articles():
175
+ """Collect articles from biz-daily."""
176
+ items = []
177
+ daily_dir = Path(OUTPUT_ROOT) / 'biz-daily'
178
+ if not daily_dir.exists():
179
+ return items
180
+
181
+ for date_dir in sorted(daily_dir.iterdir(), reverse=True)[:30]: # Last 30 days
182
+ if not date_dir.is_dir():
183
+ continue
184
+ for topic_dir in date_dir.iterdir():
185
+ if not topic_dir.is_dir():
186
+ continue
187
+ for f in topic_dir.glob(' marriage*.md'):
188
+ if f.name == 'README.md':
189
+ continue
190
+ try:
191
+ content = f.read_text(encoding='utf-8')
192
+ # Extract title and body
193
+ lines = content.split('\n')
194
+ title = ''
195
+ body_start = 0
196
+ for i, line in enumerate(lines):
197
+ if line.startswith('title:'):
198
+ title = line.split(':', 1)[1].strip().strip('"')
199
+ if line.startswith('## 正文'):
200
+ body_start = i + 1
201
+ break
202
+ body = '\n'.join(lines[body_start:body_start + 50]) if body_start else content[:1000]
203
+ if title:
204
+ items.append({
205
+ "id": f"article:{f.relative_to(daily_dir)}",
206
+ "type": "article",
207
+ "title": title,
208
+ "date": date_dir.name,
209
+ "topic": topic_dir.name,
210
+ "text": f"{title}\n{body[:500]}",
211
+ })
212
+ except:
213
+ pass
214
+
215
+ return items
216
+
217
+
218
+ # ====== Index Operations ======
219
+
220
+ def build_index(api_key: str, full: bool = False):
221
+ """Build or update the semantic index."""
222
+ INDEX_DIR.mkdir(parents=True, exist_ok=True)
223
+
224
+ # Load existing index
225
+ existing_ids = set()
226
+ if not full and META_FILE.exists():
227
+ meta = json.loads(META_FILE.read_text(encoding='utf-8'))
228
+ existing_ids = {item['id'] for item in meta}
229
+ print(f"现有索引: {len(existing_ids)} 条", file=sys.stderr)
230
+
231
+ # Collect data
232
+ print("收集数据...", file=sys.stderr)
233
+
234
+ # Load DB
235
+ config = load_config()
236
+ nt_db = config.get('ntDbPath', '')
237
+ if not nt_db:
238
+ return {"error": "未初始化,请先运行 weflow-cli init"}
239
+
240
+ nt_key = decrypt_lock(config.get('ntKey', ''))
241
+ nt_salt = config.get('ntSalt', '')
242
+
243
+ # Get name map
244
+ msg_dir = os.path.dirname(nt_db.replace('\\', '/'))
245
+ wxid_dir = os.path.dirname(os.path.dirname(msg_dir))
246
+ contact_db = os.path.join(wxid_dir, 'db_storage', 'contact', 'contact.db')
247
+ contact_key_enc = config.get('contactKey', '')
248
+ contact_salt = config.get('contactSalt', '')
249
+ contact_key = decrypt_lock(contact_key_enc) if contact_key_enc else ''
250
+ name_map = get_name_map(contact_db, contact_key, contact_salt)
251
+
252
+ # Collect items
253
+ items = []
254
+ try:
255
+ conn = open_db(nt_db, nt_key, nt_salt)
256
+ chat_items = collect_chat_messages(conn, name_map, days=90)
257
+ items.extend(chat_items)
258
+ conn.close()
259
+ except Exception as e:
260
+ print(f"[WARN] 聊天消息收集失败: {e}", file=sys.stderr)
261
+
262
+ article_items = collect_articles()
263
+ items.extend(article_items)
264
+
265
+ # Filter new items
266
+ new_items = [item for item in items if item['id'] not in existing_ids]
267
+ print(f"总数据: {len(items)} 条, 新数据: {len(new_items)} 条", file=sys.stderr)
268
+
269
+ if not new_items:
270
+ return {"status": "up_to_date", "total": len(items)}
271
+
272
+ # Generate embeddings
273
+ print("生成 embeddings...", file=sys.stderr)
274
+ texts = [item['text'] for item in new_items]
275
+ embeddings = get_embeddings(texts, api_key)
276
+
277
+ if not embeddings or all(e == [0.0] * EMBEDDING_DIM for e in embeddings):
278
+ return {"error": "Embedding 生成失败,请检查 API key"}
279
+
280
+ # Load existing vectors
281
+ if VECTORS_FILE.exists() and not full:
282
+ vectors = np.load(VECTORS_FILE)
283
+ meta = json.loads(META_FILE.read_text(encoding='utf-8'))
284
+ else:
285
+ vectors = np.empty((0, EMBEDDING_DIM), dtype=np.float32)
286
+ meta = []
287
+
288
+ # Append new vectors
289
+ new_vectors = np.array(embeddings, dtype=np.float32)
290
+ vectors = np.vstack([vectors, new_vectors]) if vectors.size else new_vectors
291
+ meta.extend(new_items)
292
+
293
+ # Normalize vectors for cosine similarity
294
+ norms = np.linalg.norm(vectors, axis=1, keepdims=True)
295
+ norms[norms == 0] = 1 # Avoid division by zero
296
+ vectors = vectors / norms
297
+
298
+ # Save
299
+ np.save(VECTORS_FILE, vectors)
300
+ META_FILE.write_text(json.dumps(meta, ensure_ascii=False), encoding='utf-8')
301
+
302
+ return {
303
+ "status": "success",
304
+ "total": len(meta),
305
+ "new": len(new_items),
306
+ }
307
+
308
+
309
+ def keyword_search(query: str, top_k: int = 10):
310
+ """Simple keyword fallback: scan articles + messages for keyword matches."""
311
+ results = []
312
+ keywords = query.lower().split()
313
+
314
+ # Scan biz-daily articles
315
+ biz_dir = Path('output/biz-daily')
316
+ if biz_dir.exists():
317
+ 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
320
+ try:
321
+ content = md_file.read_text(encoding='utf-8')[:5000]
322
+ except:
323
+ continue
324
+ score = sum(content.lower().count(kw) for kw in keywords)
325
+ if score > 0:
326
+ title = content.split('\n')[0].lstrip('# ').strip() if content.startswith('#') else md_file.stem
327
+ results.append({
328
+ 'title': title,
329
+ 'source': str(md_file.relative_to(biz_dir)),
330
+ 'score': score,
331
+ 'text': content[:300].strip(),
332
+ })
333
+
334
+ # Scan chat messages from mcp_bridge
335
+ try:
336
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
337
+ from mcp_bridge import search_messages as _search_msg
338
+ msg_results = _search_msg(query, 50)
339
+ for r in msg_results.get('results', [])[:top_k]:
340
+ results.append({
341
+ 'title': f"[{r.get('time','')}] {r.get('talker','')} > {r.get('sender','')}",
342
+ 'source': '微信聊天',
343
+ 'score': len(query),
344
+ 'text': r.get('content', '')[:200],
345
+ })
346
+ except:
347
+ pass
348
+
349
+ results.sort(key=lambda x: -x['score'])
350
+ return results[:top_k]
351
+
352
+
353
+ def search(query: str, api_key: str, top_k: int = 10):
354
+ """Semantic search with keyword fallback."""
355
+ # Try embedding-based search first
356
+ if VECTORS_FILE.exists() and META_FILE.exists():
357
+ try:
358
+ vectors = np.load(VECTORS_FILE)
359
+ meta = json.loads(META_FILE.read_text(encoding='utf-8'))
360
+ query_embeddings = get_embeddings([query], api_key)
361
+ if query_embeddings and not all(v == 0 for v in query_embeddings[0]):
362
+ query_vec = np.array(query_embeddings[0], dtype=np.float32)
363
+ query_vec = query_vec / np.linalg.norm(query_vec)
364
+ similarities = np.dot(vectors, query_vec)
365
+ top_indices = np.argsort(similarities)[::-1][:top_k]
366
+ results = []
367
+ for idx in top_indices:
368
+ item = meta[idx]
369
+ results.append({**item, 'score': float(similarities[idx])})
370
+ return results
371
+ except:
372
+ pass
373
+
374
+ # Fallback: keyword search
375
+ return keyword_search(query, top_k)
376
+
377
+ results = []
378
+ for idx in top_indices:
379
+ item = meta[idx]
380
+ results.append({
381
+ **item,
382
+ "score": float(similarities[idx]),
383
+ })
384
+
385
+ return {
386
+ "query": query,
387
+ "total": len(meta),
388
+ "results": results,
389
+ }
390
+
391
+
392
+ # ====== Main ======
393
+
394
+ def main():
395
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
396
+ parser = argparse.ArgumentParser()
397
+ subparsers = parser.add_subparsers(dest='command')
398
+
399
+ # build
400
+ p = subparsers.add_parser('build')
401
+ p.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
402
+ p.add_argument('--full', action='store_true', help='全量重建')
403
+
404
+ # update
405
+ p = subparsers.add_parser('update')
406
+ p.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
407
+
408
+ # search
409
+ p = subparsers.add_parser('search')
410
+ p.add_argument('query', nargs='?')
411
+ p.add_argument('--api-key', help='DeepSeek API key(向量搜索时需要,关键词 fallback 不需要)')
412
+ p.add_argument('--top-k', type=int, default=10)
413
+
414
+ args = parser.parse_args()
415
+ config = load_config()
416
+ api_key = args.api_key or os.environ.get('DASHSCOPE_API_KEY', '') or config.get('dashscopeApiKey', '')
417
+
418
+ if args.command == 'build':
419
+ result = build_index(api_key, full=args.full)
420
+ elif args.command == 'update':
421
+ result = build_index(api_key, full=False)
422
+ elif args.command == 'search':
423
+ query = args.query or os.environ.get('WEFLOW_SEARCH_QUERY', '')
424
+ if not query:
425
+ result = {"error": "missing search query"}
426
+ else:
427
+ result = search(query, api_key, top_k=args.top_k)
428
+ else:
429
+ result = {"error": f"未知命令: {args.command}"}
430
+
431
+ json_output(result)
432
+
433
+
434
+ if __name__ == '__main__':
435
+ main()