Commit 666fd63f 666fd63f37d1b16b94ae2388cddb880b0efdad1e by 沈秋雨

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参数避免重复缓存加载
1 parent 3f2cfa61
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()
...@@ -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
......
...@@ -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 }]
......
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