// 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 `
`;
}
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 === "Google full image match found") return "구글 동일 이미지 발견";
if (text === "Google partial image match found") return "구글 부분 일치 이미지 발견";
if (text === "Google page with matching image found") return "구글 게시 페이지 발견";
if (text === "Google visually similar image found") return "구글 유사 이미지 발견";
if (text.startsWith("Search result image similarity ")) {
return `검색 결과 사본 유사도 ${text.replace("Search result image similarity ", "")}`;
}
if (text.startsWith("Knowledge base image similarity ")) {
return `기준 DB 이미지 유사도 ${text.replace("Knowledge base image similarity ", "")}`;
}
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 ``;
})
.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;
// Same candidates, in the same order, as _result_host in
// store_serialization.py. A shorter list here means a legacy Naver blog row
// whose only host-bearing field is bloggerLink shows no chip where the server
// would have resolved one - blind to its own source, which is the failure the
// fallback exists to prevent.
for (const candidate of [
evidence.url,
evidence.sourcePageUrl,
evidence.bloggerLink,
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 || "-";
}