weflow-cli 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +3 -2
- package/CHANGELOG.md +2108 -2
- package/OPERATIONS.md +810 -1
- package/README.en.md +96 -10
- package/README.md +34 -9
- package/bin/weflow-cli.ts +937 -28
- package/dist/bin/weflow-cli.js +955 -28
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +39 -13
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/appMsgFormat.d.ts +37 -0
- package/dist/src/core/appMsgFormat.d.ts.map +1 -0
- package/dist/src/core/appMsgFormat.js +106 -0
- package/dist/src/core/appMsgFormat.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +19 -2
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +11 -1
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +0 -12
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +5 -64
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +5 -1
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/panel/client.d.ts +25 -0
- package/dist/src/panel/client.d.ts.map +1 -0
- package/dist/src/panel/client.js +34 -0
- package/dist/src/panel/client.js.map +1 -0
- package/dist/src/panel/endpoint.d.ts +45 -0
- package/dist/src/panel/endpoint.d.ts.map +1 -0
- package/dist/src/panel/endpoint.js +132 -0
- package/dist/src/panel/endpoint.js.map +1 -0
- package/dist/src/panel/launch.d.ts +50 -0
- package/dist/src/panel/launch.d.ts.map +1 -0
- package/dist/src/panel/launch.js +74 -0
- package/dist/src/panel/launch.js.map +1 -0
- package/dist/src/panel/server.d.ts +23 -0
- package/dist/src/panel/server.d.ts.map +1 -0
- package/dist/src/panel/server.js +413 -0
- package/dist/src/panel/server.js.map +1 -0
- package/dist/src/panel/userId.d.ts +37 -0
- package/dist/src/panel/userId.d.ts.map +1 -0
- package/dist/src/panel/userId.js +56 -0
- package/dist/src/panel/userId.js.map +1 -0
- package/dist/src/services/assistantDaemon.d.ts +20 -3
- package/dist/src/services/assistantDaemon.d.ts.map +1 -1
- package/dist/src/services/assistantDaemon.js +46 -9
- package/dist/src/services/assistantDaemon.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +141 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -0
- package/dist/src/services/assistantEval.js +497 -0
- package/dist/src/services/assistantEval.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +106 -4
- package/dist/src/services/assistantMemory.d.ts.map +1 -1
- package/dist/src/services/assistantMemory.js +396 -61
- package/dist/src/services/assistantMemory.js.map +1 -1
- package/dist/src/services/assistantPrivacy.d.ts +15 -2
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
- package/dist/src/services/assistantPrivacy.js +25 -6
- package/dist/src/services/assistantPrivacy.js.map +1 -1
- package/dist/src/services/assistantRouter.d.ts +41 -0
- package/dist/src/services/assistantRouter.d.ts.map +1 -0
- package/dist/src/services/assistantRouter.js +151 -0
- package/dist/src/services/assistantRouter.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +209 -3
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +599 -75
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +217 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +1116 -59
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +47 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -0
- package/dist/src/services/assistantTrace.js +150 -0
- package/dist/src/services/assistantTrace.js.map +1 -0
- package/dist/src/services/chatService.d.ts +1 -1
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +5 -2
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +15 -8
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/pythonBridge.d.ts +41 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -0
- package/dist/src/services/pythonBridge.js +119 -0
- package/dist/src/services/pythonBridge.js.map +1 -0
- package/dist/src/services/syncService.d.ts +8 -2
- package/dist/src/services/syncService.d.ts.map +1 -1
- package/dist/src/services/syncService.js +26 -7
- package/dist/src/services/syncService.js.map +1 -1
- package/dist/src/services/wechatMessageService.d.ts +25 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +69 -7
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/services/wereadService.d.ts +33 -6
- package/dist/src/services/wereadService.d.ts.map +1 -1
- package/dist/src/services/wereadService.js +27 -2
- package/dist/src/services/wereadService.js.map +1 -1
- package/dist/src/utils/atomicWrite.d.ts +4 -0
- package/dist/src/utils/atomicWrite.d.ts.map +1 -0
- package/dist/src/utils/atomicWrite.js +60 -0
- package/dist/src/utils/atomicWrite.js.map +1 -0
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -1
- package/dist/src/utils/dateRange.js +47 -0
- package/dist/src/utils/dateRange.js.map +1 -1
- package/dist/src/utils/text.d.ts +18 -0
- package/dist/src/utils/text.d.ts.map +1 -0
- package/dist/src/utils/text.js +20 -0
- package/dist/src/utils/text.js.map +1 -0
- package/docs/AI_INTERFACE.md +2 -0
- package/docs/BRANCHES.md +13 -4
- package/docs/DECISIONS.md +1424 -0
- package/docs/EXTENDING.md +187 -0
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
- package/docs/MCP.md +23 -4
- package/docs/PROJECT_STATE.md +210 -9
- package/docs/SETUP.md +22 -0
- package/docs/SYNC_CONTRACT.md +31 -3
- package/mcp-server/index.ts +681 -657
- package/package.json +84 -83
- package/resources/panel/ball-position.cjs +153 -0
- package/resources/panel/index.html +46 -0
- package/resources/panel/main.cjs +483 -0
- package/resources/panel/mascot-happy.png +0 -0
- package/resources/panel/mascot-sorry.png +0 -0
- package/resources/panel/mascot-thinking.png +0 -0
- package/resources/panel/mascot-tired.png +0 -0
- package/resources/panel/mascot.png +0 -0
- package/resources/panel/package.json +7 -0
- package/resources/panel/panel.css +415 -0
- package/resources/panel/preload.cjs +79 -0
- package/resources/panel/quick-menu.cjs +197 -0
- package/resources/panel/renderer.js +470 -0
- package/resources/panel/tray-menu.cjs +40 -0
- package/resources/panel/tray.png +0 -0
- package/scripts/_utils.py +425 -54
- package/scripts/annual_report.py +4 -2
- package/scripts/article_notes.py +423 -0
- package/scripts/backfill_articles.py +438 -0
- package/scripts/biz_daily.py +590 -105
- package/scripts/chat_notes.py +470 -0
- package/scripts/classify_daily.py +10 -18
- package/scripts/compile_wiki.py +1060 -46
- package/scripts/create_reading_notes.py +199 -14
- package/scripts/decide.py +253 -0
- package/scripts/draft_reply.py +671 -0
- package/scripts/export_chat_html.py +35 -33
- package/scripts/extract_todos.py +31 -5
- package/scripts/fav_notes.py +318 -0
- package/scripts/fix_topics.py +2 -1
- package/scripts/generate_ai_report.py +214 -9
- package/scripts/generate_html.py +18 -3
- package/scripts/generate_review.py +2 -2
- package/scripts/jev_client.py +294 -0
- package/scripts/jev_probe.py +440 -0
- package/scripts/nt_common.py +82 -0
- package/scripts/nt_decrypt.py +1755 -1460
- package/scripts/pipeline.py +27 -2
- package/scripts/promote_ideas.py +19 -4
- package/scripts/quality_eval.py +707 -0
- package/scripts/rag_chat.py +4 -4
- package/scripts/read_image.py +187 -0
- package/scripts/reply_debt.py +488 -0
- package/scripts/route_cards.py +453 -0
- package/scripts/semantic_search.py +208 -49
- package/scripts/user_notes.py +315 -0
- package/scripts/vault_rag.py +13 -7
- package/scripts/vault_search.py +21 -4
- package/scripts/wiki_lint.py +429 -0
- package/src/core/appMsgFormat.ts +117 -0
- package/src/core/ntCore.ts +27 -2
- package/src/core/sqlcipherCore.ts +5 -61
- package/src/core/wcdbCore.ts +6 -2
- package/src/panel/client.ts +49 -0
- package/src/panel/endpoint.ts +144 -0
- package/src/panel/launch.ts +101 -0
- package/src/panel/server.ts +424 -0
- package/src/panel/userId.ts +72 -0
- package/src/services/assistantDaemon.ts +57 -9
- package/src/services/assistantEval.ts +611 -0
- package/src/services/assistantMemory.ts +413 -57
- package/src/services/assistantPrivacy.ts +25 -6
- package/src/services/assistantRouter.ts +191 -0
- package/src/services/assistantService.ts +681 -74
- package/src/services/assistantTools.ts +1149 -58
- package/src/services/assistantTrace.ts +175 -0
- package/src/services/chatService.ts +6 -2
- package/src/services/configService.ts +403 -385
- package/src/services/pythonBridge.ts +139 -0
- package/src/services/syncService.ts +37 -9
- package/src/services/wechatMessageService.ts +73 -7
- package/src/services/wereadService.ts +43 -7
- package/src/utils/atomicWrite.ts +56 -0
- package/src/utils/dateRange.ts +46 -0
- package/src/utils/text.ts +20 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""会话卡片 + Jev 路由:把"搜我的聊天"变成"先选会话、再精确检索"。
|
|
3
|
+
|
|
4
|
+
**分工是实测出来的,不是设计出来的**。最初的设想是让 Jev 排序会话(先把每个会话
|
|
5
|
+
压成"卡片"再问它哪个相关)。**实测不成立**:
|
|
6
|
+
|
|
7
|
+
* noul 问"这个会话更值得打开吗" → 候选全落在 0.50~0.51,包括只有一条消息的
|
|
8
|
+
领券群和命中 213 次的会务群挤在同一档;它还漏掉了最该打开的那个。
|
|
9
|
+
* 换成 score 问"有多相关"(0/1/2 档)→ 20 个会话全给 1.74~1.76,命中 247 次的
|
|
10
|
+
与命中 8 次的一模一样。**它与命中数毫无关系**。
|
|
11
|
+
* 同一批数据里**纯本地按命中数排序**却排得很准。
|
|
12
|
+
|
|
13
|
+
原因不难理解:模型看不到会话内容,卡片上只有"类型/条数/时间/名称/命中数",
|
|
14
|
+
它对"这个会话里有没有你要找的东西"无从判断,只能给个中庸分。
|
|
15
|
+
|
|
16
|
+
**所以排序由本地命中数决定**(那是客观信号),**Jev 只做一件它确实做得到的事**:
|
|
17
|
+
从问题里切出的候选词中**挑出真正的查询词**。第一版把"个群/发会/议和/哪个"这类
|
|
18
|
+
n-gram 碎片一起递过去,它稳定地留下「会议」、否掉其余七个——这是在**有限的选项里
|
|
19
|
+
做选择**,正是它的本职(它不会凭空生成词,也不该)。
|
|
20
|
+
|
|
21
|
+
**检索仍然在本地**。微信自带的 `message_fts.db` 里存着**明文**正文与整数
|
|
22
|
+
`session_id`,而那个 `session_id` 就是同库 `name2id` 表的 **rowid**。`LIKE` 扫
|
|
23
|
+
6 万行是秒级——`MATCH` 用不了,它的分词器 `MMFtsTokenizer` 不在这里的 SQLCipher 里。
|
|
24
|
+
|
|
25
|
+
**已知边界**:这条路**只匹配字面词**,不做同义改写。"聊过上线的事"若正文里写的
|
|
26
|
+
是"部署",这里命中不到——那是向量那条路(`search`)该管的,两者互补而非替代。
|
|
27
|
+
命中数为 0 时它会直说,而不是假装找到。
|
|
28
|
+
|
|
29
|
+
**出网边界**:`ask` 只把**候选词与你的问题**发给 Jev(会话排序不走它,所以会话名、
|
|
30
|
+
条数、时间都不出网了——比第一版更小)。**消息正文从不外发**。没有 `--yes` 不发送;
|
|
31
|
+
`--keyword` 完全不问 Jev,全程不出网。
|
|
32
|
+
"""
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import sys
|
|
38
|
+
import time
|
|
39
|
+
|
|
40
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
41
|
+
from _utils import load_config, decrypt_lock # noqa: E402
|
|
42
|
+
from nt_common import derive_database_key # noqa: E402
|
|
43
|
+
|
|
44
|
+
CARDS_DIR = os.path.join(os.path.expanduser('~'), '.weflow-cli', 'cards')
|
|
45
|
+
CARDS_PATH = os.path.join(CARDS_DIR, 'sessions.json')
|
|
46
|
+
MAX_TERMS = 8 # 一次最多试几个查询词:每个词要在 4 张分表上各扫一遍
|
|
47
|
+
CJK = re.compile(r'[一-鿿]{2,}')
|
|
48
|
+
NOUL_THRESHOLD = 0.5
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _noul(answer):
|
|
52
|
+
"""取 noul 的分数。**它是概率浮点,不是布尔**——实测「天气晴朗吗」= 0.98、「在下雪吗」= 0.01。
|
|
53
|
+
|
|
54
|
+
写成 `is True` 会让每个问题都读成否,而表面现象是"模型把所有候选都否掉了":
|
|
55
|
+
一次真实调用里 8 个查询词(含显然正确的「会议」)全被读成否,程序还照着打出了
|
|
56
|
+
"它认为这些词都不是你要找的"。所以返回 `(分数, 是否缺字段)`,让调用方能把
|
|
57
|
+
**"契约变了"和"真的是低分"分开说**,而不是让解析错误伪装成模型判断。
|
|
58
|
+
"""
|
|
59
|
+
if not isinstance(answer, dict) or 'noul' not in answer:
|
|
60
|
+
return None, True
|
|
61
|
+
try:
|
|
62
|
+
return float(answer['noul']), False
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
return None, True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _open_fts(config):
|
|
68
|
+
from sqlcipher3 import dbapi2 as sqlcipher
|
|
69
|
+
msg_dir = os.path.dirname(os.path.normpath(config.get('ntDbPath', '')))
|
|
70
|
+
path = os.path.join(msg_dir, 'message_fts.db')
|
|
71
|
+
if not os.path.isfile(path):
|
|
72
|
+
raise SystemExit('找不到 message_fts.db:%s' % path)
|
|
73
|
+
passphrase = decrypt_lock(config.get('favPassphrase', ''))
|
|
74
|
+
key, salt = derive_database_key(path, '', '', passphrase)
|
|
75
|
+
conn = sqlcipher.connect(path)
|
|
76
|
+
conn.execute('PRAGMA key = "x\'%s%s\'";' % (key, salt))
|
|
77
|
+
return conn, path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def fts_tables(cursor):
|
|
81
|
+
"""消息 FTS 的正文分表。
|
|
82
|
+
|
|
83
|
+
**`_aux` 也要排除**:它以数字结尾(`message_fts_v4_aux_0`),只看后缀是不是
|
|
84
|
+
数字会把它当正文表收进来——它没有 `create_time`,且一行对一条消息,收进来会让
|
|
85
|
+
消息数**翻倍**(实测 60267 与 120734 的关系就是这么来的)。
|
|
86
|
+
`_content`/`_idx`/`_data`/`_docsize`/`_config` 是 FTS5 的内部表。
|
|
87
|
+
"""
|
|
88
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'message_fts_v4_%'")
|
|
89
|
+
out = []
|
|
90
|
+
for (name,) in cursor.fetchall():
|
|
91
|
+
if '_aux' in name or name.endswith(('_content', '_idx', '_data', '_docsize', '_config')):
|
|
92
|
+
continue
|
|
93
|
+
if name.rsplit('_', 1)[-1].isdigit():
|
|
94
|
+
out.append(name)
|
|
95
|
+
return sorted(out)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def session_usernames(cursor):
|
|
99
|
+
"""`session_id` -> username。实测 `session_id` 就是 `name2id` 的 rowid。"""
|
|
100
|
+
cursor.execute('SELECT rowid, username FROM name2id')
|
|
101
|
+
return {rowid: name for rowid, name in cursor.fetchall()}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def classify(username):
|
|
105
|
+
if username.endswith('@chatroom'):
|
|
106
|
+
return '群聊'
|
|
107
|
+
if username.startswith('gh_'):
|
|
108
|
+
return '公众号'
|
|
109
|
+
if username.isdigit() or username in ('newsapp', 'weixin', 'floatbottle', 'filehelper'):
|
|
110
|
+
return '服务号'
|
|
111
|
+
return '单聊'
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def display_names(config):
|
|
115
|
+
"""会话显示名(备注 > 昵称 > username)。取不到就**退回 username**——名字是装饰性的。
|
|
116
|
+
|
|
117
|
+
contact 库的密钥同样由 `favPassphrase` 派生(与 fts 库、订阅号库一条路子),
|
|
118
|
+
不要求 config 里另配 `contactKey`/`contactSalt`——那两个键在这台机器上是空的,
|
|
119
|
+
按 `biz_daily` 的写法会静默拿不到名字。
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
from nt_decrypt import load_contact_names
|
|
123
|
+
msg_dir = os.path.dirname(os.path.normpath(config.get('ntDbPath', '')))
|
|
124
|
+
wxid_dir = os.path.dirname(os.path.dirname(msg_dir))
|
|
125
|
+
contact_db = os.path.join(wxid_dir, 'db_storage', 'contact', 'contact.db')
|
|
126
|
+
if not os.path.isfile(contact_db):
|
|
127
|
+
return {}
|
|
128
|
+
passphrase = decrypt_lock(config.get('favPassphrase', ''))
|
|
129
|
+
# `contactKey`/`contactSalt` 在盘上是**密文**,必须先解密——直接当明文传进去,
|
|
130
|
+
# 一旦有人配了这两个键就会静默拿到一个错密钥(这台机器上是空的,所以走
|
|
131
|
+
# passphrase 那条路看不出来)。这条被 `test/encrypted_config_test.py` 抓到过。
|
|
132
|
+
return_enc = config.get('contactKey', '')
|
|
133
|
+
salt_enc = config.get('contactSalt', '')
|
|
134
|
+
key, salt = derive_database_key(contact_db,
|
|
135
|
+
decrypt_lock(return_enc) if return_enc else '',
|
|
136
|
+
decrypt_lock(salt_enc) if salt_enc else '',
|
|
137
|
+
passphrase)
|
|
138
|
+
return (load_contact_names(contact_db, key, salt) or {}) if key else {}
|
|
139
|
+
except Exception:
|
|
140
|
+
return {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_cards(config, progress=print):
|
|
144
|
+
"""只取结构:类型、条数、时间跨度、最近活跃。主题信息留到 ask 时按查询词现算。"""
|
|
145
|
+
conn, path = _open_fts(config)
|
|
146
|
+
c = conn.cursor()
|
|
147
|
+
names = session_usernames(c)
|
|
148
|
+
tables = fts_tables(c)
|
|
149
|
+
|
|
150
|
+
agg = {}
|
|
151
|
+
for table in tables:
|
|
152
|
+
try:
|
|
153
|
+
c.execute('SELECT session_id, count(*), min(create_time), max(create_time) '
|
|
154
|
+
'FROM "%s" GROUP BY session_id' % table)
|
|
155
|
+
except Exception as exc: # 分表结构变了也不该让整次构建失败
|
|
156
|
+
progress(' [WARN] 读 %s 失败:%s' % (table, exc))
|
|
157
|
+
continue
|
|
158
|
+
for sid, n, lo, hi in c.fetchall():
|
|
159
|
+
cur = agg.get(sid)
|
|
160
|
+
if cur is None:
|
|
161
|
+
agg[sid] = [n, lo, hi]
|
|
162
|
+
else:
|
|
163
|
+
cur[0] += n
|
|
164
|
+
cur[1] = min(cur[1], lo)
|
|
165
|
+
cur[2] = max(cur[2], hi)
|
|
166
|
+
conn.close()
|
|
167
|
+
|
|
168
|
+
now = time.time()
|
|
169
|
+
display = display_names(config)
|
|
170
|
+
cards = []
|
|
171
|
+
for sid, (n, lo, hi) in agg.items():
|
|
172
|
+
username = names.get(sid, '')
|
|
173
|
+
cards.append({
|
|
174
|
+
'id': sid,
|
|
175
|
+
'kind': classify(username),
|
|
176
|
+
'label': display.get(username) or username or ('session#%d' % sid),
|
|
177
|
+
'messages': n,
|
|
178
|
+
'span_days': int((hi - lo) / 86400) if hi > lo else 0,
|
|
179
|
+
'last_days_ago': round((now - hi) / 86400, 1),
|
|
180
|
+
})
|
|
181
|
+
cards.sort(key=lambda card: card['last_days_ago'])
|
|
182
|
+
return {'built_at': time.strftime('%Y-%m-%d %H:%M:%S'), 'source': os.path.basename(path),
|
|
183
|
+
'sessions': len(cards), 'messages': sum(c['messages'] for c in cards), 'cards': cards}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# 问句里的疑问词/客套话,不是搜索词。故意短——名单越长越像在给这台机器调参,
|
|
187
|
+
# 而且剩下的碎词还有 Jev 那一关可以否掉(见 `build_questions` 的 t* 问题)。
|
|
188
|
+
#
|
|
189
|
+
# **必须用词,不能用 `set('哪个哪些…')`**:那样得到的是**单字集合**,而候选词是
|
|
190
|
+
# 2–3 个字,`gram in _QUESTION_WORDS` 永远为假——过滤看着在那儿,其实一条也没滤掉。
|
|
191
|
+
_QUESTION_WORDS = frozenset('''哪个 哪些 什么 怎么 如何 为何 为什么 是不是 有没有 能不能
|
|
192
|
+
帮我 找一下 找找 最近 关于 相关 那种 这种 一下 告诉 知道 记得 想想 查查'''.split())
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def candidate_terms(question):
|
|
196
|
+
"""从问题里取候选查询词。
|
|
197
|
+
|
|
198
|
+
**短的优先**(2 字先于 3 字):中文里真词以双字居多,而滑窗切出的 3 字更可能跨
|
|
199
|
+
词边界("哪个群/个群在")。先选 2 字、再把被它包含的 3 字丢掉,于是"会议/通知"
|
|
200
|
+
能留下,而"发会议/议通知"不会挤掉它们。剩下的碎词由 Jev 否掉——它做选择是本职。
|
|
201
|
+
"""
|
|
202
|
+
segs = CJK.findall(question)
|
|
203
|
+
grams = []
|
|
204
|
+
for seg in segs:
|
|
205
|
+
for n in (2, 3):
|
|
206
|
+
grams.extend(seg[i:i + n] for i in range(len(seg) - n + 1))
|
|
207
|
+
if not grams and segs:
|
|
208
|
+
grams = segs
|
|
209
|
+
out = []
|
|
210
|
+
for gram in sorted(set(grams), key=lambda g: (len(g), g)):
|
|
211
|
+
# 丢掉**包含**已选词的候选(已选「会议」就不再要「会议通」)。方向别写反:
|
|
212
|
+
# 写成"被已选词包含"等于没滤,碎片照样占名额(这条我一开始就写反了)。
|
|
213
|
+
# 丢掉没有损失——LIKE 是子串匹配,「会议」本来就命中「会议室」。
|
|
214
|
+
if gram in _QUESTION_WORDS or any(chosen in gram for chosen in out):
|
|
215
|
+
continue
|
|
216
|
+
out.append(gram)
|
|
217
|
+
if len(out) >= MAX_TERMS:
|
|
218
|
+
break
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def count_hits(config, terms, progress=None):
|
|
223
|
+
"""每个查询词在**每个会话**里命中多少条。这是让 Jev 能判断的唯一主题信号。"""
|
|
224
|
+
conn, _ = _open_fts(config)
|
|
225
|
+
c = conn.cursor()
|
|
226
|
+
tables = fts_tables(c)
|
|
227
|
+
hits = {term: {} for term in terms}
|
|
228
|
+
for term in terms:
|
|
229
|
+
for table in tables:
|
|
230
|
+
try:
|
|
231
|
+
c.execute('SELECT session_id, count(*) FROM "%s" WHERE acontent LIKE ? '
|
|
232
|
+
'GROUP BY session_id' % table, ('%' + term + '%',))
|
|
233
|
+
except Exception:
|
|
234
|
+
continue
|
|
235
|
+
for sid, n in c.fetchall():
|
|
236
|
+
hits[term][sid] = hits[term].get(sid, 0) + n
|
|
237
|
+
conn.close()
|
|
238
|
+
return hits
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def build_questions(terms):
|
|
242
|
+
"""每个候选查询词一个 noul:这是 Jev 在这个工具里**唯一**被证明有效的用法。
|
|
243
|
+
|
|
244
|
+
第一版还有"每个会话一个 noul + 一个单选自检",实测两者都没有区分度(见模块
|
|
245
|
+
docstring),而且那个自检在分数并列时会误报成"编号错位",所以一并去掉了——
|
|
246
|
+
留着一个不产生信息的提问,只会让人以为它在做事。
|
|
247
|
+
"""
|
|
248
|
+
return {'t%d' % j: {
|
|
249
|
+
'type': 'noul',
|
|
250
|
+
'instructions': '「%s」是用户在找的那个词吗?'
|
|
251
|
+
'如果它只是中文滑窗切出来的碎片(比如把"淘宝"切成"去陶饱"),答否。'
|
|
252
|
+
% term,
|
|
253
|
+
} for j, term in enumerate(terms)}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def pick_terms(client, question, terms):
|
|
257
|
+
"""让 Jev 从候选词里挑出真正的查询词。返回 (保留的词, 全部得分, 缺字段数)。"""
|
|
258
|
+
if not terms:
|
|
259
|
+
return [], {}, 0
|
|
260
|
+
answers, _ = client.decide(
|
|
261
|
+
'用户在自己的微信聊天记录里找东西。下面是从他的问题里切出的候选搜索词,'
|
|
262
|
+
'其中有些是切错位置产生的碎片。\n用户的问题:%s' % question,
|
|
263
|
+
build_questions(terms))
|
|
264
|
+
kept, scores, missing = [], {}, 0
|
|
265
|
+
for j, term in enumerate(terms):
|
|
266
|
+
score, absent = _noul(answers.get('t%d' % j))
|
|
267
|
+
missing += int(absent)
|
|
268
|
+
if absent:
|
|
269
|
+
continue
|
|
270
|
+
scores[term] = score
|
|
271
|
+
if score >= NOUL_THRESHOLD:
|
|
272
|
+
kept.append(term)
|
|
273
|
+
return kept, scores, missing
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def rank_sessions(cards, hits, terms):
|
|
277
|
+
"""按本地命中数排序会话。**排序只看命中数,Jev 的分数不参与——这是量出来的结论。**
|
|
278
|
+
|
|
279
|
+
最初的设计是让 Jev 排会话(每张卡一个 noul / score 问题),实测不成立:noul 全落
|
|
280
|
+
0.50~0.51(领券群与会务群同档),score 全落 1.74~1.76(命中 247 与命中 8 一样高),
|
|
281
|
+
而同一批数据按命中数排得很准。原因写在模块 docstring 里。
|
|
282
|
+
|
|
283
|
+
所以这个函数**只接 (cards, hits, terms)**——没有任何地方可以塞进一个模型分数。
|
|
284
|
+
测试会连签名一起钉住:想把它改回"让模型排",得先删掉那条测试,而不是顺手加一个
|
|
285
|
+
参数就能改掉行为。
|
|
286
|
+
"""
|
|
287
|
+
scored = [(card, max(hits[t].get(card['id'], 0) for t in terms))
|
|
288
|
+
for card in cards if any(hits[t].get(card['id']) for t in terms)]
|
|
289
|
+
# 次键用 id,保证同分时顺序稳定(否则同一份输入两次跑可能给出不同顺序)。
|
|
290
|
+
scored.sort(key=lambda pair: (-pair[1], pair[0]['id']))
|
|
291
|
+
return [card for card, _ in scored]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def fetch_messages(config, session_ids, terms, per_card=5):
|
|
295
|
+
"""在选中的会话里取命中消息。LIKE 扫明文——微信的 MATCH 用不了(分词器不在)。"""
|
|
296
|
+
if not terms:
|
|
297
|
+
return {}
|
|
298
|
+
conn, _ = _open_fts(config)
|
|
299
|
+
c = conn.cursor()
|
|
300
|
+
clause = ' OR '.join(['acontent LIKE ?'] * len(terms))
|
|
301
|
+
params_base = ['%' + t + '%' for t in terms]
|
|
302
|
+
out = {}
|
|
303
|
+
for sid in session_ids:
|
|
304
|
+
rows = []
|
|
305
|
+
for table in fts_tables(c):
|
|
306
|
+
try:
|
|
307
|
+
c.execute('SELECT acontent, create_time FROM "%s" WHERE session_id = ? AND (%s) '
|
|
308
|
+
'ORDER BY create_time DESC LIMIT ?' % (table, clause),
|
|
309
|
+
[sid] + params_base + [per_card])
|
|
310
|
+
rows.extend(c.fetchall())
|
|
311
|
+
except Exception:
|
|
312
|
+
continue
|
|
313
|
+
rows.sort(key=lambda r: -r[1])
|
|
314
|
+
out[sid] = rows[:per_card]
|
|
315
|
+
conn.close()
|
|
316
|
+
return out
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _fmt_days(value):
|
|
320
|
+
if value is None:
|
|
321
|
+
return '?'
|
|
322
|
+
return ('%.0f' % value) if value >= 1 else '不到1'
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def main():
|
|
326
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
327
|
+
parser = argparse.ArgumentParser(description='会话卡片 + Jev 路由(本地检索)')
|
|
328
|
+
sub = parser.add_subparsers(dest='cmd', required=True)
|
|
329
|
+
|
|
330
|
+
b = sub.add_parser('build', help='从本地微信库构建会话卡片(不出网)')
|
|
331
|
+
b.add_argument('--out', default=CARDS_PATH)
|
|
332
|
+
|
|
333
|
+
a = sub.add_parser('ask', help='Jev 挑查询词 → 本地按命中数排序 → 取消息')
|
|
334
|
+
a.add_argument('question')
|
|
335
|
+
a.add_argument('--keyword', help='手动指定查询词(逗号分隔);不给就从问题里取')
|
|
336
|
+
a.add_argument('--per-card', type=int, default=5, help='每个会话最多取几条消息')
|
|
337
|
+
a.add_argument('--json-cards', type=int, default=5, help='--json 时最多给几个会话取消息')
|
|
338
|
+
a.add_argument('--dry-run', action='store_true', help='只打印将要发送的请求,不发送')
|
|
339
|
+
a.add_argument('--yes', action='store_true', help='确认把卡片发给 Jev(这是出网)')
|
|
340
|
+
a.add_argument('--json', action='store_true',
|
|
341
|
+
help='输出机器可读结果(会话/命中数/取到的消息),供其它程序调用')
|
|
342
|
+
|
|
343
|
+
args = parser.parse_args()
|
|
344
|
+
config = load_config()
|
|
345
|
+
|
|
346
|
+
if args.cmd == 'build':
|
|
347
|
+
data = build_cards(config)
|
|
348
|
+
os.makedirs(os.path.dirname(args.out), exist_ok=True)
|
|
349
|
+
with open(args.out, 'w', encoding='utf-8') as fh:
|
|
350
|
+
json.dump(data, fh, ensure_ascii=False, indent=1)
|
|
351
|
+
print('✓ 卡片索引:%s(%d 个会话,%d 条消息)'
|
|
352
|
+
% (args.out, data['sessions'], data['messages']))
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
if not os.path.isfile(CARDS_PATH):
|
|
356
|
+
raise SystemExit('还没有卡片索引,先跑:python scripts/route_cards.py build')
|
|
357
|
+
with open(CARDS_PATH, encoding='utf-8') as fh:
|
|
358
|
+
data = json.load(fh)
|
|
359
|
+
|
|
360
|
+
terms = ([t.strip() for t in args.keyword.split(',') if t.strip()] if args.keyword
|
|
361
|
+
else candidate_terms(args.question))
|
|
362
|
+
if not terms:
|
|
363
|
+
raise SystemExit('问题里没有可用的中文查询词;用 --keyword 指定')
|
|
364
|
+
|
|
365
|
+
# Jev 只做这一件事:从候选词里挑出真正的查询词。会话排序不走它(实测无区分度)。
|
|
366
|
+
kept, scores, missing = terms, {}, 0
|
|
367
|
+
if args.keyword:
|
|
368
|
+
print('=== 查询词(--keyword 指定,不问 Jev)===')
|
|
369
|
+
else:
|
|
370
|
+
print('=== 第一步:让 Jev 从候选词里挑 ===')
|
|
371
|
+
print(' 候选:%s' % '、'.join(terms))
|
|
372
|
+
if args.dry_run:
|
|
373
|
+
# **预览必须真的预览完就停**。改版时这里漏了 `return`,于是 `--dry-run`
|
|
374
|
+
# 变成"不问了、直接本地检索"——虽然仍不出网(`create_client` 没被调用),
|
|
375
|
+
# 但 docstring 承诺的"打印将要发送的请求"名存实亡。由测试抓回。
|
|
376
|
+
print(' 将发送(不含消息正文):')
|
|
377
|
+
for j, term in enumerate(terms):
|
|
378
|
+
print(' t%d:「%s」是用户在找的那个词吗?' % (j, term))
|
|
379
|
+
print('\n(--dry-run:没有发送任何东西)')
|
|
380
|
+
return
|
|
381
|
+
if not args.yes:
|
|
382
|
+
print(' 需要 --yes 才会发给 Jev(出网,发的是候选词与问题,不含聊天内容)')
|
|
383
|
+
return
|
|
384
|
+
else:
|
|
385
|
+
from jev_client import create_client
|
|
386
|
+
client = create_client(config=config)
|
|
387
|
+
if client is None:
|
|
388
|
+
raise SystemExit('没有 TypeSafe key;改用 --keyword 手动给查询词')
|
|
389
|
+
started = time.time()
|
|
390
|
+
kept, scores, missing = pick_terms(client, args.question, terms)
|
|
391
|
+
if missing:
|
|
392
|
+
# 缺字段是**契约变了**,不是"模型判为否"——混在一起说,会把解析 bug
|
|
393
|
+
# 说成模型判断(这一版之前就把读错字段显示成了"它把词全否了")。
|
|
394
|
+
print(' [WARN] %d 个问题没有返回 noul 字段——契约不符,'
|
|
395
|
+
'不要当成"模型判为否"。' % missing)
|
|
396
|
+
print(' 保留 %d 个(%.1fs):%s' % (
|
|
397
|
+
len(kept), time.time() - started,
|
|
398
|
+
'、'.join('%s(%.2f)' % (t, scores[t]) for t in kept) or '(无)'))
|
|
399
|
+
dropped = [t for t in terms if t not in kept]
|
|
400
|
+
if dropped:
|
|
401
|
+
print(' 否掉:%s' % '、'.join(
|
|
402
|
+
'%s(%s)' % (t, '%.2f' % scores[t] if t in scores else '缺') for t in dropped))
|
|
403
|
+
|
|
404
|
+
use = kept or terms
|
|
405
|
+
if not kept and not args.keyword:
|
|
406
|
+
print('\n 它认为这些候选词都不是你要找的,改用原始候选继续(结果可能不相关)。')
|
|
407
|
+
|
|
408
|
+
hits = count_hits(config, use)
|
|
409
|
+
ranked = rank_sessions(data['cards'], hits, use)
|
|
410
|
+
|
|
411
|
+
# 机器可读出口:给把它做成工具的调用方(助手工具)。文本那份仍然是给人看的。
|
|
412
|
+
if args.json:
|
|
413
|
+
fetched = fetch_messages(config, [c['id'] for c in ranked[:args.json_cards]], use,
|
|
414
|
+
args.per_card) if ranked else {}
|
|
415
|
+
print(json.dumps({
|
|
416
|
+
'success': True,
|
|
417
|
+
'terms': use,
|
|
418
|
+
'ranked': [{'id': c['id'], 'kind': c['kind'], 'label': c['label'],
|
|
419
|
+
'messages': c['messages'], 'lastDaysAgo': c.get('last_days_ago'),
|
|
420
|
+
'hits': {t: hits[t].get(c['id'], 0) for t in use}} for c in ranked],
|
|
421
|
+
'messages': {str(cid): [{'time': ts, 'text': text} for text, ts in rows]
|
|
422
|
+
for cid, rows in fetched.items()},
|
|
423
|
+
}, ensure_ascii=False))
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
print('\n=== 第二步:本地按命中数排序(共 %d 个会话)===' % len(data['cards']))
|
|
427
|
+
if not ranked:
|
|
428
|
+
print(' 一个会话都没有字面命中。换词再试——这条路只匹配字面词,'
|
|
429
|
+
'同义改写要靠 search 的向量那条路。')
|
|
430
|
+
return
|
|
431
|
+
for card in ranked[:10]:
|
|
432
|
+
print(' #%-5s %-3s %-24s %5d 条 最后%s天前 命中 %s' % (
|
|
433
|
+
card['id'], card['kind'], card['label'][:24], card['messages'],
|
|
434
|
+
_fmt_days(card.get('last_days_ago')),
|
|
435
|
+
'、'.join('%s×%d' % (t, hits[t][card['id']]) for t in use
|
|
436
|
+
if hits[t].get(card['id']))))
|
|
437
|
+
|
|
438
|
+
print('\n=== 第三步:在最好的 %d 个会话里取消息 ===' % min(8, len(ranked)))
|
|
439
|
+
hits_by_card = fetch_messages(config, [c['id'] for c in ranked[:8]], use, args.per_card)
|
|
440
|
+
total = 0
|
|
441
|
+
for card in ranked[:8]:
|
|
442
|
+
rows = hits_by_card.get(card['id']) or []
|
|
443
|
+
total += len(rows)
|
|
444
|
+
print(' #%s %s:%d 条' % (card['id'], card['label'][:22], len(rows)))
|
|
445
|
+
for content, ts in rows:
|
|
446
|
+
print(' [%s] %s' % (time.strftime('%m-%d %H:%M', time.localtime(ts)),
|
|
447
|
+
(content or '').replace('\n', ' ')[:60]))
|
|
448
|
+
if not total:
|
|
449
|
+
print(' (一条也没取到——命中数来自 LIKE 统计,取不到通常意味着分表结构变了)')
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
if __name__ == '__main__':
|
|
453
|
+
main()
|