Commit 93b03ea9 93b03ea91557e1bebbe201d31f48271e2d68830e by 沈秋雨

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

- 新增 L2 候选加载缓存机制,避免重复下载歌词内容
- L2 去重返回 top-K 召回候选信息,丰富判定依据
- 分类函数新增跳过导入的规则,如空歌词且作者不详、原声类歌曲名等
- 实现作者字段(作词、作曲)增量合并功能,避免重复覆盖
- 对合并冲突添加异常处理并记录日志,确保稳定执行
- 引入后台线程异步写入数据库,提升批量处理性能
- 优化 L1 索引更新,避免去重误差,提高去重准确率
- 扩展统计字段,新增作者合并和跳过导入计数
- 前端增加决策过滤选项,支持根据决策筛选展示记录
- 修改界面显示逻辑,支持删除已审核样本,完善用户操作
- 调整页面显示候选数目为 Top5,优化视觉体验
- 参数新增 L2 精确哈希自动合并的最小歌词长度配置
- 添加数据库表结构自动迁移,新增 recalled_candidates 字段支持新功能
- 增强程序中断时数据库写入的安全关闭与日志提示
1 parent 25cb261f
...@@ -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 = (
......