weflow-cli 1.5.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 (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/bin/weflow-cli-electron.cjs +59 -0
  4. package/bin/weflow-cli.ts +2311 -0
  5. package/cli.cjs +3 -0
  6. package/dist/bin/weflow-cli.d.ts +3 -0
  7. package/dist/bin/weflow-cli.d.ts.map +1 -0
  8. package/dist/bin/weflow-cli.js +2270 -0
  9. package/dist/bin/weflow-cli.js.map +1 -0
  10. package/dist/mcp-server/index.d.ts +3 -0
  11. package/dist/mcp-server/index.d.ts.map +1 -0
  12. package/dist/mcp-server/index.js +690 -0
  13. package/dist/mcp-server/index.js.map +1 -0
  14. package/dist/src/core/dbPathService.d.ts +40 -0
  15. package/dist/src/core/dbPathService.d.ts.map +1 -0
  16. package/dist/src/core/dbPathService.js +324 -0
  17. package/dist/src/core/dbPathService.js.map +1 -0
  18. package/dist/src/core/keyService.d.ts +59 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -0
  20. package/dist/src/core/keyService.js +368 -0
  21. package/dist/src/core/keyService.js.map +1 -0
  22. package/dist/src/core/ntCore.d.ts +62 -0
  23. package/dist/src/core/ntCore.d.ts.map +1 -0
  24. package/dist/src/core/ntCore.js +191 -0
  25. package/dist/src/core/ntCore.js.map +1 -0
  26. package/dist/src/core/sqlcipherCore.d.ts +119 -0
  27. package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
  28. package/dist/src/core/sqlcipherCore.js +693 -0
  29. package/dist/src/core/sqlcipherCore.js.map +1 -0
  30. package/dist/src/core/wcdbCore.d.ts +738 -0
  31. package/dist/src/core/wcdbCore.d.ts.map +1 -0
  32. package/dist/src/core/wcdbCore.js +4545 -0
  33. package/dist/src/core/wcdbCore.js.map +1 -0
  34. package/dist/src/core/wechatClient.d.ts +23 -0
  35. package/dist/src/core/wechatClient.d.ts.map +1 -0
  36. package/dist/src/core/wechatClient.js +166 -0
  37. package/dist/src/core/wechatClient.js.map +1 -0
  38. package/dist/src/services/chatService.d.ts +25 -0
  39. package/dist/src/services/chatService.d.ts.map +1 -0
  40. package/dist/src/services/chatService.js +291 -0
  41. package/dist/src/services/chatService.js.map +1 -0
  42. package/dist/src/services/configService.d.ts +43 -0
  43. package/dist/src/services/configService.d.ts.map +1 -0
  44. package/dist/src/services/configService.js +156 -0
  45. package/dist/src/services/configService.js.map +1 -0
  46. package/dist/src/services/exportService.d.ts +30 -0
  47. package/dist/src/services/exportService.d.ts.map +1 -0
  48. package/dist/src/services/exportService.js +273 -0
  49. package/dist/src/services/exportService.js.map +1 -0
  50. package/dist/src/services/wechat-formatter.d.ts +69 -0
  51. package/dist/src/services/wechat-formatter.d.ts.map +1 -0
  52. package/dist/src/services/wechat-formatter.js +248 -0
  53. package/dist/src/services/wechat-formatter.js.map +1 -0
  54. package/dist/src/services/wechatMessageService.d.ts +28 -0
  55. package/dist/src/services/wechatMessageService.d.ts.map +1 -0
  56. package/dist/src/services/wechatMessageService.js +341 -0
  57. package/dist/src/services/wechatMessageService.js.map +1 -0
  58. package/dist/src/services/wereadService.d.ts +206 -0
  59. package/dist/src/services/wereadService.d.ts.map +1 -0
  60. package/dist/src/services/wereadService.js +145 -0
  61. package/dist/src/services/wereadService.js.map +1 -0
  62. package/dist/src/services/whitelistService.d.ts +20 -0
  63. package/dist/src/services/whitelistService.d.ts.map +1 -0
  64. package/dist/src/services/whitelistService.js +60 -0
  65. package/dist/src/services/whitelistService.js.map +1 -0
  66. package/dist/src/types.d.ts +140 -0
  67. package/dist/src/types.d.ts.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/src/utils/errors.d.ts +25 -0
  71. package/dist/src/utils/errors.d.ts.map +1 -0
  72. package/dist/src/utils/errors.js +43 -0
  73. package/dist/src/utils/errors.js.map +1 -0
  74. package/dist/src/utils/pathUtils.d.ts +5 -0
  75. package/dist/src/utils/pathUtils.d.ts.map +1 -0
  76. package/dist/src/utils/pathUtils.js +16 -0
  77. package/dist/src/utils/pathUtils.js.map +1 -0
  78. package/dist/src/utils/pythonRunner.d.ts +27 -0
  79. package/dist/src/utils/pythonRunner.d.ts.map +1 -0
  80. package/dist/src/utils/pythonRunner.js +62 -0
  81. package/dist/src/utils/pythonRunner.js.map +1 -0
  82. package/dist/src/utils/talkerUtils.d.ts +14 -0
  83. package/dist/src/utils/talkerUtils.d.ts.map +1 -0
  84. package/dist/src/utils/talkerUtils.js +73 -0
  85. package/dist/src/utils/talkerUtils.js.map +1 -0
  86. package/mcp-server/index.ts +757 -0
  87. package/package.json +72 -0
  88. package/resources/key/win32/x64/wx_key.dll +0 -0
  89. package/resources/wcdb/win32/x64/SDL2.dll +0 -0
  90. package/resources/wcdb/win32/x64/WCDB.dll +0 -0
  91. package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
  92. package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
  93. package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
  94. package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
  95. package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
  96. package/scripts/_batch_link_sources.py +73 -0
  97. package/scripts/_utils.py +531 -0
  98. package/scripts/annual_report.py +796 -0
  99. package/scripts/auto_tag.py +128 -0
  100. package/scripts/biz_daily.py +896 -0
  101. package/scripts/chat_report.py +397 -0
  102. package/scripts/chat_stats.py +702 -0
  103. package/scripts/classify_daily.py +411 -0
  104. package/scripts/compile_wiki.py +258 -0
  105. package/scripts/create_reading_notes.py +370 -0
  106. package/scripts/enrich_backlinks.py +163 -0
  107. package/scripts/export_chat_html.py +766 -0
  108. package/scripts/extract_3x_key.py +86 -0
  109. package/scripts/extract_todos.py +404 -0
  110. package/scripts/fav_server.py +392 -0
  111. package/scripts/fix_topics.py +111 -0
  112. package/scripts/generate_ai_report.py +367 -0
  113. package/scripts/generate_html.py +1372 -0
  114. package/scripts/generate_review.py +170 -0
  115. package/scripts/mcp_bridge.py +398 -0
  116. package/scripts/nt_decrypt.py +562 -0
  117. package/scripts/pipeline.py +145 -0
  118. package/scripts/promote_all.py +310 -0
  119. package/scripts/promote_ideas.py +297 -0
  120. package/scripts/rag_chat.py +191 -0
  121. package/scripts/scan_decrypt_4x.py +329 -0
  122. package/scripts/semantic_search.py +431 -0
  123. package/scripts/sync_fav.py +146 -0
  124. package/scripts/sync_weread.py +192 -0
  125. package/scripts/vault_rag.py +139 -0
  126. package/scripts/vault_search.py +141 -0
  127. package/src/core/dbPathService.ts +348 -0
  128. package/src/core/keyService.ts +409 -0
  129. package/src/core/ntCore.ts +224 -0
  130. package/src/core/sqlcipherCore.ts +764 -0
  131. package/src/core/wcdbCore.ts +4568 -0
  132. package/src/core/wechatClient.ts +204 -0
  133. package/src/lz4.d.ts +10 -0
  134. package/src/services/chatService.ts +296 -0
  135. package/src/services/configService.ts +197 -0
  136. package/src/services/exportService.ts +305 -0
  137. package/src/services/wechat-formatter.ts +348 -0
  138. package/src/services/wechatMessageService.ts +383 -0
  139. package/src/services/wereadService.ts +308 -0
  140. package/src/services/whitelistService.ts +72 -0
  141. package/src/types.ts +141 -0
  142. package/src/utils/errors.ts +42 -0
  143. package/src/utils/pathUtils.ts +16 -0
  144. package/src/utils/pythonRunner.ts +81 -0
  145. package/src/utils/talkerUtils.ts +84 -0
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 微信聊天月报 — 汇总指定联系人聊天记录,DeepSeek AI 分析任务和回复。
4
+
5
+ 用法:
6
+ python scripts/chat_report.py --month 2026-04
7
+ python scripts/chat_report.py --month 2026-04 --talker 张三 --talker 李老师
8
+ python scripts/chat_report.py --month 2026-04 --from-whitelist
9
+ python scripts/chat_report.py --month 2026-04 --talker 张三 --李老师 --家人群
10
+ python scripts/chat_report.py --month 2026-04 --no-ai
11
+
12
+ 输出: output/report-2026-04.md
13
+ """
14
+
15
+ import sys, os, json, re, time, random, hashlib, argparse
16
+ from datetime import datetime, timezone, timedelta
17
+ from pathlib import Path
18
+ from collections import defaultdict, Counter
19
+
20
+ try:
21
+ from sqlcipher3 import dbapi2 as sqlcipher
22
+ except ImportError:
23
+ print("请安装: pip install sqlcipher3"); sys.exit(1)
24
+
25
+ from _utils import load_config, decrypt_lock, get_db_config, call_deepseek
26
+
27
+ OUTPUT_ROOT = 'output'
28
+ AI_DELAY = (2, 4) # AI 调用间隔 (min, max) 秒
29
+
30
+ # 任务关键词预筛 — 只有命中这些的联系人才调 AI
31
+ TASK_KEYWORDS = ['帮忙', '做一下', '问个', '帮我', '麻烦', '有空吗', '方便吗',
32
+ '能不能', '可以吗', '好不好', '行吗', '对吗', '怎么', '什么时候',
33
+ '发给我', '看一下', '改一下', '整理', '准备', '安排', '通知',
34
+ '?', '?']
35
+
36
+ # ====== Database ======
37
+
38
+ def open_db(db_path, key_hex, salt_hex):
39
+ raw_key = f"x'{key_hex}{salt_hex}'"
40
+ conn = sqlcipher.connect(db_path)
41
+ c = conn.cursor()
42
+ c.execute(f'PRAGMA key = "{raw_key}";')
43
+ c.execute("SELECT count(*) FROM sqlite_master")
44
+ return conn
45
+
46
+
47
+ def get_sessions(conn):
48
+ """Get active chat sessions from Name2Id (NT format)."""
49
+ c = conn.cursor()
50
+ try:
51
+ c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
52
+ sessions = [{'username': r[0]} for r in c.fetchall()]
53
+ return sessions
54
+ except:
55
+ pass
56
+ # Fallback: scan Msg_ tables
57
+ c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
58
+ return [{'username': t, 'table': t} for t in [r[0] for r in c.fetchall()]]
59
+
60
+
61
+ def get_name_map(contact_db, contact_key, contact_salt):
62
+ name_map = {}
63
+ if not contact_db or not contact_key or not os.path.exists(contact_db):
64
+ return name_map
65
+ try:
66
+ conn = open_db(contact_db, contact_key, contact_salt)
67
+ c = conn.cursor()
68
+ c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), username) FROM contact")
69
+ for r in c.fetchall(): name_map[r[0]] = r[1]
70
+ conn.close()
71
+ except: pass
72
+ return name_map
73
+
74
+
75
+ def get_messages(conn, talker, start_ts, end_ts, sender_map, name_map, own_wxid):
76
+ """Get text messages for a talker within time range."""
77
+ tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
78
+ c = conn.cursor()
79
+ try:
80
+ c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
81
+ if c.fetchone()[0] == 0: return []
82
+ except: return []
83
+
84
+ c.execute(f'SELECT local_id, local_type, create_time, real_sender_id, message_content FROM "{tbl}" WHERE create_time >= ? AND create_time <= ? ORDER BY create_time ASC', (start_ts, end_ts))
85
+ rows = c.fetchall()
86
+
87
+ msgs = []
88
+ for row in rows:
89
+ lt, ct, sid = row[1] or 0, row[2] or 0, row[3] or 0
90
+ username = sender_map.get(sid, '')
91
+ is_self = False
92
+ if own_wxid:
93
+ is_self = (username == own_wxid)
94
+ if not is_self:
95
+ p = own_wxid.rsplit('_', 1)
96
+ if len(p) == 2 and len(p[1]) == 4 and p[1].isalnum():
97
+ is_self = (username == p[0])
98
+
99
+ content = row[4] if isinstance(row[4], str) else (f'[{lt}类型]' if lt != 1 else '')
100
+ sender = '我' if is_self else name_map.get(username, username)
101
+ msgs.append({'time': ct, 'is_self': is_self, 'sender': sender, 'content': content, 'type': lt})
102
+ return msgs
103
+
104
+
105
+ def has_tasks(msgs):
106
+ """Quick keyword check: does this conversation likely contain tasks?"""
107
+ text = ' '.join(m['content'][:100] for m in msgs if not m['is_self'] and m['type'] == 1)
108
+ return any(kw in text for kw in TASK_KEYWORDS)
109
+
110
+
111
+ # ====== AI ======
112
+
113
+ TALKER_PROMPT = """分析微信聊天记录,识别任务和请求。
114
+
115
+ 格式:[时间] 发送者: 内容
116
+ 规则:只关注对方(非"我")的任务/请求/问句,判断我是否回复、紧急度、完成情况。
117
+
118
+ 返回JSON(不要其他内容):
119
+ {"tasks": [{"content":"任务摘要","time":"消息时间","replied":true,"reply":"回复","urgency":"高/中/低","status":"完成/进行中/未回复"}], "summary": "总结"}
120
+
121
+ ==MESSAGES==
122
+ """
123
+
124
+ SUMMARY_PROMPT = """根据以下联系人分析结果生成月度总结。统计任务数、已/未回复,按紧急度列出未回复项,给出3条行动建议。
125
+
126
+ 格式:
127
+ 【任务统计】共 N 项,已回复 M,未回复 K
128
+ 【紧急事项】
129
+ - 来源 | 任务 | 逾期
130
+ 【建议】
131
+ 1. ...
132
+ 2. ...
133
+ 3. ...
134
+
135
+ ==TALKER_DATA==
136
+ """
137
+
138
+
139
+ # ====== Main ======
140
+
141
+ def fmt_time(ts): return datetime.fromtimestamp(ts, tz=timezone(timedelta(hours=8))).strftime('%m-%d %H:%M')
142
+ def fmt_date(ts): return datetime.fromtimestamp(ts, tz=timezone(timedelta(hours=8))).strftime('%Y-%m-%d')
143
+
144
+
145
+ def main():
146
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
147
+ p = argparse.ArgumentParser(description='微信聊天月报')
148
+ p.add_argument('--month'); p.add_argument('--year'); p.add_argument('--start'); p.add_argument('--end')
149
+ p.add_argument('--talker', action='append', help='指定联系人(可多选)')
150
+ p.add_argument('--from-whitelist', action='store_true', help='使用白名单中的联系人')
151
+ p.add_argument('--api-key'); p.add_argument('--no-ai', action='store_true')
152
+ p.add_argument('--output', '-o', default=OUTPUT_ROOT)
153
+ args = p.parse_args()
154
+
155
+ # Time range
156
+ tz = timezone(timedelta(hours=8))
157
+ if args.month:
158
+ y, m = map(int, args.month.split('-'))
159
+ sd = datetime(y, m, 1, tzinfo=tz)
160
+ ed = (datetime(y, m+1, 1, tzinfo=tz) if m < 12 else datetime(y+1, 1, 1, tzinfo=tz)) - timedelta(seconds=1)
161
+ period = args.month
162
+ elif args.year:
163
+ y = int(args.year)
164
+ sd = datetime(y, 1, 1, tzinfo=tz); ed = datetime(y, 12, 31, 23, 59, 59, tzinfo=tz); period = args.year
165
+ elif args.start and args.end:
166
+ sd = datetime.strptime(args.start, '%Y-%m-%d').replace(tzinfo=tz)
167
+ ed = datetime.strptime(args.end, '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=tz)
168
+ period = f"{args.start}~{args.end}"
169
+ else:
170
+ now = datetime.now(tz)
171
+ fm = (now.year, now.month-1) if now.month > 1 else (now.year-1, 12)
172
+ sd = datetime(fm[0], fm[1], 1, tzinfo=tz)
173
+ ed = datetime(fm[0], fm[1]+1, 1, tzinfo=tz) - timedelta(seconds=1) if fm[1] < 12 else datetime(fm[0]+1, 1, 1, tzinfo=tz) - timedelta(seconds=1)
174
+ period = f"{fm[0]}-{fm[1]:02d}"
175
+
176
+ s_ts, e_ts = int(sd.timestamp()), int(ed.timestamp())
177
+
178
+ api_key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '')
179
+ if not args.no_ai and not api_key:
180
+ print('[ERROR] 需要 DeepSeek API key'); sys.exit(1)
181
+
182
+ config = load_config()
183
+ db = get_db_config(config)
184
+ if not db['nt_db'] or not db['nt_key']:
185
+ print('[ERROR] 未配置,先运行: weflow-cli init'); sys.exit(1)
186
+
187
+ print(f'=== 微信聊天月报 {period} ===')
188
+ print(f'时间: {fmt_date(s_ts)} ~ {fmt_date(e_ts)}')
189
+ print(f'AI: {"关闭" if args.no_ai else "DeepSeek V4"}')
190
+
191
+ # Resolve talker filter
192
+ target_wxids = set()
193
+ if args.talker:
194
+ # Need to resolve nicknames to wxids
195
+ conn = open_db(db['nt_db'], db['nt_key'], db['nt_salt'])
196
+ name_map = get_name_map(db['contact_db'], db['contact_key'], db['contact_salt'])
197
+ c = conn.cursor()
198
+ c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
199
+ all_sessions = {r[0]: name_map.get(r[0], r[0]) for r in c.fetchall()}
200
+ conn.close()
201
+ for t in args.talker:
202
+ t = t.strip()
203
+ found = False
204
+ for wxid, name in all_sessions.items():
205
+ if t in (wxid, name) or t.lower() in name.lower():
206
+ target_wxids.add(wxid); found = True; break
207
+ if not found:
208
+ # Try as wxid directly
209
+ target_wxids.add(t)
210
+ if target_wxids:
211
+ print(f'选定联系人: {len(target_wxids)} 人')
212
+ if args.from_whitelist:
213
+ wl = config.get('whitelist', [])
214
+ if wl: target_wxids.update(wl); print(f'白名单: {len(wl)} 人')
215
+
216
+ # Connect
217
+ print('\n连接数据库...')
218
+ conn = open_db(db['nt_db'], db['nt_key'], db['nt_salt'])
219
+ name_map = get_name_map(db['contact_db'], db['contact_key'], db['contact_salt'])
220
+ own_wxid = config.get('wxid', '')
221
+
222
+ # Get sessions
223
+ print('获取会话...')
224
+ sessions = get_sessions(conn)
225
+ if target_wxids:
226
+ sessions = [s for s in sessions if s['username'] in target_wxids]
227
+
228
+ print(f'活跃会话: {len(sessions)}')
229
+
230
+ # Build sender map
231
+ c = conn.cursor()
232
+ c.execute("SELECT rowid, user_name FROM Name2Id")
233
+ sender_map = {rowid: uname for rowid, uname in c.fetchall()}
234
+
235
+ # Phase 1: Collect
236
+ print(f'\n=== Phase 1: 采集消息 ===\n')
237
+ data = []
238
+ total_msgs = total_sent = total_recv = 0
239
+ active_days = set(); hour_dist = [0]*24
240
+
241
+ for i, s in enumerate(sessions):
242
+ wxid = s['username']; name = name_map.get(wxid, wxid)
243
+ msgs = get_messages(conn, wxid, s_ts, e_ts, sender_map, name_map, own_wxid)
244
+ if not msgs: continue
245
+ sent = sum(1 for m in msgs if m['is_self']); recv = len(msgs) - sent
246
+ days = set(fmt_date(m['time']) for m in msgs)
247
+ total_msgs += len(msgs); total_sent += sent; total_recv += recv
248
+ active_days.update(days)
249
+ for m in msgs: hour_dist[datetime.fromtimestamp(m['time'], tz=tz).hour] += 1
250
+
251
+ data.append({'wxid': wxid, 'name': name, 'msgs': msgs, 'sent': sent, 'recv': recv, 'days': len(days)})
252
+ if (i+1) % 20 == 0: print(f' {i+1}/{len(sessions)}...')
253
+
254
+ data.sort(key=lambda d: d['sent'] + d['recv'], reverse=True)
255
+ print(f'\n联系人: {len(data)} | 消息: {total_msgs} | 我发: {total_sent} | 收到: {total_recv}')
256
+
257
+ # Phase 2: AI
258
+ all_tasks = []; monthly_summary = ''
259
+ if not args.no_ai and api_key:
260
+ print(f'\n=== Phase 2: AI 分析 ===\n')
261
+ # Pre-filter: only analyze contacts with likely tasks
262
+ ai_targets = [d for d in data if has_tasks(d['msgs'])]
263
+ skipped = len(data) - len(ai_targets)
264
+ if skipped: print(f'预筛跳过 {skipped} 人(无任务关键词)\n')
265
+
266
+ for i, d in enumerate(ai_targets):
267
+ lines = []
268
+ for m in d['msgs']:
269
+ if m['type'] != 1: continue
270
+ lines.append(f"[{fmt_time(m['time'])}] {m['sender']}: {m['content'][:120]}")
271
+ if not lines: continue
272
+
273
+ prompt = TALKER_PROMPT.replace('==MESSAGES==', '\n'.join(lines[-30:]))
274
+ # V4 reasoning model: ~1 token/chars (CN overhead), need 2x input + 500 output
275
+ estimated_tokens = max(2000, len(prompt) * 2 + 500)
276
+ try:
277
+ resp = call_deepseek(prompt, api_key, max_tokens=estimated_tokens, timeout=120)
278
+ # Extract JSON from response (may be in code block or raw)
279
+ json_str = resp
280
+ code_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', resp)
281
+ if code_match: json_str = code_match.group(1)
282
+ m = re.search(r'\{[\s\S]*\}', json_str)
283
+ if m:
284
+ raw_json = m.group()
285
+ # Fix common DeepSeek JSON issues
286
+ raw_json = re.sub(r',\s*}', '}', raw_json) # trailing comma
287
+ raw_json = re.sub(r',\s*]', ']', raw_json) # trailing comma in array
288
+ analysis = json.loads(raw_json)
289
+ d['tasks'] = analysis.get('tasks', [])
290
+ d['summary'] = analysis.get('summary', '')
291
+ for t in d['tasks']: t['talker'] = d['name']
292
+ all_tasks.extend(d['tasks'])
293
+ print(f' [{i+1}/{len(ai_targets)}] {d["name"][:20]:20s} → {len(d["tasks"])} 任务')
294
+ else:
295
+ d['tasks'] = []; d['summary'] = ''
296
+ # Save raw response for debugging
297
+ with open(f'debug_ai_{i}.json', 'w', encoding='utf-8') as df:
298
+ df.write(resp[:2000])
299
+ print(f' [{i+1}/{len(ai_targets)}] {d["name"][:20]:20s} → 解析失败 (已保存debug_ai_{i}.json)')
300
+ except Exception as e:
301
+ d['tasks'] = []; d['summary'] = ''
302
+ print(f' [{i+1}/{len(ai_targets)}] {d["name"][:20]:20s} → ERR: {e}')
303
+ time.sleep(AI_DELAY[0] + random.random() * (AI_DELAY[1] - AI_DELAY[0]))
304
+
305
+ # Monthly summary
306
+ print(f'\n=== 月度总结 ===\n')
307
+ parts = []
308
+ for d in ai_targets:
309
+ if d.get('tasks'):
310
+ parts.append(f"{d['name']}: {json.dumps(d['tasks'], ensure_ascii=False)}")
311
+ if parts:
312
+ monthly_summary = call_deepseek(SUMMARY_PROMPT.replace('==TALKER_DATA==', '\n\n'.join(parts)), api_key, max_tokens=2000)
313
+ print(f' 识别任务: {len(all_tasks)} | 未回复: {sum(1 for t in all_tasks if t.get("status")=="未回复")}')
314
+
315
+ # Phase 3: Generate markdown
316
+ print(f'\n=== Phase 3: 生成报告 ===\n')
317
+ lines = [f'# 微信聊天月报 — {period}', '',
318
+ f'> 统计范围: {fmt_date(s_ts)} ~ {fmt_date(e_ts)} | 生成: {datetime.now(tz).strftime("%Y-%m-%d")}',
319
+ f'> AI: {"DeepSeek V4 Pro" if not args.no_ai else "关闭"}',
320
+ f'> 联系人: {len(data)} | 消息: {total_msgs} | 活跃天数: {len(active_days)}',
321
+ '', '---', '', '## 总览', '',
322
+ '| 指标 | 数值 |', '|------|------|',
323
+ f'| 活跃联系人 | {len(data)} |', f'| 消息总数 | {total_msgs:,} |',
324
+ f'| 我发出 | {total_sent:,} ({total_sent/max(total_msgs,1)*100:.0f}%) |',
325
+ f'| 收到 | {total_recv:,} ({total_recv/max(total_msgs,1)*100:.0f}%) |',
326
+ f'| 活跃天数 | {len(active_days)} |', '']
327
+
328
+ if not args.no_ai and all_tasks:
329
+ unreplied = [t for t in all_tasks if t.get('status') == '未回复']
330
+ replied = [t for t in all_tasks if t.get('status') != '未回复']
331
+ lines += ['## AI 任务分析', '', '> DeepSeek V4 Pro', '']
332
+ lines += [f'### 🔴 未回复 ({len(unreplied)} 项)', '']
333
+ if unreplied:
334
+ lines += ['| # | 来源 | 任务 | 时间 | 紧急度 |', '|---|------|------|------|--------|']
335
+ for i, t in enumerate(unreplied):
336
+ u = '🔴' if t.get('urgency') == '高' else ('⚠️' if t.get('urgency') == '中' else '🟢')
337
+ lines.append(f'| {i+1} | {t.get("talker","")} | {t.get("content","")[:30]} | {t.get("time","")} | {u} {t.get("urgency","")} |')
338
+ else: lines.append('✅ 无未回复任务')
339
+ lines.append('')
340
+ lines += [f'### 🟡 已回复 ({len(replied)} 项)', '']
341
+ if replied:
342
+ lines += ['| # | 来源 | 任务 | 时间 | 回复 | 状态 |', '|---|------|------|------|------|------|']
343
+ for i, t in enumerate(replied):
344
+ s = '✅' if t.get('status') == '完成' else '⏳'
345
+ lines.append(f'| {i+1} | {t.get("talker","")} | {t.get("content","")[:25]} | {t.get("time","")} | {t.get("reply","")[:20]} | {s} {t.get("status","")} |')
346
+ lines.append('')
347
+ if monthly_summary:
348
+ lines += ['### 📊 AI 月度总结', '']
349
+ for line in monthly_summary.strip().split('\n'): lines.append(f'> {line}')
350
+ lines.append('')
351
+
352
+ # Per contact
353
+ lines += ['## 按联系人', '']
354
+ for d in data:
355
+ total = d['sent'] + d['recv']
356
+ lines += [f'### {d["name"]}({total} 条)', '',
357
+ f'我发 {d["sent"]} | 对方发 {d["recv"]} | 活跃 {d["days"]} 天', '']
358
+ if d.get('summary'):
359
+ lines += [f'> {d["summary"]}', '']
360
+ if d.get('tasks'):
361
+ lines += ['**任务:**', '']
362
+ for t in d['tasks']:
363
+ s = '✅' if t.get('status') == '完成' else ('⏳' if t.get('status') == '进行中' else '❌')
364
+ lines.append(f'- [{t.get("time","")}] {t.get("content","")[:60]} → {s} {t.get("status","")}')
365
+ lines.append('')
366
+ # Recent conversations
367
+ text_msgs = [m for m in d['msgs'] if m['type'] == 1]
368
+ if text_msgs:
369
+ lines += ['**最近对话:**', '']
370
+ for m in text_msgs[-5:]:
371
+ lines.append(f'> **{fmt_time(m["time"])} {m["sender"]}:** {m["content"][:100]}')
372
+ lines.append('')
373
+
374
+ # Hour dist
375
+ lines += ['## 活跃时段', '', '| 时段 | 消息 | 占比 |', '|------|------|------|']
376
+ for label, s, e in [('00-06',0,6),('06-09',6,9),('09-12',9,12),('12-14',12,14),('14-18',14,18),('18-22',18,22),('22-24',22,24)]:
377
+ n = sum(hour_dist[s:e])
378
+ lines.append(f'| {label} | {n} | {n/max(total_msgs,1)*100:.1f}% |')
379
+ lines.append('')
380
+
381
+ # Top 10
382
+ lines += ['## TOP 10', '', '| 排名 | 联系人 | 消息 | 占比 |', '|------|--------|------|------|']
383
+ for i, d in enumerate(data[:10]):
384
+ t = d['sent'] + d['recv']
385
+ lines.append(f'| {i+1} | {d["name"]} | {t} | {t/max(total_msgs,1)*100:.1f}% |')
386
+ lines += ['', '*由 weflow-cli report 自动生成*', '']
387
+
388
+ # Write
389
+ out = Path(args.output); out.mkdir(parents=True, exist_ok=True)
390
+ fp = out / f'report-{period}.md'
391
+ with open(fp, 'w', encoding='utf-8') as f: f.write('\n'.join(lines))
392
+ print(f'✓ {fp}')
393
+ print(f' 联系人: {len(data)} | 消息: {total_msgs}')
394
+ if not args.no_ai: print(f' 任务: {len(all_tasks)} | 未回复: {sum(1 for t in all_tasks if t.get("status")=="未回复")}')
395
+
396
+
397
+ if __name__ == '__main__': main()