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,
) -> 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(

View file

@ -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

View file

@ -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()

View file

@ -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 = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`;
document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`;
@ -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
? `
<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) {
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 ? `<span class="evidence-new-chip">신규</span>` : "";
const sourceEvidenceIds = evidence.sourceEvidenceIds || [];
const sourceIds = sourceEvidenceIds.length
? `근거 ID: ${sourceEvidenceIds.join(", ")}`
@ -1619,11 +1659,12 @@ function renderEvidenceRow(evidence) {
.join("");
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}
<div class="evidence-main">
<div class="evidence-title">
<span class="source-chip ${sourceClass(evidence.source)}">${escapeHtml(sourceLabels[evidence.source] || evidence.source)}</span>
${newChip}
<span>${escapeHtml(formatEvidenceTitle(evidence))}</span>
</div>
<div class="evidence-meta">
@ -1733,6 +1774,7 @@ function renderEvidenceSearch() {
.join("")
: `<div class="empty-state">아직 실행한 검색 쿼리가 없습니다.</div>`;
activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
const searchableEvidence = searchableEvidenceItems(submission);
results.innerHTML = searchableEvidence.length
? renderEvidenceGroups({ ...submission, evidence: searchableEvidence })

View file

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

View file

@ -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;
}