weflow-cli 1.14.1 → 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.
- package/CHANGELOG.md +24 -0
- package/README.md +4 -0
- package/bin/weflow-cli.ts +169 -0
- package/dist/bin/weflow-cli.js +177 -0
- package/dist/bin/weflow-cli.js.map +1 -1
- package/docs/PROJECT_STATE.md +35 -0
- package/package.json +1 -1
- package/scripts/bonds.py +1 -1
- package/scripts/network.py +206 -0
- package/scripts/on_this_day.py +178 -0
- package/scripts/opinion_timeline.py +138 -0
- package/scripts/promises.py +185 -0
- package/scripts/semantic_search.py +32 -0
- package/scripts/voice_transcribe.py +139 -0
- package/scripts/wechat_emoticon.py +1 -1
- package/scripts/wechat_voice.py +27 -2
|
@@ -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))
|
|
@@ -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
|
-
"""`
|
|
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
|
|
package/scripts/wechat_voice.py
CHANGED
|
@@ -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(
|
|
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
|
|