Commit bbb6ce14 bbb6ce1447e193340855290021205a90c07891fe by 沈秋雨

feat(review): 优化审核结果提交和重复数据合并逻辑

1 parent 2393078b
...@@ -646,6 +646,7 @@ ...@@ -646,6 +646,7 @@
646 <option value="unsure">待确认</option> 646 <option value="unsure">待确认</option>
647 <option value="reviewed">已审核</option> 647 <option value="reviewed">已审核</option>
648 <option value="submitted">已提交</option> 648 <option value="submitted">已提交</option>
649 <option value="deleted">已删除</option>
649 <option value="">全部</option> 650 <option value="">全部</option>
650 <option value="l1_hit">L1 命中</option> 651 <option value="l1_hit">L1 命中</option>
651 <option value="conflict">L1/L2 冲突</option> 652 <option value="conflict">L1/L2 冲突</option>
...@@ -661,7 +662,7 @@ ...@@ -661,7 +662,7 @@
661 <option value="200">200</option> 662 <option value="200">200</option>
662 </select> 663 </select>
663 <button id="exportBtn">导出标注</button> 664 <button id="exportBtn">导出标注</button>
664 <button id="importReviewedBtn">入库审核通过</button> 665 <button id="importReviewedBtn">提交审核结果</button>
665 <div class="toolbar-right"> 666 <div class="toolbar-right">
666 <span>审核人:</span><span id="reviewerDisplay"></span> 667 <span>审核人:</span><span id="reviewerDisplay"></span>
667 <button id="logoutBtn" class="secondary">登出</button> 668 <button id="logoutBtn" class="secondary">登出</button>
...@@ -1002,7 +1003,7 @@ ...@@ -1002,7 +1003,7 @@
1002 group.review_claim_expires_at = record.review_claim_expires_at || ''; 1003 group.review_claim_expires_at = record.review_claim_expires_at || '';
1003 group.claimed_by_me = group.review_claimed_by === state.reviewer; 1004 group.claimed_by_me = group.review_claimed_by === state.reviewer;
1004 group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(record.biz_review_status); 1005 group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(record.biz_review_status);
1005 group.submitted = group.reviewed && record.staging_status === 'imported'; 1006 group.submitted = group.reviewed && ['imported', 'skipped'].includes(record.staging_status);
1006 } 1007 }
1007 1008
1008 function currentQuery() { 1009 function currentQuery() {
...@@ -1059,12 +1060,12 @@ ...@@ -1059,12 +1060,12 @@
1059 metric('待审核', row.pending_review_count || '0'), 1060 metric('待审核', row.pending_review_count || '0'),
1060 metric('已审核', row.reviewed_count || '0'), 1061 metric('已审核', row.reviewed_count || '0'),
1061 metric('已提交', row.submitted_count || '0'), 1062 metric('已提交', row.submitted_count || '0'),
1062 metric('确认入库', row.approved_count || '0'), 1063 metric('确认不重复', row.approved_count || '0'),
1063 metric('确认不入库', row.rejected_count || '0'), 1064 metric('确认重复', row.rejected_count || '0'),
1064 metric('已删除', row.deleted_count || '0'), 1065 metric('已删除', row.deleted_count || '0'),
1065 metric('new', row.new_count || '-'), 1066 metric('新歌', row.new_count || '-'),
1066 metric('merge', row.duplicate_count || '0'), 1067 metric('重复', row.duplicate_count || '0'),
1067 metric('skip', row.skip_count || '0') 1068 metric('已跳过', row.skip_count || '0')
1068 ].join(''); 1069 ].join('');
1069 } 1070 }
1070 1071
...@@ -1182,20 +1183,21 @@ ...@@ -1182,20 +1183,21 @@
1182 </div>`; 1183 </div>`;
1183 } 1184 }
1184 1185
1185 async function updateStagingReview(query, decision, note) { 1186 async function updateStagingReview(query, decision, note, candidateId = '') {
1186 return reviewPostJSON('/api/staging-review', { 1187 return reviewPostJSON('/api/staging-review', {
1187 staging_id: String(query.staging_id), 1188 staging_id: String(query.staging_id),
1188 expected_version: Number(query.review_version || 0), 1189 expected_version: Number(query.review_version || 0),
1189 client_token: state.clientToken, 1190 client_token: state.clientToken,
1190 reviewer: state.reviewer, 1191 reviewer: state.reviewer,
1191 decision, 1192 decision,
1192 note 1193 note,
1194 candidate_id: candidateId
1193 }); 1195 });
1194 } 1196 }
1195 1197
1196 const _DB_REVIEW_LABELS = { 1198 const _DB_REVIEW_LABELS = {
1197 approved_import: '确认入库(待入库)', 1199 approved_import: '确认不重复(待入库)',
1198 rejected_duplicate: '确认不入库', 1200 rejected_duplicate: '确认重复(待合并作者)',
1199 unsure: '待确认', 1201 unsure: '待确认',
1200 deleted: '已删除', 1202 deleted: '已删除',
1201 }; 1203 };
...@@ -1293,8 +1295,9 @@ ...@@ -1293,8 +1295,9 @@
1293 const needsManualReview = rowDecision(query) === 'review'; 1295 const needsManualReview = rowDecision(query) === 'review';
1294 const hasFinalReview = Boolean(query.review_status) && 1296 const hasFinalReview = Boolean(query.review_status) &&
1295 !['pending', 'not_required', 'unsure'].includes(query.review_status); 1297 !['pending', 'not_required', 'unsure'].includes(query.review_status);
1296 const canUndoReview = hasFinalReview && query.staging_status !== 'imported'; 1298 const isSubmitted = ['imported', 'skipped'].includes(query.staging_status);
1297 const canDeleteRecord = query.staging_status !== 'imported'; 1299 const canUndoReview = hasFinalReview && query.staging_status === 'staged';
1300 const canDeleteRecord = !['imported', 'skipped'].includes(query.staging_status);
1298 1301
1299 // Sticky area: 新入库歌词 + 召回候选(各含音频播放器) 1302 // Sticky area: 新入库歌词 + 召回候选(各含音频播放器)
1300 els.stickyInfo.innerHTML = ` 1303 els.stickyInfo.innerHTML = `
...@@ -1339,7 +1342,7 @@ ...@@ -1339,7 +1342,7 @@
1339 <div class="section-head"> 1342 <div class="section-head">
1340 <h2 class="section-title">人工标注</h2> 1343 <h2 class="section-title">人工标注</h2>
1341 ${hasFinalReview 1344 ${hasFinalReview
1342 ? `<span class="pill ${query.review_status === 'approved_import' ? 'new' : query.review_status === 'rejected_duplicate' ? 'duplicate' : query.review_status === 'deleted' ? 'duplicate' : ''}">${query.review_status === 'deleted' ? '已删除' : query.staging_status === 'imported' ? '已提交' : '已审核'}</span>` 1345 ? `<span class="pill ${query.review_status === 'approved_import' ? 'new' : query.review_status === 'rejected_duplicate' ? 'duplicate' : query.review_status === 'deleted' ? 'duplicate' : ''}">${query.review_status === 'deleted' ? '已删除' : isSubmitted ? '已提交' : '已审核'}</span>`
1343 : ''} 1346 : ''}
1344 </div> 1347 </div>
1345 <div class="section-body"> 1348 <div class="section-body">
...@@ -1347,7 +1350,9 @@ ...@@ -1347,7 +1350,9 @@
1347 <div class="review-bar" style="align-items:center"> 1350 <div class="review-bar" style="align-items:center">
1348 <span style="flex:1"> 1351 <span style="flex:1">
1349 <b>${esc(query.review_status === 'approved_import' && query.staging_status === 'imported' 1352 <b>${esc(query.review_status === 'approved_import' && query.staging_status === 'imported'
1350 ? '确认入库(已提交)' 1353 ? '确认不重复(已入库)'
1354 : query.review_status === 'rejected_duplicate' && query.staging_status === 'skipped'
1355 ? '确认重复(作者已合并)'
1351 : (_DB_REVIEW_LABELS[query.review_status] || query.review_status))}</b> 1356 : (_DB_REVIEW_LABELS[query.review_status] || query.review_status))}</b>
1352 ${query.reviewed_by ? ` · 审核人 ${esc(query.reviewed_by)}` : ''} 1357 ${query.reviewed_by ? ` · 审核人 ${esc(query.reviewed_by)}` : ''}
1353 ${query.reviewed_at ? ` · ${esc(query.reviewed_at)}` : ''} 1358 ${query.reviewed_at ? ` · ${esc(query.reviewed_at)}` : ''}
...@@ -1359,7 +1364,7 @@ ...@@ -1359,7 +1364,7 @@
1359 <div class="review-bar"> 1364 <div class="review-bar">
1360 ${['duplicate', 'not_duplicate', 'unsure'].map(value => ` 1365 ${['duplicate', 'not_duplicate', 'unsure'].map(value => `
1361 <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}"> 1366 <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}">
1362 ${value === 'duplicate' ? '确认不入库' : value === 'not_duplicate' ? '确认入库' : '待确认'} 1367 ${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'}
1363 </button>`).join('')} 1368 </button>`).join('')}
1364 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注"> 1369 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注">
1365 <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button> 1370 <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button>
...@@ -1411,7 +1416,9 @@ ...@@ -1411,7 +1416,9 @@
1411 renderDetail(); 1416 renderDetail();
1412 return; 1417 return;
1413 } 1418 }
1414 const payload = await updateStagingReview(query, dbDecision, note); 1419 const payload = await updateStagingReview(
1420 query, dbDecision, note, localDecision === 'duplicate' ? (candidate?.candidate_id || '') : ''
1421 );
1415 for (const row of state.queryRows) applyReviewSnapshot(row, payload.record); 1422 for (const row of state.queryRows) applyReviewSnapshot(row, payload.record);
1416 // 刷新统计和加载列表并行 1423 // 刷新统计和加载列表并行
1417 await Promise.all([reloadSummary(), loadGroups()]); 1424 await Promise.all([reloadSummary(), loadGroups()]);
...@@ -1530,12 +1537,12 @@ ...@@ -1530,12 +1537,12 @@
1530 alert('历史报表仅供查看,请在 staging-db 中执行入库。'); 1537 alert('历史报表仅供查看,请在 staging-db 中执行入库。');
1531 return; 1538 return;
1532 } 1539 }
1533 if (!confirm('确认入库数据库中所有"确认入库(待入库)"样本?')) return; 1540 if (!confirm('确认提交所有审核结果?“确认不重复”将入库,“确认重复”将把词/曲作者增量合并到已有记录。')) return;
1534 els.importReviewedBtn.disabled = true; 1541 els.importReviewedBtn.disabled = true;
1535 els.importReviewedBtn.textContent = '入库中...'; 1542 els.importReviewedBtn.textContent = '入库中...';
1536 try { 1543 try {
1537 const payload = await reviewPostJSON('/api/import-reviewed'); 1544 const payload = await reviewPostJSON('/api/import-reviewed');
1538 alert(`入库完成:${payload.inserted_count} 条。结果文件:${payload.review_csv}`); 1545 alert(`提交完成:不重复入库 ${payload.inserted_count} 条,重复样本 ${payload.duplicate_count || 0} 条,其中作者有增量 ${payload.author_merged_count || 0} 条。结果文件:${payload.review_csv}`);
1539 // 刷新统计和加载列表并行 1546 // 刷新统计和加载列表并行
1540 await Promise.all([reloadSummary(), loadGroups()]); 1547 await Promise.all([reloadSummary(), loadGroups()]);
1541 renderAll(); 1548 renderAll();
...@@ -1543,14 +1550,17 @@ ...@@ -1543,14 +1550,17 @@
1543 if (err.status === 409 && err.payload?.code === 'import_conflict') { 1550 if (err.status === 409 && err.payload?.code === 'import_conflict') {
1544 const conflicts = err.payload.conflicts || []; 1551 const conflicts = err.payload.conflicts || [];
1545 const details = conflicts.slice(0, 20).map((row, index) => { 1552 const details = conflicts.slice(0, 20).map((row, index) => {
1546 const types = (row.conflict_types || []).map(type => 1553 const typeLabels = {
1547 type === 'primary_key' ? '主键 ID 冲突' : '来源键冲突' 1554 primary_key: '主键 ID 冲突',
1548 ).join(' + ') || '唯一键冲突'; 1555 source_key: '来源键冲突',
1556 duplicate_target_missing: '未找到已入库的重复目标'
1557 };
1558 const types = (row.conflict_types || []).map(type => typeLabels[type] || type).join(' + ') || '数据冲突';
1549 const targetIds = [row.id_conflict_target_id, row.source_conflict_target_id] 1559 const targetIds = [row.id_conflict_target_id, row.source_conflict_target_id]
1550 .filter(value => value !== null && value !== undefined) 1560 .filter(value => value !== null && value !== undefined)
1551 .filter((value, i, values) => values.indexOf(value) === i) 1561 .filter((value, i, values) => values.indexOf(value) === i)
1552 .join('/'); 1562 .join('/');
1553 return `${index + 1}. ${row.name || '(无歌名)'} | source=${row.source_table_name || '-'}:${row.source_song_id || '-'} | staging_id=${row.staging_id} | 待入库ID=${row.incoming_id || '-'} | ${types} | 正式表ID=${targetIds || '-'}`; 1563 return `${index + 1}. ${row.name || '(无歌名)'} | source=${row.source_table_name || '-'}:${row.source_song_id || '-'} | staging_id=${row.staging_id} | 待入库ID=${row.incoming_id || '-'} | 重复候选=${row.matched_song_id || '-'} | ${types} | 正式表ID=${targetIds || '-'}`;
1554 }); 1564 });
1555 if (conflicts.length > 20) details.push(`……另有 ${conflicts.length - 20} 条未展开`); 1565 if (conflicts.length > 20) details.push(`……另有 ${conflicts.length - 20} 条未展开`);
1556 alert([ 1566 alert([
...@@ -1565,7 +1575,7 @@ ...@@ -1565,7 +1575,7 @@
1565 } 1575 }
1566 } finally { 1576 } finally {
1567 els.importReviewedBtn.disabled = false; 1577 els.importReviewedBtn.disabled = false;
1568 els.importReviewedBtn.textContent = '入库审核通过'; 1578 els.importReviewedBtn.textContent = '提交审核结果';
1569 } 1579 }
1570 } 1580 }
1571 1581
...@@ -1659,7 +1669,7 @@ ...@@ -1659,7 +1669,7 @@
1659 group.review_claim_expires_at = remote.review_claim_expires_at || ''; 1669 group.review_claim_expires_at = remote.review_claim_expires_at || '';
1660 group.claimed_by_me = Boolean(remote.claimed_by_me); 1670 group.claimed_by_me = Boolean(remote.claimed_by_me);
1661 group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(remote.biz_review_status); 1671 group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(remote.biz_review_status);
1662 group.submitted = group.reviewed && remote.staging_status === 'imported'; 1672 group.submitted = group.reviewed && ['imported', 'skipped'].includes(remote.staging_status);
1663 } 1673 }
1664 const query = currentQuery(); 1674 const query = currentQuery();
1665 const selectedRemote = query ? statuses.get(String(query.staging_id)) : null; 1675 const selectedRemote = query ? statuses.get(String(query.staging_id)) : null;
......
...@@ -96,6 +96,45 @@ def test_stale_review_returns_conflict_without_overwrite(): ...@@ -96,6 +96,45 @@ def test_stale_review_returns_conflict_without_overwrite():
96 assert not conn.committed 96 assert not conn.committed
97 97
98 98
99 def test_rejected_duplicate_persists_the_selected_candidate():
100 snapshot = {
101 "staging_id": 42,
102 "matched_song_id": None,
103 "recalled_candidates": '[{"id":"existing-song"}]',
104 "biz_review_status": "rejected_duplicate",
105 "review_version": 8,
106 }
107 cursor = FakeCursor(update_rowcount=1, snapshot=snapshot)
108 conn = FakeConnection(cursor)
109
110 with patch.object(dashboard, "_target_conn", return_value=conn):
111 dashboard._update_staging_review(
112 42, "rejected_duplicate", "same", "alice", 7, "browser-token", "existing-song"
113 )
114
115 update_sql, params = cursor.executions[1]
116 assert "matched_song_id = %s" in update_sql
117 assert params[:3] == ["rejected_duplicate", "alice", "existing-song"]
118 assert conn.committed
119
120
121 def test_rejected_duplicate_rejects_a_candidate_outside_recall_results():
122 cursor = FakeCursor(
123 update_rowcount=1,
124 snapshot={"staging_id": 42, "matched_song_id": "candidate-a", "recalled_candidates": "[]"},
125 )
126 conn = FakeConnection(cursor)
127
128 with patch.object(dashboard, "_target_conn", return_value=conn):
129 with pytest.raises(ValueError, match="不在当前召回结果"):
130 dashboard._update_staging_review(
131 42, "rejected_duplicate", "", "alice", 7, "browser-token", "candidate-b"
132 )
133
134 assert conn.rolled_back
135 assert not any(sql.lstrip().startswith("UPDATE") for sql, _params in cursor.executions)
136
137
99 def test_pending_review_restores_a_deleted_staging_row(): 138 def test_pending_review_restores_a_deleted_staging_row():
100 snapshot = { 139 snapshot = {
101 "staging_id": 42, 140 "staging_id": 42,
...@@ -185,7 +224,7 @@ class ImportCursor(FakeCursor): ...@@ -185,7 +224,7 @@ class ImportCursor(FakeCursor):
185 def __init__(self) -> None: 224 def __init__(self) -> None:
186 super().__init__(update_rowcount=1, snapshot={}) 225 super().__init__(update_rowcount=1, snapshot={})
187 self.result_sets = [ 226 self.result_sets = [
188 [{"staging_id": 42, "source_song_id": "song-1"}], 227 [{"staging_id": 42, "source_song_id": "song-1", "biz_review_status": "approved_import"}],
189 [], 228 [],
190 [{"source_song_id": "song-1", "target_id": 9001}], 229 [{"source_song_id": "song-1", "target_id": 9001}],
191 ] 230 ]
...@@ -217,7 +256,7 @@ def test_import_locks_only_unclaimed_staged_rows(): ...@@ -217,7 +256,7 @@ def test_import_locks_only_unclaimed_staged_rows():
217 def test_import_conflict_rolls_back_and_exposes_the_staging_row(): 256 def test_import_conflict_rolls_back_and_exposes_the_staging_row():
218 cursor = ImportCursor() 257 cursor = ImportCursor()
219 cursor.result_sets = [ 258 cursor.result_sets = [
220 [{"staging_id": 42, "source_song_id": "song-1"}], 259 [{"staging_id": 42, "source_song_id": "song-1", "biz_review_status": "approved_import"}],
221 [{ 260 [{
222 "staging_id": 42, 261 "staging_id": 42,
223 "source_song_id": "song-1", 262 "source_song_id": "song-1",
...@@ -241,6 +280,69 @@ def test_import_conflict_rolls_back_and_exposes_the_staging_row(): ...@@ -241,6 +280,69 @@ def test_import_conflict_rolls_back_and_exposes_the_staging_row():
241 assert not any(sql.lstrip().startswith("INSERT") for sql, _params in cursor.executions) 280 assert not any(sql.lstrip().startswith("INSERT") for sql, _params in cursor.executions)
242 281
243 282
283 class DuplicateImportCursor:
284 def __init__(self) -> None:
285 self.rowcount = 0
286 self.executions: list[tuple[str, object]] = []
287 self.current = None
288
289 def __enter__(self):
290 return self
291
292 def __exit__(self, *_args):
293 return False
294
295 def execute(self, sql, params=None):
296 self.executions.append((sql, params))
297 compact = " ".join(sql.split())
298 self.rowcount = 0
299 if "SELECT s.staging_id" in compact and "FOR UPDATE" in compact:
300 self.current = [{
301 "staging_id": 42,
302 "source_song_id": "new-song",
303 "source_table_name": "hk_song_platform",
304 "name": "same song",
305 "lyricist": "Alice/Bob",
306 "composer": "Composer B",
307 "biz_review_status": "rejected_duplicate",
308 "matched_song_id": "existing-song",
309 }]
310 elif "SELECT id FROM" in compact and "source_table_name = %s" in compact:
311 self.current = {"id": 9001}
312 elif "SELECT id, lyricist, composer" in compact:
313 self.current = {"id": 9001, "lyricist": "Alice", "composer": "Composer A"}
314 elif compact.startswith("UPDATE"):
315 self.current = None
316 self.rowcount = 1
317 else:
318 raise AssertionError(f"unexpected SQL: {compact}")
319
320 def fetchall(self):
321 return self.current or []
322
323 def fetchone(self):
324 return self.current
325
326
327 def test_confirmed_duplicate_merges_authors_without_inserting_a_new_song():
328 cursor = DuplicateImportCursor()
329 conn = FakeConnection(cursor)
330
331 with patch.object(dashboard, "_target_conn", return_value=conn):
332 result = dashboard._import_approved_staging()
333
334 sql_params = [(" ".join(sql.split()), params) for sql, params in cursor.executions]
335 assert not any(sql.startswith("INSERT") for sql, _params in sql_params)
336 assert any("SET lyricist = %s" in sql and params == ("Alice、Bob", 9001) for sql, params in sql_params)
337 assert any("SET composer = %s" in sql and params == ("Composer A、Composer B", 9001) for sql, params in sql_params)
338 assert any("SET staging_status = 'skipped'" in sql for sql, _params in sql_params)
339 assert result["duplicate_count"] == 1
340 assert result["author_merged_count"] == 1
341 assert result["inserted_count"] == 0
342 assert result["duplicate_source_ids"] == ["new-song"]
343 assert conn.committed
344
345
244 def test_browsing_a_list_item_can_auto_claim_without_reloading_groups(): 346 def test_browsing_a_list_item_can_auto_claim_without_reloading_groups():
245 html = Path("l2_review_dashboard.html").read_text(encoding="utf-8") 347 html = Path("l2_review_dashboard.html").read_text(encoding="utf-8")
246 start = html.index("for (const btn of els.queryList.querySelectorAll('.query-item'))") 348 start = html.index("for (const btn of els.queryList.querySelectorAll('.query-item'))")
...@@ -274,7 +376,17 @@ def test_dashboard_distinguishes_reviewed_from_submitted(): ...@@ -274,7 +376,17 @@ def test_dashboard_distinguishes_reviewed_from_submitted():
274 assert row["staging_status"] == "imported" 376 assert row["staging_status"] == "imported"
275 assert '<option value="reviewed">已审核</option>' in html 377 assert '<option value="reviewed">已审核</option>' in html
276 assert '<option value="submitted">已提交</option>' in html 378 assert '<option value="submitted">已提交</option>' in html
277 assert "hasFinalReview && query.staging_status !== 'imported'" in html 379 assert "hasFinalReview && query.staging_status === 'staged'" in html
380
381
382 def test_dashboard_has_a_dedicated_deleted_filter():
383 html = Path("l2_review_dashboard.html").read_text(encoding="utf-8")
384
385 assert '<option value="deleted">已删除</option>' in html
386 source = Path("serve_l2_dashboard.py").read_text(encoding="utf-8")
387 assert "elif decision == 'deleted':" in source
388 assert "s.biz_review_status = 'deleted'" in source
389 assert "s.staging_status = 'deleted'" in source
278 390
279 391
280 def test_review_access_code_issues_and_validates_edit_session(): 392 def test_review_access_code_issues_and_validates_edit_session():
......