Commit f9c6a51b f9c6a51bf403a392c0dfd57d2215d2b82a8b3d54 by 沈秋雨

初始化项目:词曲 & 音眼资产导入工具

- import_hk_songs.py: 主数据导入脚本
- lyric_dedup/: 歌词去重核心模块
- test_dedup.py: 单元测试 & L2 benchmark
- serve_l2_dashboard.py + l2_review_dashboard.html: 复核前端
- .gitignore: 排除 .env、数据文件、缓存、输出目录
- README.md: 项目说明文档
0 parents
1 # 源库 - 音眼正式
2 SOURCE_DB_HOST=
3 SOURCE_DB_PORT=3306
4 SOURCE_DB_USER=
5 SOURCE_DB_PASSWORD=
6 SOURCE_DB_NAME=
7
8 # 目标库 - 词曲库
9 TARGET_DB_HOST=
10 TARGET_DB_PORT=3306
11 TARGET_DB_USER=
12 TARGET_DB_PASSWORD=
13 TARGET_DB_NAME=
14 TARGET_TABLE_NAME=hk_songs
15
16 # OSS 配置
17 OSS_ACCESS_KEY_ID=
18 OSS_ACCESS_KEY_SECRET=
19 OSS_ENDPOINT=
20 OSS_BUCKET_NAME=
21 OSS_FILE_BASE_NAME=
1 # Python
2 __pycache__/
3 *.pyc
4 *.pyo
5 .venv/
6 .pytest_cache/
7
8 # Environment
9 .env
10
11 # macOS
12 .DS_Store
13
14 # Generated output
15 output/
16
17 # 数据文件(不纳入版本控制)
18 *.csv
19 *.sql
20 *.xlsx
1 # 词曲 & 音眼资产导入工具
2
3 本项目用于将音眼(Hikoon)数据库中的歌词和歌曲数据去重、比对后,导入至词曲库的 `hk_songs` 表,并提供 L2 歌词去重测试和人工复核前端。
4
5 ## 项目结构
6
7 ```
8 .
9 ├── import_hk_songs.py # 主脚本:聚合查询、去重、导入
10 ├── lyric_dedup/ # 歌词去重核心模块
11 │ ├── normalization.py # 歌词文本规范化
12 │ ├── checker.py # 去重匹配逻辑
13 │ ├── eval_dataset.py # 评测数据集构建
14 │ ├── file_import.py # 文件导入工具
15 │ └── cli.py # CLI 入口
16 ├── test_dedup.py # 单元测试 & L2 benchmark
17 ├── serve_l2_dashboard.py # L2 测试复核前端服务
18 ├── l2_review_dashboard.html # 前端页面
19 ├── requirements.txt # Python 依赖
20 ├── 测试流程指南.md # L2 测试流程说明
21 └── .env # 数据库配置(不纳入版本控制)
22 ```
23
24 ## 快速开始
25
26 ### 1. 安装依赖
27
28 ```bash
29 pip install -r requirements.txt
30 ```
31
32 ### 2. 配置数据库
33
34 复制 `.env.example``.env` 并填写数据库连接信息:
35
36 ```env
37 YINYAN_DB_HOST=...
38 YINYAN_DB_PORT=3306
39 YINYAN_DB_USER=...
40 YINYAN_DB_PASSWORD=...
41 YINYAN_DB_NAME=...
42
43 CIKU_DB_HOST=...
44 CIKU_DB_PORT=3306
45 CIKU_DB_USER=...
46 CIKU_DB_PASSWORD=...
47 CIKU_DB_NAME=...
48 ```
49
50 ### 3. 运行数据导入
51
52 ```bash
53 python import_hk_songs.py --help
54 ```
55
56 ### 4. 运行 L2 歌词去重测试
57
58 小样本验证:
59
60 ```bash
61 RUN_L2_BENCHMARK=1 \
62 L2_BENCHMARK_LIMIT=20 \
63 L2_BENCHMARK_EXISTING_LIMIT=50 \
64 L2_BENCHMARK_TOPKS=20,100 \
65 python -m pytest test_dedup.py::TestL2Benchmark::test_l2_recall_topk_efficiency_and_review_artifacts -q -s
66 ```
67
68 详见 [测试流程指南.md](./测试流程指南.md)
69
70 ### 5. 启动复核前端
71
72 ```bash
73 python serve_l2_dashboard.py --host 127.0.0.1 --port 8765
74 ```
75
76 然后访问 http://127.0.0.1:8765 查看测试报告、比对歌词、人工标注。
77
78 ## 普通单元测试
79
80 ```bash
81 python -m pytest test_dedup.py -q
82 ```
83
84 ## 注意事项
85
86 - `.env` 文件包含数据库凭据,**不要**提交到仓库。
87 - `output/` 目录存放测试生成的报告和歌词文件,不纳入版本控制。
88 - CSV / SQL / Excel 等数据文件不纳入版本控制。
1 #!/usr/bin/env python3
2 """
3 hk_music_record 聚合数据导入脚本
4 从音眼测试库聚合查询数据,导入至词曲库 hk_songs 表
5 """
6
7 import argparse
8 import csv
9 import logging
10 import os
11 import re
12 import sys
13 import time
14 import hashlib
15 import io
16 import socket
17 import threading
18 import unicodedata
19 from collections import defaultdict
20 from concurrent.futures import ThreadPoolExecutor, as_completed
21 from datetime import datetime
22 from pathlib import Path
23
24 import opencc
25 import pymysql
26 import requests
27 from dotenv import load_dotenv
28 from tqdm import tqdm
29
30 # lyric_dedup 模块
31 sys.path.insert(0, str(Path(__file__).resolve().parent))
32 from lyric_dedup import DuplicateChecker, DuplicateDecision, LyricRecord
33
34 # 加载 .env 配置
35 load_dotenv()
36
37 # 输出目录
38 OUTPUT_DIR = Path(__file__).resolve().parent / 'output'
39 LOG_DIR = OUTPUT_DIR / 'logs'
40 REPORT_DIR = OUTPUT_DIR / 'reports'
41 LOG_DIR.mkdir(parents=True, exist_ok=True)
42 REPORT_DIR.mkdir(parents=True, exist_ok=True)
43
44 # 日志配置
45 logging.basicConfig(
46 level=logging.INFO,
47 format='%(asctime)s [%(levelname)s] %(message)s',
48 handlers=[
49 logging.StreamHandler(sys.stdout),
50 logging.FileHandler(LOG_DIR / f'import_hk_songs_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log', encoding='utf-8')
51 ]
52 )
53 logger = logging.getLogger(__name__)
54
55 # ==================== 数据库配置 ====================
56
57 SOURCE_DB_CONFIG = {
58 'host': os.getenv('SOURCE_DB_HOST'),
59 'port': int(os.getenv('SOURCE_DB_PORT', 3306)),
60 'user': os.getenv('SOURCE_DB_USER'),
61 'password': os.getenv('SOURCE_DB_PASSWORD'),
62 'database': os.getenv('SOURCE_DB_NAME'),
63 'charset': 'utf8mb4',
64 'cursorclass': pymysql.cursors.DictCursor,
65 }
66
67 TARGET_DB_CONFIG = {
68 'host': os.getenv('TARGET_DB_HOST'),
69 'port': int(os.getenv('TARGET_DB_PORT', 3306)),
70 'user': os.getenv('TARGET_DB_USER'),
71 'password': os.getenv('TARGET_DB_PASSWORD'),
72 'database': os.getenv('TARGET_DB_NAME'),
73 'charset': 'utf8mb4',
74 }
75
76 # ==================== OSS 配置 ====================
77
78 OSS_CONFIG = {
79 'access_key_id': os.getenv('OSS_ACCESS_KEY_ID'),
80 'access_key_secret': os.getenv('OSS_ACCESS_KEY_SECRET'),
81 'endpoint': os.getenv('OSS_ENDPOINT'),
82 'bucket_name': os.getenv('OSS_BUCKET_NAME'),
83 'base_url': os.getenv('OSS_FILE_BASE_NAME'),
84 }
85
86 # ==================== 聚合查询 SQL ====================
87
88 AGGREGATE_SQL = """
89 SELECT
90 sp.id AS source_id,
91 COALESCE(NULLIF(TRIM(sp.song_name), ''),
92 NULLIF(TRIM(r.record_name), '')) AS name,
93 COALESCE(NULLIF(TRIM(sp.lyricist_name), ''),
94 NULLIF(TRIM(r.lyricist_name), '')) AS lyricist,
95 COALESCE(NULLIF(TRIM(sp.composer_name), ''),
96 NULLIF(TRIM(r.composer_name), '')) AS composer,
97 CASE
98 WHEN COALESCE(sp.release_time, r.pub_time) IS NOT NULL THEN 2
99 ELSE 1
100 END AS issue_status,
101 NULL AS intro,
102 COALESCE(NULLIF(TRIM(sp.singer_name), ''),
103 NULLIF(TRIM(r.singer_name), '')) AS singer,
104 COALESCE(NULLIF(TRIM(r.storage_url), ''),
105 NULLIF(TRIM(rs.diy_music_material_file), ''),
106 NULLIF(TRIM(rs.audition_url), ''),
107 NULLIF(TRIM(r.platform_play_url), '')) AS audio_url_source,
108 NULLIF(TRIM(rs.accompaniment), '') AS accompany_url_source,
109 rl.lyric AS lyrics_txt_content,
110 r.duration AS song_time,
111 FLOOR(rs.start_position / 1000) AS song_start,
112 FLOOR(rs.end_position / 1000) AS song_end,
113 NULLIF(TRIM(rs.music_material_file), '') AS creation_url_source,
114 NULLIF(TRIM(rs.voice_midi), '') AS opern_url_source,
115 r.version_name AS cover_version,
116 COALESCE(sp.release_time, r.pub_time) AS issue_time,
117 COALESCE(NULLIF(TRIM(r.front_cover), ''),
118 NULLIF(TRIM(rs.front_cover), ''),
119 NULLIF(TRIM(ss.front_cover_new), '')) AS cover_url_source,
120 0 AS animation_type,
121 CASE
122 WHEN rs.bpm IS NULL OR rs.bpm = 0 THEN NULL
123 WHEN rs.bpm < 80 THEN 1
124 WHEN rs.bpm <= 120 THEN 2
125 ELSE 3
126 END AS bpm_class,
127 CASE ss.review_status
128 WHEN '0' THEN 1
129 WHEN '1' THEN 3
130 WHEN '2' THEN 2
131 WHEN '3' THEN 4
132 WHEN '4' THEN 4
133 WHEN '5' THEN 0
134 ELSE 0
135 END AS review_status,
136 CASE WHEN rs.is_imputation = 1 THEN 2 ELSE 1 END AS in_status,
137 CASE
138 WHEN ss.is_grounding = 1 THEN 1
139 WHEN ss.is_grounding = 0 THEN 2
140 ELSE NULL
141 END AS song_status,
142 COALESCE(sp.create_time, r.create_time) AS commit_time,
143 NULL AS review_time,
144 rs.ground_date AS shelf_time,
145 rs.remark AS review_remark,
146 sp.create_time AS create_time,
147 sp.creator AS creator,
148 sp.update_time AS modify_time,
149 sp.updater AS modifier,
150 0 AS deleted,
151 NULL AS cooperate_type,
152 NULL AS off_shelf_remark,
153 NULL AS musician_id,
154 NULL AS commit_id,
155 NULLIF(TRIM(rs.voice_midi), '') AS sheet_music_source,
156 0 AS commit_desc,
157 NULL AS price,
158 'hk_song_platform' AS source_table_name,
159 CAST(sp.id AS CHAR) AS source_song_id,
160 NULL AS lyric_archive_element_id,
161 NULL AS melody_archive_element_id,
162 NULL AS audio_fingerprint
163 FROM hk_song_platform sp
164 LEFT JOIN (
165 SELECT song_id, MIN(record_id) AS record_id
166 FROM hk_song_and_record
167 WHERE is_main_version = 1
168 GROUP BY song_id
169 ) sr
170 ON sr.song_id = sp.id
171 LEFT JOIN hk_music_record r
172 ON r.id = sr.record_id
173 AND r.deleted = b'0'
174 LEFT JOIN hk_music_record_state rs
175 ON rs.record_id = r.id
176 AND rs.deleted = b'0'
177 LEFT JOIN hk_music_record_lyric rl
178 ON rl.raw_record_id = r.raw_record_id
179 AND rl.deleted = b'0'
180 LEFT JOIN hk_song_state ss
181 ON ss.song_id = sp.id
182 AND ss.deleted = b'0'
183 WHERE sp.deleted = b'0'
184 AND COALESCE(NULLIF(TRIM(sp.copyright_id), ''), '') <> '1871475560046465025'
185 AND COALESCE(NULLIF(TRIM(sp.copyright_name), ''), '') <> '连城小睿音乐工作室'
186 """
187
188 # ==================== 目标表 INSERT 语句 ====================
189
190 TARGET_TABLE_NAME = os.getenv('TARGET_TABLE_NAME', 'hk_songs')
191
192 INSERT_SQL_TEMPLATE = """
193 INSERT INTO {table} (
194 id, name, lyricist, composer, issue_status, intro,
195 audio_url, accompany_url, lyrics_url, lrc_url,
196 song_time, song_start, song_end,
197 creation_url, opern_url, cover_version, issue_time,
198 cover_url, animation_type, bpm_class, review_status,
199 in_status, song_status, commit_time, review_time,
200 shelf_time, review_remark, create_time, creator,
201 modify_time, modifier, deleted, cooperate_type, singer,
202 off_shelf_remark, musician_id, commit_id, sheet_music,
203 commit_desc, price, source_table_name, source_song_id,
204 lyric_archive_element_id, melody_archive_element_id, audio_fingerprint
205 ) VALUES (
206 %s,%s,%s,%s,%s,%s,
207 %s,%s,%s,%s,
208 %s,%s,%s,
209 %s,%s,%s,%s,
210 %s,%s,%s,%s,
211 %s,%s,%s,%s,
212 %s,%s,%s,%s,
213 %s,%s,%s,%s,%s,
214 %s,%s,%s,%s,
215 %s,%s,%s,%s,
216 %s,%s,%s
217 )
218 """
219
220 # URL 字段索引映射 (在 row tuple 中的位置)
221 # 0:id, 1:name, 2:lyricist, 3:composer, 4:issue_status, 5:intro,
222 # 6:audio_url, 7:accompany_url, 8:lyrics_url, 9:lrc_url,
223 # 10:song_time, 11:song_start, 12:song_end,
224 # 13:creation_url, 14:opern_url, 15:cover_version, 16:issue_time,
225 # 17:cover_url, 18:animation_type, 19:bpm_class, 20:review_status,
226 # 21:in_status, 22:song_status, 23:commit_time, 24:review_time,
227 # 25:shelf_time, 26:review_remark, 27:create_time, 28:creator,
228 # 29:modify_time, 30:modifier, 31:deleted, 32:cooperate_type, 33:singer,
229 # 34:off_shelf_remark, 35:musician_id, 36:commit_id, 37:sheet_music,
230 # 38:commit_desc, 39:price, 40:source_table_name, 41:source_song_id,
231 # 42:lyric_archive_element_id, 43:melody_archive_element_id, 44:audio_fingerprint
232
233 # 需要 OSS 处理的 URL 字段索引
234 OSS_URL_INDICES = {
235 6: 'audio', # audio_url
236 7: 'accompany', # accompany_url
237 13: 'creation', # creation_url
238 14: 'opern', # opern_url
239 17: 'cover', # cover_url
240 37: 'sheet_music', # sheet_music
241 }
242 OSS_LYRIC_INDEX = 8 # lyrics_url (从文本生成)
243
244
245 class SnowflakeIdGenerator:
246 """生成业务表使用的 bigint 主键。"""
247
248 # Twitter Snowflake epoch,生成值与现有 19 位业务 ID 量级一致。
249 EPOCH_MS = 1288834974657
250
251 def __init__(self):
252 node_seed = f"{socket.gethostname()}:{os.getpid()}:{time.time_ns()}".encode('utf-8')
253 self.worker_id = int(hashlib.sha1(node_seed).hexdigest(), 16) & 0x3FF
254 self.sequence = 0
255 self.last_ms = -1
256 self.lock = threading.Lock()
257
258 def next_id(self) -> int:
259 with self.lock:
260 now_ms = int(time.time() * 1000)
261 if now_ms < self.last_ms:
262 now_ms = self.last_ms
263
264 if now_ms == self.last_ms:
265 self.sequence = (self.sequence + 1) & 0xFFF
266 if self.sequence == 0:
267 while now_ms <= self.last_ms:
268 now_ms = int(time.time() * 1000)
269 else:
270 self.sequence = 0
271
272 self.last_ms = now_ms
273 return ((now_ms - self.EPOCH_MS) << 22) | (self.worker_id << 12) | self.sequence
274
275
276 ID_GENERATOR = SnowflakeIdGenerator()
277
278
279 def get_oss_bucket():
280 """初始化 OSS Bucket"""
281 import oss2
282 auth = oss2.Auth(OSS_CONFIG['access_key_id'], OSS_CONFIG['access_key_secret'])
283 bucket = oss2.Bucket(auth, OSS_CONFIG['endpoint'], OSS_CONFIG['bucket_name'])
284 return bucket
285
286
287 def download_file(url, timeout=30):
288 """下载文件,返回 (content_bytes, content_type) 或 (None, None)"""
289 if not url:
290 return None, None
291 # 如果不是 http/https 开头,尝试补前缀(源库有些是相对路径)
292 if not url.startswith(('http://', 'https://')):
293 return None, None
294 try:
295 resp = requests.get(url, timeout=timeout, stream=True)
296 resp.raise_for_status()
297 content_type = resp.headers.get('Content-Type', '')
298 return resp.content, content_type
299 except Exception as e:
300 logger.warning(f"下载失败: {url}, 错误: {e}")
301 return None, None
302
303
304 def upload_to_oss(bucket, content, oss_key, content_type=None):
305 """上传内容到 OSS,返回 OSS URL 或 None"""
306 if not content:
307 return None
308 try:
309 headers = {}
310 if content_type:
311 headers['Content-Type'] = content_type
312 bucket.put_object(oss_key, content, headers=headers if headers else None)
313 return f"{OSS_CONFIG['base_url']}/{oss_key}"
314 except Exception as e:
315 logger.warning(f"OSS 上传失败: {oss_key}, 错误: {e}")
316 return None
317
318
319 def process_url_field(bucket, source_url, field_type, record_id, skip_oss=False):
320 """处理 URL 字段:下载并上传 OSS,或透传"""
321 if not source_url:
322 return None
323
324 if skip_oss:
325 return source_url
326
327 content, content_type = download_file(source_url)
328 if not content:
329 # 下载失败,透传源值
330 return source_url
331
332 # 生成 OSS key
333 ext = _guess_ext(source_url, content_type, field_type)
334 oss_key = f"music_library/{field_type}/{record_id}{ext}"
335 oss_url = upload_to_oss(bucket, content, oss_key, content_type)
336 return oss_url or source_url
337
338
339 def _is_lrc_format(text):
340 """检测歌词文本是否为 LRC 格式(包含 [mm:ss.xx] 时间标签)"""
341 if not text:
342 return False
343 return bool(re.search(r'\[\d{1,2}:\d{2}[:.\d]*\]', text[:500]))
344
345
346 def process_lyrics(bucket, lyric_text, record_id, skip_oss=False):
347 """处理歌词:始终上传 .txt 到 lyrics_url;如果是 LRC 格式,额外上传 .lrc 到 lrc_url
348 返回 (lyrics_url, lrc_url)
349 """
350 if not lyric_text:
351 return None, None
352
353 if skip_oss:
354 if _is_lrc_format(lyric_text):
355 return lyric_text, lyric_text
356 return lyric_text, None
357
358 content = lyric_text.encode('utf-8')
359 # 始终上传 .txt 到 lyrics_url
360 oss_key_txt = f"music_library/lyric/{record_id}.txt"
361 lyrics_url = upload_to_oss(bucket, content, oss_key_txt, 'text/plain; charset=utf-8') or lyric_text
362
363 # 如果是 LRC 格式,额外上传 .lrc 到 lrc_url
364 lrc_url = None
365 if _is_lrc_format(lyric_text):
366 oss_key_lrc = f"music_library/lyric/{record_id}.lrc"
367 lrc_url = upload_to_oss(bucket, content, oss_key_lrc, 'text/plain; charset=utf-8') or lyric_text
368
369 return lyrics_url, lrc_url
370
371
372 def _guess_ext(url, content_type, field_type):
373 """根据 URL 或 Content-Type 猜测文件扩展名"""
374 # 先从 URL 中提取
375 if '.' in url.split('/')[-1]:
376 ext = '.' + url.split('/')[-1].split('.')[-1].split('?')[0]
377 if len(ext) <= 6:
378 return ext
379 # 根据 content_type
380 type_map = {
381 'audio/mpeg': '.mp3', 'audio/wav': '.wav', 'audio/x-wav': '.wav',
382 'audio/ogg': '.ogg', 'audio/flac': '.flac', 'audio/aac': '.aac',
383 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp',
384 'image/gif': '.gif',
385 'application/pdf': '.pdf', 'text/plain': '.txt',
386 'audio/midi': '.mid', 'application/x-midi': '.mid',
387 }
388 for ct, ext in type_map.items():
389 if ct in (content_type or ''):
390 return ext
391 # 按字段类型默认
392 defaults = {'audio': '.mp3', 'accompany': '.mp3', 'cover': '.jpg',
393 'creation': '.mp3', 'opern': '.mid', 'sheet_music': '.mid'}
394 return defaults.get(field_type, '')
395
396
397 def build_row_tuple(row, bucket, skip_oss=False):
398 """将源查询结果转为目标表行 tuple,处理 OSS 字段"""
399 record_id = row['source_id']
400
401 # 处理 URL 字段
402 audio_url = process_url_field(bucket, row.get('audio_url_source'), 'audio', record_id, skip_oss)
403 accompany_url = process_url_field(bucket, row.get('accompany_url_source'), 'accompany', record_id, skip_oss)
404 creation_url = process_url_field(bucket, row.get('creation_url_source'), 'creation', record_id, skip_oss)
405 opern_url = process_url_field(bucket, row.get('opern_url_source'), 'opern', record_id, skip_oss)
406 cover_url = process_url_field(bucket, row.get('cover_url_source'), 'cover', record_id, skip_oss)
407 sheet_music = process_url_field(bucket, row.get('sheet_music_source'), 'sheet_music', record_id, skip_oss)
408 lyrics_url, lrc_url = process_lyrics(bucket, row.get('lyrics_txt_content'), record_id, skip_oss)
409
410 return (
411 ID_GENERATOR.next_id(), # id
412 row.get('name'), # name
413 row.get('lyricist'), # lyricist
414 row.get('composer'), # composer
415 row.get('issue_status'), # issue_status
416 row.get('intro'), # intro
417 audio_url, # audio_url
418 accompany_url, # accompany_url
419 lyrics_url, # lyrics_url
420 lrc_url, # lrc_url
421 row.get('song_time'), # song_time
422 row.get('song_start'), # song_start
423 row.get('song_end'), # song_end
424 creation_url, # creation_url
425 opern_url, # opern_url
426 row.get('cover_version'), # cover_version
427 row.get('issue_time'), # issue_time
428 cover_url, # cover_url
429 row.get('animation_type', 0), # animation_type
430 row.get('bpm_class'), # bpm_class
431 row.get('review_status', 0), # review_status
432 row.get('in_status', 1), # in_status
433 row.get('song_status'), # song_status
434 row.get('commit_time'), # commit_time
435 row.get('review_time'), # review_time
436 row.get('shelf_time'), # shelf_time
437 row.get('review_remark'), # review_remark
438 row.get('create_time'), # create_time
439 row.get('creator'), # creator
440 row.get('modify_time'), # modify_time
441 row.get('modifier'), # modifier
442 0, # deleted
443 row.get('cooperate_type'), # cooperate_type
444 row.get('singer'), # singer
445 row.get('off_shelf_remark'), # off_shelf_remark
446 row.get('musician_id'), # musician_id
447 row.get('commit_id'), # commit_id
448 sheet_music, # sheet_music
449 row.get('commit_desc', 0), # commit_desc
450 row.get('price'), # price
451 row.get('source_table_name'), # source_table_name
452 row.get('source_song_id'), # source_song_id
453 row.get('lyric_archive_element_id'), # lyric_archive_element_id
454 row.get('melody_archive_element_id'),# melody_archive_element_id
455 row.get('audio_fingerprint'), # audio_fingerprint
456 )
457
458
459 def process_row_with_oss(args_tuple):
460 """线程工作函数:处理单行的 OSS 上传/下载"""
461 idx, row, bucket, skip_oss = args_tuple
462 try:
463 tuple_row = build_row_tuple(row, bucket, skip_oss=skip_oss)
464 return idx, tuple_row, None
465 except Exception as e:
466 return idx, None, e
467
468
469 # ==================== 去重逻辑 ====================
470
471 _T2S = opencc.OpenCC('t2s')
472 _METADATA_PUNCT = set(' \t\n\r,。!?;:、"“”‘’·…—~!¥()【】《》〈〉「」『』﹏,.:;!?()[]{}<>|/\\_-')
473 _INSTRUMENTAL_LYRIC_RE = re.compile(r'(?:纯音乐|純音樂|无歌词|無歌詞|没有歌词|沒有歌詞|instrumental)', re.IGNORECASE)
474
475
476 def _normalize_meta(text: str | None) -> str:
477 """元数据规范化:全角转半角(NFKC)、繁转简、去空格标点、小写"""
478 if not text:
479 return ''
480 text = unicodedata.normalize('NFKC', text) # 全角数字/字母/标点 → 半角
481 text = _T2S.convert(text.strip().lower())
482 return ''.join(c for c in text if c not in _METADATA_PUNCT)
483
484
485 def is_instrumental_lyrics(text: str | None) -> bool:
486 """识别纯音乐/无歌词提示文本,这类内容不参与 L2 歌词去重。"""
487 if not text:
488 return False
489 normalized = _T2S.convert(unicodedata.normalize('NFKC', str(text)).lower())
490 return bool(_INSTRUMENTAL_LYRIC_RE.search(normalized))
491
492
493 def build_l1_index(target_conn, table_name: str) -> dict[tuple[str, str, str], int]:
494 """从目标库加载已有歌曲的元数据索引,返回 {(name, lyricist, composer): id}"""
495 sql = f"SELECT id, name, lyricist, composer FROM {table_name} WHERE deleted = '0'"
496 index: dict[tuple[str, str, str], int] = {}
497 try:
498 with target_conn.cursor(pymysql.cursors.DictCursor) as cursor:
499 cursor.execute(sql)
500 for row in cursor.fetchall():
501 key = (
502 _normalize_meta(row.get('name')),
503 _normalize_meta(row.get('lyricist')),
504 _normalize_meta(row.get('composer')),
505 )
506 if key[0]: # name 非空才入索引
507 index[key] = row['id']
508 logger.info(f"L1 元数据索引构建完成: {len(index)} 条")
509 except Exception as e:
510 logger.error(f"L1 索引构建失败: {e}")
511 return index
512
513
514 def check_l1(row: dict, l1_index: dict) -> tuple[bool, int | None]:
515 """L1 元数据去重,返回 (is_duplicate, matched_id)"""
516 key = (
517 _normalize_meta(row.get('name')),
518 _normalize_meta(row.get('lyricist')),
519 _normalize_meta(row.get('composer')),
520 )
521 if key[0] and key in l1_index:
522 return True, l1_index[key]
523 return False, None
524
525
526 class L2CandidateIndex:
527 """L2 歌词候选召回索引,最终判定仍交给 DuplicateChecker。"""
528
529 def __init__(self, checker: DuplicateChecker, mode: str = 'topk', top_k: int = 200):
530 if mode not in {'full', 'topk'}:
531 raise ValueError("mode must be 'full' or 'topk'")
532 if top_k < 1:
533 raise ValueError('top_k must be >= 1')
534 self.checker = checker
535 self.mode = mode
536 self.top_k = top_k
537 self.records: dict[str, LyricRecord] = {}
538 self.order: dict[str, int] = {}
539 self.exact_index: dict[str, set[str]] = defaultdict(set)
540 self.token_index: dict[str, set[str]] = defaultdict(set)
541 self.line_index: dict[str, set[str]] = defaultdict(set)
542
543 def __len__(self) -> int:
544 return len(self.records)
545
546 def add(self, record: LyricRecord) -> None:
547 if record.record_id in self.records:
548 return
549
550 indexed = self.checker._index(record)
551 self.records[record.record_id] = record
552 self.order[record.record_id] = len(self.order)
553 self.exact_index[indexed.exact_hash].add(record.record_id)
554
555 for token in self._tokens_for_recall(indexed):
556 self.token_index[token].add(record.record_id)
557 for line in self._lines_for_recall(indexed):
558 self.line_index[line].add(record.record_id)
559
560 def recall(self, record: LyricRecord) -> list[LyricRecord]:
561 if self.mode == 'full':
562 return list(self.records.values())
563
564 query = self.checker._index(record)
565 exact_ids = self.exact_index.get(query.exact_hash)
566 if exact_ids:
567 return [self.records[record_id] for record_id in sorted(exact_ids, key=self.order.get)]
568
569 scores: dict[str, int] = defaultdict(int)
570 for token in self._tokens_for_recall(query):
571 for record_id in self.token_index.get(token, ()):
572 scores[record_id] += 1
573 for line in self._lines_for_recall(query):
574 for record_id in self.line_index.get(line, ()):
575 scores[record_id] += 4
576
577 ranked_ids = sorted(
578 scores,
579 key=lambda record_id: (-scores[record_id], self.order[record_id]),
580 )[:self.top_k]
581 return [self.records[record_id] for record_id in ranked_ids]
582
583 @staticmethod
584 def _tokens_for_recall(indexed) -> set[str]:
585 return set(indexed.tokens) | set(indexed.primary_tokens) | set(indexed.translation_tokens) | set(indexed.fallback_tokens)
586
587 @staticmethod
588 def _lines_for_recall(indexed) -> set[str]:
589 return set(indexed.normalized.primary_lines or indexed.normalized.unique_lines or indexed.fallback_lines)
590
591
592 def check_l2(
593 row: dict,
594 checker: DuplicateChecker,
595 candidates: list[LyricRecord] | L2CandidateIndex,
596 ) -> tuple[str, float, str | None, str]:
597 """L2 歌词内容去重,返回 (decision, confidence, matched_id, reason)"""
598 lyrics_text = row.get('lyrics_txt_content')
599 if not lyrics_text or not lyrics_text.strip():
600 return 'new', 1.0, None, '无歌词内容,跳过 L2'
601 if is_instrumental_lyrics(lyrics_text):
602 return 'new', 1.0, None, '歌词内容包含纯音乐/无歌词提示,跳过 L2'
603
604 record_id = row.get('source_id', row.get('id'))
605 record = LyricRecord(
606 record_id=str(record_id),
607 lyrics=lyrics_text,
608 title=row.get('name'),
609 artist=row.get('singer'),
610 lyricist=row.get('lyricist'),
611 composer=row.get('composer'),
612 )
613 if isinstance(candidates, L2CandidateIndex):
614 candidates = candidates.recall(record)
615 if not candidates:
616 return 'new', 1.0, None, '无候选集'
617
618 result = checker.check_record_against_candidates(record, candidates, max_candidates=5)
619 matched_id = None
620 if result.candidates:
621 best = result.candidates[0]
622 matched_id = best.record_id if result.decision != DuplicateDecision.NEW else None
623
624 return result.decision.value, result.confidence, matched_id, result.reason
625
626
627 def _candidate_by_id(
628 candidates: list[LyricRecord] | L2CandidateIndex,
629 record_id: str | None,
630 ) -> LyricRecord | None:
631 if record_id is None:
632 return None
633 record_id = str(record_id)
634 if isinstance(candidates, L2CandidateIndex):
635 return candidates.records.get(record_id)
636 return next((candidate for candidate in candidates if candidate.record_id == record_id), None)
637
638
639 def _writers_differ(row: dict, candidate: LyricRecord | None) -> bool:
640 if candidate is None:
641 return False
642 row_lyricist = _normalize_meta(row.get('lyricist'))
643 row_composer = _normalize_meta(row.get('composer'))
644 candidate_lyricist = _normalize_meta(candidate.lyricist)
645 candidate_composer = _normalize_meta(candidate.composer)
646 return (
647 bool(row_lyricist or row_composer or candidate_lyricist or candidate_composer)
648 and (row_lyricist, row_composer) != (candidate_lyricist, candidate_composer)
649 )
650
651
652 def classify_dedup_action(
653 row: dict,
654 l1_index: dict,
655 checker: DuplicateChecker,
656 candidates: list[LyricRecord] | L2CandidateIndex,
657 ) -> dict:
658 """Return the import-level dedup action.
659
660 L1 is only a recall/risk signal. L2 content decides merge/review/new.
661 """
662 l1_matched, l1_matched_id = check_l1(row, l1_index)
663 l2_candidates_for_check: list[LyricRecord] | L2CandidateIndex = candidates
664 if isinstance(candidates, L2CandidateIndex):
665 lyrics_text = row.get('lyrics_txt_content') or ''
666 record = LyricRecord(
667 record_id=str(row.get('source_id', row.get('id'))),
668 lyrics=lyrics_text,
669 title=row.get('name'),
670 artist=row.get('singer'),
671 lyricist=row.get('lyricist'),
672 composer=row.get('composer'),
673 )
674 recalled = candidates.recall(record)
675 if l1_matched_id is not None:
676 l1_candidate = candidates.records.get(str(l1_matched_id))
677 if l1_candidate and all(candidate.record_id != l1_candidate.record_id for candidate in recalled):
678 recalled.append(l1_candidate)
679 l2_candidates_for_check = recalled
680
681 decision, confidence, matched_id, reason = check_l2(row, checker, l2_candidates_for_check)
682
683 if decision == 'duplicate':
684 matched_candidate = _candidate_by_id(l2_candidates_for_check, matched_id)
685 return {
686 'action': 'merge',
687 'decision': decision,
688 'confidence': confidence,
689 'matched_id': matched_id,
690 'reason': reason,
691 'l1_matched': l1_matched,
692 'l1_matched_id': l1_matched_id,
693 'merge_authors': _writers_differ(row, matched_candidate),
694 'matched_candidate': matched_candidate,
695 }
696
697 if decision == 'review' or (l1_matched and reason == '无候选集'):
698 review_matched_id = matched_id or (str(l1_matched_id) if l1_matched_id is not None else None)
699 matched_candidate = _candidate_by_id(l2_candidates_for_check, review_matched_id)
700 return {
701 'action': 'review',
702 'decision': 'review',
703 'confidence': confidence,
704 'matched_id': review_matched_id,
705 'reason': reason if decision == 'review' else 'L1 元数据命中但缺少可比对歌词,需要人工复核',
706 'l1_matched': l1_matched,
707 'l1_matched_id': l1_matched_id,
708 'merge_authors': False,
709 'matched_candidate': matched_candidate,
710 }
711
712 return {
713 'action': 'new',
714 'decision': decision,
715 'confidence': confidence,
716 'matched_id': matched_id,
717 'reason': reason,
718 'l1_matched': l1_matched,
719 'l1_matched_id': l1_matched_id,
720 'merge_authors': False,
721 'matched_candidate': None,
722 }
723
724
725 class DedupReport:
726 """去重结果 CSV 日志"""
727
728 def __init__(self, filepath: str):
729 self.filepath = filepath
730 self.file = open(filepath, 'w', encoding='utf-8', newline='')
731 self.writer = csv.writer(self.file)
732 self.writer.writerow(['id', 'name', 'stage', 'decision', 'confidence', 'matched_id', 'reason'])
733 self.lock = threading.Lock()
734
735 def write(self, record_id, name, stage, decision, confidence='', matched_id='', reason=''):
736 with self.lock:
737 self.writer.writerow([record_id, name, stage, decision, confidence, matched_id or '', reason])
738 self.file.flush()
739
740 def close(self):
741 self.file.close()
742
743
744 class ReviewDecisionReport:
745 """人工审核前的去重决策清单。"""
746
747 FIELDNAMES = [
748 'source_id', 'name', 'lyricist', 'composer', 'singer',
749 'query_lyrics_path', 'action', 'decision', 'confidence', 'matched_id',
750 'matched_name', 'matched_lyricist', 'matched_composer', 'matched_lyrics_path',
751 'l1_matched_id',
752 'merge_authors', 'reason', 'review_decision', 'review_note',
753 ]
754
755 def __init__(self, filepath: str):
756 self.filepath = filepath
757 self.asset_dir = Path(filepath).with_suffix('') / 'lyrics'
758 self.asset_dir.mkdir(parents=True, exist_ok=True)
759 self.file = open(filepath, 'w', encoding='utf-8', newline='')
760 self.writer = csv.DictWriter(self.file, fieldnames=self.FIELDNAMES)
761 self.writer.writeheader()
762
763 def _write_lyrics_asset(self, prefix: str, record_id, title, lyrics: str | None) -> str:
764 if not lyrics:
765 return ''
766 safe_title = re.sub(r'[^\w\u4e00-\u9fff.-]+', '_', str(title or 'untitled'))[:80] or 'untitled'
767 path = self.asset_dir / f"{prefix}_{record_id}_{safe_title}.txt"
768 path.write_text(str(lyrics), encoding='utf-8')
769 return str(path)
770
771 def write(self, row: dict, action: dict) -> None:
772 source_id = row.get('source_id', row.get('id'))
773 matched_candidate = action.get('matched_candidate')
774 query_lyrics_path = self._write_lyrics_asset(
775 'query',
776 source_id,
777 row.get('name'),
778 row.get('lyrics_txt_content'),
779 )
780 matched_lyrics_path = ''
781 matched_name = ''
782 matched_lyricist = ''
783 matched_composer = ''
784 if matched_candidate:
785 matched_name = matched_candidate.title or ''
786 matched_lyricist = matched_candidate.lyricist or ''
787 matched_composer = matched_candidate.composer or ''
788 matched_lyrics_path = self._write_lyrics_asset(
789 'matched',
790 matched_candidate.record_id,
791 matched_candidate.title,
792 matched_candidate.lyrics,
793 )
794 self.writer.writerow({
795 'source_id': source_id,
796 'name': row.get('name', ''),
797 'lyricist': row.get('lyricist', ''),
798 'composer': row.get('composer', ''),
799 'singer': row.get('singer', ''),
800 'query_lyrics_path': query_lyrics_path,
801 'action': action.get('action', ''),
802 'decision': action.get('decision', ''),
803 'confidence': f"{float(action.get('confidence', 0.0)):.4f}",
804 'matched_id': action.get('matched_id') or '',
805 'matched_name': matched_name,
806 'matched_lyricist': matched_lyricist,
807 'matched_composer': matched_composer,
808 'matched_lyrics_path': matched_lyrics_path,
809 'l1_matched_id': action.get('l1_matched_id') or '',
810 'merge_authors': '1' if action.get('merge_authors') else '0',
811 'reason': action.get('reason', ''),
812 'review_decision': '',
813 'review_note': '',
814 })
815
816 def close(self):
817 self.file.close()
818
819
820 _APPROVED_REVIEW_DECISIONS = {'import', '导入', 'new', '新增', 'approve', 'approved', 'yes', 'y', '1', 'true'}
821
822
823 def load_approved_import_ids(review_csv_path: str) -> set[str]:
824 """读取人工审核后的 CSV,返回确认要导入目标库的 source_id 集合。"""
825 approved: set[str] = set()
826 with open(review_csv_path, 'r', encoding='utf-8-sig', newline='') as f:
827 reader = csv.DictReader(f)
828 if not reader.fieldnames or 'source_id' not in reader.fieldnames:
829 raise ValueError('审核结果 CSV 必须包含 source_id 列')
830 decision_field = next(
831 (field for field in ('review_decision', 'import_decision', 'approved', 'action') if field in reader.fieldnames),
832 None,
833 )
834 if decision_field is None:
835 raise ValueError('审核结果 CSV 必须包含 review_decision/import_decision/approved/action 之一')
836 for row in reader:
837 decision = str(row.get(decision_field, '')).strip().lower()
838 if decision in _APPROVED_REVIEW_DECISIONS:
839 source_id = str(row.get('source_id', '')).strip()
840 if source_id:
841 approved.add(source_id)
842 return approved
843
844
845 def main():
846 parser = argparse.ArgumentParser(description='hk_music_record 聚合数据导入至 hk_songs')
847 parser.add_argument('--limit', type=int, default=None, help='导入数据数量(默认全量)')
848 parser.add_argument('--offset', type=int, default=0, help='起始偏移量(默认 0)')
849 parser.add_argument('--skip-oss', action='store_true', help='跳过 OSS 上传,直接透传源 URL')
850 parser.add_argument('--batch-size', type=int, default=500, help='批量提交大小(默认 500)')
851 parser.add_argument('--workers', type=int, default=8, help='OSS 并发下载/上传线程数(默认 8)')
852 parser.add_argument('--skip-dedup', action='store_true', help='跳过去重,全部写入')
853 parser.add_argument('--load-existing-lyrics', action='store_true',
854 help='启动时从目标库下载已有歌词文本构建 L2 候选集(默认关闭)')
855 parser.add_argument('--dedup-threshold', type=float, default=0.78,
856 help='L2 去重 Jaccard 阈值(默认 0.78)')
857 parser.add_argument('--l2-recall', choices=['topk', 'full'], default='topk',
858 help='L2 候选召回模式:topk=倒排索引召回,full=全量候选精排(默认 topk)')
859 parser.add_argument('--l2-recall-top-k', type=int, default=200,
860 help='L2 topk 召回候选数(默认 200)')
861 parser.add_argument('--dedup-only', action='store_true',
862 help='纯预检模式:只生成去重/人工审核清单,不上传 OSS、不写目标库')
863 parser.add_argument('--review-result-csv',
864 help='人工审核后的 CSV;只导入 review_decision/import_decision 标记为导入的 source_id')
865 parser.add_argument('--dry-run', action='store_true', help='仅查询不写入,预览数据')
866 args = parser.parse_args()
867
868 logger.info("=" * 60)
869 logger.info("hk_music_record 聚合导入开始")
870 logger.info(f"参数: limit={args.limit}, offset={args.offset}, skip_oss={args.skip_oss}, "
871 f"batch_size={args.batch_size}, workers={args.workers}, "
872 f"skip_dedup={args.skip_dedup}, dedup_threshold={args.dedup_threshold}, "
873 f"l2_recall={args.l2_recall}, l2_recall_top_k={args.l2_recall_top_k}, "
874 f"dedup_only={args.dedup_only}, review_result_csv={args.review_result_csv}, "
875 f"dry_run={args.dry_run}")
876 logger.info(f"目标表: {TARGET_TABLE_NAME}")
877 logger.info("=" * 60)
878
879 # 初始化 OSS
880 bucket = None
881 if not args.skip_oss and not args.dedup_only:
882 try:
883 bucket = get_oss_bucket()
884 logger.info("OSS Bucket 初始化成功")
885 except Exception as e:
886 logger.error(f"OSS 初始化失败: {e}")
887 logger.info("提示: 可使用 --skip-oss 跳过 OSS 上传")
888 sys.exit(1)
889
890 # 连接目标库(提前连接,用于增量去重过滤)
891 logger.info(f"连接目标库: {TARGET_DB_CONFIG['host']}:{TARGET_DB_CONFIG['port']}/{TARGET_DB_CONFIG['database']}")
892 target_conn = pymysql.connect(**TARGET_DB_CONFIG)
893 insert_sql = INSERT_SQL_TEMPLATE.format(table=TARGET_TABLE_NAME)
894
895 # ===== 加载已导入的 source_song_id 集合(增量去重)=====
896 imported_ids: set[str] = set()
897 try:
898 id_sql = f"SELECT source_song_id FROM {TARGET_TABLE_NAME} WHERE source_table_name = 'hk_song_platform'"
899 with target_conn.cursor() as cursor:
900 cursor.execute(id_sql)
901 for r in cursor.fetchall():
902 sid = r[0] if isinstance(r, tuple) else r.get('source_song_id')
903 if sid is not None:
904 imported_ids.add(str(sid))
905 logger.info(f"已导入 source_song_id 集合加载完成: {len(imported_ids)} 条")
906 except Exception as e:
907 logger.warning(f"加载已导入 ID 失败(首次导入可忽略): {e}")
908
909 # 连接源库
910 logger.info(f"连接源库: {SOURCE_DB_CONFIG['host']}:{SOURCE_DB_CONFIG['port']}/{SOURCE_DB_CONFIG['database']}")
911 source_conn = pymysql.connect(**SOURCE_DB_CONFIG)
912
913 try:
914 with source_conn.cursor() as cursor:
915 sql = AGGREGATE_SQL
916 if args.limit is not None:
917 sql += f"\nLIMIT {args.limit}"
918 if args.offset:
919 sql += f"\nOFFSET {args.offset}"
920
921 logger.info("执行聚合查询...")
922 cursor.execute(sql)
923 all_rows = cursor.fetchall()
924 logger.info(f"源库查询到 {len(all_rows)} 条数据")
925
926 # 增量过滤:排除已导入的 source_song_id
927 if imported_ids:
928 rows = [r for r in all_rows if str(r['source_id']) not in imported_ids]
929 skipped = len(all_rows) - len(rows)
930 if skipped:
931 logger.info(f"增量过滤跳过 {skipped} 条(已导入),剩余 {len(rows)} 条待处理")
932 else:
933 rows = all_rows
934
935 if args.review_result_csv:
936 approved_ids = load_approved_import_ids(args.review_result_csv)
937 before_review_filter = len(rows)
938 rows = [r for r in rows if str(r['source_id']) in approved_ids]
939 logger.info(
940 f"人工审核结果过滤: approved={len(approved_ids)} | "
941 f"待处理 {before_review_filter} -> {len(rows)}"
942 )
943
944 total = len(rows)
945 logger.info(f"待导入 {total} 条数据")
946
947 if total == 0:
948 logger.info("无数据需要导入")
949 return
950
951 if args.dry_run:
952 logger.info("=== DRY RUN 模式,预览前 3 条 ===")
953 for i, row in enumerate(rows[:3]):
954 logger.info(f"Row {i+1}: source_id={row['source_id']}, name={row.get('name')}, "
955 f"lyricist={row.get('lyricist')}, composer={row.get('composer')}, "
956 f"audio_url_source={row.get('audio_url_source', '')[:80]}")
957 return
958
959 finally:
960 source_conn.close()
961 logger.info("源库连接已关闭")
962
963 # ===== 初始化去重 =====
964 l1_index: dict[tuple[str, str, str], int] = {}
965 l2_checker = None
966 l2_candidates: L2CandidateIndex | None = None
967 dedup_report = None
968 review_report = None
969
970 if not args.skip_dedup:
971 logger.info("正在构建 L1 元数据索引...")
972 l1_index = build_l1_index(target_conn, TARGET_TABLE_NAME)
973
974 logger.info(f"初始化 L2 歌词去重检查器 (阈值={args.dedup_threshold})")
975 l2_checker = DuplicateChecker(
976 duplicate_jaccard_threshold=args.dedup_threshold,
977 )
978 l2_candidates = L2CandidateIndex(
979 l2_checker,
980 mode=args.l2_recall,
981 top_k=args.l2_recall_top_k,
982 )
983 logger.info(f"L2 候选召回模式: {args.l2_recall} (top_k={args.l2_recall_top_k})")
984
985 # 可选加载目标库已有歌词
986 if args.load_existing_lyrics:
987 logger.info("加载目标库已有歌词文本(可能需要较长时间)...")
988 try:
989 lyric_sql = f"SELECT id, name, singer, lyricist, composer, lyrics_url FROM {TARGET_TABLE_NAME} WHERE deleted = '0' AND lyrics_url IS NOT NULL"
990 with target_conn.cursor(pymysql.cursors.DictCursor) as cursor:
991 cursor.execute(lyric_sql)
992 for r in cursor.fetchall():
993 url = r.get('lyrics_url')
994 if url and url.startswith(('http://', 'https://')):
995 content, _ = download_file(url, timeout=10)
996 if content:
997 try:
998 text = content.decode('utf-8')
999 except UnicodeDecodeError:
1000 text = content.decode('utf-8', errors='replace')
1001 l2_candidates.add(LyricRecord(
1002 record_id=str(r['id']),
1003 lyrics=text,
1004 title=r.get('name'),
1005 artist=r.get('singer'),
1006 lyricist=r.get('lyricist'),
1007 composer=r.get('composer'),
1008 ))
1009 logger.info(f"L2 候选集加载完成: {len(l2_candidates)} 条")
1010 except Exception as e:
1011 logger.error(f"加载已有歌词失败: {e}")
1012
1013 # 初始化去重报告 CSV
1014 report_path = str(REPORT_DIR / f'dedup_report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv')
1015 dedup_report = DedupReport(report_path)
1016 logger.info(f"去重报告将写入: {report_path}")
1017 if not args.review_result_csv:
1018 review_path = str(REPORT_DIR / f'review_decisions_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv')
1019 review_report = ReviewDecisionReport(review_path)
1020 logger.info(f"人工审核清单将写入: {review_path}")
1021
1022 if args.dedup_only:
1023 if args.skip_dedup:
1024 logger.error("--dedup-only 不能与 --skip-dedup 同时使用")
1025 return
1026 decision_counts = defaultdict(int)
1027 try:
1028 for row in tqdm(rows, desc="去重审核清单", unit="条"):
1029 record_id = row['source_id']
1030 record_name = row.get('name', '')
1031 action = classify_dedup_action(row, l1_index, l2_checker, l2_candidates)
1032 decision_counts[action['action']] += 1
1033 review_report.write(row, action)
1034 dedup_report.write(
1035 record_id,
1036 record_name,
1037 'DEDUP',
1038 action['action'],
1039 confidence=f"{float(action.get('confidence', 0.0)):.4f}",
1040 matched_id=action.get('matched_id') or '',
1041 reason=action.get('reason', ''),
1042 )
1043
1044 if action['action'] == 'new':
1045 lyrics_text = row.get('lyrics_txt_content')
1046 if lyrics_text and lyrics_text.strip():
1047 l2_candidates.add(LyricRecord(
1048 record_id=str(record_id),
1049 lyrics=lyrics_text,
1050 title=record_name,
1051 artist=row.get('singer'),
1052 lyricist=row.get('lyricist'),
1053 composer=row.get('composer'),
1054 ))
1055 key = (
1056 _normalize_meta(row.get('name')),
1057 _normalize_meta(row.get('lyricist')),
1058 _normalize_meta(row.get('composer')),
1059 )
1060 if key[0] and key not in l1_index:
1061 l1_index[key] = record_id
1062 finally:
1063 review_report.close()
1064 if dedup_report:
1065 dedup_report.close()
1066 logger.info(
1067 f"去重审核清单完成: {review_report.filepath} | "
1068 f"new={decision_counts['new']} review={decision_counts['review']} merge={decision_counts['merge']}"
1069 )
1070 return
1071
1072 # 统计计数器(线程安全)
1073 stats_lock = threading.Lock()
1074 stats = {
1075 'inserted': 0, 'errors': 0,
1076 'l1_dup': 0, 'l2_dup': 0, 'l2_review': 0, 'l2_new': 0,
1077 }
1078 start_time = time.time()
1079
1080 try:
1081 # 创建进度条
1082 pbar_oss = tqdm(total=total, desc="OSS 处理", unit="条", position=0, leave=True,
1083 bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]')
1084 pbar_db = tqdm(total=total, desc="DB 写入", unit="条", position=1, leave=True,
1085 bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]')
1086
1087 for batch_start in range(0, total, args.batch_size):
1088 batch_rows = rows[batch_start:batch_start + args.batch_size]
1089 batch_size_actual = len(batch_rows)
1090
1091 # ===== 并发处理 OSS =====
1092 # 用 dict 按索引保序
1093 results_map = {}
1094 tasks = [(batch_start + i, row, bucket, args.skip_oss) for i, row in enumerate(batch_rows)]
1095
1096 with ThreadPoolExecutor(max_workers=args.workers) as executor:
1097 futures = {executor.submit(process_row_with_oss, t): t[0] for t in tasks}
1098 for future in as_completed(futures):
1099 idx, tuple_row, err = future.result()
1100 if err:
1101 with stats_lock:
1102 stats['errors'] += 1
1103 logger.error(f"OSS 处理失败 source_id={batch_rows[idx - batch_start].get('source_id')}: {err}")
1104 else:
1105 results_map[idx] = tuple_row
1106 pbar_oss.update(1)
1107
1108 # 按原始顺序组装 batch_data
1109 batch_data = [results_map[i] for i in range(batch_start, batch_start + batch_size_actual) if i in results_map]
1110
1111 # ===== 去重过滤 =====
1112 if not args.skip_dedup and not args.review_result_csv and batch_data:
1113 filtered_data = []
1114 for i, tuple_row in enumerate(batch_data):
1115 orig_idx = batch_start + i
1116 row = batch_rows[i] if i < len(batch_rows) else None
1117 if row is None:
1118 filtered_data.append(tuple_row)
1119 continue
1120
1121 record_id = row['source_id']
1122 record_name = row.get('name', '')
1123
1124 action = classify_dedup_action(row, l1_index, l2_checker, l2_candidates)
1125 decision = action['decision']
1126 confidence = action['confidence']
1127 matched_action_id = action['matched_id']
1128 reason = action['reason']
1129
1130 if action['l1_matched']:
1131 with stats_lock:
1132 stats['l1_dup'] += 1
1133
1134 if action['action'] == 'merge':
1135 with stats_lock:
1136 stats['l2_dup'] += 1
1137 merge_note = ';作者字段需增量合并' if action.get('merge_authors') else ''
1138 dedup_report.write(record_id, record_name, 'L2', 'merge',
1139 confidence=f'{confidence:.4f}',
1140 matched_id=matched_action_id, reason=f'{reason}{merge_note}')
1141 if review_report:
1142 review_report.write(row, action)
1143 continue
1144
1145 if action['action'] == 'review':
1146 with stats_lock:
1147 stats['l2_review'] += 1
1148 dedup_report.write(record_id, record_name, 'L2', 'review',
1149 confidence=f'{confidence:.4f}',
1150 matched_id=matched_action_id, reason=reason)
1151 if review_report:
1152 review_report.write(row, action)
1153 continue
1154
1155 with stats_lock:
1156 stats['l2_new'] += 1
1157 dedup_report.write(record_id, record_name, 'L2', 'new',
1158 confidence=f'{confidence:.4f}',
1159 matched_id=matched_action_id or '',
1160 reason=reason)
1161 if review_report:
1162 review_report.write(row, action)
1163 filtered_data.append(tuple_row)
1164
1165 # 加入候选集供后续比对
1166 lyrics_text = row.get('lyrics_txt_content')
1167 if lyrics_text and lyrics_text.strip():
1168 l2_candidates.add(LyricRecord(
1169 record_id=str(record_id),
1170 lyrics=lyrics_text,
1171 title=record_name,
1172 artist=row.get('singer'),
1173 lyricist=row.get('lyricist'),
1174 composer=row.get('composer'),
1175 ))
1176
1177 batch_data = filtered_data
1178
1179 if not batch_data:
1180 pbar_db.update(batch_size_actual)
1181 continue
1182
1183 # ===== 顺序写入数据库(单连接,保证不出错)=====
1184 try:
1185 with target_conn.cursor() as cursor:
1186 cursor.executemany(insert_sql, batch_data)
1187 target_conn.commit()
1188 batch_inserted = len(batch_data)
1189 with stats_lock:
1190 stats['inserted'] += batch_inserted
1191
1192 # 更新 L1 索引(防止批次内重复)
1193 if not args.skip_dedup:
1194 for i, row in enumerate(batch_rows):
1195 key = (
1196 _normalize_meta(row.get('name')),
1197 _normalize_meta(row.get('lyricist')),
1198 _normalize_meta(row.get('composer')),
1199 )
1200 if key[0] and key not in l1_index:
1201 l1_index[key] = row['source_id']
1202 except Exception as e:
1203 logger.error(f"批量写入失败 (offset {batch_start}): {e}")
1204 target_conn.rollback()
1205 # 降级逐条写入,保证其他行不受影响
1206 for row_i, tuple_row in enumerate(batch_data):
1207 try:
1208 with target_conn.cursor() as cursor:
1209 cursor.execute(insert_sql, tuple_row)
1210 target_conn.commit()
1211 with stats_lock:
1212 stats['inserted'] += 1
1213 except Exception as e2:
1214 with stats_lock:
1215 stats['errors'] += 1
1216 logger.error(f"单条写入失败 (batch offset {batch_start + row_i}): {e2}")
1217
1218 pbar_db.update(batch_size_actual)
1219
1220 # 更新进度条描述(附加统计)
1221 with stats_lock:
1222 pbar_db.set_postfix(
1223 ins=stats['inserted'],
1224 err=stats['errors'],
1225 l1=stats['l1_dup'],
1226 l2=stats['l2_dup'],
1227 rev=stats['l2_review'],
1228 refresh=False
1229 )
1230
1231 pbar_oss.close()
1232 pbar_db.close()
1233
1234 elapsed_total = time.time() - start_time
1235 with stats_lock:
1236 logger.info("")
1237 logger.info("=" * 60)
1238 logger.info("导入完成!")
1239 logger.info(f"总计: {total} | 插入: {stats['inserted']} | 错误: {stats['errors']}")
1240 if not args.skip_dedup:
1241 logger.info(f"去重统计: L1跳过={stats['l1_dup']} | "
1242 f"L2合并命中={stats['l2_dup']} | L2待审={stats['l2_review']} | "
1243 f"L2新词={stats['l2_new']}")
1244 if dedup_report:
1245 logger.info(f"去重报告: {dedup_report.filepath}")
1246 if review_report:
1247 logger.info(f"人工审核清单: {review_report.filepath}")
1248 logger.info(f"耗时: {elapsed_total:.1f}s | 平均速度: {total/elapsed_total:.1f} 条/秒")
1249 logger.info("=" * 60)
1250
1251 finally:
1252 target_conn.close()
1253 logger.info("目标库连接已关闭")
1254 if dedup_report:
1255 dedup_report.close()
1256 if review_report:
1257 review_report.close()
1258
1259
1260 if __name__ == '__main__':
1261 main()
1 <!doctype html>
2 <html lang="zh-CN">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <title>L2 歌词召回复核</title>
7 <style>
8 :root {
9 --bg: #f6f7f9;
10 --panel: #ffffff;
11 --line: #d7dce2;
12 --text: #17202a;
13 --muted: #64717f;
14 --accent: #1264a3;
15 --accent-soft: #e8f2fb;
16 --hit: #a13d15;
17 --hit-soft: #fff0e8;
18 --review: #7b5b00;
19 --review-soft: #fff7d7;
20 --ok: #1f7a4d;
21 --ok-soft: #e7f6ee;
22 --conflict: #b42318;
23 --conflict-soft: #fff1f0;
24 --shadow: 0 1px 2px rgba(20, 30, 40, 0.08);
25 }
26
27 * { box-sizing: border-box; }
28
29 body {
30 margin: 0;
31 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
32 background: var(--bg);
33 color: var(--text);
34 letter-spacing: 0;
35 }
36
37 header {
38 height: 56px;
39 display: flex;
40 align-items: center;
41 gap: 16px;
42 padding: 0 20px;
43 background: var(--panel);
44 border-bottom: 1px solid var(--line);
45 position: sticky;
46 top: 0;
47 z-index: 5;
48 }
49
50 h1 {
51 margin: 0;
52 font-size: 18px;
53 line-height: 1.2;
54 font-weight: 650;
55 white-space: nowrap;
56 }
57
58 select, input, button {
59 height: 34px;
60 border: 1px solid var(--line);
61 background: #fff;
62 color: var(--text);
63 border-radius: 6px;
64 padding: 0 10px;
65 font-size: 14px;
66 }
67
68 button {
69 cursor: pointer;
70 background: var(--accent);
71 color: #fff;
72 border-color: var(--accent);
73 font-weight: 600;
74 }
75
76 button.secondary {
77 background: #fff;
78 color: var(--text);
79 border-color: var(--line);
80 }
81
82 button.danger {
83 background: var(--conflict);
84 border-color: var(--conflict);
85 color: #fff;
86 }
87
88 .toolbar {
89 display: flex;
90 align-items: center;
91 gap: 10px;
92 min-width: 0;
93 flex: 1;
94 }
95
96 .toolbar label {
97 color: var(--muted);
98 font-size: 13px;
99 white-space: nowrap;
100 }
101
102 main {
103 display: grid;
104 grid-template-columns: 360px 1fr;
105 height: calc(100vh - 56px);
106 min-height: 640px;
107 }
108
109 aside {
110 border-right: 1px solid var(--line);
111 background: var(--panel);
112 overflow: auto;
113 padding: 14px;
114 }
115
116 .content {
117 overflow: auto;
118 padding: 14px 18px 24px;
119 }
120
121 .section {
122 margin-bottom: 14px;
123 background: var(--panel);
124 border: 1px solid var(--line);
125 border-radius: 8px;
126 box-shadow: var(--shadow);
127 }
128
129 .section-head {
130 min-height: 42px;
131 display: flex;
132 align-items: center;
133 justify-content: space-between;
134 gap: 10px;
135 padding: 10px 12px;
136 border-bottom: 1px solid var(--line);
137 }
138
139 .section-title {
140 margin: 0;
141 font-size: 14px;
142 font-weight: 650;
143 }
144
145 .section-body {
146 padding: 12px;
147 }
148
149 .metrics {
150 display: grid;
151 grid-template-columns: repeat(2, minmax(0, 1fr));
152 gap: 8px;
153 }
154
155 .metric {
156 border: 1px solid var(--line);
157 border-radius: 6px;
158 padding: 8px;
159 background: #fbfcfd;
160 }
161
162 .metric .label {
163 color: var(--muted);
164 font-size: 12px;
165 }
166
167 .metric .value {
168 margin-top: 3px;
169 font-size: 18px;
170 font-weight: 700;
171 }
172
173 .tabs {
174 display: grid;
175 grid-template-columns: 1fr 1fr;
176 gap: 6px;
177 margin-bottom: 12px;
178 }
179
180 .tabs button {
181 background: #fff;
182 color: var(--text);
183 border-color: var(--line);
184 }
185
186 .tabs button.active {
187 background: var(--accent-soft);
188 color: var(--accent);
189 border-color: #91c1e8;
190 }
191
192 .query-list {
193 display: flex;
194 flex-direction: column;
195 gap: 8px;
196 }
197
198 .query-item {
199 width: 100%;
200 text-align: left;
201 display: flex;
202 flex-direction: column;
203 align-items: stretch;
204 background: #fff;
205 color: var(--text);
206 border: 1px solid var(--line);
207 border-radius: 7px;
208 padding: 10px;
209 min-height: 0;
210 height: auto;
211 overflow: visible;
212 }
213
214 .query-item.active {
215 border-color: var(--accent);
216 background: var(--accent-soft);
217 }
218
219 .query-item.hit {
220 border-left: 4px solid var(--hit);
221 }
222
223 .query-item.conflict {
224 border-color: var(--conflict);
225 background: var(--conflict-soft);
226 }
227
228 .query-title {
229 font-weight: 650;
230 font-size: 14px;
231 line-height: 1.25;
232 margin-bottom: 5px;
233 }
234
235 .query-meta {
236 color: var(--muted);
237 font-size: 12px;
238 line-height: 1.35;
239 }
240
241 .query-badges {
242 display: flex;
243 gap: 6px;
244 flex-wrap: wrap;
245 margin-top: 8px;
246 min-height: 22px;
247 }
248
249 .pill {
250 display: inline-flex;
251 align-items: center;
252 height: 22px;
253 border-radius: 999px;
254 padding: 0 8px;
255 font-size: 12px;
256 font-weight: 650;
257 border: 1px solid var(--line);
258 background: #fff;
259 color: var(--muted);
260 white-space: nowrap;
261 }
262
263 .pill.duplicate { background: var(--hit-soft); color: var(--hit); border-color: #efb394; }
264 .pill.review { background: var(--review-soft); color: var(--review); border-color: #e2cc72; }
265 .pill.new { background: var(--ok-soft); color: var(--ok); border-color: #93d0b0; }
266 .pill.conflict { background: var(--conflict-soft); color: var(--conflict); border-color: #f1aaa5; }
267 .pill.l1 { background: #eef1f4; color: #384452; border-color: #c8d0d9; }
268
269 .detail-grid {
270 display: grid;
271 grid-template-columns: minmax(320px, 0.9fr) minmax(420px, 1.1fr);
272 gap: 14px;
273 align-items: start;
274 }
275
276 .kv {
277 display: grid;
278 grid-template-columns: 82px 1fr;
279 gap: 6px 10px;
280 font-size: 13px;
281 line-height: 1.4;
282 }
283
284 .kv div:nth-child(odd) {
285 color: var(--muted);
286 }
287
288 .candidates {
289 display: grid;
290 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
291 gap: 10px;
292 }
293
294 .candidate {
295 border: 1px solid var(--line);
296 border-radius: 8px;
297 background: var(--panel);
298 padding: 10px;
299 cursor: pointer;
300 min-height: 132px;
301 }
302
303 .candidate.active {
304 border-color: var(--accent);
305 box-shadow: inset 0 0 0 1px var(--accent);
306 }
307
308 .candidate.duplicate {
309 background: var(--hit-soft);
310 border-color: #efb394;
311 }
312
313 .candidate.review {
314 background: var(--review-soft);
315 border-color: #e2cc72;
316 }
317
318 .candidate.conflict {
319 border-color: var(--conflict);
320 box-shadow: inset 4px 0 0 var(--conflict);
321 }
322
323 .candidate.l1hint {
324 box-shadow: inset 4px 0 0 var(--accent);
325 }
326
327 .candidate-head {
328 display: flex;
329 align-items: flex-start;
330 justify-content: space-between;
331 gap: 8px;
332 margin-bottom: 8px;
333 }
334
335 .rank {
336 width: 26px;
337 height: 26px;
338 border-radius: 50%;
339 background: #eef1f4;
340 display: inline-flex;
341 align-items: center;
342 justify-content: center;
343 font-size: 12px;
344 font-weight: 700;
345 color: var(--muted);
346 flex: none;
347 }
348
349 .candidate-name {
350 font-weight: 700;
351 font-size: 14px;
352 line-height: 1.25;
353 word-break: break-word;
354 }
355
356 .score-row {
357 display: grid;
358 grid-template-columns: repeat(3, minmax(0, 1fr));
359 gap: 6px;
360 margin-top: 8px;
361 color: var(--muted);
362 font-size: 12px;
363 }
364
365 .lyrics-grid {
366 display: grid;
367 grid-template-columns: 1fr 1fr;
368 gap: 12px;
369 }
370
371 pre {
372 margin: 0;
373 min-height: 420px;
374 max-height: 68vh;
375 overflow: auto;
376 white-space: pre-wrap;
377 word-break: break-word;
378 font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
379 font-size: 13px;
380 line-height: 1.55;
381 background: #fbfcfd;
382 border: 1px solid var(--line);
383 border-radius: 8px;
384 padding: 12px;
385 }
386
387 table {
388 width: 100%;
389 border-collapse: collapse;
390 font-size: 13px;
391 background: var(--panel);
392 }
393
394 th, td {
395 border-bottom: 1px solid var(--line);
396 padding: 8px 10px;
397 text-align: left;
398 vertical-align: top;
399 }
400
401 th {
402 color: var(--muted);
403 font-size: 12px;
404 font-weight: 650;
405 background: #fbfcfd;
406 position: sticky;
407 top: 0;
408 z-index: 1;
409 }
410
411 .empty {
412 padding: 36px;
413 text-align: center;
414 color: var(--muted);
415 border: 1px dashed var(--line);
416 border-radius: 8px;
417 background: #fff;
418 }
419
420 .path {
421 color: var(--muted);
422 font-size: 12px;
423 word-break: break-all;
424 }
425
426 .review-bar {
427 display: flex;
428 gap: 8px;
429 flex-wrap: wrap;
430 align-items: center;
431 }
432
433 .review-bar button {
434 background: #fff;
435 color: var(--text);
436 border-color: var(--line);
437 }
438
439 .review-bar button.active {
440 background: var(--accent-soft);
441 color: var(--accent);
442 border-color: #91c1e8;
443 }
444
445 .review-bar input {
446 flex: 1;
447 min-width: 220px;
448 }
449
450 @media (max-width: 1100px) {
451 main { grid-template-columns: 1fr; height: auto; }
452 aside { border-right: 0; border-bottom: 1px solid var(--line); max-height: 420px; }
453 .detail-grid, .lyrics-grid { grid-template-columns: 1fr; }
454 header { height: auto; min-height: 56px; flex-wrap: wrap; padding: 10px; }
455 .toolbar { flex-wrap: wrap; }
456 }
457 </style>
458 </head>
459 <body>
460 <header>
461 <h1>L2 歌词召回复核</h1>
462 <div class="toolbar">
463 <label for="runSelect">结果</label>
464 <select id="runSelect"></select>
465 <label for="topKSelect">topK</label>
466 <select id="topKSelect"></select>
467 <input id="searchInput" type="search" placeholder="搜索歌名 / ID">
468 <label for="pageSizeSelect">每页</label>
469 <select id="pageSizeSelect">
470 <option value="20">20</option>
471 <option value="50" selected>50</option>
472 <option value="100">100</option>
473 <option value="200">200</option>
474 </select>
475 <button id="exportBtn">导出标注</button>
476 <button id="importReviewedBtn">入库审核通过</button>
477 <button id="reloadBtn" class="secondary">刷新</button>
478 </div>
479 </header>
480
481 <main>
482 <aside>
483 <div class="tabs">
484 <button id="tabRetrieval" class="active" data-mode="retrieval">召回样本</button>
485 <button id="tabHits" data-mode="hits">命中样本</button>
486 </div>
487 <div class="section">
488 <div class="section-head">
489 <h2 class="section-title">效率指标</h2>
490 </div>
491 <div class="section-body">
492 <div id="metrics" class="metrics"></div>
493 </div>
494 </div>
495 <div class="section">
496 <div class="section-head">
497 <h2 class="section-title">样本</h2>
498 <span id="sampleCount" class="pill"></span>
499 </div>
500 <div class="section-body">
501 <div id="queryList" class="query-list"></div>
502 <div class="review-bar" style="margin-top:10px">
503 <button id="prevPageBtn" class="secondary">上一页</button>
504 <button id="nextPageBtn" class="secondary">下一页</button>
505 </div>
506 </div>
507 </div>
508 </aside>
509
510 <section class="content">
511 <div id="detail"></div>
512 </section>
513 </main>
514
515 <script>
516 const state = {
517 runs: [],
518 run: null,
519 summary: [],
520 groups: [],
521 queryRows: [],
522 totalGroups: 0,
523 hasMore: false,
524 page: 1,
525 pageSize: 50,
526 mode: 'retrieval',
527 topK: '',
528 queryId: '',
529 candidateId: '',
530 lyricsCache: new Map()
531 };
532
533 const els = {
534 runSelect: document.getElementById('runSelect'),
535 topKSelect: document.getElementById('topKSelect'),
536 searchInput: document.getElementById('searchInput'),
537 pageSizeSelect: document.getElementById('pageSizeSelect'),
538 exportBtn: document.getElementById('exportBtn'),
539 importReviewedBtn: document.getElementById('importReviewedBtn'),
540 reloadBtn: document.getElementById('reloadBtn'),
541 prevPageBtn: document.getElementById('prevPageBtn'),
542 nextPageBtn: document.getElementById('nextPageBtn'),
543 tabRetrieval: document.getElementById('tabRetrieval'),
544 tabHits: document.getElementById('tabHits'),
545 metrics: document.getElementById('metrics'),
546 sampleCount: document.getElementById('sampleCount'),
547 queryList: document.getElementById('queryList'),
548 detail: document.getElementById('detail')
549 };
550
551 function esc(value) {
552 return String(value ?? '').replace(/[&<>"']/g, ch => ({
553 '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
554 })[ch]);
555 }
556
557 function normalizeMeta(value) {
558 const punctuation = new Set(' \t\n\r,。!?;:、"“”‘’·…—~!¥()【】《》〈〉「」『』﹏,.:;!?()[]{}<>|/\\\\_-'.split(''));
559 return String(value || '')
560 .normalize('NFKC')
561 .trim()
562 .toLowerCase()
563 .split('')
564 .filter(ch => !punctuation.has(ch))
565 .join('');
566 }
567
568 function l1Match(row) {
569 if (row.l1_metadata_match === '1') return true;
570 if (row.l1_metadata_match === '0') return false;
571 return Boolean(row.query_name) &&
572 normalizeMeta(row.query_name) === normalizeMeta(row.candidate_name || row.matched_name) &&
573 normalizeMeta(row.query_lyricist) === normalizeMeta(row.candidate_lyricist || row.matched_lyricist) &&
574 normalizeMeta(row.query_composer) === normalizeMeta(row.candidate_composer || row.matched_composer);
575 }
576
577 function rowDecision(row) {
578 return row.decision || row.candidate_decision || '';
579 }
580
581 function isConflict(row) {
582 if (row.l1_l2_conflict === '1') return true;
583 const decision = rowDecision(row);
584 const l1 = l1Match(row);
585 return (decision === 'new' && l1) || (['duplicate', 'review'].includes(decision) && !l1);
586 }
587
588 function hasL1Hint(rows) {
589 return rows.some(row => rowDecision(row) === 'new' && l1Match(row));
590 }
591
592 function hasConflict(rows) {
593 return rows.some(isConflict);
594 }
595
596 function reviewKey(row) {
597 const candidateId = row.candidate_id || row.matched_id || '';
598 return [state.run?.id || '', state.topK || '', row.query_source_id || '', candidateId].join('::');
599 }
600
601 function loadReviews() {
602 try {
603 return JSON.parse(localStorage.getItem('l2ReviewAnnotations') || '{}');
604 } catch {
605 return {};
606 }
607 }
608
609 function saveReviews(reviews) {
610 localStorage.setItem('l2ReviewAnnotations', JSON.stringify(reviews));
611 }
612
613 function getReview(row) {
614 return loadReviews()[reviewKey(row)] || {};
615 }
616
617 function setReview(row, patch) {
618 const reviews = loadReviews();
619 const key = reviewKey(row);
620 reviews[key] = { ...(reviews[key] || {}), ...patch, updated_at: new Date().toISOString() };
621 saveReviews(reviews);
622 }
623
624 function getJSON(url) {
625 return fetch(url).then(res => {
626 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
627 return res.json();
628 });
629 }
630
631 function dataPath() {
632 if (!state.run) return '';
633 return state.mode === 'hits' ? state.run.hits : state.run.retrieval;
634 }
635
636 function metric(label, value) {
637 return `<div class="metric"><div class="label">${esc(label)}</div><div class="value">${esc(value)}</div></div>`;
638 }
639
640 function renderMetrics() {
641 const row = state.summary.find(item => String(item.top_k) === String(state.topK)) || {};
642 els.metrics.innerHTML = [
643 metric('总数', row.total_count || '-'),
644 metric('已入库 new', row.new_count || '-'),
645 metric('耗时(s)', row.elapsed_seconds || '-'),
646 metric('吞吐(条/s)', row.throughput_per_second || '-'),
647 metric('平均召回', row.avg_recalled_candidates || '-'),
648 metric('命中数', row.hit_count || '0'),
649 metric('merge', row.duplicate_count || '0'),
650 metric('review', row.review_count || '0')
651 ].join('');
652 }
653
654 function renderList() {
655 const groups = state.groups;
656 const totalPages = Math.max(1, Math.ceil(state.totalGroups / state.pageSize));
657 els.sampleCount.textContent = `${state.totalGroups} · ${state.page}/${totalPages}`;
658 if (!groups.length) {
659 els.queryList.innerHTML = '<div class="empty">没有样本</div>';
660 state.queryId = '';
661 state.queryRows = [];
662 renderDetail();
663 return;
664 }
665 els.queryList.innerHTML = groups.map(item => {
666 const hitClass = item.has_hit ? ' hit' : '';
667 const conflict = item.has_conflict ? ' conflict' : '';
668 const active = item.id === state.queryId ? ' active' : '';
669 const badges = [
670 item.has_conflict ? '<span class="pill conflict">L1/L2 冲突</span>' : '',
671 item.has_l1_hint ? '<span class="pill l1">L1 命中候选</span>' : ''
672 ].join(' ');
673 return `<button class="query-item${active}${hitClass}${conflict}" data-query-id="${esc(item.id)}">
674 <div class="query-title">${esc(item.query_name || '(无歌名)')}</div>
675 <div class="query-meta">ID ${esc(item.query_source_id)}</div>
676 <div class="query-meta">词 ${esc(item.query_lyricist || '-')} · 曲 ${esc(item.query_composer || '-')} · ${esc(item.count)} 条</div>
677 <div class="query-badges">${badges}</div>
678 </button>`;
679 }).join('');
680 for (const btn of els.queryList.querySelectorAll('.query-item')) {
681 btn.addEventListener('click', async () => {
682 state.queryId = btn.dataset.queryId;
683 state.candidateId = '';
684 await loadQueryRows();
685 renderAll();
686 });
687 }
688 els.prevPageBtn.disabled = state.page <= 1;
689 els.nextPageBtn.disabled = !state.hasMore;
690 }
691
692 function selectedQueryRows() {
693 return state.queryRows;
694 }
695
696 function selectedCandidate(rows) {
697 if (!rows.length) return null;
698 if (state.mode === 'hits') return rows[0];
699 if (!state.candidateId || !rows.some(row => row.candidate_id === state.candidateId)) {
700 state.candidateId = rows[0].candidate_id || '';
701 }
702 return rows.find(row => row.candidate_id === state.candidateId) || rows[0];
703 }
704
705 async function readText(path) {
706 if (!path) return '';
707 if (state.lyricsCache.has(path)) return state.lyricsCache.get(path);
708 const data = await getJSON(`/api/text?path=${encodeURIComponent(path)}`);
709 state.lyricsCache.set(path, data.text || '');
710 return data.text || '';
711 }
712
713 function candidateCard(row) {
714 const decision = row.candidate_decision || '';
715 const active = row.candidate_id === state.candidateId ? ' active' : '';
716 const conflict = isConflict(row);
717 const l1 = l1Match(row);
718 const extraClass = `${conflict ? ' conflict' : ''}${l1 ? ' l1hint' : ''}`;
719 return `<div class="candidate ${esc(decision)}${active}${extraClass}" data-candidate-id="${esc(row.candidate_id)}">
720 <div class="candidate-head">
721 <span class="rank">${esc(row.rank || '')}</span>
722 <div style="flex:1;min-width:0">
723 <div class="candidate-name">${esc(row.candidate_name || '(无歌名)')}</div>
724 </div>
725 <span class="pill ${esc(decision)}">${esc(decision || '-')}</span>
726 </div>
727 <div class="query-meta">词 ${esc(row.candidate_lyricist || '-')} · 曲 ${esc(row.candidate_composer || '-')}</div>
728 <div style="margin-top:7px;display:flex;gap:5px;flex-wrap:wrap">
729 ${l1 ? '<span class="pill l1">L1 元数据命中</span>' : '<span class="pill">L1 未命中</span>'}
730 ${conflict ? '<span class="pill conflict">冲突</span>' : ''}
731 </div>
732 <div class="score-row">
733 <div>conf<br><b>${esc(row.candidate_confidence || '-')}</b></div>
734 <div>jac<br><b>${esc(row.candidate_jaccard || '-')}</b></div>
735 <div>line<br><b>${esc(row.candidate_line_coverage || '-')}</b></div>
736 </div>
737 </div>`;
738 }
739
740 async function renderDetail() {
741 const rows = selectedQueryRows();
742 if (!rows.length) {
743 els.detail.innerHTML = '<div class="empty">选择一个样本查看详情</div>';
744 return;
745 }
746 const query = rows[0];
747 const candidate = selectedCandidate(rows);
748 const queryText = await readText(query.query_lyrics_path);
749 const candidatePath = state.mode === 'hits' ? candidate?.matched_lyrics_path : candidate?.candidate_lyrics_path;
750 const candidateText = await readText(candidatePath);
751 const hitDecision = state.mode === 'hits' ? candidate.decision : candidate?.candidate_decision;
752 const candidateName = state.mode === 'hits' ? candidate?.matched_name : candidate?.candidate_name;
753 const candidateLyricist = state.mode === 'hits' ? candidate?.matched_lyricist : candidate?.candidate_lyricist;
754 const candidateComposer = state.mode === 'hits' ? candidate?.matched_composer : candidate?.candidate_composer;
755 const reason = state.mode === 'hits' ? candidate?.reason : candidate?.candidate_reason;
756 const selectedReview = candidate ? getReview(candidate) : {};
757 const conflict = candidate ? isConflict(candidate) : false;
758 const l1 = candidate ? l1Match(candidate) : false;
759
760 els.detail.innerHTML = `
761 <div class="detail-grid">
762 <div class="section">
763 <div class="section-head">
764 <h2 class="section-title">新入库歌词</h2>
765 <span class="pill">ID ${esc(query.query_source_id)}</span>
766 </div>
767 <div class="section-body">
768 <div class="kv">
769 <div>歌名</div><div>${esc(query.query_name || '-')}</div>
770 <div>作词</div><div>${esc(query.query_lyricist || '-')}</div>
771 <div>作曲</div><div>${esc(query.query_composer || '-')}</div>
772 <div>歌词</div><div class="path">${esc(query.query_lyrics_path || '-')}</div>
773 </div>
774 </div>
775 </div>
776 <div class="section">
777 <div class="section-head">
778 <h2 class="section-title">${state.mode === 'hits' ? '命中候选' : '召回候选'}</h2>
779 <span class="pill ${esc(hitDecision || '')}">${esc(hitDecision || '-')}</span>
780 </div>
781 <div class="section-body">
782 <div class="kv">
783 <div>歌名</div><div>${esc(candidateName || '-')}</div>
784 <div>作词</div><div>${esc(candidateLyricist || '-')}</div>
785 <div>作曲</div><div>${esc(candidateComposer || '-')}</div>
786 <div>原因</div><div>${esc(reason || '-')}</div>
787 <div>L1</div><div>${l1 ? '<span class="pill l1">元数据命中</span>' : '<span class="pill">未命中</span>'} ${conflict ? '<span class="pill conflict">L1/L2 冲突</span>' : ''}</div>
788 <div>歌词</div><div class="path">${esc(candidatePath || '-')}</div>
789 </div>
790 </div>
791 </div>
792 </div>
793
794 <div class="section">
795 <div class="section-head"><h2 class="section-title">人工标注</h2></div>
796 <div class="section-body">
797 <div class="review-bar">
798 ${['duplicate', 'not_duplicate', 'unsure'].map(value => `
799 <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}">
800 ${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'}
801 </button>`).join('')}
802 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注">
803 <button id="saveReviewBtn" class="secondary">保存</button>
804 </div>
805 </div>
806 </div>
807
808 ${state.mode === 'retrieval' ? `
809 <div class="section">
810 <div class="section-head"><h2 class="section-title">Top10 候选</h2></div>
811 <div class="section-body"><div class="candidates">${rows.map(candidateCard).join('') || '<div class="empty">无召回候选</div>'}</div></div>
812 </div>` : ''}
813
814 <div class="section">
815 <div class="section-head"><h2 class="section-title">歌词对比</h2></div>
816 <div class="section-body">
817 <div class="lyrics-grid">
818 <pre>${esc(queryText || '未读取到歌词')}</pre>
819 <pre>${esc(candidateText || '未读取到歌词')}</pre>
820 </div>
821 </div>
822 </div>`;
823
824 for (const card of els.detail.querySelectorAll('.candidate')) {
825 card.addEventListener('click', () => {
826 state.candidateId = card.dataset.candidateId;
827 renderDetail();
828 });
829 }
830 for (const btn of els.detail.querySelectorAll('.review-choice')) {
831 btn.addEventListener('click', () => {
832 if (!candidate) return;
833 setReview(candidate, { final_decision: btn.dataset.review, note: document.getElementById('reviewNote')?.value || '' });
834 renderDetail();
835 });
836 }
837 const saveBtn = document.getElementById('saveReviewBtn');
838 if (saveBtn) {
839 saveBtn.addEventListener('click', () => {
840 if (!candidate) return;
841 setReview(candidate, { note: document.getElementById('reviewNote')?.value || '' });
842 renderDetail();
843 });
844 }
845 }
846
847 function csvValue(value) {
848 const text = String(value ?? '');
849 if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
850 return text;
851 }
852
853 function exportAnnotations() {
854 const reviews = loadReviews();
855 const rows = [];
856 for (const row of state.queryRows) {
857 const review = reviews[reviewKey(row)] || {};
858 rows.push({
859 run_id: state.run?.id || '',
860 top_k: state.topK,
861 query_source_id: row.query_source_id || '',
862 query_name: row.query_name || '',
863 query_lyricist: row.query_lyricist || '',
864 query_composer: row.query_composer || '',
865 candidate_id: row.candidate_id || row.matched_id || '',
866 candidate_name: row.candidate_name || row.matched_name || '',
867 candidate_lyricist: row.candidate_lyricist || row.matched_lyricist || '',
868 candidate_composer: row.candidate_composer || row.matched_composer || '',
869 l2_decision: rowDecision(row),
870 l1_metadata_match: l1Match(row) ? '1' : '0',
871 l1_l2_conflict: isConflict(row) ? '1' : '0',
872 final_decision: review.final_decision || '',
873 note: review.note || '',
874 updated_at: review.updated_at || ''
875 });
876 }
877 const fields = Object.keys(rows[0] || {
878 run_id: '', top_k: '', query_source_id: '', query_name: '', query_lyricist: '',
879 query_composer: '', candidate_id: '', candidate_name: '', candidate_lyricist: '',
880 candidate_composer: '', l2_decision: '', l1_metadata_match: '', l1_l2_conflict: '',
881 final_decision: '', note: '', updated_at: ''
882 });
883 const csv = [fields.join(','), ...rows.map(row => fields.map(field => csvValue(row[field])).join(','))].join('\n');
884 const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
885 const url = URL.createObjectURL(blob);
886 const a = document.createElement('a');
887 a.href = url;
888 a.download = `l2_review_annotations_${state.run?.id || 'run'}_topk_${state.topK || 'all'}_${state.mode}.csv`;
889 document.body.appendChild(a);
890 a.click();
891 a.remove();
892 URL.revokeObjectURL(url);
893 }
894
895 async function importReviewed() {
896 if (!state.run) return;
897 const reviews = loadReviews();
898 const prefix = `${state.run.id}::${state.topK || ''}::`;
899 const rows = [];
900 for (const [key, review] of Object.entries(reviews)) {
901 if (!key.startsWith(prefix)) continue;
902 if (review.final_decision !== 'not_duplicate') continue;
903 const parts = key.split('::');
904 const sourceId = parts[2] || '';
905 if (!sourceId) continue;
906 rows.push({
907 source_id: sourceId,
908 review_decision: 'import',
909 review_note: review.note || ''
910 });
911 }
912 if (!rows.length) {
913 alert('当前结果中没有标注为“确认不重复”的 review 样本。');
914 return;
915 }
916 if (!confirm(`确认入库 ${rows.length} 条审核通过样本?`)) return;
917 els.importReviewedBtn.disabled = true;
918 els.importReviewedBtn.textContent = '入库中...';
919 try {
920 const res = await fetch('/api/import-reviewed', {
921 method: 'POST',
922 headers: { 'Content-Type': 'application/json' },
923 body: JSON.stringify({ run_id: state.run.id, rows })
924 });
925 const payload = await res.json();
926 if (!res.ok) throw new Error(payload.error || payload.output || `${res.status} ${res.statusText}`);
927 alert(`入库完成。结果文件:${payload.review_csv}`);
928 } catch (err) {
929 alert(`入库失败:${err.message}`);
930 } finally {
931 els.importReviewedBtn.disabled = false;
932 els.importReviewedBtn.textContent = '入库审核通过';
933 }
934 }
935
936 function renderAll() {
937 renderMetrics();
938 renderList();
939 renderDetail();
940 els.tabRetrieval.classList.toggle('active', state.mode === 'retrieval');
941 els.tabHits.classList.toggle('active', state.mode === 'hits');
942 }
943
944 async function loadGroups() {
945 if (!state.run || !state.topK) return;
946 els.queryList.innerHTML = '<div class="empty">加载样本...</div>';
947 const params = new URLSearchParams({
948 path: dataPath(),
949 top_k: state.topK,
950 q: els.searchInput.value.trim(),
951 page: String(state.page),
952 page_size: String(state.pageSize)
953 });
954 const data = await getJSON(`/api/groups?${params.toString()}`);
955 state.groups = data.groups || [];
956 state.totalGroups = data.total || 0;
957 state.hasMore = Boolean(data.has_more);
958 if (!state.groups.some(group => group.id === state.queryId)) {
959 state.queryId = state.groups[0]?.id || '';
960 state.candidateId = '';
961 }
962 await loadQueryRows();
963 }
964
965 async function loadQueryRows() {
966 state.queryRows = [];
967 if (!state.queryId || !state.run || !state.topK) return;
968 const params = new URLSearchParams({
969 path: dataPath(),
970 top_k: state.topK,
971 query_id: state.queryId
972 });
973 const data = await getJSON(`/api/query?${params.toString()}`);
974 state.queryRows = data.rows || [];
975 }
976
977 async function loadRun(runId) {
978 state.run = state.runs.find(run => run.id === runId) || state.runs[0];
979 if (!state.run) return;
980 const summary = await getJSON(`/api/csv?path=${encodeURIComponent(state.run.summary)}`);
981 state.summary = summary.rows || [];
982 const topKs = Array.from(new Set(state.summary.map(row => row.top_k))).filter(Boolean);
983 els.topKSelect.innerHTML = topKs.map(k => `<option value="${esc(k)}">${esc(k)}</option>`).join('');
984 state.topK = topKs[0] || '';
985 els.topKSelect.value = state.topK;
986 els.pageSizeSelect.value = String(state.pageSize);
987 state.page = 1;
988 state.queryId = '';
989 state.candidateId = '';
990 await loadGroups();
991 renderAll();
992 }
993
994 async function loadRuns() {
995 const data = await getJSON('/api/reports');
996 state.runs = data.runs || [];
997 els.runSelect.innerHTML = state.runs.map(run =>
998 `<option value="${esc(run.id)}">${esc(run.id)}</option>`
999 ).join('');
1000 if (!state.runs.length) {
1001 els.detail.innerHTML = '<div class="empty">没有找到 l2_topk_retrieval_top10 / duplicate_hits / summary 结果文件</div>';
1002 return;
1003 }
1004 await loadRun(state.runs[0].id);
1005 }
1006
1007 els.runSelect.addEventListener('change', () => loadRun(els.runSelect.value));
1008 els.topKSelect.addEventListener('change', async () => {
1009 state.topK = els.topKSelect.value;
1010 state.page = 1;
1011 state.queryId = '';
1012 state.candidateId = '';
1013 await loadGroups();
1014 renderAll();
1015 });
1016 els.searchInput.addEventListener('input', async () => {
1017 state.page = 1;
1018 state.queryId = '';
1019 state.candidateId = '';
1020 await loadGroups();
1021 renderAll();
1022 });
1023 els.reloadBtn.addEventListener('click', loadRuns);
1024 els.exportBtn.addEventListener('click', exportAnnotations);
1025 els.importReviewedBtn.addEventListener('click', importReviewed);
1026 els.pageSizeSelect.addEventListener('change', async () => {
1027 state.pageSize = Number(els.pageSizeSelect.value) || 50;
1028 state.page = 1;
1029 state.queryId = '';
1030 state.candidateId = '';
1031 await loadGroups();
1032 renderAll();
1033 });
1034 els.prevPageBtn.addEventListener('click', async () => {
1035 if (state.page <= 1) return;
1036 state.page -= 1;
1037 state.queryId = '';
1038 state.candidateId = '';
1039 await loadGroups();
1040 renderAll();
1041 });
1042 els.nextPageBtn.addEventListener('click', async () => {
1043 if (!state.hasMore) return;
1044 state.page += 1;
1045 state.queryId = '';
1046 state.candidateId = '';
1047 await loadGroups();
1048 renderAll();
1049 });
1050 els.tabRetrieval.addEventListener('click', async () => {
1051 state.mode = 'retrieval';
1052 state.page = 1;
1053 state.queryId = '';
1054 state.candidateId = '';
1055 await loadGroups();
1056 renderAll();
1057 });
1058 els.tabHits.addEventListener('click', async () => {
1059 state.mode = 'hits';
1060 state.page = 1;
1061 state.queryId = '';
1062 state.candidateId = '';
1063 await loadGroups();
1064 renderAll();
1065 });
1066
1067 loadRuns().catch(err => {
1068 els.detail.innerHTML = `<div class="empty">${esc(err.message)}</div>`;
1069 });
1070 </script>
1071 </body>
1072 </html>
1 """Lyric duplicate detection utilities."""
2
3 from lyric_dedup.checker import DuplicateCheckResult
4 from lyric_dedup.checker import DuplicateChecker
5 from lyric_dedup.checker import DuplicateDecision
6 from lyric_dedup.checker import LyricRecord
7
8 __all__ = [
9 "DuplicateCheckResult",
10 "DuplicateChecker",
11 "DuplicateDecision",
12 "LyricRecord",
13 ]
1 """Lyric candidate ranking and duplicate decision rules."""
2
3 from __future__ import annotations
4
5 import hashlib
6 import re
7 import unicodedata
8 from dataclasses import dataclass
9 from enum import Enum
10
11 from lyric_dedup.normalization import NormalizedLyrics
12 from lyric_dedup.normalization import fingerprint_text
13 from lyric_dedup.normalization import lyric_tokens
14 from lyric_dedup.normalization import normalize_lyrics
15
16
17 class DuplicateDecision(str, Enum):
18 DUPLICATE = "duplicate"
19 REVIEW = "review"
20 NEW = "new"
21
22
23 @dataclass(frozen=True)
24 class LyricRecord:
25 record_id: str
26 lyrics: str
27 title: str | None = None
28 artist: str | None = None
29 lyricist: str | None = None
30 composer: str | None = None
31
32
33 @dataclass(frozen=True)
34 class CandidateMatch:
35 record_id: str
36 decision: DuplicateDecision
37 confidence: float
38 jaccard: float
39 line_coverage: float
40 primary_jaccard: float
41 primary_line_coverage: float
42 translation_jaccard: float
43 translation_line_coverage: float
44 matched_unique_lines: tuple[str, ...]
45 reason: str
46
47
48 @dataclass(frozen=True)
49 class DuplicateCheckResult:
50 decision: DuplicateDecision
51 confidence: float
52 candidates: tuple[CandidateMatch, ...]
53 normalized_full_text: str
54 reason: str
55
56
57 @dataclass(frozen=True)
58 class _IndexedRecord:
59 record: LyricRecord
60 normalized: NormalizedLyrics
61 exact_hash: str
62 tokens: set[str]
63 primary_tokens: set[str]
64 translation_tokens: set[str]
65 fallback_lines: tuple[str, ...]
66 fallback_tokens: set[str]
67
68
69 class DuplicateChecker:
70 """Rank PostgreSQL-recalled candidates and produce the final decision."""
71
72 def __init__(
73 self,
74 *,
75 duplicate_jaccard_threshold: float = 0.78,
76 duplicate_line_coverage_threshold: float = 0.72,
77 duplicate_high_coverage_jaccard_threshold: float = 0.78,
78 duplicate_high_coverage_line_coverage_threshold: float = 0.90,
79 review_jaccard_threshold: float = 0.45,
80 review_line_coverage_threshold: float = 0.35,
81 review_query_coverage_threshold: float = 0.40,
82 fragment_query_coverage_threshold: float = 0.80,
83 fragment_max_line_ratio: float = 0.75,
84 fragment_min_matched_lines: int = 3,
85 chorus_short_line_count_threshold: int = 6,
86 chorus_material_overlap_threshold: float = 0.20,
87 chorus_material_query_coverage_threshold: float = 0.40,
88 confidence_jaccard_weight: float = 0.58,
89 confidence_line_coverage_weight: float = 0.42,
90 metadata_duplicate_jaccard_threshold: float = 0.88,
91 metadata_duplicate_line_coverage_threshold: float = 0.65,
92 metadata_duplicate_min_primary_lines: int = 8,
93 auto_duplicate_min_primary_lines: int = 4,
94 ) -> None:
95 self.duplicate_jaccard_threshold = duplicate_jaccard_threshold
96 self.duplicate_line_coverage_threshold = duplicate_line_coverage_threshold
97 self.duplicate_high_coverage_jaccard_threshold = duplicate_high_coverage_jaccard_threshold
98 self.duplicate_high_coverage_line_coverage_threshold = duplicate_high_coverage_line_coverage_threshold
99 self.review_jaccard_threshold = review_jaccard_threshold
100 self.review_line_coverage_threshold = review_line_coverage_threshold
101 self.review_query_coverage_threshold = review_query_coverage_threshold
102 self.fragment_query_coverage_threshold = fragment_query_coverage_threshold
103 self.fragment_max_line_ratio = fragment_max_line_ratio
104 self.fragment_min_matched_lines = fragment_min_matched_lines
105 self.chorus_short_line_count_threshold = chorus_short_line_count_threshold
106 self.chorus_material_overlap_threshold = chorus_material_overlap_threshold
107 self.chorus_material_query_coverage_threshold = chorus_material_query_coverage_threshold
108 self.confidence_jaccard_weight = confidence_jaccard_weight
109 self.confidence_line_coverage_weight = confidence_line_coverage_weight
110 self.metadata_duplicate_jaccard_threshold = metadata_duplicate_jaccard_threshold
111 self.metadata_duplicate_line_coverage_threshold = metadata_duplicate_line_coverage_threshold
112 self.metadata_duplicate_min_primary_lines = metadata_duplicate_min_primary_lines
113 self.auto_duplicate_min_primary_lines = auto_duplicate_min_primary_lines
114
115 def check_record_against_candidates(
116 self,
117 record: LyricRecord,
118 candidates: list[LyricRecord],
119 *,
120 max_candidates: int = 10,
121 ) -> DuplicateCheckResult:
122 """Rank explicitly supplied candidates without doing in-memory recall.
123
124 PostgreSQL-backed callers should use this method after database recall so
125 there is only one retrieval path: PG returns candidates, Python ranks and
126 decides.
127 """
128 query = self._index(record)
129 ranked = sorted(
130 (
131 self._rank_exact_candidate(query, indexed)
132 if indexed.exact_hash == query.exact_hash
133 else self._rank_candidate(query, indexed)
134 for indexed in (self._index(candidate) for candidate in candidates)
135 ),
136 key=lambda item: (item.decision == DuplicateDecision.DUPLICATE, item.confidence, item.jaccard),
137 reverse=True,
138 )[:max_candidates]
139
140 duplicate = next((candidate for candidate in ranked if candidate.decision == DuplicateDecision.DUPLICATE), None)
141 if duplicate is not None:
142 return DuplicateCheckResult(
143 decision=DuplicateDecision.DUPLICATE,
144 confidence=duplicate.confidence,
145 candidates=tuple(ranked),
146 normalized_full_text=query.normalized.normalized_full_text,
147 reason=duplicate.reason,
148 )
149
150 review = next((candidate for candidate in ranked if candidate.decision == DuplicateDecision.REVIEW), None)
151 if review is not None:
152 return DuplicateCheckResult(
153 decision=DuplicateDecision.REVIEW,
154 confidence=review.confidence,
155 candidates=tuple(ranked),
156 normalized_full_text=query.normalized.normalized_full_text,
157 reason=review.reason,
158 )
159
160 return DuplicateCheckResult(
161 decision=DuplicateDecision.NEW,
162 confidence=1.0 - (ranked[0].confidence if ranked else 0.0),
163 candidates=tuple(ranked),
164 normalized_full_text=query.normalized.normalized_full_text,
165 reason=(
166 ranked[0].reason
167 if ranked and ranked[0].reason.startswith("无有效歌词")
168 else "精确匹配、近重复召回和字面重合信号都较低"
169 ),
170 )
171
172 def _index(self, record: LyricRecord) -> _IndexedRecord:
173 normalized = normalize_lyrics(record.lyrics)
174 return self._index_normalized(record, normalized)
175
176 def _index_normalized(self, record: LyricRecord, normalized: NormalizedLyrics) -> _IndexedRecord:
177 tokens = lyric_tokens(normalized)
178 primary_tokens = lyric_tokens(normalized, lines=normalized.primary_lines)
179 translation_tokens = lyric_tokens(normalized, lines=normalized.translation_lines)
180 fallback_lines = tuple(_fallback_no_lyrics_lines(record.lyrics))
181 fallback_tokens = set(fallback_lines)
182 exact_hash = hashlib.sha256(_exact_fingerprint(normalized, fallback_lines).encode("utf-8")).hexdigest()
183 return _IndexedRecord(
184 record=record,
185 normalized=normalized,
186 exact_hash=exact_hash,
187 tokens=tokens,
188 primary_tokens=primary_tokens,
189 translation_tokens=translation_tokens,
190 fallback_lines=fallback_lines,
191 fallback_tokens=fallback_tokens,
192 )
193
194 def _rank_exact_candidate(self, query: _IndexedRecord, candidate: _IndexedRecord) -> CandidateMatch:
195 low_confidence_split = (
196 query.normalized.split_confidence == "low" or candidate.normalized.split_confidence == "low"
197 )
198 translation_jaccard = _jaccard(query.translation_tokens, candidate.translation_tokens)
199 translation_coverage, _ = _line_coverage_lines(
200 query.normalized.translation_lines,
201 candidate.normalized.translation_lines,
202 )
203 no_effective_lyrics = not query.normalized.primary_lines and not candidate.normalized.primary_lines
204 if no_effective_lyrics:
205 decision = DuplicateDecision.NEW
206 confidence = 0.0
207 reason = "无有效歌词,不使用空内容或元数据兜底指纹自动判重"
208 elif low_confidence_split:
209 decision = DuplicateDecision.REVIEW
210 confidence = 0.95
211 reason = "原文哈希一致,但疑似整段翻译结构拆分置信度较低,需要人工复核"
212 elif _is_generic_primary_lyrics(query.normalized) or _is_generic_primary_lyrics(candidate.normalized):
213 decision = DuplicateDecision.REVIEW
214 confidence = 0.95
215 reason = "规范化后的原文哈希一致,但有效歌词过短或为通用提示,需要人工复核"
216 elif query.normalized.translation_lines or candidate.normalized.translation_lines:
217 decision = DuplicateDecision.DUPLICATE
218 confidence = 1.0
219 reason = "规范化后的原文歌词哈希完全一致,翻译行未参与自动判重"
220 else:
221 decision = DuplicateDecision.DUPLICATE
222 confidence = 1.0
223 reason = "规范化后的原文歌词哈希完全一致"
224 return CandidateMatch(
225 record_id=candidate.record.record_id,
226 decision=decision,
227 confidence=confidence,
228 jaccard=1.0,
229 line_coverage=1.0,
230 primary_jaccard=1.0,
231 primary_line_coverage=1.0,
232 translation_jaccard=round(translation_jaccard, 4),
233 translation_line_coverage=round(translation_coverage, 4),
234 matched_unique_lines=query.normalized.primary_lines,
235 reason=reason,
236 )
237
238 def _rank_candidate(self, query: _IndexedRecord, candidate: _IndexedRecord) -> CandidateMatch:
239 if not query.normalized.primary_lines or not candidate.normalized.primary_lines:
240 return _rank_no_effective_lyrics_candidate(query, candidate)
241
242 jaccard = _jaccard(query.tokens, candidate.tokens)
243 coverage, matched_lines = _line_coverage(query.normalized, candidate.normalized)
244 primary_jaccard = _jaccard(query.primary_tokens, candidate.primary_tokens)
245 primary_coverage, primary_matched_lines = _line_coverage_lines(
246 query.normalized.primary_lines,
247 candidate.normalized.primary_lines,
248 )
249 query_primary_coverage = _matched_query_line_ratio(query.normalized.primary_lines, primary_matched_lines)
250 translation_jaccard = _jaccard(query.translation_tokens, candidate.translation_tokens)
251 translation_coverage, translation_matched_lines = _line_coverage_lines(
252 query.normalized.translation_lines,
253 candidate.normalized.translation_lines,
254 )
255 chorus_only = _is_chorus_only_match(query.normalized, candidate.normalized, primary_matched_lines)
256 translation_only = (
257 bool(translation_matched_lines)
258 and primary_jaccard < self.review_jaccard_threshold
259 and primary_coverage < self.review_line_coverage_threshold
260 and (translation_jaccard >= self.review_jaccard_threshold or translation_coverage >= self.review_line_coverage_threshold)
261 )
262 low_confidence_split = (
263 query.normalized.split_confidence == "low" or candidate.normalized.split_confidence == "low"
264 )
265 query_coverage = _matched_query_line_ratio(query.normalized.unique_lines, matched_lines)
266 is_plain_fragment = _is_plain_fragment(
267 query.normalized.primary_lines,
268 candidate.normalized.primary_lines,
269 primary_matched_lines,
270 min_query_coverage=self.fragment_query_coverage_threshold,
271 max_line_ratio=self.fragment_max_line_ratio,
272 min_matched_lines=self.fragment_min_matched_lines,
273 )
274 has_review_level_overlap = (
275 primary_jaccard >= self.review_jaccard_threshold
276 or jaccard >= self.review_jaccard_threshold
277 or (
278 primary_coverage >= self.review_line_coverage_threshold
279 and query_primary_coverage >= self.review_query_coverage_threshold
280 )
281 or (
282 coverage >= self.review_line_coverage_threshold
283 and query_coverage >= self.review_query_coverage_threshold
284 )
285 )
286 has_material_chorus_overlap = chorus_only and (
287 query.normalized.content_line_count <= self.chorus_short_line_count_threshold
288 or (
289 primary_jaccard >= self.chorus_material_overlap_threshold
290 and query_primary_coverage >= self.chorus_material_query_coverage_threshold
291 )
292 or (
293 jaccard >= self.chorus_material_overlap_threshold
294 and query_coverage >= self.chorus_material_query_coverage_threshold
295 )
296 or (
297 primary_coverage >= self.chorus_material_overlap_threshold
298 and query_primary_coverage >= self.chorus_material_query_coverage_threshold
299 )
300 or (
301 coverage >= self.chorus_material_overlap_threshold
302 and query_coverage >= self.chorus_material_query_coverage_threshold
303 )
304 )
305 has_low_confidence_split_overlap = low_confidence_split and has_review_level_overlap
306 has_cover_signal = _has_cover_signal(query.record) or _has_cover_signal(candidate.record)
307 has_metadata_supported_duplicate = self._has_metadata_supported_duplicate(
308 query,
309 candidate,
310 primary_jaccard=primary_jaccard,
311 primary_coverage=primary_coverage,
312 )
313 has_enough_auto_duplicate_lyrics = _has_enough_primary_lyrics(
314 query.normalized,
315 candidate.normalized,
316 min_lines=self.auto_duplicate_min_primary_lines,
317 )
318 has_generic_primary_lyrics = (
319 _is_generic_primary_lyrics(query.normalized)
320 or _is_generic_primary_lyrics(candidate.normalized)
321 )
322
323 confidence = round(
324 (self.confidence_jaccard_weight * primary_jaccard)
325 + (self.confidence_line_coverage_weight * primary_coverage),
326 4,
327 )
328 if is_plain_fragment:
329 decision = DuplicateDecision.REVIEW
330 reason = "歌词片段只覆盖候选完整歌词的一部分,需要人工复核"
331 elif (
332 (
333 primary_jaccard >= self.duplicate_jaccard_threshold
334 or (
335 primary_jaccard >= self.duplicate_high_coverage_jaccard_threshold
336 and primary_coverage >= self.duplicate_high_coverage_line_coverage_threshold
337 )
338 )
339 and primary_coverage >= self.duplicate_line_coverage_threshold
340 and not chorus_only
341 and not translation_only
342 and not low_confidence_split
343 and not has_cover_signal
344 and has_enough_auto_duplicate_lyrics
345 and not has_generic_primary_lyrics
346 ):
347 decision = DuplicateDecision.DUPLICATE
348 if query.normalized.translation_lines or candidate.normalized.translation_lines:
349 reason = "原文歌词高度一致,翻译行未参与自动判重"
350 else:
351 reason = "原文 n-gram 字面相似度高,且行级覆盖范围广"
352 elif has_metadata_supported_duplicate:
353 decision = DuplicateDecision.REVIEW
354 reason = "同词曲/同标题且原文歌词主体高度一致,需要人工复核"
355 elif has_material_chorus_overlap:
356 decision = DuplicateDecision.REVIEW
357 reason = "重合内容主要集中在重复副歌行,需要人工复核"
358 elif (
359 translation_only
360 or has_low_confidence_split_overlap
361 or has_review_level_overlap
362 or (has_cover_signal and (primary_jaccard >= self.review_jaccard_threshold or primary_coverage >= self.review_line_coverage_threshold))
363 ):
364 decision = DuplicateDecision.REVIEW
365 reason = "候选相似度达到复核阈值,需要人工确认"
366 if translation_only:
367 reason = "仅翻译行相似,原文字面重合不足,不自动判重"
368 elif has_low_confidence_split_overlap:
369 reason = "疑似整段翻译结构但拆分置信度较低,需要人工复核"
370 elif has_cover_signal:
371 reason = "疑似翻唱/版本歌词相似,需要人工复核"
372 else:
373 decision = DuplicateDecision.NEW
374 reason = "候选重合度低于复核阈值"
375
376 return CandidateMatch(
377 record_id=candidate.record.record_id,
378 decision=decision,
379 confidence=confidence,
380 jaccard=round(jaccard, 4),
381 line_coverage=round(coverage, 4),
382 primary_jaccard=round(primary_jaccard, 4),
383 primary_line_coverage=round(primary_coverage, 4),
384 translation_jaccard=round(translation_jaccard, 4),
385 translation_line_coverage=round(translation_coverage, 4),
386 matched_unique_lines=tuple(matched_lines),
387 reason=reason,
388 )
389
390 def _has_metadata_supported_duplicate(
391 self,
392 query: _IndexedRecord,
393 candidate: _IndexedRecord,
394 *,
395 primary_jaccard: float,
396 primary_coverage: float,
397 ) -> bool:
398 if primary_jaccard < self.metadata_duplicate_jaccard_threshold:
399 return False
400 if primary_coverage < self.metadata_duplicate_line_coverage_threshold:
401 return False
402 if not _has_enough_primary_lyrics(
403 query.normalized,
404 candidate.normalized,
405 min_lines=self.metadata_duplicate_min_primary_lines,
406 ):
407 return False
408 if query.normalized.translation_lines or candidate.normalized.translation_lines:
409 return False
410 return _same_title(query.record, candidate.record) or _same_writers(query.record, candidate.record)
411
412
413 def _rank_no_effective_lyrics_candidate(query: _IndexedRecord, candidate: _IndexedRecord) -> CandidateMatch:
414 fallback_jaccard = _jaccard(query.fallback_tokens, candidate.fallback_tokens)
415 fallback_coverage, matched_lines = _line_coverage_lines(query.fallback_lines, candidate.fallback_lines)
416 if fallback_jaccard >= 0.35 and fallback_coverage >= 0.35 and len(matched_lines) >= 2:
417 return CandidateMatch(
418 record_id=candidate.record.record_id,
419 decision=DuplicateDecision.REVIEW,
420 confidence=round((0.58 * fallback_jaccard) + (0.42 * fallback_coverage), 4),
421 jaccard=round(fallback_jaccard, 4),
422 line_coverage=round(fallback_coverage, 4),
423 primary_jaccard=0.0,
424 primary_line_coverage=0.0,
425 translation_jaccard=0.0,
426 translation_line_coverage=0.0,
427 matched_unique_lines=tuple(matched_lines),
428 reason="无有效歌词,文件内容兜底特征高度相似,需要人工复核",
429 )
430 if fallback_jaccard >= 0.2 or fallback_coverage >= 0.2:
431 return CandidateMatch(
432 record_id=candidate.record.record_id,
433 decision=DuplicateDecision.REVIEW,
434 confidence=round((0.58 * fallback_jaccard) + (0.42 * fallback_coverage), 4),
435 jaccard=round(fallback_jaccard, 4),
436 line_coverage=round(fallback_coverage, 4),
437 primary_jaccard=0.0,
438 primary_line_coverage=0.0,
439 translation_jaccard=0.0,
440 translation_line_coverage=0.0,
441 matched_unique_lines=tuple(matched_lines),
442 reason="无有效歌词,文件内容兜底特征部分相似,需要人工复核",
443 )
444 return CandidateMatch(
445 record_id=candidate.record.record_id,
446 decision=DuplicateDecision.NEW,
447 confidence=0.0,
448 jaccard=round(fallback_jaccard, 4),
449 line_coverage=round(fallback_coverage, 4),
450 primary_jaccard=0.0,
451 primary_line_coverage=0.0,
452 translation_jaccard=0.0,
453 translation_line_coverage=0.0,
454 matched_unique_lines=(),
455 reason="无有效歌词,且文件内容兜底特征未命中",
456 )
457
458
459 def _jaccard(left: set[str], right: set[str]) -> float:
460 if not left and not right:
461 return 1.0
462 if not left or not right:
463 return 0.0
464 return len(left & right) / len(left | right)
465
466
467 def _normalize_meta(text: str | None) -> str:
468 if not text:
469 return ""
470 punctuation = " \t\n\r,。!?;:、\"“”‘’·…—~!¥()【】《》〈〉「」『』﹏,.:;!?()[]{}<>|/\\_-"
471 text = unicodedata.normalize("NFKC", str(text)).strip().lower()
472 return "".join(char for char in text if char not in punctuation)
473
474
475 def _same_title(left: LyricRecord, right: LyricRecord) -> bool:
476 left_title = _normalize_meta(left.title)
477 return bool(left_title) and left_title == _normalize_meta(right.title)
478
479
480 def _same_writers(left: LyricRecord, right: LyricRecord) -> bool:
481 left_lyricist = _normalize_meta(left.lyricist)
482 left_composer = _normalize_meta(left.composer)
483 return bool(left_lyricist or left_composer) and (
484 left_lyricist == _normalize_meta(right.lyricist)
485 and left_composer == _normalize_meta(right.composer)
486 )
487
488
489 def _has_cover_signal(record: LyricRecord) -> bool:
490 haystack = " ".join(
491 part for part in (record.title, record.artist, record.lyrics[:500]) if part
492 )
493 normalized = unicodedata.normalize("NFKC", haystack).lower()
494 if any(marker in normalized for marker in ("翻唱", "原唱", "片段", "串烧", "dj版")):
495 return True
496 return bool(re.search(r"\b(?:cover|covered by|remix|live|mashup|medley|dj)\b", normalized))
497
498
499 def _has_enough_primary_lyrics(
500 left: NormalizedLyrics,
501 right: NormalizedLyrics,
502 *,
503 min_lines: int,
504 ) -> bool:
505 lyric_lines = set(left.primary_lines) | set(right.primary_lines)
506 meaningful = [
507 line for line in lyric_lines
508 if not _is_metadata_like_line(line) and len(line) >= 4
509 ]
510 return len(meaningful) >= min_lines
511
512
513 def _is_metadata_like_line(line: str) -> bool:
514 normalized = _normalize_meta(line)
515 metadata_prefixes = (
516 "词",
517 "曲",
518 "作词",
519 "作曲",
520 "编曲",
521 "原唱",
522 "和声",
523 "制作",
524 "监制",
525 "录音",
526 "混音",
527 "母带",
528 "统筹",
529 "企划",
530 "营销",
531 "项目总监",
532 "出品",
533 "发行",
534 "op",
535 "sp",
536 "lyrics",
537 "composer",
538 "writer",
539 "producer",
540 "arranger",
541 )
542 return normalized.startswith(metadata_prefixes)
543
544
545 def _is_generic_primary_lyrics(normalized: NormalizedLyrics) -> bool:
546 """Reject short non-song prompts from automatic duplicate decisions."""
547 primary_lines = normalized.primary_lines
548 if not primary_lines:
549 return True
550 meaningful = [
551 line for line in primary_lines
552 if not _is_metadata_like_line(line) and len(_normalize_meta(line)) > 3
553 ]
554 if len(meaningful) > 2:
555 return False
556
557 compact = _normalize_meta(" ".join(primary_lines))
558 if len(compact) <= 3:
559 return True
560 generic_markers = (
561 "dj音乐",
562 "请欣赏",
563 "纯音乐",
564 "无歌词",
565 "伴奏",
566 "instrumental",
567 )
568 return any(marker in compact for marker in generic_markers)
569
570
571 def _exact_fingerprint(normalized: NormalizedLyrics, fallback_lines: tuple[str, ...]) -> str:
572 primary_text = fingerprint_text(normalized)
573 if primary_text:
574 return f"lyrics|{primary_text}"
575 return "no_effective_lyrics_content|" + "\n".join(fallback_lines)
576
577
578 def _fallback_no_lyrics_lines(text: str) -> list[str]:
579 import re
580 import unicodedata
581
582 lines: list[str] = []
583 for raw_line in unicodedata.normalize("NFKC", text).splitlines():
584 line = raw_line.strip().lower()
585 line = re.sub(r"\[(?:\d{1,2}:)?\d{1,2}:\d{2}(?:[.:]\d{1,3})?\]", "", line)
586 line = re.sub(r"[【\[].{0,80}?[】\]]", "", line)
587 if "歌词来自" in line or "qq音乐" in line or "网易云" in line or "酷狗" in line:
588 continue
589 if "未经" in line or "不得翻唱" in line or "不得翻录" in line or "著作权" in line:
590 continue
591 punctuation = ",。!?;:、“”‘’·…—~!¥()【】《》〈〉「」『』﹏,.;:!?()[]{}<>|/\\_-"
592 line = "".join(" " if char in punctuation else char for char in line)
593 line = re.sub(r"\s+", " ", line).strip()
594 if line:
595 lines.append(line)
596 return list(dict.fromkeys(lines))
597
598
599 def _line_coverage(left: NormalizedLyrics, right: NormalizedLyrics) -> tuple[float, list[str]]:
600 return _line_coverage_lines(left.unique_lines, right.unique_lines)
601
602
603 def _line_coverage_lines(left: tuple[str, ...], right: tuple[str, ...]) -> tuple[float, list[str]]:
604 left_lines = set(left)
605 right_lines = set(right)
606 if not left_lines and not right_lines:
607 return 1.0, []
608 if not left_lines or not right_lines:
609 return 0.0, []
610 matched = sorted(left_lines & right_lines)
611 return len(matched) / max(len(left_lines), len(right_lines)), matched
612
613
614 def _matched_query_line_ratio(query_lines: tuple[str, ...], matched_lines: list[str]) -> float:
615 query_unique_lines = set(query_lines)
616 if not query_unique_lines:
617 return 0.0
618 return len(set(matched_lines)) / len(query_unique_lines)
619
620
621 def _is_plain_fragment(
622 query_lines: tuple[str, ...],
623 candidate_lines: tuple[str, ...],
624 matched_lines: list[str],
625 *,
626 min_query_coverage: float,
627 max_line_ratio: float,
628 min_matched_lines: int,
629 ) -> bool:
630 query_unique_lines = set(query_lines)
631 candidate_unique_lines = set(candidate_lines)
632 matched_unique_lines = set(matched_lines)
633 if not query_unique_lines or not candidate_unique_lines:
634 return False
635 if len(matched_unique_lines) < min_matched_lines:
636 return False
637 line_ratio = len(query_unique_lines) / len(candidate_unique_lines)
638 query_coverage = len(matched_unique_lines) / len(query_unique_lines)
639 return line_ratio <= max_line_ratio and query_coverage >= min_query_coverage
640
641
642 def _is_chorus_only_match(left: NormalizedLyrics, right: NormalizedLyrics, matched_lines: list[str]) -> bool:
643 if not matched_lines:
644 return False
645 matched = set(matched_lines)
646 repeated_matches = [
647 line
648 for line in matched
649 if left.line_counts.get(line, 0) >= 2 or right.line_counts.get(line, 0) >= 2
650 ]
651 if len(matched) <= 2 and repeated_matches:
652 return True
653 if repeated_matches and len(repeated_matches) / len(matched) >= 0.8:
654 matched_ratio_left = sum(left.line_counts.get(line, 0) for line in matched) / max(left.content_line_count, 1)
655 matched_ratio_right = sum(right.line_counts.get(line, 0) for line in matched) / max(right.content_line_count, 1)
656 return min(matched_ratio_left, matched_ratio_right) < 0.7
657 return False
1 """PostgreSQL-backed command line tools for lyric duplicate checking."""
2
3 from __future__ import annotations
4
5 import argparse
6 import json
7 from pathlib import Path
8
9 from lyric_dedup.eval_dataset import generate_eval_set
10 from lyric_dedup.file_import import record_from_file
11
12
13 def main() -> None:
14 parser = argparse.ArgumentParser(prog="lyric-dedup")
15 subparsers = parser.add_subparsers(dest="command", required=True)
16
17 check = subparsers.add_parser("check-file", help="check one .lrc/.txt file using PostgreSQL recall")
18 check.add_argument("--dsn", default="postgresql:///lyric_dedup")
19 check.add_argument("--file", required=True)
20 check.add_argument("--max-candidates", type=int, default=5)
21 check.add_argument("--recall-limit", type=int, default=100)
22 check.add_argument("--enable-trgm", action="store_true")
23 check.add_argument("--trgm-threshold", type=float, default=0.3)
24 check.add_argument("--statement-timeout-ms", type=int, default=5000)
25
26 generate = subparsers.add_parser("generate-eval-set", help="generate labeled eval samples from a lyric library")
27 generate.add_argument("--library-dir", required=True)
28 generate.add_argument("--lyrics-dir", required=True)
29 generate.add_argument("--csv", required=True)
30 generate.add_argument("--size", type=int, default=100)
31 generate.add_argument("--positive-ratio", type=float, default=0.3)
32 generate.add_argument("--seed", type=int, default=20260602)
33 generate.add_argument(
34 "--profile",
35 choices=("standard", "hard"),
36 default="standard",
37 help="evaluation sample profile: standard production mix or harder business-realistic edge mix",
38 )
39
40 args = parser.parse_args()
41 if args.command == "check-file":
42 check_file_pg(args)
43 elif args.command == "generate-eval-set":
44 summary = generate_eval_set(
45 library_dir=Path(args.library_dir),
46 output_dir=Path(args.lyrics_dir),
47 csv_path=Path(args.csv),
48 size=args.size,
49 positive_ratio=args.positive_ratio,
50 seed=args.seed,
51 profile=args.profile,
52 )
53 print(json.dumps(summary, ensure_ascii=False))
54
55
56 def check_file_pg(args: argparse.Namespace) -> None:
57 from dedup_server.config import ServerConfig
58 from dedup_server.service import DedupService
59
60 record = record_from_file(Path(args.file))
61 config = ServerConfig(
62 dsn=args.dsn,
63 max_candidates=args.max_candidates,
64 recall_limit=args.recall_limit,
65 enable_trgm=args.enable_trgm,
66 trgm_threshold=args.trgm_threshold,
67 statement_timeout_ms=args.statement_timeout_ms,
68 )
69 service = DedupService(config=config)
70 result = service.check(record.lyrics, title=record.title, artist=record.artist)
71 print(
72 json.dumps(
73 {
74 "source": args.file,
75 "decision": result.decision,
76 "duplicate": result.duplicate,
77 "confidence": result.confidence,
78 "reason": result.reason,
79 "candidate_count": result.candidate_count,
80 },
81 ensure_ascii=False,
82 indent=2,
83 )
84 )
85
86
87 if __name__ == "__main__":
88 main()
1 """Generate production-style labeled evaluation samples from a lyric library."""
2
3 from __future__ import annotations
4
5 import csv
6 import hashlib
7 import json
8 import random
9 import re
10 import sys
11 from collections import Counter
12 from dataclasses import dataclass
13 from pathlib import Path
14
15 from lyric_dedup.checker import LyricRecord
16 from lyric_dedup.file_import import iter_lyric_files
17 from lyric_dedup.file_import import record_from_file
18 from lyric_dedup.normalization import NormalizedLyrics
19 from lyric_dedup.normalization import fingerprint_text
20 from lyric_dedup.normalization import normalize_lyrics
21
22
23 STANDARD_SAMPLE_MIX = {
24 "positive_full_duplicate": 0.30,
25 "negative_real_holdout_full_song": 0.40,
26 "negative_fragment": 0.10,
27 "negative_shared_chorus": 0.05,
28 "negative_translation_only": 0.05,
29 "negative_same_theme_synthetic": 0.05,
30 "edge_short_or_placeholder": 0.05,
31 }
32 DEFAULT_SAMPLE_MIX = STANDARD_SAMPLE_MIX
33
34 HARD_SAMPLE_MIX = {
35 "positive_realistic_variant": 0.30,
36 "negative_real_holdout_full_song": 0.20,
37 "negative_near_neighbor_holdout_full_song": 0.20,
38 "negative_long_fragment": 0.15,
39 "negative_shared_chorus": 0.05,
40 "negative_translation_only": 0.04,
41 "negative_catalog_mashup": 0.04,
42 "edge_short_or_placeholder": 0.02,
43 }
44
45
46 def _progress(message: str) -> None:
47 print(f"[eval-gen] {message}", file=sys.stderr, flush=True)
48
49
50 def _progress_count(label: str, current: int, total: int, *, step: int = 1000) -> None:
51 if total <= 0:
52 return
53 if current == 1 or current == total or current % step == 0:
54 _progress(f"{label}: {current}/{total}")
55
56
57 @dataclass(frozen=True)
58 class LyricProfile:
59 path: Path
60 record_id: str
61 raw_text: str
62 title: str
63 artist: str
64 normalized: NormalizedLyrics
65 line_count: int
66 char_count: int
67 line_count_bucket: str
68 language_bucket: str
69 source_bucket: str
70 normalized_hash: str
71 has_translation: bool
72
73
74 @dataclass(frozen=True)
75 class GeneratedSample:
76 sample_id: str
77 file: str
78 expected: str
79 sample_type: str
80 source: str
81 source_record_id: str = ""
82 candidate_record_id: str = ""
83 line_count_bucket: str = ""
84 language_bucket: str = ""
85 source_bucket: str = ""
86 title: str = ""
87 artist: str = ""
88 notes: str = ""
89
90
91 def generate_eval_set(
92 *,
93 library_dir: Path,
94 output_dir: Path,
95 csv_path: Path,
96 size: int = 100,
97 positive_ratio: float = 0.30,
98 seed: int = 20260602,
99 index_path: Path | None = None,
100 eval_index_path: Path | None = None,
101 profile: str = "standard",
102 ) -> dict[str, object]:
103 """Generate a stratified production evaluation set.
104
105 ``positive_ratio`` is kept for CLI compatibility. It overrides the default
106 positive quota while keeping the remaining negative categories proportional.
107 """
108 if size <= 0:
109 raise ValueError("size must be positive")
110
111 if profile not in {"standard", "hard"}:
112 raise ValueError("profile must be 'standard' or 'hard'")
113
114 _progress(f"start generation: profile={profile}, size={size}, positive_ratio={positive_ratio}, seed={seed}")
115 rng = random.Random(seed)
116 profiles = profile_library(library_dir)
117 if not profiles:
118 raise ValueError(f"{library_dir} 下没有 .lrc/.txt 歌词文件")
119
120 output_dir.mkdir(parents=True, exist_ok=True)
121 csv_path.parent.mkdir(parents=True, exist_ok=True)
122 _progress(f"clean output dir: {output_dir}")
123 _clean_generated_output_dir(output_dir)
124
125 plan = _sample_plan(size, positive_ratio=positive_ratio, profile=profile)
126 _progress(f"sample plan: {plan}")
127 holdout_count = min(_holdout_plan_count(plan), max(1, len(profiles) // 2))
128 holdout_profiles = _stratified_unique_sample(
129 profiles,
130 holdout_count,
131 rng,
132 )
133 holdout_ids = {profile.record_id for profile in holdout_profiles}
134 indexed_profiles = [profile for profile in profiles if profile.record_id not in holdout_ids] or profiles
135 groups = _profile_groups(indexed_profiles)
136 samples: list[GeneratedSample] = []
137
138 if profile == "hard":
139 samples.extend(
140 _build_hard_samples(
141 plan,
142 groups=groups,
143 holdout_profiles=holdout_profiles,
144 indexed_profiles=indexed_profiles,
145 output_dir=output_dir,
146 csv_base=csv_path.parent,
147 rng=rng,
148 start_index=len(samples) + 1,
149 )
150 )
151 else:
152 _progress("build positive_full_duplicate samples")
153 samples.extend(
154 _build_positive_samples(
155 _stratified_sample(groups["normal"], plan["positive_full_duplicate"], rng),
156 output_dir,
157 csv_path.parent,
158 rng,
159 start_index=len(samples) + 1,
160 )
161 )
162 _progress(f"built samples: {len(samples)}/{size}")
163 _progress("build negative_real_holdout_full_song samples")
164 samples.extend(
165 _build_holdout_full_song_samples(
166 holdout_profiles[: plan["negative_real_holdout_full_song"]],
167 output_dir,
168 csv_path.parent,
169 start_index=len(samples) + 1,
170 )
171 )
172 _progress(f"built samples: {len(samples)}/{size}")
173 _progress("build negative_fragment samples")
174 samples.extend(
175 _build_fragment_samples(
176 _stratified_sample(groups["fragmentable"], plan["negative_fragment"], rng),
177 output_dir,
178 csv_path.parent,
179 rng,
180 start_index=len(samples) + 1,
181 )
182 )
183 _progress(f"built samples: {len(samples)}/{size}")
184 _progress("build negative_shared_chorus samples")
185 samples.extend(
186 _build_shared_chorus_samples(
187 _stratified_sample(groups["normal"], plan["negative_shared_chorus"], rng),
188 output_dir,
189 csv_path.parent,
190 rng,
191 start_index=len(samples) + 1,
192 )
193 )
194 _progress(f"built samples: {len(samples)}/{size}")
195 _progress("build negative_translation_only samples")
196 samples.extend(
197 _build_translation_only_samples(
198 _stratified_sample(groups["foreign"], plan["negative_translation_only"], rng),
199 output_dir,
200 csv_path.parent,
201 rng,
202 start_index=len(samples) + 1,
203 )
204 )
205 _progress(f"built samples: {len(samples)}/{size}")
206 _progress("build negative_same_theme_synthetic samples")
207 samples.extend(
208 _build_same_theme_synthetic_samples(
209 plan["negative_same_theme_synthetic"],
210 output_dir,
211 csv_path.parent,
212 rng,
213 start_index=len(samples) + 1,
214 )
215 )
216 _progress(f"built samples: {len(samples)}/{size}")
217 _progress("build edge_short_or_placeholder samples")
218 samples.extend(
219 _build_edge_samples(
220 _stratified_sample(groups["edge"], plan["edge_short_or_placeholder"], rng),
221 output_dir,
222 csv_path.parent,
223 rng,
224 start_index=len(samples) + 1,
225 )
226 )
227 _progress(f"built samples: {len(samples)}/{size}")
228
229 if len(samples) < size:
230 _progress(f"top up with negative_same_theme_synthetic samples: {size - len(samples)}")
231 samples.extend(
232 _build_same_theme_synthetic_samples(
233 size - len(samples),
234 output_dir,
235 csv_path.parent,
236 rng,
237 start_index=len(samples) + 1,
238 )
239 )
240 samples = samples[:size]
241 rng.shuffle(samples)
242
243 _progress(f"write csv: {csv_path}")
244 _write_csv(samples, csv_path, seed=seed)
245 _progress("write manifest")
246 manifest = _write_manifest(
247 profiles=profiles,
248 samples=samples,
249 csv_path=csv_path,
250 output_dir=output_dir,
251 seed=seed,
252 plan=plan,
253 index_path=index_path,
254 eval_index_path=eval_index_path,
255 holdout_count=len(holdout_profiles),
256 profile=profile,
257 )
258 _progress("generation complete")
259 return manifest
260
261
262 def profile_library(library_dir: Path) -> list[LyricProfile]:
263 profiles: list[LyricProfile] = []
264 paths = iter_lyric_files(library_dir)
265 _progress(f"profile library: 0/{len(paths)}")
266 for index, path in enumerate(paths, start=1):
267 record = record_from_file(path, base_dir=library_dir)
268 raw_text = record.lyrics
269 normalized = normalize_lyrics(raw_text)
270 lines = normalized.primary_lines or normalized.unique_lines
271 line_count = len(lines)
272 normalized_text = fingerprint_text(normalized) or normalized.normalized_full_text
273 source_bucket = _source_bucket(path)
274 profiles.append(
275 LyricProfile(
276 path=path,
277 record_id=record.record_id,
278 raw_text=raw_text,
279 title=record.title or "",
280 artist=record.artist or "",
281 normalized=normalized,
282 line_count=line_count,
283 char_count=len(normalized_text),
284 line_count_bucket=_line_count_bucket(line_count),
285 language_bucket=_language_bucket(lines),
286 source_bucket=source_bucket,
287 normalized_hash=hashlib.sha256(normalized_text.encode("utf-8")).hexdigest(),
288 has_translation=bool(normalized.translation_lines),
289 )
290 )
291 _progress_count("profile library", index, len(paths), step=5000)
292 return profiles
293
294
295 def _sample_plan(size: int, *, positive_ratio: float, profile: str) -> dict[str, int]:
296 positive_ratio = max(0.0, min(1.0, positive_ratio))
297 mix = dict(HARD_SAMPLE_MIX if profile == "hard" else STANDARD_SAMPLE_MIX)
298 positive_key = "positive_realistic_variant" if profile == "hard" else "positive_full_duplicate"
299 negative_total = sum(value for key, value in mix.items() if key != positive_key)
300 mix[positive_key] = positive_ratio
301 for key in list(mix):
302 if key != positive_key:
303 base_mix = HARD_SAMPLE_MIX if profile == "hard" else STANDARD_SAMPLE_MIX
304 mix[key] = (1.0 - positive_ratio) * (base_mix[key] / negative_total)
305
306 plan = {key: int(size * value) for key, value in mix.items()}
307 remainder = size - sum(plan.values())
308 for key in sorted(mix, key=mix.get, reverse=True):
309 if remainder <= 0:
310 break
311 plan[key] += 1
312 remainder -= 1
313 return plan
314
315
316 def _holdout_plan_count(plan: dict[str, int]) -> int:
317 return plan.get("negative_real_holdout_full_song", 0) + plan.get("negative_near_neighbor_holdout_full_song", 0)
318
319
320 def _profile_groups(profiles: list[LyricProfile]) -> dict[str, list[LyricProfile]]:
321 normal = [profile for profile in profiles if profile.line_count >= 6]
322 edge = [profile for profile in profiles if profile.line_count <= 5]
323 return {
324 "normal": normal or profiles,
325 "fragmentable": [profile for profile in profiles if profile.line_count >= 12] or normal or profiles,
326 "foreign": [
327 profile
328 for profile in profiles
329 if profile.language_bucket in {"latin", "mixed", "jp_kr"} and profile.line_count >= 4
330 ]
331 or normal
332 or profiles,
333 "edge": edge or normal or profiles,
334 }
335
336
337 def _stratified_sample(profiles: list[LyricProfile], count: int, rng: random.Random) -> list[LyricProfile]:
338 if count <= 0 or not profiles:
339 return []
340 buckets: dict[tuple[str, str, str], list[LyricProfile]] = {}
341 for profile in profiles:
342 key = (profile.line_count_bucket, profile.language_bucket, profile.source_bucket)
343 buckets.setdefault(key, []).append(profile)
344
345 selected: list[LyricProfile] = []
346 bucket_keys = list(buckets)
347 rng.shuffle(bucket_keys)
348 cursors = {key: rng.sample(items, len(items)) for key, items in buckets.items()}
349 while len(selected) < count and bucket_keys:
350 progressed = False
351 for key in list(bucket_keys):
352 if len(selected) >= count:
353 break
354 items = cursors[key]
355 if not items:
356 bucket_keys.remove(key)
357 continue
358 selected.append(items.pop())
359 progressed = True
360 if not progressed:
361 break
362 while len(selected) < count:
363 selected.append(rng.choice(profiles))
364 return selected
365
366
367 def _stratified_unique_sample(profiles: list[LyricProfile], count: int, rng: random.Random) -> list[LyricProfile]:
368 if count <= 0 or not profiles:
369 return []
370 return _stratified_sample(profiles, min(count, len(profiles)), rng)
371
372
373 def _build_positive_samples(
374 profiles: list[LyricProfile],
375 output_dir: Path,
376 csv_base: Path,
377 rng: random.Random,
378 *,
379 start_index: int,
380 ) -> list[GeneratedSample]:
381 samples: list[GeneratedSample] = []
382 for offset, profile in enumerate(profiles):
383 raw = profile.raw_text
384 lines = _content_lines(raw)
385 variants = [
386 ("positive_exact_copy", raw),
387 ("positive_timestamped", _add_timestamps(lines)),
388 ("positive_punctuation_noise", _add_punctuation_noise(lines, rng)),
389 ("positive_platform_noise", _with_platform_noise(lines)),
390 ("positive_blank_line_noise", _add_blank_line_noise(lines)),
391 ("positive_chorus_count_changed", _change_repeated_line_counts(lines)),
392 ("positive_translation_added", _translation_added(lines)),
393 ("positive_typo_noise", _add_typo_noise(lines, rng)),
394 ]
395 sample_type, text = variants[offset % len(variants)]
396 index = start_index + offset
397 path = _write_sample_file(output_dir, f"pos_{index:05d}_{sample_type}.txt", text)
398 samples.append(_sample_from_profile(index, path, csv_base, "应去重", sample_type, profile))
399 _progress_count("positive_full_duplicate", len(samples), len(profiles))
400 return samples
401
402
403 def _build_hard_samples(
404 plan: dict[str, int],
405 *,
406 groups: dict[str, list[LyricProfile]],
407 holdout_profiles: list[LyricProfile],
408 indexed_profiles: list[LyricProfile],
409 output_dir: Path,
410 csv_base: Path,
411 rng: random.Random,
412 start_index: int,
413 ) -> list[GeneratedSample]:
414 samples: list[GeneratedSample] = []
415
416 _progress("build positive_realistic_variant samples")
417 samples.extend(
418 _build_realistic_positive_samples(
419 _stratified_sample(groups["normal"], plan["positive_realistic_variant"], rng),
420 output_dir,
421 csv_base,
422 rng,
423 start_index=start_index + len(samples),
424 )
425 )
426 _progress(f"built samples: {len(samples)}")
427
428 real_holdout_count = plan.get("negative_real_holdout_full_song", 0)
429 _progress("build negative_real_holdout_full_song samples")
430 samples.extend(
431 _build_holdout_full_song_samples(
432 holdout_profiles[:real_holdout_count],
433 output_dir,
434 csv_base,
435 start_index=start_index + len(samples),
436 )
437 )
438 _progress(f"built samples: {len(samples)}")
439
440 near_count = plan.get("negative_near_neighbor_holdout_full_song", 0)
441 _progress("build negative_near_neighbor_holdout_full_song samples")
442 near_holdouts = _near_neighbor_holdouts(
443 holdout_profiles[real_holdout_count:],
444 indexed_profiles,
445 near_count,
446 )
447 samples.extend(
448 _build_holdout_full_song_samples(
449 near_holdouts,
450 output_dir,
451 csv_base,
452 start_index=start_index + len(samples),
453 sample_type="negative_near_neighbor_holdout_full_song",
454 notes="full real holdout lyric selected for catalog line overlap with indexed songs",
455 )
456 )
457 _progress(f"built samples: {len(samples)}")
458
459 _progress("build negative_long_fragment samples")
460 samples.extend(
461 _build_fragment_samples(
462 _stratified_sample(groups["fragmentable"], plan.get("negative_long_fragment", 0), rng),
463 output_dir,
464 csv_base,
465 rng,
466 start_index=start_index + len(samples),
467 sample_type="negative_long_fragment",
468 long_fragment=True,
469 notes="realistic long partial lyric upload, not a full-song duplicate",
470 )
471 )
472 _progress(f"built samples: {len(samples)}")
473
474 _progress("build negative_shared_chorus samples")
475 samples.extend(
476 _build_shared_chorus_samples(
477 _stratified_sample(groups["normal"], plan.get("negative_shared_chorus", 0), rng),
478 output_dir,
479 csv_base,
480 rng,
481 start_index=start_index + len(samples),
482 )
483 )
484 _progress(f"built samples: {len(samples)}")
485
486 _progress("build negative_translation_only samples")
487 samples.extend(
488 _build_translation_only_samples(
489 _stratified_sample(groups["foreign"], plan.get("negative_translation_only", 0), rng),
490 output_dir,
491 csv_base,
492 rng,
493 start_index=start_index + len(samples),
494 )
495 )
496 _progress(f"built samples: {len(samples)}")
497
498 _progress("build negative_catalog_mashup samples")
499 samples.extend(
500 _build_catalog_mashup_samples(
501 _stratified_sample(groups["normal"], plan.get("negative_catalog_mashup", 0) * 3, rng),
502 plan.get("negative_catalog_mashup", 0),
503 output_dir,
504 csv_base,
505 rng,
506 start_index=start_index + len(samples),
507 )
508 )
509 _progress(f"built samples: {len(samples)}")
510
511 _progress("build edge_short_or_placeholder samples")
512 samples.extend(
513 _build_edge_samples(
514 _stratified_sample(groups["edge"], plan.get("edge_short_or_placeholder", 0), rng),
515 output_dir,
516 csv_base,
517 rng,
518 start_index=start_index + len(samples),
519 )
520 )
521 return samples
522
523
524 def _build_realistic_positive_samples(
525 profiles: list[LyricProfile],
526 output_dir: Path,
527 csv_base: Path,
528 rng: random.Random,
529 *,
530 start_index: int,
531 ) -> list[GeneratedSample]:
532 samples: list[GeneratedSample] = []
533 for offset, profile in enumerate(profiles):
534 content_lines = _content_lines(profile.raw_text)
535 primary_lines = list(profile.normalized.primary_lines or profile.normalized.unique_lines) or content_lines
536 variants = [
537 ("positive_platform_mixed_noise", _platform_mixed_noise(content_lines, rng)),
538 ("positive_near_full_missing_section", _near_full_missing_section(primary_lines, rng)),
539 ("positive_block_translation_added", _block_translation_added(primary_lines)),
540 ("positive_typo_and_punctuation_noise", _stronger_typo_and_punctuation_noise(content_lines, rng)),
541 ("positive_timestamped_platform_variant", _timestamped_platform_variant(content_lines)),
542 ("positive_chorus_count_changed", _change_repeated_line_counts(content_lines)),
543 ]
544 sample_type, text = variants[offset % len(variants)]
545 index = start_index + offset
546 path = _write_sample_file(output_dir, f"pos_{index:05d}_{sample_type}.txt", text)
547 samples.append(_sample_from_profile(index, path, csv_base, "应去重", sample_type, profile))
548 _progress_count("positive_realistic_variant", len(samples), len(profiles))
549 return samples
550
551
552 def _build_holdout_full_song_samples(
553 profiles: list[LyricProfile],
554 output_dir: Path,
555 csv_base: Path,
556 *,
557 start_index: int,
558 sample_type: str = "negative_real_holdout_full_song",
559 notes: str = "full real lyric held out from the generated eval index",
560 ) -> list[GeneratedSample]:
561 samples: list[GeneratedSample] = []
562 for offset, profile in enumerate(profiles):
563 index = start_index + offset
564 text = profile.raw_text
565 path = _write_sample_file(output_dir, f"neg_{index:05d}_{sample_type}.txt", text)
566 samples.append(
567 _sample_from_profile(
568 index,
569 path,
570 csv_base,
571 "不应去重",
572 sample_type,
573 profile,
574 notes=notes,
575 )
576 )
577 _progress_count(sample_type, len(samples), len(profiles))
578 return samples
579
580
581 def _build_same_theme_synthetic_samples(
582 count: int,
583 output_dir: Path,
584 csv_base: Path,
585 rng: random.Random,
586 *,
587 start_index: int,
588 ) -> list[GeneratedSample]:
589 samples: list[GeneratedSample] = []
590 for offset in range(count):
591 index = start_index + offset
592 text = _same_theme_synthetic(index, rng)
593 path = _write_sample_file(output_dir, f"neg_{index:05d}_negative_same_theme_synthetic.txt", text)
594 samples.append(
595 GeneratedSample(
596 sample_id=f"sample-{index:05d}",
597 file=str(path.relative_to(csv_base)),
598 expected="不应去重",
599 sample_type="negative_same_theme_synthetic",
600 source="synthetic",
601 notes="same-theme synthetic full lyric not copied from library",
602 )
603 )
604 _progress_count("negative_same_theme_synthetic", len(samples), count)
605 return samples
606
607
608 def _build_fragment_samples(
609 profiles: list[LyricProfile],
610 output_dir: Path,
611 csv_base: Path,
612 rng: random.Random,
613 *,
614 start_index: int,
615 sample_type: str = "negative_fragment",
616 long_fragment: bool = False,
617 notes: str = "partial lyric fragment only",
618 ) -> list[GeneratedSample]:
619 samples: list[GeneratedSample] = []
620 for offset, profile in enumerate(profiles):
621 lines = list(profile.normalized.primary_lines or profile.normalized.unique_lines)
622 text = _long_song_fragment(lines, rng) if long_fragment else _single_song_fragment(lines, rng)
623 index = start_index + offset
624 path = _write_sample_file(output_dir, f"neg_{index:05d}_{sample_type}.txt", text)
625 samples.append(
626 _sample_from_profile(
627 index,
628 path,
629 csv_base,
630 "不应去重",
631 sample_type,
632 profile,
633 notes=notes,
634 )
635 )
636 _progress_count(sample_type, len(samples), len(profiles))
637 return samples
638
639
640 def _build_catalog_mashup_samples(
641 profiles: list[LyricProfile],
642 count: int,
643 output_dir: Path,
644 csv_base: Path,
645 rng: random.Random,
646 *,
647 start_index: int,
648 ) -> list[GeneratedSample]:
649 samples: list[GeneratedSample] = []
650 if count <= 0 or not profiles:
651 return samples
652 for offset in range(count):
653 index = start_index + offset
654 picked = rng.sample(profiles, k=min(3, len(profiles)))
655 text = _catalog_mashup_text(picked, rng)
656 path = _write_sample_file(output_dir, f"neg_{index:05d}_negative_catalog_mashup.txt", text)
657 samples.append(
658 GeneratedSample(
659 sample_id=f"sample-{index:05d}",
660 file=str(path.relative_to(csv_base)),
661 expected="不应去重",
662 sample_type="negative_catalog_mashup",
663 source=" | ".join(str(profile.path) for profile in picked),
664 notes="medley-style partial lyric assembled from multiple catalog songs",
665 )
666 )
667 _progress_count("negative_catalog_mashup", len(samples), count)
668 return samples
669
670
671 def _build_shared_chorus_samples(
672 profiles: list[LyricProfile],
673 output_dir: Path,
674 csv_base: Path,
675 rng: random.Random,
676 *,
677 start_index: int,
678 ) -> list[GeneratedSample]:
679 samples: list[GeneratedSample] = []
680 for offset, profile in enumerate(profiles):
681 lines = list(profile.normalized.primary_lines or profile.normalized.unique_lines)
682 repeated = _repeated_or_sampled_lines(profile.normalized, rng)
683 text = "\n".join(
684 [
685 "清晨的光落在新的街口",
686 "我把故事重新写给以后",
687 *repeated,
688 *repeated,
689 "所有答案都从这里开始",
690 ]
691 )
692 index = start_index + offset
693 path = _write_sample_file(output_dir, f"neg_{index:05d}_negative_shared_chorus.txt", text)
694 samples.append(
695 _sample_from_profile(
696 index,
697 path,
698 csv_base,
699 "不应去重",
700 "negative_shared_chorus",
701 profile,
702 notes="shared repeated lines with new surrounding content",
703 )
704 )
705 _progress_count("negative_shared_chorus", len(samples), len(profiles))
706 return samples
707
708
709 def _build_translation_only_samples(
710 profiles: list[LyricProfile],
711 output_dir: Path,
712 csv_base: Path,
713 rng: random.Random,
714 *,
715 start_index: int,
716 ) -> list[GeneratedSample]:
717 samples: list[GeneratedSample] = []
718 for offset, profile in enumerate(profiles):
719 lines = list(profile.normalized.translation_lines) or [
720 _pseudo_translation(idx) for idx in range(1, min(8, max(profile.line_count, 4)) + 1)
721 ]
722 rng.shuffle(lines)
723 text = "\n".join(lines[:8])
724 index = start_index + offset
725 path = _write_sample_file(output_dir, f"neg_{index:05d}_negative_translation_only.txt", text)
726 samples.append(
727 _sample_from_profile(
728 index,
729 path,
730 csv_base,
731 "不应去重",
732 "negative_translation_only",
733 profile,
734 notes="translation-like text without matching original lyric",
735 )
736 )
737 _progress_count("negative_translation_only", len(samples), len(profiles))
738 return samples
739
740
741 def _build_edge_samples(
742 profiles: list[LyricProfile],
743 output_dir: Path,
744 csv_base: Path,
745 rng: random.Random,
746 *,
747 start_index: int,
748 ) -> list[GeneratedSample]:
749 samples: list[GeneratedSample] = []
750 for offset, profile in enumerate(profiles):
751 lines = list(profile.normalized.primary_lines or profile.normalized.unique_lines)
752 if profile.line_count <= 1:
753 text = _same_theme_synthetic(start_index + offset, rng)
754 notes = "zero or one effective line; use synthetic edge negative"
755 else:
756 text = _short_shared_snippet(lines, rng)
757 notes = "short lyric edge case with limited overlap"
758 index = start_index + offset
759 path = _write_sample_file(output_dir, f"neg_{index:05d}_edge_short_or_placeholder.txt", text)
760 samples.append(
761 _sample_from_profile(
762 index,
763 path,
764 csv_base,
765 "不应去重",
766 "edge_short_or_placeholder",
767 profile,
768 notes=notes,
769 )
770 )
771 _progress_count("edge_short_or_placeholder", len(samples), len(profiles))
772 return samples
773
774
775 def _sample_from_profile(
776 index: int,
777 path: Path,
778 csv_base: Path,
779 expected: str,
780 sample_type: str,
781 profile: LyricProfile,
782 *,
783 candidate_record_id: str = "",
784 notes: str = "",
785 ) -> GeneratedSample:
786 return GeneratedSample(
787 sample_id=f"sample-{index:05d}",
788 file=str(path.relative_to(csv_base)),
789 expected=expected,
790 sample_type=sample_type,
791 source=str(profile.path),
792 source_record_id=profile.record_id,
793 candidate_record_id=candidate_record_id,
794 line_count_bucket=profile.line_count_bucket,
795 language_bucket=profile.language_bucket,
796 source_bucket=profile.source_bucket,
797 title=profile.title,
798 artist=profile.artist,
799 notes=notes,
800 )
801
802
803 def _write_sample_file(output_dir: Path, name: str, text: str) -> Path:
804 path = output_dir / name
805 path.write_text(text.strip() + "\n", encoding="utf-8")
806 return path
807
808
809 def _write_csv(samples: list[GeneratedSample], csv_path: Path, *, seed: int) -> None:
810 fieldnames = [
811 "id",
812 "file",
813 "expected",
814 "sample_type",
815 "source",
816 "source_record_id",
817 "candidate_record_id",
818 "line_count_bucket",
819 "language_bucket",
820 "source_bucket",
821 "title",
822 "artist",
823 "seed",
824 "notes",
825 ]
826 with csv_path.open("w", encoding="utf-8", newline="") as file:
827 writer = csv.DictWriter(file, fieldnames=fieldnames)
828 writer.writeheader()
829 for sample in samples:
830 writer.writerow(
831 {
832 "id": sample.sample_id,
833 "file": sample.file,
834 "expected": sample.expected,
835 "sample_type": sample.sample_type,
836 "source": sample.source,
837 "source_record_id": sample.source_record_id,
838 "candidate_record_id": sample.candidate_record_id,
839 "line_count_bucket": sample.line_count_bucket,
840 "language_bucket": sample.language_bucket,
841 "source_bucket": sample.source_bucket,
842 "title": sample.title,
843 "artist": sample.artist,
844 "seed": seed,
845 "notes": sample.notes,
846 }
847 )
848
849
850 def _write_manifest(
851 *,
852 profiles: list[LyricProfile],
853 samples: list[GeneratedSample],
854 csv_path: Path,
855 output_dir: Path,
856 seed: int,
857 plan: dict[str, int],
858 index_path: Path | None,
859 eval_index_path: Path,
860 holdout_count: int,
861 profile: str,
862 ) -> dict[str, object]:
863 manifest = {
864 "profile": profile,
865 "seed": seed,
866 "library_files": len(profiles),
867 "sample_size": len(samples),
868 "plan": plan,
869 "source_index": str(index_path) if index_path else "",
870 "eval_index": str(eval_index_path) if eval_index_path else "",
871 "holdout_records": holdout_count,
872 "lyrics_dir": str(output_dir),
873 "csv": str(csv_path),
874 "manifest": str(csv_path.with_suffix(csv_path.suffix + ".manifest.json")),
875 "sample_type_counts": dict(Counter(sample.sample_type for sample in samples)),
876 "expected_counts": dict(Counter(sample.expected for sample in samples)),
877 "line_count_bucket_counts": dict(Counter(profile.line_count_bucket for profile in profiles)),
878 "language_bucket_counts": dict(Counter(profile.language_bucket for profile in profiles)),
879 "source_bucket_counts": dict(Counter(profile.source_bucket for profile in profiles).most_common(50)),
880 "unique_source_records": len({sample.source_record_id for sample in samples if sample.source_record_id}),
881 }
882 csv_path.with_suffix(csv_path.suffix + ".manifest.json").write_text(
883 json.dumps(manifest, ensure_ascii=False, indent=2),
884 encoding="utf-8",
885 )
886 return manifest
887
888
889 def _near_neighbor_holdouts(
890 holdout_profiles: list[LyricProfile],
891 indexed_profiles: list[LyricProfile],
892 count: int,
893 ) -> list[LyricProfile]:
894 if count <= 0 or not holdout_profiles:
895 return []
896 if not indexed_profiles:
897 return holdout_profiles[:count]
898
899 line_to_indexed_count: Counter[str] = Counter()
900 for profile in indexed_profiles:
901 for line in set(profile.normalized.primary_lines or profile.normalized.unique_lines):
902 if len(line) >= 4:
903 line_to_indexed_count[line] += 1
904
905 scored: list[tuple[float, LyricProfile]] = []
906 for profile in holdout_profiles:
907 lines = set(profile.normalized.primary_lines or profile.normalized.unique_lines)
908 useful_lines = {line for line in lines if len(line) >= 4}
909 if not useful_lines:
910 score = 0.0
911 else:
912 shared = sum(1 for line in useful_lines if line_to_indexed_count[line] > 0)
913 common_weight = sum(min(line_to_indexed_count[line], 5) for line in useful_lines)
914 score = (shared / len(useful_lines)) + (common_weight / (len(useful_lines) * 20))
915 scored.append((score, profile))
916
917 scored.sort(key=lambda item: item[0], reverse=True)
918 return [profile for _, profile in scored[:count]]
919
920
921 def _content_lines(text: str) -> list[str]:
922 lines = [line.strip() for line in text.splitlines() if line.strip()]
923 return lines or [text.strip()]
924
925
926 def _clean_generated_output_dir(output_dir: Path) -> None:
927 for path in output_dir.iterdir():
928 if path.is_file() and path.suffix.lower() in {".txt", ".lrc"}:
929 path.unlink()
930
931
932 def _line_count_bucket(line_count: int) -> str:
933 if line_count == 0:
934 return "zero"
935 if line_count <= 5:
936 return "short"
937 if line_count <= 40:
938 return "normal"
939 return "long"
940
941
942 def _language_bucket(lines: tuple[str, ...]) -> str:
943 text = "\n".join(lines)
944 cjk = len(re.findall(r"[\u4e00-\u9fff]", text))
945 latin = len(re.findall(r"[A-Za-z]", text))
946 kana = len(re.findall(r"[\u3040-\u30ff]", text))
947 hangul = len(re.findall(r"[\uac00-\ud7af]", text))
948 if kana or hangul:
949 return "jp_kr"
950 if cjk and latin:
951 return "mixed"
952 if cjk:
953 return "zh"
954 if latin:
955 return "latin"
956 return "other"
957
958
959 def _source_bucket(path: Path) -> str:
960 stem = path.stem
961 parts = stem.split("_")
962 if len(parts) >= 2:
963 code = re.sub(r"\d+$", "", parts[-1])
964 return code or "unknown"
965 return "unknown"
966
967
968 def _add_timestamps(lines: list[str]) -> str:
969 return "\n".join(f"[00:{idx % 60:02d}.00]{line}" for idx, line in enumerate(lines, start=1))
970
971
972 def _platform_mixed_noise(lines: list[str], rng: random.Random) -> str:
973 noisy = _add_blank_line_noise(lines).splitlines()
974 if noisy:
975 noisy = _add_punctuation_noise(noisy, rng).splitlines()
976 return "\n".join(["作词:未知", "歌词来自平台同步", *noisy, "未经著作权人许可 不得商业使用"])
977
978
979 def _timestamped_platform_variant(lines: list[str]) -> str:
980 timestamped = _add_timestamps(lines).splitlines()
981 return "\n".join(["[00:00.00]歌词贡献者:用户上传", *timestamped])
982
983
984 def _add_punctuation_noise(lines: list[str], rng: random.Random) -> str:
985 marks = ["!", "?", "...", ",", "。"]
986 return "\n".join(f"{line}{rng.choice(marks)}" for line in lines)
987
988
989 def _with_platform_noise(lines: list[str]) -> str:
990 return "\n".join(["歌词来自QQ音乐", "作词:测试", *lines, "未经著作权人许可 不得翻唱"])
991
992
993 def _add_blank_line_noise(lines: list[str]) -> str:
994 result: list[str] = []
995 for idx, line in enumerate(lines, start=1):
996 result.append(line)
997 if idx % 4 == 0:
998 result.append("")
999 return "\n".join(result)
1000
1001
1002 def _change_repeated_line_counts(lines: list[str]) -> str:
1003 seen: set[str] = set()
1004 result: list[str] = []
1005 for line in lines:
1006 if line in seen:
1007 continue
1008 seen.add(line)
1009 result.append(line)
1010 return "\n".join(result or lines)
1011
1012
1013 def _translation_added(lines: list[str]) -> str:
1014 result: list[str] = []
1015 for idx, line in enumerate(lines, start=1):
1016 result.append(line)
1017 if _looks_foreign(line) and idx <= 24:
1018 result.append(_pseudo_translation(idx))
1019 return "\n".join(result)
1020
1021
1022 def _block_translation_added(lines: list[str]) -> str:
1023 body = "\n".join(lines)
1024 translation_count = min(8, max(4, len(lines) // 4))
1025 translations = [_pseudo_translation(index) for index in range(1, translation_count + 1)]
1026 return "\n".join([body, "", *translations])
1027
1028
1029 def _near_full_missing_section(lines: list[str], rng: random.Random) -> str:
1030 if len(lines) <= 8:
1031 return "\n".join(lines)
1032 drop_count = max(1, min(max(1, len(lines) // 5), 8))
1033 start = rng.randrange(0, max(1, len(lines) - drop_count + 1))
1034 kept = lines[:start] + lines[start + drop_count :]
1035 return "\n".join(kept or lines)
1036
1037
1038 def _add_typo_noise(lines: list[str], rng: random.Random) -> str:
1039 if not lines:
1040 return ""
1041 result = list(lines)
1042 editable_indexes = [index for index, line in enumerate(result) if _can_typo_line(line)]
1043 if not editable_indexes:
1044 return "\n".join(result)
1045 typo_count = max(1, min(4, len(editable_indexes) // 8 or 1))
1046 for index in rng.sample(editable_indexes, k=min(typo_count, len(editable_indexes))):
1047 result[index] = _typo_line(result[index], rng)
1048 return "\n".join(result)
1049
1050
1051 def _stronger_typo_and_punctuation_noise(lines: list[str], rng: random.Random) -> str:
1052 if not lines:
1053 return ""
1054 result = _add_punctuation_noise(lines, rng).splitlines()
1055 editable_indexes = [index for index, line in enumerate(result) if _can_typo_line(line)]
1056 typo_count = max(1, min(8, len(editable_indexes) // 6 or 1))
1057 for index in rng.sample(editable_indexes, k=min(typo_count, len(editable_indexes))):
1058 result[index] = _typo_line(result[index], rng)
1059 return "\n".join(result)
1060
1061
1062 def _can_typo_line(line: str) -> bool:
1063 return bool(re.search(r"[A-Za-z]{4,}|[\u4e00-\u9fff]{4,}", line))
1064
1065
1066 def _typo_line(line: str, rng: random.Random) -> str:
1067 words = list(re.finditer(r"[A-Za-z]{4,}", line))
1068 if words and rng.random() < 0.65:
1069 match = rng.choice(words)
1070 typo = _typo_english_word(match.group(0), rng)
1071 return line[: match.start()] + typo + line[match.end() :]
1072 cjk_positions = [index for index, char in enumerate(line) if "\u4e00" <= char <= "\u9fff"]
1073 if cjk_positions:
1074 index = rng.choice(cjk_positions)
1075 return line[:index] + _typo_cjk_char(line[index]) + line[index + 1 :]
1076 return line
1077
1078
1079 def _typo_english_word(word: str, rng: random.Random) -> str:
1080 if len(word) <= 4 or rng.random() < 0.55:
1081 remove_at = rng.randrange(1, max(2, len(word) - 1))
1082 return word[:remove_at] + word[remove_at + 1 :]
1083 swap_at = rng.randrange(1, max(2, len(word) - 2))
1084 chars = list(word)
1085 chars[swap_at], chars[swap_at + 1] = chars[swap_at + 1], chars[swap_at]
1086 return "".join(chars)
1087
1088
1089 def _typo_cjk_char(char: str) -> str:
1090 replacements = {
1091 "你": "妳",
1092 "爱": "爰",
1093 "夜": "液",
1094 "里": "裏",
1095 "风": "凤",
1096 "雨": "兩",
1097 "听": "昕",
1098 "说": "説",
1099 "想": "相",
1100 "梦": "夣",
1101 "心": "芯",
1102 "光": "先",
1103 "城": "诚",
1104 "远": "迩",
1105 "回": "囬",
1106 "走": "赱",
1107 "海": "毎",
1108 "天": "夭",
1109 }
1110 return replacements.get(char, char)
1111
1112
1113 def _single_song_fragment(lines: list[str], rng: random.Random) -> str:
1114 if len(lines) <= 4:
1115 return "\n".join(lines[: max(1, len(lines) // 2)])
1116 fragment_len = max(2, min(8, len(lines) // rng.choice([3, 4, 5])))
1117 start = rng.randrange(0, max(1, len(lines) - fragment_len + 1))
1118 return "\n".join(lines[start : start + fragment_len])
1119
1120
1121 def _long_song_fragment(lines: list[str], rng: random.Random) -> str:
1122 if len(lines) <= 8:
1123 return _single_song_fragment(lines, rng)
1124 fragment_len = max(6, min(len(lines) - 1, int(len(lines) * rng.uniform(0.35, 0.60))))
1125 start = rng.randrange(0, max(1, len(lines) - fragment_len + 1))
1126 return "\n".join(lines[start : start + fragment_len])
1127
1128
1129 def _catalog_mashup_text(profiles: list[LyricProfile], rng: random.Random) -> str:
1130 sections: list[str] = []
1131 for profile in profiles:
1132 lines = list(profile.normalized.primary_lines or profile.normalized.unique_lines)
1133 if not lines:
1134 continue
1135 section_len = min(max(2, len(lines) // 8), 5)
1136 start = rng.randrange(0, max(1, len(lines) - section_len + 1))
1137 sections.extend(lines[start : start + section_len])
1138 if not sections:
1139 return _same_theme_synthetic(0, rng)
1140 return "\n".join(sections)
1141
1142
1143 def _short_shared_snippet(lines: list[str], rng: random.Random) -> str:
1144 snippet = rng.sample(lines, k=min(2, len(lines))) if lines else []
1145 synthetic = [
1146 "清晨的风吹过新的街口",
1147 "我把昨天放进安静的口袋",
1148 *snippet,
1149 "故事从这里重新开始",
1150 "灯光落下我继续往前走",
1151 ]
1152 return "\n".join(synthetic)
1153
1154
1155 def _repeated_or_sampled_lines(normalized: NormalizedLyrics, rng: random.Random) -> list[str]:
1156 repeated = [line for line, count in normalized.line_counts.items() if count >= 2]
1157 if repeated:
1158 return rng.sample(repeated, k=min(2, len(repeated)))
1159 lines = list(normalized.primary_lines or normalized.unique_lines)
1160 return rng.sample(lines, k=min(2, len(lines))) if lines else []
1161
1162
1163 def _same_theme_synthetic(index: int, rng: random.Random) -> str:
1164 starts = ["我在夜里想起远方的你", "城市灯火陪我走过雨季", "风把旧名字吹向清晨"]
1165 middles = ["那些没说完的话留在风里", "新的路口慢慢亮起", "时间把答案交给下一站"]
1166 ends = ["明天醒来我们各自继续", "我会把今天写成新的旋律", "故事从这里重新开始"]
1167 return "\n".join(
1168 [
1169 rng.choice(starts),
1170 rng.choice(middles),
1171 rng.choice(ends),
1172 f"这是第 {index} 个全新测试样本",
1173 ]
1174 )
1175
1176
1177 def _pseudo_translation(index: int) -> str:
1178 translations = [
1179 "今晚我仍然想念你",
1180 "风会带走所有疲惫",
1181 "黑暗里也会有光",
1182 "别让昨天困住自己",
1183 "我们终会继续向前",
1184 "雨停以后天空会亮",
1185 "把遗憾留在旧时光",
1186 "你已经足够好了",
1187 ]
1188 return translations[(index - 1) % len(translations)]
1189
1190
1191 def _looks_foreign(line: str) -> bool:
1192 latin = len(re.findall(r"[A-Za-z]", line))
1193 cjk = len(re.findall(r"[\u4e00-\u9fff]", line))
1194 return latin > 0 and cjk == 0
1 """Import LRC/TXT lyric files into records."""
2
3 from __future__ import annotations
4
5 import hashlib
6 from pathlib import Path
7
8 from lyric_dedup.checker import LyricRecord
9
10
11 SUPPORTED_SUFFIXES = {".lrc", ".txt"}
12
13
14 def iter_lyric_files(root: str | Path) -> list[Path]:
15 base = Path(root)
16 return sorted(
17 path
18 for path in base.rglob("*")
19 if path.is_file() and path.suffix.lower() in SUPPORTED_SUFFIXES
20 )
21
22
23 def read_lyric_file(path: str | Path) -> str:
24 file_path = Path(path)
25 data = file_path.read_bytes()
26 for encoding in ("utf-8-sig", "utf-8", "gb18030", "big5"):
27 try:
28 return data.decode(encoding)
29 except UnicodeDecodeError:
30 continue
31 return data.decode("utf-8", errors="replace")
32
33
34 def record_from_file(path: str | Path, *, base_dir: str | Path | None = None) -> LyricRecord:
35 file_path = Path(path)
36 lyrics = read_lyric_file(file_path)
37 title, artist = _metadata_from_name(file_path.stem)
38 record_id = _record_id(file_path, base_dir)
39 return LyricRecord(record_id=record_id, lyrics=lyrics, title=title, artist=artist)
40
41
42 def records_from_dir(root: str | Path) -> list[LyricRecord]:
43 return [record_from_file(path, base_dir=root) for path in iter_lyric_files(root)]
44
45
46 def _record_id(path: Path, base_dir: str | Path | None) -> str:
47 if base_dir is None:
48 source = str(path.resolve())
49 else:
50 source = str(path.resolve().relative_to(Path(base_dir).resolve()))
51 digest = hashlib.sha1(source.encode("utf-8")).hexdigest()[:12]
52 return f"{digest}:{source}"
53
54
55 def _metadata_from_name(stem: str) -> tuple[str | None, str | None]:
56 cleaned = stem.removesuffix("-歌词").removesuffix("_歌词").removesuffix(" 歌词").strip()
57 if " - " in cleaned:
58 artist, title = cleaned.split(" - ", 1)
59 return title.strip() or None, artist.strip() or None
60 for sep in ("-", "_"):
61 if sep in cleaned:
62 title, artist = cleaned.rsplit(sep, 1)
63 return title.strip() or None, artist.strip() or None
64 return stem.strip() or None, None
1 """Lyric-specific normalization and feature extraction."""
2
3 from __future__ import annotations
4
5 import re
6 import string
7 import unicodedata
8 from collections import Counter
9 from dataclasses import dataclass
10
11 import opencc
12
13
14 _T2S_CONVERTER = opencc.OpenCC("t2s")
15
16 _TIMESTAMP_RE = re.compile(r"\[((?:\d{1,2}:)?\d{1,2}:\d{2}(?:[.:]\d{1,3})?)\]")
17 _BRACKET_RE = re.compile(r"[\[((【<《].{0,40}?[\]))】>》]")
18 _ROLE_PREFIX_RE = re.compile(r"^\s*(?:男|女|合|主歌|副歌|verse|chorus|bridge|rap)\s*[::]\s*", re.IGNORECASE)
19 _CREDIT_PREFIX_RE = re.compile(
20 r"^\s*(?:作词|作詞|作曲|编曲|編曲|制作|製作|监制|監製|录音|錄音|混音|母带|"
21 r"出品|发行|發行|歌词|歌詞|lyric(?:s)?|composer|writer|producer|arranger|"
22 r"copyright|未经|未經|qq音乐|酷狗|网易云|網易雲|lrc)",
23 re.IGNORECASE,
24 )
25 _WATERMARK_RE = re.compile(
26 r"(?:qq音乐|酷狗音乐|网易云音乐|網易雲音樂|虾米音乐|歌词网|歌詞網|"
27 r"music\.163\.com|www\.|http[s]?://|\blrc\b)",
28 re.IGNORECASE,
29 )
30 _INSTRUMENTAL_RE = re.compile(
31 r"(?:纯音乐|純音樂|无歌词|無歌詞|没有歌词|沒有歌詞|instrumental)",
32 re.IGNORECASE,
33 )
34 _CJK_RE = re.compile(r"[\u4e00-\u9fff]")
35 _LATIN_RE = re.compile(r"[a-zA-Z]")
36 _KANA_RE = re.compile(r"[\u3040-\u30ff]")
37 _HANGUL_RE = re.compile(r"[\uac00-\ud7af]")
38 _WORD_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff]", re.IGNORECASE)
39 _INLINE_SPLIT_RE = re.compile(r"\s+(?:/|\|||)\s+|(?<=[A-Za-z])\s*[-—]\s*(?=[\u4e00-\u9fff])")
40
41
42 @dataclass(frozen=True)
43 class _LineEntry:
44 text: str
45 timestamp: str | None
46 language: str
47 source_index: int
48
49
50 @dataclass(frozen=True)
51 class NormalizedLyrics:
52 raw_text: str
53 normalized_full_text: str
54 normalized_lines: tuple[str, ...]
55 unique_lines: tuple[str, ...]
56 line_counts: dict[str, int]
57 content_line_count: int
58 primary_lines: tuple[str, ...]
59 translation_lines: tuple[str, ...]
60 unknown_lines: tuple[str, ...]
61 line_roles: tuple[str, ...]
62 split_confidence: str
63 split_reason: str
64
65
66 def normalize_lyrics(text: str) -> NormalizedLyrics:
67 """Normalize lyrics while preserving line-level structure for ranking."""
68 entries: list[_LineEntry] = []
69 for index, raw_line in enumerate(unicodedata.normalize("NFKC", text).splitlines()):
70 entries.extend(_clean_line_entries(raw_line, index))
71
72 cleaned_lines = [entry.text for entry in entries]
73 roles, confidence, reason = _assign_line_roles(entries)
74 primary_lines = tuple(entry.text for entry, role in zip(entries, roles, strict=False) if role == "primary")
75 translation_lines = tuple(entry.text for entry, role in zip(entries, roles, strict=False) if role == "translation")
76 unknown_lines = tuple(entry.text for entry, role in zip(entries, roles, strict=False) if role == "unknown")
77 if not primary_lines:
78 primary_lines = tuple(cleaned_lines)
79 roles = tuple("primary" for _ in cleaned_lines)
80 if cleaned_lines and confidence == "none":
81 reason = "未检测到可分离的翻译结构,全部有效行按原文处理"
82
83 counts = Counter(cleaned_lines)
84 unique_lines = tuple(dict.fromkeys(cleaned_lines))
85 return NormalizedLyrics(
86 raw_text=text,
87 normalized_full_text="\n".join(cleaned_lines),
88 normalized_lines=tuple(cleaned_lines),
89 unique_lines=unique_lines,
90 line_counts=dict(counts),
91 content_line_count=len(cleaned_lines),
92 primary_lines=tuple(dict.fromkeys(primary_lines)),
93 translation_lines=tuple(dict.fromkeys(translation_lines)),
94 unknown_lines=tuple(dict.fromkeys(unknown_lines)),
95 line_roles=tuple(roles),
96 split_confidence=confidence,
97 split_reason=reason,
98 )
99
100
101 def fingerprint_text(normalized: NormalizedLyrics) -> str:
102 """Return a text form suitable for exact hashing.
103
104 Repeated adjacent or non-adjacent lyric lines are collapsed so different chorus
105 repeat counts do not prevent exact duplicate detection.
106 """
107 return "\n".join(normalized.primary_lines or normalized.unique_lines)
108
109
110 def lyric_tokens(
111 normalized: NormalizedLyrics,
112 ngram_size: int = 3,
113 *,
114 lines: tuple[str, ...] | None = None,
115 ) -> set[str]:
116 """Build mixed CJK/Latin n-grams with repeated lines down-weighted."""
117 tokens: set[str] = set()
118 selected_lines = lines if lines is not None else normalized.unique_lines
119 for line in selected_lines:
120 units = _token_units(line)
121 if len(units) < ngram_size:
122 if units:
123 tokens.add(" ".join(units))
124 continue
125 for start in range(len(units) - ngram_size + 1):
126 tokens.add(" ".join(units[start : start + ngram_size]))
127 return tokens
128
129
130 def _clean_line_entries(raw_line: str, source_index: int) -> list[_LineEntry]:
131 timestamp_match = _TIMESTAMP_RE.search(raw_line)
132 timestamp = timestamp_match.group(1) if timestamp_match else None
133 line = _TIMESTAMP_RE.sub("", raw_line)
134 line = _ROLE_PREFIX_RE.sub("", line).strip()
135 inline_entries = _split_inline_translation(line, timestamp, source_index)
136 if inline_entries:
137 return inline_entries
138 return _entry_from_text(line, timestamp, source_index)
139
140
141 def _split_inline_translation(line: str, timestamp: str | None, source_index: int) -> list[_LineEntry]:
142 parts = [part.strip() for part in _INLINE_SPLIT_RE.split(line, maxsplit=1)]
143 if len(parts) != 2:
144 return []
145 left_entries = _entry_from_text(parts[0], timestamp, source_index)
146 right_entries = _entry_from_text(parts[1], timestamp, source_index)
147 if not left_entries or not right_entries:
148 return []
149 left_lang = left_entries[0].language
150 right_lang = right_entries[0].language
151 if _is_foreign_language(left_lang) and right_lang == "zh":
152 return [left_entries[0], right_entries[0]]
153 if left_lang == "zh" and _is_foreign_language(right_lang):
154 return [right_entries[0], left_entries[0]]
155 return []
156
157
158 def _entry_from_text(text: str, timestamp: str | None, source_index: int) -> list[_LineEntry]:
159 line = _BRACKET_RE.sub("", text)
160 line = _T2S_CONVERTER.convert(line.strip().lower())
161 if not line or _is_noise_line(line):
162 return []
163 line = _strip_symbols(line)
164 if not line:
165 return []
166 return [_LineEntry(text=line, timestamp=timestamp, language=_detect_language(line), source_index=source_index)]
167
168
169 def _assign_line_roles(entries: list[_LineEntry]) -> tuple[tuple[str, ...], str, str]:
170 if not entries:
171 return (), "none", "没有有效歌词行"
172
173 timestamp_roles = _roles_by_same_timestamp(entries)
174 if timestamp_roles is not None:
175 return timestamp_roles, "high", "同时间戳下检测到外文行和中文行配对"
176
177 inline_roles = _roles_by_inline_translation(entries)
178 if inline_roles is not None:
179 return inline_roles, "medium", "同一原始行内检测到明显的外文和中文翻译"
180
181 alternating_roles = _roles_by_alternating_translation(entries)
182 if alternating_roles is not None:
183 return alternating_roles, "high", "检测到稳定的外文行和中文翻译行交替结构"
184
185 block_roles = _roles_by_translation_block(entries)
186 if block_roles is not None:
187 return block_roles, "low", "检测到疑似原文段落加中文翻译段落,置信度较低"
188
189 return tuple("primary" for _ in entries), "none", "未检测到可分离的翻译结构,全部有效行按原文处理"
190
191
192 def _roles_by_same_timestamp(entries: list[_LineEntry]) -> tuple[str, ...] | None:
193 roles = ["unknown"] * len(entries)
194 groups: dict[str, list[int]] = {}
195 for idx, entry in enumerate(entries):
196 if entry.timestamp:
197 groups.setdefault(entry.timestamp, []).append(idx)
198
199 paired = 0
200 for indexes in groups.values():
201 if len(indexes) < 2:
202 continue
203 foreign = [idx for idx in indexes if _is_foreign_language(entries[idx].language)]
204 chinese = [idx for idx in indexes if entries[idx].language == "zh"]
205 if not foreign or not chinese:
206 continue
207 for idx in foreign:
208 roles[idx] = "primary"
209 for idx in chinese:
210 roles[idx] = "translation"
211 paired += 1
212
213 if paired == 0:
214 return None
215 for idx, role in enumerate(roles):
216 if role == "unknown":
217 roles[idx] = "primary"
218 return tuple(roles)
219
220
221 def _roles_by_alternating_translation(entries: list[_LineEntry]) -> tuple[str, ...] | None:
222 roles = ["unknown"] * len(entries)
223 pairs = 0
224 idx = 0
225 while idx < len(entries) - 1:
226 current = entries[idx]
227 nxt = entries[idx + 1]
228 if _is_foreign_language(current.language) and nxt.language == "zh":
229 roles[idx] = "primary"
230 roles[idx + 1] = "translation"
231 pairs += 1
232 idx += 2
233 continue
234 idx += 1
235
236 if pairs < 2:
237 return None
238 assigned = sum(1 for role in roles if role != "unknown")
239 if assigned / len(entries) < 0.65:
240 return None
241 for idx, role in enumerate(roles):
242 if role == "unknown":
243 roles[idx] = "primary"
244 return tuple(roles)
245
246
247 def _roles_by_inline_translation(entries: list[_LineEntry]) -> tuple[str, ...] | None:
248 roles = ["primary"] * len(entries)
249 pairs = 0
250 by_source: dict[int, list[int]] = {}
251 for idx, entry in enumerate(entries):
252 by_source.setdefault(entry.source_index, []).append(idx)
253 for indexes in by_source.values():
254 if len(indexes) != 2:
255 continue
256 first, second = indexes
257 if _is_foreign_language(entries[first].language) and entries[second].language == "zh":
258 roles[first] = "primary"
259 roles[second] = "translation"
260 pairs += 1
261 elif entries[first].language == "zh" and _is_foreign_language(entries[second].language):
262 roles[first] = "translation"
263 roles[second] = "primary"
264 pairs += 1
265 return tuple(roles) if pairs else None
266
267
268 def _roles_by_translation_block(entries: list[_LineEntry]) -> tuple[str, ...] | None:
269 if len(entries) < 4:
270 return None
271 midpoint = len(entries) // 2
272 first = entries[:midpoint]
273 second = entries[midpoint:]
274 first_foreign = sum(1 for entry in first if _is_foreign_language(entry.language))
275 second_zh = sum(1 for entry in second if entry.language == "zh")
276 if first_foreign / len(first) >= 0.75 and second_zh / len(second) >= 0.75:
277 return tuple("primary" if idx < midpoint else "translation" for idx in range(len(entries)))
278 return None
279
280
281 def _detect_language(line: str) -> str:
282 cjk = len(_CJK_RE.findall(line))
283 latin = len(_LATIN_RE.findall(line))
284 kana = len(_KANA_RE.findall(line))
285 hangul = len(_HANGUL_RE.findall(line))
286 if hangul:
287 return "kr"
288 if kana:
289 return "jp"
290 if cjk and latin:
291 return "mixed"
292 if cjk:
293 return "zh"
294 if latin:
295 return "latin"
296 return "other"
297
298
299 def _is_foreign_language(language: str) -> bool:
300 return language in {"latin", "jp", "kr", "other"}
301
302
303 def _is_noise_line(line: str) -> bool:
304 if _CREDIT_PREFIX_RE.search(line) or _WATERMARK_RE.search(line):
305 return True
306 if _INSTRUMENTAL_RE.search(line):
307 return True
308 has_cjk_or_latin = bool(_CJK_RE.search(line) or _LATIN_RE.search(line))
309 if not has_cjk_or_latin:
310 return True
311 compact = _strip_symbols(line)
312 return len(compact) <= 1
313
314
315 def _strip_symbols(line: str) -> str:
316 punctuation = string.punctuation + ",。!?;:、“”‘’·…—~!¥()【】《》〈〉「」『』﹏"
317 line = "".join(" " if char in punctuation else char for char in line)
318 line = re.sub(r"\s+", " ", line)
319 line = re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])", "", line)
320 return line.strip()
321
322
323 def _token_units(line: str) -> list[str]:
324 units: list[str] = []
325 for match in _WORD_RE.finditer(line):
326 token = match.group(0).lower()
327 if _CJK_RE.fullmatch(token):
328 units.append(token)
329 else:
330 units.append(token)
331 return units
1 pymysql
2 python-dotenv
3 oss2
4 requests
5 tqdm
6 opencc-python-reimplemented
1 #!/usr/bin/env python3
2 """Local server for the L2 lyric review dashboard."""
3
4 from __future__ import annotations
5
6 import argparse
7 import csv
8 import json
9 import mimetypes
10 import subprocess
11 import sys
12 import time
13 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14 from pathlib import Path
15 from urllib.parse import parse_qs, unquote, urlparse
16
17
18 ROOT = Path(__file__).resolve().parent
19 REPORT_DIR = ROOT / "output" / "reports"
20 DASHBOARD = ROOT / "l2_review_dashboard.html"
21 REPORT_DIR.mkdir(parents=True, exist_ok=True)
22 GROUP_INDEX_CACHE: dict[tuple[str, int, int, str], list[dict[str, object]]] = {}
23
24
25 def _json_response(handler: BaseHTTPRequestHandler, payload: object, status: int = 200) -> None:
26 body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
27 try:
28 handler.send_response(status)
29 handler.send_header("Content-Type", "application/json; charset=utf-8")
30 handler.send_header("Content-Length", str(len(body)))
31 handler.end_headers()
32 handler.wfile.write(body)
33 except (BrokenPipeError, ConnectionResetError):
34 return
35
36
37 def _error(handler: BaseHTTPRequestHandler, message: str, status: int = 400) -> None:
38 _json_response(handler, {"error": message}, status=status)
39
40
41 def _safe_path(raw_path: str) -> Path:
42 path = Path(unquote(raw_path)).expanduser()
43 if not path.is_absolute():
44 path = ROOT / path
45 resolved = path.resolve()
46 allowed_roots = [ROOT.resolve(), REPORT_DIR.resolve()]
47 if not any(resolved == base or base in resolved.parents for base in allowed_roots):
48 raise ValueError(f"path is outside workspace: {raw_path}")
49 if not resolved.exists() or not resolved.is_file():
50 raise FileNotFoundError(str(resolved))
51 return resolved
52
53
54 def _read_csv(path: Path) -> list[dict[str, str]]:
55 with open(path, "r", encoding="utf-8-sig", newline="") as f:
56 return list(csv.DictReader(f))
57
58
59 def _iter_csv(path: Path):
60 with open(path, "r", encoding="utf-8-sig", newline="") as f:
61 yield from csv.DictReader(f)
62
63
64 def _report_runs() -> list[dict[str, str]]:
65 summaries = {
66 path.name.replace("l2_topk_benchmark_summary_", "").removesuffix(".csv"): path
67 for path in REPORT_DIR.glob("l2_topk_benchmark_summary_*.csv")
68 }
69 retrievals = {
70 path.name.replace("l2_topk_retrieval_top10_", "").removesuffix(".csv"): path
71 for path in REPORT_DIR.glob("l2_topk_retrieval_top10_*.csv")
72 }
73 hits = {
74 path.name.replace("l2_topk_duplicate_hits_", "").removesuffix(".csv"): path
75 for path in REPORT_DIR.glob("l2_topk_duplicate_hits_*.csv")
76 }
77 run_ids = sorted(set(summaries) & set(retrievals) & set(hits), reverse=True)
78 benchmark_runs = [
79 {
80 "id": run_id,
81 "type": "benchmark",
82 "summary": str(summaries[run_id]),
83 "retrieval": str(retrievals[run_id]),
84 "hits": str(hits[run_id]),
85 }
86 for run_id in run_ids
87 ]
88 review_runs = [
89 {
90 "id": path.name.replace("review_decisions_", "").removesuffix(".csv"),
91 "type": "import",
92 "summary": str(path),
93 "retrieval": str(path),
94 "hits": str(path),
95 "review": str(path),
96 }
97 for path in sorted(REPORT_DIR.glob("review_decisions_*.csv"), reverse=True)
98 ]
99 return review_runs + benchmark_runs
100
101
102 def _row_decision(row: dict[str, str]) -> str:
103 return row.get("action") or row.get("decision") or row.get("candidate_decision") or ""
104
105
106 def _is_import_review_row(row: dict[str, str]) -> bool:
107 return "source_id" in row and "action" in row and "query_source_id" not in row
108
109
110 def _dashboard_row(row: dict[str, str]) -> dict[str, str]:
111 if not _is_import_review_row(row):
112 return row
113 source_id = row.get("source_id", "")
114 action = row.get("action", "")
115 return {
116 **row,
117 "top_k": "import",
118 "rank": "1",
119 "query_source_id": source_id,
120 "query_name": row.get("name", ""),
121 "query_lyricist": row.get("lyricist", ""),
122 "query_composer": row.get("composer", ""),
123 "query_lyrics_path": row.get("query_lyrics_path", ""),
124 "candidate_id": row.get("matched_id", ""),
125 "candidate_name": row.get("matched_name", ""),
126 "candidate_lyricist": row.get("matched_lyricist", ""),
127 "candidate_composer": row.get("matched_composer", ""),
128 "candidate_lyrics_path": row.get("matched_lyrics_path", ""),
129 "candidate_decision": action,
130 "candidate_confidence": row.get("confidence", ""),
131 "candidate_reason": row.get("reason", ""),
132 "decision": action,
133 "l1_metadata_match": "1" if row.get("l1_matched_id") else "0",
134 "l1_l2_conflict": "0",
135 }
136
137
138 def _review_summary(path: Path) -> list[dict[str, str]]:
139 counts = {"new": 0, "merge": 0, "review": 0}
140 total = 0
141 for row in _iter_csv(path):
142 total += 1
143 action = row.get("action", "")
144 if action in counts:
145 counts[action] += 1
146 return [{
147 "top_k": "import",
148 "elapsed_seconds": "-",
149 "throughput_per_second": "-",
150 "avg_recalled_candidates": "-",
151 "hit_count": str(counts["merge"] + counts["review"]),
152 "duplicate_count": str(counts["merge"]),
153 "review_count": str(counts["review"]),
154 "new_count": str(counts["new"]),
155 "total_count": str(total),
156 }]
157
158
159 def _norm(value: str | None) -> str:
160 return (value or "").strip().lower()
161
162
163 def _matches_term(row: dict[str, object], term: str) -> bool:
164 if not term:
165 return True
166 haystack = " ".join(
167 [
168 row.get("query_source_id", ""),
169 row.get("query_name", ""),
170 row.get("query_lyricist", ""),
171 row.get("query_composer", ""),
172 ]
173 ).lower()
174 return term in haystack
175
176
177 def _group_index(path: Path, top_k: str) -> list[dict[str, object]]:
178 stat = path.stat()
179 cache_key = (str(path), stat.st_mtime_ns, stat.st_size, top_k)
180 cached = GROUP_INDEX_CACHE.get(cache_key)
181 if cached is not None:
182 return cached
183
184 groups_by_id: dict[str, dict[str, object]] = {}
185 order = 0
186 for raw_row in _iter_csv(path):
187 row = _dashboard_row(raw_row)
188 if top_k and str(row.get("top_k", "")) != top_k:
189 continue
190 query_id = row.get("query_source_id", "")
191 if not query_id:
192 continue
193
194 group = groups_by_id.get(query_id)
195 if group is None:
196 group = {
197 "id": query_id,
198 "_order": order,
199 "query_source_id": query_id,
200 "query_name": row.get("query_name", ""),
201 "query_lyricist": row.get("query_lyricist", ""),
202 "query_composer": row.get("query_composer", ""),
203 "count": 0,
204 "has_hit": False,
205 "has_conflict": False,
206 "has_l1_hint": False,
207 }
208 groups_by_id[query_id] = group
209 order += 1
210
211 group["count"] = int(group["count"]) + 1
212 decision = _row_decision(row)
213 l1_match = row.get("l1_metadata_match") == "1"
214 conflict = row.get("l1_l2_conflict") == "1"
215 group["has_hit"] = bool(group["has_hit"]) or decision in {"duplicate", "review"}
216 group["has_conflict"] = bool(group["has_conflict"]) or conflict
217 group["has_l1_hint"] = bool(group["has_l1_hint"]) or (decision == "new" and l1_match)
218
219 groups = sorted(
220 groups_by_id.values(),
221 key=lambda group: (
222 0 if group["has_conflict"] else 1,
223 0 if group["has_l1_hint"] else 1,
224 0 if group["has_hit"] else 1,
225 int(group["_order"]),
226 ),
227 )
228 GROUP_INDEX_CACHE.clear()
229 GROUP_INDEX_CACHE[cache_key] = groups
230 return groups
231
232
233 def _groups_response(path: Path, top_k: str, term: str, page: int, page_size: int) -> dict[str, object]:
234 filtered = [group for group in _group_index(path, top_k) if _matches_term(group, term)]
235 start = max(0, (page - 1) * page_size)
236 end = start + page_size
237 page_groups = filtered[start:end]
238 return {
239 "total": len(filtered),
240 "page": page,
241 "page_size": page_size,
242 "has_more": end < len(filtered),
243 "groups": [{key: value for key, value in group.items() if not key.startswith("_")} for group in page_groups],
244 }
245
246
247 def _query_rows_response(path: Path, top_k: str, query_id: str) -> dict[str, object]:
248 rows = []
249 found = False
250 for raw_row in _iter_csv(path):
251 row = _dashboard_row(raw_row)
252 if str(row.get("top_k", "")) != top_k:
253 continue
254 current_query_id = row.get("query_source_id", "")
255 if current_query_id == query_id:
256 found = True
257 rows.append(row)
258 continue
259 if found:
260 break
261 return {"rows": rows}
262
263
264 def _write_review_import_csv(rows: list[dict[str, str]]) -> Path:
265 path = REPORT_DIR / f"review_import_approved_{time.strftime('%Y%m%d_%H%M%S')}.csv"
266 with open(path, "w", encoding="utf-8", newline="") as f:
267 writer = csv.DictWriter(f, fieldnames=["source_id", "review_decision", "review_note"])
268 writer.writeheader()
269 for row in rows:
270 writer.writerow({
271 "source_id": row.get("source_id", ""),
272 "review_decision": row.get("review_decision", ""),
273 "review_note": row.get("review_note", ""),
274 })
275 return path
276
277
278 def _run_import(review_csv: Path) -> dict[str, object]:
279 cmd = [
280 "uv",
281 "run",
282 "python",
283 "import_hk_songs.py",
284 "--review-result-csv",
285 str(review_csv),
286 "--load-existing-lyrics",
287 ]
288 result = subprocess.run(
289 cmd,
290 cwd=ROOT,
291 text=True,
292 stdout=subprocess.PIPE,
293 stderr=subprocess.STDOUT,
294 check=False,
295 )
296 return {
297 "command": " ".join(cmd),
298 "returncode": result.returncode,
299 "output": result.stdout[-12000:],
300 }
301
302
303 class Handler(BaseHTTPRequestHandler):
304 def log_message(self, fmt: str, *args: object) -> None:
305 print(f"{self.address_string()} - {fmt % args}")
306
307 def do_GET(self) -> None:
308 parsed = urlparse(self.path)
309 if parsed.path == "/":
310 self._serve_file(DASHBOARD)
311 return
312 if parsed.path == "/api/reports":
313 _json_response(self, {"runs": _report_runs()})
314 return
315 if parsed.path == "/api/csv":
316 params = parse_qs(parsed.query)
317 raw_path = params.get("path", [""])[0]
318 try:
319 path = _safe_path(raw_path)
320 if path.name.startswith("review_decisions_"):
321 _json_response(self, {"path": str(path), "rows": _review_summary(path)})
322 else:
323 _json_response(self, {"path": str(path), "rows": _read_csv(path)})
324 except Exception as exc: # noqa: BLE001 - local diagnostic API
325 _error(self, str(exc), status=404)
326 return
327 if parsed.path == "/api/groups":
328 params = parse_qs(parsed.query)
329 try:
330 path = _safe_path(params.get("path", [""])[0])
331 top_k = params.get("top_k", [""])[0]
332 term = _norm(params.get("q", [""])[0])
333 page = max(1, int(params.get("page", ["1"])[0]))
334 page_size = min(200, max(10, int(params.get("page_size", ["50"])[0])))
335 _json_response(self, _groups_response(path, top_k, term, page, page_size))
336 except Exception as exc: # noqa: BLE001 - local diagnostic API
337 _error(self, str(exc), status=404)
338 return
339 if parsed.path == "/api/query":
340 params = parse_qs(parsed.query)
341 try:
342 path = _safe_path(params.get("path", [""])[0])
343 top_k = params.get("top_k", [""])[0]
344 query_id = params.get("query_id", [""])[0]
345 _json_response(self, _query_rows_response(path, top_k, query_id))
346 except Exception as exc: # noqa: BLE001 - local diagnostic API
347 _error(self, str(exc), status=404)
348 return
349 if parsed.path == "/api/text":
350 params = parse_qs(parsed.query)
351 raw_path = params.get("path", [""])[0]
352 try:
353 path = _safe_path(raw_path)
354 text = path.read_text(encoding="utf-8", errors="replace")
355 _json_response(self, {"path": str(path), "text": text})
356 except Exception as exc: # noqa: BLE001 - local diagnostic API
357 _error(self, str(exc), status=404)
358 return
359 if parsed.path.startswith("/static/"):
360 self._serve_file(ROOT / parsed.path.lstrip("/"))
361 return
362 _error(self, "not found", status=404)
363
364 def do_POST(self) -> None:
365 parsed = urlparse(self.path)
366 if parsed.path != "/api/import-reviewed":
367 _error(self, "not found", status=404)
368 return
369 try:
370 length = int(self.headers.get("Content-Length", "0"))
371 payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
372 rows = payload.get("rows") or []
373 if not isinstance(rows, list):
374 raise ValueError("rows must be a list")
375 approved = [
376 {
377 "source_id": str(row.get("source_id", "")).strip(),
378 "review_decision": "import",
379 "review_note": str(row.get("review_note", "")).strip(),
380 }
381 for row in rows
382 if str(row.get("source_id", "")).strip()
383 and str(row.get("review_decision", "")).strip().lower() in {"import", "导入", "not_duplicate"}
384 ]
385 if not approved:
386 raise ValueError("没有可入库的审核通过样本")
387 review_csv = _write_review_import_csv(approved)
388 result = _run_import(review_csv)
389 status = 200 if result["returncode"] == 0 else 500
390 _json_response(self, {"review_csv": str(review_csv), **result}, status=status)
391 except Exception as exc: # noqa: BLE001 - local diagnostic API
392 _error(self, str(exc), status=400)
393
394 def _serve_file(self, path: Path) -> None:
395 if not path.exists() or not path.is_file():
396 _error(self, "not found", status=404)
397 return
398 content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
399 body = path.read_bytes()
400 self.send_response(200)
401 self.send_header("Content-Type", content_type)
402 self.send_header("Content-Length", str(len(body)))
403 self.end_headers()
404 self.wfile.write(body)
405
406
407 def main() -> None:
408 parser = argparse.ArgumentParser(description="Serve L2 lyric review dashboard")
409 parser.add_argument("--host", default="127.0.0.1")
410 parser.add_argument("--port", type=int, default=8765)
411 args = parser.parse_args()
412
413 server = ThreadingHTTPServer((args.host, args.port), Handler)
414 print(f"L2 dashboard: http://{args.host}:{args.port}")
415 server.serve_forever()
416
417
418 if __name__ == "__main__":
419 try:
420 main()
421 except KeyboardInterrupt:
422 sys.exit(0)
1 #!/usr/bin/env python3
2 """
3 歌词去重入库功能完整测试用例
4 覆盖 L1(元数据匹配)和 L2(歌词内容匹配)两层去重逻辑
5 """
6
7 import csv
8 import os
9 import sys
10 import tempfile
11 import time
12 from concurrent.futures import ThreadPoolExecutor, as_completed
13 from pathlib import Path
14 from typing import Any
15
16 import pymysql
17 import pytest
18 from tqdm import tqdm
19
20 # 确保项目根目录在 sys.path
21 sys.path.insert(0, str(Path(__file__).resolve().parent))
22
23 from import_hk_songs import (
24 AGGREGATE_SQL,
25 REPORT_DIR,
26 SOURCE_DB_CONFIG,
27 TARGET_DB_CONFIG,
28 TARGET_TABLE_NAME,
29 OSS_CONFIG,
30 _is_lrc_format,
31 _normalize_meta,
32 build_row_tuple,
33 classify_dedup_action,
34 check_l1,
35 check_l2,
36 download_file,
37 is_instrumental_lyrics,
38 process_lyrics,
39 DedupReport,
40 L2CandidateIndex,
41 load_approved_import_ids,
42 )
43 from lyric_dedup import DuplicateChecker, DuplicateDecision, LyricRecord
44 from lyric_dedup.checker import CandidateMatch
45
46
47 # ====================================================================
48 # 聚合 SQL 导入规则
49 # ====================================================================
50
51 class TestAggregateSqlRules:
52 """测试源库聚合查询中的业务过滤规则"""
53
54 def test_excludes_liancheng_xiaorui_compositions_but_not_yinyan_self_made_compositions(self):
55 assert '1871475560046465025' in AGGREGATE_SQL
56 assert '连城小睿音乐工作室' in AGGREGATE_SQL
57
58 where_clause = AGGREGATE_SQL.split('WHERE', 1)[1]
59 assert 'sp.copyright_id' in where_clause
60 assert 'sp.copyright_name' in where_clause
61 assert '音眼自制' not in where_clause
62
63
64 # ====================================================================
65 # L1 元数据规范化 _normalize_meta
66 # ====================================================================
67
68 class TestNormalizeMeta:
69 """测试元数据规范化函数"""
70
71 def test_empty_none(self):
72 assert _normalize_meta(None) == ''
73 assert _normalize_meta('') == ''
74 assert _normalize_meta(' ') == ''
75
76 def test_basic_chinese(self):
77 assert _normalize_meta('遗失的心跳') == '遗失的心跳'
78
79 def test_lowercase(self):
80 assert _normalize_meta('Hello World') == 'helloworld'
81
82 def test_strip_spaces(self):
83 assert _normalize_meta(' 周杰伦 ') == '周杰伦'
84
85 def test_remove_punctuation(self):
86 assert _normalize_meta('你,好吗?') == '你好吗'
87 assert _normalize_meta('test song') == 'testsong'
88
89 def test_traditional_to_simplified(self):
90 """繁体转简体"""
91 assert _normalize_meta('遺失的心跳') == '遗失的心跳'
92 assert _normalize_meta('愛') == '爱'
93 assert _normalize_meta('國語') == '国语'
94
95 def test_mixed_content(self):
96 """混合中英文+标点+空格"""
97 result = _normalize_meta(' Hello 世界 ,Test! ')
98 assert result == 'hello世界test'
99
100 def test_punctuation_only(self):
101 assert _normalize_meta(',。!?') == ''
102
103 def test_fullwidth_digits(self):
104 """全角数字转半角(NFKC)"""
105 assert _normalize_meta('⑦裏香') == '7里香' # ⑦→7,裏→里(繁转简)
106 assert _normalize_meta('第①章') == '第1章'
107 assert _normalize_meta('123') == '123'
108
109 def test_fullwidth_letters(self):
110 """全角字母转半角(NFKC)"""
111 assert _normalize_meta('ABC') == 'abc'
112 assert _normalize_meta('Hello') == 'hello'
113
114 def test_fullwidth_punctuation(self):
115 """全角标点经 NFKC 后能被正常删除"""
116 # NFKC 将全角标点转为半角,然后被标点删除步骤清除
117 assert _normalize_meta('你好!?世界') == '你好世界'
118
119
120 # ====================================================================
121 # L1 元数据去重 check_l1
122 # ====================================================================
123
124 class TestCheckL1:
125 """测试 L1 元数据去重"""
126
127 def setup_method(self):
128 """构建测试索引"""
129 # 索引 key 必须用 _normalize_meta 规范化后的值
130 self.l1_index = {
131 (_normalize_meta('遗失的心跳'), _normalize_meta('萧亚轩'), _normalize_meta('Per Eklund')): 1001,
132 (_normalize_meta('妥协'), _normalize_meta('Wonderful'), _normalize_meta('阿沁')): 1002,
133 (_normalize_meta('晴天'), _normalize_meta('周杰伦'), _normalize_meta('周杰伦')): 1003,
134 }
135
136 def test_exact_match(self):
137 row = {'name': '遗失的心跳', 'lyricist': '萧亚轩', 'composer': 'Per Eklund'}
138 is_dup, matched_id = check_l1(row, self.l1_index)
139 assert is_dup is True
140 assert matched_id == 1001
141
142 def test_no_match(self):
143 row = {'name': '全新的歌', 'lyricist': '未知', 'composer': '未知'}
144 is_dup, matched_id = check_l1(row, self.l1_index)
145 assert is_dup is False
146 assert matched_id is None
147
148 def test_traditional_chinese_match(self):
149 """繁体名应匹配简体索引"""
150 row = {'name': '遺失的心跳', 'lyricist': '蕭亞軒', 'composer': 'Per Eklund'}
151 is_dup, matched_id = check_l1(row, self.l1_index)
152 assert is_dup is True
153 assert matched_id == 1001
154
155 def test_punctuation_ignored(self):
156 """标点差异应被忽略"""
157 row = {'name': '晴天', 'lyricist': '周杰伦', 'composer': '周杰伦!'}
158 is_dup, matched_id = check_l1(row, self.l1_index)
159 assert is_dup is True
160 assert matched_id == 1003
161
162 def test_empty_name_not_matched(self):
163 """空歌名不应匹配任何记录"""
164 row = {'name': '', 'lyricist': '萧亚轩', 'composer': 'Per Eklund'}
165 is_dup, matched_id = check_l1(row, self.l1_index)
166 assert is_dup is False
167
168 def test_none_fields(self):
169 """None 字段不应崩溃"""
170 row = {'name': '晴天', 'lyricist': None, 'composer': None}
171 is_dup, matched_id = check_l1(row, self.l1_index)
172 assert is_dup is False
173
174 def test_partial_match_not_duplicate(self):
175 """只有部分字段匹配不算重复"""
176 row = {'name': '晴天', 'lyricist': '方文山', 'composer': '周杰伦'}
177 is_dup, matched_id = check_l1(row, self.l1_index)
178 # lyricist 不同,不算重复
179 assert is_dup is False
180
181
182 # ====================================================================
183 # L2 歌词内容去重 check_l2
184 # ====================================================================
185
186 class TestCheckL2:
187 """测试 L2 歌词内容去重"""
188
189 def setup_method(self):
190 self.checker = DuplicateChecker()
191
192 def test_empty_lyrics(self):
193 """空歌词应返回 new"""
194 row = {'id': 1, 'lyrics_txt_content': '', 'name': 'test', 'singer': 'test'}
195 decision, confidence, matched_id, reason = check_l2(row, self.checker, [])
196 assert decision == 'new'
197 assert '无歌词内容' in reason
198
199 def test_none_lyrics(self):
200 row = {'id': 1, 'lyrics_txt_content': None, 'name': 'test', 'singer': 'test'}
201 decision, _, _, _ = check_l2(row, self.checker, [])
202 assert decision == 'new'
203
204 def test_no_candidates(self):
205 """无候选集应返回 new"""
206 row = {
207 'id': 1,
208 'lyrics_txt_content': '你是我的眼\n带我领略四季的变换\n你是我的眼\n带我穿越拥挤的人潮',
209 'name': '你是我的眼',
210 'singer': '萧煌奇',
211 }
212 decision, confidence, matched_id, reason = check_l2(row, self.checker, [])
213 assert decision == 'new'
214 assert '无候选集' in reason
215
216 def test_metadata_only_lyrics_are_not_duplicate(self):
217 """双方都只有元数据行时,L2 不应自动判重。"""
218 candidate = LyricRecord(
219 record_id='existing_metadata_only',
220 lyrics='作词:张三\n作曲:李四',
221 title='旧歌',
222 )
223 row = {
224 'id': 2,
225 'lyrics_txt_content': '作词:王五\n作曲:赵六',
226 'name': '新歌',
227 'singer': '测试歌手',
228 }
229
230 decision, confidence, matched_id, reason = check_l2(row, self.checker, [candidate])
231
232 assert decision == 'new'
233 assert matched_id is None
234 assert '无有效歌词' in reason
235
236 def test_instrumental_only_lyrics_are_not_duplicate(self):
237 """双方都是纯音乐提示时,L2 不应自动判重。"""
238 candidate = LyricRecord(
239 record_id='existing_instrumental',
240 lyrics='纯音乐,请欣赏',
241 title='纯音乐 A',
242 )
243 row = {
244 'id': 3,
245 'lyrics_txt_content': '纯音乐,请欣赏',
246 'name': '纯音乐 B',
247 'singer': '测试歌手',
248 }
249
250 decision, confidence, matched_id, reason = check_l2(row, self.checker, [candidate])
251
252 assert decision == 'new'
253 assert matched_id is None
254 assert '纯音乐' in reason
255
256 def test_lyrics_containing_instrumental_hint_skip_l2(self):
257 """歌词内容包含纯音乐提示时,L2 应直接跳过。"""
258 candidate = LyricRecord(
259 record_id='existing_lyrics',
260 lyrics='纯音乐\n这是一段伴奏提示',
261 title='旧纯音乐',
262 )
263 row = {
264 'id': 4,
265 'lyrics_txt_content': '本曲为纯音乐,请欣赏',
266 'name': '新纯音乐',
267 'singer': '测试歌手',
268 }
269
270 decision, confidence, matched_id, reason = check_l2(row, self.checker, [candidate])
271
272 assert decision == 'new'
273 assert matched_id is None
274 assert '纯音乐' in reason
275
276 def test_exact_duplicate(self):
277 """完全相同的歌词应判为 duplicate"""
278 lyrics = '\n'.join([
279 '你是我的眼 带我领略四季的变换',
280 '你是我的眼 带我穿越拥挤的人潮',
281 '你是我的眼 带我阅读浩瀚的书海',
282 '因为你是我的眼 让我看见这世界',
283 '就在我眼前 一切都没改变',
284 '眼前的世界 如此的清晰',
285 '我知道 你就是我的眼',
286 '让我看见 这美丽的世界',
287 ])
288 candidate = LyricRecord(record_id='existing_1', lyrics=lyrics, title='你是我的眼', artist='萧煌奇')
289 row = {
290 'id': 2,
291 'lyrics_txt_content': lyrics,
292 'name': '你是我的眼',
293 'singer': '萧煌奇',
294 }
295 decision, confidence, matched_id, reason = check_l2(
296 row, self.checker, [candidate]
297 )
298 assert decision == 'duplicate'
299 assert confidence >= 0.9
300 assert matched_id == 'existing_1'
301
302 def test_similar_lyrics_high_overlap(self):
303 """高度相似的歌词应判为 duplicate 或 review"""
304 base_lyrics = '\n'.join([
305 '如果有一天 我变得更复杂',
306 '是否还记得 当初的模样',
307 '那些年少轻狂的日子',
308 '如今已变成了回忆',
309 '我站在街头 看着人来人往',
310 '寻找曾经熟悉的面孔',
311 '时间带走了许多',
312 '却带不走我对你的思念',
313 '如果有一天 我们再相遇',
314 '请告诉我 你还记得我吗',
315 ])
316
317 # 修改几行制造高相似度变体
318 similar_lyrics = '\n'.join([
319 '如果有一天 我变得更复杂',
320 '是否还记得 当初的样子', # 微小改动
321 '那些年少轻狂的日子',
322 '如今已变成了美好回忆', # 加了"美好"
323 '我站在街头 看着人来人往',
324 '寻找曾经熟悉的那些面孔', # 加了"那些"
325 '时间带走了许多东西', # 加了"东西"
326 '却带不走我对你的思念',
327 '如果有一天 我们再相遇',
328 '请告诉我 你还记得我吗',
329 ])
330
331 candidate = LyricRecord(record_id='c1', lyrics=base_lyrics, title='如果有一天')
332 row = {
333 'id': 2,
334 'lyrics_txt_content': similar_lyrics,
335 'name': '如果有一天(另一版)',
336 'singer': '未知',
337 }
338 decision, confidence, matched_id, reason = check_l2(
339 row, self.checker, [candidate]
340 )
341 # 高相似度 -> 应该不是 new(duplicate 或 review)
342 assert decision in ('duplicate', 'review'), f"Expected duplicate/review but got {decision}"
343
344 def test_completely_different_lyrics(self):
345 """完全不同的歌词应判为 new"""
346 lyrics_a = '\n'.join([
347 '清晨的阳光洒在窗台',
348 '我端起咖啡望向窗外',
349 '新的一天又将开始',
350 '把昨天的烦恼留在梦里',
351 ])
352 lyrics_b = '\n'.join([
353 '夜晚的星空如此美丽',
354 '我独自走在寂静的路上',
355 '风吹过脸颊带着凉意',
356 '远方的灯火闪烁不停',
357 ])
358 candidate = LyricRecord(record_id='c1', lyrics=lyrics_a, title='清晨')
359 row = {
360 'id': 2,
361 'lyrics_txt_content': lyrics_b,
362 'name': '夜晚',
363 'singer': '未知',
364 }
365 decision, confidence, matched_id, reason = check_l2(
366 row, self.checker, [candidate]
367 )
368 assert decision == 'new'
369
370 def test_fragment_goes_to_review(self):
371 """歌词片段不应自动判重,也不应自动放行,应留给人工审核。"""
372 full_lyrics = '\n'.join([
373 '第一行歌词内容在这里',
374 '第二行歌词继续写下去',
375 '第三行歌词还是不同',
376 '第四行歌词依然在变化',
377 '第五行歌词新的表达',
378 '第六行歌词更加精彩',
379 '第七行歌词快要结束',
380 '第八行歌词最后收尾',
381 ])
382 # 取其中 3 行作为可识别的片段
383 fragment = '\n'.join([
384 '第三行歌词还是不同',
385 '第四行歌词依然在变化',
386 '第五行歌词新的表达',
387 ])
388 candidate = LyricRecord(record_id='c1', lyrics=full_lyrics, title='完整歌曲')
389 row = {
390 'id': 2,
391 'lyrics_txt_content': fragment,
392 'name': '片段歌曲',
393 'singer': '未知',
394 }
395 decision, _, _, _ = check_l2(row, self.checker, [candidate])
396 assert decision == 'review'
397
398 def test_lrc_with_timestamps_still_detects(self):
399 """带 LRC 时间戳的歌词仍能正确去重"""
400 plain_lyrics = '\n'.join([
401 '你是我的小呀小苹果',
402 '怎么爱你都不嫌多',
403 '红红的小脸温暖我的心窝',
404 '点亮我生命的火火火火火',
405 '你是我的小呀小苹果',
406 '就像天边最美的云朵',
407 '春天又来到了花开满山坡',
408 '种下希望就会收获',
409 ])
410 lrc_lyrics = '\n'.join([
411 '[00:12.00]你是我的小呀小苹果',
412 '[00:16.00]怎么爱你都不嫌多',
413 '[00:20.00]红红的小脸温暖我的心窝',
414 '[00:24.00]点亮我生命的火火火火火',
415 '[00:28.00]你是我的小呀小苹果',
416 '[00:32.00]就像天边最美的云朵',
417 '[00:36.00]春天又来到了花开满山坡',
418 '[00:40.00]种下希望就会收获',
419 ])
420 candidate = LyricRecord(record_id='c1', lyrics=plain_lyrics, title='小苹果')
421 row = {
422 'id': 2,
423 'lyrics_txt_content': lrc_lyrics,
424 'name': '小苹果(LRC版)',
425 'singer': '筷子兄弟',
426 }
427 decision, confidence, matched_id, reason = check_l2(
428 row, self.checker, [candidate]
429 )
430 assert decision == 'duplicate', f"Expected duplicate, got {decision}: {reason}"
431
432 def test_multiple_candidates_best_match(self):
433 """多个候选时选最佳匹配"""
434 lyrics_query = '\n'.join([
435 '我是一只小小鸟',
436 '想要飞却怎么也飞不高',
437 '我寻寻觅觅寻寻觅觅',
438 '一个温暖的怀抱',
439 '我是一只小小鸟',
440 '想要飞却怎么也飞不高',
441 '这样的要求算不算太高',
442 '我是一只小小鸟',
443 ])
444 candidate_exact = LyricRecord(record_id='exact', lyrics=lyrics_query, title='小小鸟')
445 candidate_different = LyricRecord(
446 record_id='diff',
447 lyrics='完全不同的歌词内容\n这里是第二行\n第三行也不一样\n第四行更是如此',
448 title='不同的歌',
449 )
450 row = {
451 'id': 3,
452 'lyrics_txt_content': lyrics_query,
453 'name': '小小鸟',
454 'singer': '赵传',
455 }
456 decision, confidence, matched_id, _ = check_l2(
457 row, self.checker, [candidate_different, candidate_exact]
458 )
459 assert decision == 'duplicate'
460 assert matched_id == 'exact'
461
462 def test_threshold_override(self):
463 """自定义阈值影响判定"""
464 lyrics = '\n'.join([
465 '风吹过脸庞带来你的消息',
466 '雨落在窗前想起那个夏季',
467 '我站在路口等一个奇迹',
468 '你是否还记得我们的约定',
469 ])
470 candidate = LyricRecord(record_id='c1', lyrics=lyrics, title='约定')
471 row = {
472 'id': 2,
473 'lyrics_txt_content': lyrics,
474 'name': '约定',
475 'singer': '光良',
476 }
477
478 # 极低阈值 -> 更容易判为 duplicate
479 strict_checker = DuplicateChecker(duplicate_jaccard_threshold=0.3)
480 decision_strict, _, _, _ = check_l2(row, strict_checker, [candidate])
481 assert decision_strict == 'duplicate'
482
483 def test_strong_review_same_writers_promotes_to_duplicate(self):
484 """同词曲且主体歌词强重合的 review 边界样本应升级为 duplicate。"""
485 candidate_lyrics = '\n'.join([
486 '词:王琪',
487 '曲:王琪',
488 '那夜的雨也没能留住你',
489 '山谷的风它陪着我哭泣',
490 '你的驼铃声仿佛还在我耳边响起',
491 '告诉我你曾来过这里',
492 '我酿的酒喝不醉我自己',
493 '你唱的歌却让我一醉不起',
494 '我愿意陪你翻过雪山穿越戈壁',
495 '可你不辞而别还断绝了所有的消息',
496 '心上人我在可可托海等你',
497 '他们说你嫁到了伊犁',
498 ])
499 query_lyrics = '\n'.join([
500 '歌曲:可可托海的牧羊人',
501 '词:王琪',
502 '曲:王琪',
503 '那夜的雨也没能留住你',
504 '山谷的风它陪着我哭泣',
505 '你的驼铃声仿佛还在我耳边响起',
506 '告诉我你曾来过这里',
507 '我酿的酒喝不醉我自己',
508 '你唱的歌却让我一醉不起',
509 '我愿意陪你翻过雪山穿越戈壁',
510 '可你不辞而别还断绝了',
511 '所有的消息',
512 '心上人我在可可托海等你',
513 ])
514 candidate = LyricRecord(
515 record_id='c1',
516 lyrics=candidate_lyrics,
517 title='可可托海的牧羊人',
518 lyricist='王琪',
519 composer='王琪',
520 )
521 row = {
522 'id': 2,
523 'lyrics_txt_content': query_lyrics,
524 'name': '歌曲:可可托海的牧羊人',
525 'singer': '测试歌手',
526 'lyricist': '王琪',
527 'composer': '王琪',
528 }
529
530 decision, _, matched_id, reason = check_l2(row, self.checker, [candidate])
531
532 assert decision == 'duplicate', reason
533 assert matched_id == 'c1'
534
535 def test_strong_review_cover_version_stays_review(self):
536 """翻唱/cover 版本即使歌词强重合,也应留给人工审核。"""
537 candidate_lyrics = '\n'.join([
538 '词:陈粒',
539 '曲:陈粒',
540 '窗外雨都停了',
541 '屋里灯还黑着',
542 '数着你的冷漠',
543 '把玩着寂寞',
544 '电话还没拨已经口渴',
545 '为你熬的夜都冷了',
546 '数的羊都跑了',
547 '一个两个',
548 '嘲笑我笑我耳朵失灵的',
549 '笑我放你走了走了走了',
550 ])
551 cover_lyrics = '\n'.join([
552 '走马 - 摩登兄弟翻唱版',
553 '原唱:陈粒',
554 '词:陈粒',
555 '曲:陈粒',
556 '窗外雨都停了',
557 '屋里灯还黑着',
558 '数着你的冷漠',
559 '把玩着寂寞',
560 '电话还没拨已经口渴',
561 '为你熬的夜都冷了',
562 '数的羊都跑了',
563 '一个两个',
564 '嘲笑我笑我耳朵失灵的',
565 ])
566 candidate = LyricRecord(
567 record_id='c1',
568 lyrics=candidate_lyrics,
569 title='走马',
570 artist='陈粒',
571 lyricist='陈粒',
572 composer='陈粒',
573 )
574 row = {
575 'id': 2,
576 'lyrics_txt_content': cover_lyrics,
577 'name': '走马 翻唱版',
578 'singer': '摩登兄弟',
579 'lyricist': '陈粒',
580 'composer': '陈粒',
581 }
582
583 decision, _, matched_id, reason = check_l2(row, self.checker, [candidate])
584
585 assert decision == 'review', reason
586 assert matched_id == 'c1'
587
588 def test_credit_only_high_review_stays_review(self):
589 """高分只来自词曲/制作信息时,不能升级为 duplicate。"""
590 candidate = LyricRecord(
591 record_id='c1',
592 lyrics='词:安智英\n曲:安智英/Vanilla Man\n사랑할 수밖에 - 脸红的思春期',
593 title='사랑할 수밖에',
594 lyricist='安智英',
595 composer='安智英/Vanilla Man',
596 )
597 row = {
598 'id': 2,
599 'lyrics_txt_content': '词:安智英\n曲:安智英/Vanilla Man\n스노우볼 - 脸红的思春期',
600 'name': '스노우볼',
601 'singer': '脸红的思春期',
602 'lyricist': '安智英',
603 'composer': '安智英/Vanilla Man',
604 }
605
606 decision, _, _, reason = check_l2(row, self.checker, [candidate])
607
608 assert decision != 'duplicate', reason
609
610 def test_same_writers_different_lyrics_never_duplicates(self):
611 """词曲作者相同但歌词内容不同,不应因元数据相同被合并。"""
612 candidate = LyricRecord(
613 record_id='c1',
614 lyrics='\n'.join([
615 '第一首歌的第一句',
616 '第一首歌的第二句',
617 '第一首歌的第三句',
618 '第一首歌的第四句',
619 '第一首歌的第五句',
620 '第一首歌的第六句',
621 '第一首歌的第七句',
622 '第一首歌的第八句',
623 ]),
624 title='同词曲旧歌',
625 lyricist='共同作者',
626 composer='共同作者',
627 )
628 row = {
629 'id': 2,
630 'lyrics_txt_content': '\n'.join([
631 '另一首歌的第一句',
632 '另一首歌的第二句',
633 '另一首歌的第三句',
634 '另一首歌的第四句',
635 '另一首歌的第五句',
636 '另一首歌的第六句',
637 '另一首歌的第七句',
638 '另一首歌的第八句',
639 ]),
640 'name': '同词曲新歌',
641 'singer': '测试歌手',
642 'lyricist': '共同作者',
643 'composer': '共同作者',
644 }
645
646 decision, _, matched_id, reason = check_l2(row, self.checker, [candidate])
647
648 assert decision != 'duplicate', reason
649
650 def test_no_effective_lyrics_fallback_never_duplicates(self):
651 """无有效歌词兜底特征相似也只能复核,不能自动合并。"""
652 candidate = LyricRecord(
653 record_id='c1',
654 lyrics='作词:张三\n作曲:李四\n编曲:王五',
655 title='旧元数据歌',
656 )
657 row = {
658 'id': 2,
659 'lyrics_txt_content': '作词:张三\n作曲:李四\n编曲:王五',
660 'name': '新元数据歌',
661 'singer': '测试歌手',
662 }
663
664 decision, _, matched_id, reason = check_l2(row, self.checker, [candidate])
665
666 assert decision != 'duplicate', reason
667
668 def test_same_lyrics_different_writers_duplicates_for_author_merge(self):
669 """歌词内容相同,即使词曲作者不同,也应命中合并,作者差异交给导入层增量处理。"""
670 lyrics = '\n'.join([
671 '同一份歌词第一行',
672 '同一份歌词第二行',
673 '同一份歌词第三行',
674 '同一份歌词第四行',
675 '同一份歌词第五行',
676 '同一份歌词第六行',
677 '同一份歌词第七行',
678 '同一份歌词第八行',
679 ])
680 candidate = LyricRecord(
681 record_id='c1',
682 lyrics=lyrics,
683 title='旧歌名',
684 lyricist='旧词作者',
685 composer='旧曲作者',
686 )
687 row = {
688 'id': 2,
689 'lyrics_txt_content': lyrics,
690 'name': '新歌名',
691 'singer': '测试歌手',
692 'lyricist': '新词作者',
693 'composer': '新曲作者',
694 }
695
696 decision, _, matched_id, reason = check_l2(row, self.checker, [candidate])
697
698 assert decision == 'duplicate', reason
699 assert matched_id == 'c1'
700
701
702 # ====================================================================
703 # LRC 格式检测 _is_lrc_format
704 # ====================================================================
705
706 class TestIsLrcFormat:
707 """测试 LRC 格式检测"""
708
709 def test_none_empty(self):
710 assert _is_lrc_format(None) is False
711 assert _is_lrc_format('') is False
712
713 def test_plain_text(self):
714 assert _is_lrc_format('你是我的小苹果\n怎么爱你都不嫌多') is False
715
716 def test_lrc_with_timestamps(self):
717 assert _is_lrc_format('[00:12.00]你是我的小苹果\n[00:16.00]怎么爱你都不嫌多') is True
718
719 def test_lrc_with_mm_ss(self):
720 assert _is_lrc_format('[01:30]歌词内容在这里') is True
721
722 def test_lrc_with_hours(self):
723 assert _is_lrc_format('[1:30:00]歌词内容') is True
724
725 def test_bracket_without_time(self):
726 """非时间标签的方括号不算 LRC"""
727 assert _is_lrc_format('[前奏]歌曲开始\n[副歌]高潮部分') is False
728
729
730 # ====================================================================
731 # process_lyrics 歌词处理
732 # ====================================================================
733
734 class TestProcessLyrics:
735 """测试歌词处理函数(skip-oss 模式)"""
736
737 def test_empty_lyrics(self):
738 lyrics_url, lrc_url = process_lyrics(None, '', 1, skip_oss=True)
739 assert lyrics_url is None
740 assert lrc_url is None
741
742 def test_none_lyrics(self):
743 lyrics_url, lrc_url = process_lyrics(None, None, 1, skip_oss=True)
744 assert lyrics_url is None
745 assert lrc_url is None
746
747 def test_plain_text_skip_oss(self):
748 """纯文本 + skip_oss -> lyrics_url 有值,lrc_url 为空"""
749 text = '你是我的小苹果\n怎么爱你都不嫌多'
750 lyrics_url, lrc_url = process_lyrics(None, text, 1, skip_oss=True)
751 assert lyrics_url == text
752 assert lrc_url is None
753
754 def test_lrc_format_skip_oss(self):
755 """LRC 格式 + skip_oss -> 两个字段都有值"""
756 text = '[00:12.00]你是我的小苹果\n[00:16.00]怎么爱你都不嫌多'
757 lyrics_url, lrc_url = process_lyrics(None, text, 1, skip_oss=True)
758 assert lyrics_url == text
759 assert lrc_url == text
760
761
762 # ====================================================================
763 # 入库行构建 build_row_tuple
764 # ====================================================================
765
766 class TestBuildRowTuple:
767 """测试目标表写入 tuple 构建"""
768
769 def test_build_row_tuple_includes_generated_primary_key(self):
770 row = {
771 'source_id': 123,
772 'name': '测试歌曲',
773 'lyricist': '词作者',
774 'composer': '曲作者',
775 'issue_status': 1,
776 'source_table_name': 'hk_song_platform',
777 'source_song_id': '123',
778 }
779
780 tuple_row = build_row_tuple(row, None, skip_oss=True)
781
782 assert len(tuple_row) == 45
783 assert isinstance(tuple_row[0], int)
784 assert tuple_row[0] > 0
785 assert tuple_row[1] == '测试歌曲'
786 assert tuple_row[40] == 'hk_song_platform'
787 assert tuple_row[41] == '123'
788
789
790 # ====================================================================
791 # L2 候选召回索引 L2CandidateIndex
792 # ====================================================================
793
794 class TestL2CandidateIndex:
795 """测试 L2 topK 召回索引"""
796
797 def test_topk_recall_keeps_exact_hash_match(self):
798 checker = DuplicateChecker()
799 index = L2CandidateIndex(checker, mode='topk', top_k=1)
800 exact = LyricRecord(
801 record_id='exact',
802 lyrics='你是我的眼\n带我领略四季的变换\n你是我的眼\n带我穿越拥挤的人潮',
803 title='你是我的眼',
804 )
805 unrelated = LyricRecord(record_id='unrelated', lyrics='完全不同的歌词\n没有任何关系', title='不同')
806 index.add(unrelated)
807 index.add(exact)
808
809 recalled = index.recall(exact)
810
811 assert [record.record_id for record in recalled] == ['exact']
812
813 def test_topk_recall_limits_candidates_before_ranking(self):
814 checker = DuplicateChecker()
815 index = L2CandidateIndex(checker, mode='topk', top_k=2)
816 query = LyricRecord(
817 record_id='query',
818 lyrics='春天的风吹过山岗\n花开的声音落在心上\n我沿着河流寻找月光',
819 title='春风',
820 )
821 related_a = LyricRecord(record_id='a', lyrics='春天的风吹过山岗\n花开的声音留在心上', title='a')
822 related_b = LyricRecord(record_id='b', lyrics='我沿着河流寻找月光\n春天的风吹过远方', title='b')
823 unrelated = LyricRecord(record_id='c', lyrics='城市霓虹闪烁不停\n陌生人穿过雨夜', title='c')
824 for record in (unrelated, related_a, related_b):
825 index.add(record)
826
827 recalled = index.recall(query)
828
829 recalled_ids = {record.record_id for record in recalled}
830 assert len(recalled) == 2
831 assert recalled_ids == {'a', 'b'}
832
833 def test_check_l2_uses_index_recall_then_existing_ranker(self):
834 checker = DuplicateChecker()
835 index = L2CandidateIndex(checker, mode='topk', top_k=10)
836 lyrics = '\n'.join([
837 '你是我的眼 带我领略四季的变换',
838 '你是我的眼 带我穿越拥挤的人潮',
839 '你是我的眼 带我阅读浩瀚的书海',
840 '因为你是我的眼 让我看见这世界',
841 ])
842 index.add(LyricRecord(record_id='existing', lyrics=lyrics, title='你是我的眼'))
843
844 decision, confidence, matched_id, reason = check_l2(
845 {
846 'source_id': 999,
847 'lyrics_txt_content': lyrics,
848 'name': '你是我的眼',
849 'singer': '萧煌奇',
850 },
851 checker,
852 index,
853 )
854
855 assert decision == 'duplicate'
856 assert confidence == 1.0
857 assert matched_id == 'existing'
858 assert '哈希完全一致' in reason
859
860
861 # ====================================================================
862 # DedupReport 去重报告
863 # ====================================================================
864
865 class TestDedupReport:
866 """测试去重报告 CSV 输出"""
867
868 def test_write_and_read(self):
869 with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
870 filepath = f.name
871
872 try:
873 report = DedupReport(filepath)
874 report.write(1001, '遗失的心跳', 'L1', 'duplicate', matched_id=500, reason='元数据完全匹配')
875 report.write(1002, '妥协', 'L2', 'new', confidence='0.9500', reason='无相似候选')
876 report.write(1003, '晴天', 'L2', 'review', confidence='0.6200', matched_id=800,
877 reason='候选相似度达到复核阈值')
878 report.close()
879
880 with open(filepath, 'r', encoding='utf-8') as f:
881 reader = csv.reader(f)
882 rows = list(reader)
883
884 assert len(rows) == 4 # header + 3 data rows
885 assert rows[0] == ['id', 'name', 'stage', 'decision', 'confidence', 'matched_id', 'reason']
886 assert rows[1][0] == '1001'
887 assert rows[1][2] == 'L1'
888 assert rows[1][3] == 'duplicate'
889 assert rows[2][3] == 'new'
890 assert rows[3][3] == 'review'
891 assert rows[3][5] == '800'
892 finally:
893 os.unlink(filepath)
894
895
896 class TestReviewResultCsv:
897 """测试人工审核结果过滤。"""
898
899 def test_load_approved_import_ids_only_returns_explicit_imports(self, tmp_path):
900 review_csv = tmp_path / 'review.csv'
901 review_csv.write_text(
902 '\n'.join([
903 'source_id,review_decision,review_note',
904 '100,import,确认导入',
905 '101,review,继续复核',
906 '102,skip,合并',
907 '103,导入,中文确认',
908 '104,,空白不导入',
909 ]),
910 encoding='utf-8',
911 )
912
913 assert load_approved_import_ids(str(review_csv)) == {'100', '103'}
914
915
916 # ====================================================================
917 # 集成测试:L1 + L2 联合去重流程
918 # ====================================================================
919
920 class TestDedupIntegration:
921 """测试 L1+L2 联合去重的完整流程"""
922
923 def setup_method(self):
924 self.checker = DuplicateChecker()
925 # 模拟已有的 L1 索引
926 self.l1_index = {
927 ('晴天', '周杰伦', '周杰伦'): 100,
928 ('七里香', '方文山', '周杰伦'): 101,
929 }
930 # 模拟已有的 L2 候选集
931 self.l2_candidates = [
932 LyricRecord(
933 record_id='100',
934 lyrics='\n'.join([
935 '故事的小黄花 从出生那年就飘着',
936 '童年的荡秋千 随记忆一直晃到现在',
937 'Re So So Si Do Si La',
938 'So La Si Si Si Si La Si La So',
939 '吹着前奏望着天空 我想起花瓣试着掉落',
940 '为你翘课的那一天 花落的那一天',
941 '教室的那一间 我怎么看不见',
942 '消失的下雨天 我好想再淋一遍',
943 ]),
944 title='晴天',
945 artist='周杰伦',
946 ),
947 ]
948
949 def test_l1_catches_metadata_duplicate(self):
950 """L1 应拦住元数据重复的记录"""
951 row = {'name': '晴天', 'lyricist': '周杰伦', 'composer': '周杰伦',
952 'id': 200, 'lyrics_txt_content': '不同的歌词内容', 'singer': '周杰伦'}
953 is_dup, matched_id = check_l1(row, self.l1_index)
954 assert is_dup is True
955 assert matched_id == 100
956
957 def test_l2_catches_lyric_duplicate(self):
958 """L2 应拦住歌词重复但元数据不同的记录"""
959 # 歌名不同但歌词一样
960 row = {
961 'id': 201,
962 'name': '晴天(翻唱版)',
963 'lyricist': '翻唱歌手',
964 'composer': '翻唱歌手',
965 'lyrics_txt_content': '\n'.join([
966 '故事的小黄花 从出生那年就飘着',
967 '童年的荡秋千 随记忆一直晃到现在',
968 'Re So So Si Do Si La',
969 'So La Si Si Si Si La Si La So',
970 '吹着前奏望着天空 我想起花瓣试着掉落',
971 '为你翘课的那一天 花落的那一天',
972 '教室的那一间 我怎么看不见',
973 '消失的下雨天 我好想再淋一遍',
974 ]),
975 'singer': '翻唱歌手',
976 }
977 # L1 不命中
978 is_dup, _ = check_l1(row, self.l1_index)
979 assert is_dup is False
980
981 # L2 应命中
982 decision, confidence, matched_id, reason = check_l2(
983 row, self.checker, self.l2_candidates
984 )
985 assert decision == 'duplicate'
986 assert matched_id == '100'
987
988 def test_l2_passes_different_song(self):
989 """L2 应放过真正不同的歌"""
990 row = {
991 'id': 202,
992 'name': '稻香',
993 'lyricist': '周杰伦',
994 'composer': '周杰伦',
995 'lyrics_txt_content': '\n'.join([
996 '对这个世界如果你有太多的抱怨',
997 '跌倒了就不敢继续往前走',
998 '为什么人要这么的脆弱 堕落',
999 '请你打开电视看看',
1000 '多少人为生命在努力勇敢的走下去',
1001 '我们是不是该知足',
1002 '珍惜一切 就算没有拥有',
1003 '还记得你说家是唯一的城堡',
1004 ]),
1005 'singer': '周杰伦',
1006 }
1007 # L1 不命中(歌名不同)
1008 is_dup, _ = check_l1(row, self.l1_index)
1009 assert is_dup is False
1010
1011 # L2 也不命中
1012 decision, _, _, _ = check_l2(row, self.checker, self.l2_candidates)
1013 assert decision == 'new'
1014
1015 def test_l1_match_with_different_lyrics_is_not_skipped(self):
1016 """L1 命中只作为召回信号;歌词不同不能跳过或合并。"""
1017 row = {
1018 'id': 203,
1019 'name': '晴天',
1020 'lyricist': '周杰伦',
1021 'composer': '周杰伦',
1022 'lyrics_txt_content': '\n'.join([
1023 '这是一首完全不同的歌第一句',
1024 '这是一首完全不同的歌第二句',
1025 '这是一首完全不同的歌第三句',
1026 '这是一首完全不同的歌第四句',
1027 '这是一首完全不同的歌第五句',
1028 '这是一首完全不同的歌第六句',
1029 '这是一首完全不同的歌第七句',
1030 '这是一首完全不同的歌第八句',
1031 ]),
1032 'singer': '周杰伦',
1033 }
1034
1035 action = classify_dedup_action(row, self.l1_index, self.checker, self.l2_candidates)
1036
1037 assert action['action'] == 'new'
1038 assert action['l1_matched_id'] == 100
1039 assert action['matched_id'] is None
1040
1041 def test_same_lyrics_different_writers_returns_merge_author_action(self):
1042 """歌词相同但作者不同,导入决策应合并并标记作者增量。"""
1043 lyrics = self.l2_candidates[0].lyrics
1044 row = {
1045 'id': 204,
1046 'name': '另一个歌名',
1047 'lyricist': '新词作者',
1048 'composer': '新曲作者',
1049 'lyrics_txt_content': lyrics,
1050 'singer': '新歌手',
1051 }
1052
1053 action = classify_dedup_action(row, self.l1_index, self.checker, self.l2_candidates)
1054
1055 assert action['action'] == 'merge'
1056 assert action['merge_authors'] is True
1057 assert action['matched_id'] == '100'
1058
1059 def test_full_pipeline_simulation(self):
1060 """模拟完整 L1 -> L2 -> 写入 的管线流程"""
1061 batch = [
1062 # 1. L1 重复(元数据匹配)
1063 {'id': 300, 'name': '晴天', 'lyricist': '周杰伦', 'composer': '周杰伦',
1064 'lyrics_txt_content': '随便什么歌词', 'singer': '周杰伦'},
1065 # 2. L2 重复(歌词相同但元数据不同)
1066 {'id': 301, 'name': '晴天 Remix', 'lyricist': 'DJ', 'composer': 'DJ',
1067 'lyrics_txt_content': '\n'.join([
1068 '故事的小黄花 从出生那年就飘着',
1069 '童年的荡秋千 随记忆一直晃到现在',
1070 'Re So So Si Do Si La',
1071 'So La Si Si Si Si La Si La So',
1072 '吹着前奏望着天空 我想起花瓣试着掉落',
1073 '为你翘课的那一天 花落的那一天',
1074 '教室的那一间 我怎么看不见',
1075 '消失的下雨天 我好想再淋一遍',
1076 ]), 'singer': 'DJ'},
1077 # 3. 新歌(L1+L2 都不命中)
1078 {'id': 302, 'name': '夜曲', 'lyricist': '方文山', 'composer': '周杰伦',
1079 'lyrics_txt_content': '\n'.join([
1080 '一群嗜血的蚂蚁 被腐肉所吸引',
1081 '我面无表情 看孤独的风景',
1082 '失去你 爱恨开始分明',
1083 '失去你 还有什么事好关心',
1084 '当太阳升起的时候',
1085 '我开始学会怎么去忘记',
1086 '你的笑容勉强不来',
1087 '爱深埋在尘埃 没有人能明白',
1088 ]), 'singer': '周杰伦'},
1089 ]
1090
1091 accepted = []
1092 skipped_l1 = []
1093 skipped_l2 = []
1094
1095 for row in batch:
1096 # L1
1097 is_dup, matched_id = check_l1(row, self.l1_index)
1098 if is_dup:
1099 skipped_l1.append((row['id'], matched_id))
1100 continue
1101
1102 # L2
1103 decision, confidence, l2_matched_id, reason = check_l2(
1104 row, self.checker, self.l2_candidates
1105 )
1106 if decision == 'duplicate':
1107 skipped_l2.append((row['id'], l2_matched_id))
1108 continue
1109
1110 accepted.append(row['id'])
1111 # 加入候选集
1112 if row.get('lyrics_txt_content'):
1113 self.l2_candidates.append(LyricRecord(
1114 record_id=str(row['id']),
1115 lyrics=row['lyrics_txt_content'],
1116 title=row.get('name'),
1117 artist=row.get('singer'),
1118 ))
1119
1120 # 验证结果
1121 assert len(skipped_l1) == 1 # id=300 被 L1 拦住
1122 assert skipped_l1[0] == (300, 100)
1123 assert len(skipped_l2) == 1 # id=301 被 L2 拦住
1124 assert skipped_l2[0] == (301, '100')
1125 assert accepted == [302] # id=302 通过两层去重
1126 assert len(self.l2_candidates) == 2 # 原1条 + 新增1条
1127
1128
1129 # ====================================================================
1130 # 边界情况
1131 # ====================================================================
1132
1133 class TestEdgeCases:
1134 """边界情况和异常场景"""
1135
1136 def test_very_short_lyrics(self):
1137 """极短歌词不应崩溃"""
1138 checker = DuplicateChecker()
1139 candidate = LyricRecord(record_id='c1', lyrics='爱', title='短歌')
1140 row = {'id': 1, 'lyrics_txt_content': '爱', 'name': '短歌', 'singer': 'test'}
1141 decision, _, _, _ = check_l2(row, checker, [candidate])
1142 # 极短歌词可能是 duplicate(哈希匹配)或 new,但不应崩溃
1143 assert decision in ('duplicate', 'review', 'new')
1144
1145 def test_whitespace_only_lyrics(self):
1146 """纯空白歌词"""
1147 checker = DuplicateChecker()
1148 row = {'id': 1, 'lyrics_txt_content': ' \n\n ', 'name': 'test', 'singer': 'test'}
1149 decision, _, _, reason = check_l2(row, checker, [])
1150 assert decision == 'new'
1151
1152 def test_l1_index_with_duplicate_names(self):
1153 """L1 索引中同名歌曲但不同作者"""
1154 l1_index = {
1155 ('晴天', '周杰伦', '周杰伦'): 100,
1156 ('晴天', '张三', '李四'): 101,
1157 }
1158 row1 = {'name': '晴天', 'lyricist': '周杰伦', 'composer': '周杰伦'}
1159 row2 = {'name': '晴天', 'lyricist': '张三', 'composer': '李四'}
1160 row3 = {'name': '晴天', 'lyricist': '王五', 'composer': '赵六'}
1161
1162 assert check_l1(row1, l1_index) == (True, 100)
1163 assert check_l1(row2, l1_index) == (True, 101)
1164 assert check_l1(row3, l1_index) == (False, None)
1165
1166 def test_l2_with_lrc_timestamps_normalization(self):
1167 """L2 应正确处理带时间戳的歌词"""
1168 checker = DuplicateChecker()
1169 plain = '我爱你中国\n心爱的母亲\n我为你流泪\n也为你自豪'
1170 with_ts = '[00:01.00]我爱你中国\n[00:05.00]心爱的母亲\n[00:09.00]我为你流泪\n[00:13.00]也为你自豪'
1171
1172 candidate = LyricRecord(record_id='c1', lyrics=plain, title='我爱你中国')
1173 row = {'id': 2, 'lyrics_txt_content': with_ts, 'name': '我爱你中国', 'singer': '汪峰'}
1174 decision, _, _, _ = check_l2(row, checker, [candidate])
1175 # normalization 应该去掉时间戳后识别为相同歌词
1176 assert decision == 'duplicate'
1177
1178
1179 class TestTargetTableBaseline:
1180 """测试把目标表已有数据下载到本地后再作为去重测试基线。"""
1181
1182 def test_download_target_table_baseline_writes_manifest_and_local_lyrics(self, monkeypatch, tmp_path):
1183 rows = [
1184 {
1185 'id': 100,
1186 'name': '晴天',
1187 'singer': '周杰伦',
1188 'lyricist': '周杰伦',
1189 'composer': '周杰伦',
1190 'lyrics_url': 'https://example.test/qingtian.txt',
1191 },
1192 {
1193 'id': 101,
1194 'name': '本地歌词',
1195 'singer': '测试歌手',
1196 'lyricist': '测试词',
1197 'composer': '测试曲',
1198 'lyrics_url': '已经存在的歌词文本',
1199 },
1200 ]
1201
1202 class FakeCursor:
1203 def __enter__(self):
1204 return self
1205
1206 def __exit__(self, exc_type, exc, tb):
1207 return False
1208
1209 def execute(self, sql):
1210 self.sql = sql
1211
1212 def fetchall(self):
1213 return rows
1214
1215 class FakeConnection:
1216 def cursor(self):
1217 return FakeCursor()
1218
1219 def close(self):
1220 pass
1221
1222 monkeypatch.setattr(pymysql, 'connect', lambda **kwargs: FakeConnection())
1223 monkeypatch.setattr(
1224 sys.modules[__name__],
1225 'download_file',
1226 lambda url, timeout=30: ('故事的小黄花\n童年的荡秋千'.encode('utf-8'), 'text/plain'),
1227 )
1228
1229 items, manifest_path = _download_target_table_baseline(
1230 tmp_path,
1231 limit=0,
1232 download_timeout=3,
1233 show_progress=False,
1234 )
1235
1236 assert manifest_path.exists()
1237 assert len(items) == 2
1238 assert [item['record'].record_id for item in items] == ['100', '101']
1239 assert items[0]['record'].lyrics == '故事的小黄花\n童年的荡秋千'
1240 assert items[1]['record'].lyrics == '已经存在的歌词文本'
1241 assert Path(items[0]['local_lyrics_path']).exists()
1242 assert Path(items[1]['local_lyrics_path']).exists()
1243
1244 with open(manifest_path, 'r', encoding='utf-8') as f:
1245 manifest_rows = list(csv.DictReader(f))
1246 assert manifest_rows[0]['id'] == '100'
1247 assert manifest_rows[0]['local_lyrics_path'] == items[0]['local_lyrics_path']
1248
1249 def test_download_target_table_baseline_resolves_relative_oss_path(self, monkeypatch, tmp_path):
1250 rows = [
1251 {
1252 'id': 100,
1253 'name': '相对路径歌词',
1254 'singer': '测试歌手',
1255 'lyricist': '测试词',
1256 'composer': '测试曲',
1257 'lyrics_url': '/music_library/music_lyric/20250626/100.txt',
1258 },
1259 ]
1260 requested_urls = []
1261
1262 class FakeCursor:
1263 def __enter__(self):
1264 return self
1265
1266 def __exit__(self, exc_type, exc, tb):
1267 return False
1268
1269 def execute(self, sql):
1270 self.sql = sql
1271
1272 def fetchall(self):
1273 return rows
1274
1275 class FakeConnection:
1276 def cursor(self):
1277 return FakeCursor()
1278
1279 def close(self):
1280 pass
1281
1282 monkeypatch.setattr(pymysql, 'connect', lambda **kwargs: FakeConnection())
1283 monkeypatch.setitem(OSS_CONFIG, 'base_url', 'https://oss.example.test')
1284
1285 def fake_download(url, timeout=30):
1286 requested_urls.append(url)
1287 return '真正的歌词内容\n第二行'.encode('utf-8'), 'text/plain'
1288
1289 monkeypatch.setattr(sys.modules[__name__], 'download_file', fake_download)
1290
1291 items, _ = _download_target_table_baseline(
1292 tmp_path,
1293 limit=0,
1294 download_timeout=3,
1295 show_progress=False,
1296 )
1297
1298 assert requested_urls == ['https://oss.example.test/music_library/music_lyric/20250626/100.txt']
1299 assert len(items) == 1
1300 assert items[0]['record'].lyrics == '真正的歌词内容\n第二行'
1301 assert Path(items[0]['local_lyrics_path']).read_text(encoding='utf-8') == '真正的歌词内容\n第二行'
1302
1303 def test_download_target_table_baseline_skips_relative_oss_path_when_download_fails(self, monkeypatch, tmp_path):
1304 rows = [
1305 {
1306 'id': 100,
1307 'name': '下载失败',
1308 'singer': '测试歌手',
1309 'lyricist': '测试词',
1310 'composer': '测试曲',
1311 'lyrics_url': '/music_library/music_lyric/20250626/missing.txt',
1312 },
1313 ]
1314
1315 class FakeCursor:
1316 def __enter__(self):
1317 return self
1318
1319 def __exit__(self, exc_type, exc, tb):
1320 return False
1321
1322 def execute(self, sql):
1323 self.sql = sql
1324
1325 def fetchall(self):
1326 return rows
1327
1328 class FakeConnection:
1329 def cursor(self):
1330 return FakeCursor()
1331
1332 def close(self):
1333 pass
1334
1335 monkeypatch.setattr(pymysql, 'connect', lambda **kwargs: FakeConnection())
1336 monkeypatch.setitem(OSS_CONFIG, 'base_url', 'https://oss.example.test')
1337 monkeypatch.setattr(sys.modules[__name__], 'download_file', lambda url, timeout=30: (None, None))
1338
1339 items, manifest_path = _download_target_table_baseline(
1340 tmp_path,
1341 limit=0,
1342 download_timeout=3,
1343 show_progress=False,
1344 )
1345
1346 assert items == []
1347 with open(manifest_path, 'r', encoding='utf-8') as f:
1348 manifest_rows = list(csv.DictReader(f))
1349 assert manifest_rows == []
1350
1351 def test_download_target_table_baseline_accepts_download_workers(self, monkeypatch, tmp_path):
1352 rows = [
1353 {
1354 'id': idx,
1355 'name': f'并发歌词{idx}',
1356 'singer': '测试歌手',
1357 'lyricist': '测试词',
1358 'composer': '测试曲',
1359 'lyrics_url': f'https://example.test/{idx}.txt',
1360 }
1361 for idx in range(1, 4)
1362 ]
1363
1364 class FakeCursor:
1365 def __enter__(self):
1366 return self
1367
1368 def __exit__(self, exc_type, exc, tb):
1369 return False
1370
1371 def execute(self, sql):
1372 self.sql = sql
1373
1374 def fetchall(self):
1375 return rows
1376
1377 class FakeConnection:
1378 def cursor(self):
1379 return FakeCursor()
1380
1381 def close(self):
1382 pass
1383
1384 monkeypatch.setattr(pymysql, 'connect', lambda **kwargs: FakeConnection())
1385 monkeypatch.setattr(
1386 sys.modules[__name__],
1387 'download_file',
1388 lambda url, timeout=30: (f'歌词内容 {url}'.encode('utf-8'), 'text/plain'),
1389 )
1390
1391 items, _ = _download_target_table_baseline(
1392 tmp_path,
1393 limit=0,
1394 download_timeout=3,
1395 download_workers=2,
1396 show_progress=False,
1397 )
1398
1399 assert [item['source_id'] for item in items] == ['1', '2', '3']
1400
1401 def test_download_target_table_baseline_skips_instrumental_lyrics(self, monkeypatch, tmp_path):
1402 rows = [
1403 {
1404 'id': 100,
1405 'name': '纯音乐',
1406 'singer': '测试歌手',
1407 'lyricist': '',
1408 'composer': '',
1409 'lyrics_url': 'https://example.test/instrumental.txt',
1410 },
1411 ]
1412
1413 class FakeCursor:
1414 def __enter__(self):
1415 return self
1416
1417 def __exit__(self, exc_type, exc, tb):
1418 return False
1419
1420 def execute(self, sql):
1421 self.sql = sql
1422
1423 def fetchall(self):
1424 return rows
1425
1426 class FakeConnection:
1427 def cursor(self):
1428 return FakeCursor()
1429
1430 def close(self):
1431 pass
1432
1433 monkeypatch.setattr(pymysql, 'connect', lambda **kwargs: FakeConnection())
1434 monkeypatch.setattr(
1435 sys.modules[__name__],
1436 'download_file',
1437 lambda url, timeout=30: ('本曲为纯音乐,请欣赏'.encode('utf-8'), 'text/plain'),
1438 )
1439
1440 items, manifest_path = _download_target_table_baseline(
1441 tmp_path,
1442 limit=0,
1443 download_timeout=3,
1444 show_progress=False,
1445 )
1446
1447 assert items == []
1448 with open(manifest_path, 'r', encoding='utf-8') as f:
1449 assert list(csv.DictReader(f)) == []
1450
1451
1452 def _env_bool(name: str, default: bool = False) -> bool:
1453 value = os.getenv(name)
1454 if value is None:
1455 return default
1456 return value.strip().lower() in {'1', 'true', 'yes', 'y', 'on'}
1457
1458
1459 def _env_int(name: str, default: int) -> int:
1460 value = os.getenv(name)
1461 if not value:
1462 return default
1463 return int(value)
1464
1465
1466 def _env_float(name: str, default: float | None = None) -> float | None:
1467 value = os.getenv(name)
1468 if not value:
1469 return default
1470 return float(value)
1471
1472
1473 def _parse_topks(value: str | None) -> list[int]:
1474 if not value:
1475 return [20, 50, 100, 200, 500]
1476 return [int(item.strip()) for item in value.split(',') if item.strip()]
1477
1478
1479 def _safe_filename(value: str | None, fallback: str) -> str:
1480 text = value or fallback
1481 text = ''.join(char if char.isalnum() or char in {'-', '_'} else '_' for char in text)
1482 return text[:80] or fallback
1483
1484
1485 def _write_lyrics_file(base_dir: Path, prefix: str, record: LyricRecord) -> Path:
1486 base_dir.mkdir(parents=True, exist_ok=True)
1487 filename = f"{prefix}_{_safe_filename(record.record_id, 'record')}_{_safe_filename(record.title, 'untitled')}.txt"
1488 path = base_dir / filename
1489 path.write_text(record.lyrics or '', encoding='utf-8')
1490 return path
1491
1492
1493 def _progress(iterable, *, desc: str, unit: str, leave: bool = True, total: int | None = None):
1494 return tqdm(
1495 iterable,
1496 desc=desc,
1497 unit=unit,
1498 leave=leave,
1499 total=total,
1500 dynamic_ncols=True,
1501 mininterval=1.0,
1502 smoothing=0.1,
1503 )
1504
1505
1506 def _lyrics_text_from_target_row(row: dict[str, Any], download_timeout: int) -> str:
1507 lyrics_url = str(row.get('lyrics_url') or '').strip()
1508 if not lyrics_url:
1509 return ''
1510 if lyrics_url.startswith(('http://', 'https://')):
1511 content, _ = download_file(lyrics_url, timeout=download_timeout)
1512 if not content:
1513 return ''
1514 return content.decode('utf-8', errors='replace')
1515 if lyrics_url.startswith('/'):
1516 base_url = (OSS_CONFIG.get('base_url') or '').rstrip('/')
1517 if not base_url:
1518 return ''
1519 content, _ = download_file(f"{base_url}{lyrics_url}", timeout=download_timeout)
1520 if not content:
1521 return ''
1522 return content.decode('utf-8', errors='replace')
1523 return lyrics_url
1524
1525
1526 def _target_baseline_item_from_row(
1527 row: dict[str, Any],
1528 lyric_dir: Path,
1529 download_timeout: int,
1530 ) -> tuple[dict[str, Any], dict[str, Any]] | None:
1531 lyrics = _lyrics_text_from_target_row(row, download_timeout)
1532 if not lyrics.strip():
1533 return None
1534 if is_instrumental_lyrics(lyrics):
1535 return None
1536
1537 record = LyricRecord(
1538 record_id=str(row['id']),
1539 lyrics=lyrics,
1540 title=row.get('name'),
1541 artist=row.get('singer'),
1542 lyricist=row.get('lyricist'),
1543 composer=row.get('composer'),
1544 )
1545 local_lyrics_path = _write_lyrics_file(lyric_dir, 'target', record)
1546 item = {
1547 'record': record,
1548 'source_id': str(row['id']),
1549 'name': row.get('name') or '',
1550 'singer': row.get('singer') or '',
1551 'lyricist': row.get('lyricist') or '',
1552 'composer': row.get('composer') or '',
1553 'lyrics_url': row.get('lyrics_url') or '',
1554 'local_lyrics_path': str(local_lyrics_path),
1555 }
1556 manifest_row = {
1557 'id': item['source_id'],
1558 'name': item['name'],
1559 'singer': item['singer'],
1560 'lyricist': item['lyricist'],
1561 'composer': item['composer'],
1562 'lyrics_url': item['lyrics_url'],
1563 'local_lyrics_path': item['local_lyrics_path'],
1564 }
1565 return item, manifest_row
1566
1567
1568 def _download_target_table_baseline(
1569 output_dir: Path,
1570 limit: int,
1571 download_timeout: int,
1572 download_workers: int = 8,
1573 show_progress: bool = True,
1574 ) -> tuple[list[dict[str, Any]], Path]:
1575 """下载 TARGET_TABLE 已有数据到本地,返回可作为去重基线的记录。"""
1576 baseline_dir = output_dir / 'target_table_baseline'
1577 lyric_dir = baseline_dir / 'lyrics'
1578 baseline_dir.mkdir(parents=True, exist_ok=True)
1579 lyric_dir.mkdir(parents=True, exist_ok=True)
1580
1581 sql = (
1582 f"SELECT id, name, singer, lyricist, composer, lyrics_url FROM {TARGET_TABLE_NAME} "
1583 "WHERE deleted = '0' AND lyrics_url IS NOT NULL "
1584 "ORDER BY id"
1585 )
1586 if limit > 0:
1587 sql += f" LIMIT {limit}"
1588
1589 if show_progress:
1590 tqdm.write(f"查询 TARGET_TABLE 基线: table={TARGET_TABLE_NAME}, limit={limit or 'ALL'}")
1591 conn = pymysql.connect(**TARGET_DB_CONFIG, cursorclass=pymysql.cursors.DictCursor)
1592 try:
1593 with conn.cursor() as cursor:
1594 cursor.execute(sql)
1595 rows = cursor.fetchall()
1596 finally:
1597 conn.close()
1598 if show_progress:
1599 tqdm.write(f"TARGET_TABLE 基线查询完成: {len(rows)} 条")
1600
1601 download_workers = max(1, download_workers)
1602 if show_progress:
1603 tqdm.write(f"下载 TARGET_TABLE 歌词: workers={download_workers}, timeout={download_timeout}s")
1604
1605 results: list[tuple[int, dict[str, Any], dict[str, Any]]] = []
1606 if download_workers == 1:
1607 iterable = _progress(list(enumerate(rows)), desc='下载基线', unit='条') if show_progress else enumerate(rows)
1608 for index, row in iterable:
1609 result = _target_baseline_item_from_row(row, lyric_dir, download_timeout)
1610 if result is not None:
1611 item, manifest_row = result
1612 results.append((index, item, manifest_row))
1613 else:
1614 with ThreadPoolExecutor(max_workers=download_workers) as executor:
1615 futures = {
1616 executor.submit(_target_baseline_item_from_row, row, lyric_dir, download_timeout): index
1617 for index, row in enumerate(rows)
1618 }
1619 iterable = (
1620 _progress(as_completed(futures), desc='下载基线', unit='条', total=len(futures))
1621 if show_progress else as_completed(futures)
1622 )
1623 for future in iterable:
1624 index = futures[future]
1625 try:
1626 result = future.result()
1627 except Exception as exc:
1628 if show_progress:
1629 tqdm.write(f"TARGET_TABLE 歌词处理失败: index={index}, error={exc}")
1630 continue
1631 if result is not None:
1632 item, manifest_row = result
1633 results.append((index, item, manifest_row))
1634
1635 results.sort(key=lambda result: result[0])
1636 items = [item for _, item, _ in results]
1637 manifest_rows = [manifest_row for _, _, manifest_row in results]
1638
1639 manifest_path = baseline_dir / 'target_table_baseline.csv'
1640 _write_csv(
1641 manifest_path,
1642 manifest_rows,
1643 ['id', 'name', 'singer', 'lyricist', 'composer', 'lyrics_url', 'local_lyrics_path'],
1644 show_progress=show_progress,
1645 desc='写基线清单',
1646 )
1647 if show_progress:
1648 tqdm.write(
1649 f"TARGET_TABLE 基线已保存: 有效={len(items)}/{len(rows)}, "
1650 f"manifest={manifest_path}, lyrics_dir={lyric_dir}"
1651 )
1652 return items, manifest_path
1653
1654
1655 def _fetch_source_lyric_records(limit: int, offset: int, show_progress: bool = True) -> list[dict[str, Any]]:
1656 sql = AGGREGATE_SQL
1657 sql += f"\nLIMIT {limit}"
1658 if offset:
1659 sql += f"\nOFFSET {offset}"
1660
1661 if show_progress:
1662 tqdm.write(f"查询源库歌词: limit={limit}, offset={offset}")
1663 conn = pymysql.connect(**SOURCE_DB_CONFIG)
1664 try:
1665 with conn.cursor() as cursor:
1666 cursor.execute(sql)
1667 rows = cursor.fetchall()
1668 finally:
1669 conn.close()
1670 if show_progress:
1671 tqdm.write(f"源库查询完成: {len(rows)} 条")
1672
1673 records: list[dict[str, Any]] = []
1674 iterable = _progress(rows, desc='整理源库', unit='条') if show_progress else rows
1675 for row in iterable:
1676 lyrics = row.get('lyrics_txt_content')
1677 if not lyrics or not lyrics.strip():
1678 continue
1679 if is_instrumental_lyrics(lyrics):
1680 continue
1681 record = LyricRecord(
1682 record_id=str(row['source_id']),
1683 lyrics=lyrics,
1684 title=row.get('name'),
1685 artist=row.get('singer'),
1686 lyricist=row.get('lyricist'),
1687 composer=row.get('composer'),
1688 )
1689 records.append({
1690 'record': record,
1691 'source_id': str(row['source_id']),
1692 'name': row.get('name') or '',
1693 'singer': row.get('singer') or '',
1694 'lyricist': row.get('lyricist') or '',
1695 'composer': row.get('composer') or '',
1696 })
1697 return records
1698
1699
1700 def _load_existing_lyric_records(
1701 limit: int,
1702 download_timeout: int,
1703 show_progress: bool = True,
1704 download_workers: int = 8,
1705 ) -> list[dict[str, Any]]:
1706 output_dir = REPORT_DIR / f"target_table_baseline_{time.strftime('%Y%m%d_%H%M%S')}"
1707 records, manifest_path = _download_target_table_baseline(
1708 output_dir,
1709 limit,
1710 download_timeout,
1711 download_workers=download_workers,
1712 show_progress=show_progress,
1713 )
1714 print(f"TARGET_TABLE baseline manifest: {manifest_path}")
1715 return records
1716
1717
1718 def _build_record_meta(items: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
1719 return {item['record'].record_id: item for item in items}
1720
1721
1722 def _candidate_match_to_row(match: CandidateMatch) -> dict[str, Any]:
1723 return {
1724 'candidate_decision': match.decision.value,
1725 'candidate_confidence': f"{match.confidence:.4f}",
1726 'candidate_jaccard': f"{match.jaccard:.4f}",
1727 'candidate_line_coverage': f"{match.line_coverage:.4f}",
1728 'candidate_primary_jaccard': f"{match.primary_jaccard:.4f}",
1729 'candidate_primary_line_coverage': f"{match.primary_line_coverage:.4f}",
1730 'candidate_reason': match.reason,
1731 }
1732
1733
1734 def _l1_metadata_match(left: dict[str, Any], right: dict[str, Any]) -> bool:
1735 return (
1736 bool(left.get('name'))
1737 and _normalize_meta(left.get('name')) == _normalize_meta(right.get('name'))
1738 and _normalize_meta(left.get('lyricist')) == _normalize_meta(right.get('lyricist'))
1739 and _normalize_meta(left.get('composer')) == _normalize_meta(right.get('composer'))
1740 )
1741
1742
1743 def _run_l2_retrieval_benchmark(
1744 source_items: list[dict[str, Any]],
1745 seed_items: list[dict[str, Any]],
1746 top_k: int,
1747 output_dir: Path,
1748 top_n: int,
1749 show_progress: bool = True,
1750 ) -> dict[str, Any]:
1751 checker = DuplicateChecker()
1752 index = L2CandidateIndex(checker, mode='topk', top_k=top_k)
1753 record_meta = _build_record_meta(seed_items)
1754 lyric_dir = output_dir / 'lyrics'
1755 lyric_paths: dict[str, str] = {}
1756
1757 seed_iterable = _progress(seed_items, desc=f'建索引 k={top_k}', unit='条', leave=False) if show_progress else seed_items
1758 for seed in seed_iterable:
1759 record = seed['record']
1760 index.add(record)
1761 lyric_paths[record.record_id] = seed.get('local_lyrics_path') or str(
1762 _write_lyrics_file(lyric_dir, 'candidate', record)
1763 )
1764
1765 retrieval_rows: list[dict[str, Any]] = []
1766 hit_rows: list[dict[str, Any]] = []
1767 decision_counts = {'duplicate': 0, 'review': 0, 'new': 0}
1768 total_recalled = 0
1769
1770 started = time.perf_counter()
1771 source_iterable = _progress(source_items, desc=f'L2 k={top_k}', unit='条') if show_progress else source_items
1772 for source in source_iterable:
1773 record = source['record']
1774 query_lyric_path = str(_write_lyrics_file(lyric_dir, 'query', record))
1775 recalled = index.recall(record)
1776 total_recalled += len(recalled)
1777 row = {
1778 'source_id': record.record_id,
1779 'lyrics_txt_content': record.lyrics,
1780 'name': record.title,
1781 'singer': record.artist,
1782 }
1783 decision, confidence, matched_id, reason = check_l2(row, checker, index)
1784 decision_counts[decision] += 1
1785 if show_progress:
1786 source_iterable.set_postfix(
1787 dup=decision_counts['duplicate'],
1788 rev=decision_counts['review'],
1789 new=decision_counts['new'],
1790 avg=f"{(total_recalled / max(sum(decision_counts.values()), 1)):.1f}",
1791 refresh=False,
1792 )
1793
1794 if decision in {'duplicate', 'review'}:
1795 hit_rows.append({
1796 'top_k': top_k,
1797 'query_source_id': source['source_id'],
1798 'query_name': source['name'],
1799 'query_lyricist': source['lyricist'],
1800 'query_composer': source['composer'],
1801 'query_lyrics_path': query_lyric_path,
1802 'decision': decision,
1803 'confidence': f"{confidence:.4f}",
1804 'matched_id': matched_id or '',
1805 'matched_name': record_meta.get(matched_id or '', {}).get('name', ''),
1806 'matched_lyricist': record_meta.get(matched_id or '', {}).get('lyricist', ''),
1807 'matched_composer': record_meta.get(matched_id or '', {}).get('composer', ''),
1808 'matched_lyrics_path': lyric_paths.get(matched_id or '', ''),
1809 'reason': reason,
1810 })
1811
1812 query_rank_result = checker.check_record_against_candidates(record, recalled, max_candidates=top_n)
1813 by_candidate_id = {match.record_id: match for match in query_rank_result.candidates}
1814 for rank, candidate in enumerate(recalled[:top_n], start=1):
1815 candidate_meta = record_meta.get(candidate.record_id, {})
1816 l1_match = _l1_metadata_match(source, candidate_meta)
1817 if candidate.record_id not in lyric_paths:
1818 lyric_paths[candidate.record_id] = str(_write_lyrics_file(lyric_dir, 'candidate', candidate))
1819 match = by_candidate_id.get(candidate.record_id)
1820 match_row = _candidate_match_to_row(match) if match else {
1821 'candidate_decision': '',
1822 'candidate_confidence': '',
1823 'candidate_jaccard': '',
1824 'candidate_line_coverage': '',
1825 'candidate_primary_jaccard': '',
1826 'candidate_primary_line_coverage': '',
1827 'candidate_reason': '未进入精排 topN',
1828 }
1829 retrieval_rows.append({
1830 'top_k': top_k,
1831 'rank': rank,
1832 'query_source_id': source['source_id'],
1833 'query_name': source['name'],
1834 'query_lyricist': source['lyricist'],
1835 'query_composer': source['composer'],
1836 'query_lyrics_path': query_lyric_path,
1837 'candidate_id': candidate.record_id,
1838 'candidate_name': candidate_meta.get('name', candidate.title or ''),
1839 'candidate_lyricist': candidate_meta.get('lyricist', ''),
1840 'candidate_composer': candidate_meta.get('composer', ''),
1841 'candidate_lyrics_path': lyric_paths[candidate.record_id],
1842 'l1_metadata_match': '1' if l1_match else '0',
1843 'l1_l2_conflict': '1' if (
1844 (decision == 'new' and l1_match)
1845 or (decision in {'duplicate', 'review'} and candidate.record_id == (matched_id or '') and not l1_match)
1846 ) else '0',
1847 **match_row,
1848 })
1849
1850 if decision == 'new':
1851 index.add(record)
1852 record_meta[record.record_id] = source
1853 lyric_paths[record.record_id] = query_lyric_path
1854
1855 elapsed = time.perf_counter() - started
1856 return {
1857 'top_k': top_k,
1858 'elapsed_seconds': elapsed,
1859 'throughput_per_second': len(source_items) / elapsed if elapsed > 0 else 0.0,
1860 'avg_recalled': total_recalled / len(source_items) if source_items else 0.0,
1861 'decision_counts': decision_counts,
1862 'retrieval_rows': retrieval_rows,
1863 'hit_rows': hit_rows,
1864 }
1865
1866
1867 def _write_csv(
1868 path: Path,
1869 rows: list[dict[str, Any]],
1870 fieldnames: list[str],
1871 show_progress: bool = False,
1872 desc: str | None = None,
1873 ) -> None:
1874 with open(path, 'w', encoding='utf-8', newline='') as f:
1875 writer = csv.DictWriter(f, fieldnames=fieldnames)
1876 writer.writeheader()
1877 iterable = _progress(rows, desc=desc or f'写 {path.name}', unit='行') if show_progress else rows
1878 for row in iterable:
1879 writer.writerow(row)
1880
1881
1882 def _summarize_l1_l2_conflicts(rows: list[dict[str, Any]]) -> dict[str, int]:
1883 conflict_rows = [row for row in rows if row.get('l1_l2_conflict') == '1']
1884 l1_hit_l2_new_rows = [
1885 row for row in conflict_rows
1886 if (row.get('candidate_decision') or row.get('decision') or '') == 'new'
1887 and row.get('l1_metadata_match') == '1'
1888 ]
1889 l2_hit_l1_miss_rows = [
1890 row for row in conflict_rows
1891 if (row.get('candidate_decision') or row.get('decision') or '') in {'duplicate', 'review'}
1892 and row.get('l1_metadata_match') != '1'
1893 ]
1894 return {
1895 'l1_l2_conflict_row_count': len(conflict_rows),
1896 'l1_l2_conflict_query_count': len({row.get('query_source_id', '') for row in conflict_rows}),
1897 'l1_hit_l2_new_row_count': len(l1_hit_l2_new_rows),
1898 'l1_hit_l2_new_query_count': len({row.get('query_source_id', '') for row in l1_hit_l2_new_rows}),
1899 'l2_hit_l1_miss_row_count': len(l2_hit_l1_miss_rows),
1900 'l2_hit_l1_miss_query_count': len({row.get('query_source_id', '') for row in l2_hit_l1_miss_rows}),
1901 }
1902
1903
1904 def _write_l2_benchmark_reports(
1905 summary_rows: list[dict[str, Any]],
1906 retrieval_rows: list[dict[str, Any]],
1907 hit_rows: list[dict[str, Any]],
1908 show_progress: bool = True,
1909 ) -> tuple[Path, Path, Path]:
1910 timestamp = time.strftime('%Y%m%d_%H%M%S')
1911 summary_path = REPORT_DIR / f'l2_topk_benchmark_summary_{timestamp}.csv'
1912 retrieval_path = REPORT_DIR / f'l2_topk_retrieval_top10_{timestamp}.csv'
1913 hits_path = REPORT_DIR / f'l2_topk_duplicate_hits_{timestamp}.csv'
1914
1915 _write_csv(summary_path, summary_rows, list(summary_rows[0].keys()), show_progress, '写入 L2 汇总报告')
1916 retrieval_fields = [
1917 'top_k', 'rank',
1918 'query_source_id', 'query_name', 'query_lyricist', 'query_composer', 'query_lyrics_path',
1919 'candidate_id', 'candidate_name', 'candidate_lyricist', 'candidate_composer', 'candidate_lyrics_path',
1920 'l1_metadata_match', 'l1_l2_conflict',
1921 'candidate_decision', 'candidate_confidence', 'candidate_jaccard', 'candidate_line_coverage',
1922 'candidate_primary_jaccard', 'candidate_primary_line_coverage', 'candidate_reason',
1923 ]
1924 hit_fields = [
1925 'top_k',
1926 'query_source_id', 'query_name', 'query_lyricist', 'query_composer', 'query_lyrics_path',
1927 'decision', 'confidence', 'matched_id', 'matched_name', 'matched_lyricist',
1928 'matched_composer', 'matched_lyrics_path', 'reason',
1929 ]
1930 _write_csv(retrieval_path, retrieval_rows, retrieval_fields, show_progress, '写入 L2 召回明细')
1931 _write_csv(hits_path, hit_rows, hit_fields, show_progress, '写入 L2 命中明细')
1932
1933 return summary_path, retrieval_path, hits_path
1934
1935
1936 # ====================================================================
1937 # 手动 L2 检索效率/人工复核材料评测
1938 # ====================================================================
1939
1940 @pytest.mark.skipif(
1941 not _env_bool('RUN_L2_BENCHMARK'),
1942 reason='设置 RUN_L2_BENCHMARK=1 才会连接数据库并运行 L2 topK 评测',
1943 )
1944 class TestL2Benchmark:
1945 """只测试歌词下载和 L2 去重,不上传/下载音频、封面等资源,不写库。"""
1946
1947 def test_l2_recall_topk_efficiency_and_review_artifacts(self):
1948 limit = _env_int('L2_BENCHMARK_LIMIT', 200)
1949 offset = _env_int('L2_BENCHMARK_OFFSET', 0)
1950 topks = _parse_topks(os.getenv('L2_BENCHMARK_TOPKS'))
1951 top_n = _env_int('L2_BENCHMARK_RETRIEVAL_TOP_N', 10)
1952 load_existing = _env_bool('L2_BENCHMARK_LOAD_EXISTING', True)
1953 existing_limit = _env_int('L2_BENCHMARK_EXISTING_LIMIT', 500)
1954 download_timeout = _env_int('L2_BENCHMARK_DOWNLOAD_TIMEOUT', 10)
1955 download_workers = _env_int('L2_BENCHMARK_DOWNLOAD_WORKERS', 8)
1956 show_progress = _env_bool('L2_BENCHMARK_PROGRESS', True)
1957 output_dir = REPORT_DIR / f"l2_topk_benchmark_assets_{time.strftime('%Y%m%d_%H%M%S')}"
1958
1959 baseline_manifest_path = None
1960 if load_existing:
1961 seed_records, baseline_manifest_path = _download_target_table_baseline(
1962 output_dir,
1963 existing_limit,
1964 download_timeout,
1965 download_workers=download_workers,
1966 show_progress=show_progress,
1967 )
1968 else:
1969 seed_records = []
1970 print(f"L2 benchmark source query: limit={limit}, offset={offset}")
1971 records = _fetch_source_lyric_records(limit, offset, show_progress=show_progress)
1972 assert records, '源库查询结果中没有可评测的歌词内容'
1973 print(f"L2 benchmark loaded: source_records={len(records)}, seed_records={len(seed_records)}, topks={topks}")
1974 if baseline_manifest_path:
1975 print(f"TARGET_TABLE baseline manifest: {baseline_manifest_path}")
1976
1977 summary_rows = []
1978 retrieval_rows = []
1979 hit_rows = []
1980
1981 topk_iterable = _progress(topks, desc='topK配置', unit='组') if show_progress else topks
1982 for top_k in topk_iterable:
1983 result = _run_l2_retrieval_benchmark(
1984 records,
1985 seed_records,
1986 top_k,
1987 output_dir,
1988 top_n,
1989 show_progress=show_progress,
1990 )
1991 decision_counts = result['decision_counts']
1992 conflict_counts = _summarize_l1_l2_conflicts(result['retrieval_rows'])
1993 summary_rows.append({
1994 'mode': 'topk',
1995 'top_k': top_k,
1996 'source_records': len(records),
1997 'seed_records': len(seed_records),
1998 'retrieval_top_n': top_n,
1999 'download_workers': download_workers,
2000 'elapsed_seconds': f"{result['elapsed_seconds']:.4f}",
2001 'throughput_per_second': f"{result['throughput_per_second']:.2f}",
2002 'avg_recalled_candidates': f"{result['avg_recalled']:.2f}",
2003 'duplicate_count': decision_counts['duplicate'],
2004 'review_count': decision_counts['review'],
2005 'new_count': decision_counts['new'],
2006 'hit_count': decision_counts['duplicate'] + decision_counts['review'],
2007 **conflict_counts,
2008 'assets_dir': str(output_dir),
2009 'target_table_baseline_manifest': str(baseline_manifest_path or ''),
2010 })
2011 retrieval_rows.extend(result['retrieval_rows'])
2012 hit_rows.extend(result['hit_rows'])
2013 if show_progress:
2014 topk_iterable.set_postfix(
2015 top_k=top_k,
2016 dup=decision_counts['duplicate'],
2017 rev=decision_counts['review'],
2018 new=decision_counts['new'],
2019 refresh=False,
2020 )
2021
2022 summary_path, retrieval_path, hits_path = _write_l2_benchmark_reports(
2023 summary_rows,
2024 retrieval_rows,
2025 hit_rows,
2026 show_progress=show_progress,
2027 )
2028 print(f"\nL2 benchmark summary: {summary_path}")
2029 print(f"L2 benchmark retrieval top{top_n}: {retrieval_path}")
2030 print(f"L2 benchmark duplicate/review hits: {hits_path}")
2031 print(f"L2 benchmark lyric assets: {output_dir}")
2032 for row in summary_rows:
2033 print(row)
2034
2035
2036 if __name__ == '__main__':
2037 pytest.main([__file__, '-v', '--tb=short'])
1 # L2 歌词去重测试流程指南
2
3 本文档说明如何运行 L2 歌词召回测试、查看测试产物,并启动前端页面做人工复核。
4
5 ## 1. 运行 L2 测试脚本
6
7 在项目根目录执行:
8
9 ```bash
10 RUN_L2_BENCHMARK=1 \
11 L2_BENCHMARK_LIMIT=20000000 \
12 L2_BENCHMARK_OFFSET=0 \
13 L2_BENCHMARK_LOAD_EXISTING=1 \
14 L2_BENCHMARK_EXISTING_LIMIT=50000 \
15 L2_BENCHMARK_DOWNLOAD_WORKERS=32 \
16 L2_BENCHMARK_TOPKS=20 \
17 L2_BENCHMARK_RETRIEVAL_TOP_N=10 \
18 python -m pytest test_dedup.py::TestL2Benchmark::test_l2_recall_topk_efficiency_and_review_artifacts -q -s
19 ```
20
21 这个测试只处理歌词:
22
23 - 从源库读取待导入歌词。
24 - 可选从目标测试库读取已有 `lyrics_url` 并下载歌词文件。
25 - 不下载或上传音频、封面、伴奏、曲谱等资源。
26 - 不写入数据库。
27 - 对不同 `top_k` 召回配置输出效率指标和人工复核材料。
28
29 ## 2. 参数说明
30
31 | 参数 | 默认值 | 说明 |
32 | --------------------------------- | --------------------- | ------------------------------------------------------------------------------- |
33 | `RUN_L2_BENCHMARK` | 未开启 | 必须设为 `1` 才会运行联网 benchmark;否则 pytest 会跳过该测试。 |
34 | `L2_BENCHMARK_LIMIT` | `200` | 从源库查询多少条待评测歌曲。 |
35 | `L2_BENCHMARK_OFFSET` | `0` | 源库查询偏移量,用于分段抽样。 |
36 | `L2_BENCHMARK_LOAD_EXISTING` | `1` | 是否加载目标测试库已有歌词作为历史候选。设为 `0` 时只测试本批新歌之间的召回。 |
37 | `L2_BENCHMARK_EXISTING_LIMIT` | `500` | 从目标测试库加载多少条已有歌词候选;设为 `0` 表示不加 SQL `LIMIT`。 |
38 | `L2_BENCHMARK_TOPKS` | `20,50,100,200,500` | 要对比的 topK 召回候选规模,逗号分隔。 |
39 | `L2_BENCHMARK_RETRIEVAL_TOP_N` | `10` | 每条新歌在结果表中保留前多少个候选。前端当前按 top10 展示。 |
40 | `L2_BENCHMARK_DOWNLOAD_TIMEOUT` | `10` | 下载已有歌词文件的超时时间,单位秒。 |
41
42 ## 3. 输出结果
43
44 测试完成后会在 `output/reports/` 下生成:
45
46 ```text
47 l2_topk_benchmark_summary_YYYYMMDD_HHMMSS.csv
48 l2_topk_retrieval_top10_YYYYMMDD_HHMMSS.csv
49 l2_topk_duplicate_hits_YYYYMMDD_HHMMSS.csv
50 l2_topk_benchmark_assets_YYYYMMDD_HHMMSS/lyrics/
51 ```
52
53 各文件含义:
54
55 | 文件 | 说明 |
56 | -------------------------------------- | -------------------------------------------------------------------------------------------------------- |
57 | `l2_topk_benchmark_summary_*.csv` | 每个 topK 的效率汇总,包括耗时、吞吐、平均召回候选数、duplicate/review/new/hit 数量。 |
58 | `l2_topk_retrieval_top10_*.csv` | 每条新歌的 topN 召回候选明细,包含新歌和候选的歌名、歌手、词曲作者、歌词文件路径、相似度指标、判定原因。 |
59 | `l2_topk_duplicate_hits_*.csv` | 只保留命中 `duplicate``review` 的样本,适合人工重点复核。 |
60 | `l2_topk_benchmark_assets_*/lyrics/` | 新歌和候选歌词正文文件。CSV 中只保留路径,不直接塞歌词全文。 |
61
62 `l2_topk_retrieval_top10_*.csv` 还包含:
63
64 - `l1_metadata_match`:新歌与候选按 L1 元数据规则(歌名、作词人、作曲人)是否命中。
65 - `l1_l2_conflict`:L1 与 L2 结论是否冲突。比如 L2 判新歌但召回候选中有 L1 命中,或 L2 判重复但命中候选 L1 未命中。
66
67 ## 4. 启动前端页面
68
69 运行:
70
71 ```bash
72 python serve_l2_dashboard.py --host 127.0.0.1 --port 8765
73 ```
74
75 然后打开:
76
77 ```text
78 http://127.0.0.1:8765
79 ```
80
81 前端会自动扫描 `output/reports/` 下成套的 benchmark 结果文件。
82
83 ## 5. 前端使用方式
84
85 页面主要功能:
86
87 - 选择不同测试 run。
88 - 切换不同 `top_k`
89 - 查看效率指标:耗时、吞吐、平均召回候选数、duplicate/review/new/hit 数量。
90 - 在“召回样本”中查看每条新歌的 topN 候选。
91 - 在“命中样本”中只查看 `duplicate` / `review` 样本。
92 - 左右并排查看新歌歌词和候选歌词。
93 - 高亮 L1/L2 冲突样本。
94 - 对样本做人工标注:确认重复、确认不重复、待确认。
95 - 点击“导出标注”导出当前 run、topK 和当前视图下的人工标注 CSV。
96 - 按歌名、歌手、ID 搜索样本。
97
98 如果刚跑完新测试但页面没有更新,点击页面右上角“刷新”。
99
100 ## 6. 推荐测试方式
101
102 先用小样本确认流程:
103
104 ```bash
105 RUN_L2_BENCHMARK=1 \
106 L2_BENCHMARK_LIMIT=20 \
107 L2_BENCHMARK_EXISTING_LIMIT=50 \
108 L2_BENCHMARK_TOPKS=20,100 \
109 python -m pytest test_dedup.py::TestL2Benchmark::test_l2_recall_topk_efficiency_and_review_artifacts -q -s
110 ```
111
112 再扩大样本:
113
114 ```bash
115 RUN_L2_BENCHMARK=1 \
116 L2_BENCHMARK_LIMIT=1000 \
117 L2_BENCHMARK_EXISTING_LIMIT=5000 \
118 L2_BENCHMARK_TOPKS=20,50,100,200,500 \
119 python -m pytest test_dedup.py::TestL2Benchmark::test_l2_recall_topk_efficiency_and_review_artifacts -q -s
120 ```
121
122 如果目标库已有歌词很多,`L2_BENCHMARK_EXISTING_LIMIT=0` 会加载全部候选,运行时间和歌词下载时间会明显增加。
123
124 ## 7. 普通单元测试
125
126 运行全部普通测试:
127
128 ```bash
129 python -m pytest test_dedup.py -q
130 ```
131
132 默认情况下,L2 benchmark 会被跳过,不会联网。
1 音眼数据库配置:
2
3 测试:rm-bp18h64ad9ak4d7h5do.mysql.rds.aliyuncs.com 3306 root Hikoon123! hikoon-data-test
4
5 正式:username: yinyan360
6 password: yinyan_hikoon!@#6699 内网: rm-bp10xcwu0930i0h00.mysql.rds.aliyuncs.com 外网:rm-bp10xcwu0930i0h00ro.mysql.rds.aliyuncs.com 端口:3306
...\ No newline at end of file ...\ No newline at end of file