POSA_Copyrighter/web/operator-gui/app-format.js
changukyu e38dad83c9 fix: 증거 행 레이아웃·호스트 노출·보존 케이스 설명, 프롬프트 스탬프 갱신
1) 긴 호스트가 제목을 세로 한 줄짜리 컬럼으로 만들었다

   .evidence-title 은 flex-wrap 없는 flex 행이고 source-chip·host-chip 둘 다
   축소를 거부한다(flex: 0 0 auto). 그래서 제목에 남는 폭이 수십 px 이 되고
   overflow-wrap: anywhere 가 그걸 한 단어씩 세로로 쪼갰다. 컨테이너에
   flex-wrap, 제목에 min-width: 14ch 를 줘서 제목이 자기 줄로 내려간다.

   실렌더 측정 (같은 DOM 에 수정 전 CSS 런타임 주입, Playwright):
     360px  제목 51x95px (5줄) -> 246x19px (1줄), 행 높이 95 -> 50
     1280px 제목 9x380px (~20줄) -> 205x19px, 행 높이 380 -> 50

   주석은 데스크톱에서는 해결됐다고 적혀 있었지만, 45자 CDN 호스트로는
   1280px 에서도 재현된다 — 폰 전용 문제가 아니었다.

2) 20ch 로 잘린 호스트를 읽을 방법이 없었다

   칩 title 이 "게시 호스트" 고정 문구여서, 20자 접두어가 같은 두 CDN 호스트를
   링크를 열지 않고 구분할 수 없었다 — 모노스페이스를 고른 이유가 그 판별이다.
   툴팁에 실제 호스트를 싣는다.

3) 클라이언트 호스트 폴백이 서버보다 짧았다

   _result_host 는 (url, result_url, page_url, source_page_url, blogger_link,
   image_url) 를 순회하는데 evidenceHost 는 3개만 봤다. host 키가 생기기 전에
   저장된 네이버 블로그 행이 bloggerLink 만 가진 경우 칩이 조용히 사라졌다 —
   "레거시 증거가 자기 출처에 눈멀지 않게" 라는 폴백의 목적과 반대다.

4) 보존된 케이스가 설명 없는 깨진 이미지였다

   assetMissing 을 읽는 소비자가 GUI·API·내보내기 어디에도 없었다. 판정은 DB 에
   남지만 콘솔에서는 "판정 후 파일 아카이브" 와 "업로드 손상" 이 구분되지
   않았다. 큐 썸네일과 케이스 이미지에 표시 + 파생물 노트에 사유를 붙인다.

5) PROMPT_VERSION 갱신 (ko-v3-structured -> ko-v4-structured-host)

   847f7a8 이 PROMPT_DATA_FIELDS 의 domain -> host 로 모든 증거 줄의 내용을
   바꿨는데 스탬프는 그대로여서, 한 버전이 서로 다른 두 프롬프트를 가리켰다.
   화이트리스트가 다른 모듈에 있어도 프롬프트의 일부라는 점을 llm_prompts
   독스트링의 규칙에 명시했다. 렌더된 줄이 host= 를 담는지 보는 행위 테스트도
   추가했다(튜플 멤버십 검사로는 안 잡힌다).

   CLAUDE.md 요구대로 실모델 프로브(qwen2.5:7b, 로컬 ollama): grounded 3 /
   dropped 0 / verified True, 형식 예시 반향 없음, 인용 라벨 정확(E1=full match).
   다만 모델은 host 를 문장에 쓰지 않고 page_title 로 서술했다 — 호스트는
   운영자 칩에는 닿지만 서술에는 아직 반영되지 않는다(SYSTEM_PROMPT 과제).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-18 23:54:00 +09:00

618 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("&", "&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);
}
// 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 || "-";
}