POSA_Copyrighter/web/operator-gui/app-render-queue.js
changukyu 4e8e4b3181 feat: 선례 승계 — 재제출을 다시 심사하지 않는다
8/8 실효성 평가의 결론은 "검색·기록 노동은 줄여주나 심사 건수는 전혀 못 줄인다"
였다. 이 커밋은 그 축을 직접 건드리는 첫 기능이다.

중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능이다 — 재저장(JPEG q90)
/80%축소/50%축소/밝기+10% 변형 24건 중 24건 그룹핑, 같은 24건에서 SHA-256
완전일치는 0건. 그런데 그 결과를 쓰는 곳이 없었다. 서버는 store_duplicates.py
에서 priorDecisions 와 conflicting 을 계산해 내려보내는데, 콘솔은 그것을 큐
배지의 title 속성에만 넣었다. 마우스를 올린 사람만 "이거 지난주에 반려한
이미지"임을 알 수 있었고, 재제출은 처음부터 다시 심사됐다.

서버는 건드리지 않았다. 자리만 옮겼다.

  큐 배지        "동일 이미지 3건 · 선례 반려" (title 이 아니라 본문에)
  판정 패널      결정 메모 바로 위에 선례 배너 + [선례 승계] 1타

승계는 별도 판정 경로를 만들지 않는다. 메모를 채워 기존 setDecision 을 그대로
타므로 반려 메모 필수 검사 · 판정 시점 스냅샷 · 감사 이벤트가 유지되고, 승계
사실은 메모로 감사에 남는다("선례 승계: 동일 이미지 SUB-OLD1, SUB-OLD2 와 같은
판정").

판정 불일치는 승계 대상이 아니다. 같은 이미지에 서로 다른 판정이 있다는 것은
자동 처리할 상황이 아니라 사람이 봐야 하는 신호이므로, conflicting 이면 버튼을
아예 내지 않고 색을 갈라 표시한다.

실렌더 확인(Playwright 1440x900, 실제 서버 + 부트스트랩 스텁):
세 상태(승계 가능 / 판정 불일치 / 선례 없음)의 배너 문구와 버튼 유무가 의도대로,
승계 클릭 시 POST 본문이 위 메모 그대로, 콘솔 에러 0, 가로 오버플로 없음.

검증: ruff clean, pytest 622 passed (정적 3건 추가).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 12:05:38 +09:00

410 lines
13 KiB
JavaScript

// Part 5 of 15 of the operator console app script.
//
// Queue list, import status, and the case review shell.
//
// Load order is significant: these are classic (non-module) scripts sharing one
// global scope, and index.html must keep them in the order listed above/below.
// Splitting is a pure line-range extraction of the former single app.js.
// Shown wherever a retained case's image would render. `assetMissing` is set by
// the reload sweep when a decided case's source file has been moved out of the
// queue folder: the decision is kept, the file is gone, and the operator needs
// to be told which of the two happened.
const MISSING_ASSET_NOTE = "원본 파일 없음 (판정 기록 보존)";
function renderQueue() {
const rows = filteredSubmissions();
const queueLabel = state.submissionQueue?.label || "";
const queueMeta = queueLabel ? ` (${escapeHtml(queueLabel)})` : "";
const body = document.getElementById("queue-body");
document.getElementById("queue-health").innerHTML = state.apiError
? `
<span class="status-dot warn" aria-hidden="true"></span>
<span>${escapeHtml(state.apiError)}</span>
`
: `
<span class="status-dot ${rows.some((item) => item.riskBand === "failed") ? "warn" : "ok"}" aria-hidden="true"></span>
<span>${rows.length}건 / 고위험 ${submissions.filter((item) => item.riskBand === "high").length}건${queueMeta}</span>
`;
if (!rows.length) {
body.innerHTML = `
<tr>
<td colspan="9">
<div class="empty-state">현재 필터에 맞는 심사 건이 없습니다.</div>
</td>
</tr>
`;
return;
}
body.innerHTML = rows
.map((submission) => {
const providerHtml = visibleProviderStateEntries(submission.providerState)
.map(
([provider, status]) => {
const fullLabel = `${providerLabels[provider] || provider} ${formatProviderStatus(status)}`;
return `<span class="provider-chip queue-provider-chip ${providerClass(status)}" title="${escapeHtml(fullLabel)}">${escapeHtml(formatQueueProviderStatus(provider, status))}</span>`;
},
)
.join("");
const reasonHtml = submission.reasons
.slice(0, 2)
.map((reason) => `<span title="${escapeHtml(formatReason(reason))}">${escapeHtml(formatReason(reason))}</span>`)
.join("");
const submittedTime = formatQueueTimestamp(submission.submittedAt);
return `
<tr class="queue-row ${submission.id === state.selectedCaseId ? "selected-row" : ""}" data-row-case="${escapeHtml(submission.id)}">
<td><input type="checkbox" aria-label="${escapeHtml(submission.id)} 선택" data-bulk-id="${escapeHtml(submission.id)}"></td>
<td><img class="thumb${submission.assetMissing ? " thumb-missing" : ""}" src="${escapeHtml(submission.asset)}" alt="${escapeHtml(submission.title)} 썸네일"${submission.assetMissing ? ` title="${MISSING_ASSET_NOTE}"` : ""}></td>
<td>
<div class="queue-submission-cell">
<button class="row-action queue-case-button" type="button" data-select-case="${escapeHtml(submission.id)}" title="${escapeHtml(submission.id)}">
<span class="submission-id">${escapeHtml(submission.id)}</span>
</button>
${duplicateBadgeHtml(submission)}
</div>
</td>
<td>
<div class="score-stack queue-risk-cell" title="${escapeHtml(submission.lastAnalysis)}">
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]} ${submission.riskScore}</span>
</div>
</td>
<td><div class="reason-cell">${reasonHtml}</div></td>
<td><div class="provider-strip queue-provider-strip">${providerHtml}</div></td>
<td>${escapeHtml(submission.applicantStatus)}</td>
<td><span class="decision-badge">${decisionLabels[submission.decisionStatus]}</span></td>
<td>
<div class="queue-time-cell" title="${escapeHtml(submission.fileFacts.submitted)}">
<strong>${escapeHtml(submittedTime)}</strong>
</div>
</td>
</tr>
`;
})
.join("");
}
function renderImportStatus() {
const target = document.getElementById("submission-import-status");
if (!target) return;
const tone = state.importStatus.tone === "bad" ? "bad" : state.importStatus.tone === "warn" ? "warn" : "ok";
target.innerHTML = `
<span class="status-dot ${tone}" aria-hidden="true"></span>
<span>${escapeHtml(state.importStatus.message)}</span>
`;
}
function renderNoSelectedCase() {
document.getElementById("case-title").textContent = "선택된 제출 이미지가 없습니다";
document.getElementById("case-image").removeAttribute("src");
document.getElementById("case-image").alt = "";
document.getElementById("image-derivative-note").textContent = "API 연결 후 제출 이미지를 불러오면 이 영역에 분석 대상이 표시됩니다.";
document.getElementById("case-score").className = "score-pill risk-pending";
document.getElementById("case-score").textContent = "대기";
document.getElementById("floating-case-score").className = "score-pill risk-pending";
document.getElementById("floating-case-score").textContent = "대기";
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>`;
setPriorDecisionBox(null);
document.getElementById("derived-preview").innerHTML = `<span class="muted">자동 후보 생성 없음</span>`;
// Clear the evidence-gap recommendations too: they belong to the case that was
// just deselected, and leaving them rendered invited running suggested queries
// against nothing.
const nextActions = document.getElementById("evidence-next-actions");
if (nextActions) nextActions.innerHTML = "";
const llmDraft = document.getElementById("llm-draft");
if (llmDraft) {
llmDraft.hidden = true;
llmDraft.innerHTML = "";
}
}
function renderCaseReview() {
const submission = getSelectedCase();
if (!submission) {
renderNoSelectedCase();
return;
}
renderLlmDraft(submission);
document.getElementById("case-title").textContent = `${submission.id} · ${submission.title}`;
const caseImageElement = document.getElementById("case-image");
caseImageElement.src = safeUrl(submission.asset);
caseImageElement.alt = submission.assetMissing
? `${submission.id} 제출 이미지 - ${MISSING_ASSET_NOTE}`
: `${submission.id} 제출 이미지`;
caseImageElement.classList.toggle("case-image-missing", Boolean(submission.assetMissing));
// Reset the view controls (rotate / fit) so a new case starts unrotated.
caseImageElement.dataset.rotation = "0";
caseImageElement.style.transform = "";
caseImageElement.style.objectFit = "";
// Without this the retained case is a broken <img> with no explanation, and
// the operator cannot tell "file archived after the decision" from "upload
// corrupted". The decision survives in the DB either way; this is what makes
// it readable in the console.
document.getElementById("image-derivative-note").textContent = submission.assetMissing
? `${submission.derivativeNote} · ${MISSING_ASSET_NOTE}`
: submission.derivativeNote;
document.getElementById("case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
document.getElementById("case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`;
document.getElementById("floating-case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
document.getElementById("floating-case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`;
document.getElementById("file-facts").innerHTML = Object.entries(submission.fileFacts)
.map(([label, value]) => `<div class="fact-item"><span>${escapeHtml(formatFactLabel(label))}</span><strong>${escapeHtml(value)}</strong></div>`)
.join("");
document.getElementById("similar-strip").innerHTML = submission.similar
.map(
(item) => `
<div class="similar-item">
<img src="${escapeHtml(safeUrl(item.asset))}" alt="${escapeHtml(item.label)}">
<span>${escapeHtml(formatReason(item.label))}</span>
</div>
`,
)
.join("");
document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
.map(
(crop) => `
<a class="similar-item face-crop-item face-crop-link" href="${escapeHtml(safeUrl(crop.url))}" target="_blank" rel="noreferrer" aria-label="얼굴 영역 ${escapeHtml(String(crop.index))} 크게 보기">
<img src="${escapeHtml(safeUrl(crop.url))}" alt="얼굴 영역 ${escapeHtml(String(crop.index))} 크롭">
<span>얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다</span>
</a>
`,
)
.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);
document.getElementById("evidence-groups").innerHTML = renderEvidenceGroups(submission);
document.getElementById("recommendation-box").innerHTML = `
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]}</span>
<strong>${escapeHtml(submission.recommendation.label)}</strong>
<span class="muted">${escapeHtml(formatReason(submission.recommendation.detail))}</span>
<span class="muted small">현재 운영 결정: ${decisionLabels[submission.decisionStatus]}</span>
`;
setPriorDecisionBox(submission);
document.getElementById("derived-preview").innerHTML = `
<strong>${submission.derivedPreview.automatic ? "자동 후보 생성 예정" : "자동 이력 없음"}</strong>
<span>${escapeHtml(submission.derivedPreview.entryName)}</span>
<span class="muted small">${escapeHtml(submission.derivedPreview.effect)}</span>
`;
}
// 동일 이미지 선례를 판정 패널에 세운다.
//
// 이 정보는 서버가 이미 보내고 있었다(store_duplicates.py 의 priorDecisions).
// 문제는 자리였다 — 큐 배지의 title 속성에만 있어서, 운영자가 재제출을 처음부터
// 다시 심사해도 아무것도 막지 못했다. 중복 탐지는 이 도구에서 실증으로 검증된
// 유일한 판별 기능이므로(재저장·축소·밝기 변형 24/24 그룹핑, 같은 24건에서
// SHA-256 일치 0건) 그 결과를 판정 지점에 올린다.
function setPriorDecisionBox(submission) {
const box = document.getElementById("prior-decision-box");
if (!box) return;
const summary = submission ? priorDecisionSummary(submission) : null;
if (!summary) {
box.innerHTML = "";
box.hidden = true;
return;
}
const peers = summary.priors.map((item) => item.submissionId).join(", ");
if (summary.conflicting) {
// 같은 이미지에 서로 다른 판정이 있다. 승계를 제안하지 않는다 — 자동화할
// 상황이 아니라 운영자가 봐야 하는 신호다.
box.innerHTML = `
<strong class="prior-decision-conflict">동일 이미지 ${summary.size}건 · 판정 불일치</strong>
<span class="muted">${escapeHtml(peers || "-")}</span>
`;
} else if (!summary.inheritable) {
box.innerHTML = `
<strong>동일 이미지 ${summary.size}건</strong>
<span class="muted">아직 판정된 선례 없음</span>
`;
} else {
const label = decisionLabels[summary.inheritable] || summary.inheritable;
box.innerHTML = `
<strong>동일 이미지 선례 · ${escapeHtml(label)}</strong>
<span class="muted">${escapeHtml(peers)}</span>
<button class="row-action" type="button" data-inherit-decision="${escapeHtml(summary.inheritable)}">선례 승계</button>
`;
}
box.hidden = false;
}