③ 파일명 stem 충돌: 다른 폴더의 같은 이름 파일이 한 건으로 합쳐져 둘째가 조용히 미심사였다. id 는 재유도하지 않고(기존 판정 보존) id_collisions() 로 탐지해 bootstrap.intakeConflicts 와 파일당 1회 감사로 남기고, 콘솔 투입 상태줄에 경고 목록을 띄운다. ④ 행 버튼 '미사용' 이 false_positive 로 가 대표 근거 하나를 고르면 나머지 점수가 지워졌다 → '사용'/'무관', 사용 재클릭은 pending(표시 해제, 점수 유지). ⑤ 분모 0 비율은 None → '측정 불가'. 외부 검색이 꺼진 케이스는 점수 0 유지, 한 줄 요약에 '외부 검색 꺼짐'. 부채: operator-review-workflow 13px 넘침 해소(겹치지 않는 레인 병합) → 다이어그램 5종 첫 REBUILD OK, v6 이전 카드 문구 교정. tests/conftest.py 로 로컬 Ollama 격리(가동 중에도 통과). 루트 VERSION 0.1.0 단일 출처, /health 와 기동 로그에 버전·스코어러 버전. verify.ps1 (Ollama 가동 중, override 없음): ruff clean, 680 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
622 lines
16 KiB
JavaScript
622 lines
16 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 === "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 `<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";
|
|
|
|
// 옛 "ignored"(→ false_positive) 행도 화면에서는 "무관"으로 묶는다 — 둘 다 점수에서 빠진다.
|
|
if (status === "irrelevant" || status === "false_positive" || status === "ignored") return "irrelevant";
|
|
|
|
// pending = 표시 해제. 칩을 달지 않는다.
|
|
return "";
|
|
|
|
}
|
|
|
|
|
|
|
|
function evidenceStatusPayload(status) {
|
|
|
|
return status === "ignored" ? "irrelevant" : 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 || "-";
|
|
}
|
|
|