이건 내가 만든 부채를 갚는 커밋이다. 스코어러에 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를 넣으면서 증거 개수와 점수의 간극이 그 어느 때보다 벌어졌는데, 그 이유가 화면 어디에도 없었다. 운영자가 숫자를 믿지 못하면 그 우선순위는 우선순위가 아니고, 그러면 앞의 두 기능이 오히려 불신을 만든다. ScoreResult.breakdown 을 신설했다. reasons 가 "어떤 증거가 말했는가"라면 breakdown 은 "그래서 몇 점인가"다. 지식DB 지문 매칭 80 웹 게시 (google) 60 원래 125 · 상한 60 웹 엔티티 라벨 20 원래 114 · 상한 20 신뢰 출처 posar.info 3행 제외 합계 115점이 상한 100에 걸렸습니다 — 이 구간의 케이스는 서로 구별되지 않습니다 마지막 줄이 특히 중요하다. 100 에 걸린 케이스들은 서로 구별되지 않으므로 우선순위 판단에 쓸 수 없는데, 지금까지는 그 사실이 숨어 있었다. 자리는 증거 요약과 증거 목록 사이다 - "51개 증거"를 읽은 직후, 목록을 훑기 직전이 바로 그 질문이 생기는 지점이다. 점수는 하나도 바뀌지 않는다. 계산 흐름에 기록만 붙였고 기존 스코어러 테스트 31건이 그대로 통과한다. 가중치·버킷·밴드가 불변이므로 SCORER_VERSION 은 v4 를 유지한다(설명을 추가하는 것은 스탬프 규칙의 대상이 아니다). 분해 합계가 항상 점수와 일치하는지를 테스트로 고정했다. 분해가 점수와 다른 이야기를 하면 그건 분해가 아니라 또 하나의 주장이 된다. 실렌더(Playwright 1440x900): 패널이 의도한 자리에 서고 콘솔 에러 0. 검증: ruff clean, pytest 642 passed (스코어러 5건 + 정적 1건 추가). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
414 lines
13 KiB
JavaScript
414 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>`;
|
|
|
|
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;
|
|
|
|
}
|