From 4d98582ed3e9f99267c8aa8534a271c95bc30322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Fri, 12 Jun 2026 18:00:43 +0900 Subject: [PATCH] feat: rerun enrichment evidence diff with score delta and new-evidence badges --- src/rights_filter/server/sqlite_store.py | 34 ++++++++++++++++ tests/operator_gui/test_static_workbench.py | 14 +++++++ tests/rights_filter/server/test_http_app.py | 24 +++++++++++ web/operator-gui/app.js | 44 ++++++++++++++++++++- web/operator-gui/index.html | 1 + web/operator-gui/styles.css | 23 +++++++++++ 6 files changed, 139 insertions(+), 1 deletion(-) diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index e5762c8..63bbbe8 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -1218,6 +1218,11 @@ class CopyrighterStore: image_store: LocalSubmissionImageStore | None = None, ) -> dict[str, Any]: submission = self._get("submissions", submission_id) + score_before = int(submission.get("riskScore", 0) or 0) + evidence_before = { + str(item.get("id", "")): item + for item in self._evidence_by_submission().get(submission_id, []) + } submission["lastAnalysis"] = _now_label() self._put("submissions", submission_id, submission) evidence = { @@ -1251,6 +1256,35 @@ class CopyrighterStore: self.add_audit_event("rights.ops", "Analysis run created", submission_id, "operator rerun") self._rescore_submission(submission_id) self._sync_submission_provider_state() + + evidence_after = { + str(item.get("id", "")): item + for item in self._evidence_for_submission(submission_id) + } + rerun_marker_prefix = f"ev-{submission_id}-rerun-" + added_ids = [ + evidence_id + for evidence_id in evidence_after + if evidence_id not in evidence_before and not evidence_id.startswith(rerun_marker_prefix) + ] + removed_items = [ + evidence_before[evidence_id] + for evidence_id in evidence_before + if evidence_id not in evidence_after + ] + refreshed = self._get("submissions", submission_id) + refreshed["lastRerunDiff"] = { + "at": _now_label(), + "scoreBefore": score_before, + "scoreAfter": int(refreshed.get("riskScore", 0) or 0), + "addedEvidenceIds": added_ids, + "removedEvidenceIds": [str(item.get("id", "")) for item in removed_items], + "removedSummaries": [ + {"source": str(item.get("source", "")), "title": str(item.get("title", ""))} + for item in removed_items + ], + } + self._put("submissions", submission_id, refreshed) return self.review(submission_id) def run_auto_search( diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index 558d297..58556b7 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -841,3 +841,17 @@ def test_workbench_shows_detected_face_crop_strip(): assert "faceCrops" in script assert "얼굴 영역" in script assert "동일 인물 판정 아님" in script + + +def test_rerun_diff_summary_and_new_evidence_badges_are_rendered(): + html = _read(INDEX) + script = _read(APP_JS) + styles = _read(STYLES) + + assert 'id="rerun-diff-summary"' in html + assert "renderRerunDiffSummary" in script + assert "lastRerunDiff" in script + assert "evidence-new-chip" in script + assert "이번 재분석에서 제거됨" in script + assert ".evidence-new-chip" in styles + assert ".evidence-row-new" in styles diff --git a/tests/rights_filter/server/test_http_app.py b/tests/rights_filter/server/test_http_app.py index b296aa8..29ebfa4 100644 --- a/tests/rights_filter/server/test_http_app.py +++ b/tests/rights_filter/server/test_http_app.py @@ -1249,3 +1249,27 @@ def test_face_crops_are_empty_for_undetectable_images(tmp_path: Path): assert review.get("faceCrops", []) == [] finally: server.shutdown() + + +def test_rerun_enrichment_records_evidence_diff(tmp_path: Path): + static_dir, image_store, store = _fixtures(tmp_path) + server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir) + _start(server) + base = f"http://127.0.0.1:{server.server_port}" + + try: + initial = _json(base + "/api/submissions/SUB-API1/review") + assert "lastRerunDiff" not in initial + + rerun = _json(base + "/api/submissions/SUB-API1/rerun-enrichment", method="POST", body={}) + diff = rerun["lastRerunDiff"] + assert isinstance(diff["scoreBefore"], int) + assert isinstance(diff["scoreAfter"], int) + assert isinstance(diff["addedEvidenceIds"], list) + assert isinstance(diff["removedEvidenceIds"], list) + assert isinstance(diff["removedSummaries"], list) + assert diff["at"] + marker_prefix = "ev-SUB-API1-rerun-" + assert all(not evidence_id.startswith(marker_prefix) for evidence_id in diff["addedEvidenceIds"]) + finally: + server.shutdown() diff --git a/web/operator-gui/app.js b/web/operator-gui/app.js index 9925cf9..175081c 100644 --- a/web/operator-gui/app.js +++ b/web/operator-gui/app.js @@ -35,6 +35,8 @@ let suggestedQueryRunSummary = null; // { caseId, message } — 현재 케이스 const knowledgeFilters = { query: "", type: "all", status: "all", active: "all" }; let editingKnowledgeEntryId = ""; +let activeRerunAddedIds = new Set(); // 현재 렌더 중인 케이스의 lastRerunDiff.addedEvidenceIds + const DEFAULT_COVERAGE_THRESHOLDS = Object.freeze({ coverageGoodRate: 70, @@ -1227,6 +1229,8 @@ function renderNoSelectedCase() { document.getElementById("file-facts").innerHTML = ""; document.getElementById("similar-strip").innerHTML = ""; document.getElementById("face-crop-strip").innerHTML = ""; + activeRerunAddedIds = new Set(); + document.getElementById("rerun-diff-summary").innerHTML = ""; document.getElementById("case-reasons").innerHTML = `
표시할 케이스가 없습니다.
`; document.getElementById("evidence-groups").innerHTML = `
API 연결 실패 또는 제출 이미지 없음 상태입니다.
`; document.getElementById("recommendation-box").innerHTML = `실제 API 데이터가 로드될 때까지 판정하지 않습니다.`; @@ -1301,6 +1305,11 @@ function renderCaseReview() { ) .join(""); + activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []); + document.getElementById("rerun-diff-summary").innerHTML = submission.lastRerunDiff + ? renderRerunDiffSummary(submission.lastRerunDiff) + : ""; + document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission); document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(submission); @@ -1450,6 +1459,35 @@ function sortEvidenceItems(items) { +function renderRerunDiffSummary(diff) { + const scoreBefore = Number(diff.scoreBefore || 0); + const scoreAfter = Number(diff.scoreAfter || 0); + const delta = scoreAfter - scoreBefore; + const direction = delta > 0 ? `상승 ${delta}` : delta < 0 ? `하락 ${Math.abs(delta)}` : "변동 없음"; + const removedSummaries = diff.removedSummaries || []; + const removedBlock = removedSummaries.length + ? ` +
+ 이번 재분석에서 제거됨 · ${removedSummaries.length}개 + +
+ ` + : ""; + return ` +
+ 재분석 ${escapeHtml(diff.at || "")} + 점수 ${escapeHtml(String(scoreBefore))} → ${escapeHtml(String(scoreAfter))} (${escapeHtml(direction)}) + 신규 증거 ${(diff.addedEvidenceIds || []).length}개 + ${removedBlock} +
+ `; +} + + function renderEvidenceSummary(submission) { const groups = groupedEvidence(submission.evidence); const count = (group) => (groups[group] || []).length; @@ -1574,6 +1612,8 @@ function renderEvidenceGroup(group, items) { function renderEvidenceRow(evidence) { + const isNewFromRerun = activeRerunAddedIds.has(evidence.id); + const newChip = isNewFromRerun ? `신규` : ""; const sourceEvidenceIds = evidence.sourceEvidenceIds || []; const sourceIds = sourceEvidenceIds.length ? `근거 ID: ${sourceEvidenceIds.join(", ")}` @@ -1619,11 +1659,12 @@ function renderEvidenceRow(evidence) { .join(""); return ` -
+
${preview}
${escapeHtml(sourceLabels[evidence.source] || evidence.source)} + ${newChip} ${escapeHtml(formatEvidenceTitle(evidence))}
@@ -1733,6 +1774,7 @@ function renderEvidenceSearch() { .join("") : `
아직 실행한 검색 쿼리가 없습니다.
`; + activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []); const searchableEvidence = searchableEvidenceItems(submission); results.innerHTML = searchableEvidence.length ? renderEvidenceGroups({ ...submission, evidence: searchableEvidence }) diff --git a/web/operator-gui/index.html b/web/operator-gui/index.html index 24c9516..61ce4c2 100644 --- a/web/operator-gui/index.html +++ b/web/operator-gui/index.html @@ -243,6 +243,7 @@

증거와 판단 근거

+
diff --git a/web/operator-gui/styles.css b/web/operator-gui/styles.css index 4dad169..ba89d63 100644 --- a/web/operator-gui/styles.css +++ b/web/operator-gui/styles.css @@ -2794,3 +2794,26 @@ tbody tr.selected-row, border: 1px solid #d4d4d8; border-radius: 6px; } + +.rerun-diff-panel { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + align-items: center; + padding: 10px 12px; + border: 1px solid #d4d4d8; + border-radius: 8px; + margin-bottom: 12px; +} + +.evidence-new-chip { + border: 1px solid #1d4ed8; + border-radius: 999px; + padding: 0 8px; + font-size: 12px; + color: #1d4ed8; +} + +.evidence-row-new { + box-shadow: inset 0 0 0 2px #1d4ed8; +}