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.
@@ -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
- # `exported` 与 `written` 是两件事:`written` 只数**这次新拷**的,`exported` 数"这条消息的文档确实在
114
- # assets 里"。分开是因为第二次跑同一个目录时新拷贝是 0 —— 只报 `written` 会打印成"0 document(s)",
115
- # 而实际上 67 份都在,那是给自己制造假象(2026-10-13 真的这么打印过一次)。
116
- FILE_ASSET_STATE = {'exported': 0, 'written': 0, 'unavailable': 0}
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'] = 'exported'
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
- safe = sanitize_filename(title).strip().strip('.') or 'file'
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
- manifest = {}
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
- os.makedirs(files_dir, exist_ok=True)
2530
- if not os.path.exists(target):
2531
- with open(target, 'wb') as dst, open(source, 'rb') as src:
2532
- shutil.copyfileobj(src, dst)
2533
- file_written += 1
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
- FILE_ASSET_STATE['exported'] = file_exported
2544
- FILE_ASSET_STATE['written'] = file_written
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(os.path.join(assets, 'manifest.json'), 'w', encoding='utf-8') as fh:
2626
+ with open(manifest_path, 'w', encoding='utf-8') as fh:
2547
2627
  json.dump({'schema': 'weflow-media-manifest/v2', 'messages': manifest,
2548
- 'documents': {'exported': file_exported, 'unavailable': file_unavailable}},
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
- body = keep + '[%s](%s)' % (label or os.path.basename(rel), rel)
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('[', '&#91;').replace(']', '&#93;')
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)) + '![图片](%s)' % rel
2711
+ body = ('' if (not body or body == '[图片]') else body + chr(10) + chr(10)) \
2712
+ + '![图片](%s)' % _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
- print(f"Assets: {len(manifest)} media item(s), {FILE_ASSET_STATE['exported']} document(s) "
2824
- f"({FILE_ASSET_STATE['unavailable']} unavailable, {FILE_ASSET_STATE['written']} new) "
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
- "assets": len(manifest), "assetsNew": asset_new,
2829
- "documents": FILE_ASSET_STATE['exported'],
2830
- "documentsNew": FILE_ASSET_STATE['written'],
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['exported']} document(s) ({FILE_ASSET_STATE['unavailable']} unavailable, "
2844
- f"{FILE_ASSET_STATE['written']} new) ({asset_new} new) -> {os.path.basename(md_path)}")
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
- "assets": asset_total, "assetsNew": asset_new,
2848
- "documents": FILE_ASSET_STATE['exported'],
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
- return re.sub(r'[\\/:*?"<>|]', '_', name)[:80]
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()