feat(audit): 添加hk_songs重复数据审计及处理脚本
- 新增 audit_hk_songs_duplicates.py,支持基于name、lyricist和composer字段检测重复数据 - 支持生成重复组和重复详情的SQL查询语句 - 实现重复数据的合并计划生成及软删除重复记录功能 - 支持修复暂存表中merge跳过的记录,实现existing_into_new和new_into_existing的合并处理 - 增加命令行参数,支持输出CSV、应用合并和修复合并方向处理 feat(backfill): 增量补全bpm_class字段脚本 - 新增 backfill_bpm_class.py,从源库获取录音BPM并映射为bpm_class分类 - 支持指定目标表和每批处理条数,支持dry-run模式仅打印计划 - 采用主版本优先策略查询录音BPM数据,跳过无效或异常BPM fix(import): 优化歌词处理和缓存逻辑 - 判断歌词文本是否实质为空,避免无效歌词上传OSS - 修正合并逻辑中merge_direction对staging状态和导入ID的设置 - 扩展原声正则匹配规则,增加用户创作原声匹配 - 新增构建歌词缓存项和追加写入缓存函数,减少重复下载 - 调整load_existing_l2_candidates增加seen_keys参数避免重复缓存加载
Showing
9 changed files
with
294 additions
and
3 deletions
audit_hk_songs_duplicates.py
0 → 100644
This diff is collapsed.
Click to expand it.
backfill_bpm_class.py
0 → 100644
| 1 | #!/usr/bin/env python3 | ||
| 2 | """增量补全 hk_songs_test(或任意目标表)中缺失的 bpm_class 字段。 | ||
| 3 | |||
| 4 | 从源库 hk_music_record_state.bpm 取主版本录音的 BPM 值,按以下规则映射: | ||
| 5 | bpm = 0 或 NULL → NULL(跳过) | ||
| 6 | bpm < 80 → 1(慢歌) | ||
| 7 | 80 ≤ bpm ≤ 120 → 2(中速) | ||
| 8 | bpm > 120 → 3(快歌) | ||
| 9 | |||
| 10 | 用法: | ||
| 11 | python backfill_bpm_class.py # 默认更新 hk_songs_test | ||
| 12 | python backfill_bpm_class.py --table hk_songs # 更新其他表 | ||
| 13 | python backfill_bpm_class.py --dry-run # 只打印,不写库 | ||
| 14 | python backfill_bpm_class.py --batch-size 500 # 每批处理条数(默认200) | ||
| 15 | """ | ||
| 16 | |||
| 17 | from __future__ import annotations | ||
| 18 | |||
| 19 | import argparse | ||
| 20 | import logging | ||
| 21 | import os | ||
| 22 | |||
| 23 | import pymysql | ||
| 24 | from dotenv import load_dotenv | ||
| 25 | from tqdm import tqdm | ||
| 26 | |||
| 27 | load_dotenv(os.path.join(os.path.dirname(__file__), ".env")) | ||
| 28 | |||
| 29 | logging.basicConfig( | ||
| 30 | level=logging.INFO, | ||
| 31 | format="%(asctime)s %(levelname)s %(message)s", | ||
| 32 | datefmt="%H:%M:%S", | ||
| 33 | ) | ||
| 34 | logger = logging.getLogger(__name__) | ||
| 35 | |||
| 36 | TARGET_DB_CONFIG = { | ||
| 37 | "host": os.getenv("TARGET_DB_HOST"), | ||
| 38 | "port": int(os.getenv("TARGET_DB_PORT", 3306)), | ||
| 39 | "user": os.getenv("TARGET_DB_USER"), | ||
| 40 | "password": os.getenv("TARGET_DB_PASSWORD"), | ||
| 41 | "database": os.getenv("TARGET_DB_NAME"), | ||
| 42 | "charset": "utf8mb4", | ||
| 43 | "cursorclass": pymysql.cursors.DictCursor, | ||
| 44 | } | ||
| 45 | |||
| 46 | SOURCE_DB_CONFIG = { | ||
| 47 | "host": os.getenv("SOURCE_DB_HOST"), | ||
| 48 | "port": int(os.getenv("SOURCE_DB_PORT", 3306)), | ||
| 49 | "user": os.getenv("SOURCE_DB_USER"), | ||
| 50 | "password": os.getenv("SOURCE_DB_PASSWORD"), | ||
| 51 | "database": os.getenv("SOURCE_DB_NAME"), | ||
| 52 | "charset": "utf8mb4", | ||
| 53 | "cursorclass": pymysql.cursors.DictCursor, | ||
| 54 | } | ||
| 55 | |||
| 56 | # 主版本优先:同一首歌取 is_main_version=1 的录音,没有则取 min(record_id) | ||
| 57 | BPM_QUERY = """ | ||
| 58 | SELECT sp.id AS song_id, rs.bpm | ||
| 59 | FROM hk_song_platform sp | ||
| 60 | INNER JOIN ( | ||
| 61 | SELECT song_id, | ||
| 62 | COALESCE( | ||
| 63 | MIN(CASE WHEN is_main_version = 1 THEN record_id END), | ||
| 64 | MIN(record_id) | ||
| 65 | ) AS record_id | ||
| 66 | FROM hk_song_and_record | ||
| 67 | GROUP BY song_id | ||
| 68 | ) sr ON sr.song_id = sp.id | ||
| 69 | LEFT JOIN hk_music_record r | ||
| 70 | ON r.id = sr.record_id AND r.deleted = 0x00 | ||
| 71 | LEFT JOIN hk_music_record_state rs | ||
| 72 | ON rs.record_id = r.id AND rs.deleted = 0x00 | ||
| 73 | WHERE sp.id IN ({placeholders}) | ||
| 74 | """ | ||
| 75 | |||
| 76 | |||
| 77 | def bpm_to_class(bpm_str: str | None) -> int | None: | ||
| 78 | if not bpm_str: | ||
| 79 | return None | ||
| 80 | try: | ||
| 81 | bpm = int(float(bpm_str)) | ||
| 82 | except (ValueError, TypeError): | ||
| 83 | return None | ||
| 84 | if bpm <= 0: | ||
| 85 | return None | ||
| 86 | if bpm < 80: | ||
| 87 | return 1 | ||
| 88 | if bpm <= 120: | ||
| 89 | return 2 | ||
| 90 | return 3 | ||
| 91 | |||
| 92 | |||
| 93 | def fetch_bpm_map(src_conn, song_ids: list[str]) -> dict[str, int | None]: | ||
| 94 | """从源库批量查 bpm,返回 {source_song_id: bpm_class}。""" | ||
| 95 | if not song_ids: | ||
| 96 | return {} | ||
| 97 | placeholders = ",".join(["%s"] * len(song_ids)) | ||
| 98 | sql = BPM_QUERY.format(placeholders=placeholders) | ||
| 99 | with src_conn.cursor() as cur: | ||
| 100 | cur.execute(sql, song_ids) | ||
| 101 | rows = cur.fetchall() | ||
| 102 | return {str(r["song_id"]): bpm_to_class(r["bpm"]) for r in rows} | ||
| 103 | |||
| 104 | |||
| 105 | def backfill(table: str, batch_size: int, dry_run: bool) -> None: | ||
| 106 | tgt = pymysql.connect(**TARGET_DB_CONFIG) | ||
| 107 | src = pymysql.connect(**SOURCE_DB_CONFIG) | ||
| 108 | |||
| 109 | try: | ||
| 110 | # 查需要补全的记录总数 | ||
| 111 | with tgt.cursor() as cur: | ||
| 112 | cur.execute( | ||
| 113 | f"SELECT COUNT(*) AS cnt FROM `{table}` " | ||
| 114 | f"WHERE bpm_class IS NULL AND source_song_id IS NOT NULL AND deleted = '0'" | ||
| 115 | ) | ||
| 116 | total = cur.fetchone()["cnt"] | ||
| 117 | |||
| 118 | logger.info(f"待补全 bpm_class 的记录数: {total},表: {table}") | ||
| 119 | if total == 0: | ||
| 120 | logger.info("无需更新,退出。") | ||
| 121 | return | ||
| 122 | |||
| 123 | updated = no_bpm = 0 | ||
| 124 | last_id = 0 # 用 id > last_id 游标代替 OFFSET,避免跳过无bpm记录 | ||
| 125 | |||
| 126 | with tqdm(total=total, unit="条") as bar: | ||
| 127 | while True: | ||
| 128 | with tgt.cursor() as cur: | ||
| 129 | cur.execute( | ||
| 130 | f"SELECT id, source_song_id FROM `{table}` " | ||
| 131 | f"WHERE bpm_class IS NULL AND source_song_id IS NOT NULL AND deleted = '0' " | ||
| 132 | f"AND id > %s ORDER BY id LIMIT %s", | ||
| 133 | (last_id, batch_size), | ||
| 134 | ) | ||
| 135 | rows = cur.fetchall() | ||
| 136 | |||
| 137 | if not rows: | ||
| 138 | break | ||
| 139 | |||
| 140 | last_id = rows[-1]["id"] | ||
| 141 | song_ids = [r["source_song_id"] for r in rows] | ||
| 142 | bpm_map = fetch_bpm_map(src, song_ids) | ||
| 143 | |||
| 144 | for row in rows: | ||
| 145 | sid = row["source_song_id"] | ||
| 146 | bpm_class = bpm_map.get(str(sid)) | ||
| 147 | |||
| 148 | if bpm_class is None: | ||
| 149 | no_bpm += 1 | ||
| 150 | bar.update(1) | ||
| 151 | continue | ||
| 152 | |||
| 153 | if dry_run: | ||
| 154 | logger.debug(f"[DRY-RUN] id={row['id']} source_song_id={sid} -> bpm_class={bpm_class}") | ||
| 155 | updated += 1 | ||
| 156 | bar.update(1) | ||
| 157 | continue | ||
| 158 | |||
| 159 | with tgt.cursor() as cur: | ||
| 160 | cur.execute( | ||
| 161 | f"UPDATE `{table}` SET bpm_class = %s WHERE id = %s", | ||
| 162 | (bpm_class, row["id"]), | ||
| 163 | ) | ||
| 164 | tgt.commit() | ||
| 165 | updated += 1 | ||
| 166 | bar.update(1) | ||
| 167 | |||
| 168 | prefix = "[DRY-RUN] " if dry_run else "" | ||
| 169 | print(f"\n{'=' * 50}") | ||
| 170 | print(f"{prefix}bpm_class 补全完成") | ||
| 171 | print(f" 成功更新: {updated} 条") | ||
| 172 | print(f" 源库无bpm: {no_bpm} 条(保持 NULL)") | ||
| 173 | print(f" 合计处理: {updated + no_bpm} 条(共 {total} 条待补全)") | ||
| 174 | print(f"{'=' * 50}") | ||
| 175 | |||
| 176 | finally: | ||
| 177 | tgt.close() | ||
| 178 | src.close() | ||
| 179 | |||
| 180 | |||
| 181 | def main() -> None: | ||
| 182 | parser = argparse.ArgumentParser(description="增量补全 bpm_class 字段") | ||
| 183 | parser.add_argument("--table", default="hk_songs_test", help="目标表名(默认 hk_songs_test)") | ||
| 184 | parser.add_argument("--batch-size", type=int, default=200, help="每批处理条数(默认 200)") | ||
| 185 | parser.add_argument("--dry-run", action="store_true", help="只打印计划,不写库") | ||
| 186 | args = parser.parse_args() | ||
| 187 | backfill(args.table, args.batch_size, args.dry_run) | ||
| 188 | |||
| 189 | |||
| 190 | if __name__ == "__main__": | ||
| 191 | main() |
This diff is collapsed.
Click to expand it.
| ... | @@ -667,7 +667,8 @@ | ... | @@ -667,7 +667,8 @@ |
| 667 | metric('平均召回', row.avg_recalled_candidates || '-'), | 667 | metric('平均召回', row.avg_recalled_candidates || '-'), |
| 668 | metric('命中数', row.hit_count || '0'), | 668 | metric('命中数', row.hit_count || '0'), |
| 669 | metric('merge', row.duplicate_count || '0'), | 669 | metric('merge', row.duplicate_count || '0'), |
| 670 | metric('review', row.review_count || '0') | 670 | metric('review', row.review_count || '0'), |
| 671 | metric('skip', row.skip_count || '0') | ||
| 671 | ].join(''); | 672 | ].join(''); |
| 672 | } | 673 | } |
| 673 | 674 | ... | ... |
| ... | @@ -211,10 +211,17 @@ class DuplicateChecker: | ... | @@ -211,10 +211,17 @@ class DuplicateChecker: |
| 211 | decision = DuplicateDecision.REVIEW | 211 | decision = DuplicateDecision.REVIEW |
| 212 | confidence = 0.95 | 212 | confidence = 0.95 |
| 213 | reason = "原文哈希一致,但疑似整段翻译结构拆分置信度较低,需要人工复核" | 213 | reason = "原文哈希一致,但疑似整段翻译结构拆分置信度较低,需要人工复核" |
| 214 | elif not _has_enough_primary_text_chars( | 214 | elif not ( |
| 215 | _has_enough_primary_text_chars( | ||
| 215 | query.normalized, | 216 | query.normalized, |
| 216 | candidate.normalized, | 217 | candidate.normalized, |
| 217 | min_chars=self.exact_duplicate_min_primary_chars, | 218 | min_chars=self.exact_duplicate_min_primary_chars, |
| 219 | ) | ||
| 220 | or _has_enough_primary_lyrics( | ||
| 221 | query.normalized, | ||
| 222 | candidate.normalized, | ||
| 223 | min_lines=self.auto_duplicate_min_primary_lines, | ||
| 224 | ) | ||
| 218 | ): | 225 | ): |
| 219 | decision = DuplicateDecision.REVIEW | 226 | decision = DuplicateDecision.REVIEW |
| 220 | confidence = 0.95 | 227 | confidence = 0.95 | ... | ... |
migrate_test_to_prod.py
0 → 100644
This diff is collapsed.
Click to expand it.
| ... | @@ -103,7 +103,8 @@ def _staging_summary() -> list[dict[str, str]]: | ... | @@ -103,7 +103,8 @@ def _staging_summary() -> list[dict[str, str]]: |
| 103 | COUNT(DISTINCT source_song_id) AS total_count, | 103 | COUNT(DISTINCT source_song_id) AS total_count, |
| 104 | SUM(CASE WHEN dedup_action = 'new' THEN 1 ELSE 0 END) AS new_count, | 104 | SUM(CASE WHEN dedup_action = 'new' THEN 1 ELSE 0 END) AS new_count, |
| 105 | SUM(CASE WHEN dedup_action = 'merge' THEN 1 ELSE 0 END) AS merge_count, | 105 | SUM(CASE WHEN dedup_action = 'merge' THEN 1 ELSE 0 END) AS merge_count, |
| 106 | SUM(CASE WHEN dedup_action = 'review' THEN 1 ELSE 0 END) AS review_count | 106 | SUM(CASE WHEN dedup_action = 'review' THEN 1 ELSE 0 END) AS review_count, |
| 107 | SUM(CASE WHEN dedup_action = 'skip' THEN 1 ELSE 0 END) AS skip_count | ||
| 107 | FROM ( | 108 | FROM ( |
| 108 | SELECT source_song_id, dedup_action | 109 | SELECT source_song_id, dedup_action |
| 109 | FROM {TARGET_TABLE_NAME_TMP} | 110 | FROM {TARGET_TABLE_NAME_TMP} |
| ... | @@ -125,6 +126,7 @@ def _staging_summary() -> list[dict[str, str]]: | ... | @@ -125,6 +126,7 @@ def _staging_summary() -> list[dict[str, str]]: |
| 125 | "hit_count": str((row.get("merge_count") or 0) + (row.get("review_count") or 0)), | 126 | "hit_count": str((row.get("merge_count") or 0) + (row.get("review_count") or 0)), |
| 126 | "duplicate_count": str(row.get("merge_count") or 0), | 127 | "duplicate_count": str(row.get("merge_count") or 0), |
| 127 | "review_count": str(row.get("review_count") or 0), | 128 | "review_count": str(row.get("review_count") or 0), |
| 129 | "skip_count": str(row.get("skip_count") or 0), | ||
| 128 | "new_count": str(row.get("new_count") or 0), | 130 | "new_count": str(row.get("new_count") or 0), |
| 129 | "total_count": str(row.get("total_count") or 0), | 131 | "total_count": str(row.get("total_count") or 0), |
| 130 | }] | 132 | }] | ... | ... |
test_audit_hk_songs_duplicates.py
0 → 100644
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Tests for hk_songs duplicate audit SQL/report helpers.""" | ||
| 3 | |||
| 4 | from audit_hk_songs_duplicates import ( | ||
| 5 | build_duplicate_detail_sql, | ||
| 6 | build_duplicate_group_sql, | ||
| 7 | build_merge_plan, | ||
| 8 | build_soft_delete_sql, | ||
| 9 | summarize_duplicate_groups, | ||
| 10 | ) | ||
| 11 | |||
| 12 | |||
| 13 | def test_group_sql_uses_name_lyricist_composer_and_deleted_filter(): | ||
| 14 | sql = build_duplicate_group_sql("hk_songs_test", include_deleted=False, limit=20) | ||
| 15 | |||
| 16 | assert "`hk_songs_test`" in sql | ||
| 17 | assert "COALESCE(NULLIF(TRIM(name), ''), '') COLLATE utf8mb4_bin" in sql | ||
| 18 | assert "COALESCE(NULLIF(TRIM(lyricist), ''), '') COLLATE utf8mb4_bin" in sql | ||
| 19 | assert "COALESCE(NULLIF(TRIM(composer), ''), '') COLLATE utf8mb4_bin" in sql | ||
| 20 | assert "WHERE deleted = '0'" in sql | ||
| 21 | assert "HAVING COUNT(*) > 1" in sql | ||
| 22 | assert "LIMIT 20" in sql | ||
| 23 | |||
| 24 | |||
| 25 | def test_group_sql_can_use_database_default_case_insensitive_collation(): | ||
| 26 | sql = build_duplicate_group_sql("hk_songs_test", case_sensitive=False) | ||
| 27 | |||
| 28 | assert "COLLATE utf8mb4_bin" not in sql | ||
| 29 | |||
| 30 | |||
| 31 | def test_detail_sql_joins_duplicate_groups_back_to_rows(): | ||
| 32 | sql = build_duplicate_detail_sql("hk_songs_test", include_deleted=True, limit=None) | ||
| 33 | |||
| 34 | assert "JOIN (" in sql | ||
| 35 | assert "dup.name_key" in sql | ||
| 36 | assert "s.name_key" in sql | ||
| 37 | assert "WHERE deleted = '0'" not in sql | ||
| 38 | assert "LIMIT" not in sql | ||
| 39 | |||
| 40 | |||
| 41 | def test_summary_counts_groups_rows_and_extra_duplicates(): | ||
| 42 | groups = [ | ||
| 43 | {"duplicate_count": 2}, | ||
| 44 | {"duplicate_count": 5}, | ||
| 45 | ] | ||
| 46 | |||
| 47 | summary = summarize_duplicate_groups(groups) | ||
| 48 | |||
| 49 | assert summary == { | ||
| 50 | "duplicate_groups": 2, | ||
| 51 | "duplicate_rows": 7, | ||
| 52 | "extra_duplicate_rows": 5, | ||
| 53 | } | ||
| 54 | |||
| 55 | |||
| 56 | def test_merge_plan_keeps_row_with_largest_record_count(): | ||
| 57 | rows = [ | ||
| 58 | {"id": 10, "name": "A", "lyricist": "L", "composer": "C", "source_song_id": "s10", "record_count": 1}, | ||
| 59 | {"id": 11, "name": "A", "lyricist": "L", "composer": "C", "source_song_id": "s11", "record_count": 3}, | ||
| 60 | {"id": 12, "name": "B", "lyricist": "", "composer": "", "source_song_id": "s12", "record_count": None}, | ||
| 61 | {"id": 13, "name": "B", "lyricist": None, "composer": None, "source_song_id": "s13", "record_count": 2}, | ||
| 62 | ] | ||
| 63 | |||
| 64 | plan = build_merge_plan(rows) | ||
| 65 | |||
| 66 | assert [item["survivor_id"] for item in plan] == [11, 13] | ||
| 67 | assert [item["loser_id"] for item in plan] == [10, 12] | ||
| 68 | assert plan[0]["survivor_record_count"] == 3 | ||
| 69 | assert plan[0]["loser_record_count"] == 1 | ||
| 70 | |||
| 71 | |||
| 72 | def test_merge_plan_uses_lowest_id_as_tie_breaker(): | ||
| 73 | rows = [ | ||
| 74 | {"id": 22, "name": "A", "lyricist": "L", "composer": "C", "source_song_id": "s22", "record_count": 2}, | ||
| 75 | {"id": 21, "name": "A", "lyricist": "L", "composer": "C", "source_song_id": "s21", "record_count": 2}, | ||
| 76 | ] | ||
| 77 | |||
| 78 | plan = build_merge_plan(rows) | ||
| 79 | |||
| 80 | assert plan[0]["survivor_id"] == 21 | ||
| 81 | assert plan[0]["loser_id"] == 22 | ||
| 82 | |||
| 83 | |||
| 84 | def test_soft_delete_sql_updates_only_planned_losers(): | ||
| 85 | sql = build_soft_delete_sql("hk_songs_test", 3) | ||
| 86 | |||
| 87 | assert "UPDATE `hk_songs_test`" in sql | ||
| 88 | assert "SET deleted = '1'" in sql | ||
| 89 | assert "WHERE deleted = '0'" in sql | ||
| 90 | assert "id IN (%s,%s,%s)" in sql |
This diff is collapsed.
Click to expand it.
-
Please register or sign in to post a comment