Commit 25cb261f 25cb261f8c1c02a825a9193fee1c824006c15b19 by 沈秋雨

feat(dedup): 优化跨库去重合并逻辑与审核界面交互

- 统一导入标记歌曲状态为已下架,防止错误上架
- 升级目标库 L1 索引加载,支持合并方向决策映射
- 从源库加载歌曲关联录音数量,用于合并优先级判断
- L2 去重判定增强歌词质量判断,缺失歌词强制人工复核
- 新增基于录音数量决定新老记录合并方向的逻辑
- 改进批量导入流程,L1/L2 去重优先,避免费时 OSS 上传
- 支持跳过部分媒体资源 OSS 上传,歌词仍可上传 OSS
- 人工审核数据写入流程优化,增加录音数量和合并方向字段
- 审核界面支持本地缓存合并、撤销审核、审核决策同步后端
- 调整界面标签和交互元素样式,提高用户体验与信息展示清晰度
- 音眼词曲去重任务清单文档新增,规范数据处理阶段与里程碑计划
1 parent 33d4579f
...@@ -139,11 +139,7 @@ SELECT ...@@ -139,11 +139,7 @@ SELECT
139 ELSE 0 139 ELSE 0
140 END AS review_status, 140 END AS review_status,
141 CASE WHEN rs.is_imputation = 1 THEN 2 ELSE 1 END AS in_status, 141 CASE WHEN rs.is_imputation = 1 THEN 2 ELSE 1 END AS in_status,
142 CASE 142 2 AS song_status, -- 统一标记为已下架,避免误上架
143 WHEN ss.is_grounding = 1 THEN 1
144 WHEN ss.is_grounding = 0 THEN 2
145 ELSE NULL
146 END AS song_status,
147 COALESCE(sp.create_time, r.create_time) AS commit_time, 143 COALESCE(sp.create_time, r.create_time) AS commit_time,
148 NULL AS review_time, 144 NULL AS review_time,
149 rs.ground_date AS shelf_time, 145 rs.ground_date AS shelf_time,
...@@ -164,15 +160,25 @@ SELECT ...@@ -164,15 +160,25 @@ SELECT
164 CAST(sp.id AS CHAR) AS source_song_id, 160 CAST(sp.id AS CHAR) AS source_song_id,
165 NULL AS lyric_archive_element_id, 161 NULL AS lyric_archive_element_id,
166 NULL AS melody_archive_element_id, 162 NULL AS melody_archive_element_id,
167 NULL AS audio_fingerprint 163 NULL AS audio_fingerprint,
164 sr_cnt.record_count
168 FROM hk_song_platform sp 165 FROM hk_song_platform sp
169 LEFT JOIN ( 166 INNER JOIN (
170 SELECT song_id, MIN(record_id) AS record_id 167 SELECT song_id,
168 COALESCE(
169 MIN(CASE WHEN is_main_version = 1 THEN record_id END),
170 MIN(record_id)
171 ) AS record_id
171 FROM hk_song_and_record 172 FROM hk_song_and_record
172 WHERE is_main_version = 1
173 GROUP BY song_id 173 GROUP BY song_id
174 ) sr 174 ) sr
175 ON sr.song_id = sp.id 175 ON sr.song_id = sp.id
176 LEFT JOIN (
177 SELECT song_id, COUNT(record_id) AS record_count
178 FROM hk_song_and_record
179 GROUP BY song_id
180 ) sr_cnt
181 ON sr_cnt.song_id = sp.id
176 LEFT JOIN hk_music_record r 182 LEFT JOIN hk_music_record r
177 ON r.id = sr.record_id 183 ON r.id = sr.record_id
178 AND r.deleted = b'0' 184 AND r.deleted = b'0'
...@@ -180,7 +186,7 @@ LEFT JOIN hk_music_record_state rs ...@@ -180,7 +186,7 @@ LEFT JOIN hk_music_record_state rs
180 ON rs.record_id = r.id 186 ON rs.record_id = r.id
181 AND rs.deleted = b'0' 187 AND rs.deleted = b'0'
182 LEFT JOIN hk_music_record_lyric rl 188 LEFT JOIN hk_music_record_lyric rl
183 ON rl.raw_record_id = r.raw_record_id 189 ON rl.record_id = sr.record_id
184 AND rl.deleted = b'0' 190 AND rl.deleted = b'0'
185 LEFT JOIN hk_song_state ss 191 LEFT JOIN hk_song_state ss
186 ON ss.song_id = sp.id 192 ON ss.song_id = sp.id
...@@ -237,6 +243,7 @@ INSERT INTO {table} ( ...@@ -237,6 +243,7 @@ INSERT INTO {table} (
237 off_shelf_remark, musician_id, commit_id, sheet_music, 243 off_shelf_remark, musician_id, commit_id, sheet_music,
238 commit_desc, price, source_table_name, source_song_id, 244 commit_desc, price, source_table_name, source_song_id,
239 lyric_archive_element_id, melody_archive_element_id, audio_fingerprint, 245 lyric_archive_element_id, melody_archive_element_id, audio_fingerprint,
246 record_count,
240 dedup_action, dedup_decision, dedup_confidence, matched_song_id, l1_matched_id, 247 dedup_action, dedup_decision, dedup_confidence, matched_song_id, l1_matched_id,
241 merge_authors, dedup_reason, biz_review_status, staging_status, imported_song_id 248 merge_authors, dedup_reason, biz_review_status, staging_status, imported_song_id
242 ) VALUES ( 249 ) VALUES (
...@@ -253,6 +260,7 @@ INSERT INTO {table} ( ...@@ -253,6 +260,7 @@ INSERT INTO {table} (
253 %s,%s,%s,%s, 260 %s,%s,%s,%s,
254 %s,%s,%s, 261 %s,%s,%s,
255 %s,%s,%s,%s,%s, 262 %s,%s,%s,%s,%s,
263 %s,
256 %s,%s,%s,%s,%s 264 %s,%s,%s,%s,%s
257 ) 265 )
258 ON DUPLICATE KEY UPDATE 266 ON DUPLICATE KEY UPDATE
...@@ -327,6 +335,9 @@ class SnowflakeIdGenerator: ...@@ -327,6 +335,9 @@ class SnowflakeIdGenerator:
327 335
328 ID_GENERATOR = SnowflakeIdGenerator() 336 ID_GENERATOR = SnowflakeIdGenerator()
329 337
338 # 合并方向决策用:目标库 id → 源库 source_song_id
339 _target_id_to_source_sid: dict[int, str] = {}
340
330 341
331 def get_oss_bucket(): 342 def get_oss_bucket():
332 """初始化 OSS Bucket""" 343 """初始化 OSS Bucket"""
...@@ -476,17 +487,23 @@ def _guess_ext(url, content_type, field_type): ...@@ -476,17 +487,23 @@ def _guess_ext(url, content_type, field_type):
476 return defaults.get(field_type, '') 487 return defaults.get(field_type, '')
477 488
478 489
479 def build_row_tuple(row, bucket, skip_oss=False): 490 def build_row_tuple(row, bucket, skip_oss=False, skip_media_oss=False):
480 """将源查询结果转为目标表行 tuple,处理 OSS 字段""" 491 """将源查询结果转为目标表行 tuple,处理 OSS 字段。
492
493 skip_oss: 全部跳过 OSS(包括歌词),直接透传源 URL。
494 skip_media_oss: 仅跳过媒体资源(音频/封面/曲谱等),歌词仍上传 OSS。
495 """
481 record_id = row['source_id'] 496 record_id = row['source_id']
482 497
483 # 处理 URL 字段 498 # 媒体资源:skip_oss 或 skip_media_oss 均跳过
484 audio_url = process_url_field(bucket, row.get('audio_url_source'), 'audio', record_id, skip_oss) 499 media_skip = skip_oss or skip_media_oss
485 accompany_url = process_url_field(bucket, row.get('accompany_url_source'), 'accompany', record_id, skip_oss) 500 audio_url = process_url_field(bucket, row.get('audio_url_source'), 'audio', record_id, media_skip)
486 creation_url = process_url_field(bucket, row.get('creation_url_source'), 'creation', record_id, skip_oss) 501 accompany_url = process_url_field(bucket, row.get('accompany_url_source'), 'accompany', record_id, media_skip)
487 opern_url = process_url_field(bucket, row.get('opern_url_source'), 'opern', record_id, skip_oss) 502 creation_url = process_url_field(bucket, row.get('creation_url_source'), 'creation', record_id, media_skip)
488 cover_url = process_url_field(bucket, row.get('cover_url_source'), 'cover', record_id, skip_oss) 503 opern_url = process_url_field(bucket, row.get('opern_url_source'), 'opern', record_id, media_skip)
489 sheet_music = process_url_field(bucket, row.get('sheet_music_source'), 'sheet_music', record_id, skip_oss) 504 cover_url = process_url_field(bucket, row.get('cover_url_source'), 'cover', record_id, media_skip)
505 sheet_music = process_url_field(bucket, row.get('sheet_music_source'), 'sheet_music', record_id, media_skip)
506 # 歌词:仅 skip_oss 时跳过,skip_media_oss 仍上传
490 lyrics_url, lrc_url = process_lyrics(bucket, row.get('lyrics_txt_content'), record_id, skip_oss) 507 lyrics_url, lrc_url = process_lyrics(bucket, row.get('lyrics_txt_content'), record_id, skip_oss)
491 508
492 return ( 509 return (
...@@ -538,7 +555,7 @@ def build_row_tuple(row, bucket, skip_oss=False): ...@@ -538,7 +555,7 @@ def build_row_tuple(row, bucket, skip_oss=False):
538 ) 555 )
539 556
540 557
541 def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str) -> tuple: 558 def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str, record_count=None) -> tuple:
542 """构建暂存表行;tuple_row 前 45 列与目标表保持同构。""" 559 """构建暂存表行;tuple_row 前 45 列与目标表保持同构。"""
543 dedup_action = action.get('action') or '' 560 dedup_action = action.get('action') or ''
544 if dedup_action == 'new': 561 if dedup_action == 'new':
...@@ -558,6 +575,7 @@ def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str) -> ...@@ -558,6 +575,7 @@ def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str) ->
558 ID_GENERATOR.next_id(), 575 ID_GENERATOR.next_id(),
559 import_batch_id, 576 import_batch_id,
560 *tuple_row, 577 *tuple_row,
578 record_count,
561 dedup_action, 579 dedup_action,
562 action.get('decision'), 580 action.get('decision'),
563 action.get('confidence'), 581 action.get('confidence'),
...@@ -573,10 +591,10 @@ def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str) -> ...@@ -573,10 +591,10 @@ def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str) ->
573 591
574 def process_row_with_oss(args_tuple): 592 def process_row_with_oss(args_tuple):
575 """线程工作函数:处理单行的 OSS 上传/下载""" 593 """线程工作函数:处理单行的 OSS 上传/下载"""
576 idx, row, bucket, skip_oss = args_tuple 594 idx, row, bucket, skip_oss, skip_media_oss = args_tuple
577 try: 595 try:
578 # upload_to_oss 已改用 requests 直接调 REST API,bucket 参数仅作兼容保留 596 # upload_to_oss 已改用 requests 直接调 REST API,bucket 参数仅作兼容保留
579 tuple_row = build_row_tuple(row, bucket, skip_oss=skip_oss) 597 tuple_row = build_row_tuple(row, bucket, skip_oss=skip_oss, skip_media_oss=skip_media_oss)
580 return idx, tuple_row, None 598 return idx, tuple_row, None
581 except Exception as e: 599 except Exception as e:
582 return idx, None, e 600 return idx, None, e
...@@ -606,10 +624,16 @@ def is_instrumental_lyrics(text: str | None) -> bool: ...@@ -606,10 +624,16 @@ def is_instrumental_lyrics(text: str | None) -> bool:
606 return bool(_INSTRUMENTAL_LYRIC_RE.search(normalized)) 624 return bool(_INSTRUMENTAL_LYRIC_RE.search(normalized))
607 625
608 626
609 def build_l1_index(target_conn, table_name: str) -> dict[tuple[str, str, str], int]: 627 def build_l1_index(target_conn, table_name: str):
610 """从目标库加载已有歌曲的元数据索引,返回 {(name, lyricist, composer): id}""" 628 """从目标库加载已有歌曲的元数据索引。
611 sql = f"SELECT id, name, lyricist, composer FROM {table_name} WHERE deleted = '0'" 629
630 Returns:
631 l1_index: {(name, lyricist, composer): target_id}
632 id_to_source_sid: {target_id: source_song_id} 用于合并方向决策
633 """
634 sql = f"SELECT id, name, lyricist, composer, source_song_id FROM {table_name} WHERE deleted = '0'"
612 index: dict[tuple[str, str, str], int] = {} 635 index: dict[tuple[str, str, str], int] = {}
636 id_to_source_sid: dict[int, str] = {}
613 try: 637 try:
614 with target_conn.cursor(pymysql.cursors.DictCursor) as cursor: 638 with target_conn.cursor(pymysql.cursors.DictCursor) as cursor:
615 cursor.execute(sql) 639 cursor.execute(sql)
...@@ -619,12 +643,39 @@ def build_l1_index(target_conn, table_name: str) -> dict[tuple[str, str, str], i ...@@ -619,12 +643,39 @@ def build_l1_index(target_conn, table_name: str) -> dict[tuple[str, str, str], i
619 _normalize_meta(row.get('lyricist')), 643 _normalize_meta(row.get('lyricist')),
620 _normalize_meta(row.get('composer')), 644 _normalize_meta(row.get('composer')),
621 ) 645 )
646 target_id = row['id']
622 if key[0]: # name 非空才入索引 647 if key[0]: # name 非空才入索引
623 index[key] = row['id'] 648 index[key] = target_id
649 sid = row.get('source_song_id')
650 if sid is not None:
651 id_to_source_sid[target_id] = str(sid)
624 logger.info(f"L1 元数据索引构建完成: {len(index)} 条") 652 logger.info(f"L1 元数据索引构建完成: {len(index)} 条")
625 except Exception as e: 653 except Exception as e:
626 logger.error(f"L1 索引构建失败: {e}") 654 logger.error(f"L1 索引构建失败: {e}")
627 return index 655 return index, id_to_source_sid
656
657
658 def load_source_record_counts(source_conn, source_song_ids: set[str]) -> dict[str, int]:
659 """从源库查询指定 song_id 集合关联的录音数量。"""
660 if not source_song_ids:
661 return {}
662 counts: dict[str, int] = {}
663 try:
664 placeholders = ','.join(['%s'] * len(source_song_ids))
665 sql = f"""
666 SELECT song_id, COUNT(record_id) AS record_count
667 FROM hk_song_and_record
668 WHERE song_id IN ({placeholders})
669 GROUP BY song_id
670 """
671 with source_conn.cursor(pymysql.cursors.DictCursor) as cursor:
672 cursor.execute(sql, list(source_song_ids))
673 for row in cursor.fetchall():
674 counts[str(row['song_id'])] = row['record_count']
675 logger.info(f"源库录音数量加载完成: {len(counts)} 条")
676 except Exception as e:
677 logger.warning(f"加载源库录音数量失败: {e}")
678 return counts
628 679
629 680
630 def check_l1(row: dict, l1_index: dict) -> tuple[bool, int | None]: 681 def check_l1(row: dict, l1_index: dict) -> tuple[bool, int | None]:
...@@ -771,15 +822,18 @@ def classify_dedup_action( ...@@ -771,15 +822,18 @@ def classify_dedup_action(
771 l1_index: dict, 822 l1_index: dict,
772 checker: DuplicateChecker, 823 checker: DuplicateChecker,
773 candidates: list[LyricRecord] | L2CandidateIndex, 824 candidates: list[LyricRecord] | L2CandidateIndex,
825 source_record_counts: dict[str, int] | None = None,
774 ) -> dict: 826 ) -> dict:
775 """Return the import-level dedup action. 827 """Return the import-level dedup action.
776 828
777 L1 is only a recall/risk signal. L2 content decides merge/review/new. 829 L1 is only a recall/risk signal. L2 content decides merge/review/new.
830 当判定为 duplicate 时,根据源库关联录音数量决定合并方向:
831 录音少的合并到录音多的。
778 """ 832 """
779 l1_matched, l1_matched_id = check_l1(row, l1_index) 833 l1_matched, l1_matched_id = check_l1(row, l1_index)
834 lyrics_text = row.get('lyrics_txt_content') or ''
780 l2_candidates_for_check: list[LyricRecord] | L2CandidateIndex = candidates 835 l2_candidates_for_check: list[LyricRecord] | L2CandidateIndex = candidates
781 if isinstance(candidates, L2CandidateIndex): 836 if isinstance(candidates, L2CandidateIndex):
782 lyrics_text = row.get('lyrics_txt_content') or ''
783 record = LyricRecord( 837 record = LyricRecord(
784 record_id=str(row.get('source_id', row.get('id'))), 838 record_id=str(row.get('source_id', row.get('id'))),
785 lyrics=lyrics_text, 839 lyrics=lyrics_text,
...@@ -799,6 +853,43 @@ def classify_dedup_action( ...@@ -799,6 +853,43 @@ def classify_dedup_action(
799 853
800 if decision == 'duplicate': 854 if decision == 'duplicate':
801 matched_candidate = _candidate_by_id(l2_candidates_for_check, matched_id) 855 matched_candidate = _candidate_by_id(l2_candidates_for_check, matched_id)
856
857 # 合并方向决策:录音少的合并到录音多的
858 new_sid = str(row.get('source_song_id', row.get('source_id', '')))
859 new_count = (row.get('record_count') or 0) if source_record_counts is not None else None
860 existing_count = None
861 if source_record_counts is not None and matched_id is not None:
862 try:
863 existing_sid = _target_id_to_source_sid.get(int(matched_id))
864 if existing_sid:
865 existing_count = source_record_counts.get(existing_sid, 0)
866 except (ValueError, TypeError):
867 pass
868
869 # 歌词质量兜底:现有记录无歌词但新记录有歌词 → 强制人工复核
870 existing_has_lyrics = matched_candidate and bool(matched_candidate.lyrics and matched_candidate.lyrics.strip())
871 new_has_lyrics = bool(lyrics_text and lyrics_text.strip())
872 if not existing_has_lyrics and new_has_lyrics:
873 return {
874 'action': 'review',
875 'decision': 'review',
876 'confidence': confidence,
877 'matched_id': matched_id,
878 'reason': f'L2 判定重复,但现有记录无歌词而新记录有歌词({reason}),需要人工决定保留哪个版本',
879 'l1_matched': l1_matched,
880 'l1_matched_id': l1_matched_id,
881 'merge_authors': _writers_differ(row, matched_candidate),
882 'matched_candidate': matched_candidate,
883 'merge_direction': 'existing_into_new',
884 'new_record_count': new_count,
885 'existing_record_count': existing_count,
886 }
887
888 merge_direction = 'new_into_existing'
889 if new_count is not None and existing_count is not None:
890 if new_count > existing_count:
891 merge_direction = 'existing_into_new'
892
802 return { 893 return {
803 'action': 'merge', 894 'action': 'merge',
804 'decision': decision, 895 'decision': decision,
...@@ -809,6 +900,27 @@ def classify_dedup_action( ...@@ -809,6 +900,27 @@ def classify_dedup_action(
809 'l1_matched_id': l1_matched_id, 900 'l1_matched_id': l1_matched_id,
810 'merge_authors': _writers_differ(row, matched_candidate), 901 'merge_authors': _writers_differ(row, matched_candidate),
811 'matched_candidate': matched_candidate, 902 'matched_candidate': matched_candidate,
903 'merge_direction': merge_direction,
904 'new_record_count': new_count,
905 'existing_record_count': existing_count,
906 }
907
908 # 空歌词 / 歌词获取失败:L2 无法比对,必须人工复核,不能自动入库
909 no_lyrics = not lyrics_text or not lyrics_text.strip()
910 if no_lyrics and not is_instrumental_lyrics(lyrics_text):
911 return {
912 'action': 'review',
913 'decision': 'review',
914 'confidence': 0.0,
915 'matched_id': str(l1_matched_id) if l1_matched_id is not None else None,
916 'reason': '歌词内容为空或获取失败,L2 无法去重比对,需要人工复核',
917 'l1_matched': l1_matched,
918 'l1_matched_id': l1_matched_id,
919 'merge_authors': False,
920 'matched_candidate': None,
921 'merge_direction': None,
922 'new_record_count': row.get('record_count'),
923 'existing_record_count': None,
812 } 924 }
813 925
814 if decision == 'review' or (l1_matched and reason == '无候选集'): 926 if decision == 'review' or (l1_matched and reason == '无候选集'):
...@@ -824,6 +936,9 @@ def classify_dedup_action( ...@@ -824,6 +936,9 @@ def classify_dedup_action(
824 'l1_matched_id': l1_matched_id, 936 'l1_matched_id': l1_matched_id,
825 'merge_authors': False, 937 'merge_authors': False,
826 'matched_candidate': matched_candidate, 938 'matched_candidate': matched_candidate,
939 'merge_direction': None,
940 'new_record_count': row.get('record_count'),
941 'existing_record_count': None,
827 } 942 }
828 943
829 return { 944 return {
...@@ -836,6 +951,9 @@ def classify_dedup_action( ...@@ -836,6 +951,9 @@ def classify_dedup_action(
836 'l1_matched_id': l1_matched_id, 951 'l1_matched_id': l1_matched_id,
837 'merge_authors': False, 952 'merge_authors': False,
838 'matched_candidate': None, 953 'matched_candidate': None,
954 'merge_direction': None,
955 'new_record_count': row.get('record_count'),
956 'existing_record_count': None,
839 } 957 }
840 958
841 959
...@@ -866,7 +984,8 @@ class ReviewDecisionReport: ...@@ -866,7 +984,8 @@ class ReviewDecisionReport:
866 'query_lyrics_path', 'action', 'decision', 'confidence', 'matched_id', 984 'query_lyrics_path', 'action', 'decision', 'confidence', 'matched_id',
867 'matched_name', 'matched_lyricist', 'matched_composer', 'matched_lyrics_path', 985 'matched_name', 'matched_lyricist', 'matched_composer', 'matched_lyrics_path',
868 'l1_matched_id', 986 'l1_matched_id',
869 'merge_authors', 'reason', 'review_decision', 'review_note', 987 'merge_authors', 'merge_direction', 'new_record_count', 'existing_record_count',
988 'reason', 'review_decision', 'review_note',
870 ] 989 ]
871 990
872 def __init__(self, filepath: str): 991 def __init__(self, filepath: str):
...@@ -925,6 +1044,9 @@ class ReviewDecisionReport: ...@@ -925,6 +1044,9 @@ class ReviewDecisionReport:
925 'matched_lyrics_path': matched_lyrics_path, 1044 'matched_lyrics_path': matched_lyrics_path,
926 'l1_matched_id': action.get('l1_matched_id') or '', 1045 'l1_matched_id': action.get('l1_matched_id') or '',
927 'merge_authors': '1' if action.get('merge_authors') else '0', 1046 'merge_authors': '1' if action.get('merge_authors') else '0',
1047 'merge_direction': action.get('merge_direction') or '',
1048 'new_record_count': action.get('new_record_count', ''),
1049 'existing_record_count': action.get('existing_record_count', ''),
928 'reason': action.get('reason', ''), 1050 'reason': action.get('reason', ''),
929 'review_decision': '', 1051 'review_decision': '',
930 'review_note': '', 1052 'review_note': '',
...@@ -959,11 +1081,14 @@ def load_approved_import_ids(review_csv_path: str) -> set[str]: ...@@ -959,11 +1081,14 @@ def load_approved_import_ids(review_csv_path: str) -> set[str]:
959 return approved 1081 return approved
960 1082
961 1083
1084
962 def main(): 1085 def main():
963 parser = argparse.ArgumentParser(description='hk_music_record 聚合数据导入至 hk_songs') 1086 parser = argparse.ArgumentParser(description='hk_music_record 聚合数据导入至 hk_songs')
964 parser.add_argument('--limit', type=int, default=None, help='导入数据数量(默认全量)') 1087 parser.add_argument('--limit', type=int, default=None, help='导入数据数量(默认全量)')
965 parser.add_argument('--offset', type=int, default=0, help='起始偏移量(默认 0)') 1088 parser.add_argument('--offset', type=int, default=0, help='起始偏移量(默认 0)')
966 parser.add_argument('--skip-oss', action='store_true', help='跳过 OSS 上传,直接透传源 URL') 1089 parser.add_argument('--skip-oss', action='store_true', help='跳过 OSS 上传,直接透传源 URL')
1090 parser.add_argument('--skip-media-oss', action='store_true',
1091 help='仅跳过媒体资源(音频/封面/曲谱)的 OSS 上传,歌词仍上传 OSS')
967 parser.add_argument('--batch-size', type=int, default=500, help='批量提交大小(默认 500)') 1092 parser.add_argument('--batch-size', type=int, default=500, help='批量提交大小(默认 500)')
968 parser.add_argument('--workers', type=int, default=8, help='OSS 并发下载/上传线程数(默认 8)') 1093 parser.add_argument('--workers', type=int, default=8, help='OSS 并发下载/上传线程数(默认 8)')
969 parser.add_argument('--skip-dedup', action='store_true', help='跳过去重,全部写入') 1094 parser.add_argument('--skip-dedup', action='store_true', help='跳过去重,全部写入')
...@@ -1012,7 +1137,8 @@ def main(): ...@@ -1012,7 +1137,8 @@ def main():
1012 staging_insert_sql = STAGING_INSERT_SQL_TEMPLATE.format(table=TARGET_TABLE_NAME_TMP) 1137 staging_insert_sql = STAGING_INSERT_SQL_TEMPLATE.format(table=TARGET_TABLE_NAME_TMP)
1013 import_batch_id = datetime.now().strftime("%Y%m%d_%H%M%S") 1138 import_batch_id = datetime.now().strftime("%Y%m%d_%H%M%S")
1014 1139
1015 # ===== 加载已导入的 source_song_id 集合(增量去重)===== 1140 # ===== 加载已处理的 source_song_id 集合(增量去重)=====
1141 # 包括:1) 已写入目标表的 2) 已在暂存表中标记过 dedup_action 的(review/merge/new)
1016 imported_ids: set[str] = set() 1142 imported_ids: set[str] = set()
1017 try: 1143 try:
1018 id_sql = f"SELECT source_song_id FROM {TARGET_TABLE_NAME} WHERE source_table_name = 'hk_song_platform'" 1144 id_sql = f"SELECT source_song_id FROM {TARGET_TABLE_NAME} WHERE source_table_name = 'hk_song_platform'"
...@@ -1022,9 +1148,24 @@ def main(): ...@@ -1022,9 +1148,24 @@ def main():
1022 sid = r[0] if isinstance(r, tuple) else r.get('source_song_id') 1148 sid = r[0] if isinstance(r, tuple) else r.get('source_song_id')
1023 if sid is not None: 1149 if sid is not None:
1024 imported_ids.add(str(sid)) 1150 imported_ids.add(str(sid))
1025 logger.info(f"已导入 source_song_id 集合加载完成: {len(imported_ids)} 条") 1151 target_count = len(imported_ids)
1152 logger.info(f"目标库已导入 source_song_id: {target_count} 条")
1153 except Exception as e:
1154 logger.warning(f"加载目标库已导入 ID 失败(首次导入可忽略): {e}")
1155
1156 try:
1157 staging_sql = f"SELECT DISTINCT source_song_id FROM {TARGET_TABLE_NAME_TMP} WHERE source_table_name = 'hk_song_platform' AND dedup_action IS NOT NULL AND dedup_action <> ''"
1158 with target_conn.cursor() as cursor:
1159 cursor.execute(staging_sql)
1160 for r in cursor.fetchall():
1161 sid = r[0] if isinstance(r, tuple) else r.get('source_song_id')
1162 if sid is not None:
1163 imported_ids.add(str(sid))
1164 staging_count = len(imported_ids) - target_count
1165 if staging_count > 0:
1166 logger.info(f"暂存表已处理 source_song_id: {staging_count} 条(累计去重集合 {len(imported_ids)} 条)")
1026 except Exception as e: 1167 except Exception as e:
1027 logger.warning(f"加载已导入 ID 失败(首次导入可忽略): {e}") 1168 logger.warning(f"加载暂存表已处理 ID 失败(暂存表不存在时可忽略): {e}")
1028 1169
1029 # 连接源库 1170 # 连接源库
1030 logger.info(f"连接源库: {SOURCE_DB_CONFIG['host']}:{SOURCE_DB_CONFIG['port']}/{SOURCE_DB_CONFIG['database']}") 1171 logger.info(f"连接源库: {SOURCE_DB_CONFIG['host']}:{SOURCE_DB_CONFIG['port']}/{SOURCE_DB_CONFIG['database']}")
...@@ -1086,10 +1227,19 @@ def main(): ...@@ -1086,10 +1227,19 @@ def main():
1086 l2_candidates: L2CandidateIndex | None = None 1227 l2_candidates: L2CandidateIndex | None = None
1087 dedup_report = None 1228 dedup_report = None
1088 review_report = None 1229 review_report = None
1230 source_record_counts: dict[str, int] = {}
1089 1231
1090 if not args.skip_dedup: 1232 if not args.skip_dedup:
1091 logger.info("正在构建 L1 元数据索引...") 1233 logger.info("正在构建 L1 元数据索引...")
1092 l1_index = build_l1_index(target_conn, TARGET_TABLE_NAME) 1234 l1_index, _target_id_to_source_sid = build_l1_index(target_conn, TARGET_TABLE_NAME)
1235
1236 logger.info("正在加载源库录音数量(用于合并方向决策)...")
1237 _source_sids = set(_target_id_to_source_sid.values())
1238 _src_conn = pymysql.connect(**SOURCE_DB_CONFIG)
1239 try:
1240 source_record_counts = load_source_record_counts(_src_conn, _source_sids)
1241 finally:
1242 _src_conn.close()
1093 1243
1094 logger.info(f"初始化 L2 歌词去重检查器 (阈值={args.dedup_threshold})") 1244 logger.info(f"初始化 L2 歌词去重检查器 (阈值={args.dedup_threshold})")
1095 l2_checker = DuplicateChecker( 1245 l2_checker = DuplicateChecker(
...@@ -1148,7 +1298,8 @@ def main(): ...@@ -1148,7 +1298,8 @@ def main():
1148 for row in tqdm(rows, desc="去重审核清单", unit="条"): 1298 for row in tqdm(rows, desc="去重审核清单", unit="条"):
1149 record_id = row['source_id'] 1299 record_id = row['source_id']
1150 record_name = row.get('name', '') 1300 record_name = row.get('name', '')
1151 action = classify_dedup_action(row, l1_index, l2_checker, l2_candidates) 1301 action = classify_dedup_action(row, l1_index, l2_checker, l2_candidates,
1302 source_record_counts=source_record_counts)
1152 decision_counts[action['action']] += 1 1303 decision_counts[action['action']] += 1
1153 review_report.write(row, action) 1304 review_report.write(row, action)
1154 dedup_report.write( 1305 dedup_report.write(
...@@ -1208,103 +1359,125 @@ def main(): ...@@ -1208,103 +1359,125 @@ def main():
1208 batch_rows = rows[batch_start:batch_start + args.batch_size] 1359 batch_rows = rows[batch_start:batch_start + args.batch_size]
1209 batch_size_actual = len(batch_rows) 1360 batch_size_actual = len(batch_rows)
1210 1361
1211 # ===== 并发处理 OSS ===== 1362 # ===== 步骤1:去重分类(纯内存,先于 OSS)=====
1212 # 用 dict 按索引保序 1363 # L1/L2 去重只依赖源库查询结果(name/lyricist/composer/lyrics_txt_content),
1213 results_map = {} 1364 # 不需要任何 URL 下载,因此可以在 OSS 之前完成,避免对 merge/review 记录做无效上传。
1214 tasks = [(batch_start + i, row, bucket, args.skip_oss) for i, row in enumerate(batch_rows)]
1215
1216 with ThreadPoolExecutor(max_workers=args.workers) as executor:
1217 futures = {executor.submit(process_row_with_oss, t): t[0] for t in tasks}
1218 for future in as_completed(futures):
1219 idx, tuple_row, err = future.result()
1220 if err:
1221 with stats_lock:
1222 stats['errors'] += 1
1223 logger.error(f"OSS 处理失败 source_id={batch_rows[idx - batch_start].get('source_id')}: {err}")
1224 else:
1225 results_map[idx] = tuple_row
1226 pbar_oss.update(1)
1227
1228 # 按原始顺序组装 batch_data
1229 batch_data = [results_map[i] for i in range(batch_start, batch_start + batch_size_actual) if i in results_map]
1230
1231 # ===== 去重过滤 =====
1232 staging_data = [] 1365 staging_data = []
1233 if not args.skip_dedup and not args.review_result_csv and batch_data: 1366 new_rows: list = []
1234 filtered_data = [] 1367 row_actions: dict = {}
1235 for i, tuple_row in enumerate(batch_data):
1236 orig_idx = batch_start + i
1237 row = batch_rows[i] if i < len(batch_rows) else None
1238 if row is None:
1239 filtered_data.append(tuple_row)
1240 continue
1241 1368
1369 if not args.skip_dedup and not args.review_result_csv:
1370 for row in batch_rows:
1242 record_id = row['source_id'] 1371 record_id = row['source_id']
1243 record_name = row.get('name', '') 1372 record_name = row.get('name', '')
1244 1373
1245 action = classify_dedup_action(row, l1_index, l2_checker, l2_candidates) 1374 action = classify_dedup_action(row, l1_index, l2_checker, l2_candidates,
1246 decision = action['decision'] 1375 source_record_counts=source_record_counts)
1247 confidence = action['confidence'] 1376 row_actions[record_id] = action
1248 matched_action_id = action['matched_id']
1249 reason = action['reason']
1250 1377
1251 if action['l1_matched']: 1378 if action['l1_matched']:
1252 with stats_lock: 1379 with stats_lock:
1253 stats['l1_dup'] += 1 1380 stats['l1_dup'] += 1
1254 1381
1382 confidence = action['confidence']
1383 matched_action_id = action['matched_id']
1384 reason = action['reason']
1385
1255 if action['action'] == 'merge': 1386 if action['action'] == 'merge':
1256 with stats_lock: 1387 with stats_lock:
1257 stats['l2_dup'] += 1 1388 stats['l2_dup'] += 1
1258 staging_data.append(build_staging_tuple(tuple_row, action, import_batch_id))
1259 merge_note = ';作者字段需增量合并' if action.get('merge_authors') else '' 1389 merge_note = ';作者字段需增量合并' if action.get('merge_authors') else ''
1260 dedup_report.write(record_id, record_name, 'L2', 'merge', 1390 dedup_report.write(record_id, record_name, 'L2', 'merge',
1261 confidence=f'{confidence:.4f}', 1391 confidence=f'{confidence:.4f}',
1262 matched_id=matched_action_id, reason=f'{reason}{merge_note}') 1392 matched_id=matched_action_id, reason=f'{reason}{merge_note}')
1263 if review_report: 1393 if review_report:
1264 review_report.write(row, action) 1394 review_report.write(row, action)
1265 continue
1266 1395
1267 if action['action'] == 'review': 1396 elif action['action'] == 'review':
1268 with stats_lock: 1397 with stats_lock:
1269 stats['l2_review'] += 1 1398 stats['l2_review'] += 1
1270 staging_data.append(build_staging_tuple(tuple_row, action, import_batch_id))
1271 dedup_report.write(record_id, record_name, 'L2', 'review', 1399 dedup_report.write(record_id, record_name, 'L2', 'review',
1272 confidence=f'{confidence:.4f}', 1400 confidence=f'{confidence:.4f}',
1273 matched_id=matched_action_id, reason=reason) 1401 matched_id=matched_action_id, reason=reason)
1274 if review_report: 1402 if review_report:
1275 review_report.write(row, action) 1403 review_report.write(row, action)
1276 continue
1277 1404
1278 with stats_lock: 1405 else: # new
1279 stats['l2_new'] += 1 1406 with stats_lock:
1280 staging_data.append(build_staging_tuple(tuple_row, action, import_batch_id)) 1407 stats['l2_new'] += 1
1281 dedup_report.write(record_id, record_name, 'L2', 'new', 1408 dedup_report.write(record_id, record_name, 'L2', 'new',
1282 confidence=f'{confidence:.4f}', 1409 confidence=f'{confidence:.4f}',
1283 matched_id=matched_action_id or '', 1410 matched_id=matched_action_id or '',
1284 reason=reason) 1411 reason=reason)
1285 if review_report: 1412 if review_report:
1286 review_report.write(row, action) 1413 review_report.write(row, action)
1287 filtered_data.append(tuple_row) 1414 new_rows.append(row)
1288 1415
1289 # 加入候选集供后续比对 1416 # 加入 L2 候选集供后续批次比对
1290 lyrics_text = row.get('lyrics_txt_content') 1417 lyrics_text = row.get('lyrics_txt_content')
1291 if lyrics_text and lyrics_text.strip(): 1418 if lyrics_text and lyrics_text.strip():
1292 l2_candidates.add(LyricRecord( 1419 l2_candidates.add(LyricRecord(
1293 record_id=str(record_id), 1420 record_id=str(record_id),
1294 lyrics=lyrics_text, 1421 lyrics=lyrics_text,
1295 title=record_name, 1422 title=record_name,
1296 artist=row.get('singer'), 1423 artist=row.get('singer'),
1297 lyricist=row.get('lyricist'), 1424 lyricist=row.get('lyricist'),
1298 composer=row.get('composer'), 1425 composer=row.get('composer'),
1299 )) 1426 ))
1300 1427 else:
1301 batch_data = filtered_data 1428 # skip_dedup 或 review_result_csv 模式:全部视为 new
1429 new_rows = list(batch_rows)
1430
1431 # ===== 步骤2:OSS 并发处理(只对 new 记录)=====
1432 results_map: dict = {}
1433 tasks = [(i, row, bucket, args.skip_oss, args.skip_media_oss) for i, row in enumerate(new_rows)]
1434
1435 if tasks:
1436 with ThreadPoolExecutor(max_workers=args.workers) as executor:
1437 futures = {executor.submit(process_row_with_oss, t): t[0] for t in tasks}
1438 for future in as_completed(futures):
1439 idx, tuple_row, err = future.result()
1440 if err:
1441 with stats_lock:
1442 stats['errors'] += 1
1443 logger.error(f"OSS 处理失败 source_id={new_rows[idx].get('source_id')}: {err}")
1444 else:
1445 results_map[idx] = tuple_row
1446 pbar_oss.update(1)
1447
1448 # merge/review 记录跳过 OSS,直接推进进度条
1449 pbar_oss.update(batch_size_actual - len(new_rows))
1450
1451 # 按顺序组装 batch_data(写入 hk_songs 的 new 记录)
1452 batch_data = [results_map[i] for i in range(len(new_rows)) if i in results_map]
1453
1454 # ===== 步骤3:构建 staging tuples =====
1455 if not args.skip_dedup and not args.review_result_csv:
1456 # new 记录:使用 OSS 处理后的 tuple_row
1457 for i, row in enumerate(new_rows):
1458 if i not in results_map:
1459 continue
1460 action = row_actions.get(row['source_id'], {
1461 'action': 'new', 'decision': 'new', 'confidence': 1.0,
1462 'matched_id': None, 'l1_matched_id': None,
1463 'merge_authors': False, 'reason': '',
1464 })
1465 staging_data.append(build_staging_tuple(results_map[i], action, import_batch_id,
1466 record_count=row.get('record_count')))
1467
1468 # merge/review 记录:透传源 URL,跳过 OSS
1469 for row in batch_rows:
1470 action = row_actions.get(row['source_id'])
1471 if action and action['action'] in ('merge', 'review'):
1472 tuple_row = build_row_tuple(row, None, skip_oss=True)
1473 staging_data.append(build_staging_tuple(tuple_row, action, import_batch_id,
1474 record_count=row.get('record_count')))
1302 1475
1303 if not batch_data and not staging_data: 1476 if not batch_data and not staging_data:
1304 pbar_db.update(batch_size_actual) 1477 pbar_db.update(batch_size_actual)
1305 continue 1478 continue
1306 1479
1307 # ===== 顺序写入数据库(单连接,保证不出错)===== 1480 # ===== 步骤4:顺序写入数据库(单连接,保证不出错)=====
1308 try: 1481 try:
1309 with target_conn.cursor() as cursor: 1482 with target_conn.cursor() as cursor:
1310 if batch_data: 1483 if batch_data:
......
...@@ -171,22 +171,18 @@ ...@@ -171,22 +171,18 @@
171 } 171 }
172 172
173 .tabs { 173 .tabs {
174 display: grid;
175 grid-template-columns: 1fr 1fr;
176 gap: 6px;
177 margin-bottom: 12px; 174 margin-bottom: 12px;
178 } 175 }
179 176
180 .tabs button { 177 .tabs .tab-label {
181 background: #fff; 178 display: inline-block;
182 color: var(--text); 179 padding: 6px 16px;
183 border-color: var(--line);
184 }
185
186 .tabs button.active {
187 background: var(--accent-soft); 180 background: var(--accent-soft);
188 color: var(--accent); 181 color: var(--accent);
189 border-color: #91c1e8; 182 border: 1px solid #91c1e8;
183 border-radius: 6px;
184 font-size: 14px;
185 font-weight: 500;
190 } 186 }
191 187
192 .query-list { 188 .query-list {
...@@ -462,7 +458,7 @@ ...@@ -462,7 +458,7 @@
462 <div class="toolbar"> 458 <div class="toolbar">
463 <label for="runSelect">结果</label> 459 <label for="runSelect">结果</label>
464 <select id="runSelect"></select> 460 <select id="runSelect"></select>
465 <label for="topKSelect">topK</label> 461 <label id="topKLabel" for="topKSelect">topK</label>
466 <select id="topKSelect"></select> 462 <select id="topKSelect"></select>
467 <input id="searchInput" type="search" placeholder="搜索歌名 / ID"> 463 <input id="searchInput" type="search" placeholder="搜索歌名 / ID">
468 <label for="pageSizeSelect">每页</label> 464 <label for="pageSizeSelect">每页</label>
...@@ -481,8 +477,7 @@ ...@@ -481,8 +477,7 @@
481 <main> 477 <main>
482 <aside> 478 <aside>
483 <div class="tabs"> 479 <div class="tabs">
484 <button id="tabRetrieval" class="active" data-mode="retrieval">召回样本</button> 480 <span class="tab-label">召回样本</span>
485 <button id="tabHits" data-mode="hits">命中样本</button>
486 </div> 481 </div>
487 <div class="section"> 482 <div class="section">
488 <div class="section-head"> 483 <div class="section-head">
...@@ -533,6 +528,7 @@ ...@@ -533,6 +528,7 @@
533 const els = { 528 const els = {
534 runSelect: document.getElementById('runSelect'), 529 runSelect: document.getElementById('runSelect'),
535 topKSelect: document.getElementById('topKSelect'), 530 topKSelect: document.getElementById('topKSelect'),
531 topKLabel: document.getElementById('topKLabel'),
536 searchInput: document.getElementById('searchInput'), 532 searchInput: document.getElementById('searchInput'),
537 pageSizeSelect: document.getElementById('pageSizeSelect'), 533 pageSizeSelect: document.getElementById('pageSizeSelect'),
538 exportBtn: document.getElementById('exportBtn'), 534 exportBtn: document.getElementById('exportBtn'),
...@@ -540,8 +536,7 @@ ...@@ -540,8 +536,7 @@
540 reloadBtn: document.getElementById('reloadBtn'), 536 reloadBtn: document.getElementById('reloadBtn'),
541 prevPageBtn: document.getElementById('prevPageBtn'), 537 prevPageBtn: document.getElementById('prevPageBtn'),
542 nextPageBtn: document.getElementById('nextPageBtn'), 538 nextPageBtn: document.getElementById('nextPageBtn'),
543 tabRetrieval: document.getElementById('tabRetrieval'), 539
544 tabHits: document.getElementById('tabHits'),
545 metrics: document.getElementById('metrics'), 540 metrics: document.getElementById('metrics'),
546 sampleCount: document.getElementById('sampleCount'), 541 sampleCount: document.getElementById('sampleCount'),
547 queryList: document.getElementById('queryList'), 542 queryList: document.getElementById('queryList'),
...@@ -554,6 +549,12 @@ ...@@ -554,6 +549,12 @@
554 })[ch]); 549 })[ch]);
555 } 550 }
556 551
552 function fmtPath(value) {
553 if (!value) return '-';
554 if (value.startsWith('_raw:')) return '(内嵌歌词)';
555 return esc(value);
556 }
557
557 function normalizeMeta(value) { 558 function normalizeMeta(value) {
558 const punctuation = new Set(' \t\n\r,。!?;:、"“”‘’·…—~!¥()【】《》〈〉「」『』﹏,.:;!?()[]{}<>|/\\\\_-'.split('')); 559 const punctuation = new Set(' \t\n\r,。!?;:、"“”‘’·…—~!¥()【】《》〈〉「」『』﹏,.:;!?()[]{}<>|/\\\\_-'.split(''));
559 return String(value || '') 560 return String(value || '')
...@@ -600,14 +601,20 @@ ...@@ -600,14 +601,20 @@
600 601
601 function loadReviews() { 602 function loadReviews() {
602 try { 603 try {
603 return JSON.parse(localStorage.getItem('l2ReviewAnnotations') || '{}'); 604 const stored = JSON.parse(localStorage.getItem('l2ReviewAnnotations') || '{}');
605 // 合并内存缓存(防止 localStorage 不可用时回退)
606 return { ...stored, ...(window._reviewCache || {}) };
604 } catch { 607 } catch {
605 return {}; 608 return window._reviewCache || {};
606 } 609 }
607 } 610 }
608 611
609 function saveReviews(reviews) { 612 function saveReviews(reviews) {
610 localStorage.setItem('l2ReviewAnnotations', JSON.stringify(reviews)); 613 try {
614 localStorage.setItem('l2ReviewAnnotations', JSON.stringify(reviews));
615 } catch (e) {
616 console.error('保存标注失败:', e);
617 }
611 } 618 }
612 619
613 function getReview(row) { 620 function getReview(row) {
...@@ -619,6 +626,9 @@ ...@@ -619,6 +626,9 @@
619 const key = reviewKey(row); 626 const key = reviewKey(row);
620 reviews[key] = { ...(reviews[key] || {}), ...patch, updated_at: new Date().toISOString() }; 627 reviews[key] = { ...(reviews[key] || {}), ...patch, updated_at: new Date().toISOString() };
621 saveReviews(reviews); 628 saveReviews(reviews);
629 // 同时缓存到内存,防止 localStorage 不可用时丢失
630 if (!window._reviewCache) window._reviewCache = {};
631 window._reviewCache[key] = reviews[key];
622 } 632 }
623 633
624 function getJSON(url) { 634 function getJSON(url) {
...@@ -630,7 +640,7 @@ ...@@ -630,7 +640,7 @@
630 640
631 function dataPath() { 641 function dataPath() {
632 if (!state.run) return ''; 642 if (!state.run) return '';
633 return state.mode === 'hits' ? state.run.hits : state.run.retrieval; 643 return state.run.retrieval;
634 } 644 }
635 645
636 function metric(label, value) { 646 function metric(label, value) {
...@@ -695,7 +705,6 @@ ...@@ -695,7 +705,6 @@
695 705
696 function selectedCandidate(rows) { 706 function selectedCandidate(rows) {
697 if (!rows.length) return null; 707 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)) { 708 if (!state.candidateId || !rows.some(row => row.candidate_id === state.candidateId)) {
700 state.candidateId = rows[0].candidate_id || ''; 709 state.candidateId = rows[0].candidate_id || '';
701 } 710 }
...@@ -704,6 +713,7 @@ ...@@ -704,6 +713,7 @@
704 713
705 async function readText(path) { 714 async function readText(path) {
706 if (!path) return ''; 715 if (!path) return '';
716 if (path.startsWith('_raw:')) return path.slice(5);
707 if (state.lyricsCache.has(path)) return state.lyricsCache.get(path); 717 if (state.lyricsCache.has(path)) return state.lyricsCache.get(path);
708 const data = await getJSON(`/api/text?path=${encodeURIComponent(path)}`); 718 const data = await getJSON(`/api/text?path=${encodeURIComponent(path)}`);
709 state.lyricsCache.set(path, data.text || ''); 719 state.lyricsCache.set(path, data.text || '');
...@@ -737,6 +747,25 @@ ...@@ -737,6 +747,25 @@
737 </div>`; 747 </div>`;
738 } 748 }
739 749
750 async function updateStagingReview(sourceId, decision, note) {
751 const res = await fetch('/api/staging-review', {
752 method: 'POST',
753 headers: { 'Content-Type': 'application/json' },
754 body: JSON.stringify({ source_id: sourceId, decision, note })
755 });
756 if (!res.ok) {
757 const err = await res.json().catch(() => ({}));
758 throw new Error(err.error || `${res.status}`);
759 }
760 return res.json();
761 }
762
763 const _DB_REVIEW_LABELS = {
764 approved_import: '确认不重复(已入库)',
765 rejected_duplicate: '确认重复',
766 unsure: '待确认',
767 };
768
740 async function renderDetail() { 769 async function renderDetail() {
741 const rows = selectedQueryRows(); 770 const rows = selectedQueryRows();
742 if (!rows.length) { 771 if (!rows.length) {
...@@ -746,13 +775,13 @@ ...@@ -746,13 +775,13 @@
746 const query = rows[0]; 775 const query = rows[0];
747 const candidate = selectedCandidate(rows); 776 const candidate = selectedCandidate(rows);
748 const queryText = await readText(query.query_lyrics_path); 777 const queryText = await readText(query.query_lyrics_path);
749 const candidatePath = state.mode === 'hits' ? candidate?.matched_lyrics_path : candidate?.candidate_lyrics_path; 778 const candidatePath = candidate?.candidate_lyrics_path;
750 const candidateText = await readText(candidatePath); 779 const candidateText = await readText(candidatePath);
751 const hitDecision = state.mode === 'hits' ? candidate.decision : candidate?.candidate_decision; 780 const hitDecision = candidate?.candidate_decision;
752 const candidateName = state.mode === 'hits' ? candidate?.matched_name : candidate?.candidate_name; 781 const candidateName = candidate?.candidate_name;
753 const candidateLyricist = state.mode === 'hits' ? candidate?.matched_lyricist : candidate?.candidate_lyricist; 782 const candidateLyricist = candidate?.candidate_lyricist;
754 const candidateComposer = state.mode === 'hits' ? candidate?.matched_composer : candidate?.candidate_composer; 783 const candidateComposer = candidate?.candidate_composer;
755 const reason = state.mode === 'hits' ? candidate?.reason : candidate?.candidate_reason; 784 const reason = candidate?.candidate_reason;
756 const selectedReview = candidate ? getReview(candidate) : {}; 785 const selectedReview = candidate ? getReview(candidate) : {};
757 const conflict = candidate ? isConflict(candidate) : false; 786 const conflict = candidate ? isConflict(candidate) : false;
758 const l1 = candidate ? l1Match(candidate) : false; 787 const l1 = candidate ? l1Match(candidate) : false;
...@@ -769,13 +798,13 @@ ...@@ -769,13 +798,13 @@
769 <div>歌名</div><div>${esc(query.query_name || '-')}</div> 798 <div>歌名</div><div>${esc(query.query_name || '-')}</div>
770 <div>作词</div><div>${esc(query.query_lyricist || '-')}</div> 799 <div>作词</div><div>${esc(query.query_lyricist || '-')}</div>
771 <div>作曲</div><div>${esc(query.query_composer || '-')}</div> 800 <div>作曲</div><div>${esc(query.query_composer || '-')}</div>
772 <div>歌词</div><div class="path">${esc(query.query_lyrics_path || '-')}</div> 801 <div>歌词</div><div class="path">${fmtPath(query.query_lyrics_path)}</div>
773 </div> 802 </div>
774 </div> 803 </div>
775 </div> 804 </div>
776 <div class="section"> 805 <div class="section">
777 <div class="section-head"> 806 <div class="section-head">
778 <h2 class="section-title">${state.mode === 'hits' ? '命中候选' : '召回候选'}</h2> 807 <h2 class="section-title">召回候选</h2>
779 <span class="pill ${esc(hitDecision || '')}">${esc(hitDecision || '-')}</span> 808 <span class="pill ${esc(hitDecision || '')}">${esc(hitDecision || '-')}</span>
780 </div> 809 </div>
781 <div class="section-body"> 810 <div class="section-body">
...@@ -785,31 +814,47 @@ ...@@ -785,31 +814,47 @@
785 <div>作曲</div><div>${esc(candidateComposer || '-')}</div> 814 <div>作曲</div><div>${esc(candidateComposer || '-')}</div>
786 <div>原因</div><div>${esc(reason || '-')}</div> 815 <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> 816 <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> 817 <div>歌词</div><div class="path">${fmtPath(candidatePath)}</div>
789 </div> 818 </div>
790 </div> 819 </div>
791 </div> 820 </div>
792 </div> 821 </div>
793 822
794 <div class="section"> 823 <div class="section">
795 <div class="section-head"><h2 class="section-title">人工标注</h2></div> 824 <div class="section-head">
825 <h2 class="section-title">人工标注</h2>
826 ${query.review_status && query.review_status !== 'pending' && query.review_status !== 'not_required'
827 ? `<span class="pill ${query.review_status === 'approved_import' ? 'new' : query.review_status === 'rejected_duplicate' ? 'duplicate' : ''}">已审核</span>`
828 : ''}
829 </div>
796 <div class="section-body"> 830 <div class="section-body">
797 <div class="review-bar"> 831 ${query.review_status && query.review_status !== 'pending' && query.review_status !== 'not_required' ? `
798 ${['duplicate', 'not_duplicate', 'unsure'].map(value => ` 832 <div class="review-bar" style="align-items:center">
799 <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}"> 833 <span style="flex:1">
800 ${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'} 834 <b>${esc(_DB_REVIEW_LABELS[query.review_status] || query.review_status)}</b>
801 </button>`).join('')} 835 ${query.reviewed_by ? ` · 审核人 ${esc(query.reviewed_by)}` : ''}
802 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注"> 836 ${query.reviewed_at ? ` · ${esc(query.reviewed_at)}` : ''}
803 <button id="saveReviewBtn" class="secondary">保存</button> 837 ${query.review_note ? ` · 备注: ${esc(query.review_note)}` : ''}
804 </div> 838 </span>
839 <button id="undoReviewBtn" class="secondary">撤销审核</button>
840 </div>
841 ` : `
842 <div class="review-bar">
843 ${['duplicate', 'not_duplicate', 'unsure'].map(value => `
844 <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}">
845 ${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'}
846 </button>`).join('')}
847 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注">
848 <button id="saveReviewBtn" class="secondary">保存</button>
849 </div>
850 `}
805 </div> 851 </div>
806 </div> 852 </div>
807 853
808 ${state.mode === 'retrieval' ? ` 854 <div class="section">
809 <div class="section"> 855 <div class="section-head"><h2 class="section-title">Top10 候选</h2></div>
810 <div class="section-head"><h2 class="section-title">Top10 候选</h2></div> 856 <div class="section-body"><div class="candidates">${rows.map(candidateCard).join('') || '<div class="empty">无召回候选</div>'}</div></div>
811 <div class="section-body"><div class="candidates">${rows.map(candidateCard).join('') || '<div class="empty">无召回候选</div>'}</div></div> 857 </div>
812 </div>` : ''}
813 858
814 <div class="section"> 859 <div class="section">
815 <div class="section-head"><h2 class="section-title">歌词对比</h2></div> 860 <div class="section-head"><h2 class="section-title">歌词对比</h2></div>
...@@ -827,10 +872,23 @@ ...@@ -827,10 +872,23 @@
827 renderDetail(); 872 renderDetail();
828 }); 873 });
829 } 874 }
875 const _REVIEW_DECISION_MAP = { duplicate: 'rejected_duplicate', not_duplicate: 'approved_import', unsure: 'unsure' };
830 for (const btn of els.detail.querySelectorAll('.review-choice')) { 876 for (const btn of els.detail.querySelectorAll('.review-choice')) {
831 btn.addEventListener('click', () => { 877 btn.addEventListener('click', async () => {
832 if (!candidate) return; 878 if (!candidate) return;
833 setReview(candidate, { final_decision: btn.dataset.review, note: document.getElementById('reviewNote')?.value || '' }); 879 const localDecision = btn.dataset.review;
880 const note = document.getElementById('reviewNote')?.value || '';
881 const dbDecision = _REVIEW_DECISION_MAP[localDecision];
882 setReview(candidate, { final_decision: localDecision, note });
883 btn.disabled = true;
884 try {
885 await updateStagingReview(query.query_source_id, dbDecision, note);
886 // 重新加载当前样本数据以刷新 DB 状态
887 await loadQueryRows(query.query_source_id);
888 } catch (e) {
889 console.error('DB同步失败:', e);
890 alert(`标注已保存到本地,但数据库同步失败: ${e.message}`);
891 }
834 renderDetail(); 892 renderDetail();
835 }); 893 });
836 } 894 }
...@@ -842,6 +900,21 @@ ...@@ -842,6 +900,21 @@
842 renderDetail(); 900 renderDetail();
843 }); 901 });
844 } 902 }
903 const undoBtn = document.getElementById('undoReviewBtn');
904 if (undoBtn) {
905 undoBtn.addEventListener('click', async () => {
906 if (!confirm('确认撤销此样本的人工审核结果?')) return;
907 undoBtn.disabled = true;
908 try {
909 await updateStagingReview(query.query_source_id, 'pending', '');
910 if (candidate) setReview(candidate, { final_decision: undefined, note: '' });
911 await loadQueryRows(query.query_source_id);
912 } catch (e) {
913 alert(`撤销失败: ${e.message}`);
914 }
915 renderDetail();
916 });
917 }
845 } 918 }
846 919
847 function csvValue(value) { 920 function csvValue(value) {
...@@ -910,7 +983,11 @@ ...@@ -910,7 +983,11 @@
910 }); 983 });
911 } 984 }
912 if (!rows.length) { 985 if (!rows.length) {
913 alert('当前结果中没有标注为“确认不重复”的 review 样本。'); 986 // 诊断信息:显示当前结果下实际找到的标注情况
987 const allForRun = Object.entries(reviews).filter(([k]) => k.startsWith(prefix));
988 const decisionSummary = allForRun.map(([, v]) => v.final_decision || '未标注').join(', ') || '无';
989 alert(`当前结果中没有标注为“确认不重复”的 review 样本。\n\n` +
990 `当前结果下找到 ${allForRun.length} 条标注,状态: ${decisionSummary}`);
914 return; 991 return;
915 } 992 }
916 if (!confirm(`确认入库 ${rows.length} 条审核通过样本?`)) return; 993 if (!confirm(`确认入库 ${rows.length} 条审核通过样本?`)) return;
...@@ -937,8 +1014,7 @@ ...@@ -937,8 +1014,7 @@
937 renderMetrics(); 1014 renderMetrics();
938 renderList(); 1015 renderList();
939 renderDetail(); 1016 renderDetail();
940 els.tabRetrieval.classList.toggle('active', state.mode === 'retrieval'); 1017
941 els.tabHits.classList.toggle('active', state.mode === 'hits');
942 } 1018 }
943 1019
944 async function loadGroups() { 1020 async function loadGroups() {
...@@ -981,6 +1057,9 @@ ...@@ -981,6 +1057,9 @@
981 const summary = await getJSON(`/api/csv?path=${encodeURIComponent(state.run.summary)}`); 1057 const summary = await getJSON(`/api/csv?path=${encodeURIComponent(state.run.summary)}`);
982 state.summary = summary.rows || []; 1058 state.summary = summary.rows || [];
983 const topKs = Array.from(new Set(state.summary.map(row => row.top_k))).filter(Boolean); 1059 const topKs = Array.from(new Set(state.summary.map(row => row.top_k))).filter(Boolean);
1060 const hasNumericTopK = topKs.some(k => /^\d+$/.test(k));
1061 els.topKLabel.style.display = hasNumericTopK ? '' : 'none';
1062 els.topKSelect.style.display = hasNumericTopK ? '' : 'none';
984 els.topKSelect.innerHTML = topKs.map(k => `<option value="${esc(k)}">${esc(k)}</option>`).join(''); 1063 els.topKSelect.innerHTML = topKs.map(k => `<option value="${esc(k)}">${esc(k)}</option>`).join('');
985 state.topK = topKs[0] || ''; 1064 state.topK = topKs[0] || '';
986 els.topKSelect.value = state.topK; 1065 els.topKSelect.value = state.topK;
...@@ -1048,22 +1127,7 @@ ...@@ -1048,22 +1127,7 @@
1048 await loadGroups(); 1127 await loadGroups();
1049 renderAll(); 1128 renderAll();
1050 }); 1129 });
1051 els.tabRetrieval.addEventListener('click', async () => { 1130
1052 state.mode = 'retrieval';
1053 state.page = 1;
1054 state.queryId = '';
1055 state.candidateId = '';
1056 await loadGroups();
1057 renderAll();
1058 });
1059 els.tabHits.addEventListener('click', async () => {
1060 state.mode = 'hits';
1061 state.page = 1;
1062 state.queryId = '';
1063 state.candidateId = '';
1064 await loadGroups();
1065 renderAll();
1066 });
1067 1131
1068 loadRuns().catch(err => { 1132 loadRuns().catch(err => {
1069 els.detail.innerHTML = `<div class="empty">${esc(err.message)}</div>`; 1133 els.detail.innerHTML = `<div class="empty">${esc(err.message)}</div>`;
......
...@@ -55,7 +55,7 @@ TARGET_COLUMNS = [ ...@@ -55,7 +55,7 @@ TARGET_COLUMNS = [
55 55
56 56
57 def _json_response(handler: BaseHTTPRequestHandler, payload: object, status: int = 200) -> None: 57 def _json_response(handler: BaseHTTPRequestHandler, payload: object, status: int = 200) -> None:
58 body = json.dumps(payload, ensure_ascii=False).encode("utf-8") 58 body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
59 try: 59 try:
60 handler.send_response(status) 60 handler.send_response(status)
61 handler.send_header("Content-Type", "application/json; charset=utf-8") 61 handler.send_header("Content-Type", "application/json; charset=utf-8")
...@@ -100,11 +100,19 @@ def _target_conn(): ...@@ -100,11 +100,19 @@ def _target_conn():
100 def _staging_summary() -> list[dict[str, str]]: 100 def _staging_summary() -> list[dict[str, str]]:
101 sql = f""" 101 sql = f"""
102 SELECT 102 SELECT
103 COUNT(*) AS total_count, 103 COUNT(DISTINCT source_song_id) AS total_count,
104 SUM(dedup_action = 'new') AS new_count, 104 SUM(CASE WHEN dedup_action = 'new' THEN 1 ELSE 0 END) AS new_count,
105 SUM(dedup_action = 'merge') AS merge_count, 105 SUM(CASE WHEN dedup_action = 'merge' THEN 1 ELSE 0 END) AS merge_count,
106 SUM(dedup_action = 'review') AS review_count 106 SUM(CASE WHEN dedup_action = 'review' THEN 1 ELSE 0 END) AS review_count
107 FROM {TARGET_TABLE_NAME_TMP} 107 FROM (
108 SELECT source_song_id, dedup_action
109 FROM {TARGET_TABLE_NAME_TMP}
110 WHERE (source_song_id, staging_id) IN (
111 SELECT source_song_id, MAX(staging_id)
112 FROM {TARGET_TABLE_NAME_TMP}
113 GROUP BY source_song_id
114 )
115 ) latest
108 """ 116 """
109 with _target_conn() as conn, conn.cursor() as cursor: 117 with _target_conn() as conn, conn.cursor() as cursor:
110 cursor.execute(sql) 118 cursor.execute(sql)
...@@ -123,6 +131,10 @@ def _staging_summary() -> list[dict[str, str]]: ...@@ -123,6 +131,10 @@ def _staging_summary() -> list[dict[str, str]]:
123 131
124 132
125 def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]: 133 def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]:
134 lyrics_url = str(row.get("lyrics_url") or "")
135 # 若 lyrics_url 不是有效 URL/路径,说明是原始歌词文本(skip_oss 模式),加前缀供前端直接使用
136 if lyrics_url and not lyrics_url.startswith(("http://", "https://", "/")):
137 lyrics_url = f"_raw:{lyrics_url}"
126 return { 138 return {
127 "top_k": "staging", 139 "top_k": "staging",
128 "rank": "1", 140 "rank": "1",
...@@ -130,7 +142,7 @@ def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]: ...@@ -130,7 +142,7 @@ def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]:
130 "query_name": str(row.get("name") or ""), 142 "query_name": str(row.get("name") or ""),
131 "query_lyricist": str(row.get("lyricist") or ""), 143 "query_lyricist": str(row.get("lyricist") or ""),
132 "query_composer": str(row.get("composer") or ""), 144 "query_composer": str(row.get("composer") or ""),
133 "query_lyrics_path": str(row.get("lyrics_url") or ""), 145 "query_lyrics_path": lyrics_url,
134 "candidate_id": str(row.get("matched_song_id") or ""), 146 "candidate_id": str(row.get("matched_song_id") or ""),
135 "candidate_name": "", 147 "candidate_name": "",
136 "candidate_lyricist": "", 148 "candidate_lyricist": "",
...@@ -145,6 +157,8 @@ def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]: ...@@ -145,6 +157,8 @@ def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]:
145 "staging_id": str(row.get("staging_id") or ""), 157 "staging_id": str(row.get("staging_id") or ""),
146 "review_status": str(row.get("biz_review_status") or ""), 158 "review_status": str(row.get("biz_review_status") or ""),
147 "review_note": str(row.get("biz_review_note") or ""), 159 "review_note": str(row.get("biz_review_note") or ""),
160 "reviewed_by": str(row.get("reviewed_by") or ""),
161 "reviewed_at": str(row.get("reviewed_at") or ""),
148 "l1_metadata_match": "1" if row.get("l1_matched_id") else "0", 162 "l1_metadata_match": "1" if row.get("l1_matched_id") else "0",
149 "l1_l2_conflict": "0", 163 "l1_l2_conflict": "0",
150 } 164 }
...@@ -161,22 +175,36 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int) -> ...@@ -161,22 +175,36 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int) ->
161 if mode == "hits": 175 if mode == "hits":
162 where_clauses.append("dedup_action IN ('merge', 'review')") 176 where_clauses.append("dedup_action IN ('merge', 'review')")
163 where = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" 177 where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
164 count_sql = f"SELECT COUNT(*) AS n FROM {TARGET_TABLE_NAME_TMP} {where}" 178 # 同一首歌可能因多次导入批次产生多行,按 source_song_id 去重取最新一行
179 count_sql = f"SELECT COUNT(DISTINCT source_song_id) AS n FROM {TARGET_TABLE_NAME_TMP} {where}"
165 sql = f""" 180 sql = f"""
166 SELECT staging_id, source_song_id, name, lyricist, composer, dedup_action, 181 SELECT s.staging_id, s.source_song_id, s.name, s.lyricist, s.composer, s.dedup_action,
167 biz_review_status, l1_matched_id 182 s.biz_review_status, s.l1_matched_id
168 FROM {TARGET_TABLE_NAME_TMP} 183 FROM {TARGET_TABLE_NAME_TMP} s
169 {where} 184 INNER JOIN (
185 SELECT source_song_id, MAX(staging_id) AS max_staging_id
186 FROM {TARGET_TABLE_NAME_TMP}
187 {where}
188 GROUP BY source_song_id
189 ) latest ON s.staging_id = latest.max_staging_id
170 ORDER BY 190 ORDER BY
171 CASE dedup_action WHEN 'review' THEN 0 WHEN 'merge' THEN 1 ELSE 2 END, 191 CASE s.dedup_action WHEN 'review' THEN 0 WHEN 'merge' THEN 1 ELSE 2 END,
172 staging_create_time DESC 192 s.staging_create_time DESC
173 LIMIT %s OFFSET %s 193 LIMIT %s OFFSET %s
174 """ 194 """
175 with _target_conn() as conn, conn.cursor() as cursor: 195 with _target_conn() as conn, conn.cursor() as cursor:
176 cursor.execute(count_sql, params) 196 cursor.execute(count_sql, params)
177 total = (cursor.fetchone() or {}).get("n", 0) 197 total = (cursor.fetchone() or {}).get("n", 0)
178 cursor.execute(sql, [*params, page_size, max(0, (page - 1) * page_size)]) 198 cursor.execute(sql, [*params, page_size, max(0, (page - 1) * page_size)])
179 rows = cursor.fetchall() 199 raw_rows = cursor.fetchall()
200 # Python 层面再去重,防止 SQL 层面因数据库版本或数据异常未能完全去重
201 seen_source_ids: set[str] = set()
202 rows = []
203 for row in raw_rows:
204 sid = str(row.get("source_song_id") or "")
205 if sid not in seen_source_ids:
206 seen_source_ids.add(sid)
207 rows.append(row)
180 groups = [ 208 groups = [
181 { 209 {
182 "id": str(row.get("source_song_id") or ""), 210 "id": str(row.get("source_song_id") or ""),
...@@ -200,7 +228,68 @@ def _staging_query_rows(query_id: str) -> dict[str, object]: ...@@ -200,7 +228,68 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
200 with _target_conn() as conn, conn.cursor() as cursor: 228 with _target_conn() as conn, conn.cursor() as cursor:
201 cursor.execute(sql, (query_id,)) 229 cursor.execute(sql, (query_id,))
202 row = cursor.fetchone() 230 row = cursor.fetchone()
203 return {"rows": [_staging_dashboard_row(row)] if row else []} 231 if not row:
232 return {"rows": []}
233 matched_song_id = row.get("matched_song_id")
234 matched_row = None
235 if matched_song_id:
236 # 先尝试目标表查找(matched_id 是目标库 ID 的情况)
237 cursor.execute(
238 f"SELECT name, lyricist, composer, lyrics_url FROM {TARGET_TABLE_NAME} WHERE id = %s",
239 (matched_song_id,),
240 )
241 matched_row = cursor.fetchone()
242 # 目标表找不到时,回退到暂存表查找(同批次内匹配,matched_id 是源库 source_song_id)
243 if not matched_row:
244 cursor.execute(
245 f"SELECT name, lyricist, composer, lyrics_url FROM {TARGET_TABLE_NAME_TMP}"
246 f" WHERE source_song_id = %s ORDER BY staging_id DESC LIMIT 1",
247 (str(matched_song_id),),
248 )
249 matched_row = cursor.fetchone()
250 dashboard_row = _staging_dashboard_row(row)
251 if matched_row:
252 dashboard_row["candidate_name"] = str(matched_row.get("name") or "")
253 dashboard_row["candidate_lyricist"] = str(matched_row.get("lyricist") or "")
254 dashboard_row["candidate_composer"] = str(matched_row.get("composer") or "")
255 cand_lyrics = str(matched_row.get("lyrics_url") or "")
256 if cand_lyrics and not cand_lyrics.startswith(("http://", "https://", "/")):
257 cand_lyrics = f"_raw:{cand_lyrics}"
258 dashboard_row["candidate_lyrics_path"] = cand_lyrics
259 return {"rows": [dashboard_row]}
260
261
262 def _update_staging_review(source_song_id: str, decision: str, note: str, reviewer: str = "dashboard") -> dict[str, object]:
263 """更新暂存表的人工审核状态。
264
265 decision: 'approved_import' | 'rejected_duplicate' | 'unsure' | 'pending' (撤销)
266 """
267 if decision == "pending":
268 # 撤销审核
269 sql = f"""
270 UPDATE {TARGET_TABLE_NAME_TMP}
271 SET biz_review_status = 'pending',
272 reviewed_by = NULL,
273 reviewed_at = NULL,
274 biz_review_note = NULLIF(%s, '')
275 WHERE source_song_id = %s AND dedup_action = 'review'
276 """
277 params = [note, source_song_id]
278 else:
279 sql = f"""
280 UPDATE {TARGET_TABLE_NAME_TMP}
281 SET biz_review_status = %s,
282 reviewed_by = %s,
283 reviewed_at = NOW(),
284 biz_review_note = NULLIF(%s, '')
285 WHERE source_song_id = %s AND dedup_action = 'review'
286 """
287 params = [decision, reviewer, note, source_song_id]
288 with _target_conn() as conn, conn.cursor() as cursor:
289 cursor.execute(sql, params)
290 affected = cursor.rowcount
291 conn.commit()
292 return {"source_song_id": source_song_id, "decision": decision, "affected": affected}
204 293
205 294
206 def _import_approved_staging(source_ids: list[str], reviewer: str = "dashboard") -> dict[str, object]: 295 def _import_approved_staging(source_ids: list[str], reviewer: str = "dashboard") -> dict[str, object]:
...@@ -227,12 +316,18 @@ def _import_approved_staging(source_ids: list[str], reviewer: str = "dashboard") ...@@ -227,12 +316,18 @@ def _import_approved_staging(source_ids: list[str], reviewer: str = "dashboard")
227 AND s.biz_review_status = 'approved_import' 316 AND s.biz_review_status = 'approved_import'
228 AND s.staging_status <> 'imported' 317 AND s.staging_status <> 'imported'
229 """ 318 """
230 mark_sql = f""" 319 # 入库后查询目标表实际 ID,正确回填 imported_song_id(不能用暂存表自己的 id)
320 lookup_target_id_sql = f"""
321 SELECT source_song_id, id AS target_id
322 FROM {TARGET_TABLE_NAME}
323 WHERE source_song_id IN ({placeholders})
324 """
325 mark_sql_template = f"""
231 UPDATE {TARGET_TABLE_NAME_TMP} 326 UPDATE {TARGET_TABLE_NAME_TMP}
232 SET staging_status = 'imported', 327 SET staging_status = 'imported',
233 imported_song_id = id, 328 imported_song_id = %s,
234 error_message = NULL 329 error_message = NULL
235 WHERE source_song_id IN ({placeholders}) 330 WHERE source_song_id = %s
236 AND dedup_action = 'review' 331 AND dedup_action = 'review'
237 AND biz_review_status = 'approved_import' 332 AND biz_review_status = 'approved_import'
238 """ 333 """
...@@ -241,9 +336,19 @@ def _import_approved_staging(source_ids: list[str], reviewer: str = "dashboard") ...@@ -241,9 +336,19 @@ def _import_approved_staging(source_ids: list[str], reviewer: str = "dashboard")
241 approved_count = cursor.rowcount 336 approved_count = cursor.rowcount
242 cursor.execute(insert_sql, source_ids) 337 cursor.execute(insert_sql, source_ids)
243 inserted_count = cursor.rowcount 338 inserted_count = cursor.rowcount
244 cursor.execute(mark_sql, source_ids) 339 # 从目标表查出实际生成的主键 ID,逐条回填 imported_song_id
340 cursor.execute(lookup_target_id_sql, source_ids)
341 id_map = {str(row["source_song_id"]): row["target_id"] for row in cursor.fetchall()}
342 imported_updated = 0
343 for sid, target_id in id_map.items():
344 cursor.execute(mark_sql_template, [target_id, sid])
345 imported_updated += cursor.rowcount
245 conn.commit() 346 conn.commit()
246 return {"approved_count": approved_count, "inserted_count": inserted_count} 347 return {
348 "approved_count": approved_count,
349 "inserted_count": inserted_count,
350 "imported_song_ids_updated": imported_updated,
351 }
247 352
248 353
249 def _report_runs() -> list[dict[str, str]]: 354 def _report_runs() -> list[dict[str, str]]:
...@@ -481,6 +586,7 @@ def _run_import(review_csv: Path) -> dict[str, object]: ...@@ -481,6 +586,7 @@ def _run_import(review_csv: Path) -> dict[str, object]:
481 "--review-result-csv", 586 "--review-result-csv",
482 str(review_csv), 587 str(review_csv),
483 "--load-existing-lyrics", 588 "--load-existing-lyrics",
589 "--skip-media-oss",
484 ] 590 ]
485 result = subprocess.run( 591 result = subprocess.run(
486 cmd, 592 cmd,
...@@ -522,6 +628,7 @@ class Handler(BaseHTTPRequestHandler): ...@@ -522,6 +628,7 @@ class Handler(BaseHTTPRequestHandler):
522 else: 628 else:
523 _json_response(self, {"path": str(path), "rows": _read_csv(path)}) 629 _json_response(self, {"path": str(path), "rows": _read_csv(path)})
524 except Exception as exc: # noqa: BLE001 - local diagnostic API 630 except Exception as exc: # noqa: BLE001 - local diagnostic API
631 print(f"[ERROR] /api/csv 异常: {exc}")
525 _error(self, str(exc), status=404) 632 _error(self, str(exc), status=404)
526 return 633 return
527 if parsed.path == "/api/groups": 634 if parsed.path == "/api/groups":
...@@ -577,6 +684,23 @@ class Handler(BaseHTTPRequestHandler): ...@@ -577,6 +684,23 @@ class Handler(BaseHTTPRequestHandler):
577 684
578 def do_POST(self) -> None: 685 def do_POST(self) -> None:
579 parsed = urlparse(self.path) 686 parsed = urlparse(self.path)
687 if parsed.path == "/api/staging-review":
688 try:
689 length = int(self.headers.get("Content-Length", "0"))
690 payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
691 source_id = str(payload.get("source_id", "")).strip()
692 decision = str(payload.get("decision", "")).strip()
693 note = str(payload.get("note", "")).strip()
694 reviewer = str(payload.get("reviewer", "dashboard")).strip() or "dashboard"
695 if not source_id:
696 raise ValueError("source_id is required")
697 if decision not in ("approved_import", "rejected_duplicate", "unsure", "pending"):
698 raise ValueError(f"invalid decision: {decision}")
699 result = _update_staging_review(source_id, decision, note, reviewer)
700 _json_response(self, result)
701 except Exception as exc: # noqa: BLE001
702 _error(self, str(exc), status=400)
703 return
580 if parsed.path != "/api/import-reviewed": 704 if parsed.path != "/api/import-reviewed":
581 _error(self, "not found", status=404) 705 _error(self, "not found", status=404)
582 return 706 return
......
1 # 音眼词曲跨库去重任务清单
2
3 ## 一、数据定位
4
5 ### 音眼库
6 - [x] 完成音眼库已归集数据定位(以 `hk_song_platform` 为主)
7
8 ### 词曲库
9 - [x] 完成词曲库已有数据定位(以`hk_songs`
10 - [x] 确认词曲、录音对应的平台主页(音眼.hk_music_record.platform_index_url)
11 - [x] 整理审核所需的展示信息
12
13 ---
14
15 ## 二、跨库疑似重复比对
16
17 - [x] 建立音眼词曲与词曲库的候选匹配关系
18 - [x] 完成 L1(基础信息)疑似重复筛选
19 - [x] 完成 L2(歌词内容)重复确认
20 - [x] 输出待人工审核的疑似重复数据
21
22 ---
23
24 ## 三、人工审核管道
25
26 - [x] 建立待审核数据池
27 - [x] 保存疑似重复关系及匹配依据
28 - [x] 建立审核状态管理(待审核 / 已确认 / 已忽略)
29 - [ ] 保留审核日志,支持结果追溯
30
31 ---
32
33 ## 四、审核页面
34
35 ### 数据展示
36 - [x] 展示音眼词曲信息
37 - [x] 展示词曲库词曲信息
38 - [x] 展示双方歌词内容
39 - [ ] 展示关联录音数量
40 - [ ] 展示平台主页入口
41 - [ ] 展示其他辅助判断信息(歌名、作者等)
42
43 ### 审核操作
44 - [ ] 支持确认新入库
45 - [ ] 支持确认合并已有词曲
46 - [ ] 支持暂不处理 / 忽略
47 - [ ] 支持跳转平台主页辅助核验
48
49 ---
50
51 ## 五、审核结果处理
52
53 - [ ] 实现新词曲入库流程
54 - [ ] 实现词曲合并流程
55 - [ ] 更新词曲与录音关联关系
56 - [ ] 保留完整操作日志
57
58 ---
59
60 ## 当前里程碑
61
62 - [x] 音眼库数据定位完成
63 - [x] 词曲库数据定位完成
64 - [x] 跨库比对完成
65 - [x] 人工审核管道完成
66 - [x] 审核页面完成
67 - [ ] 审核结果处理完成
68 - [ ] 跨库去重流程上线
...\ No newline at end of file ...\ No newline at end of file