Commit 93b03ea9 93b03ea91557e1bebbe201d31f48271e2d68830e by 沈秋雨

feat(dedup): 优化歌词去重逻辑并引入作者字段增量合并

- 新增 L2 候选加载缓存机制,避免重复下载歌词内容
- L2 去重返回 top-K 召回候选信息,丰富判定依据
- 分类函数新增跳过导入的规则,如空歌词且作者不详、原声类歌曲名等
- 实现作者字段(作词、作曲)增量合并功能,避免重复覆盖
- 对合并冲突添加异常处理并记录日志,确保稳定执行
- 引入后台线程异步写入数据库,提升批量处理性能
- 优化 L1 索引更新,避免去重误差,提高去重准确率
- 扩展统计字段,新增作者合并和跳过导入计数
- 前端增加决策过滤选项,支持根据决策筛选展示记录
- 修改界面显示逻辑,支持删除已审核样本,完善用户操作
- 调整页面显示候选数目为 Top5,优化视觉体验
- 参数新增 L2 精确哈希自动合并的最小歌词长度配置
- 添加数据库表结构自动迁移,新增 recalled_candidates 字段支持新功能
- 增强程序中断时数据库写入的安全关闭与日志提示
1 parent 25cb261f
...@@ -6,8 +6,10 @@ hk_music_record 聚合数据导入脚本 ...@@ -6,8 +6,10 @@ hk_music_record 聚合数据导入脚本
6 6
7 import argparse 7 import argparse
8 import csv 8 import csv
9 import json
9 import logging 10 import logging
10 import os 11 import os
12 import queue
11 import re 13 import re
12 import sys 14 import sys
13 import time 15 import time
...@@ -40,8 +42,10 @@ load_dotenv() ...@@ -40,8 +42,10 @@ load_dotenv()
40 OUTPUT_DIR = Path(__file__).resolve().parent / 'output' 42 OUTPUT_DIR = Path(__file__).resolve().parent / 'output'
41 LOG_DIR = OUTPUT_DIR / 'logs' 43 LOG_DIR = OUTPUT_DIR / 'logs'
42 REPORT_DIR = OUTPUT_DIR / 'reports' 44 REPORT_DIR = OUTPUT_DIR / 'reports'
45 CACHE_DIR = OUTPUT_DIR / 'cache'
43 LOG_DIR.mkdir(parents=True, exist_ok=True) 46 LOG_DIR.mkdir(parents=True, exist_ok=True)
44 REPORT_DIR.mkdir(parents=True, exist_ok=True) 47 REPORT_DIR.mkdir(parents=True, exist_ok=True)
48 CACHE_DIR.mkdir(parents=True, exist_ok=True)
45 49
46 # 日志配置 50 # 日志配置
47 logging.basicConfig( 51 logging.basicConfig(
...@@ -245,7 +249,8 @@ INSERT INTO {table} ( ...@@ -245,7 +249,8 @@ INSERT INTO {table} (
245 lyric_archive_element_id, melody_archive_element_id, audio_fingerprint, 249 lyric_archive_element_id, melody_archive_element_id, audio_fingerprint,
246 record_count, 250 record_count,
247 dedup_action, dedup_decision, dedup_confidence, matched_song_id, l1_matched_id, 251 dedup_action, dedup_decision, dedup_confidence, matched_song_id, l1_matched_id,
248 merge_authors, dedup_reason, biz_review_status, staging_status, imported_song_id 252 merge_authors, dedup_reason, biz_review_status, staging_status, imported_song_id,
253 recalled_candidates
249 ) VALUES ( 254 ) VALUES (
250 %s,%s, 255 %s,%s,
251 %s,%s,%s,%s,%s,%s, 256 %s,%s,%s,%s,%s,%s,
...@@ -261,7 +266,8 @@ INSERT INTO {table} ( ...@@ -261,7 +266,8 @@ INSERT INTO {table} (
261 %s,%s,%s, 266 %s,%s,%s,
262 %s,%s,%s,%s,%s, 267 %s,%s,%s,%s,%s,
263 %s, 268 %s,
264 %s,%s,%s,%s,%s 269 %s,%s,%s,%s,%s,
270 %s
265 ) 271 )
266 ON DUPLICATE KEY UPDATE 272 ON DUPLICATE KEY UPDATE
267 dedup_action = VALUES(dedup_action), 273 dedup_action = VALUES(dedup_action),
...@@ -274,6 +280,7 @@ ON DUPLICATE KEY UPDATE ...@@ -274,6 +280,7 @@ ON DUPLICATE KEY UPDATE
274 biz_review_status = VALUES(biz_review_status), 280 biz_review_status = VALUES(biz_review_status),
275 staging_status = VALUES(staging_status), 281 staging_status = VALUES(staging_status),
276 imported_song_id = VALUES(imported_song_id), 282 imported_song_id = VALUES(imported_song_id),
283 recalled_candidates = VALUES(recalled_candidates),
277 error_message = NULL 284 error_message = NULL
278 """ 285 """
279 286
...@@ -566,11 +573,18 @@ def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str, re ...@@ -566,11 +573,18 @@ def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str, re
566 biz_review_status = 'not_required' 573 biz_review_status = 'not_required'
567 staging_status = 'skipped' 574 staging_status = 'skipped'
568 imported_song_id = None 575 imported_song_id = None
576 elif dedup_action == 'skip':
577 biz_review_status = 'not_required'
578 staging_status = 'skipped'
579 imported_song_id = None
569 else: 580 else:
570 biz_review_status = 'pending' 581 biz_review_status = 'pending'
571 staging_status = 'staged' 582 staging_status = 'staged'
572 imported_song_id = None 583 imported_song_id = None
573 584
585 recalled_candidates = action.get('recalled_candidates')
586 recalled_json = json.dumps(recalled_candidates, ensure_ascii=False) if recalled_candidates else None
587
574 return ( 588 return (
575 ID_GENERATOR.next_id(), 589 ID_GENERATOR.next_id(),
576 import_batch_id, 590 import_batch_id,
...@@ -586,6 +600,7 @@ def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str, re ...@@ -586,6 +600,7 @@ def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str, re
586 biz_review_status, 600 biz_review_status,
587 staging_status, 601 staging_status,
588 imported_song_id, 602 imported_song_id,
603 recalled_json,
589 ) 604 )
590 605
591 606
...@@ -605,6 +620,8 @@ def process_row_with_oss(args_tuple): ...@@ -605,6 +620,8 @@ def process_row_with_oss(args_tuple):
605 _T2S = opencc.OpenCC('t2s') 620 _T2S = opencc.OpenCC('t2s')
606 _METADATA_PUNCT = set(' \t\n\r,。!?;:、"“”‘’·…—~!¥()【】《》〈〉「」『』﹏,.:;!?()[]{}<>|/\\_-') 621 _METADATA_PUNCT = set(' \t\n\r,。!?;:、"“”‘’·…—~!¥()【】《》〈〉「」『』﹏,.:;!?()[]{}<>|/\\_-')
607 _INSTRUMENTAL_LYRIC_RE = re.compile(r'(?:纯音乐|純音樂|无歌词|無歌詞|没有歌词|沒有歌詞|instrumental)', re.IGNORECASE) 622 _INSTRUMENTAL_LYRIC_RE = re.compile(r'(?:纯音乐|純音樂|无歌词|無歌詞|没有歌词|沒有歌詞|instrumental)', re.IGNORECASE)
623 _ORIGINAL_SOUND_RE = re.compile(r'^@\S+的原声$')
624 _UNKNOWN_AUTHORS = {'不详', '未知', '无', '佚名', 'unknown', 'none', 'n/a', ''}
608 625
609 626
610 def _normalize_meta(text: str | None) -> str: 627 def _normalize_meta(text: str | None) -> str:
...@@ -616,6 +633,26 @@ def _normalize_meta(text: str | None) -> str: ...@@ -616,6 +633,26 @@ def _normalize_meta(text: str | None) -> str:
616 return ''.join(c for c in text if c not in _METADATA_PUNCT) 633 return ''.join(c for c in text if c not in _METADATA_PUNCT)
617 634
618 635
636 def mark_rows_in_l1_index(rows: list[dict], l1_index: dict[tuple[str, str, str], int | str]) -> None:
637 """把已接受处理的行预先写入内存 L1 索引,供后续批次去重使用。"""
638 for row in rows:
639 key = (
640 _normalize_meta(row.get('name')),
641 _normalize_meta(row.get('lyricist')),
642 _normalize_meta(row.get('composer')),
643 )
644 if key[0] and key not in l1_index:
645 l1_index[key] = row['source_id']
646
647
648 def stop_db_writer(write_queue: queue.Queue, writer_thread: threading.Thread) -> None:
649 """等待已入队 DB 写入完成,再停止后台写入线程。"""
650 write_queue.join()
651 if writer_thread.is_alive():
652 write_queue.put(None)
653 writer_thread.join()
654
655
619 def is_instrumental_lyrics(text: str | None) -> bool: 656 def is_instrumental_lyrics(text: str | None) -> bool:
620 """识别纯音乐/无歌词提示文本,这类内容不参与 L2 歌词去重。""" 657 """识别纯音乐/无歌词提示文本,这类内容不参与 L2 歌词去重。"""
621 if not text: 658 if not text:
...@@ -678,6 +715,86 @@ def load_source_record_counts(source_conn, source_song_ids: set[str]) -> dict[st ...@@ -678,6 +715,86 @@ def load_source_record_counts(source_conn, source_song_ids: set[str]) -> dict[st
678 return counts 715 return counts
679 716
680 717
718 def _decode_lyric_content(content: bytes) -> str:
719 try:
720 return content.decode('utf-8')
721 except UnicodeDecodeError:
722 return content.decode('utf-8', errors='replace')
723
724
725 def load_existing_l2_candidates(
726 rows: list[dict],
727 cache_path: Path,
728 downloader=download_file,
729 ) -> tuple[list[LyricRecord], dict[str, int]]:
730 """从目标库 lyrics_url 行构建 L2 候选;本地缓存已下载歌词,避免重启全量拉 OSS。"""
731 cached_by_key: dict[tuple[str, str], dict] = {}
732 if cache_path.exists():
733 with cache_path.open('r', encoding='utf-8') as f:
734 for line in f:
735 line = line.strip()
736 if not line:
737 continue
738 try:
739 item = json.loads(line)
740 except json.JSONDecodeError:
741 continue
742 record_id = str(item.get('id') or '')
743 url = str(item.get('url') or '')
744 lyrics = item.get('lyrics')
745 if record_id and url and isinstance(lyrics, str) and lyrics.strip():
746 cached_by_key[(record_id, url)] = item
747
748 records: list[LyricRecord] = []
749 next_cache: dict[tuple[str, str], dict] = {}
750 stats = {'cached': 0, 'downloaded': 0, 'failed': 0}
751
752 for row in rows:
753 url = row.get('lyrics_url')
754 if not url or not str(url).startswith(('http://', 'https://')):
755 continue
756
757 record_id = str(row['id'])
758 key = (record_id, str(url))
759 item = cached_by_key.get(key)
760 if item:
761 stats['cached'] += 1
762 else:
763 content, _ = downloader(str(url), timeout=10)
764 if not content:
765 stats['failed'] += 1
766 continue
767 item = {
768 'id': record_id,
769 'url': str(url),
770 'lyrics': _decode_lyric_content(content),
771 'name': row.get('name'),
772 'singer': row.get('singer'),
773 'lyricist': row.get('lyricist'),
774 'composer': row.get('composer'),
775 }
776 stats['downloaded'] += 1
777
778 next_cache[key] = item
779 records.append(LyricRecord(
780 record_id=record_id,
781 lyrics=item['lyrics'],
782 title=row.get('name') or item.get('name'),
783 artist=row.get('singer') or item.get('singer'),
784 lyricist=row.get('lyricist') or item.get('lyricist'),
785 composer=row.get('composer') or item.get('composer'),
786 ))
787
788 cache_path.parent.mkdir(parents=True, exist_ok=True)
789 tmp_path = cache_path.with_suffix(cache_path.suffix + '.tmp')
790 with tmp_path.open('w', encoding='utf-8') as f:
791 for item in next_cache.values():
792 f.write(json.dumps(item, ensure_ascii=False) + '\n')
793 tmp_path.replace(cache_path)
794
795 return records, stats
796
797
681 def check_l1(row: dict, l1_index: dict) -> tuple[bool, int | None]: 798 def check_l1(row: dict, l1_index: dict) -> tuple[bool, int | None]:
682 """L1 元数据去重,返回 (is_duplicate, matched_id)""" 799 """L1 元数据去重,返回 (is_duplicate, matched_id)"""
683 key = ( 800 key = (
...@@ -760,13 +877,16 @@ def check_l2( ...@@ -760,13 +877,16 @@ def check_l2(
760 row: dict, 877 row: dict,
761 checker: DuplicateChecker, 878 checker: DuplicateChecker,
762 candidates: list[LyricRecord] | L2CandidateIndex, 879 candidates: list[LyricRecord] | L2CandidateIndex,
763 ) -> tuple[str, float, str | None, str]: 880 ) -> tuple[str, float, str | None, str, list[dict]]:
764 """L2 歌词内容去重,返回 (decision, confidence, matched_id, reason)""" 881 """L2 歌词内容去重,返回 (decision, confidence, matched_id, reason, recalled_candidates)。
882
883 recalled_candidates: top-K 召回候选信息列表,即使最终判定为 new 也返回。
884 """
765 lyrics_text = row.get('lyrics_txt_content') 885 lyrics_text = row.get('lyrics_txt_content')
766 if not lyrics_text or not lyrics_text.strip(): 886 if not lyrics_text or not lyrics_text.strip():
767 return 'new', 1.0, None, '无歌词内容,跳过 L2' 887 return 'new', 1.0, None, '无歌词内容,跳过 L2', []
768 if is_instrumental_lyrics(lyrics_text): 888 if is_instrumental_lyrics(lyrics_text):
769 return 'new', 1.0, None, '歌词内容包含纯音乐/无歌词提示,跳过 L2' 889 return 'new', 1.0, None, '歌词内容包含纯音乐/无歌词提示,跳过 L2', []
770 890
771 record_id = row.get('source_id', row.get('id')) 891 record_id = row.get('source_id', row.get('id'))
772 record = LyricRecord( 892 record = LyricRecord(
...@@ -778,18 +898,37 @@ def check_l2( ...@@ -778,18 +898,37 @@ def check_l2(
778 composer=row.get('composer'), 898 composer=row.get('composer'),
779 ) 899 )
780 if isinstance(candidates, L2CandidateIndex): 900 if isinstance(candidates, L2CandidateIndex):
781 candidates = candidates.recall(record) 901 recalled_records = candidates.recall(record)
782 if not candidates: 902 else:
783 return 'new', 1.0, None, '无候选集' 903 recalled_records = candidates
904 if not recalled_records:
905 return 'new', 1.0, None, '无候选集', []
906
907 result = checker.check_record_against_candidates(record, recalled_records, max_candidates=5)
908
909 # 构建召回候选信息(保留 top 10,即使判定为 new)
910 recalled_candidates: list[dict] = []
911 candidate_record_map: dict[str, LyricRecord] = {r.record_id: r for r in recalled_records}
912 for cm in result.candidates[:10]:
913 cr = candidate_record_map.get(cm.record_id)
914 recalled_candidates.append({
915 'id': cm.record_id,
916 'name': cr.title if cr else None,
917 'lyricist': cr.lyricist if cr else None,
918 'composer': cr.composer if cr else None,
919 'decision': cm.decision.value,
920 'confidence': round(cm.confidence, 4),
921 'jaccard': round(cm.jaccard, 4),
922 'line_coverage': round(cm.line_coverage, 4),
923 })
784 924
785 result = checker.check_record_against_candidates(record, candidates, max_candidates=5)
786 # new 记录不返回 matched_id,避免前端误认为有命中 925 # new 记录不返回 matched_id,避免前端误认为有命中
787 if result.decision == DuplicateDecision.NEW: 926 if result.decision == DuplicateDecision.NEW:
788 matched_id = None 927 matched_id = None
789 else: 928 else:
790 matched_id = result.candidates[0].record_id if result.candidates else None 929 matched_id = result.candidates[0].record_id if result.candidates else None
791 930
792 return result.decision.value, result.confidence, matched_id, result.reason 931 return result.decision.value, result.confidence, matched_id, result.reason, recalled_candidates
793 932
794 933
795 def _candidate_by_id( 934 def _candidate_by_id(
...@@ -817,6 +956,61 @@ def _writers_differ(row: dict, candidate: LyricRecord | None) -> bool: ...@@ -817,6 +956,61 @@ def _writers_differ(row: dict, candidate: LyricRecord | None) -> bool:
817 ) 956 )
818 957
819 958
959 _AUTHOR_SEP_RE = re.compile(r'[,,/;;、]')
960 _AUTHOR_MERGE_SQL = 'UPDATE `{table}` SET {{col}} = %s WHERE id = %s'.format(table=TARGET_TABLE_NAME)
961
962
963 def _merge_author_field(existing: str | None, new: str | None) -> str:
964 """增量合并作者字段:保留已有作者,追加新作者(去重)。"""
965 if not new:
966 return existing or ''
967 if not existing:
968 return new
969
970 def _split_authors(text: str) -> list[str]:
971 return [a.strip() for a in _AUTHOR_SEP_RE.split(text) if a.strip()]
972
973 existing_list = _split_authors(existing)
974 new_list = _split_authors(new)
975
976 existing_norm = {_normalize_meta(a) for a in existing_list}
977 result = list(existing_list)
978 for author in new_list:
979 if _normalize_meta(author) not in existing_norm:
980 result.append(author)
981 existing_norm.add(_normalize_meta(author))
982
983 merged = '、'.join(result)
984 return merged if merged != existing else ''
985
986
987 def execute_author_merges(cursor, author_merge_tasks: list[dict]) -> int:
988 """执行作者字段增量合并 UPDATE,返回成功合并的记录数。"""
989 merged_count = 0
990 for task in author_merge_tasks:
991 try:
992 if task['lyricist']:
993 cursor.execute(
994 _AUTHOR_MERGE_SQL.format(col='lyricist'),
995 (task['lyricist'], task['matched_id']),
996 )
997 if task['composer']:
998 cursor.execute(
999 _AUTHOR_MERGE_SQL.format(col='composer'),
1000 (task['composer'], task['matched_id']),
1001 )
1002 merged_count += 1
1003 except Exception as e:
1004 logger.error(
1005 f"作者字段合并失败 matched_id={task['matched_id']}: {e}"
1006 )
1007 return merged_count
1008
1009
1010 def _is_short_exact_l2_review(decision: str, reason: str) -> bool:
1011 return decision == 'review' and '规范化后的原文哈希一致' in reason and '有效歌词过短' in reason
1012
1013
820 def classify_dedup_action( 1014 def classify_dedup_action(
821 row: dict, 1015 row: dict,
822 l1_index: dict, 1016 l1_index: dict,
...@@ -830,8 +1024,48 @@ def classify_dedup_action( ...@@ -830,8 +1024,48 @@ def classify_dedup_action(
830 当判定为 duplicate 时,根据源库关联录音数量决定合并方向: 1024 当判定为 duplicate 时,根据源库关联录音数量决定合并方向:
831 录音少的合并到录音多的。 1025 录音少的合并到录音多的。
832 """ 1026 """
833 l1_matched, l1_matched_id = check_l1(row, l1_index) 1027 # 规则1:歌词为空 + 词曲作者不详 → 不导
834 lyrics_text = row.get('lyrics_txt_content') or '' 1028 lyrics_text = row.get('lyrics_txt_content') or ''
1029 no_lyrics = not lyrics_text or not lyrics_text.strip()
1030 lyricist_raw = (row.get('lyricist') or '').strip()
1031 composer_raw = (row.get('composer') or '').strip()
1032 if no_lyrics and lyricist_raw.lower() in _UNKNOWN_AUTHORS and composer_raw.lower() in _UNKNOWN_AUTHORS:
1033 return {
1034 'action': 'skip',
1035 'decision': 'skip',
1036 'confidence': 1.0,
1037 'matched_id': None,
1038 'reason': '歌词为空且词曲作者不详,不导入',
1039 'l1_matched': False,
1040 'l1_matched_id': None,
1041 'merge_authors': False,
1042 'matched_candidate': None,
1043 'merge_direction': None,
1044 'new_record_count': row.get('record_count'),
1045 'existing_record_count': None,
1046 'recalled_candidates': [],
1047 }
1048
1049 # 规则2:歌名形如 "@XXX的原声" → 不导
1050 song_name = (row.get('name') or '').strip()
1051 if _ORIGINAL_SOUND_RE.match(song_name):
1052 return {
1053 'action': 'skip',
1054 'decision': 'skip',
1055 'confidence': 1.0,
1056 'matched_id': None,
1057 'reason': f'歌名「{song_name}」为原声类内容,不导入',
1058 'l1_matched': False,
1059 'l1_matched_id': None,
1060 'merge_authors': False,
1061 'matched_candidate': None,
1062 'merge_direction': None,
1063 'new_record_count': row.get('record_count'),
1064 'existing_record_count': None,
1065 'recalled_candidates': [],
1066 }
1067
1068 l1_matched, l1_matched_id = check_l1(row, l1_index)
835 l2_candidates_for_check: list[LyricRecord] | L2CandidateIndex = candidates 1069 l2_candidates_for_check: list[LyricRecord] | L2CandidateIndex = candidates
836 if isinstance(candidates, L2CandidateIndex): 1070 if isinstance(candidates, L2CandidateIndex):
837 record = LyricRecord( 1071 record = LyricRecord(
...@@ -849,7 +1083,57 @@ def classify_dedup_action( ...@@ -849,7 +1083,57 @@ def classify_dedup_action(
849 recalled.append(l1_candidate) 1083 recalled.append(l1_candidate)
850 l2_candidates_for_check = recalled 1084 l2_candidates_for_check = recalled
851 1085
852 decision, confidence, matched_id, reason = check_l2(row, checker, l2_candidates_for_check) 1086 decision, confidence, matched_id, reason, recalled_candidates = check_l2(row, checker, l2_candidates_for_check)
1087
1088 if _is_short_exact_l2_review(decision, reason):
1089 if not l1_matched:
1090 return {
1091 'action': 'new',
1092 'decision': 'new',
1093 'confidence': 1.0,
1094 'matched_id': None,
1095 'reason': f'{reason};L1 元数据未命中,按新歌处理',
1096 'l1_matched': l1_matched,
1097 'l1_matched_id': l1_matched_id,
1098 'merge_authors': False,
1099 'matched_candidate': None,
1100 'merge_direction': None,
1101 'new_record_count': row.get('record_count'),
1102 'existing_record_count': None,
1103 'recalled_candidates': recalled_candidates,
1104 }
1105
1106 matched_id = str(l1_matched_id)
1107 matched_candidate = _candidate_by_id(l2_candidates_for_check, matched_id)
1108 new_count = (row.get('record_count') or 0) if source_record_counts is not None else None
1109 existing_count = None
1110 if source_record_counts is not None and l1_matched_id is not None:
1111 try:
1112 existing_sid = _target_id_to_source_sid.get(int(l1_matched_id))
1113 if existing_sid:
1114 existing_count = source_record_counts.get(existing_sid, 0)
1115 except (ValueError, TypeError):
1116 pass
1117
1118 merge_direction = 'new_into_existing'
1119 if new_count is not None and existing_count is not None and new_count > existing_count:
1120 merge_direction = 'existing_into_new'
1121
1122 return {
1123 'action': 'merge',
1124 'decision': 'duplicate',
1125 'confidence': 1.0,
1126 'matched_id': matched_id,
1127 'reason': f'{reason};L1 元数据命中,按 L1 判定重复',
1128 'l1_matched': l1_matched,
1129 'l1_matched_id': l1_matched_id,
1130 'merge_authors': _writers_differ(row, matched_candidate),
1131 'matched_candidate': matched_candidate,
1132 'merge_direction': merge_direction,
1133 'new_record_count': new_count,
1134 'existing_record_count': existing_count,
1135 'recalled_candidates': recalled_candidates,
1136 }
853 1137
854 if decision == 'duplicate': 1138 if decision == 'duplicate':
855 matched_candidate = _candidate_by_id(l2_candidates_for_check, matched_id) 1139 matched_candidate = _candidate_by_id(l2_candidates_for_check, matched_id)
...@@ -866,6 +1150,27 @@ def classify_dedup_action( ...@@ -866,6 +1150,27 @@ def classify_dedup_action(
866 except (ValueError, TypeError): 1150 except (ValueError, TypeError):
867 pass 1151 pass
868 1152
1153 # 规则3:歌词相似但歌名与词曲作者均不一致 → 洗盗蹭嫌疑,强制 review
1154 if matched_candidate:
1155 _name_match = _normalize_meta(row.get('name')) == _normalize_meta(matched_candidate.title)
1156 _authors_match = not _writers_differ(row, matched_candidate)
1157 if not _name_match and not _authors_match:
1158 return {
1159 'action': 'review',
1160 'decision': 'review',
1161 'confidence': confidence,
1162 'matched_id': matched_id,
1163 'reason': f'歌词相似但歌名与词曲作者均不一致,有洗盗蹭嫌疑({reason})',
1164 'l1_matched': l1_matched,
1165 'l1_matched_id': l1_matched_id,
1166 'merge_authors': True,
1167 'matched_candidate': matched_candidate,
1168 'merge_direction': None,
1169 'new_record_count': new_count,
1170 'existing_record_count': existing_count,
1171 'recalled_candidates': recalled_candidates,
1172 }
1173
869 # 歌词质量兜底:现有记录无歌词但新记录有歌词 → 强制人工复核 1174 # 歌词质量兜底:现有记录无歌词但新记录有歌词 → 强制人工复核
870 existing_has_lyrics = matched_candidate and bool(matched_candidate.lyrics and matched_candidate.lyrics.strip()) 1175 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()) 1176 new_has_lyrics = bool(lyrics_text and lyrics_text.strip())
...@@ -883,6 +1188,7 @@ def classify_dedup_action( ...@@ -883,6 +1188,7 @@ def classify_dedup_action(
883 'merge_direction': 'existing_into_new', 1188 'merge_direction': 'existing_into_new',
884 'new_record_count': new_count, 1189 'new_record_count': new_count,
885 'existing_record_count': existing_count, 1190 'existing_record_count': existing_count,
1191 'recalled_candidates': recalled_candidates,
886 } 1192 }
887 1193
888 merge_direction = 'new_into_existing' 1194 merge_direction = 'new_into_existing'
...@@ -903,6 +1209,7 @@ def classify_dedup_action( ...@@ -903,6 +1209,7 @@ def classify_dedup_action(
903 'merge_direction': merge_direction, 1209 'merge_direction': merge_direction,
904 'new_record_count': new_count, 1210 'new_record_count': new_count,
905 'existing_record_count': existing_count, 1211 'existing_record_count': existing_count,
1212 'recalled_candidates': recalled_candidates,
906 } 1213 }
907 1214
908 # 空歌词 / 歌词获取失败:L2 无法比对,必须人工复核,不能自动入库 1215 # 空歌词 / 歌词获取失败:L2 无法比对,必须人工复核,不能自动入库
...@@ -921,6 +1228,7 @@ def classify_dedup_action( ...@@ -921,6 +1228,7 @@ def classify_dedup_action(
921 'merge_direction': None, 1228 'merge_direction': None,
922 'new_record_count': row.get('record_count'), 1229 'new_record_count': row.get('record_count'),
923 'existing_record_count': None, 1230 'existing_record_count': None,
1231 'recalled_candidates': recalled_candidates,
924 } 1232 }
925 1233
926 if decision == 'review' or (l1_matched and reason == '无候选集'): 1234 if decision == 'review' or (l1_matched and reason == '无候选集'):
...@@ -939,6 +1247,7 @@ def classify_dedup_action( ...@@ -939,6 +1247,7 @@ def classify_dedup_action(
939 'merge_direction': None, 1247 'merge_direction': None,
940 'new_record_count': row.get('record_count'), 1248 'new_record_count': row.get('record_count'),
941 'existing_record_count': None, 1249 'existing_record_count': None,
1250 'recalled_candidates': recalled_candidates,
942 } 1251 }
943 1252
944 return { 1253 return {
...@@ -954,6 +1263,7 @@ def classify_dedup_action( ...@@ -954,6 +1263,7 @@ def classify_dedup_action(
954 'merge_direction': None, 1263 'merge_direction': None,
955 'new_record_count': row.get('record_count'), 1264 'new_record_count': row.get('record_count'),
956 'existing_record_count': None, 1265 'existing_record_count': None,
1266 'recalled_candidates': recalled_candidates,
957 } 1267 }
958 1268
959 1269
...@@ -1096,6 +1406,8 @@ def main(): ...@@ -1096,6 +1406,8 @@ def main():
1096 help='启动时从目标库下载已有歌词文本构建 L2 候选集(默认关闭)') 1406 help='启动时从目标库下载已有歌词文本构建 L2 候选集(默认关闭)')
1097 parser.add_argument('--dedup-threshold', type=float, default=0.78, 1407 parser.add_argument('--dedup-threshold', type=float, default=0.78,
1098 help='L2 去重 Jaccard 阈值(默认 0.78)') 1408 help='L2 去重 Jaccard 阈值(默认 0.78)')
1409 parser.add_argument('--dedup-min-primary-chars', type=int, default=40,
1410 help='L2 精确哈希自动合并所需的最小规范化原文歌词长度(默认 40,过短转人工复核)')
1099 parser.add_argument('--l2-recall', choices=['topk', 'full'], default='topk', 1411 parser.add_argument('--l2-recall', choices=['topk', 'full'], default='topk',
1100 help='L2 候选召回模式:topk=倒排索引召回,full=全量候选精排(默认 topk)') 1412 help='L2 候选召回模式:topk=倒排索引召回,full=全量候选精排(默认 topk)')
1101 parser.add_argument('--l2-recall-top-k', type=int, default=200, 1413 parser.add_argument('--l2-recall-top-k', type=int, default=200,
...@@ -1112,6 +1424,7 @@ def main(): ...@@ -1112,6 +1424,7 @@ def main():
1112 logger.info(f"参数: limit={args.limit}, offset={args.offset}, skip_oss={args.skip_oss}, " 1424 logger.info(f"参数: limit={args.limit}, offset={args.offset}, skip_oss={args.skip_oss}, "
1113 f"batch_size={args.batch_size}, workers={args.workers}, " 1425 f"batch_size={args.batch_size}, workers={args.workers}, "
1114 f"skip_dedup={args.skip_dedup}, dedup_threshold={args.dedup_threshold}, " 1426 f"skip_dedup={args.skip_dedup}, dedup_threshold={args.dedup_threshold}, "
1427 f"dedup_min_primary_chars={args.dedup_min_primary_chars}, "
1115 f"l2_recall={args.l2_recall}, l2_recall_top_k={args.l2_recall_top_k}, " 1428 f"l2_recall={args.l2_recall}, l2_recall_top_k={args.l2_recall_top_k}, "
1116 f"dedup_only={args.dedup_only}, review_result_csv={args.review_result_csv}, " 1429 f"dedup_only={args.dedup_only}, review_result_csv={args.review_result_csv}, "
1117 f"dry_run={args.dry_run}") 1430 f"dry_run={args.dry_run}")
...@@ -1137,6 +1450,25 @@ def main(): ...@@ -1137,6 +1450,25 @@ def main():
1137 staging_insert_sql = STAGING_INSERT_SQL_TEMPLATE.format(table=TARGET_TABLE_NAME_TMP) 1450 staging_insert_sql = STAGING_INSERT_SQL_TEMPLATE.format(table=TARGET_TABLE_NAME_TMP)
1138 import_batch_id = datetime.now().strftime("%Y%m%d_%H%M%S") 1451 import_batch_id = datetime.now().strftime("%Y%m%d_%H%M%S")
1139 1452
1453 # 自动迁移:确保暂存表存在 recalled_candidates 列
1454 try:
1455 with target_conn.cursor() as cursor:
1456 cursor.execute(
1457 f"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
1458 f"WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'recalled_candidates'",
1459 (TARGET_DB_CONFIG['database'], TARGET_TABLE_NAME_TMP),
1460 )
1461 if not cursor.fetchone():
1462 cursor.execute(
1463 f"ALTER TABLE `{TARGET_TABLE_NAME_TMP}` "
1464 f"ADD COLUMN `recalled_candidates` text DEFAULT NULL COMMENT 'L2 召回候选 JSON' "
1465 f"AFTER `imported_song_id`"
1466 )
1467 target_conn.commit()
1468 logger.info(f"暂存表 {TARGET_TABLE_NAME_TMP} 新增 recalled_candidates 列")
1469 except Exception as e:
1470 logger.warning(f"暂存表 recalled_candidates 列迁移跳过: {e}")
1471
1140 # ===== 加载已处理的 source_song_id 集合(增量去重)===== 1472 # ===== 加载已处理的 source_song_id 集合(增量去重)=====
1141 # 包括:1) 已写入目标表的 2) 已在暂存表中标记过 dedup_action 的(review/merge/new) 1473 # 包括:1) 已写入目标表的 2) 已在暂存表中标记过 dedup_action 的(review/merge/new)
1142 imported_ids: set[str] = set() 1474 imported_ids: set[str] = set()
...@@ -1244,6 +1576,7 @@ def main(): ...@@ -1244,6 +1576,7 @@ def main():
1244 logger.info(f"初始化 L2 歌词去重检查器 (阈值={args.dedup_threshold})") 1576 logger.info(f"初始化 L2 歌词去重检查器 (阈值={args.dedup_threshold})")
1245 l2_checker = DuplicateChecker( 1577 l2_checker = DuplicateChecker(
1246 duplicate_jaccard_threshold=args.dedup_threshold, 1578 duplicate_jaccard_threshold=args.dedup_threshold,
1579 exact_duplicate_min_primary_chars=args.dedup_min_primary_chars,
1247 ) 1580 )
1248 l2_candidates = L2CandidateIndex( 1581 l2_candidates = L2CandidateIndex(
1249 l2_checker, 1582 l2_checker,
...@@ -1259,24 +1592,16 @@ def main(): ...@@ -1259,24 +1592,16 @@ def main():
1259 lyric_sql = f"SELECT id, name, singer, lyricist, composer, lyrics_url FROM {TARGET_TABLE_NAME} WHERE deleted = '0' AND lyrics_url IS NOT NULL" 1592 lyric_sql = f"SELECT id, name, singer, lyricist, composer, lyrics_url FROM {TARGET_TABLE_NAME} WHERE deleted = '0' AND lyrics_url IS NOT NULL"
1260 with target_conn.cursor(pymysql.cursors.DictCursor) as cursor: 1593 with target_conn.cursor(pymysql.cursors.DictCursor) as cursor:
1261 cursor.execute(lyric_sql) 1594 cursor.execute(lyric_sql)
1262 for r in cursor.fetchall(): 1595 existing_lyric_rows = cursor.fetchall()
1263 url = r.get('lyrics_url') 1596 cache_path = CACHE_DIR / f'{TARGET_TABLE_NAME}_existing_lyrics.jsonl'
1264 if url and url.startswith(('http://', 'https://')): 1597 existing_records, cache_stats = load_existing_l2_candidates(existing_lyric_rows, cache_path)
1265 content, _ = download_file(url, timeout=10) 1598 for record in existing_records:
1266 if content: 1599 l2_candidates.add(record)
1267 try: 1600 logger.info(
1268 text = content.decode('utf-8') 1601 f"L2 候选集加载完成: {len(l2_candidates)} 条 | "
1269 except UnicodeDecodeError: 1602 f"缓存命中={cache_stats['cached']} 下载={cache_stats['downloaded']} 失败={cache_stats['failed']} | "
1270 text = content.decode('utf-8', errors='replace') 1603 f"缓存文件={cache_path}"
1271 l2_candidates.add(LyricRecord( 1604 )
1272 record_id=str(r['id']),
1273 lyrics=text,
1274 title=r.get('name'),
1275 artist=r.get('singer'),
1276 lyricist=r.get('lyricist'),
1277 composer=r.get('composer'),
1278 ))
1279 logger.info(f"L2 候选集加载完成: {len(l2_candidates)} 条")
1280 except Exception as e: 1605 except Exception as e:
1281 logger.error(f"加载已有歌词失败: {e}") 1606 logger.error(f"加载已有歌词失败: {e}")
1282 1607
...@@ -1336,7 +1661,8 @@ def main(): ...@@ -1336,7 +1661,8 @@ def main():
1336 dedup_report.close() 1661 dedup_report.close()
1337 logger.info( 1662 logger.info(
1338 f"去重审核清单完成: {review_report.filepath} | " 1663 f"去重审核清单完成: {review_report.filepath} | "
1339 f"new={decision_counts['new']} review={decision_counts['review']} merge={decision_counts['merge']}" 1664 f"new={decision_counts['new']} review={decision_counts['review']} "
1665 f"merge={decision_counts['merge']} skip={decision_counts['skip']}"
1340 ) 1666 )
1341 return 1667 return
1342 1668
...@@ -1345,6 +1671,7 @@ def main(): ...@@ -1345,6 +1671,7 @@ def main():
1345 stats = { 1671 stats = {
1346 'inserted': 0, 'errors': 0, 1672 'inserted': 0, 'errors': 0,
1347 'l1_dup': 0, 'l2_dup': 0, 'l2_review': 0, 'l2_new': 0, 1673 'l1_dup': 0, 'l2_dup': 0, 'l2_review': 0, 'l2_new': 0,
1674 'author_merged': 0, 'skipped': 0,
1348 } 1675 }
1349 start_time = time.time() 1676 start_time = time.time()
1350 1677
...@@ -1355,16 +1682,84 @@ def main(): ...@@ -1355,16 +1682,84 @@ def main():
1355 pbar_db = tqdm(total=total, desc="DB 写入", unit="条", position=1, leave=True, 1682 pbar_db = tqdm(total=total, desc="DB 写入", unit="条", position=1, leave=True,
1356 bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]') 1683 bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]')
1357 1684
1685 # ===== 后台 DB 写入线程:与下一批次的 OSS 处理并行 =====
1686 db_write_queue: queue.Queue = queue.Queue(maxsize=2)
1687 db_write_errors: list[str] = []
1688
1689 def _background_db_writer():
1690 """后台线程:顺序写入 DB,与主线程 OSS 处理并行。"""
1691 while True:
1692 item = db_write_queue.get()
1693 if item is None:
1694 db_write_queue.task_done()
1695 break
1696 batch_data, staging_data, batch_rows, batch_size_actual, batch_start, author_merge_tasks = item
1697 try:
1698 with target_conn.cursor() as cursor:
1699 if batch_data:
1700 cursor.executemany(insert_sql, batch_data)
1701 if staging_data:
1702 cursor.executemany(staging_insert_sql, staging_data)
1703 if author_merge_tasks:
1704 merged_cnt = execute_author_merges(cursor, author_merge_tasks)
1705 with stats_lock:
1706 stats['author_merged'] += merged_cnt
1707 target_conn.commit()
1708 batch_inserted = len(batch_data)
1709 with stats_lock:
1710 stats['inserted'] += batch_inserted
1711 except Exception as e:
1712 logger.error(f"批量写入失败 (offset {batch_start}): {e}")
1713 target_conn.rollback()
1714 db_write_errors.append(f"batch@{batch_start}: {e}")
1715 # 降级逐条写入
1716 for row_i, tuple_row in enumerate(batch_data):
1717 try:
1718 with target_conn.cursor() as cursor:
1719 cursor.execute(insert_sql, tuple_row)
1720 target_conn.commit()
1721 with stats_lock:
1722 stats['inserted'] += 1
1723 except Exception as e2:
1724 with stats_lock:
1725 stats['errors'] += 1
1726 logger.error(f"单条写入失败 (batch offset {batch_start + row_i}): {e2}")
1727 # 降级模式下也尝试执行作者合并
1728 if author_merge_tasks:
1729 try:
1730 with target_conn.cursor() as cursor:
1731 merged_cnt = execute_author_merges(cursor, author_merge_tasks)
1732 target_conn.commit()
1733 with stats_lock:
1734 stats['author_merged'] += merged_cnt
1735 except Exception as e_merge:
1736 logger.error(f"降级作者合并失败 (offset {batch_start}): {e_merge}")
1737 pbar_db.update(batch_size_actual)
1738 with stats_lock:
1739 pbar_db.set_postfix(
1740 ins=stats['inserted'],
1741 err=stats['errors'],
1742 l1=stats['l1_dup'],
1743 l2=stats['l2_dup'],
1744 rev=stats['l2_review'],
1745 auth=stats['author_merged'],
1746 skip=stats['skipped'],
1747 refresh=False
1748 )
1749 db_write_queue.task_done()
1750
1751 db_writer_thread = threading.Thread(target=_background_db_writer, daemon=True)
1752 db_writer_thread.start()
1753
1358 for batch_start in range(0, total, args.batch_size): 1754 for batch_start in range(0, total, args.batch_size):
1359 batch_rows = rows[batch_start:batch_start + args.batch_size] 1755 batch_rows = rows[batch_start:batch_start + args.batch_size]
1360 batch_size_actual = len(batch_rows) 1756 batch_size_actual = len(batch_rows)
1361 1757
1362 # ===== 步骤1:去重分类(纯内存,先于 OSS)===== 1758 # ===== 步骤1:去重分类(纯内存)=====
1363 # L1/L2 去重只依赖源库查询结果(name/lyricist/composer/lyrics_txt_content),
1364 # 不需要任何 URL 下载,因此可以在 OSS 之前完成,避免对 merge/review 记录做无效上传。
1365 staging_data = [] 1759 staging_data = []
1366 new_rows: list = [] 1760 new_rows: list = []
1367 row_actions: dict = {} 1761 row_actions: dict = {}
1762 author_merge_tasks: list[dict] = []
1368 1763
1369 if not args.skip_dedup and not args.review_result_csv: 1764 if not args.skip_dedup and not args.review_result_csv:
1370 for row in batch_rows: 1765 for row in batch_rows:
...@@ -1393,6 +1788,29 @@ def main(): ...@@ -1393,6 +1788,29 @@ def main():
1393 if review_report: 1788 if review_report:
1394 review_report.write(row, action) 1789 review_report.write(row, action)
1395 1790
1791 # 收集作者增量合并任务
1792 if action.get('merge_authors') and action.get('matched_id'):
1793 matched_cand = action.get('matched_candidate')
1794 merged_lyricist = _merge_author_field(
1795 getattr(matched_cand, 'lyricist', None) if matched_cand else None,
1796 row.get('lyricist'),
1797 )
1798 merged_composer = _merge_author_field(
1799 getattr(matched_cand, 'composer', None) if matched_cand else None,
1800 row.get('composer'),
1801 )
1802 if merged_lyricist or merged_composer:
1803 author_merge_tasks.append({
1804 'matched_id': int(action['matched_id']),
1805 'lyricist': merged_lyricist,
1806 'composer': merged_composer,
1807 'source_id': record_id,
1808 })
1809 logger.debug(
1810 f"作者合并任务: matched_id={action['matched_id']}, "
1811 f"lyricist→{merged_lyricist!r}, composer→{merged_composer!r}"
1812 )
1813
1396 elif action['action'] == 'review': 1814 elif action['action'] == 'review':
1397 with stats_lock: 1815 with stats_lock:
1398 stats['l2_review'] += 1 1816 stats['l2_review'] += 1
...@@ -1402,6 +1820,13 @@ def main(): ...@@ -1402,6 +1820,13 @@ def main():
1402 if review_report: 1820 if review_report:
1403 review_report.write(row, action) 1821 review_report.write(row, action)
1404 1822
1823 elif action['action'] == 'skip':
1824 with stats_lock:
1825 stats['skipped'] += 1
1826 dedup_report.write(record_id, record_name, 'PRE', 'skip',
1827 confidence='1.0000',
1828 reason=reason)
1829
1405 else: # new 1830 else: # new
1406 with stats_lock: 1831 with stats_lock:
1407 stats['l2_new'] += 1 1832 stats['l2_new'] += 1
...@@ -1413,7 +1838,6 @@ def main(): ...@@ -1413,7 +1838,6 @@ def main():
1413 review_report.write(row, action) 1838 review_report.write(row, action)
1414 new_rows.append(row) 1839 new_rows.append(row)
1415 1840
1416 # 加入 L2 候选集供后续批次比对
1417 lyrics_text = row.get('lyrics_txt_content') 1841 lyrics_text = row.get('lyrics_txt_content')
1418 if lyrics_text and lyrics_text.strip(): 1842 if lyrics_text and lyrics_text.strip():
1419 l2_candidates.add(LyricRecord( 1843 l2_candidates.add(LyricRecord(
...@@ -1425,7 +1849,6 @@ def main(): ...@@ -1425,7 +1849,6 @@ def main():
1425 composer=row.get('composer'), 1849 composer=row.get('composer'),
1426 )) 1850 ))
1427 else: 1851 else:
1428 # skip_dedup 或 review_result_csv 模式:全部视为 new
1429 new_rows = list(batch_rows) 1852 new_rows = list(batch_rows)
1430 1853
1431 # ===== 步骤2:OSS 并发处理(只对 new 记录)===== 1854 # ===== 步骤2:OSS 并发处理(只对 new 记录)=====
...@@ -1445,15 +1868,13 @@ def main(): ...@@ -1445,15 +1868,13 @@ def main():
1445 results_map[idx] = tuple_row 1868 results_map[idx] = tuple_row
1446 pbar_oss.update(1) 1869 pbar_oss.update(1)
1447 1870
1448 # merge/review 记录跳过 OSS,直接推进进度条
1449 pbar_oss.update(batch_size_actual - len(new_rows)) 1871 pbar_oss.update(batch_size_actual - len(new_rows))
1450 1872
1451 # 按顺序组装 batch_data(写入 hk_songs 的 new 记录) 1873 # 按顺序组装 batch_data
1452 batch_data = [results_map[i] for i in range(len(new_rows)) if i in results_map] 1874 batch_data = [results_map[i] for i in range(len(new_rows)) if i in results_map]
1453 1875
1454 # ===== 步骤3:构建 staging tuples ===== 1876 # ===== 步骤3:构建 staging tuples =====
1455 if not args.skip_dedup and not args.review_result_csv: 1877 if not args.skip_dedup and not args.review_result_csv:
1456 # new 记录:使用 OSS 处理后的 tuple_row
1457 for i, row in enumerate(new_rows): 1878 for i, row in enumerate(new_rows):
1458 if i not in results_map: 1879 if i not in results_map:
1459 continue 1880 continue
...@@ -1465,10 +1886,9 @@ def main(): ...@@ -1465,10 +1886,9 @@ def main():
1465 staging_data.append(build_staging_tuple(results_map[i], action, import_batch_id, 1886 staging_data.append(build_staging_tuple(results_map[i], action, import_batch_id,
1466 record_count=row.get('record_count'))) 1887 record_count=row.get('record_count')))
1467 1888
1468 # merge/review 记录:透传源 URL,跳过 OSS
1469 for row in batch_rows: 1889 for row in batch_rows:
1470 action = row_actions.get(row['source_id']) 1890 action = row_actions.get(row['source_id'])
1471 if action and action['action'] in ('merge', 'review'): 1891 if action and action['action'] in ('merge', 'review', 'skip'):
1472 tuple_row = build_row_tuple(row, None, skip_oss=True) 1892 tuple_row = build_row_tuple(row, None, skip_oss=True)
1473 staging_data.append(build_staging_tuple(tuple_row, action, import_batch_id, 1893 staging_data.append(build_staging_tuple(tuple_row, action, import_batch_id,
1474 record_count=row.get('record_count'))) 1894 record_count=row.get('record_count')))
...@@ -1477,56 +1897,13 @@ def main(): ...@@ -1477,56 +1897,13 @@ def main():
1477 pbar_db.update(batch_size_actual) 1897 pbar_db.update(batch_size_actual)
1478 continue 1898 continue
1479 1899
1480 # ===== 步骤4:顺序写入数据库(单连接,保证不出错)===== 1900 # ===== 步骤4:交给后台线程写入 DB(主线程继续下一批次 OSS)=====
1481 try:
1482 with target_conn.cursor() as cursor:
1483 if batch_data:
1484 cursor.executemany(insert_sql, batch_data)
1485 if staging_data:
1486 cursor.executemany(staging_insert_sql, staging_data)
1487 target_conn.commit()
1488 batch_inserted = len(batch_data)
1489 with stats_lock:
1490 stats['inserted'] += batch_inserted
1491
1492 # 更新 L1 索引(防止批次内重复)
1493 if not args.skip_dedup: 1901 if not args.skip_dedup:
1494 for i, row in enumerate(batch_rows): 1902 mark_rows_in_l1_index(batch_rows, l1_index)
1495 key = ( 1903 db_write_queue.put((batch_data, staging_data, batch_rows, batch_size_actual, batch_start, author_merge_tasks))
1496 _normalize_meta(row.get('name')),
1497 _normalize_meta(row.get('lyricist')),
1498 _normalize_meta(row.get('composer')),
1499 )
1500 if key[0] and key not in l1_index:
1501 l1_index[key] = row['source_id']
1502 except Exception as e:
1503 logger.error(f"批量写入失败 (offset {batch_start}): {e}")
1504 target_conn.rollback()
1505 # 降级逐条写入,保证其他行不受影响
1506 for row_i, tuple_row in enumerate(batch_data):
1507 try:
1508 with target_conn.cursor() as cursor:
1509 cursor.execute(insert_sql, tuple_row)
1510 target_conn.commit()
1511 with stats_lock:
1512 stats['inserted'] += 1
1513 except Exception as e2:
1514 with stats_lock:
1515 stats['errors'] += 1
1516 logger.error(f"单条写入失败 (batch offset {batch_start + row_i}): {e2}")
1517 1904
1518 pbar_db.update(batch_size_actual) 1905 # 所有批次已入队;等待后台 DB 写入收尾后停止线程
1519 1906 stop_db_writer(db_write_queue, db_writer_thread)
1520 # 更新进度条描述(附加统计)
1521 with stats_lock:
1522 pbar_db.set_postfix(
1523 ins=stats['inserted'],
1524 err=stats['errors'],
1525 l1=stats['l1_dup'],
1526 l2=stats['l2_dup'],
1527 rev=stats['l2_review'],
1528 refresh=False
1529 )
1530 1907
1531 pbar_oss.close() 1908 pbar_oss.close()
1532 pbar_db.close() 1909 pbar_db.close()
...@@ -1540,7 +1917,8 @@ def main(): ...@@ -1540,7 +1917,8 @@ def main():
1540 if not args.skip_dedup: 1917 if not args.skip_dedup:
1541 logger.info(f"去重统计: L1跳过={stats['l1_dup']} | " 1918 logger.info(f"去重统计: L1跳过={stats['l1_dup']} | "
1542 f"L2合并命中={stats['l2_dup']} | L2待审={stats['l2_review']} | " 1919 f"L2合并命中={stats['l2_dup']} | L2待审={stats['l2_review']} | "
1543 f"L2新词={stats['l2_new']}") 1920 f"L2新词={stats['l2_new']} | 作者合并={stats['author_merged']} | "
1921 f"跳过={stats['skipped']}")
1544 if dedup_report: 1922 if dedup_report:
1545 logger.info(f"去重报告: {dedup_report.filepath}") 1923 logger.info(f"去重报告: {dedup_report.filepath}")
1546 if review_report: 1924 if review_report:
...@@ -1548,6 +1926,17 @@ def main(): ...@@ -1548,6 +1926,17 @@ def main():
1548 logger.info(f"耗时: {elapsed_total:.1f}s | 平均速度: {total/elapsed_total:.1f} 条/秒") 1926 logger.info(f"耗时: {elapsed_total:.1f}s | 平均速度: {total/elapsed_total:.1f} 条/秒")
1549 logger.info("=" * 60) 1927 logger.info("=" * 60)
1550 1928
1929 except KeyboardInterrupt:
1930 logger.warning("收到中断信号,正在等待已入队 DB 写入完成...")
1931 if 'db_write_queue' in locals() and 'db_writer_thread' in locals():
1932 stop_db_writer(db_write_queue, db_writer_thread)
1933 if 'pbar_oss' in locals():
1934 pbar_oss.close()
1935 if 'pbar_db' in locals():
1936 pbar_db.close()
1937 logger.warning("已停止导入;未入队的 OSS 结果不会写入 DB,可安全重跑继续。")
1938 raise
1939
1551 finally: 1940 finally:
1552 # 显式关闭 OSS session,避免 __del__ 中的 Bad file descriptor / ConnectionPool 警告 1941 # 显式关闭 OSS session,避免 __del__ 中的 Bad file descriptor / ConnectionPool 警告
1553 if bucket is not None: 1942 if bucket is not None:
......
...@@ -460,6 +460,13 @@ ...@@ -460,6 +460,13 @@
460 <select id="runSelect"></select> 460 <select id="runSelect"></select>
461 <label id="topKLabel" for="topKSelect">topK</label> 461 <label id="topKLabel" for="topKSelect">topK</label>
462 <select id="topKSelect"></select> 462 <select id="topKSelect"></select>
463 <label for="decisionFilter">决策</label>
464 <select id="decisionFilter">
465 <option value="">全部</option>
466 <option value="new">new</option>
467 <option value="merge">merge</option>
468 <option value="review">review</option>
469 </select>
463 <input id="searchInput" type="search" placeholder="搜索歌名 / ID"> 470 <input id="searchInput" type="search" placeholder="搜索歌名 / ID">
464 <label for="pageSizeSelect">每页</label> 471 <label for="pageSizeSelect">每页</label>
465 <select id="pageSizeSelect"> 472 <select id="pageSizeSelect">
...@@ -520,6 +527,7 @@ ...@@ -520,6 +527,7 @@
520 pageSize: 50, 527 pageSize: 50,
521 mode: 'retrieval', 528 mode: 'retrieval',
522 topK: '', 529 topK: '',
530 decisionFilter: '',
523 queryId: '', 531 queryId: '',
524 candidateId: '', 532 candidateId: '',
525 lyricsCache: new Map() 533 lyricsCache: new Map()
...@@ -529,6 +537,7 @@ ...@@ -529,6 +537,7 @@
529 runSelect: document.getElementById('runSelect'), 537 runSelect: document.getElementById('runSelect'),
530 topKSelect: document.getElementById('topKSelect'), 538 topKSelect: document.getElementById('topKSelect'),
531 topKLabel: document.getElementById('topKLabel'), 539 topKLabel: document.getElementById('topKLabel'),
540 decisionFilter: document.getElementById('decisionFilter'),
532 searchInput: document.getElementById('searchInput'), 541 searchInput: document.getElementById('searchInput'),
533 pageSizeSelect: document.getElementById('pageSizeSelect'), 542 pageSizeSelect: document.getElementById('pageSizeSelect'),
534 exportBtn: document.getElementById('exportBtn'), 543 exportBtn: document.getElementById('exportBtn'),
...@@ -576,7 +585,7 @@ ...@@ -576,7 +585,7 @@
576 } 585 }
577 586
578 function rowDecision(row) { 587 function rowDecision(row) {
579 return row.decision || row.candidate_decision || ''; 588 return row.candidate_decision || row.decision || row.action || '';
580 } 589 }
581 590
582 function isConflict(row) { 591 function isConflict(row) {
...@@ -706,7 +715,8 @@ ...@@ -706,7 +715,8 @@
706 function selectedCandidate(rows) { 715 function selectedCandidate(rows) {
707 if (!rows.length) return null; 716 if (!rows.length) return null;
708 if (!state.candidateId || !rows.some(row => row.candidate_id === state.candidateId)) { 717 if (!state.candidateId || !rows.some(row => row.candidate_id === state.candidateId)) {
709 state.candidateId = rows[0].candidate_id || ''; 718 const firstReal = rows.find(row => row.candidate_id);
719 state.candidateId = firstReal ? firstReal.candidate_id : (rows[0].candidate_id || '');
710 } 720 }
711 return rows.find(row => row.candidate_id === state.candidateId) || rows[0]; 721 return rows.find(row => row.candidate_id === state.candidateId) || rows[0];
712 } 722 }
...@@ -726,6 +736,7 @@ ...@@ -726,6 +736,7 @@
726 const conflict = isConflict(row); 736 const conflict = isConflict(row);
727 const l1 = l1Match(row); 737 const l1 = l1Match(row);
728 const extraClass = `${conflict ? ' conflict' : ''}${l1 ? ' l1hint' : ''}`; 738 const extraClass = `${conflict ? ' conflict' : ''}${l1 ? ' l1hint' : ''}`;
739 const l2Decision = row.candidate_decision || '';
729 return `<div class="candidate ${esc(decision)}${active}${extraClass}" data-candidate-id="${esc(row.candidate_id)}"> 740 return `<div class="candidate ${esc(decision)}${active}${extraClass}" data-candidate-id="${esc(row.candidate_id)}">
730 <div class="candidate-head"> 741 <div class="candidate-head">
731 <span class="rank">${esc(row.rank || '')}</span> 742 <span class="rank">${esc(row.rank || '')}</span>
...@@ -737,6 +748,7 @@ ...@@ -737,6 +748,7 @@
737 <div class="query-meta">词 ${esc(row.candidate_lyricist || '-')} · 曲 ${esc(row.candidate_composer || '-')}</div> 748 <div class="query-meta">词 ${esc(row.candidate_lyricist || '-')} · 曲 ${esc(row.candidate_composer || '-')}</div>
738 <div style="margin-top:7px;display:flex;gap:5px;flex-wrap:wrap"> 749 <div style="margin-top:7px;display:flex;gap:5px;flex-wrap:wrap">
739 ${l1 ? '<span class="pill l1">L1 元数据命中</span>' : '<span class="pill">L1 未命中</span>'} 750 ${l1 ? '<span class="pill l1">L1 元数据命中</span>' : '<span class="pill">L1 未命中</span>'}
751 ${l2Decision ? `<span class="pill ${esc(l2Decision)}">L2 ${esc(l2Decision)}</span>` : ''}
740 ${conflict ? '<span class="pill conflict">冲突</span>' : ''} 752 ${conflict ? '<span class="pill conflict">冲突</span>' : ''}
741 </div> 753 </div>
742 <div class="score-row"> 754 <div class="score-row">
...@@ -764,6 +776,7 @@ ...@@ -764,6 +776,7 @@
764 approved_import: '确认不重复(已入库)', 776 approved_import: '确认不重复(已入库)',
765 rejected_duplicate: '确认重复', 777 rejected_duplicate: '确认重复',
766 unsure: '待确认', 778 unsure: '待确认',
779 deleted: '已删除',
767 }; 780 };
768 781
769 async function renderDetail() { 782 async function renderDetail() {
...@@ -824,7 +837,7 @@ ...@@ -824,7 +837,7 @@
824 <div class="section-head"> 837 <div class="section-head">
825 <h2 class="section-title">人工标注</h2> 838 <h2 class="section-title">人工标注</h2>
826 ${query.review_status && query.review_status !== 'pending' && query.review_status !== 'not_required' 839 ${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>` 840 ? `<span class="pill ${query.review_status === 'approved_import' ? 'new' : query.review_status === 'rejected_duplicate' ? 'duplicate' : query.review_status === 'deleted' ? 'duplicate' : ''}">${query.review_status === 'deleted' ? '已删除' : '已审核'}</span>`
828 : ''} 841 : ''}
829 </div> 842 </div>
830 <div class="section-body"> 843 <div class="section-body">
...@@ -846,14 +859,15 @@ ...@@ -846,14 +859,15 @@
846 </button>`).join('')} 859 </button>`).join('')}
847 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注"> 860 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注">
848 <button id="saveReviewBtn" class="secondary">保存</button> 861 <button id="saveReviewBtn" class="secondary">保存</button>
862 <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button>
849 </div> 863 </div>
850 `} 864 `}
851 </div> 865 </div>
852 </div> 866 </div>
853 867
854 <div class="section"> 868 <div class="section">
855 <div class="section-head"><h2 class="section-title">Top10 候选</h2></div> 869 <div class="section-head"><h2 class="section-title">Top5 候选</h2></div>
856 <div class="section-body"><div class="candidates">${rows.map(candidateCard).join('') || '<div class="empty">无召回候选</div>'}</div></div> 870 <div class="section-body"><div class="candidates">${rows.filter(r => r.candidate_id).map(candidateCard).join('') || '<div class="empty">无召回候选</div>'}</div></div>
857 </div> 871 </div>
858 872
859 <div class="section"> 873 <div class="section">
...@@ -900,6 +914,21 @@ ...@@ -900,6 +914,21 @@
900 renderDetail(); 914 renderDetail();
901 }); 915 });
902 } 916 }
917 const deleteBtn = document.getElementById('deleteReviewBtn');
918 if (deleteBtn) {
919 deleteBtn.addEventListener('click', async () => {
920 if (!confirm('确认删除此样本?删除后将不再导入。')) return;
921 deleteBtn.disabled = true;
922 try {
923 await updateStagingReview(query.query_source_id, 'deleted',
924 document.getElementById('reviewNote')?.value || '');
925 await loadQueryRows(query.query_source_id);
926 } catch (e) {
927 alert(`删除失败: ${e.message}`);
928 }
929 renderDetail();
930 });
931 }
903 const undoBtn = document.getElementById('undoReviewBtn'); 932 const undoBtn = document.getElementById('undoReviewBtn');
904 if (undoBtn) { 933 if (undoBtn) {
905 undoBtn.addEventListener('click', async () => { 934 undoBtn.addEventListener('click', async () => {
...@@ -1024,6 +1053,7 @@ ...@@ -1024,6 +1053,7 @@
1024 path: dataPath(), 1053 path: dataPath(),
1025 top_k: state.topK, 1054 top_k: state.topK,
1026 mode: state.mode, 1055 mode: state.mode,
1056 decision: state.decisionFilter,
1027 q: els.searchInput.value.trim(), 1057 q: els.searchInput.value.trim(),
1028 page: String(state.page), 1058 page: String(state.page),
1029 page_size: String(state.pageSize) 1059 page_size: String(state.pageSize)
...@@ -1100,6 +1130,14 @@ ...@@ -1100,6 +1130,14 @@
1100 await loadGroups(); 1130 await loadGroups();
1101 renderAll(); 1131 renderAll();
1102 }); 1132 });
1133 els.decisionFilter.addEventListener('change', async () => {
1134 state.decisionFilter = els.decisionFilter.value;
1135 state.page = 1;
1136 state.queryId = '';
1137 state.candidateId = '';
1138 await loadGroups();
1139 renderAll();
1140 });
1103 els.reloadBtn.addEventListener('click', loadRuns); 1141 els.reloadBtn.addEventListener('click', loadRuns);
1104 els.exportBtn.addEventListener('click', exportAnnotations); 1142 els.exportBtn.addEventListener('click', exportAnnotations);
1105 els.importReviewedBtn.addEventListener('click', importReviewed); 1143 els.importReviewedBtn.addEventListener('click', importReviewed);
......
...@@ -91,6 +91,7 @@ class DuplicateChecker: ...@@ -91,6 +91,7 @@ class DuplicateChecker:
91 metadata_duplicate_line_coverage_threshold: float = 0.65, 91 metadata_duplicate_line_coverage_threshold: float = 0.65,
92 metadata_duplicate_min_primary_lines: int = 8, 92 metadata_duplicate_min_primary_lines: int = 8,
93 auto_duplicate_min_primary_lines: int = 4, 93 auto_duplicate_min_primary_lines: int = 4,
94 exact_duplicate_min_primary_chars: int = 40,
94 ) -> None: 95 ) -> None:
95 self.duplicate_jaccard_threshold = duplicate_jaccard_threshold 96 self.duplicate_jaccard_threshold = duplicate_jaccard_threshold
96 self.duplicate_line_coverage_threshold = duplicate_line_coverage_threshold 97 self.duplicate_line_coverage_threshold = duplicate_line_coverage_threshold
...@@ -111,6 +112,7 @@ class DuplicateChecker: ...@@ -111,6 +112,7 @@ class DuplicateChecker:
111 self.metadata_duplicate_line_coverage_threshold = metadata_duplicate_line_coverage_threshold 112 self.metadata_duplicate_line_coverage_threshold = metadata_duplicate_line_coverage_threshold
112 self.metadata_duplicate_min_primary_lines = metadata_duplicate_min_primary_lines 113 self.metadata_duplicate_min_primary_lines = metadata_duplicate_min_primary_lines
113 self.auto_duplicate_min_primary_lines = auto_duplicate_min_primary_lines 114 self.auto_duplicate_min_primary_lines = auto_duplicate_min_primary_lines
115 self.exact_duplicate_min_primary_chars = exact_duplicate_min_primary_chars
114 116
115 def check_record_against_candidates( 117 def check_record_against_candidates(
116 self, 118 self,
...@@ -209,6 +211,14 @@ class DuplicateChecker: ...@@ -209,6 +211,14 @@ class DuplicateChecker:
209 decision = DuplicateDecision.REVIEW 211 decision = DuplicateDecision.REVIEW
210 confidence = 0.95 212 confidence = 0.95
211 reason = "原文哈希一致,但疑似整段翻译结构拆分置信度较低,需要人工复核" 213 reason = "原文哈希一致,但疑似整段翻译结构拆分置信度较低,需要人工复核"
214 elif not _has_enough_primary_text_chars(
215 query.normalized,
216 candidate.normalized,
217 min_chars=self.exact_duplicate_min_primary_chars,
218 ):
219 decision = DuplicateDecision.REVIEW
220 confidence = 0.95
221 reason = "规范化后的原文哈希一致,但有效歌词过短,需要人工复核"
212 elif _is_generic_primary_lyrics(query.normalized) or _is_generic_primary_lyrics(candidate.normalized): 222 elif _is_generic_primary_lyrics(query.normalized) or _is_generic_primary_lyrics(candidate.normalized):
213 decision = DuplicateDecision.REVIEW 223 decision = DuplicateDecision.REVIEW
214 confidence = 0.95 224 confidence = 0.95
...@@ -510,6 +520,21 @@ def _has_enough_primary_lyrics( ...@@ -510,6 +520,21 @@ def _has_enough_primary_lyrics(
510 return len(meaningful) >= min_lines 520 return len(meaningful) >= min_lines
511 521
512 522
523 def _has_enough_primary_text_chars(
524 left: NormalizedLyrics,
525 right: NormalizedLyrics,
526 *,
527 min_chars: int,
528 ) -> bool:
529 left_chars = _normalized_primary_text_length(left)
530 right_chars = _normalized_primary_text_length(right)
531 return min(left_chars, right_chars) >= min_chars
532
533
534 def _normalized_primary_text_length(normalized: NormalizedLyrics) -> int:
535 return len(_normalize_meta("".join(normalized.primary_lines)))
536
537
513 def _is_metadata_like_line(line: str) -> bool: 538 def _is_metadata_like_line(line: str) -> bool:
514 normalized = _normalize_meta(line) 539 normalized = _normalize_meta(line)
515 metadata_prefixes = ( 540 metadata_prefixes = (
......
...@@ -164,7 +164,7 @@ def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]: ...@@ -164,7 +164,7 @@ def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]:
164 } 164 }
165 165
166 166
167 def _staging_groups_response(mode: str, term: str, page: int, page_size: int) -> dict[str, object]: 167 def _staging_groups_response(mode: str, term: str, page: int, page_size: int, decision: str = '') -> dict[str, object]:
168 where_clauses = [] 168 where_clauses = []
169 params: list[object] = [] 169 params: list[object] = []
170 if term: 170 if term:
...@@ -174,6 +174,10 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int) -> ...@@ -174,6 +174,10 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int) ->
174 # hits 模式下只展示有命中的记录(merge/review) 174 # hits 模式下只展示有命中的记录(merge/review)
175 if mode == "hits": 175 if mode == "hits":
176 where_clauses.append("dedup_action IN ('merge', 'review')") 176 where_clauses.append("dedup_action IN ('merge', 'review')")
177 # 决策筛选:new / merge / review
178 if decision and decision in ('new', 'merge', 'review'):
179 where_clauses.append("dedup_action = %s")
180 params.append(decision)
177 where = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" 181 where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
178 # 同一首歌可能因多次导入批次产生多行,按 source_song_id 去重取最新一行 182 # 同一首歌可能因多次导入批次产生多行,按 source_song_id 去重取最新一行
179 count_sql = f"SELECT COUNT(DISTINCT source_song_id) AS n FROM {TARGET_TABLE_NAME_TMP} {where}" 183 count_sql = f"SELECT COUNT(DISTINCT source_song_id) AS n FROM {TARGET_TABLE_NAME_TMP} {where}"
...@@ -216,6 +220,7 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int) -> ...@@ -216,6 +220,7 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int) ->
216 "has_hit": row.get("dedup_action") in {"merge", "review"}, 220 "has_hit": row.get("dedup_action") in {"merge", "review"},
217 "has_conflict": False, 221 "has_conflict": False,
218 "has_l1_hint": bool(row.get("l1_matched_id")), 222 "has_l1_hint": bool(row.get("l1_matched_id")),
223 "decision": row.get("dedup_action") or "",
219 } 224 }
220 for row in rows 225 for row in rows
221 ] 226 ]
...@@ -248,6 +253,9 @@ def _staging_query_rows(query_id: str) -> dict[str, object]: ...@@ -248,6 +253,9 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
248 ) 253 )
249 matched_row = cursor.fetchone() 254 matched_row = cursor.fetchone()
250 dashboard_row = _staging_dashboard_row(row) 255 dashboard_row = _staging_dashboard_row(row)
256 has_main_match = bool(matched_song_id)
257 # 主行 rank:仅 merge/review 有主匹配时显示 "1",new 样本置空以便召回候选从 1 开始编号
258 dashboard_row["rank"] = "1" if has_main_match else ""
251 if matched_row: 259 if matched_row:
252 dashboard_row["candidate_name"] = str(matched_row.get("name") or "") 260 dashboard_row["candidate_name"] = str(matched_row.get("name") or "")
253 dashboard_row["candidate_lyricist"] = str(matched_row.get("lyricist") or "") 261 dashboard_row["candidate_lyricist"] = str(matched_row.get("lyricist") or "")
...@@ -256,13 +264,98 @@ def _staging_query_rows(query_id: str) -> dict[str, object]: ...@@ -256,13 +264,98 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
256 if cand_lyrics and not cand_lyrics.startswith(("http://", "https://", "/")): 264 if cand_lyrics and not cand_lyrics.startswith(("http://", "https://", "/")):
257 cand_lyrics = f"_raw:{cand_lyrics}" 265 cand_lyrics = f"_raw:{cand_lyrics}"
258 dashboard_row["candidate_lyrics_path"] = cand_lyrics 266 dashboard_row["candidate_lyrics_path"] = cand_lyrics
259 return {"rows": [dashboard_row]} 267
268 rows = [dashboard_row]
269
270 # 解析 recalled_candidates JSON,为每个召回候选生成一张卡片
271 recalled_json = row.get("recalled_candidates")
272 if recalled_json:
273 try:
274 recalled_list = json.loads(recalled_json) if isinstance(recalled_json, str) else recalled_json
275 main_candidate_id = str(matched_song_id or "")
276
277 # 批量查找召回候选的歌词 URL(目标表 + 暂存表 fallback)
278 recalled_ids_str = [
279 str(c.get("id") or "")
280 for c in recalled_list
281 if c.get("id") and str(c.get("id")) != main_candidate_id
282 ]
283 # 转 int 避免 bigint/string 类型不匹配导致 IN 查询返空
284 recalled_ids_int: list[int] = []
285 for rid in recalled_ids_str:
286 try:
287 recalled_ids_int.append(int(rid))
288 except (ValueError, TypeError):
289 pass
290 lyrics_map: dict[str, dict] = {}
291 if recalled_ids_int:
292 placeholders = ",".join(["%s"] * len(recalled_ids_int))
293 with _target_conn() as tconn, tconn.cursor() as tcursor:
294 # 目标表查找
295 tcursor.execute(
296 f"SELECT id, name, lyricist, composer, lyrics_url"
297 f" FROM {TARGET_TABLE_NAME} WHERE id IN ({placeholders})",
298 recalled_ids_int,
299 )
300 for mrow in tcursor.fetchall():
301 lyrics_map[str(mrow.get("id"))] = mrow
302 # 暂存表 fallback:目标表找不到的 ID 从暂存表补查
303 missing_ids = [rid for rid in recalled_ids_int if str(rid) not in lyrics_map]
304 if missing_ids:
305 ph2 = ",".join(["%s"] * len(missing_ids))
306 tcursor.execute(
307 f"SELECT source_song_id, name, lyricist, composer, lyrics_url"
308 f" FROM {TARGET_TABLE_NAME_TMP}"
309 f" WHERE source_song_id IN ({ph2})"
310 f" ORDER BY staging_id DESC",
311 [str(mid) for mid in missing_ids],
312 )
313 for mrow in tcursor.fetchall():
314 sid = str(mrow.get("source_song_id"))
315 if sid not in lyrics_map:
316 lyrics_map[sid] = mrow
317
318 rank = 2 if has_main_match else 1
319 for cand in recalled_list:
320 cand_id = str(cand.get("id") or "")
321 # 跳过已经是主匹配候选的(避免重复显示)
322 if cand_id and cand_id == main_candidate_id:
323 continue
324 cand_row = dict(dashboard_row)
325 cand_row["rank"] = str(rank)
326 cand_row["candidate_id"] = cand_id
327 # 优先用目标表/暂存表查到的元数据,回退到 JSON 中的信息
328 target_info = lyrics_map.get(cand_id)
329 if target_info:
330 cand_row["candidate_name"] = str(target_info.get("name") or cand.get("name") or "")
331 cand_row["candidate_lyricist"] = str(target_info.get("lyricist") or cand.get("lyricist") or "")
332 cand_row["candidate_composer"] = str(target_info.get("composer") or cand.get("composer") or "")
333 cand_lyrics = str(target_info.get("lyrics_url") or "")
334 if cand_lyrics and not cand_lyrics.startswith(("http://", "https://", "/")):
335 cand_lyrics = f"_raw:{cand_lyrics}"
336 cand_row["candidate_lyrics_path"] = cand_lyrics
337 else:
338 cand_row["candidate_name"] = str(cand.get("name") or "")
339 cand_row["candidate_lyricist"] = str(cand.get("lyricist") or "")
340 cand_row["candidate_composer"] = str(cand.get("composer") or "")
341 cand_row["candidate_lyrics_path"] = ""
342 cand_row["candidate_decision"] = str(cand.get("decision") or "new")
343 cand_row["candidate_confidence"] = str(cand.get("confidence") or "")
344 cand_row["candidate_jaccard"] = str(cand.get("jaccard") or "")
345 cand_row["candidate_line_coverage"] = str(cand.get("line_coverage") or "")
346 cand_row["candidate_reason"] = ""
347 rows.append(cand_row)
348 rank += 1
349 except (json.JSONDecodeError, TypeError):
350 pass
351
352 return {"rows": rows}
260 353
261 354
262 def _update_staging_review(source_song_id: str, decision: str, note: str, reviewer: str = "dashboard") -> dict[str, object]: 355 def _update_staging_review(source_song_id: str, decision: str, note: str, reviewer: str = "dashboard") -> dict[str, object]:
263 """更新暂存表的人工审核状态。 356 """更新暂存表的人工审核状态。
264 357
265 decision: 'approved_import' | 'rejected_duplicate' | 'unsure' | 'pending' (撤销) 358 decision: 'approved_import' | 'rejected_duplicate' | 'unsure' | 'pending' (撤销) | 'deleted'
266 """ 359 """
267 if decision == "pending": 360 if decision == "pending":
268 # 撤销审核 361 # 撤销审核
...@@ -275,6 +368,18 @@ def _update_staging_review(source_song_id: str, decision: str, note: str, review ...@@ -275,6 +368,18 @@ def _update_staging_review(source_song_id: str, decision: str, note: str, review
275 WHERE source_song_id = %s AND dedup_action = 'review' 368 WHERE source_song_id = %s AND dedup_action = 'review'
276 """ 369 """
277 params = [note, source_song_id] 370 params = [note, source_song_id]
371 elif decision == "deleted":
372 # 删除:不限定 dedup_action,对 review/skip 等各种 action 均可执行
373 sql = f"""
374 UPDATE {TARGET_TABLE_NAME_TMP}
375 SET staging_status = 'deleted',
376 biz_review_status = 'deleted',
377 reviewed_by = %s,
378 reviewed_at = NOW(),
379 biz_review_note = NULLIF(%s, '')
380 WHERE source_song_id = %s
381 """
382 params = [reviewer, note, source_song_id]
278 else: 383 else:
279 sql = f""" 384 sql = f"""
280 UPDATE {TARGET_TABLE_NAME_TMP} 385 UPDATE {TARGET_TABLE_NAME_TMP}
...@@ -502,6 +607,7 @@ def _group_index(path: Path, top_k: str) -> list[dict[str, object]]: ...@@ -502,6 +607,7 @@ def _group_index(path: Path, top_k: str) -> list[dict[str, object]]:
502 "has_hit": False, 607 "has_hit": False,
503 "has_conflict": False, 608 "has_conflict": False,
504 "has_l1_hint": False, 609 "has_l1_hint": False,
610 "decision": _row_decision(row),
505 } 611 }
506 groups_by_id[query_id] = group 612 groups_by_id[query_id] = group
507 order += 1 613 order += 1
...@@ -528,11 +634,14 @@ def _group_index(path: Path, top_k: str) -> list[dict[str, object]]: ...@@ -528,11 +634,14 @@ def _group_index(path: Path, top_k: str) -> list[dict[str, object]]:
528 return groups 634 return groups
529 635
530 636
531 def _groups_response(path: Path, top_k: str, mode: str, term: str, page: int, page_size: int) -> dict[str, object]: 637 def _groups_response(path: Path, top_k: str, mode: str, term: str, page: int, page_size: int, decision: str = '') -> dict[str, object]:
532 groups = _group_index(path, top_k) 638 groups = _group_index(path, top_k)
533 # hits 模式下只展示有命中的记录(merge/review),排除纯 new 639 # hits 模式下只展示有命中的记录(merge/review),排除纯 new
534 if mode == "hits": 640 if mode == "hits":
535 groups = [g for g in groups if g.get("has_hit")] 641 groups = [g for g in groups if g.get("has_hit")]
642 # 决策筛选
643 if decision and decision in ('new', 'merge', 'review', 'duplicate'):
644 groups = [g for g in groups if g.get("decision") == decision or (decision == 'merge' and g.get("decision") == 'merge')]
536 filtered = [group for group in groups if _matches_term(group, term)] 645 filtered = [group for group in groups if _matches_term(group, term)]
537 start = max(0, (page - 1) * page_size) 646 start = max(0, (page - 1) * page_size)
538 end = start + page_size 647 end = start + page_size
...@@ -638,13 +747,14 @@ class Handler(BaseHTTPRequestHandler): ...@@ -638,13 +747,14 @@ class Handler(BaseHTTPRequestHandler):
638 top_k = params.get("top_k", [""])[0] 747 top_k = params.get("top_k", [""])[0]
639 mode = params.get("mode", ["retrieval"])[0] 748 mode = params.get("mode", ["retrieval"])[0]
640 term = _norm(params.get("q", [""])[0]) 749 term = _norm(params.get("q", [""])[0])
750 decision = _norm(params.get("decision", [""])[0])
641 page = max(1, int(params.get("page", ["1"])[0])) 751 page = max(1, int(params.get("page", ["1"])[0]))
642 page_size = min(200, max(10, int(params.get("page_size", ["50"])[0]))) 752 page_size = min(200, max(10, int(params.get("page_size", ["50"])[0])))
643 if raw_path == STAGING_DB_PATH: 753 if raw_path == STAGING_DB_PATH:
644 _json_response(self, _staging_groups_response(mode, term, page, page_size)) 754 _json_response(self, _staging_groups_response(mode, term, page, page_size, decision))
645 return 755 return
646 path = _safe_path(raw_path) 756 path = _safe_path(raw_path)
647 _json_response(self, _groups_response(path, top_k, mode, term, page, page_size)) 757 _json_response(self, _groups_response(path, top_k, mode, term, page, page_size, decision))
648 except Exception as exc: # noqa: BLE001 - local diagnostic API 758 except Exception as exc: # noqa: BLE001 - local diagnostic API
649 _error(self, str(exc), status=404) 759 _error(self, str(exc), status=404)
650 return 760 return
...@@ -694,7 +804,7 @@ class Handler(BaseHTTPRequestHandler): ...@@ -694,7 +804,7 @@ class Handler(BaseHTTPRequestHandler):
694 reviewer = str(payload.get("reviewer", "dashboard")).strip() or "dashboard" 804 reviewer = str(payload.get("reviewer", "dashboard")).strip() or "dashboard"
695 if not source_id: 805 if not source_id:
696 raise ValueError("source_id is required") 806 raise ValueError("source_id is required")
697 if decision not in ("approved_import", "rejected_duplicate", "unsure", "pending"): 807 if decision not in ("approved_import", "rejected_duplicate", "unsure", "pending", "deleted"):
698 raise ValueError(f"invalid decision: {decision}") 808 raise ValueError(f"invalid decision: {decision}")
699 result = _update_staging_review(source_id, decision, note, reviewer) 809 result = _update_staging_review(source_id, decision, note, reviewer)
700 _json_response(self, result) 810 _json_response(self, result)
......
...@@ -5,9 +5,12 @@ ...@@ -5,9 +5,12 @@
5 """ 5 """
6 6
7 import csv 7 import csv
8 import json
8 import os 9 import os
10 import queue
9 import sys 11 import sys
10 import tempfile 12 import tempfile
13 import threading
11 import time 14 import time
12 from concurrent.futures import ThreadPoolExecutor, as_completed 15 from concurrent.futures import ThreadPoolExecutor, as_completed
13 from pathlib import Path 16 from pathlib import Path
...@@ -28,6 +31,8 @@ from import_hk_songs import ( ...@@ -28,6 +31,8 @@ from import_hk_songs import (
28 TARGET_TABLE_NAME, 31 TARGET_TABLE_NAME,
29 OSS_CONFIG, 32 OSS_CONFIG,
30 _is_lrc_format, 33 _is_lrc_format,
34 mark_rows_in_l1_index,
35 stop_db_writer,
31 _normalize_meta, 36 _normalize_meta,
32 build_row_tuple, 37 build_row_tuple,
33 classify_dedup_action, 38 classify_dedup_action,
...@@ -39,6 +44,7 @@ from import_hk_songs import ( ...@@ -39,6 +44,7 @@ from import_hk_songs import (
39 DedupReport, 44 DedupReport,
40 L2CandidateIndex, 45 L2CandidateIndex,
41 load_approved_import_ids, 46 load_approved_import_ids,
47 load_existing_l2_candidates,
42 ) 48 )
43 from lyric_dedup import DuplicateChecker, DuplicateDecision, LyricRecord 49 from lyric_dedup import DuplicateChecker, DuplicateDecision, LyricRecord
44 from lyric_dedup.checker import CandidateMatch 50 from lyric_dedup.checker import CandidateMatch
...@@ -117,6 +123,97 @@ class TestNormalizeMeta: ...@@ -117,6 +123,97 @@ class TestNormalizeMeta:
117 assert _normalize_meta('你好!?世界') == '你好世界' 123 assert _normalize_meta('你好!?世界') == '你好世界'
118 124
119 125
126 class TestPendingL1Index:
127 """测试异步 DB 写入时的内存 L1 索引维护"""
128
129 def test_marks_rows_before_background_db_writer_finishes(self):
130 l1_index = {}
131 rows = [
132 {'source_id': 101, 'name': '晴天', 'lyricist': '周杰伦', 'composer': '周杰伦'},
133 {'source_id': 102, 'name': '', 'lyricist': '无标题', 'composer': '匿名'},
134 ]
135
136 mark_rows_in_l1_index(rows, l1_index)
137
138 key = (_normalize_meta('晴天'), _normalize_meta('周杰伦'), _normalize_meta('周杰伦'))
139 assert l1_index[key] == 101
140 assert len(l1_index) == 1
141
142
143 class TestDbWriterShutdown:
144 """测试后台 DB 写入线程的优雅停止"""
145
146 def test_stop_db_writer_drains_queued_items_before_stopping(self):
147 write_queue = queue.Queue()
148 processed = []
149
150 def writer():
151 while True:
152 item = write_queue.get()
153 if item is None:
154 write_queue.task_done()
155 break
156 processed.append(item)
157 write_queue.task_done()
158
159 thread = threading.Thread(target=writer)
160 thread.start()
161 write_queue.put('batch-1')
162 write_queue.put('batch-2')
163
164 stop_db_writer(write_queue, thread)
165
166 assert processed == ['batch-1', 'batch-2']
167 assert not thread.is_alive()
168
169
170 class TestExistingLyricsCache:
171 """测试目标库已有歌词加载缓存"""
172
173 def test_load_existing_l2_candidates_reuses_cached_lyrics(self, tmp_path):
174 cache_path = tmp_path / 'lyrics_cache.jsonl'
175 cached = {
176 'id': '100',
177 'url': 'https://oss.example.test/100.txt',
178 'lyrics': '缓存里的歌词\n第二行',
179 'name': '旧歌',
180 'singer': '旧歌手',
181 'lyricist': '旧词',
182 'composer': '旧曲',
183 }
184 cache_path.write_text(json.dumps(cached, ensure_ascii=False) + '\n', encoding='utf-8')
185 rows = [
186 {
187 'id': 100,
188 'lyrics_url': 'https://oss.example.test/100.txt',
189 'name': '旧歌',
190 'singer': '旧歌手',
191 'lyricist': '旧词',
192 'composer': '旧曲',
193 },
194 {
195 'id': 101,
196 'lyrics_url': 'https://oss.example.test/101.txt',
197 'name': '新歌',
198 'singer': '新歌手',
199 'lyricist': '新词',
200 'composer': '新曲',
201 },
202 ]
203 downloaded_urls = []
204
205 def downloader(url, timeout=10):
206 downloaded_urls.append(url)
207 return '下载的新歌词\n第二行'.encode('utf-8'), 'text/plain'
208
209 records, stats = load_existing_l2_candidates(rows, cache_path, downloader=downloader)
210
211 assert downloaded_urls == ['https://oss.example.test/101.txt']
212 assert [r.record_id for r in records] == ['100', '101']
213 assert records[0].lyrics == '缓存里的歌词\n第二行'
214 assert stats == {'cached': 1, 'downloaded': 1, 'failed': 0}
215
216
120 # ==================================================================== 217 # ====================================================================
121 # L1 元数据去重 check_l1 218 # L1 元数据去重 check_l1
122 # ==================================================================== 219 # ====================================================================
...@@ -192,13 +289,13 @@ class TestCheckL2: ...@@ -192,13 +289,13 @@ class TestCheckL2:
192 def test_empty_lyrics(self): 289 def test_empty_lyrics(self):
193 """空歌词应返回 new""" 290 """空歌词应返回 new"""
194 row = {'id': 1, 'lyrics_txt_content': '', 'name': 'test', 'singer': 'test'} 291 row = {'id': 1, 'lyrics_txt_content': '', 'name': 'test', 'singer': 'test'}
195 decision, confidence, matched_id, reason = check_l2(row, self.checker, []) 292 decision, confidence, matched_id, reason, _ = check_l2(row, self.checker, [])
196 assert decision == 'new' 293 assert decision == 'new'
197 assert '无歌词内容' in reason 294 assert '无歌词内容' in reason
198 295
199 def test_none_lyrics(self): 296 def test_none_lyrics(self):
200 row = {'id': 1, 'lyrics_txt_content': None, 'name': 'test', 'singer': 'test'} 297 row = {'id': 1, 'lyrics_txt_content': None, 'name': 'test', 'singer': 'test'}
201 decision, _, _, _ = check_l2(row, self.checker, []) 298 decision, _, _, _, _ = check_l2(row, self.checker, [])
202 assert decision == 'new' 299 assert decision == 'new'
203 300
204 def test_no_candidates(self): 301 def test_no_candidates(self):
...@@ -209,7 +306,7 @@ class TestCheckL2: ...@@ -209,7 +306,7 @@ class TestCheckL2:
209 'name': '你是我的眼', 306 'name': '你是我的眼',
210 'singer': '萧煌奇', 307 'singer': '萧煌奇',
211 } 308 }
212 decision, confidence, matched_id, reason = check_l2(row, self.checker, []) 309 decision, confidence, matched_id, reason, _ = check_l2(row, self.checker, [])
213 assert decision == 'new' 310 assert decision == 'new'
214 assert '无候选集' in reason 311 assert '无候选集' in reason
215 312
...@@ -227,7 +324,7 @@ class TestCheckL2: ...@@ -227,7 +324,7 @@ class TestCheckL2:
227 'singer': '测试歌手', 324 'singer': '测试歌手',
228 } 325 }
229 326
230 decision, confidence, matched_id, reason = check_l2(row, self.checker, [candidate]) 327 decision, confidence, matched_id, reason, _ = check_l2(row, self.checker, [candidate])
231 328
232 assert decision == 'new' 329 assert decision == 'new'
233 assert matched_id is None 330 assert matched_id is None
...@@ -247,7 +344,7 @@ class TestCheckL2: ...@@ -247,7 +344,7 @@ class TestCheckL2:
247 'singer': '测试歌手', 344 'singer': '测试歌手',
248 } 345 }
249 346
250 decision, confidence, matched_id, reason = check_l2(row, self.checker, [candidate]) 347 decision, confidence, matched_id, reason, _ = check_l2(row, self.checker, [candidate])
251 348
252 assert decision == 'new' 349 assert decision == 'new'
253 assert matched_id is None 350 assert matched_id is None
...@@ -267,7 +364,7 @@ class TestCheckL2: ...@@ -267,7 +364,7 @@ class TestCheckL2:
267 'singer': '测试歌手', 364 'singer': '测试歌手',
268 } 365 }
269 366
270 decision, confidence, matched_id, reason = check_l2(row, self.checker, [candidate]) 367 decision, confidence, matched_id, reason, _ = check_l2(row, self.checker, [candidate])
271 368
272 assert decision == 'new' 369 assert decision == 'new'
273 assert matched_id is None 370 assert matched_id is None
...@@ -292,13 +389,30 @@ class TestCheckL2: ...@@ -292,13 +389,30 @@ class TestCheckL2:
292 'name': '你是我的眼', 389 'name': '你是我的眼',
293 'singer': '萧煌奇', 390 'singer': '萧煌奇',
294 } 391 }
295 decision, confidence, matched_id, reason = check_l2( 392 decision, confidence, matched_id, reason, _ = check_l2(
296 row, self.checker, [candidate] 393 row, self.checker, [candidate]
297 ) 394 )
298 assert decision == 'duplicate' 395 assert decision == 'duplicate'
299 assert confidence >= 0.9 396 assert confidence >= 0.9
300 assert matched_id == 'existing_1' 397 assert matched_id == 'existing_1'
301 398
399 def test_short_exact_hash_match_is_review_not_duplicate(self):
400 """规范化后歌词过短时,即使哈希完全一致也不能自动合并。"""
401 short_lyrics = '[00:00.730]春风吹过我的心'
402 candidate = LyricRecord(record_id='existing_short', lyrics=short_lyrics, title='旧歌')
403 row = {
404 'id': 22,
405 'lyrics_txt_content': '[00:01.120]春风吹过我的心',
406 'name': '另一首歌',
407 'singer': '不同歌手',
408 }
409
410 decision, _, matched_id, reason, _ = check_l2(row, self.checker, [candidate])
411
412 assert decision == 'review'
413 assert matched_id == 'existing_short'
414 assert '过短' in reason
415
302 def test_similar_lyrics_high_overlap(self): 416 def test_similar_lyrics_high_overlap(self):
303 """高度相似的歌词应判为 duplicate 或 review""" 417 """高度相似的歌词应判为 duplicate 或 review"""
304 base_lyrics = '\n'.join([ 418 base_lyrics = '\n'.join([
...@@ -335,7 +449,7 @@ class TestCheckL2: ...@@ -335,7 +449,7 @@ class TestCheckL2:
335 'name': '如果有一天(另一版)', 449 'name': '如果有一天(另一版)',
336 'singer': '未知', 450 'singer': '未知',
337 } 451 }
338 decision, confidence, matched_id, reason = check_l2( 452 decision, confidence, matched_id, reason, _ = check_l2(
339 row, self.checker, [candidate] 453 row, self.checker, [candidate]
340 ) 454 )
341 # 高相似度 -> 应该不是 new(duplicate 或 review) 455 # 高相似度 -> 应该不是 new(duplicate 或 review)
...@@ -362,7 +476,7 @@ class TestCheckL2: ...@@ -362,7 +476,7 @@ class TestCheckL2:
362 'name': '夜晚', 476 'name': '夜晚',
363 'singer': '未知', 477 'singer': '未知',
364 } 478 }
365 decision, confidence, matched_id, reason = check_l2( 479 decision, confidence, matched_id, reason, _ = check_l2(
366 row, self.checker, [candidate] 480 row, self.checker, [candidate]
367 ) 481 )
368 assert decision == 'new' 482 assert decision == 'new'
...@@ -392,7 +506,7 @@ class TestCheckL2: ...@@ -392,7 +506,7 @@ class TestCheckL2:
392 'name': '片段歌曲', 506 'name': '片段歌曲',
393 'singer': '未知', 507 'singer': '未知',
394 } 508 }
395 decision, _, _, _ = check_l2(row, self.checker, [candidate]) 509 decision, _, _, _, _ = check_l2(row, self.checker, [candidate])
396 assert decision == 'review' 510 assert decision == 'review'
397 511
398 def test_lrc_with_timestamps_still_detects(self): 512 def test_lrc_with_timestamps_still_detects(self):
...@@ -424,7 +538,7 @@ class TestCheckL2: ...@@ -424,7 +538,7 @@ class TestCheckL2:
424 'name': '小苹果(LRC版)', 538 'name': '小苹果(LRC版)',
425 'singer': '筷子兄弟', 539 'singer': '筷子兄弟',
426 } 540 }
427 decision, confidence, matched_id, reason = check_l2( 541 decision, confidence, matched_id, reason, _ = check_l2(
428 row, self.checker, [candidate] 542 row, self.checker, [candidate]
429 ) 543 )
430 assert decision == 'duplicate', f"Expected duplicate, got {decision}: {reason}" 544 assert decision == 'duplicate', f"Expected duplicate, got {decision}: {reason}"
...@@ -453,7 +567,7 @@ class TestCheckL2: ...@@ -453,7 +567,7 @@ class TestCheckL2:
453 'name': '小小鸟', 567 'name': '小小鸟',
454 'singer': '赵传', 568 'singer': '赵传',
455 } 569 }
456 decision, confidence, matched_id, _ = check_l2( 570 decision, confidence, matched_id, _, _ = check_l2(
457 row, self.checker, [candidate_different, candidate_exact] 571 row, self.checker, [candidate_different, candidate_exact]
458 ) 572 )
459 assert decision == 'duplicate' 573 assert decision == 'duplicate'
...@@ -477,7 +591,7 @@ class TestCheckL2: ...@@ -477,7 +591,7 @@ class TestCheckL2:
477 591
478 # 极低阈值 -> 更容易判为 duplicate 592 # 极低阈值 -> 更容易判为 duplicate
479 strict_checker = DuplicateChecker(duplicate_jaccard_threshold=0.3) 593 strict_checker = DuplicateChecker(duplicate_jaccard_threshold=0.3)
480 decision_strict, _, _, _ = check_l2(row, strict_checker, [candidate]) 594 decision_strict, _, _, _, _ = check_l2(row, strict_checker, [candidate])
481 assert decision_strict == 'duplicate' 595 assert decision_strict == 'duplicate'
482 596
483 def test_strong_review_same_writers_promotes_to_duplicate(self): 597 def test_strong_review_same_writers_promotes_to_duplicate(self):
...@@ -527,7 +641,7 @@ class TestCheckL2: ...@@ -527,7 +641,7 @@ class TestCheckL2:
527 'composer': '王琪', 641 'composer': '王琪',
528 } 642 }
529 643
530 decision, _, matched_id, reason = check_l2(row, self.checker, [candidate]) 644 decision, _, matched_id, reason, _ = check_l2(row, self.checker, [candidate])
531 645
532 assert decision == 'duplicate', reason 646 assert decision == 'duplicate', reason
533 assert matched_id == 'c1' 647 assert matched_id == 'c1'
...@@ -580,7 +694,7 @@ class TestCheckL2: ...@@ -580,7 +694,7 @@ class TestCheckL2:
580 'composer': '陈粒', 694 'composer': '陈粒',
581 } 695 }
582 696
583 decision, _, matched_id, reason = check_l2(row, self.checker, [candidate]) 697 decision, _, matched_id, reason, _ = check_l2(row, self.checker, [candidate])
584 698
585 assert decision == 'review', reason 699 assert decision == 'review', reason
586 assert matched_id == 'c1' 700 assert matched_id == 'c1'
...@@ -603,7 +717,7 @@ class TestCheckL2: ...@@ -603,7 +717,7 @@ class TestCheckL2:
603 'composer': '安智英/Vanilla Man', 717 'composer': '安智英/Vanilla Man',
604 } 718 }
605 719
606 decision, _, _, reason = check_l2(row, self.checker, [candidate]) 720 decision, _, _, reason, _ = check_l2(row, self.checker, [candidate])
607 721
608 assert decision != 'duplicate', reason 722 assert decision != 'duplicate', reason
609 723
...@@ -643,7 +757,7 @@ class TestCheckL2: ...@@ -643,7 +757,7 @@ class TestCheckL2:
643 'composer': '共同作者', 757 'composer': '共同作者',
644 } 758 }
645 759
646 decision, _, matched_id, reason = check_l2(row, self.checker, [candidate]) 760 decision, _, matched_id, reason, _ = check_l2(row, self.checker, [candidate])
647 761
648 assert decision != 'duplicate', reason 762 assert decision != 'duplicate', reason
649 763
...@@ -661,7 +775,7 @@ class TestCheckL2: ...@@ -661,7 +775,7 @@ class TestCheckL2:
661 'singer': '测试歌手', 775 'singer': '测试歌手',
662 } 776 }
663 777
664 decision, _, matched_id, reason = check_l2(row, self.checker, [candidate]) 778 decision, _, matched_id, reason, _ = check_l2(row, self.checker, [candidate])
665 779
666 assert decision != 'duplicate', reason 780 assert decision != 'duplicate', reason
667 781
...@@ -693,7 +807,7 @@ class TestCheckL2: ...@@ -693,7 +807,7 @@ class TestCheckL2:
693 'composer': '新曲作者', 807 'composer': '新曲作者',
694 } 808 }
695 809
696 decision, _, matched_id, reason = check_l2(row, self.checker, [candidate]) 810 decision, _, matched_id, reason, _ = check_l2(row, self.checker, [candidate])
697 811
698 assert decision == 'duplicate', reason 812 assert decision == 'duplicate', reason
699 assert matched_id == 'c1' 813 assert matched_id == 'c1'
...@@ -841,7 +955,7 @@ class TestL2CandidateIndex: ...@@ -841,7 +955,7 @@ class TestL2CandidateIndex:
841 ]) 955 ])
842 index.add(LyricRecord(record_id='existing', lyrics=lyrics, title='你是我的眼')) 956 index.add(LyricRecord(record_id='existing', lyrics=lyrics, title='你是我的眼'))
843 957
844 decision, confidence, matched_id, reason = check_l2( 958 decision, confidence, matched_id, reason, _ = check_l2(
845 { 959 {
846 'source_id': 999, 960 'source_id': 999,
847 'lyrics_txt_content': lyrics, 961 'lyrics_txt_content': lyrics,
...@@ -979,7 +1093,7 @@ class TestDedupIntegration: ...@@ -979,7 +1093,7 @@ class TestDedupIntegration:
979 assert is_dup is False 1093 assert is_dup is False
980 1094
981 # L2 应命中 1095 # L2 应命中
982 decision, confidence, matched_id, reason = check_l2( 1096 decision, confidence, matched_id, reason, _ = check_l2(
983 row, self.checker, self.l2_candidates 1097 row, self.checker, self.l2_candidates
984 ) 1098 )
985 assert decision == 'duplicate' 1099 assert decision == 'duplicate'
...@@ -1009,7 +1123,7 @@ class TestDedupIntegration: ...@@ -1009,7 +1123,7 @@ class TestDedupIntegration:
1009 assert is_dup is False 1123 assert is_dup is False
1010 1124
1011 # L2 也不命中 1125 # L2 也不命中
1012 decision, _, _, _ = check_l2(row, self.checker, self.l2_candidates) 1126 decision, _, _, _, _ = check_l2(row, self.checker, self.l2_candidates)
1013 assert decision == 'new' 1127 assert decision == 'new'
1014 1128
1015 def test_l1_match_with_different_lyrics_is_not_skipped(self): 1129 def test_l1_match_with_different_lyrics_is_not_skipped(self):
...@@ -1056,6 +1170,44 @@ class TestDedupIntegration: ...@@ -1056,6 +1170,44 @@ class TestDedupIntegration:
1056 assert action['merge_authors'] is True 1170 assert action['merge_authors'] is True
1057 assert action['matched_id'] == '100' 1171 assert action['matched_id'] == '100'
1058 1172
1173 def test_short_l2_exact_match_without_l1_is_new(self):
1174 """L2 过短 exact-hash 命中但 L1 未命中时,直接作为新歌。"""
1175 candidate = LyricRecord(record_id='102', lyrics='春风吹过我的心', title='旧歌')
1176 row = {
1177 'id': 205,
1178 'name': '另一首歌',
1179 'lyricist': '不同词作者',
1180 'composer': '不同曲作者',
1181 'lyrics_txt_content': '[00:00.730]春风吹过我的心',
1182 'singer': '不同歌手',
1183 }
1184
1185 action = classify_dedup_action(row, self.l1_index, self.checker, [candidate])
1186
1187 assert action['action'] == 'new'
1188 assert action['decision'] == 'new'
1189 assert action['matched_id'] is None
1190 assert action['l1_matched'] is False
1191
1192 def test_short_l2_exact_match_with_l1_is_merge(self):
1193 """L2 过短 exact-hash 命中且 L1 命中时,按 L1 结果自动合并。"""
1194 candidate = LyricRecord(record_id='100', lyrics='春风吹过我的心', title='晴天')
1195 row = {
1196 'id': 206,
1197 'name': '晴天',
1198 'lyricist': '周杰伦',
1199 'composer': '周杰伦',
1200 'lyrics_txt_content': '[00:00.730]春风吹过我的心',
1201 'singer': '周杰伦',
1202 }
1203
1204 action = classify_dedup_action(row, self.l1_index, self.checker, [candidate])
1205
1206 assert action['action'] == 'merge'
1207 assert action['decision'] == 'duplicate'
1208 assert action['matched_id'] == '100'
1209 assert action['l1_matched'] is True
1210
1059 def test_full_pipeline_simulation(self): 1211 def test_full_pipeline_simulation(self):
1060 """模拟完整 L1 -> L2 -> 写入 的管线流程""" 1212 """模拟完整 L1 -> L2 -> 写入 的管线流程"""
1061 batch = [ 1213 batch = [
...@@ -1100,7 +1252,7 @@ class TestDedupIntegration: ...@@ -1100,7 +1252,7 @@ class TestDedupIntegration:
1100 continue 1252 continue
1101 1253
1102 # L2 1254 # L2
1103 decision, confidence, l2_matched_id, reason = check_l2( 1255 decision, confidence, l2_matched_id, reason, _ = check_l2(
1104 row, self.checker, self.l2_candidates 1256 row, self.checker, self.l2_candidates
1105 ) 1257 )
1106 if decision == 'duplicate': 1258 if decision == 'duplicate':
...@@ -1138,7 +1290,7 @@ class TestEdgeCases: ...@@ -1138,7 +1290,7 @@ class TestEdgeCases:
1138 checker = DuplicateChecker() 1290 checker = DuplicateChecker()
1139 candidate = LyricRecord(record_id='c1', lyrics='爱', title='短歌') 1291 candidate = LyricRecord(record_id='c1', lyrics='爱', title='短歌')
1140 row = {'id': 1, 'lyrics_txt_content': '爱', 'name': '短歌', 'singer': 'test'} 1292 row = {'id': 1, 'lyrics_txt_content': '爱', 'name': '短歌', 'singer': 'test'}
1141 decision, _, _, _ = check_l2(row, checker, [candidate]) 1293 decision, _, _, _, _ = check_l2(row, checker, [candidate])
1142 # 极短歌词可能是 duplicate(哈希匹配)或 new,但不应崩溃 1294 # 极短歌词可能是 duplicate(哈希匹配)或 new,但不应崩溃
1143 assert decision in ('duplicate', 'review', 'new') 1295 assert decision in ('duplicate', 'review', 'new')
1144 1296
...@@ -1146,7 +1298,7 @@ class TestEdgeCases: ...@@ -1146,7 +1298,7 @@ class TestEdgeCases:
1146 """纯空白歌词""" 1298 """纯空白歌词"""
1147 checker = DuplicateChecker() 1299 checker = DuplicateChecker()
1148 row = {'id': 1, 'lyrics_txt_content': ' \n\n ', 'name': 'test', 'singer': 'test'} 1300 row = {'id': 1, 'lyrics_txt_content': ' \n\n ', 'name': 'test', 'singer': 'test'}
1149 decision, _, _, reason = check_l2(row, checker, []) 1301 decision, _, _, reason, _ = check_l2(row, checker, [])
1150 assert decision == 'new' 1302 assert decision == 'new'
1151 1303
1152 def test_l1_index_with_duplicate_names(self): 1304 def test_l1_index_with_duplicate_names(self):
...@@ -1171,7 +1323,7 @@ class TestEdgeCases: ...@@ -1171,7 +1323,7 @@ class TestEdgeCases:
1171 1323
1172 candidate = LyricRecord(record_id='c1', lyrics=plain, title='我爱你中国') 1324 candidate = LyricRecord(record_id='c1', lyrics=plain, title='我爱你中国')
1173 row = {'id': 2, 'lyrics_txt_content': with_ts, 'name': '我爱你中国', 'singer': '汪峰'} 1325 row = {'id': 2, 'lyrics_txt_content': with_ts, 'name': '我爱你中国', 'singer': '汪峰'}
1174 decision, _, _, _ = check_l2(row, checker, [candidate]) 1326 decision, _, _, _, _ = check_l2(row, checker, [candidate])
1175 # normalization 应该去掉时间戳后识别为相同歌词 1327 # normalization 应该去掉时间戳后识别为相同歌词
1176 assert decision == 'duplicate' 1328 assert decision == 'duplicate'
1177 1329
...@@ -1780,7 +1932,7 @@ def _run_l2_retrieval_benchmark( ...@@ -1780,7 +1932,7 @@ def _run_l2_retrieval_benchmark(
1780 'name': record.title, 1932 'name': record.title,
1781 'singer': record.artist, 1933 'singer': record.artist,
1782 } 1934 }
1783 decision, confidence, matched_id, reason = check_l2(row, checker, index) 1935 decision, confidence, matched_id, reason, _ = check_l2(row, checker, index)
1784 decision_counts[decision] += 1 1936 decision_counts[decision] += 1
1785 if show_progress: 1937 if show_progress:
1786 source_iterable.set_postfix( 1938 source_iterable.set_postfix(
......