weflow-cli 1.8.1 → 1.9.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 (100) hide show
  1. package/CHANGELOG.md +359 -0
  2. package/OPERATIONS.md +79 -0
  3. package/README.en.md +9 -2
  4. package/README.md +12 -2
  5. package/bin/weflow-cli-electron.cjs +2 -0
  6. package/bin/weflow-cli.ts +648 -79
  7. package/dist/bin/weflow-cli.js +675 -78
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/http.d.ts +14 -0
  10. package/dist/mcp-server/http.d.ts.map +1 -0
  11. package/dist/mcp-server/http.js +84 -0
  12. package/dist/mcp-server/http.js.map +1 -0
  13. package/dist/mcp-server/httpConfig.d.ts +50 -0
  14. package/dist/mcp-server/httpConfig.d.ts.map +1 -0
  15. package/dist/mcp-server/httpConfig.js +104 -0
  16. package/dist/mcp-server/httpConfig.js.map +1 -0
  17. package/dist/mcp-server/index.d.ts +13 -1
  18. package/dist/mcp-server/index.d.ts.map +1 -1
  19. package/dist/mcp-server/index.js +39 -373
  20. package/dist/mcp-server/index.js.map +1 -1
  21. package/dist/src/panel/server.d.ts.map +1 -1
  22. package/dist/src/panel/server.js +26 -1
  23. package/dist/src/panel/server.js.map +1 -1
  24. package/dist/src/services/assistantEval.d.ts +69 -0
  25. package/dist/src/services/assistantEval.d.ts.map +1 -1
  26. package/dist/src/services/assistantEval.js +270 -8
  27. package/dist/src/services/assistantEval.js.map +1 -1
  28. package/dist/src/services/assistantScenes.d.ts +81 -0
  29. package/dist/src/services/assistantScenes.d.ts.map +1 -0
  30. package/dist/src/services/assistantScenes.js +322 -0
  31. package/dist/src/services/assistantScenes.js.map +1 -0
  32. package/dist/src/services/assistantService.d.ts +14 -4
  33. package/dist/src/services/assistantService.d.ts.map +1 -1
  34. package/dist/src/services/assistantService.js +82 -6
  35. package/dist/src/services/assistantService.js.map +1 -1
  36. package/dist/src/services/assistantSkills.d.ts +94 -0
  37. package/dist/src/services/assistantSkills.d.ts.map +1 -0
  38. package/dist/src/services/assistantSkills.js +330 -0
  39. package/dist/src/services/assistantSkills.js.map +1 -0
  40. package/dist/src/services/assistantTools.d.ts +7 -0
  41. package/dist/src/services/assistantTools.d.ts.map +1 -1
  42. package/dist/src/services/assistantTools.js +770 -61
  43. package/dist/src/services/assistantTools.js.map +1 -1
  44. package/dist/src/services/assistantTrace.d.ts +4 -0
  45. package/dist/src/services/assistantTrace.d.ts.map +1 -1
  46. package/dist/src/services/assistantTrace.js +9 -0
  47. package/dist/src/services/assistantTrace.js.map +1 -1
  48. package/dist/src/services/configService.d.ts +7 -0
  49. package/dist/src/services/configService.d.ts.map +1 -1
  50. package/dist/src/services/configService.js +4 -2
  51. package/dist/src/services/configService.js.map +1 -1
  52. package/dist/src/services/outboundSend.d.ts +53 -0
  53. package/dist/src/services/outboundSend.d.ts.map +1 -0
  54. package/dist/src/services/outboundSend.js +90 -0
  55. package/dist/src/services/outboundSend.js.map +1 -0
  56. package/dist/src/services/pythonBridge.d.ts.map +1 -1
  57. package/dist/src/services/pythonBridge.js +1 -0
  58. package/dist/src/services/pythonBridge.js.map +1 -1
  59. package/dist/src/utils/python.js +2 -2
  60. package/dist/src/utils/python.js.map +1 -1
  61. package/docs/BRANCHES.md +37 -0
  62. package/docs/DECISIONS.md +350 -0
  63. package/docs/EXTENDING.md +29 -3
  64. package/docs/MCP.md +56 -2
  65. package/docs/PROJECT_STATE.md +121 -9
  66. package/mcp-server/http.ts +108 -0
  67. package/mcp-server/httpConfig.ts +126 -0
  68. package/mcp-server/index.ts +344 -681
  69. package/package.json +1 -1
  70. package/resources/panel/ball-position.cjs +48 -2
  71. package/resources/panel/index.html +6 -4
  72. package/resources/panel/main.cjs +137 -5
  73. package/resources/panel/mascot-base.png +0 -0
  74. package/resources/panel/mascot-iris.png +0 -0
  75. package/resources/panel/mascot-peek.png +0 -0
  76. package/resources/panel/panel.css +107 -63
  77. package/resources/panel/preload.cjs +26 -0
  78. package/resources/panel/renderer.js +167 -10
  79. package/scripts/_utils.py +47 -1
  80. package/scripts/backfill_articles.py +202 -21
  81. package/scripts/biz_daily.py +8 -31
  82. package/scripts/bonds.py +331 -0
  83. package/scripts/chat_notes.py +551 -470
  84. package/scripts/chat_stats.py +8 -7
  85. package/scripts/compile_wiki.py +24 -2
  86. package/scripts/export_chat_html.py +3 -1
  87. package/scripts/mcp_bridge.py +6 -7
  88. package/scripts/wechat_voice.py +9 -0
  89. package/scripts/wiki_lint.py +30 -7
  90. package/src/panel/server.ts +25 -1
  91. package/src/services/assistantEval.ts +323 -12
  92. package/src/services/assistantScenes.ts +342 -0
  93. package/src/services/assistantService.ts +81 -6
  94. package/src/services/assistantSkills.ts +364 -0
  95. package/src/services/assistantTools.ts +738 -59
  96. package/src/services/assistantTrace.ts +9 -0
  97. package/src/services/configService.ts +412 -403
  98. package/src/services/outboundSend.ts +126 -0
  99. package/src/services/pythonBridge.ts +1 -0
  100. package/src/utils/python.ts +2 -2
@@ -473,37 +473,14 @@ def get_db_keys(config):
473
473
  contact_salt = config.get('contactSalt', '')
474
474
  contact_key = decrypt_lock(contact_key_enc) if contact_key_enc else ''
475
475
 
476
- # biz_message_0.db (每库独立密钥)
477
- biz_db = os.path.join(msg_dir, 'biz_message_0.db')
478
- biz_key_enc = config.get('bizKey', '')
479
- biz_salt = config.get('bizSalt', '')
480
- pass_enc = config.get('favPassphrase', '')
481
- passphrase = decrypt_lock(pass_enc) if pass_enc else ''
482
- if passphrase and os.path.exists(biz_db):
483
- with open(biz_db, 'rb') as fh:
484
- biz_salt = fh.read(16).hex()
485
- biz_key_enc = ''
486
- if biz_key_enc and biz_salt:
487
- biz_key = decrypt_lock(biz_key_enc)
488
- else:
489
- # 从全库共用 passphrase 派生 (微信 4.x: PBKDF2-HMAC-SHA512, 256000 轮, 32 字节)
490
- pass_enc = config.get('favPassphrase', '')
491
- passphrase = decrypt_lock(pass_enc) if pass_enc else ''
492
- biz_key = ''
493
- if passphrase and os.path.exists(biz_db):
494
- with open(biz_db, 'rb') as fh:
495
- biz_salt = fh.read(16).hex()
496
- biz_key = hashlib.pbkdf2_hmac(
497
- 'sha512', bytes.fromhex(passphrase),
498
- bytes.fromhex(biz_salt), 256000, dklen=32
499
- ).hex()
500
- if not biz_key:
501
- # 只指向 passphrase 一条路: 早先还建议 `config set bizKey`, 但该键不在
502
- # CLI 的可写白名单里, 照做必然报 INVALID_CONFIG_KEY。
503
- raise SystemExit(
504
- '缺少公众号数据库密钥: 请运行 weflow-cli fav set-key --passphrase <64位hex> '
505
- '配置全库 passphrase (自动派生各库密钥)'
506
- )
476
+ # biz_message_0.db(订阅号库):钥匙派生统一在 `_utils.biz_message_db()`。
477
+ # 这块原来自己算了两遍(先按 bizKey/bizSalt,又被下面按 passphrase 派生的一遍覆盖),
478
+ # 两遍并存就是分叉留下的痕迹 —— 见那个函数的注释。
479
+ from _utils import biz_message_db
480
+ try:
481
+ biz_db, biz_key, biz_salt = biz_message_db(config)
482
+ except RuntimeError as exc:
483
+ raise SystemExit(str(exc))
507
484
 
508
485
  return {
509
486
  'biz_db': biz_db, 'biz_key': biz_key, 'biz_salt': biz_salt,
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env python3
2
+ """关系温度 — 谁在冷却、谁在变热、以及"只有微信这一条线"的人。
3
+
4
+ 不是在排"我最常跟谁聊"(那个排行榜没有信息量),而是看**形状**:
5
+ · 历史很厚但正在静默 —— 一年里聊了几百条,最近几个月一句话没有;
6
+ · 最近突然变热 —— 近 7 天的量是平时日均的几倍;
7
+ · 只有微信这一条线 —— 整段对话里从没出现过电话/邮箱/约见面。
8
+
9
+ ## 为什么"不可替代"要用两条硬判据,而不是感觉
10
+
11
+ 第一版我拿"对话里没出现过其它联系方式"当不可替代的判据,结果前几名全是
12
+ **星巴克小助手、天天神券福利君、串掌门(南村店)、文件传输助手** —— 服务号。
13
+ 原因很直白:机器人**必然**满足这个判据,它没有别的渠道可提。方向正好反了。
14
+
15
+ 所以现在先按联系人库里的**硬标记**把非人剔除:
16
+ · `username` 以 `@openim` 结尾(企业微信/服务号那类);
17
+ · `username == 'filehelper'`;
18
+ · `username` 以 `gh_` 开头(公众号);
19
+ · `local_type != 1`(不是好友)。
20
+ 真人都是 `wxid_*`;另外**互换过微信号**(`alias` 非空)本身就是关系的硬信号。
21
+
22
+ ## 边界
23
+
24
+ 只读本地库,不联网、不调用模型、不发送任何东西,也不生成"该说什么"——
25
+ 后者是 `draft_reply` 的活儿。它给的是**一份可以每周扫一眼的名单**。
26
+
27
+ 口径与 `chat_lines`/`chat_notes` 一致:只算**文本**消息(`localType == 1`),
28
+ 因为图片/表情/转账的条数比的是习惯,不是关系。
29
+ """
30
+ import argparse
31
+ import json
32
+ import os
33
+ import sys
34
+ from datetime import datetime, timezone, timedelta
35
+
36
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
37
+
38
+ import nt_decrypt # noqa: E402
39
+ import sqlcipher3.dbapi2 as sqlcipher # noqa: E402
40
+ from _utils import load_config, decrypt_lock # noqa: E402
41
+ from reply_debt import collect_conversations # noqa: E402
42
+ from chat_notes import fetch_all_messages # noqa: E402
43
+
44
+ TZ = timezone(timedelta(hours=8))
45
+ TYPE_TEXT = 1
46
+ DEFAULT_DAYS = 365
47
+ DEFAULT_LIMIT = 400
48
+ # 少于这么多条文本的会话不进榜:两三句话说明不了关系,只会带来噪声
49
+ MIN_MESSAGES = 20
50
+ # 静默多久算"冷却"。按"会不会让你意外"定,不是按统计:一个月没说话在多数关系里正常,
51
+ # 三个月就值得看一眼了。
52
+ COOL_DAYS = 60
53
+ # 近 7 天比平时日均快这么多倍才算"变热"
54
+ HOT_RATIO = 2.0
55
+ # 真人常用的一批词,用来判"还有没有别的通道"
56
+ OTHER_CHANNEL = ('打电话', '手机号', '电话号', '邮箱', '邮件', '短信', '见个面',
57
+ '见面', '约个饭', '聚餐', '发你邮箱')
58
+
59
+ # 名字里带这些的,**只标出来、不自动排除**。
60
+ #
61
+ # 为什么只标不排:实测有个「普华口腔门诊部」的 id 是 `wxid_v5q0oc8eb04712`、`local_type=1`、
62
+ # 还有自定义微信号 —— 它**和真人长得一模一样**,结构上没有任何标记能区分(`@openim` 那种是另一类)。
63
+ # 所以"谁算人"这一手,工具摆出可疑的、由人决定;名单在 `output/bonds-skip.txt` 或 `--skip` 里。
64
+ SERVICE_NAME_HINTS = ('门诊', '口腔', '医院', '客服', '小助手', '福利', '旗舰店', '专营店',
65
+ '专卖店', '营业厅', '有限公司', '学社', '助理', 'Bot', 'bot')
66
+ # 自定名单的默认位置(一行一个名字或 id,`#` 开头是注释)
67
+ SKIP_FILE = os.path.join('output', 'bonds-skip.txt')
68
+
69
+
70
+ def is_human(username, local_type, alias):
71
+ """这条会话通往一个**人**吗。
72
+
73
+ 判据是 **id 的形状**,不是备注名,也不是 `flag`:
74
+
75
+ - 带 `@` 的一律不是人:`@chatroom` 是群、`@openim` 是服务号、`@weclaw` 是机器人
76
+ (实测 `微信ClawBot` 就是这个后缀 —— 只挡 `@openim` 会漏它);
77
+ - `filehelper`(文件传输助手)与 `gh_*`(公众号)也都不是。
78
+
79
+ 为什么**不用 `flag`**:它看着更硬(真朋友大多 `flag=3`),但实测 38 个人里有三个不是 3 ——
80
+ `微信ClawBot`(2049, 机器人)、`彪弟`(2051)、`平凡的世界`(2563)。后两个是**真人**,
81
+ 多出来的位来自"当初怎么加上的"。拿 `flag == 3` 当判据会一刀砍掉两个朋友,
82
+ 而且砍得无声无息。
83
+ """
84
+ if not username:
85
+ return False
86
+ if '@' in username or username == 'filehelper' or username.startswith('gh_'):
87
+ return False
88
+ # 不在联系人库里的(0)放行:那是"读不到那一侧",不是"不是人"。
89
+ return local_type in (0, 1)
90
+
91
+
92
+ def load_contact_meta(db):
93
+ """`username -> (local_type, alias)`。读不了一律返回空表(调用方据此少判一手)。"""
94
+ path = nt_decrypt.find_contact_db_path(db)
95
+ if not path:
96
+ return {}
97
+ try:
98
+ conn = sqlcipher.connect(path)
99
+ cursor = conn.cursor()
100
+ cursor.execute('PRAGMA key = "x' + chr(39) + decrypt_lock(load_config().get('contactKey', ''))
101
+ + load_config().get('contactSalt', '') + chr(39) + '";')
102
+ meta = {u: (lt, al or '') for u, lt, al in
103
+ cursor.execute('SELECT username, local_type, alias FROM contact').fetchall()}
104
+ conn.close()
105
+ return meta
106
+ except Exception as error:
107
+ print('联系人库读不了(%s):只能用会话列表判断' % str(error)[:60], file=sys.stderr)
108
+ return {}
109
+
110
+
111
+ def load_skip(path=None, extra=()):
112
+ """自定名单:一行一个名字或 id,`#` 开头是注释。读不了就当空表(少排除比多排除安全)。"""
113
+ names = {str(x).strip() for x in extra if str(x).strip()}
114
+ target = path or SKIP_FILE
115
+ try:
116
+ with open(target, encoding='utf-8') as handle:
117
+ for line in handle:
118
+ line = line.strip()
119
+ if line and not line.startswith('#'):
120
+ names.add(line)
121
+ except OSError:
122
+ pass
123
+ return names
124
+
125
+
126
+ def looks_like_service(name):
127
+ """名字像不像服务号/门店。**只是标记**,排不排由人定(见 SERVICE_NAME_HINTS)。"""
128
+ return any(hint in (name or '') for hint in SERVICE_NAME_HINTS)
129
+
130
+
131
+ def shape_of(messages, now):
132
+ """一段对话的形状。`messages` 只含文本消息。"""
133
+ stamps = sorted(m.get('createTime') or 0 for m in messages)
134
+ days = {datetime.fromtimestamp(t, TZ).date() for t in stamps}
135
+ last = max(stamps) if stamps else 0
136
+ silent_days = (now - last) / 86400 if last else 0
137
+ span_days = max(1, ((last - min(stamps)) / 86400)) if len(stamps) > 1 else 1
138
+ per_day = len(stamps) / span_days # 平时日均
139
+ recent = sum(1 for t in stamps if (now - t) / 86400 <= 7)
140
+ return {
141
+ 'messages': len(stamps),
142
+ 'activeDays': len(days),
143
+ 'silentDays': round(silent_days, 1),
144
+ 'perDay': round(per_day, 2),
145
+ 'recent7': recent,
146
+ 'mine': round(100.0 * sum(1 for m in messages if m.get('isSend')) / max(1, len(stamps))),
147
+ 'last': datetime.fromtimestamp(last, TZ).strftime('%Y-%m-%d') if last else '',
148
+ }
149
+
150
+
151
+ def classify(shape, other_channel):
152
+ """这条关系现在是什么状态。四条判据都是**可复现**的阈值,不靠感觉。"""
153
+ tags = []
154
+ if shape['silentDays'] >= COOL_DAYS and shape['messages'] >= MIN_MESSAGES * 5:
155
+ tags.append('cooling') # 历史厚 + 静默久
156
+ if shape['recent7'] >= 10 and shape['perDay'] > 0 and shape['recent7'] / 7 >= shape['perDay'] * HOT_RATIO:
157
+ tags.append('heating')
158
+ if not other_channel:
159
+ tags.append('only-wechat') # 整段历史里没出现过别的渠道
160
+ if 0 < shape['silentDays'] <= 7:
161
+ tags.append('active')
162
+ return tags
163
+
164
+
165
+ def collect(days=DEFAULT_DAYS, limit=DEFAULT_LIMIT, skip=()):
166
+ """算一份名单。**不联网、不调模型、不写文件。**"""
167
+ config = load_config()
168
+ db = config.get('ntDbPath', '')
169
+ if not db:
170
+ return None, 'no-db'
171
+ conns = nt_decrypt.connect_message_shards(
172
+ db, decrypt_lock(config.get('ntKey', '')), config.get('ntSalt', ''),
173
+ decrypt_lock(config.get('favPassphrase') or config.get('decryptKey') or ''))
174
+ if not conns:
175
+ return None, 'no-conn'
176
+ try:
177
+ name_map = {}
178
+ contact_db = nt_decrypt.find_contact_db_path(db)
179
+ if contact_db:
180
+ name_map = nt_decrypt.load_contact_names(
181
+ contact_db, decrypt_lock(config.get('contactKey', '')),
182
+ config.get('contactSalt', ''))
183
+ meta = load_contact_meta(db)
184
+ own = config.get('wxid', '')
185
+ now = int(datetime.now(TZ).timestamp())
186
+ people = []
187
+ dropped = [] # 排除了谁、为什么 —— 绝不静默少几个人
188
+ for item in collect_conversations(conns, name_map, own, days, limit):
189
+ talker = item['talker']
190
+ name = item.get('name') or talker
191
+ if talker.endswith('@chatroom'):
192
+ continue # 群不是"通往某个人"的通道
193
+ local_type, alias = meta.get(talker, (1 if not meta else 0, ''))
194
+ if not is_human(talker, local_type, alias):
195
+ dropped.append({'name': name, 'why': 'id 形状不是人'})
196
+ continue
197
+ if talker in skip or name in skip:
198
+ dropped.append({'name': name, 'why': '在自定名单里'})
199
+ continue
200
+ all_messages, _ = fetch_all_messages(conns, talker, name_map, own)
201
+ messages = [m for m in all_messages if m.get('localType') == TYPE_TEXT]
202
+ if len(messages) < MIN_MESSAGES:
203
+ dropped.append({'name': name, 'why': '文本不足 %d 条' % MIN_MESSAGES})
204
+ continue
205
+ other = any(any(word in (m.get('parsedContent') or '') for word in OTHER_CHANNEL)
206
+ for m in messages)
207
+ shape = shape_of(messages, now)
208
+ tags = classify(shape, other)
209
+ if looks_like_service(name):
210
+ # **只标不排**:这种账号与真人在结构上无法区分(见 SERVICE_NAME_HINTS)
211
+ tags.append('suspect-service')
212
+ people.append({
213
+ 'name': name,
214
+ 'talker': talker,
215
+ 'swappedIds': bool(alias),
216
+ 'otherChannel': other,
217
+ 'tags': tags,
218
+ **shape,
219
+ })
220
+ return {'people': people, 'days': days, 'dropped': dropped}, None
221
+ finally:
222
+ for conn in conns:
223
+ conn.close()
224
+
225
+
226
+ def budget(people, limit=12):
227
+ """把名单切成报告要的那几组。`cooling` 按"会失去多少"排(条数 × 静默天数)。"""
228
+ cooling = [p for p in people if 'cooling' in p['tags']]
229
+ cooling.sort(key=lambda p: -(p['messages'] * p['silentDays']))
230
+ heating = [p for p in people if 'heating' in p['tags']]
231
+ heating.sort(key=lambda p: -p['recent7'])
232
+ only = [p for p in people if 'only-wechat' in p['tags']]
233
+ only.sort(key=lambda p: -p['activeDays'])
234
+ return {
235
+ 'cooling': cooling[:limit],
236
+ 'heating': heating[:limit],
237
+ 'onlyWechat': only[:limit],
238
+ 'counts': {'people': len(people), 'cooling': len(cooling),
239
+ 'heating': len(heating), 'onlyWechat': len(only)},
240
+ }
241
+
242
+
243
+ def render(payload, limit=12):
244
+ view = budget(payload['people'], limit)
245
+ lines = []
246
+ counts = view['counts']
247
+ lines.append('看过的单聊(≥%d 条文本,最近 %d 天): %d 人;其中只有微信这一条线的: %d 人'
248
+ % (MIN_MESSAGES, payload['days'], counts['people'], counts['onlyWechat']))
249
+ if view['cooling']:
250
+ lines.append('')
251
+ lines.append('冷却中(历史厚、静默 ≥%d 天;按"条数 × 静默天数"排):' % COOL_DAYS)
252
+ for p in view['cooling']:
253
+ lines.append(' %-16s %5d 条 / %3d 个活跃天 / 已静默 %6.0f 天(最后一次 %s)%s'
254
+ % (p['name'][:16], p['messages'], p['activeDays'], p['silentDays'], p['last'],
255
+ ' ⚠只有微信这一条线' if 'only-wechat' in p['tags'] else ''))
256
+ if view['heating']:
257
+ lines.append('')
258
+ lines.append('最近变热(近 7 天 ≥ 平时日均的 %.1f 倍):' % HOT_RATIO)
259
+ for p in view['heating']:
260
+ lines.append(' %-16s 近 7 天 %3d 条(平时日均 %.2f)/ 共 %d 条' % (p['name'][:16], p['recent7'],
261
+ p['perDay'], p['messages']))
262
+ if view['onlyWechat']:
263
+ lines.append('')
264
+ lines.append('只有微信这一条线(历史里从没出现过电话/邮箱/约见面;按活跃天数排):')
265
+ for p in view['onlyWechat']:
266
+ lines.append(' %-16s 活跃 %3d 天 / %5d 条 / 最近 %6.0f 天前'
267
+ % (p['name'][:16], p['activeDays'], p['messages'], p['silentDays']))
268
+ suspects = [p for p in payload['people'] if 'suspect-service' in p['tags']]
269
+ if suspects:
270
+ lines.append('')
271
+ lines.append('名字像服务号/门店(**工具不替你决定**;要排除就写进 %s 或加 --skip):' % SKIP_FILE)
272
+ for p in suspects[:limit]:
273
+ lines.append(' %-16s %5d 条 / 最近 %6.0f 天前'
274
+ % (p['name'][:16], p['messages'], p['silentDays']))
275
+ dropped = payload.get('dropped') or []
276
+ if dropped:
277
+ lines.append('')
278
+ reasons = {}
279
+ for item in dropped:
280
+ reasons.setdefault(item['why'], []).append(item['name'])
281
+ for why, names in sorted(reasons.items(), key=lambda kv: -len(kv[1])):
282
+ shown = '、'.join(names[:6]) + ('…' if len(names) > 6 else '')
283
+ lines.append('已排除(%s)共 %d 个:%s' % (why, len(names), shown))
284
+ if not (view['cooling'] or view['heating']):
285
+ lines.append('(没有正在冷却或变热的关系)')
286
+ return '\n'.join(lines)
287
+
288
+
289
+ def main():
290
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
291
+ parser = argparse.ArgumentParser(
292
+ description='关系温度:谁在冷却、谁在变热、谁只有微信这一条线(只读、不联网、不调模型)')
293
+ parser.add_argument('--days', type=int, default=DEFAULT_DAYS, help='看最近多少天(默认 %d)' % DEFAULT_DAYS)
294
+ parser.add_argument('--limit', type=int, default=DEFAULT_LIMIT,
295
+ help='最多扫几个会话(默认 %d)' % DEFAULT_LIMIT)
296
+ parser.add_argument('--top', type=int, default=12, help='每组列几条(默认 12)')
297
+ parser.add_argument('--skip', action='append', default=[], metavar='NAME',
298
+ help='把某个名字或 id 排除掉(可重复);' + SKIP_FILE + ' 里也能写,一行一个')
299
+ parser.add_argument('--json', action='store_true')
300
+ args = parser.parse_args()
301
+
302
+ if args.days < 1:
303
+ print(json.dumps({'success': False, 'error': '--days 要 ≥ 1'}) if args.json else '--days 要 ≥ 1')
304
+ return 1
305
+
306
+ payload, failure = collect(args.days, args.limit, skip=load_skip(extra=args.skip))
307
+ if payload is None:
308
+ message = {'no-db': '配置里没有 ntDbPath,先运行 weflow-cli init',
309
+ 'no-conn': '无法打开消息数据库,请检查密钥(weflow-cli check)'}.get(failure, failure)
310
+ print(json.dumps({'success': False, 'error': message}, ensure_ascii=False) if args.json else message)
311
+ return 1
312
+
313
+ view = budget(payload['people'], args.top)
314
+ if args.json:
315
+ print(json.dumps({'success': True, 'action': 'bonds', 'days': payload['days'],
316
+ 'counts': view['counts'],
317
+ 'cooling': view['cooling'], 'heating': view['heating'],
318
+ 'onlyWechat': view['onlyWechat'],
319
+ 'suspectService': [p for p in payload['people']
320
+ if 'suspect-service' in p['tags']],
321
+ 'dropped': payload.get('dropped') or [],
322
+ 'readsLocalData': True, 'invokesAI': False, 'writesFiles': False,
323
+ 'note': '只读本地库;不发送、不生成话术'},
324
+ ensure_ascii=False, indent=2))
325
+ else:
326
+ print(render(payload, args.top))
327
+ return 0
328
+
329
+
330
+ if __name__ == '__main__':
331
+ sys.exit(main())