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