// 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 ? ` ${escapeHtml(state.apiError)} ` : ` ${rows.length}건 / 고위험 ${submissions.filter((item) => item.riskBand === "high").length}건${queueMeta} `; if (!rows.length) { body.innerHTML = `
현재 필터에 맞는 심사 건이 없습니다.
`; return; } body.innerHTML = rows .map((submission) => { const providerHtml = visibleProviderStateEntries(submission.providerState) .map( ([provider, status]) => { const fullLabel = `${providerLabels[provider] || provider} ${formatProviderStatus(status)}`; return `${escapeHtml(formatQueueProviderStatus(provider, status))}`; }, ) .join(""); const reasonHtml = submission.reasons .slice(0, 2) .map((reason) => `${escapeHtml(formatReason(reason))}`) .join(""); const submittedTime = formatQueueTimestamp(submission.submittedAt); return ` ${escapeHtml(submission.title)} 썸네일
${duplicateBadgeHtml(submission)}
${riskLabels[submission.riskBand]} ${submission.riskScore}
${reasonHtml}
${providerHtml}
${escapeHtml(submission.applicantStatus)} ${decisionLabels[submission.decisionStatus]}
${escapeHtml(submittedTime)}
`; }) .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 = ` ${escapeHtml(state.importStatus.message)} `; } 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 = `
표시할 케이스가 없습니다.
`; setScoreBreakdown(null); document.getElementById("evidence-groups").innerHTML = `
API 연결 실패 또는 제출 이미지 없음 상태입니다.
`; document.getElementById("recommendation-box").innerHTML = `실제 API 데이터가 로드될 때까지 판정하지 않습니다.`; setPriorDecisionBox(null); document.getElementById("derived-preview").innerHTML = `자동 후보 생성 없음`; // 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 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]) => `
${escapeHtml(formatFactLabel(label))}${escapeHtml(value)}
`) .join(""); document.getElementById("similar-strip").innerHTML = submission.similar .map( (item) => `
${escapeHtml(item.label)} ${escapeHtml(formatReason(item.label))}
`, ) .join(""); document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || []) .map( (crop) => ` 얼굴 영역 ${escapeHtml(String(crop.index))} 크롭 얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다 `, ) .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); setScoreBreakdown(submission); document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(submission); document.getElementById("evidence-groups").innerHTML = renderEvidenceGroups(submission); document.getElementById("recommendation-box").innerHTML = ` ${riskLabels[submission.riskBand]} ${escapeHtml(submission.recommendation.label)} ${escapeHtml(formatReason(submission.recommendation.detail))} 현재 운영 결정: ${decisionLabels[submission.decisionStatus]} `; setPriorDecisionBox(submission); document.getElementById("derived-preview").innerHTML = ` ${submission.derivedPreview.automatic ? "자동 후보 생성 예정" : "자동 이력 없음"} ${escapeHtml(submission.derivedPreview.entryName)} ${escapeHtml(submission.derivedPreview.effect)} `; } // 동일 이미지 선례를 판정 패널에 세운다. // // 이 정보는 서버가 이미 보내고 있었다(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 = ` 동일 이미지 ${summary.size}건 · 판정 불일치 ${escapeHtml(peers || "-")} `; } else if (!summary.inheritable) { box.innerHTML = ` 동일 이미지 ${summary.size}건 아직 판정된 선례 없음 `; } else { const label = decisionLabels[summary.inheritable] || summary.inheritable; box.innerHTML = ` 동일 이미지 선례 · ${escapeHtml(label)} ${escapeHtml(peers)} `; } box.hidden = false; }