feat: rerun enrichment evidence diff with score delta and new-evidence badges
This commit is contained in:
parent
1e0f4f8690
commit
4d98582ed3
6 changed files with 139 additions and 1 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue