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