POSA_Copyrighter/web/operator-gui/app-format.js
changukyu fc4e04f65c refactor: app.js 5,428줄을 관심사별 app-*.js 15개로 분할
단일 파일이 프로젝트 상한(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건.
2026-08-08 09:48:52 +09:00

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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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 || "-";
}