P1 3번("매칭 호스트 기반 신뢰출처 구분")을 착수하며 전제를 검증했더니 틀렸다.
호스트는 "이미 저장돼 있고 점수만 안 쓰는" 게 아니라 저장 시점에 파괴되고
있었다.
google_custom_search.py:240,296 이 실호스트(displayLink)를 data["domain"] 에
넣는데, store_serialization.py:298 이 data["provider"] 를 우선하므로 그 값이
API 이름으로 덮어써진다. Vision·Naver 는 호스트를 아예 안 넣고 URL 만 준다.
결과적으로 저장된 모든 행의 domain 이 "google" 이고, LLM 프롬프트 12줄이 전부
domain=google 이었다 — 저작권 심사에서 가장 결정적인 "어디에 떠 있나"를 모델도
운영자도 볼 수 없었다. 같은 케이스의 payload 55행 중 41행이 url 을 갖고 있는데도.
domain 필드는 재사용할 수 없다. provider 커버리지 동기화와 CSE 쿼리 시그니처
매칭이 그 값을 키로 쓰고(store_serialization.py:195, 599) 콘솔도 provider
라벨로 렌더한다. 그래서 host 를 별도 키로 신설했다.
_result_host(): data["domain"] 이 provider 와 다르면 그것을, 아니면 결과/페이지
URL 에서 호스트를 파싱한다. www 제거·소문자화. 파싱 실패는 "호스트 없음"이지
영속화 실패가 아니다.
점수에는 영향이 없다 — RiskScorer 는 domain 도 host 도 읽지 않는다. 증거 id 도
안 바뀐다: _evidence_id 는 도메인 객체의 data 를 해싱하고 payload 를 해싱하지
않으므로 payload 키 추가가 id 를 움직이지 않는다.
PROMPT_DATA_FIELDS 의 domain -> host. 화이트리스트 크기는 그대로.
콘솔은 증거 카드 제목 줄에 monospace host-chip 을 붙인다. 기존 저장 행에는 host
키가 없으므로 evidenceHost() 가 저장된 URL 에서 파생한다 — 292행 마이그레이션도,
bootstrap 마다 행당 파싱도 필요 없다(렌더되는 행만 지나간다).
실렌더 검증(127.0.0.1:9512, 실 DB)에서만 드러난 레이아웃 결함 2건을 고쳤다.
코드 리딩으로는 보이지 않았다.
1) 긴 CDN 호스트(cloudfront-ap-northeast-1.images.arcpublishing.com)가 제목 줄
폭을 다 먹어, evidence-title 의 overflow-wrap:anywhere 와 결합해 제목이 한
글자씩 세로로 깨졌다. 칩을 max-width 20ch 로 묶고 제목에 flex:1 1 auto 를 줘
남는 폭을 갖게 했다.
2) 그 수정 후에는 반대로 biz.chosun.com(14자)까지 잘렸다. 칩을 flex:0 0 auto 로
고정했다 — 제목("구글 full image match found")은 전 행이 동일한 상용구이고
호스트가 그 행의 실제 내용이므로, 들어갈 수 있는 호스트가 잘리는 쪽이 잘못이다.
최종: 칩 82개 전부 유효 호스트 형태(TLD com/edu/hk/jp/kr/net/org), 가시 칩 41개 중
잘리는 것 4개(모두 20자 초과 CDN), 콘솔 에러 0.
PROVENANCE 배점은 보류한다. 두 조건이 다 안 맞는다. (a) detect_screen_capture 의
카테고리가 geometry 와 subtitle_band 둘뿐인데 2개를 요구하므로 라벨의 실제 의미는
"geometry AND 자막"이고, 자막 없는 방송 캡처는 구조적으로 도달 불가다 — 임계를
낮추는 게 아니라 독립적인 세 번째 신호가 필요하고 그건 새 탐지기다. 한계를 실측과
함께 상수 옆에 기록했다. (b) "새 탐지 신호는 0점 라벨로 출시한 뒤 decision_stats
상관을 보고 가중치 부여"가 2026-07-25 결정이고, 판정 0건인 지금 배점하면 그 결정을
위반한다.
신뢰출처 판정 자체는 "어느 도메인이 우리 것/라이선스 계약처인가"라는 운영자
데이터가 있어야 한다. 호스트가 살아났으므로 목록만 정해지면 cleared_reference 와
같은 패턴(점수 0 + 사유 노출)으로 붙는다.
테스트 4건 추가. pytest 574 passed(착수 전 570), 린트 클린.
455 lines
15 KiB
JavaScript
455 lines
15 KiB
JavaScript
// 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
|
|
? `
|
|
<details class="rerun-removed">
|
|
<summary>이번 재분석에서 제거됨 · ${removedSummaries.length}개</summary>
|
|
<ul>
|
|
${removedSummaries
|
|
.map((item) => `<li>${escapeHtml(sourceLabels[item.source] || item.source || "내부")} · ${escapeHtml(formatReason(item.reason || ""))}</li>`)
|
|
.join("")}
|
|
</ul>
|
|
</details>
|
|
`
|
|
: "";
|
|
return `
|
|
<section class="rerun-diff-panel" aria-label="재분석 변경 요약">
|
|
<strong>재분석 ${escapeHtml(diff.at || "")}</strong>
|
|
<span>점수 ${escapeHtml(String(scoreBefore))} → ${escapeHtml(String(scoreAfter))} (${escapeHtml(direction)})</span>
|
|
<span>신규 증거 ${(diff.addedEvidenceIds || []).length}개</span>
|
|
${removedBlock}
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
|
|
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 `
|
|
<section class="evidence-summary-board" aria-label="판단 요약">
|
|
<div class="summary-lead">
|
|
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]} ${submission.riskScore}</span>
|
|
<div>
|
|
<strong>${escapeHtml(primaryJudgment)}</strong>
|
|
<span>${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거</span>
|
|
</div>
|
|
</div>
|
|
<div class="summary-stat-grid">
|
|
${summaryCards
|
|
.map(
|
|
([label, value]) => `
|
|
<div class="summary-stat ${value ? "has-signal" : ""}">
|
|
<span>${escapeHtml(label)}</span>
|
|
<strong>${value}</strong>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
|
|
|
|
function renderEvidenceGroups(submission) {
|
|
if (!submission.evidence.length) {
|
|
return `<div class="empty-state">아직 분석 증거가 없습니다. 외부 검색 도구 상태를 확인하고 증거 재수집을 실행하세요.</div>`;
|
|
}
|
|
|
|
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 `
|
|
|
|
<section class="evidence-group evidence-group-${escapeHtml(group.tone)}" aria-label="${escapeHtml(group.label)} 증거">
|
|
|
|
<div class="evidence-group-head">
|
|
|
|
<div>
|
|
|
|
<h3>${escapeHtml(group.label)}</h3>
|
|
|
|
<span>${escapeHtml(group.description)}</span>
|
|
|
|
</div>
|
|
|
|
<strong>${items.length}개</strong>
|
|
|
|
</div>
|
|
|
|
<div class="evidence-card-grid">
|
|
|
|
${topItems.map(renderEvidenceRow).join("")}
|
|
|
|
</div>
|
|
|
|
${
|
|
|
|
detailItems.length
|
|
|
|
? `
|
|
|
|
<details class="evidence-details">
|
|
|
|
<summary>자세히 보기 · ${detailItems.length}개</summary>
|
|
|
|
<div class="evidence-card-grid evidence-card-grid-detail">
|
|
|
|
${detailItems.map(renderEvidenceRow).join("")}
|
|
|
|
</div>
|
|
|
|
</details>
|
|
|
|
`
|
|
|
|
: ""
|
|
|
|
}
|
|
|
|
</section>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderEvidenceRow(evidence) {
|
|
const isNewFromRerun = activeRerunAddedIds.has(evidence.id);
|
|
const newChip = isNewFromRerun ? `<span class="evidence-new-chip">신규</span>` : "";
|
|
const sourceEvidenceIds = evidence.sourceEvidenceIds || [];
|
|
const sourceIds = sourceEvidenceIds.length
|
|
? `근거 ID: ${sourceEvidenceIds.join(", ")}`
|
|
: "근거 ID 없음";
|
|
const contribution = evidence.contributed ? "점수 반영" : "참고";
|
|
const normalizedOperatorStatus = normalizeEvidenceOperatorStatus(evidence.operatorStatus);
|
|
const operatorStatus = normalizedOperatorStatus
|
|
? `<span class="evidence-status-chip">${escapeHtml(formatEvidenceStatus(normalizedOperatorStatus))}</span>`
|
|
: "";
|
|
const confidence = typeof evidence.confidence === "number" ? evidence.confidence.toFixed(2) : evidence.confidence;
|
|
const matchType = evidence.matchType ? `<span>${escapeHtml(formatMatchType(evidence.matchType))}</span>` : "";
|
|
const pageTitle = evidence.pageTitle ? `<span>${escapeHtml(evidence.pageTitle)}</span>` : "";
|
|
const faceCropMeta = evidence.faceCropSearch
|
|
? `<span>얼굴 영역 ${escapeHtml(evidence.cropIndex || "")}</span><span>${escapeHtml(evidence.privacyNote || "동일 인물 판정은 아닙니다")}</span>`
|
|
: "";
|
|
const watchlistMeta =
|
|
evidence.knowledgeEntryStatus === "watchlist"
|
|
? `<span>주의 후보: ${escapeHtml(evidence.knowledgeEntryName || evidence.knowledgeEntryId || "-")}</span><span>원 케이스 ${escapeHtml(evidence.sourceSubmissionId || "-")}</span>`
|
|
: "";
|
|
const providerScore =
|
|
evidence.providerScore !== undefined && evidence.providerScore !== null && evidence.providerScore !== "" ? `<span>검색 도구 점수 ${escapeHtml(evidence.providerScore)}</span>` : "";
|
|
const similarity =
|
|
evidence.similarity !== undefined && evidence.similarity !== ""
|
|
? `<span>이미지 유사도 ${escapeHtml(Number(evidence.similarity).toFixed(2))}</span>`
|
|
: "";
|
|
const queryStrategy = evidence.queryStrategy ? `<span>${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}</span>` : "";
|
|
const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
|
|
const searchType = evidence.searchType ? `<span>${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}</span>` : "";
|
|
// 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 ? `<span class="host-chip" title="게시 호스트">${escapeHtml(hostName)}</span>` : "";
|
|
const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
|
|
const preview = renderEvidencePreview(evidence);
|
|
const link = renderEvidenceLink(evidence);
|
|
const statusButtons = [
|
|
["used_for_judgment", "사용"],
|
|
["ignored", "미사용"],
|
|
]
|
|
.map(
|
|
([status, label]) => `
|
|
<button class="row-action ${normalizedOperatorStatus === status ? "active" : ""}" type="button" data-evidence-status="${status}" data-evidence-id="${escapeHtml(evidence.id)}">
|
|
${label}
|
|
</button>
|
|
`,
|
|
)
|
|
.join("");
|
|
|
|
return `
|
|
<article class="evidence-row ${hasPreview ? "" : "no-preview"} ${isNewFromRerun ? "evidence-row-new" : ""}" data-evidence-id="${escapeHtml(evidence.id)}">
|
|
${preview}
|
|
<div class="evidence-main">
|
|
<div class="evidence-title">
|
|
<span class="source-chip ${sourceClass(evidence.source)}">${escapeHtml(sourceLabels[evidence.source] || evidence.source)}</span>
|
|
${newChip}
|
|
${host}
|
|
<span>${escapeHtml(formatEvidenceTitle(evidence))}</span>
|
|
</div>
|
|
<div class="evidence-meta">
|
|
<span>신뢰도 ${escapeHtml(confidence)}</span>
|
|
<span>${escapeHtml(contribution)}</span>
|
|
${operatorStatus}
|
|
${matchType}
|
|
${faceCropMeta}
|
|
${watchlistMeta}
|
|
${providerScore}
|
|
${similarity}
|
|
${queryStrategy}
|
|
${searchType}
|
|
${pageTitle}
|
|
<span>${escapeHtml(evidence.query || "쿼리 없음")}</span>
|
|
<span>${escapeHtml(formatDomain(evidence.domain))}</span>
|
|
<span>${escapeHtml(evidence.retrievedAt)}</span>
|
|
<span>${escapeHtml(sourceIds)}</span>
|
|
</div>
|
|
${link}
|
|
</div>
|
|
<div class="evidence-actions">
|
|
${statusButtons}
|
|
</div>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
|
|
function renderEvidencePreview(evidence) {
|
|
|
|
const previewUrl = evidence.thumbnailUrl || evidence.imageUrl;
|
|
|
|
const targetUrl = evidence.imageUrl || evidence.url || previewUrl;
|
|
|
|
if (!previewUrl) return `<div class="evidence-preview evidence-preview-empty" aria-hidden="true"></div>`;
|
|
|
|
return `
|
|
|
|
<a class="evidence-preview" href="${escapeHtml(safeUrl(targetUrl))}" target="_blank" rel="noreferrer" aria-label="검색 이미지 열기">
|
|
|
|
<img src="${escapeHtml(safeUrl(previewUrl))}" alt="" onerror="this.style.display='none'">
|
|
|
|
</a>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderEvidenceLink(evidence) {
|
|
|
|
const url = evidence.url || evidence.imageUrl || "";
|
|
|
|
if (!url) return "";
|
|
|
|
const label = evidence.pageTitle || evidence.url || evidence.imageUrl;
|
|
|
|
return `
|
|
|
|
<a class="evidence-link" href="${escapeHtml(safeUrl(url))}" target="_blank" rel="noreferrer">
|
|
|
|
${escapeHtml(label)}
|
|
|
|
</a>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
|
|
|
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 = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
|
|
results.innerHTML = `<div class="empty-state">API 연결 후 검색 근거를 확인할 수 있습니다.</div>`;
|
|
return;
|
|
}
|
|
|
|
document.getElementById("query-case-label").textContent = submission.id;
|
|
queryHistory.innerHTML = submission.queryHistory.length
|
|
? submission.queryHistory
|
|
.map(
|
|
(query) => `
|
|
<article class="query-row">
|
|
<div class="row-title">
|
|
<span class="source-chip ${sourceClass(query.provider)}">${escapeHtml(providerLabels[query.provider] || query.provider)}</span>
|
|
<span>${escapeHtml(query.query)}</span>
|
|
</div>
|
|
<div class="row-meta">
|
|
<span>${escapeHtml(formatQueryStatus(query.status))}</span>
|
|
${query.strategy ? `<span>${escapeHtml(formatQueryStrategy(query.strategy))}</span>` : ""}
|
|
${query.source ? `<span>출처 ${escapeHtml(query.source)}</span>` : ""}
|
|
<span>${escapeHtml(query.timestamp)}</span>
|
|
<span>${query.count}건</span>
|
|
</div>
|
|
<button class="row-action" type="button" data-rerun-query="${escapeHtml(query.query)}" data-rerun-provider="${escapeHtml(query.provider || "")}">쿼리 재실행</button>
|
|
</article>
|
|
`,
|
|
)
|
|
.join("")
|
|
: `<div class="empty-state">아직 실행한 검색 쿼리가 없습니다.</div>`;
|
|
|
|
activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
|
|
const searchableEvidence = searchableEvidenceItems(submission);
|
|
results.innerHTML = searchableEvidence.length
|
|
? renderEvidenceGroups({ ...submission, evidence: searchableEvidence })
|
|
: `<div class="empty-state">현재 케이스에는 검색 결과가 없습니다.</div>`;
|
|
}
|
|
|