1) 긴 호스트가 제목을 세로 한 줄짜리 컬럼으로 만들었다
.evidence-title 은 flex-wrap 없는 flex 행이고 source-chip·host-chip 둘 다
축소를 거부한다(flex: 0 0 auto). 그래서 제목에 남는 폭이 수십 px 이 되고
overflow-wrap: anywhere 가 그걸 한 단어씩 세로로 쪼갰다. 컨테이너에
flex-wrap, 제목에 min-width: 14ch 를 줘서 제목이 자기 줄로 내려간다.
실렌더 측정 (같은 DOM 에 수정 전 CSS 런타임 주입, Playwright):
360px 제목 51x95px (5줄) -> 246x19px (1줄), 행 높이 95 -> 50
1280px 제목 9x380px (~20줄) -> 205x19px, 행 높이 380 -> 50
주석은 데스크톱에서는 해결됐다고 적혀 있었지만, 45자 CDN 호스트로는
1280px 에서도 재현된다 — 폰 전용 문제가 아니었다.
2) 20ch 로 잘린 호스트를 읽을 방법이 없었다
칩 title 이 "게시 호스트" 고정 문구여서, 20자 접두어가 같은 두 CDN 호스트를
링크를 열지 않고 구분할 수 없었다 — 모노스페이스를 고른 이유가 그 판별이다.
툴팁에 실제 호스트를 싣는다.
3) 클라이언트 호스트 폴백이 서버보다 짧았다
_result_host 는 (url, result_url, page_url, source_page_url, blogger_link,
image_url) 를 순회하는데 evidenceHost 는 3개만 봤다. host 키가 생기기 전에
저장된 네이버 블로그 행이 bloggerLink 만 가진 경우 칩이 조용히 사라졌다 —
"레거시 증거가 자기 출처에 눈멀지 않게" 라는 폴백의 목적과 반대다.
4) 보존된 케이스가 설명 없는 깨진 이미지였다
assetMissing 을 읽는 소비자가 GUI·API·내보내기 어디에도 없었다. 판정은 DB 에
남지만 콘솔에서는 "판정 후 파일 아카이브" 와 "업로드 손상" 이 구분되지
않았다. 큐 썸네일과 케이스 이미지에 표시 + 파생물 노트에 사유를 붙인다.
5) PROMPT_VERSION 갱신 (ko-v3-structured -> ko-v4-structured-host)
847f7a8 이 PROMPT_DATA_FIELDS 의 domain -> host 로 모든 증거 줄의 내용을
바꿨는데 스탬프는 그대로여서, 한 버전이 서로 다른 두 프롬프트를 가리켰다.
화이트리스트가 다른 모듈에 있어도 프롬프트의 일부라는 점을 llm_prompts
독스트링의 규칙에 명시했다. 렌더된 줄이 host= 를 담는지 보는 행위 테스트도
추가했다(튜플 멤버십 검사로는 안 잡힌다).
CLAUDE.md 요구대로 실모델 프로브(qwen2.5:7b, 로컬 ollama): grounded 3 /
dropped 0 / verified True, 형식 예시 반향 없음, 인용 라벨 정확(E1=full match).
다만 모델은 host 를 문장에 쓰지 않고 page_title 로 서술했다 — 호스트는
운영자 칩에는 닿지만 서술에는 아직 반영되지 않는다(SYSTEM_PROMPT 과제).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
337 lines
11 KiB
JavaScript
337 lines
11 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>`;
|
|
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>
|
|
|
|
`;
|
|
|
|
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>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|