weflow-cli 1.11.0 → 1.12.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.
- package/CHANGELOG.md +200 -0
- package/OPERATIONS.md +120 -1
- package/bin/weflow-cli.ts +53 -3
- package/dist/bin/weflow-cli.js +53 -3
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +3 -1
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +25 -7
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/exportService.d.ts +7 -0
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +1 -1
- package/dist/src/services/exportService.js.map +1 -1
- package/docs/DECISIONS.md +315 -0
- package/docs/MCP.md +1 -1
- package/docs/NEXT_ACTIONS.md +230 -0
- package/docs/PROJECT_STATE.md +91 -10
- package/docs/RELEASING.md +47 -0
- package/package.json +1 -1
- package/scripts/backfill_articles.py +64 -14
- package/scripts/export_chat_html.py +181 -42
- package/scripts/recall_eval.py +247 -0
- package/scripts/semantic_search.py +659 -46
- package/src/services/assistantEval.ts +3 -1
- package/src/services/assistantTools.ts +2471 -2452
- package/src/services/exportService.ts +14 -2
|
@@ -14,6 +14,7 @@ import re
|
|
|
14
14
|
import base64
|
|
15
15
|
import urllib.request
|
|
16
16
|
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
17
18
|
import concurrent.futures
|
|
18
19
|
import shutil
|
|
19
20
|
import struct
|
|
@@ -110,10 +111,13 @@ _FILE_ASSET_SUBDIR = 'files'
|
|
|
110
111
|
# 文档那半的计数(图片那半的计数在写盘时现算)。放在模块级是为了不改 `write_assets` 的返回形状 ——
|
|
111
112
|
# 它已经有两个调用方(markdown 导出与 `--media`),与 COVER_STATE/VOICE_STATE 是同一套写法。
|
|
112
113
|
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
|
|
114
|
+
# **三个词各管一件事,别混**(媒体状态统一后,报告/清单/JSON 三处都按这套说法):
|
|
115
|
+
# `written` = 现在躺在导出目录里的份数("可用数",同一目录跑第二遍时它不为 0)
|
|
116
|
+
# `copied` = **这一轮**新拷进去的份数(第二遍跑就是 0)
|
|
117
|
+
# `unavailable` = 没能落盘的份数(本地没有那份文件 / 太大 / 拷贝失败)
|
|
118
|
+
# 分开是因为只报 `copied` 会把第二遍打印成"0 document(s)",而实际上 67 份都在 —— 那是给自己制造
|
|
119
|
+
# 假象(2026-10-13 真这么打印过一次)。
|
|
120
|
+
FILE_ASSET_STATE = {'written': 0, 'copied': 0, 'unavailable': 0}
|
|
117
121
|
|
|
118
122
|
# Off by default: full-resolution originals are 5-24MB / 3000-5700px each, and
|
|
119
123
|
# downscaling ~500 of them costs minutes (PIL decode dominates). The cache's
|
|
@@ -539,6 +543,10 @@ def scan_account_file_index(account_dir):
|
|
|
539
543
|
except OSError:
|
|
540
544
|
continue
|
|
541
545
|
index.setdefault((normalize_asset_name(fname), size), []).append(path)
|
|
546
|
+
# 排序是为了**可复现**:多候选且消息没带 md5 时取 `[0]`,而 `os.walk` 的顺序不保证稳定 ——
|
|
547
|
+
# 不排的话,同一次导出在两台机器上可能落到不同名字的副本上。
|
|
548
|
+
for paths in index.values():
|
|
549
|
+
paths.sort()
|
|
542
550
|
return index
|
|
543
551
|
|
|
544
552
|
|
|
@@ -2140,10 +2148,15 @@ def build_media_record(local_type, image_b64, image_map, local_id, create_time,
|
|
|
2140
2148
|
if file_info is not None:
|
|
2141
2149
|
# 文档本体(appmsg type 6)走的是另一条路:没有缩略图、也不在图片索引里,能不能导出只看
|
|
2142
2150
|
# 本地 `msg/file/` 有没有那份文件。不并入图片那条链,否则报告会把"文件没下载"写成"没有可靠标识"。
|
|
2151
|
+
#
|
|
2152
|
+
# 状态词是 `found` 而**不是** `exported`:这份报告是在**写 assets 之前**生成的,它答的是
|
|
2153
|
+
# "能不能在源数据里定位到"(`found` / `missing`),答不了"有没有写进导出目录"。
|
|
2154
|
+
# 后者由清单的 `documents.written` 回答(`write_assets` 写的)。两件事用两个词,
|
|
2155
|
+
# 免得"这份文件已导出"这种话在拷贝失败时变成假的。
|
|
2143
2156
|
record['kind'] = 'file'
|
|
2144
2157
|
record['bytes'] = file_info.get('bytes') or None
|
|
2145
2158
|
if file_hit:
|
|
2146
|
-
record['status'] = '
|
|
2159
|
+
record['status'] = 'found'
|
|
2147
2160
|
record['mediaKey'] = 'file'
|
|
2148
2161
|
else:
|
|
2149
2162
|
record['status'] = 'missing'
|
|
@@ -2448,19 +2461,77 @@ p {{ margin:8px 0; font-size:14px; }}
|
|
|
2448
2461
|
</html>'''
|
|
2449
2462
|
|
|
2450
2463
|
|
|
2464
|
+
_WINDOWS_RESERVED_NAMES = frozenset(
|
|
2465
|
+
['con', 'prn', 'aux', 'nul']
|
|
2466
|
+
+ ['com%d' % index for index in range(1, 10)]
|
|
2467
|
+
+ ['lpt%d' % index for index in range(1, 10)])
|
|
2468
|
+
|
|
2469
|
+
|
|
2470
|
+
def _clean_asset_component(text):
|
|
2471
|
+
"""把一段文件名清洗到"任何 Windows 上都能创建"。
|
|
2472
|
+
|
|
2473
|
+
在 `sanitize_filename` 之外多管一件会**静默改结果**的事:首尾的空白与点(Windows 会把 `a .` 存成 `a`,
|
|
2474
|
+
于是清单里的名字和磁盘上的名字对不上)。
|
|
2475
|
+
"""
|
|
2476
|
+
return sanitize_filename(text).strip().strip('.')
|
|
2477
|
+
|
|
2478
|
+
|
|
2451
2479
|
def _asset_file_name(title, used_names, source, md5):
|
|
2452
2480
|
"""落盘的文档文件名:保持原样(只清洗非法字符),重名才加 md5 前 8 位。
|
|
2453
2481
|
|
|
2454
2482
|
不改名是刻意的 —— 那是用户自己的文件名,读者要靠它认东西;微信自己也会加 `(1)`。但两个不同来源
|
|
2455
2483
|
的文件可能同名,那种情况必须区分,否则后来者会覆盖先到者、清单指错文件。
|
|
2484
|
+
|
|
2485
|
+
**截断要在扩展名之前**:真机实测过一个超长标题,80 字符上限把 `.pdf` 一起切掉了,落盘成没有扩展名的
|
|
2486
|
+
文件 —— 系统与编辑器都不知道该用什么打开它。
|
|
2456
2487
|
"""
|
|
2457
|
-
|
|
2488
|
+
stem, ext = os.path.splitext(str(title or ''))
|
|
2489
|
+
# 扩展名单独保留(也过一遍清洗),但不参与"给主干剩下的额度";异常长的"扩展名"当没扩展名处理
|
|
2490
|
+
ext = _clean_asset_component(ext).replace('.', '')
|
|
2491
|
+
if len(ext) > 16:
|
|
2492
|
+
stem, ext = str(title or ''), ''
|
|
2493
|
+
stem = _clean_asset_component(stem)
|
|
2494
|
+
room = max(1, 80 - (len(ext) + 1 if ext else 0))
|
|
2495
|
+
base = stem[:room] or 'file'
|
|
2496
|
+
if base.lower() in _WINDOWS_RESERVED_NAMES:
|
|
2497
|
+
# `CON.pdf` 这类在 Windows 上打不开(会被当成设备名)—— 加个前缀就正常了
|
|
2498
|
+
base = '_' + base
|
|
2499
|
+
safe = base + ('.' + ext if ext else '')
|
|
2458
2500
|
if used_names.get(safe) in (None, source):
|
|
2459
2501
|
return safe
|
|
2460
2502
|
stem, ext = os.path.splitext(safe)
|
|
2461
2503
|
return '%s_%s%s' % (stem, (md5 or 'copy')[:8], ext)
|
|
2462
2504
|
|
|
2463
2505
|
|
|
2506
|
+
def _load_existing_manifest(manifest_path, out_dir):
|
|
2507
|
+
"""读回同目录里已有的清单条目(v1 的裸字符串也认),丢掉**已经不在磁盘上**的那些。
|
|
2508
|
+
|
|
2509
|
+
为什么不是直接覆盖:清单住在 `<会话>_assets/` 这个**共享**目录里,而导出可以分批做
|
|
2510
|
+
(`--limit 200` 一次、整段再一次;md 一次、`json --media` 再一次)。覆盖会让窄窗口那次的清单把宽窗口
|
|
2511
|
+
那次的条目**删掉**,而 md 正文里的链接还在 —— 之后任何"读清单带媒体"的导出都会少一批,
|
|
2512
|
+
且两边都不报错。2026-10-13 真机就是这么撞上的:整段导出的 585 条被 `--limit 600` 那次的 150 条盖掉。
|
|
2513
|
+
"""
|
|
2514
|
+
try:
|
|
2515
|
+
with open(manifest_path, 'r', encoding='utf-8') as handle:
|
|
2516
|
+
data = json.load(handle)
|
|
2517
|
+
except (OSError, ValueError):
|
|
2518
|
+
return {}
|
|
2519
|
+
messages = data.get('messages') if isinstance(data, dict) else None
|
|
2520
|
+
if not isinstance(messages, dict):
|
|
2521
|
+
return {}
|
|
2522
|
+
kept = {}
|
|
2523
|
+
for key, value in messages.items():
|
|
2524
|
+
if isinstance(value, str):
|
|
2525
|
+
record = {'path': value, 'kind': 'image'}
|
|
2526
|
+
elif isinstance(value, dict) and isinstance(value.get('path'), str):
|
|
2527
|
+
record = value
|
|
2528
|
+
else:
|
|
2529
|
+
continue
|
|
2530
|
+
if os.path.isfile(os.path.join(out_dir, record['path'].replace('/', os.sep))):
|
|
2531
|
+
kept[key] = record
|
|
2532
|
+
return kept
|
|
2533
|
+
|
|
2534
|
+
|
|
2464
2535
|
def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
|
|
2465
2536
|
"""把媒体**落盘**并把 `manifest.json` 写好;返回 `(assets 目录名, manifest, 新写文件数)`。
|
|
2466
2537
|
|
|
@@ -2477,7 +2548,9 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
|
|
|
2477
2548
|
assets = assets_dir or os.path.join(out_dir, file_prefix + '_assets')
|
|
2478
2549
|
os.makedirs(assets, exist_ok=True)
|
|
2479
2550
|
|
|
2480
|
-
|
|
2551
|
+
# 与已有清单**合并**:分批导出时,窄窗口那一次不该把宽窗口那次的条目删掉(见 `_load_existing_manifest`)。
|
|
2552
|
+
manifest_path = os.path.join(assets, 'manifest.json')
|
|
2553
|
+
manifest = _load_existing_manifest(manifest_path, out_dir)
|
|
2481
2554
|
written = 0
|
|
2482
2555
|
for item in formatted:
|
|
2483
2556
|
blob = item.get('image_b64')
|
|
@@ -2502,7 +2575,6 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
|
|
|
2502
2575
|
'kind': 'image',
|
|
2503
2576
|
}
|
|
2504
2577
|
|
|
2505
|
-
file_exported = 0
|
|
2506
2578
|
file_written = 0
|
|
2507
2579
|
file_unavailable = 0
|
|
2508
2580
|
files_dir = os.path.join(assets, _FILE_ASSET_SUBDIR)
|
|
@@ -2526,13 +2598,18 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
|
|
|
2526
2598
|
info.get('md5'))
|
|
2527
2599
|
target = os.path.join(files_dir, name)
|
|
2528
2600
|
if used_names.get(name) != source:
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2601
|
+
try:
|
|
2602
|
+
os.makedirs(files_dir, exist_ok=True)
|
|
2603
|
+
if not os.path.exists(target):
|
|
2604
|
+
with open(target, 'wb') as dst, open(source, 'rb') as src:
|
|
2605
|
+
shutil.copyfileobj(src, dst)
|
|
2606
|
+
file_written += 1
|
|
2607
|
+
except OSError:
|
|
2608
|
+
# 一个文件拷不进去(名字太怪、路径过长、目标被占用)不能让**整次导出**在最后一步崩掉 ——
|
|
2609
|
+
# 前面几分钟的解析与图片抽取都在,代价只是这一份文档缺失,且它会计入 unavailable。
|
|
2610
|
+
file_unavailable += 1
|
|
2611
|
+
continue
|
|
2534
2612
|
used_names[name] = source
|
|
2535
|
-
file_exported += 1
|
|
2536
2613
|
manifest['%s:%s' % (item.get('local_id'), item.get('create_time'))] = {
|
|
2537
2614
|
'path': os.path.relpath(target, out_dir).replace('\\', '/'),
|
|
2538
2615
|
'kind': 'file',
|
|
@@ -2540,16 +2617,67 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
|
|
|
2540
2617
|
'bytes': size,
|
|
2541
2618
|
}
|
|
2542
2619
|
|
|
2543
|
-
|
|
2544
|
-
|
|
2620
|
+
# `written` 数的是**合并后清单里真实存在的文档条目**(第二遍跑同一目录时它不为 0,而新拷贝是 0);
|
|
2621
|
+
# `copied` 才是"这次新拷了几个"。两个都写出来,免得读清单的人把后者当前者。
|
|
2622
|
+
file_written_total = sum(1 for record in manifest.values() if record.get('kind') == 'file')
|
|
2623
|
+
FILE_ASSET_STATE['written'] = file_written_total
|
|
2624
|
+
FILE_ASSET_STATE['copied'] = file_written
|
|
2545
2625
|
FILE_ASSET_STATE['unavailable'] = file_unavailable
|
|
2546
|
-
with open(
|
|
2626
|
+
with open(manifest_path, 'w', encoding='utf-8') as fh:
|
|
2547
2627
|
json.dump({'schema': 'weflow-media-manifest/v2', 'messages': manifest,
|
|
2548
|
-
'documents': {'
|
|
2628
|
+
'documents': {'written': file_written_total, 'copied': file_written,
|
|
2629
|
+
'unavailable': file_unavailable}},
|
|
2549
2630
|
fh, ensure_ascii=False, indent=2)
|
|
2550
2631
|
return os.path.basename(os.path.normpath(assets)), manifest, written + file_written
|
|
2551
2632
|
|
|
2552
2633
|
|
|
2634
|
+
def _markdown_link_target(rel):
|
|
2635
|
+
"""把相对路径转成 markdown 链接目标:只编码会**截断链接**的那些字符。
|
|
2636
|
+
|
|
2637
|
+
真机实测(2026-10-13,重导咸鱼梦想家时核对产物才发现):文件名叫
|
|
2638
|
+
`…研究生学位(毕业)论文开题报告(2).pdf`,写出来是 `[名字](…报告(2).pdf)` —— 任何 markdown 渲染器
|
|
2639
|
+
都在第一个半角 `)` 处结束目标,链接指向 `…报告(2`,点开是空的。带空格的文件名同理(目标在空白处结束)。
|
|
2640
|
+
所以按段编码这几个字符(括号、空白、`<`、`>`、`[`、`]`、`#`、`?`、`%`、引号、反斜杠),
|
|
2641
|
+
**中文保持原样**:本地预览器都按 UTF-8 解路径,编码成 `%E5%…` 反而让人看不懂。
|
|
2642
|
+
"""
|
|
2643
|
+
out = []
|
|
2644
|
+
for ch in str(rel or '').replace('\\', '/'):
|
|
2645
|
+
if ch.isspace() or ch in '()[]<>#"\'%?\\':
|
|
2646
|
+
out.append('%%%02X' % ord(ch))
|
|
2647
|
+
else:
|
|
2648
|
+
out.append(ch)
|
|
2649
|
+
return ''.join(out)
|
|
2650
|
+
|
|
2651
|
+
|
|
2652
|
+
def media_json_summary(media_items, media_report_path=None, manifest=None, asset_new=0, assets_dir=''):
|
|
2653
|
+
"""命令行最后那行 JSON 里的媒体摘要 —— **三种格式共用同一份词汇与同一份计数逻辑**。
|
|
2654
|
+
|
|
2655
|
+
每个词只回答一个问题,互不覆盖:
|
|
2656
|
+
|
|
2657
|
+
* `reportStatus`:按**能不能在源数据里定位到**分类(图片是 `embedded`/`remote-fetched`/`missing`,
|
|
2658
|
+
文档是 `found`/`missing`)。这是"这份媒体在源数据里有没有"。
|
|
2659
|
+
* `assets.written / copied`:**有没有写进导出目录**(written 是现在那里的总数,copied 是这一轮新写的)。
|
|
2660
|
+
与上一条是两回事:源数据里找得到的,可能因为太大或拷贝失败而没写进去。
|
|
2661
|
+
* `documents.unavailable`:没能落盘的份数(没找到 / 太大 / 拷贝失败)。
|
|
2662
|
+
|
|
2663
|
+
三处原本各拼一份 JSON(还各写了不同的键名),同一个事实就有三种说法;这里收成一份,
|
|
2664
|
+
顺带把**报告文件名**一起给出来(此前只有 HTML 那条给)。不含路径、正文或密钥。
|
|
2665
|
+
"""
|
|
2666
|
+
counts = {}
|
|
2667
|
+
for item in media_items or []:
|
|
2668
|
+
status = item.get('status') or 'unknown'
|
|
2669
|
+
counts[status] = counts.get(status, 0) + 1
|
|
2670
|
+
return {
|
|
2671
|
+
'report': os.path.basename(media_report_path) if media_report_path else None,
|
|
2672
|
+
'reportItems': len(media_items or []),
|
|
2673
|
+
'reportStatus': counts,
|
|
2674
|
+
'assets': {'items': len(manifest or {}), 'new': asset_new, 'dir': assets_dir or None},
|
|
2675
|
+
'documents': {'written': FILE_ASSET_STATE['written'],
|
|
2676
|
+
'copied': FILE_ASSET_STATE['copied'],
|
|
2677
|
+
'unavailable': FILE_ASSET_STATE['unavailable']},
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
|
|
2553
2681
|
def write_markdown(out_dir, file_prefix, formatted, assets_dir=''):
|
|
2554
2682
|
"""markdown 正文 + 媒体落盘(落盘那半在 `write_assets`,两者共用同一份清单)。"""
|
|
2555
2683
|
assets_name, manifest, written = write_assets(out_dir, file_prefix, formatted, assets_dir)
|
|
@@ -2573,9 +2701,15 @@ def write_markdown(out_dir, file_prefix, formatted, assets_dir=''):
|
|
|
2573
2701
|
if rel and kind == 'file':
|
|
2574
2702
|
# 文档不是图片:`` 会被当成坏图,要写成链接,链接文字用原始文件名。
|
|
2575
2703
|
keep = '' if (not body or body.startswith('[文件]')) else body + chr(10) + chr(10)
|
|
2576
|
-
|
|
2704
|
+
# 链接文字用原始文件名;`[`/`]` 换成实体(`[草稿] 方案.docx` 会把链接本身的结构打断),
|
|
2705
|
+
# 控制字符去掉(标题里可能带换行/制表符,落盘名已去掉,文字也得跟上,否则渲染出来断开)
|
|
2706
|
+
text = ''.join(ch for ch in (label or os.path.basename(rel))
|
|
2707
|
+
if ord(ch) >= 32 and ord(ch) != 127)
|
|
2708
|
+
text = text.replace('[', '[').replace(']', ']')
|
|
2709
|
+
body = keep + '[%s](%s)' % (text, _markdown_link_target(rel))
|
|
2577
2710
|
elif rel:
|
|
2578
|
-
body = ('' if (not body or body == '[图片]') else body + chr(10) + chr(10))
|
|
2711
|
+
body = ('' if (not body or body == '[图片]') else body + chr(10) + chr(10)) \
|
|
2712
|
+
+ '' % _markdown_link_target(rel)
|
|
2579
2713
|
lines.extend(['**%s** %s' % (who, stamp), '', body.strip() or '(空)', ''])
|
|
2580
2714
|
|
|
2581
2715
|
md_path = os.path.join(out_dir, file_prefix + '.md')
|
|
@@ -2820,35 +2954,39 @@ def main():
|
|
|
2820
2954
|
if args.assets_only:
|
|
2821
2955
|
# 只抽媒体:其它格式的 `--media` 会先跑这一步,再各自消费清单。
|
|
2822
2956
|
assets_name, manifest, asset_new = write_assets(args.out, file_prefix, formatted, args.assets_dir)
|
|
2823
|
-
|
|
2824
|
-
|
|
2957
|
+
# 这一步也写媒体报告:三种形态的调用方应当拿到同一份东西(状态计数 + 报告文件名),
|
|
2958
|
+
# 少写这一份的话,`--media` 的调用者只能看到文档计数、看不到"哪些媒体没嵌上、为什么"。
|
|
2959
|
+
media_report_path = None
|
|
2960
|
+
if media_items:
|
|
2961
|
+
media_report_path = os.path.join(args.out, f'{file_prefix}_media.json')
|
|
2962
|
+
with open(media_report_path, 'w', encoding='utf-8') as handle:
|
|
2963
|
+
json.dump(build_media_report(media_items, display_name, args.talker, len(formatted)),
|
|
2964
|
+
handle, ensure_ascii=False, indent=2)
|
|
2965
|
+
print(f"Assets: {len(manifest)} media item(s), {FILE_ASSET_STATE['written']} document(s) "
|
|
2966
|
+
f"({FILE_ASSET_STATE['unavailable']} unavailable, {FILE_ASSET_STATE['copied']} new) "
|
|
2825
2967
|
f"({asset_new} new) -> {assets_name}")
|
|
2826
2968
|
print(json.dumps({"success": True, "total": len(formatted),
|
|
2827
2969
|
"files": [os.path.join(args.out, assets_name)], "format": "assets",
|
|
2828
|
-
"
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
"documentsUnavailable": FILE_ASSET_STATE['unavailable'],
|
|
2832
|
-
"assetsDir": assets_name}, ensure_ascii=False))
|
|
2970
|
+
"media": media_json_summary(media_items, media_report_path, manifest,
|
|
2971
|
+
asset_new, assets_name)},
|
|
2972
|
+
ensure_ascii=False))
|
|
2833
2973
|
conn.close()
|
|
2834
2974
|
return
|
|
2835
2975
|
if args.format == 'md':
|
|
2836
2976
|
md_path, asset_total, asset_new, assets_name = write_markdown(args.out, file_prefix, formatted, args.assets_dir)
|
|
2977
|
+
media_report_path = None # 没有媒体时下面那段不跑,但 JSON 里仍要如实给出 None
|
|
2837
2978
|
if media_items:
|
|
2838
2979
|
media_report_path = os.path.join(args.out, f'{file_prefix}_media.json')
|
|
2839
2980
|
with open(media_report_path, 'w', encoding='utf-8') as handle:
|
|
2840
2981
|
json.dump(build_media_report(media_items, display_name, args.talker, len(formatted)),
|
|
2841
2982
|
handle, ensure_ascii=False, indent=2)
|
|
2842
2983
|
print(f"Markdown: {len(formatted)} message(s), {asset_total} media item(s), "
|
|
2843
|
-
f"{FILE_ASSET_STATE['
|
|
2844
|
-
f"{FILE_ASSET_STATE['
|
|
2984
|
+
f"{FILE_ASSET_STATE['written']} document(s) ({FILE_ASSET_STATE['unavailable']} unavailable, "
|
|
2985
|
+
f"{FILE_ASSET_STATE['copied']} new) ({asset_new} new) -> {os.path.basename(md_path)}")
|
|
2845
2986
|
print(json.dumps({"success": True, "total": len(formatted), "parts": 1,
|
|
2846
2987
|
"files": [md_path], "format": "md",
|
|
2847
|
-
"
|
|
2848
|
-
|
|
2849
|
-
"documentsNew": FILE_ASSET_STATE['written'],
|
|
2850
|
-
"documentsUnavailable": FILE_ASSET_STATE['unavailable'],
|
|
2851
|
-
"assetsDir": assets_name},
|
|
2988
|
+
"media": media_json_summary(media_items, media_report_path, manifest=None,
|
|
2989
|
+
asset_new=asset_new, assets_dir=assets_name)},
|
|
2852
2990
|
ensure_ascii=False))
|
|
2853
2991
|
conn.close()
|
|
2854
2992
|
return
|
|
@@ -2898,17 +3036,13 @@ def main():
|
|
|
2898
3036
|
# Print JSON summary for CLI integration.
|
|
2899
3037
|
# The four original keys are untouched; `media` is purely additive and the
|
|
2900
3038
|
# one existing consumer (src/services/exportService.ts) ignores it.
|
|
3039
|
+
# 三种格式(html/md/assets)用**同一个** media_json_summary,词汇与计数不再各说各话。
|
|
2901
3040
|
print(json.dumps({
|
|
2902
3041
|
"success": True,
|
|
2903
3042
|
"total": total,
|
|
2904
3043
|
"parts": len(html_files),
|
|
2905
3044
|
"files": html_files,
|
|
2906
|
-
"media": (
|
|
2907
|
-
"schema": "weflow-media-report/v1",
|
|
2908
|
-
"records": len(media_items),
|
|
2909
|
-
"status": media_report['counts'],
|
|
2910
|
-
"report": os.path.basename(media_report_path) if media_report_path else None,
|
|
2911
|
-
} if media_report else None),
|
|
3045
|
+
"media": media_json_summary(media_items, media_report_path),
|
|
2912
3046
|
}))
|
|
2913
3047
|
|
|
2914
3048
|
|
|
@@ -2924,8 +3058,13 @@ def _phase(label):
|
|
|
2924
3058
|
|
|
2925
3059
|
|
|
2926
3060
|
def sanitize_filename(name: str) -> str:
|
|
2927
|
-
"""Remove characters unsafe for filenames.
|
|
2928
|
-
|
|
3061
|
+
"""Remove characters unsafe for filenames.
|
|
3062
|
+
|
|
3063
|
+
控制字符也要去掉:它们不在"非法字符"那张表里,但 Windows 创建时会直接
|
|
3064
|
+
`OSError: Invalid argument`(2026-10-13 实测到一个带制表符的标题把整个导出搞崩)。
|
|
3065
|
+
"""
|
|
3066
|
+
kept = ''.join(ch for ch in str(name or '') if ord(ch) >= 32 and ord(ch) != 127)
|
|
3067
|
+
return re.sub(r'[\\/:*?"<>|]', '_', kept)[:80]
|
|
2929
3068
|
|
|
2930
3069
|
|
|
2931
3070
|
if __name__ == '__main__':
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""语义索引的**召回/排序评价**(可复跑、可对比的评测台)。
|
|
3
|
+
|
|
4
|
+
为什么要有它:2026-10-15 之前,检索质量的结论都来自"手工试问 + 看前几条"——那种判据查不出
|
|
5
|
+
"改完是变好还是变坏",也复现不了。指标一律用业界标准口径,不自创:
|
|
6
|
+
|
|
7
|
+
· Recall@1/5/10 —— 该回来的回来没有(RAG 真正在乎的是 @5/@10)
|
|
8
|
+
· MRR@10 —— 第一条正确结果的倒数名次(单答案查询的主指标,MS MARCO 口径)
|
|
9
|
+
· nDCG@10 —— 排序质量(BEIR / MTEB 的主指标;这里是二值相关性、单条标准答案,
|
|
10
|
+
所以 nDCG@10 = 1/log2(1+rank),命中在第 10 名之外记 0)
|
|
11
|
+
· Hit@1 —— 与 Recall@1 同义,单列是因为中文规范(《人工智能 检索增强生成系统测评
|
|
12
|
+
规范》2024003631)里用的是"命中率"这个词
|
|
13
|
+
· 覆盖(coverage)—— **这一条是我们这类语料特有的**:答案在库里根本不存在时,任何排序都救不了,
|
|
14
|
+
把它算进召回率是在诬赖排序。所以每题先判"库里有没有"。
|
|
15
|
+
|
|
16
|
+
两套标准答案(都不依赖人工标注、也不把用户数据写进仓库):
|
|
17
|
+
· `frag` —— 从记录**自己正文中段**取 12 字干净片段当问句(唯一性已校验)。测"局部能不能召回整条"。
|
|
18
|
+
· `title` —— 文章用它的标题、聊天用它开头 30 字。这是用户最常干的事(记得那句话)。
|
|
19
|
+
· `question`(可选)—— 你手写的真问题 + 一个"答案里必然出现的字面量"当锚,走 `--queries-file`
|
|
20
|
+
(TSV:问题<TAB>锚<TAB>线)。**该文件请放在仓库外**:锚串取自你的语料。
|
|
21
|
+
|
|
22
|
+
`--dedup` 会先把**同文本**的记录合并再排名(多号转载同一篇时,top-k 里塞满同一份内容会虚高
|
|
23
|
+
召回、掏空信息量)。它与不合并的两种名次都报,差多少一眼能看出来。
|
|
24
|
+
|
|
25
|
+
用法(调 embedding 服务,需 --yes):
|
|
26
|
+
python scripts/recall_eval.py --records 30 --yes
|
|
27
|
+
python scripts/recall_eval.py --records 30 --dedup --json --yes
|
|
28
|
+
python scripts/recall_eval.py --queries-file D:/tmp/q.tsv --records 0 --yes
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import importlib.util
|
|
33
|
+
import json
|
|
34
|
+
import math
|
|
35
|
+
import os
|
|
36
|
+
import random
|
|
37
|
+
import re
|
|
38
|
+
import sys
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
HERE = Path(__file__).resolve().parent
|
|
42
|
+
sys.path.insert(0, str(HERE))
|
|
43
|
+
|
|
44
|
+
spec = importlib.util.spec_from_file_location('semantic_search', HERE / 'semantic_search.py')
|
|
45
|
+
ss = importlib.util.module_from_spec(spec)
|
|
46
|
+
spec.loader.exec_module(ss)
|
|
47
|
+
|
|
48
|
+
CJK = lambda ch: '\u4e00' <= ch <= '\u9fff'
|
|
49
|
+
BANNED = set(' \t\n\r:/\\"\'|<>()()【】[],;○●·、。!?,')
|
|
50
|
+
JUNK = set('=_&%/.?#@~^`$*+{}[]')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def clean_window(text, mid, length=12):
|
|
54
|
+
"""取一段干净的中文片段(≥60% 汉字、不含标点/URL 残片/长数字)——否则会拿 URL 碎片当问句。"""
|
|
55
|
+
for start in range(max(0, mid), max(0, min(len(text) - length, mid + 60))):
|
|
56
|
+
w = text[start:start + length]
|
|
57
|
+
if len(w) < length or set(w) & BANNED or set(w) & JUNK:
|
|
58
|
+
continue
|
|
59
|
+
if sum(1 for c in w if CJK(c)) / len(w) < 0.6 or re.search(r'\d{5,}', w):
|
|
60
|
+
continue
|
|
61
|
+
return w
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def rank_of(order, target_group):
|
|
66
|
+
"""目标(或其同文本组)在结果里的名次;不在表里返回 None。"""
|
|
67
|
+
for i, idx in enumerate(order, 1):
|
|
68
|
+
if idx in target_group:
|
|
69
|
+
return i
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def metrics(rank, k=10):
|
|
74
|
+
hit = rank is not None and rank <= k
|
|
75
|
+
return {
|
|
76
|
+
'hit@1': 1.0 if rank == 1 else 0.0,
|
|
77
|
+
'recall@1': 1.0 if rank == 1 else 0.0,
|
|
78
|
+
'recall@5': 1.0 if rank is not None and rank <= 5 else 0.0,
|
|
79
|
+
'recall@10': 1.0 if hit else 0.0,
|
|
80
|
+
'mrr@10': (1.0 / rank) if hit else 0.0,
|
|
81
|
+
'ndcg@10': (1.0 / math.log2(1 + rank)) if hit else 0.0,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_cases(meta, texts, per_line, seed):
|
|
86
|
+
"""自动造题:每线抽 per_line 条记录,各出 frag / title 两种问句。"""
|
|
87
|
+
groups = {}
|
|
88
|
+
for i, t in enumerate(texts):
|
|
89
|
+
groups.setdefault(hash(t), []).append(i)
|
|
90
|
+
|
|
91
|
+
rng = random.Random(seed)
|
|
92
|
+
cases = []
|
|
93
|
+
for kind, line, minlen in (('article', 'wiki', 220), ('chat', 'chat', 70)):
|
|
94
|
+
pool = [i for i, m in enumerate(meta) if m.get('type') == kind and len(texts[i]) >= minlen]
|
|
95
|
+
rng.shuffle(pool)
|
|
96
|
+
picked = 0
|
|
97
|
+
for i in pool:
|
|
98
|
+
if picked >= per_line:
|
|
99
|
+
break
|
|
100
|
+
t = texts[i]
|
|
101
|
+
body = t[t.find('\n') + 1:] if kind == 'article' and t.find('\n') >= 0 else t
|
|
102
|
+
frag = clean_window(body, len(body) // 3) or clean_window(body, len(body) // 2)
|
|
103
|
+
title = (meta[i].get('title') or '').strip() or clean_window(t, 0, 30)
|
|
104
|
+
if not frag or not title:
|
|
105
|
+
continue
|
|
106
|
+
group = set(groups[hash(t)])
|
|
107
|
+
cases.append({'form': 'frag', 'line': line, 'idx': i, 'group': group, 'query': frag})
|
|
108
|
+
cases.append({'form': 'title', 'line': line, 'idx': i, 'group': group, 'query': title})
|
|
109
|
+
picked += 1
|
|
110
|
+
return cases, groups
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_questions(path, texts):
|
|
114
|
+
"""TSV:问题<TAB>锚<TAB>线 → 标准答案 = 含锚的记录集合(覆盖为 0 的题单独列出,不计入召回)。"""
|
|
115
|
+
cases = []
|
|
116
|
+
for raw in Path(path).read_text(encoding='utf-8').splitlines():
|
|
117
|
+
raw = raw.strip()
|
|
118
|
+
if not raw or raw.startswith('#') or '\t' not in raw:
|
|
119
|
+
continue
|
|
120
|
+
parts = raw.split('\t')
|
|
121
|
+
q, anchor = parts[0].strip(), parts[1].strip()
|
|
122
|
+
line = parts[2].strip() if len(parts) > 2 else 'all'
|
|
123
|
+
truth = {i for i, t in enumerate(texts) if anchor.lower() in t.lower()}
|
|
124
|
+
cases.append({'form': 'question', 'line': line, 'idx': -1, 'group': truth or set(),
|
|
125
|
+
'query': q, 'anchor': anchor, 'covered': bool(truth)})
|
|
126
|
+
return cases
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main():
|
|
130
|
+
ap = argparse.ArgumentParser(description='语义索引召回/排序评测台')
|
|
131
|
+
ap.add_argument('--records', type=int, default=30, help='每条线抽多少条记录造题(默认 30)')
|
|
132
|
+
ap.add_argument('--queries-file', help='手写问句 TSV(问题<TAB>锚<TAB>线);请放仓库外')
|
|
133
|
+
ap.add_argument('--top-k', type=int, default=10)
|
|
134
|
+
ap.add_argument('--seed', type=int, default=7)
|
|
135
|
+
ap.add_argument('--dedup', action='store_true', help='排名前把同文本记录合并')
|
|
136
|
+
ap.add_argument('--rerank', action='store_true',
|
|
137
|
+
help='调真正的重排(先试 Jev 契约,再退到已配置的对话模型)')
|
|
138
|
+
ap.add_argument('--mmr', action='store_true', help='排名后做 MMR 去冗余(λ=0.7)')
|
|
139
|
+
ap.add_argument('--rerank-pool', type=int, default=ss.RERANK_CANDIDATES,
|
|
140
|
+
help='重排看多少条候选(默认 %d)' % ss.RERANK_CANDIDATES)
|
|
141
|
+
ap.add_argument('--json', action='store_true')
|
|
142
|
+
ap.add_argument('--yes', action='store_true', help='确认调用云端 embedding(每条约十几 tokens)')
|
|
143
|
+
args = ap.parse_args()
|
|
144
|
+
|
|
145
|
+
if not args.yes:
|
|
146
|
+
raise SystemExit('这一步会调用 embedding 服务(花钱、且问句出境)。确认后加 --yes。')
|
|
147
|
+
|
|
148
|
+
meta = json.loads(ss.META_FILE.read_text(encoding='utf-8'))
|
|
149
|
+
vectors = None
|
|
150
|
+
import numpy as np
|
|
151
|
+
vectors = np.load(ss.VECTORS_FILE, mmap_mode='r')
|
|
152
|
+
texts = [m.get('text') or '' for m in meta]
|
|
153
|
+
|
|
154
|
+
cases, groups = build_cases(meta, texts, args.records, args.seed) if args.records else ([], {})
|
|
155
|
+
if args.queries_file:
|
|
156
|
+
cases += load_questions(args.queries_file, texts)
|
|
157
|
+
if not cases:
|
|
158
|
+
raise SystemExit('没有题(--records 0 且没给 --queries-file)')
|
|
159
|
+
|
|
160
|
+
key = ss.get_dashscope_key(ss.load_config())
|
|
161
|
+
embs = ss.get_embeddings([c['query'] for c in cases], key)
|
|
162
|
+
if any(all(v == 0 for v in e) for e in embs):
|
|
163
|
+
raise SystemExit('embedding 服务返回全零 —— 先查 key/网络,别拿这个结果下结论')
|
|
164
|
+
|
|
165
|
+
rows = []
|
|
166
|
+
for c, emb in zip(cases, embs):
|
|
167
|
+
v = np.array(emb, dtype=np.float32)
|
|
168
|
+
v = v / np.linalg.norm(v)
|
|
169
|
+
sims = np.asarray(vectors @ v)
|
|
170
|
+
types = ss.line_types(c['line'])
|
|
171
|
+
if types is not None:
|
|
172
|
+
sims = np.where(np.array([str(m.get('type', '')) in types for m in meta]), sims, -np.inf)
|
|
173
|
+
order = [int(i) for i in np.argsort(sims)[::-1]]
|
|
174
|
+
order = [i for i in order if np.isfinite(sims[i])]
|
|
175
|
+
if args.rerank:
|
|
176
|
+
# 与 `search()` 同一条路:先按原契约(Jev)排,失败再退到已配置的对话模型
|
|
177
|
+
pool = [{**meta[i], '_idx': i, 'score': float(sims[i])}
|
|
178
|
+
for i in order[:args.rerank_pool]]
|
|
179
|
+
ranked = ss.rerank_dispatch(c['query'], pool, ss.create_client(),
|
|
180
|
+
pool_size=args.rerank_pool)
|
|
181
|
+
order = [it['_idx'] for it in ranked] + list(order[args.rerank_pool:])
|
|
182
|
+
if args.mmr:
|
|
183
|
+
pool2 = [{**meta[i], '_row': i, 'rerankScore': r.get('rerankScore')}
|
|
184
|
+
for i, r in zip(order[:args.rerank_pool], ranked)]
|
|
185
|
+
order = [it['_row'] for it in ss.mmr_select(pool2, vectors, args.top_k)]
|
|
186
|
+
if args.dedup:
|
|
187
|
+
seen, keep = set(), []
|
|
188
|
+
for i in order:
|
|
189
|
+
h = hash(texts[i])
|
|
190
|
+
if h in seen:
|
|
191
|
+
continue
|
|
192
|
+
seen.add(h)
|
|
193
|
+
keep.append(i)
|
|
194
|
+
order = keep
|
|
195
|
+
rank = rank_of(order, c['group'])
|
|
196
|
+
# 多样性:最终 top-k 里两两余弦的均值 —— 越低说明结果越不重复。
|
|
197
|
+
# 加它是因为 MMR 这类去冗余与「单答案召回」是相冲的,只看召回会误判。
|
|
198
|
+
top = [i for i in order[:args.top_k] if i < len(texts)]
|
|
199
|
+
div = None
|
|
200
|
+
if len(top) >= 2:
|
|
201
|
+
mat = np.asarray(vectors[top], dtype=np.float32)
|
|
202
|
+
sims = mat @ mat.T
|
|
203
|
+
n = len(top)
|
|
204
|
+
div = float((sims.sum() - np.trace(sims)) / (n * (n - 1)))
|
|
205
|
+
rows.append({**c, 'group': None, 'rank': rank, 'div': div,
|
|
206
|
+
'metrics': metrics(rank, args.top_k)})
|
|
207
|
+
|
|
208
|
+
def agg(sel):
|
|
209
|
+
covered = [r for r in sel if r['rank'] is not None or (r.get('covered') is not False)]
|
|
210
|
+
scored = [r for r in covered if r['rank'] is not None]
|
|
211
|
+
out = {'n': len(sel), 'n_covered': len(covered),
|
|
212
|
+
'n_coverage_gap': sum(1 for r in sel if r.get('covered') is False)}
|
|
213
|
+
for k in ('hit@1', 'recall@1', 'recall@5', 'recall@10', 'mrr@10', 'ndcg@10'):
|
|
214
|
+
out[k] = (sum(r['metrics'][k] for r in covered) / len(covered)) if covered else 0.0
|
|
215
|
+
out['n_recalled'] = len(scored)
|
|
216
|
+
divs = [r['div'] for r in sel if r.get('div') is not None]
|
|
217
|
+
out['pairwiseDiversity'] = (sum(divs) / len(divs)) if divs else None
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
report = {'config': {'records': args.records, 'seed': args.seed, 'dedup': args.dedup,
|
|
221
|
+
'rerank': args.rerank, 'rerankPool': args.rerank_pool, 'mmr': args.mmr, 'rerankState': dict(ss.RERANK_STATE),
|
|
222
|
+
'topK': args.top_k, 'indexCounts': ss.line_counts()},
|
|
223
|
+
'overall': agg(rows), 'byGroup': {}, 'cases': [
|
|
224
|
+
{'form': r['form'], 'line': r['line'], 'query': r['query'], 'rank': r['rank']}
|
|
225
|
+
for r in rows]}
|
|
226
|
+
for form in sorted({r['form'] for r in rows}):
|
|
227
|
+
for line in sorted({r['line'] for r in rows if r['form'] == form}):
|
|
228
|
+
sel = [r for r in rows if r['form'] == form and r['line'] == line]
|
|
229
|
+
if sel:
|
|
230
|
+
report['byGroup']['%s/%s' % (form, line)] = agg(sel)
|
|
231
|
+
|
|
232
|
+
if args.json:
|
|
233
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
234
|
+
return
|
|
235
|
+
print('题数 %d(覆盖缺口 %d) dedup=%s' % (report['overall']['n'],
|
|
236
|
+
report['overall']['n_coverage_gap'], args.dedup))
|
|
237
|
+
print('%-16s %6s %8s %8s %8s %8s %8s %9s'
|
|
238
|
+
% ('组', 'n', 'hit@1', 'recall@5', 'recall@10', 'mrr@10', 'ndcg@10', '多样性'))
|
|
239
|
+
for name, m in [('overall', report['overall'])] + list(report['byGroup'].items()):
|
|
240
|
+
div = m.get('pairwiseDiversity')
|
|
241
|
+
print('%-16s %6d %8.2f %8.2f %8.2f %8.2f %8.2f %9s'
|
|
242
|
+
% (name, m['n'], m['hit@1'], m['recall@5'], m['recall@10'], m['mrr@10'],
|
|
243
|
+
m['ndcg@10'], ('%.3f' % div) if div is not None else '-'))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == '__main__':
|
|
247
|
+
main()
|