weflow-cli 1.14.0 → 1.15.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.
@@ -0,0 +1,138 @@
1
+ """某个号的观点时间线:它这一年都说过什么,什么时候开始变了。
2
+
3
+ 日报每天把文章存成 `output/biz-daily/<date>/<topic>/*.md`(frontmatter 带 `source`、
4
+ `date`,正文有 `## AI 摘要`),所以"这个号的观点怎么变"是可以按时间排出来的 ——
5
+ 而微信里你只能一篇篇点开、还没有横轴。
6
+
7
+ 用法:
8
+ python scripts/opinion_timeline.py --list # 哪些号存了多少篇
9
+ python scripts/opinion_timeline.py --source 某号 # 时间线
10
+ python scripts/opinion_timeline.py --source 某号 --months 3
11
+ python scripts/opinion_timeline.py --source 某号 --ai # 让模型总结观点变化
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import pathlib
18
+ import re
19
+ import sys
20
+ from collections import defaultdict
21
+
22
+ SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
23
+ sys.path.insert(0, str(SCRIPT_DIR))
24
+
25
+ # 摘要复用日报那套清洗:原始文本是「【核心观点】…【关键细节】1.…」,直接打出来是噪音。
26
+ from daily_mail import clean_summary as _clean # noqa: E402
27
+
28
+ ROOT = SCRIPT_DIR.parent
29
+ DAILY_DIR = ROOT / 'output' / 'biz-daily'
30
+
31
+
32
+ def parse_md(path):
33
+ """frontmatter + `## AI 摘要` 那一段。解析不出来就跳过,不让一篇坏文件毁掉整条时间线。"""
34
+ try:
35
+ text = path.read_text(encoding='utf-8')
36
+ except Exception:
37
+ return None
38
+ meta = {}
39
+ body = text
40
+ if text.startswith('---'):
41
+ end = text.find('\n---', 3)
42
+ if end > 0:
43
+ for line in text[3:end].splitlines():
44
+ if ':' in line:
45
+ k, v = line.split(':', 1)
46
+ meta[k.strip()] = v.strip().strip('"')
47
+ body = text[end + 4:]
48
+ summary = ''
49
+ m = re.search(r'##\s*AI 摘要\s*\n+(.+?)(?:\n#|\n---|$)', body, re.S)
50
+ if m:
51
+ summary = ' '.join(m.group(1).split())[:200]
52
+ return {
53
+ 'title': meta.get('title', ''),
54
+ 'source': meta.get('source', ''),
55
+ 'date': meta.get('date', ''),
56
+ 'relevance': meta.get('relevance', ''),
57
+ 'topic': meta.get('topic', ''),
58
+ 'url': meta.get('url', ''),
59
+ 'summary': summary,
60
+ }
61
+
62
+
63
+ def load_all():
64
+ """所有日期目录里的文章。"""
65
+ items = []
66
+ if not DAILY_DIR.is_dir():
67
+ return items
68
+ for day in sorted(DAILY_DIR.iterdir()):
69
+ if not day.is_dir():
70
+ continue
71
+ for md in day.rglob('*.md'):
72
+ rec = parse_md(md)
73
+ if rec and rec['source']:
74
+ items.append(rec)
75
+ return items
76
+
77
+
78
+ def main():
79
+ ap = argparse.ArgumentParser(description='某个号的观点时间线')
80
+ ap.add_argument('--source', default='', help='公众号名(子串匹配)')
81
+ ap.add_argument('--months', type=int, default=0, help='只看最近 N 个月(0 = 全部)')
82
+ ap.add_argument('--limit', type=int, default=30, help='最多列几篇(默认 30,最近的在前)')
83
+ ap.add_argument('--list', action='store_true', help='列出存了哪些号、各多少篇')
84
+ ap.add_argument('--ai', action='store_true', help='让模型总结观点变化(要 API key)')
85
+ ap.add_argument('--json', action='store_true')
86
+ args = ap.parse_args()
87
+
88
+ items = load_all()
89
+ if not items:
90
+ raise SystemExit(f'{DAILY_DIR} 下没有可解析的文章')
91
+
92
+ if args.list:
93
+ counter = defaultdict(int)
94
+ for it in items:
95
+ counter[it['source']] += 1
96
+ rows = sorted(counter.items(), key=lambda kv: -kv[1])
97
+ print(f'共 {len(rows)} 个号,{len(items)} 篇\n')
98
+ for name, n in rows[:40]:
99
+ print(f' {n:>4} 篇 {name}')
100
+ return
101
+
102
+ if not args.source:
103
+ ap.print_help()
104
+ return
105
+
106
+ picked = [it for it in items if args.source in it['source']]
107
+ if not picked:
108
+ raise SystemExit(f'没有匹配「{args.source}」的文章;用 --list 看有哪些号')
109
+ picked.sort(key=lambda x: x['date'], reverse=True)
110
+ if args.limit and args.limit > 0:
111
+ picked = picked[:args.limit]
112
+
113
+ dates = sorted({it['date'] for it in picked})
114
+ print(f"{picked[0]['source']}:{len(picked)} 篇,{dates[0]} ~ {dates[-1]}\n")
115
+ for it in picked:
116
+ rel = f"({it['relevance']})" if it['relevance'] else ''
117
+ print(f"{it['date']} {it['title'][:60]}{rel}")
118
+ if it['summary']:
119
+ print(f" {_clean(it['summary'], 160)}")
120
+
121
+ if args.ai:
122
+ from _utils import load_config, get_api_key, call_ai
123
+ api_key = get_api_key(load_config())
124
+ if not api_key:
125
+ print('\n[WARN] 没有可用的 API key,跳过观点总结', file=sys.stderr)
126
+ return
127
+ lines = [f"{it['date']} {it['title']}:{it['summary']}" for it in picked][-40:]
128
+ prompt = (
129
+ f"下面是同一个公众号「{picked[0]['source']}」按时间排列的文章与要点。\n"
130
+ '请用中文回答:它的关注点或立场有没有随时间变化?如果有,指出从什么变成什么,'
131
+ '并给出对应的日期作为证据;如果没有明显变化,就说明它一直关注什么。'
132
+ '不要罗列所有篇目,不要空话。\n\n' + '\n'.join(lines))
133
+ print('\n--- 观点变化(AI 总结)---')
134
+ print(call_ai(prompt, 'deepseek', api_key, max_tokens=800))
135
+
136
+
137
+ if __name__ == '__main__':
138
+ main()
@@ -0,0 +1,185 @@
1
+ """欠账清单:我说过要给谁做什么,后来做了没有。
2
+
3
+ 这是微信永远回答不了的问题 —— 它知道"我明天发你",但不知道"兑现"是什么。
4
+
5
+ **默认不调模型**:先用规则把候选捞出来(我发的 + 承诺句式),再在同一个会话之后的对话里
6
+ 找"完成信号 / 对方催问 / 对方确认"三类证据。规则会误报,所以每条都带着原话和后续,
7
+ 让读者自己判 —— 而不是给一个"AI 认为你欠了 7 件事"的黑箱。
8
+
9
+ 用法:
10
+ python scripts/promises.py --days 60
11
+ python scripts/promises.py --days 90 --json
12
+ python scripts/promises.py --days 60 --talker 老表
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import pathlib
19
+ import re
20
+ import sys
21
+ from collections import defaultdict
22
+ from datetime import datetime, timezone, timedelta
23
+
24
+ SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
25
+ sys.path.insert(0, str(SCRIPT_DIR))
26
+
27
+ from _utils import load_config, decrypt_lock # noqa: E402
28
+ import nt_decrypt as nd # noqa: E402
29
+
30
+ TZ = timezone(timedelta(hours=8))
31
+
32
+ # 承诺句式:我 + 将来/稍后 + 一个交付动作。宁可多收,后面还有证据过滤。
33
+ PROMISE_RE = re.compile(
34
+ r'(我|这边)[^。!?\n]{0,12}'
35
+ r'(明天|后天|下周|下周一|晚点|稍后|回头|等会|等会儿|下午|晚上|今天之内|这周)[^。!?\n]{0,12}'
36
+ r'(发|给|寄|做|弄|整理|传|送|安排|联系|确认|回复|处理)')
37
+ DELIVER_RE = re.compile(r'(发了|发你了|发您了|已发|寄了|弄好了|做好了|整理好了|搞定了|已上传|传了|安排了|回复了|处理好了)')
38
+ CHASE_RE = re.compile(r'(发了吗|寄了吗|好了吗|弄好了吗|什么时候|还没|催一下|怎么还没|在吗.{0,6}(发|给))')
39
+ ACK_RE = re.compile(r'^(收到|好的|好滴|谢谢|多谢|ok|OK|嗯嗯|好嘞)')
40
+ _NOT_PROMISE = re.compile(r'(我明天要|我下周要|我想|我觉得|我昨天|我上次|我已经)')
41
+
42
+
43
+ def _clean(text):
44
+ text = ' '.join(str(text or '').split())
45
+ return re.sub(r'^[A-Za-z0-9_\-]+@?(chatroom|openim)?\s*:\s*', '', text)
46
+
47
+
48
+ def same_account(name, wxid):
49
+ """配置里的 wxid 与 `Name2Id` 里的账号名**可能是同一个人的两种写法**。
50
+
51
+ 实测本机:配置是 `wxid_账号A_后缀`,而 `Name2Id` 里只有 `wxid_账号A`(没有后缀)——
52
+ 精确比较一个都匹配不上,于是"我发的消息"一条也捞不到(第一版就是这么得到 0 条承诺的)。
53
+ """
54
+ n, w = (name or '').strip(), (wxid or '').strip()
55
+ if not n or not w:
56
+ return False
57
+ return n == w or n.startswith(w + '_') or w.startswith(n + '_')
58
+
59
+
60
+ def load_messages(conns, days, my_wxid):
61
+ """{talker: [ (ts, is_me, sender, text) ]},按时间升序。"""
62
+ since = int((datetime.now(TZ) - timedelta(days=days)).timestamp())
63
+ out = defaultdict(list)
64
+ for conn in conns:
65
+ c = conn.cursor()
66
+ try:
67
+ c.execute('SELECT rowid, user_name FROM Name2Id')
68
+ id2name = {r[0]: r[1] for r in c.fetchall()}
69
+ except Exception:
70
+ continue
71
+ try:
72
+ c.execute('SELECT user_name FROM Name2Id WHERE is_session = 1')
73
+ sessions = [r[0] for r in c.fetchall() if r[0]]
74
+ except Exception:
75
+ sessions = []
76
+ for talker in sessions:
77
+ tbl = 'Msg_%s' % __import__('hashlib').md5(talker.encode()).hexdigest()
78
+ try:
79
+ c.execute('SELECT COUNT(*) FROM sqlite_master WHERE name=?', (tbl,))
80
+ if c.fetchone()[0] == 0:
81
+ continue
82
+ c.execute('SELECT create_time, real_sender_id, local_type, message_content FROM "%s" '
83
+ 'WHERE create_time >= ? ORDER BY create_time' % tbl, (since,))
84
+ for ts, sender, ltype, content in c.fetchall():
85
+ if (int(ltype or 0) & 0xFFFFFFFF) != 1:
86
+ continue
87
+ text = _clean(nd._decode_content(content))
88
+ if not text:
89
+ continue
90
+ who = id2name.get(sender, '')
91
+ out[talker].append((int(ts), same_account(who, my_wxid), who, text))
92
+ except Exception:
93
+ continue
94
+ return out
95
+
96
+
97
+ def classify(follow):
98
+ """看承诺之后发生了什么:兑现 / 被催 / 只有确认 / 无后续。"""
99
+ if any(f[1] and DELIVER_RE.search(f[3]) for f in follow):
100
+ return 'delivered', '我后来提到"已发/已弄好"'
101
+ chases = [f for f in follow if not f[1] and CHASE_RE.search(f[3])]
102
+ if chases:
103
+ return 'chased', '对方后来催过:' + chases[0][3][:40]
104
+ if any(not f[1] and ACK_RE.search(f[3]) for f in follow):
105
+ return 'acked', '对方回复了确认'
106
+ return 'unknown', '没有后续证据'
107
+
108
+
109
+ def main():
110
+ ap = argparse.ArgumentParser(description='我说过要给谁做什么,后来做了没有')
111
+ ap.add_argument('--days', type=int, default=60)
112
+ ap.add_argument('--talker', default='', help='只看某个会话(wxid 或昵称子串)')
113
+ ap.add_argument('--limit', type=int, default=30, help='最多列几条(默认 30)')
114
+ ap.add_argument('--json', action='store_true')
115
+ args = ap.parse_args()
116
+
117
+ cfg = load_config()
118
+ db = cfg.get('ntDbPath') or ''
119
+ my_wxid = cfg.get('wxid') or ''
120
+ if not db:
121
+ raise SystemExit('配置里没有 ntDbPath')
122
+ key = decrypt_lock(cfg.get('ntKey', '') or '')
123
+ salt = decrypt_lock(cfg.get('ntSalt', '') or '')
124
+ pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
125
+ conns = nd.connect_message_shards(db, key, salt, pw)
126
+ if not conns:
127
+ raise SystemExit('打不开消息分片')
128
+
129
+ try:
130
+ by_talker = load_messages(conns, args.days, my_wxid)
131
+ # 昵称在 **contact.db**,消息库里没有 `contact` 表 —— 从消息库找一定落空,
132
+ # 输出里就只剩 wxid(和 on_this_day.py 同一个坑)。
133
+ name_map = {}
134
+ try:
135
+ from network import load_graph
136
+ people, _rooms, _members = load_graph(cfg)
137
+ name_map = {p['username']: (p['remark'] or p['nick'] or p['username'])
138
+ for p in people.values()}
139
+ except Exception as exc:
140
+ print(f'[WARN] 读联系人失败({type(exc).__name__}: {exc}),下面显示 id',
141
+ file=sys.stderr)
142
+ finally:
143
+ for conn in conns:
144
+ try:
145
+ conn.close()
146
+ except Exception:
147
+ pass
148
+
149
+ items = []
150
+ for talker, msgs in by_talker.items():
151
+ shown = name_map.get(talker, talker)
152
+ if args.talker and args.talker not in (talker, shown):
153
+ continue
154
+ for i, (ts, is_me, who, text) in enumerate(msgs):
155
+ if not is_me or not PROMISE_RE.search(text) or _NOT_PROMISE.search(text):
156
+ continue
157
+ follow = msgs[i + 1:i + 20]
158
+ state, why = classify(follow)
159
+ items.append({
160
+ 'time': datetime.fromtimestamp(ts, tz=TZ).strftime('%Y-%m-%d %H:%M'),
161
+ 'talker': shown,
162
+ 'said': text[:100],
163
+ 'state': state,
164
+ 'evidence': why,
165
+ })
166
+ items.sort(key=lambda x: x['time'], reverse=True)
167
+ items = items[:args.limit]
168
+
169
+ if args.json:
170
+ print(json.dumps({'success': True, 'days': args.days, 'items': items}, ensure_ascii=False, indent=2))
171
+ return
172
+
173
+ label = {'delivered': '已兑现', 'chased': '可能欠着', 'acked': '已确认', 'unknown': '看不清'}
174
+ order = {'chased': 0, 'unknown': 1, 'delivered': 2, 'acked': 3}
175
+ items.sort(key=lambda x: (order[x['state']], x['time']))
176
+ print(f'最近 {args.days} 天里,"我"承诺过 {len(items)} 件事:\n')
177
+ for it in items:
178
+ print(f"[{label[it['state']]}] {it['time']} {it['talker']}")
179
+ print(f" 我说:{it['said']}")
180
+ print(f" 后续:{it['evidence']}")
181
+ print()
182
+
183
+
184
+ if __name__ == '__main__':
185
+ main()
@@ -1166,6 +1166,11 @@ PHRASE_DB = PHRASE_INDEX_DIR / 'phrase.sqlite'
1166
1166
  PHRASE_MIN_CHARS = 3
1167
1167
  PHRASE_TEXT_LIMIT = FULL_TEXT_LIMIT
1168
1168
 
1169
+ # 语音转写清单(`voice_transcribe.py` 产出)。它不是语义索引的一部分——语音走的是另一个
1170
+ # 引擎、也不该为它再花一次 embedding 钱——但它同样是"原话",所以并进**短语索引**:
1171
+ # `search --phrase` 于是能搜到语音说过什么,而语音是微信自己搜不到的东西。
1172
+ VOICE_JSONL = INDEX_DIR.parent / '.voice_index' / 'voices.jsonl'
1173
+
1169
1174
 
1170
1175
  def escape_phrase(query):
1171
1176
  """把整串当**字面量**(而不是 FTS 语法):外层加引号,内部 `"` 翻倍。"""
@@ -1224,6 +1229,33 @@ def build_phrase_index(line: str = 'all', db_path=None, meta_path=None):
1224
1229
  rows.append((text, str(item.get('id') or ''), key, _phrase_label(item),
1225
1230
  str(item.get('date') or item.get('time') or ''),
1226
1231
  str(item.get('title') or '')))
1232
+
1233
+ # 语音转写并进同一条短语线(`type='voice'`)。`--line chat|wiki` 时**不**带它——
1234
+ # 那两条线是用户明确限定的范围,语音属于第三类证据。
1235
+ #
1236
+ # 路径**在这里现算**(挂在 `OUTPUT_ROOT` 下),不写成模块级常量:常量在 import 时就固定,
1237
+ # 测试把 `OUTPUT_ROOT` 指到临时目录后仍会读到真实 `output/` 里的语音,于是
1238
+ # "3 条记录"变成 8 条(2026-10-22 就是这么红的)。
1239
+ if types is None:
1240
+ voice_file = Path(OUTPUT_ROOT) / '.voice_index' / 'voices.jsonl'
1241
+ voice_rows = 0
1242
+ if voice_file.exists():
1243
+ try:
1244
+ for line_text in voice_file.read_text(encoding='utf-8').splitlines():
1245
+ if not line_text.strip():
1246
+ continue
1247
+ rec = json.loads(line_text)
1248
+ body = str(rec.get('text') or '').strip()
1249
+ if len(body) < PHRASE_MIN_CHARS:
1250
+ continue
1251
+ rows.append((body, str(rec.get('id') or ''), 'voice',
1252
+ str(rec.get('talker') or ''), str(rec.get('time') or ''), ''))
1253
+ voice_rows += 1
1254
+ except Exception as exc:
1255
+ print(f'[WARN] 读语音转写失败({type(exc).__name__}: {exc}),这次不并入',
1256
+ file=sys.stderr)
1257
+ if voice_rows:
1258
+ by_line['voice'] = by_line.get('voice', 0) + voice_rows
1227
1259
  path = Path(db_path or PHRASE_DB)
1228
1260
  path.parent.mkdir(parents=True, exist_ok=True)
1229
1261
  conn = sqlite3.connect(str(path))
@@ -1235,7 +1267,15 @@ def build_phrase_index(line: str = 'all', db_path=None, meta_path=None):
1235
1267
  conn.executemany('INSERT INTO hits(body, rid, line, label, date, title)'
1236
1268
  ' VALUES (?, ?, ?, ?, ?, ?)', rows)
1237
1269
  conn.commit()
1238
- conn.execute('VACUUM')
1270
+ # `VACUUM` 要写临时文件;在受限环境(沙箱 / %TEMP% 不可写)它会报
1271
+ # `unable to open database file`,而**数据其实已经写进去了** —— 那一步只是把库压实。
1272
+ # 所以它失败不能被当成"索引没建好":2026-10-22 实测 155MB 的库内容完整,
1273
+ # 却因为这一行让整个 phrase-build 的退出码变成非 0。
1274
+ try:
1275
+ conn.execute('VACUUM')
1276
+ except Exception as exc:
1277
+ print(f'[WARN] 短语索引已写入,但 VACUUM 失败({type(exc).__name__}: {exc})'
1278
+ '——只是没压实,查询不受影响', file=sys.stderr)
1239
1279
  finally:
1240
1280
  conn.close()
1241
1281
  return {'records': len(rows), 'byLine': by_line, 'textMissing': text_missing,
@@ -0,0 +1,139 @@
1
+ """批量转写语音消息,产出一份可检索的清单(`output/.voice_index/voices.jsonl`)。
2
+
3
+ 为什么要有它:`wechat_voice.py` 只能一条会话一条会话地转,转完就躺在一个按**内容哈希**
4
+ 命名的缓存里——**知道"这段音频说了什么",但不知道"是哪条消息、谁在什么时候发的"**。
5
+ 而检索需要的正是后者。
6
+
7
+ 所以这里做三件事:
8
+ 1. 从 `media_*.db` 的 `VoiceInfo` 按时间取语音(带上会话、发送者、时间);
9
+ 2. 调 `wechat_voice.ensure_transcripts` 填空缺(断点续传:已转过的按内容命中缓存);
10
+ 3. 把 `(消息, 时间, 会话, 文本)` 落成 JSONL,供 `phrase-build` 并进 FTS。
11
+
12
+ 用法:
13
+ python scripts/voice_transcribe.py --days 30
14
+ python scripts/voice_transcribe.py --days 7 --limit 5 # 先试几条
15
+ python scripts/voice_transcribe.py --days 30 --model tiny # 更快、更糙
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import pathlib
22
+ import sys
23
+ import time
24
+ from datetime import datetime, timezone, timedelta
25
+
26
+ SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
27
+ sys.path.insert(0, str(SCRIPT_DIR))
28
+
29
+ from _utils import load_config, decrypt_lock # noqa: E402
30
+ import wechat_voice as wv # noqa: E402
31
+
32
+ ROOT = SCRIPT_DIR.parent
33
+ OUT_DIR = ROOT / 'output' / '.voice_index'
34
+ CACHE_DIR = ROOT / 'output' / '.voice_cache'
35
+ TZ = timezone(timedelta(hours=8))
36
+
37
+
38
+ def _keys(cfg):
39
+ """配置里的密钥是 `lock:` 密文,必须先解密 —— 直接拿密文去派生只会得到"不是数据库"。"""
40
+ key = decrypt_lock(cfg.get('ntKey', '') or '')
41
+ salt = decrypt_lock(cfg.get('ntSalt', '') or '')
42
+ pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
43
+ return key, salt, pw
44
+
45
+
46
+ def collect(cfg, days, limit=0):
47
+ """[(shard, chat_name_id, local_id, create_time, blob, name)],按时间倒序。"""
48
+ key, salt, pw = _keys(cfg)
49
+ db = cfg.get('ntDbPath') or ''
50
+ if not db:
51
+ raise SystemExit('配置里没有 ntDbPath')
52
+ since = int(time.time()) - days * 86400
53
+ rows = []
54
+ for shard in wv.voice_shards(db):
55
+ conn = None
56
+ try:
57
+ conn, cur = wv._open(shard, key, salt, pw)
58
+ cur.execute('SELECT rowid, user_name FROM Name2Id')
59
+ names = {r[0]: r[1] for r in cur.fetchall()}
60
+ cur.execute(
61
+ 'SELECT chat_name_id, local_id, create_time, voice_data FROM VoiceInfo '
62
+ 'WHERE voice_data IS NOT NULL AND create_time >= ? '
63
+ 'ORDER BY create_time DESC', (since,))
64
+ for cid, lid, ts, blob in cur.fetchall():
65
+ rows.append((str(shard), cid, lid, int(ts or 0), blob,
66
+ names.get(cid, str(cid))))
67
+ except Exception as exc:
68
+ print(f'[WARN] {pathlib.Path(shard).name} 读不了({type(exc).__name__}: {exc})',
69
+ file=sys.stderr)
70
+ finally:
71
+ if conn:
72
+ conn.close()
73
+ rows.sort(key=lambda r: r[3], reverse=True)
74
+ if limit and limit > 0:
75
+ rows = rows[:limit]
76
+
77
+ # 会话名从 **contact.db** 补一层:消息库的 `Name2Id` 只有 id,语音清单里
78
+ # 只写 id 的话,搜索结果显示成 `wxid_xxx`(2026-10-22 第一版就是这样)。
79
+ try:
80
+ from network import load_graph
81
+ people, _rooms, _members = load_graph(cfg)
82
+ by_username = {p['username']: (p['remark'] or p['nick'] or p['username'])
83
+ for p in people.values()}
84
+ rows = [(s, c, l, t, b, by_username.get(name, name)) for s, c, l, t, b, name in rows]
85
+ except Exception as exc:
86
+ print(f'[WARN] 读联系人失败({type(exc).__name__}: {exc}),语音清单里显示 id',
87
+ file=sys.stderr)
88
+ return rows
89
+
90
+
91
+ def main():
92
+ ap = argparse.ArgumentParser(description='批量转写微信语音,产出可检索清单')
93
+ ap.add_argument('--days', type=int, default=30, help='只转最近 N 天(默认 30)')
94
+ ap.add_argument('--limit', type=int, default=0, help='最多转写几条(0 = 不限)')
95
+ ap.add_argument('--model', default='small', help='faster-whisper 模型名或本地目录')
96
+ ap.add_argument('--language', default='zh', help='语言提示')
97
+ ap.add_argument('--cache-dir', default='', help='转写缓存目录(换模型时换一个,否则会命中旧模型的结果)')
98
+ args = ap.parse_args()
99
+
100
+ cfg = load_config()
101
+ rows = collect(cfg, args.days, args.limit)
102
+ print(f'最近 {args.days} 天语音: {len(rows)} 条')
103
+ if not rows:
104
+ return 0
105
+ cache_dir = args.cache_dir or str(CACHE_DIR)
106
+ pathlib.Path(cache_dir).mkdir(parents=True, exist_ok=True)
107
+
108
+ voices = {(r[5], r[2]): r[4] for r in rows}
109
+ started = time.time()
110
+ processed, cached, failed = wv.ensure_transcripts(
111
+ voices, cache_dir, model_name=args.model, language=args.language)
112
+ elapsed = time.time() - started
113
+ print(f'转写完成: 新增 {processed},缓存命中 {cached},失败 {failed},耗时 {elapsed/60:.1f} 分钟')
114
+
115
+ cache = wv.TranscriptCache(cache_dir)
116
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
117
+ out = OUT_DIR / 'voices.jsonl'
118
+ written = empty = 0
119
+ with out.open('w', encoding='utf-8') as fh:
120
+ for shard, cid, lid, ts, blob, name in rows:
121
+ text = (cache.get(wv.voice_key(blob)) or '').strip()
122
+ if not text:
123
+ empty += 1
124
+ continue
125
+ dt = datetime.fromtimestamp(ts, tz=TZ)
126
+ fh.write(json.dumps({
127
+ 'id': f'voice:{name}:{lid}',
128
+ 'talker': name,
129
+ 'time': dt.strftime('%Y-%m-%d %H:%M'),
130
+ 'ts': ts,
131
+ 'text': text[:500],
132
+ }, ensure_ascii=False) + '\n')
133
+ written += 1
134
+ print(f'写入 {out}:{written} 条有文字(空转写/解码失败 {empty} 条)')
135
+ return 0
136
+
137
+
138
+ if __name__ == '__main__':
139
+ sys.exit(main())
@@ -56,7 +56,7 @@ def derive_key(seed, wxid):
56
56
 
57
57
 
58
58
  def account_wxid(account_dir_name):
59
- """`wxid_mgnjdl8034eh22_4c8e` -> `wxid_mgnjdl8034eh22`."""
59
+ """`wxid_账号A_后缀` -> `wxid_账号A`."""
60
60
  parts = account_dir_name.rsplit('_', 1)
61
61
  return parts[0] if len(parts) == 2 and len(parts[1]) == 4 else account_dir_name
62
62
 
@@ -234,10 +234,35 @@ class TranscriptCache:
234
234
 
235
235
 
236
236
  def transcribe_wav(wav_path, model, beam_size=1, language=DEFAULT_LANGUAGE):
237
- """Transcript for a WAV file, or '' when nothing was recognised."""
237
+ """Transcript for a WAV file, or '' when nothing was recognised.
238
+
239
+ `condition_on_previous_text=False` 是**针对复读**的:Whisper 会把已生成的文本当作
240
+ 后续解码的上文条件,在听不清的时候这个条件会把同一句反复"确认"下去(实测同一段
241
+ 方言语音稳定输出「我做的事情都没有做过」三遍)。关掉它以后每段独立解码,复读的
242
+ 自我强化链就断了。`vad_filter` 再切掉静音——短语音里静音占比高时最容易触发幻觉。
243
+ """
238
244
  try:
239
- segments, _ = model.transcribe(wav_path, language=language, beam_size=beam_size)
245
+ segments, _ = model.transcribe(
246
+ wav_path, language=language, beam_size=beam_size,
247
+ condition_on_previous_text=False,
248
+ vad_filter=True,
249
+ vad_parameters={'min_silence_duration_ms': 300},
250
+ no_speech_threshold=0.6,
251
+ # 复读的第二道闸:`compression_ratio_threshold` 判"这段输出是不是在重复自己",
252
+ # 超了就提高温度重解(默认 2.4,实测偏松);`temperature` 给一条回退阶梯,
253
+ # 让它在重复时真的敢走下一步而不是原地复读。
254
+ compression_ratio_threshold=2.0,
255
+ temperature=[0.0, 0.2, 0.4, 0.6, 0.8, 1.0],
256
+ repetition_penalty=1.15,
257
+ )
240
258
  return ''.join(segment.text for segment in segments).strip()
259
+ except TypeError:
260
+ # 老版本 faster-whisper 不认这些关键字:退回最朴素的调用,别因为参数报错丢掉转写。
261
+ try:
262
+ segments, _ = model.transcribe(wav_path, language=language, beam_size=beam_size)
263
+ return ''.join(segment.text for segment in segments).strip()
264
+ except Exception:
265
+ return ''
241
266
  except Exception:
242
267
  return ''
243
268