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), 린트 클린.
608 lines
15 KiB
JavaScript
608 lines
15 KiB
JavaScript
// Part 3 of 15 of the operator console app script.
|
|
//
|
|
// Coverage math, HTML/URL escaping, and every label-formatting helper.
|
|
//
|
|
// 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 coveragePercent(numerator, denominator) {
|
|
|
|
if (!denominator) return 0;
|
|
|
|
return Math.round((numerator / denominator) * 1000) / 10;
|
|
|
|
}
|
|
|
|
|
|
|
|
function normalizeCoverageThresholds(next = {}) {
|
|
|
|
const raw = next || {};
|
|
|
|
const normalized = {};
|
|
|
|
const intValue = (value) => {
|
|
|
|
const parsed = Number(value);
|
|
|
|
if (!Number.isFinite(parsed)) return null;
|
|
|
|
return Math.max(0, Math.min(100, Math.round(parsed)));
|
|
|
|
};
|
|
|
|
|
|
|
|
normalized.coverageGoodRate = intValue(raw.coverageGoodRate);
|
|
|
|
if (!Number.isFinite(normalized.coverageGoodRate)) {
|
|
|
|
normalized.coverageGoodRate = DEFAULT_COVERAGE_THRESHOLDS.coverageGoodRate;
|
|
|
|
}
|
|
|
|
|
|
|
|
normalized.coverageWarnRate = intValue(raw.coverageWarnRate);
|
|
|
|
if (!Number.isFinite(normalized.coverageWarnRate)) {
|
|
|
|
normalized.coverageWarnRate = DEFAULT_COVERAGE_THRESHOLDS.coverageWarnRate;
|
|
|
|
}
|
|
|
|
normalized.coverageWarnRate = Math.min(normalized.coverageWarnRate, normalized.coverageGoodRate);
|
|
|
|
|
|
|
|
normalized.queryGoodRate = intValue(raw.queryGoodRate);
|
|
|
|
if (!Number.isFinite(normalized.queryGoodRate)) {
|
|
|
|
normalized.queryGoodRate = DEFAULT_COVERAGE_THRESHOLDS.queryGoodRate;
|
|
|
|
}
|
|
|
|
|
|
|
|
normalized.queryWarnRate = intValue(raw.queryWarnRate);
|
|
|
|
if (!Number.isFinite(normalized.queryWarnRate)) {
|
|
|
|
normalized.queryWarnRate = DEFAULT_COVERAGE_THRESHOLDS.queryWarnRate;
|
|
|
|
}
|
|
|
|
normalized.queryWarnRate = Math.min(normalized.queryWarnRate, normalized.queryGoodRate);
|
|
|
|
|
|
|
|
return normalized;
|
|
|
|
}
|
|
|
|
|
|
|
|
function coverageTone(rate, kind = "coverage") {
|
|
|
|
const config = coverageThresholds;
|
|
|
|
const goodRate = config[`${kind}GoodRate`];
|
|
|
|
const warnRate = config[`${kind}WarnRate`];
|
|
|
|
if (!Number.isFinite(goodRate) || !Number.isFinite(warnRate)) return "bad";
|
|
|
|
if (rate >= goodRate) return "ok";
|
|
|
|
if (rate >= warnRate) return "warn";
|
|
|
|
return "bad";
|
|
|
|
}
|
|
|
|
|
|
|
|
function coverageToneLabelConfig(kind = "coverage") {
|
|
|
|
const config = coverageThresholds;
|
|
|
|
const goodRate = Number.isFinite(config[`${kind}GoodRate`])
|
|
|
|
? config[`${kind}GoodRate`]
|
|
|
|
: DEFAULT_COVERAGE_THRESHOLDS[`${kind}GoodRate`];
|
|
|
|
const warnRate = Number.isFinite(config[`${kind}WarnRate`])
|
|
|
|
? config[`${kind}WarnRate`]
|
|
|
|
: DEFAULT_COVERAGE_THRESHOLDS[`${kind}WarnRate`];
|
|
|
|
return `양호: ${goodRate}% 이상, 주의: ${warnRate}%~${Math.max(0, goodRate - 1)}%, 위험: ${Math.max(0, warnRate - 1)}% 미만`;
|
|
|
|
}
|
|
|
|
|
|
|
|
function clampPercent(value) {
|
|
|
|
if (!Number.isFinite(value)) return 0;
|
|
|
|
return Math.max(0, Math.min(100, value));
|
|
|
|
}
|
|
|
|
|
|
|
|
function coverageTabButton({ filter, label, value, tone = "ok", title = "" }) {
|
|
|
|
const active = state.filters.source === filter || (filter === "all" && state.filters.source === "all");
|
|
|
|
return `
|
|
|
|
<button
|
|
|
|
class="coverage-tab ${tone} ${active ? "active" : ""}"
|
|
|
|
type="button"
|
|
|
|
data-coverage-filter="${escapeHtml(filter)}"
|
|
|
|
title="${escapeHtml(title || label)}"
|
|
|
|
aria-pressed="${active ? "true" : "false"}"
|
|
|
|
>
|
|
|
|
<span>${escapeHtml(label)}</span>
|
|
|
|
<strong>${escapeHtml(value)}</strong>
|
|
|
|
</button>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderCoverageTabs() {
|
|
|
|
const target = document.getElementById("coverage-tabs");
|
|
|
|
if (!target) return;
|
|
|
|
|
|
|
|
const summaries = searchCoverage.submissions || {};
|
|
|
|
const queries = searchCoverage.queries || {};
|
|
|
|
const providers = (searchCoverage.providers || []).filter((provider) => !retiredProviderIds.has(provider.id));
|
|
|
|
const total = Number(summaries.total || submissions.length || 0);
|
|
|
|
const coverageRate = coveragePercent(summaries.coverageSubmissions || 0, total);
|
|
|
|
const coverageStatus = coverageTone(coverageRate, "coverage");
|
|
|
|
const failedQueries = Number(queries.failed || 0);
|
|
|
|
const providerTabs = [...providers]
|
|
|
|
.sort((left, right) => (right.querySubmissions || 0) - (left.querySubmissions || 0))
|
|
|
|
.map((provider) => {
|
|
|
|
const filter = provider.id && provider.id.includes("google") ? "google" : provider.id || "all";
|
|
|
|
const queryCount = Number(provider.queryEntries || 0);
|
|
|
|
const evidenceCount = Number(provider.evidenceSubmissions || 0);
|
|
|
|
return coverageTabButton({
|
|
|
|
filter,
|
|
|
|
label: provider.name || provider.id || "provider",
|
|
|
|
value: `${queryCount} / ${evidenceCount}`,
|
|
|
|
tone: evidenceCount ? "ok" : queryCount ? "warn" : "idle",
|
|
|
|
title: `쿼리 ${queryCount}, 근거 보유 케이스 ${evidenceCount}`,
|
|
|
|
});
|
|
|
|
})
|
|
|
|
.join("");
|
|
|
|
|
|
|
|
target.innerHTML = `
|
|
|
|
${coverageTabButton({ filter: "all", label: "전체", value: `${total}` })}
|
|
|
|
${coverageTabButton({
|
|
|
|
filter: "covered",
|
|
|
|
label: "근거 보유",
|
|
|
|
value: `${summaries.coverageSubmissions || 0}/${total}`,
|
|
|
|
tone: coverageStatus,
|
|
|
|
title: coverageToneLabelConfig("coverage"),
|
|
|
|
})}
|
|
|
|
${providerTabs || coverageTabButton({ filter: "all", label: "외부 검색 tool", value: "0" })}
|
|
|
|
${coverageTabButton({ filter: "failure", label: "실패", value: `${failedQueries}`, tone: failedQueries ? "bad" : "idle" })}
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
|
|
|
function applyCoverageFilter(filter) {
|
|
|
|
state.filters.source = filter || "all";
|
|
|
|
const sourceSelect = document.getElementById("source-filter");
|
|
|
|
if (sourceSelect) {
|
|
sourceSelect.value = [...sourceSelect.options].some((option) => option.value === state.filters.source) ? state.filters.source : "all";
|
|
}
|
|
|
|
renderCoverageTabs();
|
|
|
|
renderQueue();
|
|
|
|
}
|
|
|
|
|
|
|
|
function showApiError(message) {
|
|
|
|
state.apiError = message;
|
|
|
|
const target = document.getElementById("queue-health");
|
|
|
|
if (target) target.textContent = message;
|
|
|
|
}
|
|
|
|
|
|
|
|
function escapeHtml(value) {
|
|
|
|
return String(value)
|
|
|
|
.replaceAll("&", "&")
|
|
|
|
.replaceAll("<", "<")
|
|
|
|
.replaceAll(">", ">")
|
|
|
|
.replaceAll('"', """)
|
|
|
|
.replaceAll("'", "'");
|
|
|
|
}
|
|
|
|
|
|
|
|
function safeUrl(value) {
|
|
|
|
// Defense-in-depth for URLs derived from external search results: only allow
|
|
|
|
// absolute http(s) or same-origin paths into href/src (blocks javascript:,
|
|
|
|
// data:, etc.). The server also normalizes these server-side.
|
|
|
|
const url = String(value || "").trim();
|
|
|
|
// Same-origin path, but reject protocol-relative ("//host") and backslash
|
|
// ("/\\host") forms that browsers normalize to an external host.
|
|
const isSameOriginPath = url.startsWith("/") && !url.startsWith("//") && !url.startsWith("/\\");
|
|
|
|
if (/^https?:\/\//i.test(url) || isSameOriginPath) {
|
|
|
|
return url;
|
|
|
|
}
|
|
|
|
return "#";
|
|
|
|
}
|
|
|
|
|
|
|
|
function formatReason(reason) {
|
|
const text = String(reason || "");
|
|
if (!text) return "";
|
|
|
|
if (text === "Naver search result found") return "네이버 검색 결과 발견";
|
|
if (text === "Naver search returned no results") return "네이버 검색 결과 없음";
|
|
if (text === "Naver blog search result found") return "네이버 블로그 검색 결과 발견";
|
|
if (text === "Naver blog search returned no results") return "네이버 블로그 검색 결과 없음";
|
|
if (text === "Naver web search result found") return "네이버 웹문서 검색 결과 발견";
|
|
if (text === "Naver web search returned no results") return "네이버 웹문서 검색 결과 없음";
|
|
if (text === "Google custom image search result found") return "구글 이미지 검색 결과 발견";
|
|
if (text === "Google custom image search returned no results") return "구글 이미지 검색 결과 없음";
|
|
if (text === "Google custom web search result found") return "구글 웹 검색 결과 발견";
|
|
if (text === "Google custom web search returned no results") return "구글 웹 검색 결과 없음";
|
|
if (text.startsWith("Google face crop web evidence: ")) {
|
|
return `얼굴 영역 웹 근거: ${formatReason(text.replace("Google face crop web evidence: ", ""))}`;
|
|
}
|
|
if (text.startsWith("Web entity matched ")) return `웹 엔티티 일치: ${text.replace("Web entity matched ", "")}`;
|
|
if (text.startsWith("Google weak label ")) return `구글 약한 라벨 ${text.replace("Google weak label ", "")}`;
|
|
if (text.startsWith("Best guess label ")) return `구글 추정 라벨 ${text.replace("Best guess label ", "")}`;
|
|
if (text === "LLM summary has no source evidence") return "내부 요약에 연결 근거 없음";
|
|
if (text === "LLM summary generated") return "내부 요약 생성";
|
|
if (text.startsWith("LLM assistance failed")) return text.replace("LLM assistance failed", "내부 요약 실패");
|
|
if (text.includes("Google Web Detection timeout")) return "구글 이미지 감지 시간 초과";
|
|
if (text === "local submission") return "로컬 제출 이미지";
|
|
if (text.startsWith("naver.example / rank ")) return text.replace("naver.example / rank ", "네이버 예시 / 순위 ");
|
|
if (text.startsWith("google.example / page")) return "구글 예시 / 페이지";
|
|
if (text.startsWith("prior rejection / ")) return text.replace("prior rejection / ", "이전 반려 / ");
|
|
|
|
return text
|
|
.replaceAll("Google Web Detection", "구글 이미지 감지")
|
|
.replaceAll("Google", "구글")
|
|
.replaceAll("Naver", "네이버")
|
|
.replaceAll("google", "구글")
|
|
.replaceAll("naver", "네이버")
|
|
.replaceAll("internal", "내부")
|
|
.replaceAll("local", "로컬")
|
|
.replaceAll("submission", "제출")
|
|
.replaceAll("page", "페이지")
|
|
.replaceAll("weak", "약함")
|
|
.replaceAll("LLM", "내부 요약")
|
|
.replaceAll("confidence", "신뢰도")
|
|
.replaceAll("sourceEvidenceIds", "근거 ID")
|
|
.replaceAll("rank", "순위");
|
|
}
|
|
|
|
function formatEvidenceTitle(evidence) {
|
|
|
|
return formatReason(evidence.title || evidence.reason || "");
|
|
|
|
}
|
|
|
|
|
|
|
|
function formatProviderStatus(status) {
|
|
|
|
return providerStatusLabels[status] || status;
|
|
|
|
}
|
|
|
|
|
|
function formatProviderName(provider) {
|
|
return providerLabels[provider.id] || provider.name || provider.id;
|
|
}
|
|
|
|
|
|
|
|
function formatQueueProviderStatus(provider, status) {
|
|
const providerNames = {
|
|
internal: "내부",
|
|
naver: "네이버",
|
|
naver_blog: "블로그",
|
|
naver_web: "웹문서",
|
|
google: "구글",
|
|
google_search: "구글",
|
|
llm: "요약",
|
|
};
|
|
|
|
const statusNames = {
|
|
ok: "정상",
|
|
covered: "근거 있음",
|
|
empty: "없음",
|
|
failed: "실패",
|
|
disabled: "중지",
|
|
skipped: "미실행",
|
|
pending: "미실행",
|
|
not_run: "미실행",
|
|
};
|
|
|
|
return `${providerNames[provider] || providerLabels[provider] || provider} ${statusNames[status] || formatProviderStatus(status)}`;
|
|
}
|
|
|
|
function visibleProviderStateEntries(providerState) {
|
|
|
|
return Object.entries(providerState || {}).filter(([provider]) => !retiredProviderIds.has(provider));
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderOperatorSearchProviderOptions() {
|
|
const optionHtml = operatorSearchProviders
|
|
.map((provider) => {
|
|
const runtime = providers.find((item) => item.id === provider.id);
|
|
const unavailable = Boolean(runtime) && !runtime.enabled;
|
|
const label = unavailable ? `${provider.label} (비활성)` : provider.label;
|
|
const reason = unavailable ? String(runtime.lastFailure || runtime.compliance || "외부 검색 tool 미연결") : "";
|
|
return `<option value="${escapeHtml(provider.id)}" ${unavailable ? "disabled" : ""} title="${escapeHtml(reason)}">${escapeHtml(label)}</option>`;
|
|
})
|
|
.join("");
|
|
|
|
["manual-query-provider", "collection-provider"].forEach((elementId) => {
|
|
const select = document.getElementById(elementId);
|
|
if (!select) return;
|
|
const current = select.value;
|
|
select.innerHTML = optionHtml;
|
|
select.value = operatorSearchProviders.some((provider) => provider.id === current) ? current : operatorSearchProviders[0].id;
|
|
if (select.selectedOptions[0] && select.selectedOptions[0].disabled) {
|
|
select.value = operatorSearchProviders[0].id;
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
|
|
function formatCandidateStatus(status) {
|
|
|
|
return candidateStatusLabels[status] || status;
|
|
|
|
}
|
|
|
|
|
|
|
|
function formatCandidateSourceType(sourceType) {
|
|
|
|
const labels = {
|
|
|
|
search_result_image: "검색 이미지 결과",
|
|
|
|
provider_page_image: "페이지 대표 이미지",
|
|
|
|
html_page_image: "페이지 대표 이미지",
|
|
|
|
};
|
|
|
|
return labels[sourceType] || sourceType || "검색 이미지 결과";
|
|
|
|
}
|
|
|
|
|
|
|
|
function formatQueryStatus(status) {
|
|
return window.OperatorSearch.formatQueryStatus(status);
|
|
}
|
|
|
|
|
|
function formatQueryStrategy(strategy) {
|
|
return window.OperatorSearch.formatQueryStrategy(strategy);
|
|
}
|
|
|
|
|
|
function formatEvidenceStatus(status) {
|
|
|
|
return evidenceStatusLabels[normalizeEvidenceOperatorStatus(status)] || status || "자동 판단";
|
|
|
|
}
|
|
|
|
|
|
|
|
function normalizeEvidenceOperatorStatus(status) {
|
|
|
|
if (status === "used_for_judgment") return "used_for_judgment";
|
|
|
|
const ignoredStatuses = new Set();
|
|
|
|
ignoredStatuses.add("irrelevant");
|
|
|
|
ignoredStatuses.add("false_positive");
|
|
|
|
ignoredStatuses.add("ignored");
|
|
|
|
if (ignoredStatuses.has(status)) return "ignored";
|
|
|
|
return status || "";
|
|
|
|
}
|
|
|
|
|
|
|
|
function evidenceStatusPayload(status) {
|
|
|
|
return status === "ignored" ? "false_positive" : status;
|
|
|
|
}
|
|
|
|
|
|
|
|
function formatKnowledgeStatus(status) {
|
|
|
|
return knowledgeStatusLabels[status || "confirmed"] || status;
|
|
|
|
}
|
|
|
|
|
|
|
|
function formatKnowledgeType(type) {
|
|
|
|
return knowledgeTypeLabels[type] || type;
|
|
|
|
}
|
|
|
|
|
|
|
|
function formatProvenance(provenance) {
|
|
|
|
return provenanceLabels[provenance] || provenance;
|
|
|
|
}
|
|
|
|
|
|
|
|
function formatMatchType(matchType) {
|
|
const labels = {
|
|
full: "동일 이미지",
|
|
partial: "부분 일치",
|
|
page: "포함 페이지",
|
|
visual: "유사 이미지",
|
|
weak_label: "약한 라벨",
|
|
entity: "엔티티",
|
|
google_best_guess: "구글 추정 라벨",
|
|
google_face_crop_page: "얼굴 영역 웹페이지",
|
|
google_face_crop_entity: "얼굴 영역 엔티티",
|
|
empty: "결과 없음",
|
|
};
|
|
|
|
labels.search_result_image = "검색 결과 이미지 지목";
|
|
labels.search_result_page_image = "검색 결과 페이지 이미지 지목";
|
|
return labels[matchType] || matchType;
|
|
}
|
|
|
|
|
|
|
|
function formatFactLabel(label) {
|
|
const labels = {
|
|
size: "크기",
|
|
format: "형식",
|
|
submitted: "제출",
|
|
analysis: "분석",
|
|
};
|
|
|
|
return labels[label] || formatReason(label);
|
|
}
|
|
|
|
|
|
|
|
function formatDomain(domain) {
|
|
if (!domain) return "내부";
|
|
return formatReason(domain);
|
|
}
|
|
|
|
|
|
// Evidence stored before the server persisted `host` still carries its result
|
|
// URL, so derive the hostname here rather than migrating 292+ rows or paying a
|
|
// parse per row on every bootstrap. Only rendered rows reach this.
|
|
function evidenceHost(evidence) {
|
|
if (evidence.host) return evidence.host;
|
|
for (const candidate of [evidence.url, evidence.sourcePageUrl, evidence.imageUrl]) {
|
|
if (!candidate) continue;
|
|
try {
|
|
return new URL(candidate).hostname.toLowerCase().replace(/^www\./, "");
|
|
} catch (error) {
|
|
// Not a parseable absolute URL — try the next one.
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
|
|
function formatQueueTimestamp(value) {
|
|
return value || "-";
|
|
}
|
|
|