初始化项目:词曲 & 音眼资产导入工具
- 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
Showing
16 changed files
with
7412 additions
and
0 deletions
.env.example
0 → 100644
| 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= |
.gitignore
0 → 100644
README.md
0 → 100644
| 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 等数据文件不纳入版本控制。 |
import_hk_songs.py
0 → 100644
| 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() |
l2_review_dashboard.html
0 → 100644
| 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 | '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' | ||
| 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> |
lyric_dedup/__init__.py
0 → 100644
| 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 | ] |
lyric_dedup/checker.py
0 → 100644
| 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 |
lyric_dedup/cli.py
0 → 100644
| 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() |
lyric_dedup/eval_dataset.py
0 → 100644
| 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 |
lyric_dedup/file_import.py
0 → 100644
| 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 |
lyric_dedup/normalization.py
0 → 100644
| 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 |
requirements.txt
0 → 100644
serve_l2_dashboard.py
0 → 100644
| 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) |
test_dedup.py
0 → 100644
| 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']) |
测试流程指南.md
0 → 100644
| 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 会被跳过,不会联网。 |
音眼数据库配置.md
0 → 100644
| 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 |
-
Please register or sign in to post a comment