weflow-cli 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1949 -1
  3. package/OPERATIONS.md +774 -1
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +934 -27
  7. package/dist/bin/weflow-cli.js +952 -27
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +19 -2
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +11 -1
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +1 -1
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +5 -2
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/pythonBridge.d.ts +41 -0
  88. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  89. package/dist/src/services/pythonBridge.js +119 -0
  90. package/dist/src/services/pythonBridge.js.map +1 -0
  91. package/dist/src/services/syncService.d.ts +8 -2
  92. package/dist/src/services/syncService.d.ts.map +1 -1
  93. package/dist/src/services/syncService.js +26 -7
  94. package/dist/src/services/syncService.js.map +1 -1
  95. package/dist/src/services/wechatMessageService.d.ts +25 -0
  96. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  97. package/dist/src/services/wechatMessageService.js +69 -7
  98. package/dist/src/services/wechatMessageService.js.map +1 -1
  99. package/dist/src/utils/atomicWrite.d.ts +4 -0
  100. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  101. package/dist/src/utils/atomicWrite.js +60 -0
  102. package/dist/src/utils/atomicWrite.js.map +1 -0
  103. package/dist/src/utils/dateRange.d.ts +15 -0
  104. package/dist/src/utils/dateRange.d.ts.map +1 -1
  105. package/dist/src/utils/dateRange.js +47 -0
  106. package/dist/src/utils/dateRange.js.map +1 -1
  107. package/dist/src/utils/text.d.ts +18 -0
  108. package/dist/src/utils/text.d.ts.map +1 -0
  109. package/dist/src/utils/text.js +20 -0
  110. package/dist/src/utils/text.js.map +1 -0
  111. package/docs/AI_INTERFACE.md +2 -0
  112. package/docs/DECISIONS.md +1424 -0
  113. package/docs/EXTENDING.md +187 -0
  114. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  115. package/docs/MCP.md +23 -4
  116. package/docs/PROJECT_STATE.md +299 -98
  117. package/docs/SETUP.md +22 -0
  118. package/docs/SYNC_CONTRACT.md +31 -3
  119. package/mcp-server/index.ts +681 -657
  120. package/package.json +84 -83
  121. package/resources/panel/ball-position.cjs +153 -0
  122. package/resources/panel/index.html +46 -0
  123. package/resources/panel/main.cjs +447 -0
  124. package/resources/panel/mascot-focus.png +0 -0
  125. package/resources/panel/mascot-happy.png +0 -0
  126. package/resources/panel/mascot-sorry.png +0 -0
  127. package/resources/panel/mascot-tired.png +0 -0
  128. package/resources/panel/mascot.png +0 -0
  129. package/resources/panel/package.json +7 -0
  130. package/resources/panel/panel.css +407 -0
  131. package/resources/panel/preload.cjs +72 -0
  132. package/resources/panel/quick-menu.cjs +197 -0
  133. package/resources/panel/renderer.js +441 -0
  134. package/resources/panel/tray-menu.cjs +40 -0
  135. package/resources/panel/tray.png +0 -0
  136. package/scripts/_utils.py +403 -54
  137. package/scripts/annual_report.py +4 -2
  138. package/scripts/article_notes.py +423 -0
  139. package/scripts/backfill_articles.py +438 -0
  140. package/scripts/biz_daily.py +590 -105
  141. package/scripts/chat_notes.py +470 -0
  142. package/scripts/classify_daily.py +10 -18
  143. package/scripts/compile_wiki.py +1059 -46
  144. package/scripts/create_reading_notes.py +199 -14
  145. package/scripts/decide.py +253 -0
  146. package/scripts/draft_reply.py +671 -0
  147. package/scripts/export_chat_html.py +35 -33
  148. package/scripts/extract_todos.py +31 -5
  149. package/scripts/fav_notes.py +318 -0
  150. package/scripts/fix_topics.py +2 -1
  151. package/scripts/generate_ai_report.py +214 -9
  152. package/scripts/generate_html.py +18 -3
  153. package/scripts/generate_review.py +2 -2
  154. package/scripts/jev_client.py +294 -0
  155. package/scripts/jev_probe.py +440 -0
  156. package/scripts/nt_common.py +82 -0
  157. package/scripts/nt_decrypt.py +1755 -1460
  158. package/scripts/pipeline.py +27 -2
  159. package/scripts/promote_ideas.py +19 -4
  160. package/scripts/quality_eval.py +707 -0
  161. package/scripts/rag_chat.py +4 -4
  162. package/scripts/read_image.py +187 -0
  163. package/scripts/reply_debt.py +488 -0
  164. package/scripts/route_cards.py +453 -0
  165. package/scripts/semantic_search.py +208 -49
  166. package/scripts/user_notes.py +315 -0
  167. package/scripts/vault_rag.py +13 -7
  168. package/scripts/vault_search.py +21 -4
  169. package/scripts/wiki_lint.py +397 -0
  170. package/src/core/appMsgFormat.ts +117 -0
  171. package/src/core/ntCore.ts +27 -2
  172. package/src/core/sqlcipherCore.ts +5 -61
  173. package/src/core/wcdbCore.ts +6 -2
  174. package/src/panel/client.ts +49 -0
  175. package/src/panel/endpoint.ts +144 -0
  176. package/src/panel/launch.ts +101 -0
  177. package/src/panel/server.ts +424 -0
  178. package/src/panel/userId.ts +72 -0
  179. package/src/services/assistantDaemon.ts +57 -9
  180. package/src/services/assistantEval.ts +611 -0
  181. package/src/services/assistantMemory.ts +413 -57
  182. package/src/services/assistantPrivacy.ts +18 -4
  183. package/src/services/assistantRouter.ts +191 -0
  184. package/src/services/assistantService.ts +671 -74
  185. package/src/services/assistantTools.ts +1118 -57
  186. package/src/services/assistantTrace.ts +175 -0
  187. package/src/services/chatService.ts +6 -2
  188. package/src/services/configService.ts +403 -385
  189. package/src/services/pythonBridge.ts +139 -0
  190. package/src/services/syncService.ts +37 -9
  191. package/src/services/wechatMessageService.ts +73 -7
  192. package/src/utils/atomicWrite.ts +56 -0
  193. package/src/utils/dateRange.ts +46 -0
  194. package/src/utils/text.ts +20 -0
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env python3
2
+ """谁在等我回话 —— 给每个会话判一次"欠账"。
3
+
4
+ **这和 `extract_todos.py` 不是同一件事。** 那个脚本扫最多 80 个会话、每会话 50 条,
5
+ 然后把**全部**塞进一次 LLM 调用,问的是"有没有承诺/待办被提到"。它答不了这里要问的
6
+ 问题:**是谁在等**、等了多久、该不该先回他。把 4000 条消息一次性交给模型,
7
+ "是哪个人在等"这件事在输出里根本没有立足之地。
8
+
9
+ 本脚本按会话逐个判:一个会话一份 state,一次请求问 5 个问题,所以每条判断都
10
+ 知道它属于哪个会话。这是决策模型(而非生成模型)才划算的做法——实测一次判断约 1 秒,
11
+ 且**多问几个问题几乎不加成本**(2 个问题 0.84s / 12 个问题 0.91s),
12
+ 所以这 5 个问题基本是白送的。
13
+
14
+ 用法:
15
+
16
+ python scripts/reply_debt.py # 最近 14 天有动静的会话
17
+ python scripts/reply_debt.py --days 30 --limit 40
18
+ python scripts/reply_debt.py --json
19
+
20
+ **它不是事实,是提示。** 判断来自一个决策模型,会有判错的时候,尤其是反讽、玩笑
21
+ 和"对方只是随口一说"。输出里带着概率,就是为了让人自己决定信到哪一档;
22
+ `--min-prob` 可以把它当阈值用。**没有金标准校准过**,这一点不要忘。
23
+ """
24
+ import argparse
25
+ import html
26
+ import json
27
+ import os
28
+ import sys
29
+ import time
30
+ from concurrent.futures import ThreadPoolExecutor, as_completed
31
+ from datetime import datetime, timedelta, timezone
32
+
33
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
+ from _utils import load_config, decrypt_lock # noqa: E402
35
+ from jev_client import JevError, create_client # noqa: E402
36
+
37
+ import nt_decrypt # noqa: E402
38
+
39
+ TZ = timezone(timedelta(hours=8))
40
+ WORKERS = 6 # 与服务保持克制:Jev 新到会返 529(已实测)
41
+ MAX_MSG_CHARS = 120 # 每条消息进 state 前的截断
42
+
43
+ # 消息类型标签。**这不是装饰**:非文本消息在 message_content 里是空的,不给标签
44
+ # 的话 state 里就只剩一行"对方:"后面什么都没有——模型是在空白上给分。
45
+ # 完整的类型处理在 export_chat_html.format_message 那条 if/elif 里,这里只取常见的几种。
46
+ TYPE_LABELS = {1: '文本', 3: '图片', 34: '语音', 42: '名片', 43: '视频',
47
+ 47: '表情', 48: '位置', 49: '链接/文件/小程序',
48
+ 10000: '系统消息', 10002: '系统消息'}
49
+ SUBSTANTIVE_CHARS = 5 # 少于这个字数算不上"实质发言",只用来算证据强度
50
+ TRANSCRIPT_SIZE = 30 # 每个会话取最近多少条
51
+
52
+ # 会话类型:用来把"客服/推销/通知"从欠账里摘出去——它们也在"等回复",
53
+ # 但那不是人情债,混进来会把这份榜单变成噪音。
54
+ KINDS = {
55
+ '闲聊': '寒暄、闲聊、朋友间随口聊',
56
+ '工作': '工作事务、协作、交付',
57
+ '家庭': '家人、亲戚',
58
+ '客户': '客户、合作方、外部对接',
59
+ '群聊': '群里的讨论',
60
+ '服务': '客服、推销、验证码、公众号推送、系统通知',
61
+ }
62
+
63
+
64
+ PAGE_CSS = """
65
+ :root { color-scheme: light dark; }
66
+ * { box-sizing: border-box; }
67
+ body { margin: 0 auto; padding: 40px 24px 64px; max-width: 860px;
68
+ font: 15px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
69
+ color: #1a1a1a; background: #fafafa; }
70
+ h1 { font-size: 21px; margin: 0 0 4px; }
71
+ .sub { color: #777; font-size: 13px; margin-bottom: 28px; }
72
+ .lede { display: flex; gap: 36px; align-items: baseline; margin: 0 0 28px;
73
+ padding: 20px 24px; background: #fff; border: 1px solid #e6e6e6; border-radius: 10px; }
74
+ .lede b { font-size: 30px; font-weight: 650; }
75
+ .lede span { color: #666; font-size: 13px; }
76
+ table { width: 100%; border-collapse: collapse; background: #fff;
77
+ border: 1px solid #e6e6e6; border-radius: 10px; overflow: hidden; }
78
+ th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid #f0f0f0; }
79
+ th { font-size: 12px; font-weight: 600; color: #666; background: #fcfcfc; }
80
+ tr:last-child td { border-bottom: 0; }
81
+ .num { font-variant-numeric: tabular-nums; }
82
+ .thin { color: #b26a00; }
83
+ .muted { color: #888; }
84
+ .note { margin-top: 26px; font-size: 12.5px; color: #777; }
85
+ .note strong { color: #444; }
86
+ """
87
+
88
+
89
+ def render_html(debts, uncertain, noise_count, meta):
90
+ """一份**不含任何聊天内容**的可分享页面。
91
+
92
+ 这是刻意的约束,不是遗漏:只输出概率、天数、类别与证据量,所以它可以给别人看
93
+ 而不泄露对话。名字要转义——联系人的显示名是外部数据,里面完全可能有 `<`。
94
+ """
95
+ def esc(value):
96
+ return html.escape(str(value if value is not None else ''), quote=True)
97
+
98
+ def row(item, dim=False):
99
+ proof = item.get('evidence') or {}
100
+ thin = proof.get('theirLastChars', 0) < SUBSTANTIVE_CHARS
101
+ cls = ' class="muted"' if dim else ''
102
+ return (
103
+ '<tr%s><td>%s</td><td class="num">%.1f 天</td><td>%s</td>'
104
+ '<td class="num">%.2f</td><td class="num">%.2f</td>'
105
+ '<td class="num%s">对方末条 %s 字</td></tr>'
106
+ % (cls, esc(item.get('name')), item.get('days') or 0, esc(item.get('kind') or '?'),
107
+ item.get('waiting') or 0, item.get('urgencyScore') or 0,
108
+ ' thin' if thin else '', proof.get('theirLastChars', '?')))
109
+
110
+ longest = max((item.get('days') or 0 for item in debts), default=0)
111
+ kinds = {}
112
+ for item in debts:
113
+ key = item.get('kind') or '?'
114
+ kinds[key] = kinds.get(key, 0) + 1
115
+ lede = [
116
+ '<div class="lede"><div><b>%d</b> <span>个会话在等我回话</span></div>'
117
+ '<div><b>%.1f</b> <span>最久等了(天)</span></div>'
118
+ '<div><b>%d</b> <span>个拿不太准</span></div></div>'
119
+ % (len(debts), longest, len(uncertain)),
120
+ ]
121
+ header = ('<tr><th>会话</th><th>等了</th><th>类别</th>'
122
+ '<th>在等我</th><th>紧急</th><th>证据</th></tr>')
123
+
124
+ parts = ['<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">',
125
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
126
+ '<title>谁在等我回话</title><style>%s</style></head><body>' % PAGE_CSS,
127
+ '<h1>谁在等我回话</h1>',
128
+ '<div class="sub">最近 %s 天有动静的会话 · 生成于 %s</div>'
129
+ % (esc(meta.get('days')), esc(meta.get('generatedAt')))]
130
+ parts.extend(lede)
131
+ if debts:
132
+ parts.append('<table>%s%s</table>' % (header, ''.join(row(i) for i in debts)))
133
+ else:
134
+ parts.append('<p class="muted">没有判定为欠账的会话(阈值 %.2f)。</p>'
135
+ % (meta.get('minProb') or 0))
136
+ if uncertain:
137
+ parts.append('<h2 style="font-size:15px;margin:30px 0 10px;">拿不太准</h2>')
138
+ parts.append('<table>%s%s</table>'
139
+ % (header, ''.join(row(i, dim=True) for i in uncertain)))
140
+ note = ['<div class="note">',
141
+ '<strong>这不是事实,是提示。</strong>判断来自一个决策模型,没有金标准校准过;'
142
+ '页面里的概率就是让你自己决定信到哪一档;'
143
+ '同一批数据两次跑的分数会有出入,阈值附近的差别不必细究。<br>',
144
+ '「证据」是对方最后一条消息的字数——少于 %d 字的那几条,'
145
+ '分数建立在很薄的证据上,不足为凭。<br>' % SUBSTANTIVE_CHARS]
146
+ if noise_count:
147
+ note.append('另有 %d 个判定为客服/推销/通知类,未计入。' % noise_count)
148
+ note.append('<br><strong>本页不含任何聊天内容</strong>——只有概率、天数与计数,'
149
+ '所以它可以被分享。')
150
+ note.append('</div></body></html>')
151
+ parts.extend(note)
152
+ return ''.join(parts)
153
+
154
+
155
+ def build_questions():
156
+ return {
157
+ 'waiting': {'type': 'noul',
158
+ 'instructions': '这段对话停在了对方在等我回应的位置吗?'
159
+ '也就是最后是对方发来的、并且内容需要我答复,'
160
+ '而不是我刚回过他或他只是随口一说。'},
161
+ 'urgency': {'type': 'score',
162
+ 'instructions': '这件事有多急着回?',
163
+ 'criteria': ['不急,回不回都行',
164
+ '一般,这几天回一下',
165
+ '该回了,再拖不合适',
166
+ '拖不得了,对方在等我给准信']},
167
+ 'commitment': {'type': 'noul',
168
+ 'instructions': '我在这段对话里承诺过什么,而到现在还没有下文?'},
169
+ 'money': {'type': 'noul',
170
+ 'instructions': '涉及金钱、交付物或明确期限这类硬承诺吗?'},
171
+ 'kind': {'type': 'choice',
172
+ 'instructions': '这段对话属于哪一类?',
173
+ 'criteria': KINDS},
174
+ }
175
+
176
+
177
+ def build_state(name, is_group, lines):
178
+ who = '群聊' if is_group else '单聊'
179
+ return ('会话:%s(%s)\n\n最近的对话(时间从早到晚):\n%s'
180
+ % (name, who, '\n'.join(lines)))
181
+
182
+
183
+ def format_line(message, max_chars=MAX_MSG_CHARS):
184
+ """转录里的一行:`[09-23 12:20] 老王:那个文件你什么时候发我`。
185
+
186
+ **这段形状 TS 侧还有一份**(`assistantTools.ts` 的 `transcriptLine`,面板/微信那条路
187
+ 用 `--stdin` 把渲染好的行交进来)。两处必须逐字一致——它们**已经漂过**(时刻格式、
188
+ 对方那句用「对方」还是人名、截断 120 还是 160),所以有
189
+ `test/transcript-format-contract.test.ts` 拿同一批消息把两边逐字比一遍。
190
+
191
+ `max_chars` 是个参数,因为两条路要的不一样:欠账雷达按 120 字截(它自己 state 的大小),
192
+ 而起草那条路要 160——与 TS 侧 `TRANSCRIPT_MSG_CHARS` 对齐,否则同一句话在面板里和
193
+ 命令行里长短不一,判断会被喂进不同的东西。
194
+ """
195
+ when = datetime.fromtimestamp(message.get('createTime') or 0, tz=TZ).strftime('%m-%d %H:%M')
196
+ # **`senderUsername` 不参与**:它是消息表里的 wxid 原列,不是名字(`nt_decrypt` 另给了
197
+ # 解析好的 `senderDisplay`)。拿它当标签等于把账号标识发到云端模型,而这一行只需要
198
+ # 让模型分得清谁说的。所以:有名字用名字,没有就写「对方」。
199
+ speaker = '我' if message.get('isSend') else (message.get('senderDisplay') or '对方')
200
+ text = (message.get('parsedContent') or message.get('content') or '').strip()
201
+ text = text.replace(chr(10), ' ')
202
+ # 截断要**留个记号**:光切掉的话,被切在半句上的话看起来像说完了,模型会照着半句话作答。
203
+ # TS 侧 `clipWithMarker` 就是这个形状(`…`),两边保持一致也是契约测试在盯的事之一。
204
+ if len(text) > max_chars:
205
+ text = text[:max_chars] + '…'
206
+ if not text:
207
+ label = TYPE_LABELS.get(message.get('localType'))
208
+ text = '[%s]' % label if label else '[非文本 localType=%s]' % message.get('localType')
209
+ return '[%s] %s:%s' % (when, speaker, text)
210
+
211
+
212
+ def evidence(messages):
213
+ """这条判断建立在多少证据上。
214
+
215
+ "在等我 0.69" 如果是靠对方最后那条 **2 个字**得出的,它和靠一段完整说明得出的
216
+ 0.69 完全不是一回事。模型看不到这个区别(它只看到文本),所以由输出告诉人。
217
+ """
218
+ theirs = [m for m in messages if not m.get('isSend')]
219
+ last = theirs[-1] if theirs else {}
220
+ last_text = (last.get('parsedContent') or last.get('content') or '').strip()
221
+ substantive = sum(
222
+ 1 for m in theirs
223
+ if len((m.get('parsedContent') or m.get('content') or '').strip()) >= SUBSTANTIVE_CHARS)
224
+ return {'theirLastChars': len(last_text), 'theirSubstantive': substantive,
225
+ 'theirCount': len(theirs)}
226
+
227
+
228
+ def self_check(verdict, lines):
229
+ """判定说"对方在等我",可最后一条明明是我发的——两者必有一个错。
230
+
231
+ 这是这份输出里唯一能**机械证伪**的一类判断("他是不是在等我"没法自动核验),
232
+ 所以宁可啰嗦也要报出来:一个自相矛盾的数字不该被当成线索用。
233
+ """
234
+ if not lines or (verdict.get('waiting') or 0) < 0.5:
235
+ return None
236
+ if lines[-1].startswith('[') and '] 我:' in lines[-1]:
237
+ return '最后一条是我发的,却判成"在等我"'
238
+ return None
239
+
240
+
241
+ def decide_one(client, name, is_group, lines):
242
+ """一个会话一次请求。返回 dict,或 None(问不出来)。"""
243
+ from jev_client import score_to_relevance # 复用同一套零索引映射
244
+ try:
245
+ answers, usage = client.decide(build_state(name, is_group, lines),
246
+ build_questions())
247
+ except Exception as error:
248
+ print(' [WARN] %s 判断失败(%s):%s' % (name, type(error).__name__, error))
249
+ return None
250
+
251
+ def noul(key):
252
+ value = (answers.get(key) or {}).get('noul')
253
+ try:
254
+ return float(value)
255
+ except (TypeError, ValueError):
256
+ return None
257
+
258
+ urgency = answers.get('urgency') or {}
259
+ kind = (answers.get('kind') or {}).get('choice')
260
+ return {
261
+ 'name': name,
262
+ 'waiting': noul('waiting'),
263
+ 'commitment': noul('commitment'),
264
+ 'money': noul('money'),
265
+ 'urgencyScore': urgency.get('score'),
266
+ 'kind': kind if kind in KINDS else None,
267
+ 'usage': usage,
268
+ }
269
+
270
+
271
+ def collect_conversations(conns, name_map, own_wxid, days, limit):
272
+ """最近有动静的会话,按最后一条消息倒序。"""
273
+ sessions = nt_decrypt.get_sessions(conns).get('sessions') or []
274
+ cutoff = int((datetime.now(TZ) - timedelta(days=days)).timestamp())
275
+ picked = []
276
+ for session in sessions:
277
+ talker = session.get('username')
278
+ # 字段名是 lastTimestamp(get_sessions 自己就已经解析好了显示名)。
279
+ last = session.get('lastTimestamp') or session.get('last_time') or 0
280
+ if not talker or last < cutoff:
281
+ continue
282
+ picked.append({'talker': talker, 'last': last,
283
+ 'unread': session.get('unreadCount') or 0,
284
+ # get_sessions 不接受 name_map,所以 displayName 往往就是
285
+ # wxid 本身;用在联系人库里解析出来的名字优先。
286
+ 'name': name_map.get(talker) or session.get('displayName') or talker})
287
+ picked.sort(key=lambda item: -item['last'])
288
+ return picked[:limit] if limit else picked
289
+
290
+
291
+ def main():
292
+ parser = argparse.ArgumentParser(description='谁在等我回话(用决策模型逐会话判断)')
293
+ parser.add_argument('--days', type=int, default=14, help='只看最近多少天有动静的会话')
294
+ parser.add_argument('--limit', type=int, default=40, help='最多判多少个会话')
295
+ parser.add_argument('--min-prob', type=float, default=0.5,
296
+ help='waiting 概率低于此值就不算欠账(默认 0.5)')
297
+ parser.add_argument('--html', metavar='PATH',
298
+ help='额外写一份不含聊天内容的单页 HTML(可分享)')
299
+ parser.add_argument('--dry-run', action='store_true',
300
+ help='只列出会判哪些会话、要发多少字符,不调用决策模型')
301
+ parser.add_argument('--json', action='store_true')
302
+ args = parser.parse_args()
303
+
304
+ config = load_config()
305
+ db = config.get('ntDbPath', '')
306
+ if not db:
307
+ print('配置里没有 ntDbPath,先运行 weflow-cli init', file=sys.stderr)
308
+ return 2
309
+ key = decrypt_lock(config.get('ntKey', ''))
310
+ salt = config.get('ntSalt', '')
311
+ passphrase = decrypt_lock(config.get('favPassphrase') or config.get('decryptKey') or '')
312
+ own_wxid = config.get('wxid', '')
313
+
314
+ conns = nt_decrypt.connect_message_shards(db, key, salt, passphrase)
315
+ if not conns:
316
+ print('无法打开消息数据库,请检查密钥(weflow-cli check)', file=sys.stderr)
317
+ return 2
318
+
319
+ contact_db = nt_decrypt.find_contact_db_path(db)
320
+ name_map = {}
321
+ if contact_db:
322
+ name_map = nt_decrypt.load_contact_names(
323
+ contact_db,
324
+ decrypt_lock(config.get('contactKey', '')),
325
+ config.get('contactSalt', ''))
326
+
327
+ try:
328
+ picked = collect_conversations(conns, name_map, own_wxid, args.days, args.limit)
329
+ if not picked:
330
+ print('最近 %d 天没有活跃会话' % args.days)
331
+ return 0
332
+ print('扫描 %d 个最近 %d 天有动静的会话…\n' % (len(picked), args.days))
333
+
334
+ # 每个会话的最近若干条消息,一次读完(连接只开一次)
335
+ transcripts = {}
336
+ owed = {}
337
+ proofs = {}
338
+ for item in picked:
339
+ result = nt_decrypt.get_messages(conns, item['talker'], TRANSCRIPT_SIZE,
340
+ name_map=name_map, own_wxid=own_wxid)
341
+ messages = result.get('messages') or []
342
+ if not messages:
343
+ continue
344
+ transcripts[item['talker']] = [format_line(m) for m in reversed(messages)]
345
+ proofs[item['talker']] = evidence(list(reversed(messages)))
346
+ # 对方最后一次说话的时间——欠账天数的基准。
347
+ theirs = [m.get('createTime') or 0 for m in messages if not m.get('isSend')]
348
+ owed[item['talker']] = max(theirs) if theirs else 0
349
+
350
+ if args.dry_run:
351
+ # 只读本地、零出境。列出要判什么、要发多少字符——不读一遍就报不出这个数,
352
+ # 而读数本来也不出境。真正的判断调用在下面。
353
+ total = sum(len(chr(10).join(lines)) for lines in transcripts.values())
354
+ preview = {'success': True, 'dryRun': True, 'action': 'reply-debt.scan',
355
+ 'days': args.days, 'conversations': len(transcripts),
356
+ 'stateChars': total, 'readsLocalChat': True,
357
+ 'invokesAI': True, 'writesNothing': True}
358
+ if args.json:
359
+ print(json.dumps(preview, ensure_ascii=False))
360
+ else:
361
+ print('预览:将把 %d 个会话、约 %d 字符的聊天正文发给决策模型'
362
+ % (len(transcripts), total))
363
+ print(' (api.typesafe.ai)。只读,不写任何本地文件。')
364
+ return 0
365
+
366
+ # key 检查放在预览**之后**:预览要回答的正是"值不值得跑",
367
+ # 拿 key 当它的前置条件,等于让人先掏钱再看菜单。
368
+ client = create_client()
369
+ if client is None:
370
+ print('缺少 TypeSafe key。这个命令的全部价值就在那次判断调用上,'
371
+ '没有 key 就没有可降级的行为:' + chr(10) +
372
+ ' weflow-cli config set typesafeApiKey "..."', file=sys.stderr)
373
+ return 2
374
+
375
+ started = time.time()
376
+ rows = []
377
+ with ThreadPoolExecutor(max_workers=WORKERS) as pool:
378
+ futures = {pool.submit(decide_one, client, item['name'],
379
+ '@chatroom' in item['talker'],
380
+ transcripts.get(item['talker'], [])): item
381
+ for item in picked if transcripts.get(item['talker'])}
382
+ for future in as_completed(futures):
383
+ verdict = future.result()
384
+ if verdict:
385
+ item = futures[future]
386
+ verdict['last'] = item['last']
387
+ verdict['owed_since'] = owed.get(item['talker']) or item['last']
388
+ verdict['selfCheck'] = self_check(
389
+ verdict, transcripts.get(item['talker'], []))
390
+ verdict['evidence'] = proofs.get(item['talker'], {})
391
+ rows.append(verdict)
392
+ print('判断完成 %d/%d,耗时 %.1fs\n' % (len(rows), len(futures), time.time() - started))
393
+ finally:
394
+ for conn in conns:
395
+ conn.close()
396
+
397
+ now = datetime.now(TZ)
398
+ for row in rows:
399
+ # 欠账天数必须从**对方最后一条**算起,而不是会话最后活动时间:
400
+ # 如果我最后回过他,会话时间是"我"的消息时间,算出来永远是 0 天——
401
+ # 那恰好把最该看见的情况(对方说完就没下文了)抹平。对方没发过就是 0。
402
+ owed_since = row.pop('owed_since', None) or row['last']
403
+ row['days'] = round((now - datetime.fromtimestamp(owed_since, tz=TZ)).total_seconds()
404
+ / 86400, 1)
405
+ # 排序键:概率为主,紧急度为辅;金额与承诺只用来标注,不参与排序,
406
+ # 因为那会让"涉及钱"压过"确实在等我"。
407
+ row['rank'] = (row['waiting'] or 0) * 2 + (row['urgencyScore'] or 0) / 4
408
+ rows.sort(key=lambda item: -item['rank'])
409
+
410
+ # 服务号/推销也在"等回复",但那不是人情债;把它们从榜单里摘掉,
411
+ # 但保留计数说明摘了多少,免得看起来像是没扫到。
412
+ debts = [r for r in rows if (r['waiting'] or 0) >= args.min_prob and r['kind'] != '服务']
413
+ noise = [r for r in rows if r['kind'] == '服务']
414
+ uncertain = [r for r in rows if 0.3 <= (r['waiting'] or 0) < args.min_prob]
415
+
416
+ if args.html:
417
+ # 名字是外部数据,转义由 render_html 负责;这里只保证目录存在。
418
+ target = os.path.abspath(args.html)
419
+ parent = os.path.dirname(target)
420
+ if parent:
421
+ os.makedirs(parent, exist_ok=True)
422
+ with open(target, 'w', encoding='utf-8') as handle:
423
+ handle.write(render_html(debts, uncertain, len(noise), {
424
+ 'days': args.days, 'minProb': args.min_prob,
425
+ 'generatedAt': now.strftime('%Y-%m-%d %H:%M'),
426
+ }))
427
+ print('已写出:%s(不含聊天内容,可直接分享)' % target)
428
+
429
+ if args.json:
430
+ print(json.dumps({'debts': debts, 'excluded_service': len(noise),
431
+ 'uncertain': uncertain}, ensure_ascii=False, indent=2))
432
+ return 0
433
+
434
+ print('=' * 62)
435
+ if not debts:
436
+ print('在等你回话的会话:0 个(阈值 %.2f)' % args.min_prob)
437
+ else:
438
+ longest = max(item['days'] for item in debts)
439
+ kinds = {}
440
+ for item in debts:
441
+ kinds[item['kind']] = kinds.get(item['kind'], 0) + 1
442
+ print('在等你回话:**%d 个会话**,最久的等了 **%.0f 天**'
443
+ % (len(debts), longest))
444
+ print(' 构成:' + ' · '.join('%s %d' % (k, v) for k, v in
445
+ sorted(kinds.items(), key=lambda kv: -kv[1])))
446
+ hard = [i for i in debts if (i['money'] or 0) >= 0.5 or (i['commitment'] or 0) >= 0.5]
447
+ if hard:
448
+ print(' 其中 %d 个涉及明确承诺或涉及钱' % len(hard))
449
+ print('=' * 62)
450
+
451
+ for item in debts:
452
+ flags = []
453
+ if (item['commitment'] or 0) >= 0.5:
454
+ flags.append('承诺未兑现')
455
+ if (item['money'] or 0) >= 0.5:
456
+ flags.append('涉及钱/交付')
457
+ proof = item.get('evidence') or {}
458
+ print(chr(10) + '%-22s 等了 %4.1f 天 类别 %s 在等我 %.2f 紧急 %.2f'
459
+ % (item['name'][:22], item['days'], item['kind'] or '?',
460
+ item['waiting'] or 0, item['urgencyScore'] or 0))
461
+ thin = proof.get('theirLastChars', 0) < SUBSTANTIVE_CHARS
462
+ print(' 证据:对方末条 %s 字 · 对方实质发言 %s 条%s'
463
+ % (proof.get('theirLastChars', '?'), proof.get('theirSubstantive', '?'),
464
+ ' ← 证据很薄,这个分数不可当结论' if thin else ''))
465
+ if flags:
466
+ print(' ' + ' · '.join(flags))
467
+
468
+ contradictory = [r for r in debts + uncertain if r.get('selfCheck')]
469
+ if contradictory:
470
+ print(chr(10) + '-' * 62 + chr(10) +
471
+ '自检报出 %d 条自相矛盾的判断(按定义不该采信):' % len(contradictory))
472
+ for item in contradictory:
473
+ print(' %-22s 在等我 %.2f %s'
474
+ % (item['name'][:22], item['waiting'] or 0, item['selfCheck']))
475
+
476
+ if uncertain:
477
+ print('\n%s\n另外 %d 个拿不太准(waiting 在 0.3~%.2f 之间),'
478
+ '可能是玩笑或随口一说:' % ('-' * 62, len(uncertain), args.min_prob))
479
+ for item in uncertain:
480
+ print(' %-22s %.2f %s' % (item['name'][:22], item['waiting'], item['kind'] or '?'))
481
+ if noise:
482
+ print('\n另有 %d 个判定为客服/推销/通知类,未计入欠账。' % len(noise))
483
+ print('\n(判断由决策模型给出,**没有金标准校准过**,当提示看不当事实用。)')
484
+ return 0
485
+
486
+
487
+ if __name__ == '__main__':
488
+ sys.exit(main())