// 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}개
` : ""; 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); 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 }) : `
현재 케이스에는 검색 결과가 없습니다.
`; }