feat: rerun enrichment evidence diff with score delta and new-evidence badges

This commit is contained in:
유창욱 2026-06-12 18:00:43 +09:00
parent 1e0f4f8690
commit 4d98582ed3
6 changed files with 139 additions and 1 deletions

View file

@ -1218,6 +1218,11 @@ class CopyrighterStore:
image_store: LocalSubmissionImageStore | None = None, image_store: LocalSubmissionImageStore | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
submission = self._get("submissions", submission_id) 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() submission["lastAnalysis"] = _now_label()
self._put("submissions", submission_id, submission) self._put("submissions", submission_id, submission)
evidence = { evidence = {
@ -1251,6 +1256,35 @@ class CopyrighterStore:
self.add_audit_event("rights.ops", "Analysis run created", submission_id, "operator rerun") self.add_audit_event("rights.ops", "Analysis run created", submission_id, "operator rerun")
self._rescore_submission(submission_id) self._rescore_submission(submission_id)
self._sync_submission_provider_state() 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) return self.review(submission_id)
def run_auto_search( def run_auto_search(

View file

@ -841,3 +841,17 @@ def test_workbench_shows_detected_face_crop_strip():
assert "faceCrops" in script assert "faceCrops" in script
assert "얼굴 영역" in script assert "얼굴 영역" 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

View file

@ -1249,3 +1249,27 @@ def test_face_crops_are_empty_for_undetectable_images(tmp_path: Path):
assert review.get("faceCrops", []) == [] assert review.get("faceCrops", []) == []
finally: finally:
server.shutdown() 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()

View file

@ -35,6 +35,8 @@ let suggestedQueryRunSummary = null; // { caseId, message } — 현재 케이스
const knowledgeFilters = { query: "", type: "all", status: "all", active: "all" }; const knowledgeFilters = { query: "", type: "all", status: "all", active: "all" };
let editingKnowledgeEntryId = ""; let editingKnowledgeEntryId = "";
let activeRerunAddedIds = new Set(); // 현재 렌더 중인 케이스의 lastRerunDiff.addedEvidenceIds
const DEFAULT_COVERAGE_THRESHOLDS = Object.freeze({ const DEFAULT_COVERAGE_THRESHOLDS = Object.freeze({
coverageGoodRate: 70, coverageGoodRate: 70,
@ -1227,6 +1229,8 @@ function renderNoSelectedCase() {
document.getElementById("file-facts").innerHTML = ""; document.getElementById("file-facts").innerHTML = "";
document.getElementById("similar-strip").innerHTML = ""; document.getElementById("similar-strip").innerHTML = "";
document.getElementById("face-crop-strip").innerHTML = ""; document.getElementById("face-crop-strip").innerHTML = "";
activeRerunAddedIds = new Set();
document.getElementById("rerun-diff-summary").innerHTML = "";
document.getElementById("case-reasons").innerHTML = `<div class="empty-state">표시할 케이스가 없습니다.</div>`; document.getElementById("case-reasons").innerHTML = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`; document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`;
document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`; document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`;
@ -1301,6 +1305,11 @@ function renderCaseReview() {
) )
.join(""); .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("case-reasons").innerHTML = renderEvidenceSummary(submission);
document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(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
? `
<details class="rerun-removed">
<summary>이번 재분석에서 제거됨 · ${removedSummaries.length}</summary>
<ul>
${removedSummaries
.map((item) => `<li>${escapeHtml(sourceLabels[item.source] || item.source || "내부")} · ${escapeHtml(formatReason(item.title || ""))}</li>`)
.join("")}
</ul>
</details>
`
: "";
return `
<section class="rerun-diff-panel" aria-label="재분석 변경 요약">
<strong>재분석 ${escapeHtml(diff.at || "")}</strong>
<span>점수 ${escapeHtml(String(scoreBefore))} ${escapeHtml(String(scoreAfter))} (${escapeHtml(direction)})</span>
<span>신규 증거 ${(diff.addedEvidenceIds || []).length}</span>
${removedBlock}
</section>
`;
}
function renderEvidenceSummary(submission) { function renderEvidenceSummary(submission) {
const groups = groupedEvidence(submission.evidence); const groups = groupedEvidence(submission.evidence);
const count = (group) => (groups[group] || []).length; const count = (group) => (groups[group] || []).length;
@ -1574,6 +1612,8 @@ function renderEvidenceGroup(group, items) {
function renderEvidenceRow(evidence) { function renderEvidenceRow(evidence) {
const isNewFromRerun = activeRerunAddedIds.has(evidence.id);
const newChip = isNewFromRerun ? `<span class="evidence-new-chip">신규</span>` : "";
const sourceEvidenceIds = evidence.sourceEvidenceIds || []; const sourceEvidenceIds = evidence.sourceEvidenceIds || [];
const sourceIds = sourceEvidenceIds.length const sourceIds = sourceEvidenceIds.length
? `근거 ID: ${sourceEvidenceIds.join(", ")}` ? `근거 ID: ${sourceEvidenceIds.join(", ")}`
@ -1619,11 +1659,12 @@ function renderEvidenceRow(evidence) {
.join(""); .join("");
return ` return `
<article class="evidence-row ${hasPreview ? "" : "no-preview"}" data-evidence-id="${escapeHtml(evidence.id)}"> <article class="evidence-row ${hasPreview ? "" : "no-preview"} ${isNewFromRerun ? "evidence-row-new" : ""}" data-evidence-id="${escapeHtml(evidence.id)}">
${preview} ${preview}
<div class="evidence-main"> <div class="evidence-main">
<div class="evidence-title"> <div class="evidence-title">
<span class="source-chip ${sourceClass(evidence.source)}">${escapeHtml(sourceLabels[evidence.source] || evidence.source)}</span> <span class="source-chip ${sourceClass(evidence.source)}">${escapeHtml(sourceLabels[evidence.source] || evidence.source)}</span>
${newChip}
<span>${escapeHtml(formatEvidenceTitle(evidence))}</span> <span>${escapeHtml(formatEvidenceTitle(evidence))}</span>
</div> </div>
<div class="evidence-meta"> <div class="evidence-meta">
@ -1733,6 +1774,7 @@ function renderEvidenceSearch() {
.join("") .join("")
: `<div class="empty-state">아직 실행한 검색 쿼리가 없습니다.</div>`; : `<div class="empty-state">아직 실행한 검색 쿼리가 없습니다.</div>`;
activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
const searchableEvidence = searchableEvidenceItems(submission); const searchableEvidence = searchableEvidenceItems(submission);
results.innerHTML = searchableEvidence.length results.innerHTML = searchableEvidence.length
? renderEvidenceGroups({ ...submission, evidence: searchableEvidence }) ? renderEvidenceGroups({ ...submission, evidence: searchableEvidence })

View file

@ -243,6 +243,7 @@
<h2 id="evidence-pane-title">증거와 판단 근거</h2> <h2 id="evidence-pane-title">증거와 판단 근거</h2>
<span class="score-pill" id="case-score"></span> <span class="score-pill" id="case-score"></span>
</div> </div>
<div id="rerun-diff-summary" class="rerun-diff-summary"></div>
<div id="case-reasons" class="reason-list"></div> <div id="case-reasons" class="reason-list"></div>
<div id="evidence-next-actions" class="evidence-next-actions"></div> <div id="evidence-next-actions" class="evidence-next-actions"></div>
<div id="evidence-groups" class="evidence-groups"></div> <div id="evidence-groups" class="evidence-groups"></div>

View file

@ -2794,3 +2794,26 @@ tbody tr.selected-row,
border: 1px solid #d4d4d8; border: 1px solid #d4d4d8;
border-radius: 6px; 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;
}