코퍼스 DB 로 실 콘솔을 띄워 보니 큐의 '상위 근거' 칸이 '구글 visually s... 구글 페이지 with ...'(사유 원문 둘을 잘라 붙인 것)이었고 증거 제목도 '구글 full image match found' 로 절반 영어였다. 스코어러가 점수 계산과 별개로 집계(_Tally)해 riskBreakdown.headline 에 '웹 완전일치 사본 4곳 · 식별 가능 인물 · 이름 있는 검색 승격 2건 · 라벨 9개' 를 싣는다. 순서는 예산표, 웹은 가장 강한 뷰만 호스트 수(곳)로, 등록 일치·클리어 이력·미수행·실패· 신뢰 출처 제외까지 한 줄. 큐 행과 케이스 요약 보드의 첫 줄이 이것으로 바뀌고 headline 없는 옛 케이스만 예전 배치. formatReason 에 Vision 매치 4종·검색결과 사본· 기준 DB 유사도 한국어 매핑. 요약 보드 첫 줄은 word-break: keep-all 로 구 단위 줄바꿈. 점수·밴드 변화 없음(SCORER_VERSION 유지). 실렌더로 큐·케이스 확인. verify.ps1: ruff clean, 663 passed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
422 lines
13 KiB
JavaScript
422 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("");
|
|
|
|
// 스코어러가 준 한 줄 요약("웹 완전일치 사본 3곳 · 식별 가능 인물")이 있으면
|
|
// 그것이 상위 근거다. 사유 원문 둘을 잘라 붙이면 `구글 visually s...` 가 된다.
|
|
const headline = submission.riskBreakdown && submission.riskBreakdown.headline;
|
|
|
|
const reasonHtml = headline
|
|
|
|
? `<span title="${escapeHtml(headline)}">${escapeHtml(headline)}</span>`
|
|
|
|
: 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>`;
|
|
|
|
setScoreBreakdown(null);
|
|
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);
|
|
|
|
setScoreBreakdown(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;
|
|
|
|
}
|