weflow-cli 1.5.0 → 1.6.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.
- package/ARCHITECTURE.md +105 -0
- package/CHANGELOG.md +121 -0
- package/CONTRIBUTING.md +45 -0
- package/LICENSE +21 -21
- package/OPERATIONS.md +272 -0
- package/README.en.md +281 -0
- package/README.md +334 -149
- package/SECURITY.md +47 -0
- package/bin/weflow-cli.ts +4050 -581
- package/dist/bin/weflow-cli.js +4310 -588
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +178 -236
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +15 -1
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +177 -19
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/core/keyService.d.ts +8 -0
- package/dist/src/core/keyService.d.ts.map +1 -1
- package/dist/src/core/keyService.js +127 -22
- package/dist/src/core/keyService.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +59 -1
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +201 -65
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +18 -1
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +3 -6
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/services/assistantDaemon.d.ts +19 -0
- package/dist/src/services/assistantDaemon.d.ts.map +1 -0
- package/dist/src/services/assistantDaemon.js +117 -0
- package/dist/src/services/assistantDaemon.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +36 -0
- package/dist/src/services/assistantMemory.d.ts.map +1 -0
- package/dist/src/services/assistantMemory.js +182 -0
- package/dist/src/services/assistantMemory.js.map +1 -0
- package/dist/src/services/assistantPrivacy.d.ts +25 -0
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -0
- package/dist/src/services/assistantPrivacy.js +77 -0
- package/dist/src/services/assistantPrivacy.js.map +1 -0
- package/dist/src/services/assistantRouting.d.ts +18 -0
- package/dist/src/services/assistantRouting.d.ts.map +1 -0
- package/dist/src/services/assistantRouting.js +68 -0
- package/dist/src/services/assistantRouting.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +37 -0
- package/dist/src/services/assistantService.d.ts.map +1 -0
- package/dist/src/services/assistantService.js +284 -0
- package/dist/src/services/assistantService.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +24 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -0
- package/dist/src/services/assistantTools.js +582 -0
- package/dist/src/services/assistantTools.js.map +1 -0
- package/dist/src/services/chatService.d.ts +44 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +105 -4
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +55 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +147 -6
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/evidenceService.d.ts +30 -0
- package/dist/src/services/evidenceService.d.ts.map +1 -0
- package/dist/src/services/evidenceService.js +98 -0
- package/dist/src/services/evidenceService.js.map +1 -0
- package/dist/src/services/exportService.d.ts +13 -20
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +113 -47
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/services/initKeyService.d.ts +19 -0
- package/dist/src/services/initKeyService.d.ts.map +1 -0
- package/dist/src/services/initKeyService.js +80 -0
- package/dist/src/services/initKeyService.js.map +1 -0
- package/dist/src/services/messageContract.d.ts +23 -0
- package/dist/src/services/messageContract.d.ts.map +1 -0
- package/dist/src/services/messageContract.js +49 -0
- package/dist/src/services/messageContract.js.map +1 -0
- package/dist/src/services/messageQuery.d.ts +7 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -0
- package/dist/src/services/messageQuery.js +30 -0
- package/dist/src/services/messageQuery.js.map +1 -0
- package/dist/src/services/wechat-formatter.js +14 -14
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +31 -3
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/services/whitelistService.d.ts +52 -4
- package/dist/src/services/whitelistService.d.ts.map +1 -1
- package/dist/src/services/whitelistService.js +183 -17
- package/dist/src/services/whitelistService.js.map +1 -1
- package/dist/src/types.d.ts +20 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -0
- package/dist/src/utils/dateRange.js +48 -0
- package/dist/src/utils/dateRange.js.map +1 -0
- package/dist/src/utils/mcpSecurity.d.ts +5 -0
- package/dist/src/utils/mcpSecurity.d.ts.map +1 -0
- package/dist/src/utils/mcpSecurity.js +39 -0
- package/dist/src/utils/mcpSecurity.js.map +1 -0
- package/dist/src/utils/packageRoot.d.ts +2 -0
- package/dist/src/utils/packageRoot.d.ts.map +1 -0
- package/dist/src/utils/packageRoot.js +16 -0
- package/dist/src/utils/packageRoot.js.map +1 -0
- package/dist/src/utils/python.d.ts +2 -0
- package/dist/src/utils/python.d.ts.map +1 -0
- package/dist/src/utils/python.js +72 -0
- package/dist/src/utils/python.js.map +1 -0
- package/dist/src/utils/pythonProcessEnv.d.ts +3 -0
- package/dist/src/utils/pythonProcessEnv.d.ts.map +1 -0
- package/dist/src/utils/pythonProcessEnv.js +46 -0
- package/dist/src/utils/pythonProcessEnv.js.map +1 -0
- package/dist/src/utils/talkerUtils.d.ts +4 -1
- package/dist/src/utils/talkerUtils.d.ts.map +1 -1
- package/dist/src/utils/talkerUtils.js +9 -9
- package/dist/src/utils/talkerUtils.js.map +1 -1
- package/dist/src/utils/wechatEmoji.d.ts +5 -0
- package/dist/src/utils/wechatEmoji.d.ts.map +1 -0
- package/dist/src/utils/wechatEmoji.js +35 -0
- package/dist/src/utils/wechatEmoji.js.map +1 -0
- package/docs/AI_INTERFACE.md +189 -0
- package/docs/BRANCHES.md +87 -0
- package/docs/DATA_CONTRACT.md +74 -0
- package/docs/DECISIONS.md +270 -0
- package/docs/DEEPSEEK_V4_FLASH/346/226/275/345/267/245/346/226/207/346/241/243.md +234 -0
- package/docs/EVIDENCE_GUIDE.md +96 -0
- package/docs/MCP.md +95 -0
- package/docs/PARTNERS.md +41 -0
- package/docs/PROJECT_STATE.md +96 -0
- package/docs/RELEASING.md +124 -0
- package/docs/ROADMAP.md +191 -0
- package/docs/SETUP.md +103 -0
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/docs/images/weflow-architecture.png +0 -0
- package/docs/images/weflow-architecture.svg +65 -0
- package/mcp-server/index.ts +169 -269
- package/package.json +81 -72
- package/requirements-3x.txt +4 -0
- package/requirements-voice.txt +15 -0
- package/requirements.txt +9 -0
- package/resources/js/marked.min.js +69 -0
- package/resources/wechat-emoji/666.png +0 -0
- package/resources/wechat-emoji/Awesome.png +0 -0
- package/resources/wechat-emoji/Concerned.png +0 -0
- package/resources/wechat-emoji/Cry.png +0 -0
- package/resources/wechat-emoji/Emm.png +0 -0
- package/resources/wechat-emoji/Facepalm.png +0 -0
- package/resources/wechat-emoji/Grin.png +0 -0
- package/resources/wechat-emoji/OK.png +0 -0
- package/resources/wechat-emoji/Respect.png +0 -0
- package/resources/wechat-emoji/Sick.png +0 -0
- package/resources/wechat-emoji/Sleep.png +0 -0
- package/resources/wechat-emoji/Smile.png +0 -0
- package/resources/wechat-emoji//344/272/262/344/272/262.png +0 -0
- package/resources/wechat-emoji//344/276/277/344/276/277.png +0 -0
- package/resources/wechat-emoji//345/201/267/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/202/262/346/205/242.png +0 -0
- package/resources/wechat-emoji//345/206/215/350/247/201.png +0 -0
- package/resources/wechat-emoji//345/207/213/350/260/242.png +0 -0
- package/resources/wechat-emoji//345/212/240/346/262/271.png +0 -0
- package/resources/wechat-emoji//345/213/276/345/274/225.png +0 -0
- package/resources/wechat-emoji//345/217/221/345/221/206.png +0 -0
- package/resources/wechat-emoji//345/217/221/346/200/222.png +0 -0
- package/resources/wechat-emoji//345/217/221/346/212/226.png +0 -0
- package/resources/wechat-emoji//345/217/257/346/200/234.png +0 -0
- package/resources/wechat-emoji//345/217/263/345/223/274/345/223/274.png +0 -0
- package/resources/wechat-emoji//345/217/271/346/260/224.png +0 -0
- package/resources/wechat-emoji//345/220/203/347/223/234.png +0 -0
- package/resources/wechat-emoji//345/220/210/345/215/201.png +0 -0
- package/resources/wechat-emoji//345/220/220.png +0 -0
- package/resources/wechat-emoji//345/221/262/347/211/231.png +0 -0
- package/resources/wechat-emoji//345/222/222/351/252/202.png +0 -0
- package/resources/wechat-emoji//345/222/226/345/225/241.png +0 -0
- package/resources/wechat-emoji//345/223/207.png +0 -0
- package/resources/wechat-emoji//345/225/244/351/205/222.png +0 -0
- package/resources/wechat-emoji//345/230/230.png +0 -0
- package/resources/wechat-emoji//345/230/264/345/224/207.png +0 -0
- package/resources/wechat-emoji//345/230/277/345/223/210.png +0 -0
- package/resources/wechat-emoji//345/233/247.png +0 -0
- package/resources/wechat-emoji//345/233/260.png +0 -0
- package/resources/wechat-emoji//345/235/217/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/244/247/345/223/255.png +0 -0
- package/resources/wechat-emoji//345/244/251/345/225/212.png +0 -0
- package/resources/wechat-emoji//345/244/252/351/230/263.png +0 -0
- package/resources/wechat-emoji//345/244/261/346/234/233.png +0 -0
- package/resources/wechat-emoji//345/245/270/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/245/275/347/232/204.png +0 -0
- package/resources/wechat-emoji//345/247/224/345/261/210.png +0 -0
- package/resources/wechat-emoji//345/256/263/347/276/236.png +0 -0
- package/resources/wechat-emoji//345/260/264/345/260/254.png +0 -0
- package/resources/wechat-emoji//345/272/206/347/245/235.png +0 -0
- package/resources/wechat-emoji//345/274/261.png +0 -0
- package/resources/wechat-emoji//345/274/272.png +0 -0
- package/resources/wechat-emoji//345/276/227/346/204/217.png +0 -0
- package/resources/wechat-emoji//345/276/256/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/277/203/347/242/216.png +0 -0
- package/resources/wechat-emoji//345/277/253/345/223/255/344/272/206.png +0 -0
- package/resources/wechat-emoji//346/201/220/346/203/247.png +0 -0
- package/resources/wechat-emoji//346/202/240/351/227/262.png +0 -0
- package/resources/wechat-emoji//346/203/212/346/201/220.png +0 -0
- package/resources/wechat-emoji//346/203/212/350/256/266.png +0 -0
- package/resources/wechat-emoji//346/204/211/345/277/253.png +0 -0
- package/resources/wechat-emoji//346/206/250/347/254/221.png +0 -0
- package/resources/wechat-emoji//346/211/223/350/204/270.png +0 -0
- package/resources/wechat-emoji//346/212/223/347/213/202.png +0 -0
- package/resources/wechat-emoji//346/212/240/351/274/273.png +0 -0
- package/resources/wechat-emoji//346/212/261/346/213/263.png +0 -0
- package/resources/wechat-emoji//346/213/245/346/212/261.png +0 -0
- package/resources/wechat-emoji//346/213/263/345/244/264.png +0 -0
- package/resources/wechat-emoji//346/215/202/350/204/270.png +0 -0
- package/resources/wechat-emoji//346/217/241/346/211/213.png +0 -0
- package/resources/wechat-emoji//346/222/207/345/230/264.png +0 -0
- package/resources/wechat-emoji//346/223/246/346/261/227.png +0 -0
- package/resources/wechat-emoji//346/225/262/346/211/223.png +0 -0
- package/resources/wechat-emoji//346/227/240/350/257/255.png +0 -0
- package/resources/wechat-emoji//346/227/272/346/237/264.png +0 -0
- package/resources/wechat-emoji//346/231/225.png +0 -0
- package/resources/wechat-emoji//346/234/210/344/272/256.png +0 -0
- package/resources/wechat-emoji//346/234/272/346/231/272.png +0 -0
- package/resources/wechat-emoji//346/261/227.png +0 -0
- package/resources/wechat-emoji//346/265/201/346/263/252.png +0 -0
- package/resources/wechat-emoji//347/202/270/345/274/271.png +0 -0
- package/resources/wechat-emoji//347/203/237/350/212/261.png +0 -0
- package/resources/wechat-emoji//347/210/206/347/253/271.png +0 -0
- package/resources/wechat-emoji//347/210/261/345/277/203.png +0 -0
- package/resources/wechat-emoji//347/214/252/345/244/264.png +0 -0
- package/resources/wechat-emoji//347/216/253/347/221/260.png +0 -0
- package/resources/wechat-emoji//347/224/237/347/227/205.png +0 -0
- package/resources/wechat-emoji//347/226/221/351/227/256.png +0 -0
- package/resources/wechat-emoji//347/231/274.png +0 -0
- package/resources/wechat-emoji//347/231/275/347/234/274.png +0 -0
- package/resources/wechat-emoji//347/232/261/347/234/211.png +0 -0
- package/resources/wechat-emoji//347/235/241.png +0 -0
- package/resources/wechat-emoji//347/240/264/346/266/225/344/270/272/347/254/221.png +0 -0
- package/resources/wechat-emoji//347/244/274/347/211/251.png +0 -0
- package/resources/wechat-emoji//347/244/276/344/274/232/347/244/276/344/274/232.png +0 -0
- package/resources/wechat-emoji//347/246/217.png +0 -0
- package/resources/wechat-emoji//347/254/221/350/204/270.png +0 -0
- package/resources/wechat-emoji//347/272/242/345/214/205.png +0 -0
- package/resources/wechat-emoji//347/277/273/347/231/275/347/234/274.png +0 -0
- package/resources/wechat-emoji//350/200/266.png +0 -0
- package/resources/wechat-emoji//350/203/234/345/210/251.png +0 -0
- package/resources/wechat-emoji//350/204/270/347/272/242.png +0 -0
- package/resources/wechat-emoji//350/211/262.png +0 -0
- package/resources/wechat-emoji//350/213/246/346/266/251.png +0 -0
- package/resources/wechat-emoji//350/217/234/345/210/200.png +0 -0
- package/resources/wechat-emoji//350/233/213/347/263/225.png +0 -0
- package/resources/wechat-emoji//350/241/260.png +0 -0
- package/resources/wechat-emoji//350/243/202/345/274/200.png +0 -0
- package/resources/wechat-emoji//350/256/251/346/210/221/347/234/213/347/234/213.png +0 -0
- package/resources/wechat-emoji//350/260/203/347/232/256.png +0 -0
- package/resources/wechat-emoji//350/267/263/350/267/263.png +0 -0
- package/resources/wechat-emoji//350/275/254/345/234/210.png +0 -0
- package/resources/wechat-emoji//351/204/231/350/247/206.png +0 -0
- package/resources/wechat-emoji//351/227/255/345/230/264.png +0 -0
- package/resources/wechat-emoji//351/230/264/351/231/251.png +0 -0
- package/resources/wechat-emoji//351/232/276/350/277/207.png +0 -0
- package/resources/wechat-emoji//351/252/267/351/253/205.png +0 -0
- package/resources/wechat-emoji//351/274/223/346/216/214.png +0 -0
- package/scripts/_batch_link_sources.py +73 -73
- package/scripts/_utils.py +29 -0
- package/scripts/annual_report.py +796 -796
- package/scripts/auto_tag.py +128 -128
- package/scripts/biz_daily.py +210 -53
- package/scripts/chat_report.py +10 -0
- package/scripts/chat_stats.py +702 -702
- package/scripts/classify_daily.py +17 -11
- package/scripts/create_reading_notes.py +370 -370
- package/scripts/daily_stats.py +132 -0
- package/scripts/enrich_backlinks.py +163 -163
- package/scripts/export_chat_html.py +1704 -107
- package/scripts/extract_todos.py +445 -404
- package/scripts/fav_server.py +119 -12
- package/scripts/fix_topics.py +111 -111
- package/scripts/generate_ai_report.py +1 -1
- package/scripts/generate_html.py +597 -277
- package/scripts/inspection_records.py +354 -0
- package/scripts/mcp_bridge.py +398 -398
- package/scripts/nt_decrypt.py +702 -48
- package/scripts/pipeline.py +27 -19
- package/scripts/promote_all.py +312 -310
- package/scripts/promote_ideas.py +299 -297
- package/scripts/rag_chat.py +194 -191
- package/scripts/semantic_search.py +435 -431
- package/scripts/sync_fav.py +201 -146
- package/scripts/sync_weread.py +192 -192
- package/scripts/vault_rag.py +140 -139
- package/scripts/vault_search.py +141 -141
- package/scripts/wechat_emoji.py +132 -0
- package/scripts/wechat_emoticon.py +433 -0
- package/scripts/wechat_image.py +258 -0
- package/scripts/wechat_voice.py +327 -0
- package/src/core/dbPathService.ts +163 -18
- package/src/core/keyService.ts +137 -25
- package/src/core/ntCore.ts +215 -62
- package/src/core/sqlcipherCore.ts +17 -1
- package/src/core/wcdbCore.ts +3 -7
- package/src/services/assistantDaemon.ts +111 -0
- package/src/services/assistantMemory.ts +193 -0
- package/src/services/assistantPrivacy.ts +87 -0
- package/src/services/assistantRouting.ts +92 -0
- package/src/services/assistantService.ts +295 -0
- package/src/services/assistantTools.ts +549 -0
- package/src/services/chatService.ts +133 -4
- package/src/services/configService.ts +194 -6
- package/src/services/evidenceService.ts +129 -0
- package/src/services/exportService.ts +113 -48
- package/src/services/initKeyService.ts +83 -0
- package/src/services/messageContract.ts +77 -0
- package/src/services/messageQuery.ts +38 -0
- package/src/services/wechat-formatter.ts +348 -348
- package/src/services/wechatMessageService.ts +25 -3
- package/src/services/wereadService.ts +308 -308
- package/src/services/whitelistService.ts +209 -22
- package/src/types.ts +20 -0
- package/src/utils/dateRange.ts +58 -0
- package/src/utils/mcpSecurity.ts +39 -0
- package/src/utils/packageRoot.ts +14 -0
- package/src/utils/python.ts +73 -0
- package/src/utils/pythonProcessEnv.ts +45 -0
- package/src/utils/talkerUtils.ts +14 -2
- package/dist/src/utils/errors.d.ts +0 -25
- package/dist/src/utils/errors.d.ts.map +0 -1
- package/dist/src/utils/errors.js +0 -43
- package/dist/src/utils/errors.js.map +0 -1
- package/dist/src/utils/pythonRunner.d.ts +0 -27
- package/dist/src/utils/pythonRunner.d.ts.map +0 -1
- package/dist/src/utils/pythonRunner.js +0 -62
- package/dist/src/utils/pythonRunner.js.map +0 -1
- package/src/utils/errors.ts +0 -42
- 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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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()
|