feat(dedup): 优化歌词去重逻辑并引入作者字段增量合并
- 新增 L2 候选加载缓存机制,避免重复下载歌词内容 - L2 去重返回 top-K 召回候选信息,丰富判定依据 - 分类函数新增跳过导入的规则,如空歌词且作者不详、原声类歌曲名等 - 实现作者字段(作词、作曲)增量合并功能,避免重复覆盖 - 对合并冲突添加异常处理并记录日志,确保稳定执行 - 引入后台线程异步写入数据库,提升批量处理性能 - 优化 L1 索引更新,避免去重误差,提高去重准确率 - 扩展统计字段,新增作者合并和跳过导入计数 - 前端增加决策过滤选项,支持根据决策筛选展示记录 - 修改界面显示逻辑,支持删除已审核样本,完善用户操作 - 调整页面显示候选数目为 Top5,优化视觉体验 - 参数新增 L2 精确哈希自动合并的最小歌词长度配置 - 添加数据库表结构自动迁移,新增 recalled_candidates 字段支持新功能 - 增强程序中断时数据库写入的安全关闭与日志提示
Showing
5 changed files
with
846 additions
and
132 deletions
| ... | @@ -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: | 1901 | if not args.skip_dedup: |
| 1482 | with target_conn.cursor() as cursor: | 1902 | mark_rows_in_l1_index(batch_rows, l1_index) |
| 1483 | if batch_data: | 1903 | db_write_queue.put((batch_data, staging_data, batch_rows, batch_size_actual, batch_start, author_merge_tasks)) |
| 1484 | cursor.executemany(insert_sql, batch_data) | 1904 | |
| 1485 | if staging_data: | 1905 | # 所有批次已入队;等待后台 DB 写入收尾后停止线程 |
| 1486 | cursor.executemany(staging_insert_sql, staging_data) | 1906 | stop_db_writer(db_write_queue, db_writer_thread) |
| 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: | ||
| 1494 | for i, row in enumerate(batch_rows): | ||
| 1495 | key = ( | ||
| 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 | |||
| 1518 | pbar_db.update(batch_size_actual) | ||
| 1519 | |||
| 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( | ... | ... |
-
Please register or sign in to post a comment