③ 파일명 stem 충돌: 다른 폴더의 같은 이름 파일이 한 건으로 합쳐져 둘째가 조용히 미심사였다. id 는 재유도하지 않고(기존 판정 보존) id_collisions() 로 탐지해 bootstrap.intakeConflicts 와 파일당 1회 감사로 남기고, 콘솔 투입 상태줄에 경고 목록을 띄운다. ④ 행 버튼 '미사용' 이 false_positive 로 가 대표 근거 하나를 고르면 나머지 점수가 지워졌다 → '사용'/'무관', 사용 재클릭은 pending(표시 해제, 점수 유지). ⑤ 분모 0 비율은 None → '측정 불가'. 외부 검색이 꺼진 케이스는 점수 0 유지, 한 줄 요약에 '외부 검색 꺼짐'. 부채: operator-review-workflow 13px 넘침 해소(겹치지 않는 레인 병합) → 다이어그램 5종 첫 REBUILD OK, v6 이전 카드 문구 교정. tests/conftest.py 로 로컬 Ollama 격리(가동 중에도 통과). 루트 VERSION 0.1.0 단일 출처, /health 와 기동 로그에 버전·스코어러 버전. verify.ps1 (Ollama 가동 중, override 없음): ruff clean, 680 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
442 lines
14 KiB
JavaScript
442 lines
14 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";
|
|
|
|
// 파일명이 겹쳐 심사 건으로 들어가지 못한 파일. 여기서 말하지 않으면 운영자는
|
|
// 큐가 비는 순간 "다 처리했다"고 믿는다 — 한 지원자의 사진이 조용히 빠진 채로.
|
|
const conflicts = state.intakeConflicts || [];
|
|
const conflictHtml = conflicts.length
|
|
? `
|
|
<div class="intake-conflicts" role="alert">
|
|
<strong>심사 건으로 들어가지 못한 파일 ${conflicts.length}개</strong> — 파일명을 바꾼 뒤 다시 불러오세요.
|
|
<ul>
|
|
${conflicts
|
|
.map((item) =>
|
|
item.kind === "same_stem"
|
|
? `<li><code>${escapeHtml(item.file)}</code> · <code>${escapeHtml(item.keptFile)}</code> 와 파일명이 같음</li>`
|
|
: `<li><code>${escapeHtml(item.file)}</code> · 다른 큐(${escapeHtml(item.ownerQueueId)})에 같은 id 가 있음</li>`,
|
|
)
|
|
.join("")}
|
|
</ul>
|
|
</div>`
|
|
: "";
|
|
|
|
target.innerHTML = `
|
|
|
|
<span class="status-dot ${conflicts.length ? "warn" : tone}" aria-hidden="true"></span>
|
|
|
|
<span>${escapeHtml(state.importStatus.message)}</span>
|
|
${conflictHtml}
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|