단일 파일이 프로젝트 상한(800줄)의 6.8배였다. 분할은 순수 라인범위 추출이며 어떤 구문도 수정·재배치·재포맷하지 않았다 — 15개 파트의 연결 결과가 원본 app.js 와 바이트 단위로 동일함을 확인하고 나눴다(125,689B). 클래식 스크립트(모듈 아님)라 하나의 전역 스코프를 공유하므로 로드 순서가 곧 계약이다. 원본에서 로드 시점에 실행되는 최상위 구문은 파일 끝의 리스너 등록 2개뿐이었고(DOMContentLoaded/hashchange), 나머지 참조는 전부 함수 본문 안이라 순서만 지키면 의미가 보존된다. 그 순서를 index.html 에 명시하고 테스트로 고정: - test_index_loads_every_app_part_exactly_once_in_declared_order 각 파트가 정확히 1회, 선언 순서대로 로드되는지 + 디스크의 app-*.js 가 전부 배선됐는지 + 구 app.js 가 남아있지 않은지 - test_app_parts_stay_within_the_file_size_budget (800줄 상한) 정적 스펙 31곳은 app.js 본문을 문자열로 검사하고 있었다. 이 스펙들의 관심사는 '어느 파일에 있는가'가 아니라 '콘솔에 그 동작이 있는가'이므로, 파트를 로드 순서대로 이어붙여 읽는 _read_app() 으로 바꿨다. 검증: Playwright E2E 6건이 실브라우저에서 통과(콘솔 에러 0 단언 포함). 운영 콘솔 실렌더 확인 — 큐/케이스 심사 정상, 스크립트 15개 전부 200, 새로고침 후 콘솔 메시지 0건.
591 lines
14 KiB
JavaScript
591 lines
14 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);
|
|
}
|
|
|
|
|
|
function formatQueueTimestamp(value) {
|
|
return value || "-";
|
|
}
|
|
|