// Part 6 of 15 of the operator console app script.
//
// Evidence grouping, ordering, and the evidence panel markup.
//
// 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.
function evidenceGroupDefinitions() {
return [
{ id: "watchlist", label: "주의 후보 근거", description: "보류/반려 이력에서 생성된 주의 후보 또는 유사 이미지입니다.", tone: "strong" },
{ id: "face_web", label: "얼굴 영역 웹 근거", description: "얼굴 영역만으로 찾은 웹 검색 결과입니다. 동일 인물 판정은 아닙니다.", tone: "search" },
{ id: "full", label: "동일 이미지", description: "원본 이미지 직접 매칭", tone: "strong" },
{ id: "partial", label: "부분 매칭", description: "일부 영역 또는 파생 이미지 매칭", tone: "strong" },
{ id: "page", label: "출처 페이지", description: "같은 이미지가 포함된 웹페이지", tone: "strong" },
{ id: "naver", label: "네이버 검색", description: "텍스트 기반 이미지 검색 결과", tone: "search" },
{ id: "face", label: "얼굴/인물 감지", description: "동일 인물 판정은 아니며 얼굴 또는 사람 존재만 표시합니다.", tone: "local" },
{ id: "provenance", label: "출처 표기", description: "파일에 들어 있던 권리 표기와 화면 캡처 신호입니다. 자기신고 정보이며 점수에 반영하지 않습니다.", tone: "local" },
{ id: "visual", label: "유사 이미지", description: "시각적으로 유사한 참고 결과", tone: "weak" },
{ id: "weak", label: "약한 라벨", description: "점수에 직접 반영하지 않는 힌트", tone: "weak" },
{ id: "llm", label: "내부 요약", description: "출처 연결 요약 메모", tone: "note" },
{ id: "internal", label: "내부 지문", description: "로컬 지문 기반 내부 처리 기록", tone: "local" },
{ id: "failure", label: "실패/건너뜀", description: "외부 검색 도구 실패 또는 정책상 스킵", tone: "failure" },
];
}
function evidenceGroupFor(evidence) {
if (evidence.knowledgeEntryStatus === "watchlist") return "watchlist";
if (evidence.faceCropSearch) return "face_web";
if (evidence.source === "google") {
if (evidence.matchType === "full") return "full";
if (evidence.matchType === "partial") return "partial";
if (evidence.matchType === "page") return "page";
if (evidence.matchType === "visual") return "visual";
if (evidence.matchType === "weak_label" || evidence.status === "weak") return "weak";
return "visual";
}
if (evidence.source === "naver") return "naver";
if (evidence.source === "face") return "face";
if (evidence.source === "provenance") return "provenance";
if (evidence.source === "llm") return "llm";
if (evidence.source === "failure") return "failure";
return "internal";
}
function groupedEvidence(evidence) {
return evidence.reduce((collection, item) => {
const group = evidenceGroupFor(item);
if (!collection[group]) collection[group] = [];
collection[group].push(item);
return collection;
}, {});
}
function evidencePriorityScore(evidence) {
const groupPriority = {
full: 100,
watchlist: 95,
partial: 85,
page: 75,
naver: 65,
face_web: 58,
face: 55,
visual: 35,
weak: 10,
llm: 8,
internal: 6,
failure: 4,
};
return (groupPriority[evidenceGroupFor(evidence)] || 0) + Number(evidence.confidence || 0);
}
function sortEvidenceItems(items) {
return [...items].sort((left, right) => evidencePriorityScore(right) - evidencePriorityScore(left));
}
function renderRerunDiffSummary(diff) {
const scoreBefore = Number(diff.scoreBefore || 0);
const scoreAfter = Number(diff.scoreAfter || 0);
const delta = scoreAfter - scoreBefore;
const direction = delta > 0 ? `상승 ${delta}` : delta < 0 ? `하락 ${Math.abs(delta)}` : "변동 없음";
const removedSummaries = diff.removedSummaries || [];
const removedBlock = removedSummaries.length
? `
이번 재분석에서 제거됨 · ${removedSummaries.length}개
${removedSummaries
.map((item) => `- ${escapeHtml(sourceLabels[item.source] || item.source || "내부")} · ${escapeHtml(formatReason(item.reason || ""))}
`)
.join("")}
`
: "";
return `
재분석 ${escapeHtml(diff.at || "")}
점수 ${escapeHtml(String(scoreBefore))} → ${escapeHtml(String(scoreAfter))} (${escapeHtml(direction)})
신규 증거 ${(diff.addedEvidenceIds || []).length}개
${removedBlock}
`;
}
function renderEvidenceSummary(submission) {
const groups = groupedEvidence(submission.evidence);
const count = (group) => (groups[group] || []).length;
const directCount = count("full") + count("partial") + count("page");
const searchCount = directCount + count("naver");
const primaryJudgment = directCount
? "직접 매칭 우선 확인"
: count("visual")
? "유사 이미지 참고 확인"
: count("face")
? "인물 신호만 확인됨"
: "증거 부족";
const summaryCards = [
["동일", count("full")],
["부분", count("partial")],
["페이지", count("page")],
["유사", count("visual")],
["네이버", count("naver")],
["인물", count("face")],
];
return `
${riskLabels[submission.riskBand]} ${submission.riskScore}
${escapeHtml(primaryJudgment)}
${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거
${summaryCards
.map(
([label, value]) => `
${escapeHtml(label)}
${value}
`,
)
.join("")}
`;
}
function renderEvidenceGroups(submission) {
if (!submission.evidence.length) {
return `
아직 분석 증거가 없습니다. 외부 검색 도구 상태를 확인하고 증거 재수집을 실행하세요.
`;
}
const groups = groupedEvidence(submission.evidence);
return evidenceGroupDefinitions()
.filter((group) => groups[group.id]?.length)
.map((group) => renderEvidenceGroup(group, sortEvidenceItems(groups[group.id])))
.join("");
}
function renderEvidenceGroup(group, items) {
const topItems = items.slice(0, 3);
const detailItems = items.slice(3);
return `
${escapeHtml(group.label)}
${escapeHtml(group.description)}
${items.length}개
${topItems.map(renderEvidenceRow).join("")}
${
detailItems.length
? `
자세히 보기 · ${detailItems.length}개
${detailItems.map(renderEvidenceRow).join("")}
`
: ""
}
`;
}
function renderEvidenceRow(evidence) {
const isNewFromRerun = activeRerunAddedIds.has(evidence.id);
const newChip = isNewFromRerun ? `신규` : "";
const sourceEvidenceIds = evidence.sourceEvidenceIds || [];
const sourceIds = sourceEvidenceIds.length
? `근거 ID: ${sourceEvidenceIds.join(", ")}`
: "근거 ID 없음";
const contribution = evidence.contributed ? "점수 반영" : "참고";
const normalizedOperatorStatus = normalizeEvidenceOperatorStatus(evidence.operatorStatus);
const operatorStatus = normalizedOperatorStatus
? `${escapeHtml(formatEvidenceStatus(normalizedOperatorStatus))}`
: "";
const confidence = typeof evidence.confidence === "number" ? evidence.confidence.toFixed(2) : evidence.confidence;
const matchType = evidence.matchType ? `${escapeHtml(formatMatchType(evidence.matchType))}` : "";
const pageTitle = evidence.pageTitle ? `${escapeHtml(evidence.pageTitle)}` : "";
const faceCropMeta = evidence.faceCropSearch
? `얼굴 영역 ${escapeHtml(evidence.cropIndex || "")}${escapeHtml(evidence.privacyNote || "동일 인물 판정은 아닙니다")}`
: "";
const watchlistMeta =
evidence.knowledgeEntryStatus === "watchlist"
? `주의 후보: ${escapeHtml(evidence.knowledgeEntryName || evidence.knowledgeEntryId || "-")}원 케이스 ${escapeHtml(evidence.sourceSubmissionId || "-")}`
: "";
const providerScore =
evidence.providerScore !== undefined && evidence.providerScore !== null && evidence.providerScore !== "" ? `검색 도구 점수 ${escapeHtml(evidence.providerScore)}` : "";
const similarity =
evidence.similarity !== undefined && evidence.similarity !== ""
? `이미지 유사도 ${escapeHtml(Number(evidence.similarity).toFixed(2))}`
: "";
const queryStrategy = evidence.queryStrategy ? `${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}` : "";
const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
const searchType = evidence.searchType ? `${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}` : "";
// Where the match is published. Read before the title in a rights review: a
// copy on the licensor's own site and one on a scraper blog are not the same
// finding. Kept out of last-child position so the title keeps its wrap rule.
const hostName = evidenceHost(evidence);
// The full host goes in the tooltip because the chip is capped at 20ch: two
// CDN hosts sharing a 20-character prefix are otherwise indistinguishable
// without opening the link, which is the discrimination the monospace font
// was chosen for.
const host = hostName
? `${escapeHtml(hostName)}`
: "";
const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
const preview = renderEvidencePreview(evidence);
const link = renderEvidenceLink(evidence);
const statusButtons = [
["used_for_judgment", "사용"],
["ignored", "미사용"],
]
.map(
([status, label]) => `
`,
)
.join("");
return `
${preview}
${escapeHtml(sourceLabels[evidence.source] || evidence.source)}
${newChip}
${host}
${escapeHtml(formatEvidenceTitle(evidence))}
신뢰도 ${escapeHtml(confidence)}
${escapeHtml(contribution)}
${operatorStatus}
${matchType}
${faceCropMeta}
${watchlistMeta}
${providerScore}
${similarity}
${queryStrategy}
${searchType}
${pageTitle}
${escapeHtml(evidence.query || "쿼리 없음")}
${escapeHtml(formatDomain(evidence.domain))}
${escapeHtml(evidence.retrievedAt)}
${escapeHtml(sourceIds)}
${link}
${statusButtons}
`;
}
function renderEvidencePreview(evidence) {
const previewUrl = evidence.thumbnailUrl || evidence.imageUrl;
const targetUrl = evidence.imageUrl || evidence.url || previewUrl;
if (!previewUrl) return ``;
return `
`;
}
function renderEvidenceLink(evidence) {
const url = evidence.url || evidence.imageUrl || "";
if (!url) return "";
const label = evidence.pageTitle || evidence.url || evidence.imageUrl;
return `
${escapeHtml(label)}
`;
}
function renderEvidenceSearch() {
const submission = getSelectedCase();
const queryHistory = document.getElementById("query-history");
const results = document.getElementById("search-results");
if (!submission) {
document.getElementById("query-case-label").textContent = "-";
queryHistory.innerHTML = `표시할 케이스가 없습니다.
`;
results.innerHTML = `API 연결 후 검색 근거를 확인할 수 있습니다.
`;
return;
}
document.getElementById("query-case-label").textContent = submission.id;
queryHistory.innerHTML = submission.queryHistory.length
? submission.queryHistory
.map(
(query) => `
${escapeHtml(providerLabels[query.provider] || query.provider)}
${escapeHtml(query.query)}
${escapeHtml(formatQueryStatus(query.status))}
${query.strategy ? `${escapeHtml(formatQueryStrategy(query.strategy))}` : ""}
${query.source ? `출처 ${escapeHtml(query.source)}` : ""}
${escapeHtml(query.timestamp)}
${query.count}건
`,
)
.join("")
: `아직 실행한 검색 쿼리가 없습니다.
`;
activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
const searchableEvidence = searchableEvidenceItems(submission);
results.innerHTML = searchableEvidence.length
? renderEvidenceGroups({ ...submission, evidence: searchableEvidence })
: `현재 케이스에는 검색 결과가 없습니다.
`;
}