const {
riskLabels,
decisionLabels,
providerLabels,
sourceLabels,
providerStatusLabels,
candidateStatusLabels,
evidenceStatusLabels,
knowledgeStatusLabels,
provenanceLabels,
knowledgeTypeLabels,
} = window.OperatorLabels;
const retiredProviderIds = new Set();
const operatorSearchProviders = [
{ id: "naver", label: providerLabels.naver },
{ id: "google_search", label: providerLabels.google_search },
];
const submissions = [];
const providers = [];
const knowledgeEntries = [];
const searchCoverage = {};
let suggestedQueryRunSummary = null; // { caseId, message } — 현재 케이스의 마지막 추천 쿼리 실행 결과
const knowledgeFilters = { query: "", type: "all", status: "all", active: "all" };
let editingKnowledgeEntryId = "";
let activeRerunAddedIds = new Set(); // 현재 렌더 중인 케이스의 lastRerunDiff.addedEvidenceIds
const DEFAULT_COVERAGE_THRESHOLDS = Object.freeze({
coverageGoodRate: 70,
coverageWarnRate: 40,
queryGoodRate: 70,
queryWarnRate: 40,
});
const coverageThresholds = { ...DEFAULT_COVERAGE_THRESHOLDS };
const collectionCandidates = [];
const corrections = [];
const auditEvents = [];
const decisionMemoPresets = [];
const state = {
currentView: "queue",
workbenchTab: "evidence",
knowledgeTab: "collect",
currentCollectionQuery: "",
currentCollectionProvider: "",
selectedCaseId: submissions[0]?.id || "",
apiError: "",
submissionQueue: null,
importStatus: {
tone: "idle",
message: "로컬 제출 저장소 변경 대기 중",
},
filters: {
risk: "all",
source: "all",
decision: "all",
sort: "risk",
query: "",
},
};
const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "stats", "audit"]);
// Bearer token for a server started with COPYRIGHTER_AUTH_TOKEN. Held in memory
// only (never localStorage/sessionStorage): the first authenticated request makes
// the server mint an HttpOnly session cookie, which is what subsequently
// authorizes
/media requests that cannot carry a header.
let authToken = "";
let pendingAuthPrompt = null;
function promptForAuthToken(message = "") {
const gate = document.getElementById("auth-gate");
const form = document.getElementById("auth-gate-form");
const input = document.getElementById("auth-gate-input");
const error = document.getElementById("auth-gate-error");
if (!gate || !form || !input) {
return Promise.resolve("");
}
// Collapse concurrent 401s onto one prompt so a burst of parallel requests
// cannot stack multiple dialogs.
if (pendingAuthPrompt) {
return pendingAuthPrompt;
}
if (error) {
error.hidden = !message;
error.textContent = message || "";
}
gate.hidden = false;
input.value = "";
input.focus();
pendingAuthPrompt = new Promise((resolve) => {
const onSubmit = (event) => {
event.preventDefault();
const value = input.value.trim();
if (!value) {
return;
}
form.removeEventListener("submit", onSubmit);
gate.hidden = true;
input.value = "";
pendingAuthPrompt = null;
resolve(value);
};
form.addEventListener("submit", onSubmit);
});
return pendingAuthPrompt;
}
function authHeaders(extra) {
const headers = { "Content-Type": "application/json", ...(extra || {}) };
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
return headers;
}
async function apiJson(path, options = {}, allowAuthRetry = true) {
const response = await fetch(path, {
...options,
headers: authHeaders(options.headers),
});
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch (error) {
payload = null;
}
}
if (response.status === 401 && allowAuthRetry) {
// Ask for the token, then replay this request once. A second 401 falls
// through to the normal error path instead of looping.
const token = await promptForAuthToken(
authToken ? "토큰이 올바르지 않습니다. 다시 입력하세요." : "",
);
if (token) {
authToken = token;
return apiJson(path, options, false);
}
}
if (!response.ok) {
const message = (payload && payload.error) || `API 요청 실패: ${response.status}`;
throw new Error(message);
}
return payload;
}
function replaceCollection(target, next) {
target.splice(0, target.length, ...(next || []));
}
function clearRuntimeData() {
replaceCollection(submissions, []);
replaceCollection(providers, []);
replaceCollection(knowledgeEntries, []);
replaceCollection(collectionCandidates, []);
replaceCollection(corrections, []);
replaceCollection(auditEvents, []);
Object.assign(coverageThresholds, DEFAULT_COVERAGE_THRESHOLDS);
Object.keys(searchCoverage).forEach((key) => delete searchCoverage[key]);
state.submissionQueue = null;
state.selectedCaseId = "";
}
function applyBootstrap(payload) {
// Any successful payload proves the API is reachable, so clear a stale error
// banner here (not only in refreshFromApi) — actions that applyBootstrap
// directly, e.g. promote/dismiss, otherwise leave the old error visible.
state.apiError = "";
replaceCollection(submissions, payload.submissions);
replaceCollection(providers, payload.providers);
replaceCollection(knowledgeEntries, payload.knowledgeEntries);
state.submissionQueue = payload.submissionQueue || null;
Object.assign(
coverageThresholds,
normalizeCoverageThresholds(payload.coverageThresholds || (payload.searchCoverage && payload.searchCoverage.coverageThresholds)),
);
Object.keys(searchCoverage).forEach((key) => delete searchCoverage[key]);
Object.assign(searchCoverage, payload.searchCoverage || {});
replaceCollection(collectionCandidates, payload.collectionCandidates);
replaceCollection(corrections, payload.corrections);
replaceCollection(auditEvents, payload.auditEvents);
replaceCollection(decisionMemoPresets, payload.decisionMemoPresets || []);
if (!submissions.some((item) => item.id === state.selectedCaseId)) {
state.selectedCaseId = submissions[0]?.id || "";
}
}
async function refreshFromApi() {
const payload = await apiJson("/api/bootstrap");
state.apiError = "";
applyBootstrap(payload);
renderAll();
}
function setImportStatus(message, tone = "idle") {
state.importStatus = { message, tone };
renderImportStatus();
}
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.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);
}
function formatQueueTimestamp(value) {
return value || "-";
}
function getSelectedCase() {
return submissions.find((item) => item.id === state.selectedCaseId) || submissions[0];
}
const RISK_BANDS = new Set(["low", "medium", "high", "failed", "pending"]);
function riskClass(riskBand) {
// Allowlist the band so a malformed/hostile server value can't break out of
// the class attribute it is interpolated into (these feed innerHTML).
return `risk-${RISK_BANDS.has(riskBand) ? riskBand : "pending"}`;
}
function sourceClass(source) {
if (source === "naver" || source === "naver_blog" || source === "naver_web") return "source-naver";
if (source === "google" || source === "google_search") return "source-google";
if (source === "llm") return "source-llm";
if (source === "failure") return "source-failure";
return "source-internal";
}
function providerClass(status) {
if (status === "ok" || status === "covered") return "ok";
if (status === "empty") return "skipped";
if (status === "failed") return "failed";
if (status === "disabled" || status === "skipped" || status === "not_run" || status === "pending") return "disabled";
return "skipped";
}
function sourceMatches(submission, source) {
if (source === "all") return true;
if (source === "covered") return submission.evidence.some((item) => item.contributed || item.confidence > 0);
if (source === "failure") {
return Object.values(submission.providerState).includes("failed") || submission.evidence.some((item) => item.source === "failure");
}
if (source === "face") {
return submission.evidence.some((item) => item.source === "face");
}
return submission.evidence.some((item) => item.source === source);
}
function searchableEvidenceItems(submission) {
return window.OperatorEvidenceGuidance.searchableEvidenceItems(submission);
}
function directEvidenceItems(submission) {
return window.OperatorEvidenceGuidance.directEvidenceItems(submission);
}
function hasSearchAttempt(submission) {
return window.OperatorEvidenceGuidance.hasSearchAttempt(submission);
}
function evidenceNeedsFollowup(submission) {
return window.OperatorEvidenceGuidance.evidenceNeedsFollowup(submission);
}
function evidenceFollowupReasons(submission) {
return window.OperatorEvidenceGuidance.evidenceFollowupReasons(submission);
}
function normalizedQuerySeed(submission) {
return window.OperatorEvidenceGuidance.normalizedQuerySeed(submission);
}
function suggestedEvidenceQueries(submission) {
return window.OperatorEvidenceGuidance.suggestedEvidenceQueries(submission);
}
function renderEvidenceNextActions(submission) {
const summary =
suggestedQueryRunSummary && suggestedQueryRunSummary.caseId === submission.id
? `
${escapeHtml(suggestedQueryRunSummary.message)}
`
: "";
if (!evidenceNeedsFollowup(submission)) return summary;
const queries = suggestedEvidenceQueries(submission);
const reasons = evidenceFollowupReasons(submission);
if (!queries.length) return summary;
return `
근거 보강 추천
추천 쿼리를 바로 실행하거나, 쿼리 본문을 눌러 수동 검색 입력칸에서 수정할 수 있습니다.
${reasons.map((reason) => `- ${escapeHtml(reason)}
`).join("")}
${queries
.map(
(query) => `
`,
)
.join("")}
${summary}
`;
}
function applySuggestedQuery(button) {
const queryInput = document.getElementById("manual-query");
const providerSelect = document.getElementById("manual-query-provider");
const status = document.getElementById("manual-query-status");
const provider = normalizeManualSearchProvider(button.dataset.suggestedProvider);
queryInput.value = button.dataset.suggestedQuery || "";
providerSelect.value = provider;
switchWorkbenchTab("queries");
queryInput.focus();
if (status) status.textContent = "추천 쿼리를 입력했습니다. 실행 버튼을 눌러 검색하세요.";
}
let isSuggestedQueryRunActive = false;
async function executeSuggestedQueries(queries) {
if (isSuggestedQueryRunActive) return;
const submission = getSelectedCase();
if (!submission || !queries.length) return;
const statusTarget = document.getElementById("suggested-query-run-status");
const naver = providers.find((provider) => provider.id === "naver");
if (!naver || !naver.enabled) {
if (statusTarget) statusTarget.textContent = `${providerLabels.naver} 외부 검색 tool이 비활성입니다.`;
return;
}
isSuggestedQueryRunActive = true;
try {
const results = [];
for (const [index, query] of queries.entries()) {
if (statusTarget) statusTarget.textContent = `"${query}" 실행 중… (${index + 1}/${queries.length})`;
try {
const review = await apiJson("/api/search/manual", {
method: "POST",
body: JSON.stringify({ submission_id: submission.id, provider: "naver", query }),
});
// queryHistory.count is the number of evidence rows the search produced,
// which includes the skip/failure/"no results" markers — so a query that
// was refused still reported count>=1 and was announced as "완료(근거 1건)"
// while the skip branch below could never run. Count real evidence here
// and let a failure row speak for itself.
const matching = (review.evidence || []).filter((item) => item.query === query);
const skipped = matching.find((item) => item.source === "failure");
const resultCount = matching.filter(
(item) =>
item.source !== "failure" &&
!/returned no results/i.test(String(item.title || "")),
).length;
if (skipped) {
results.push(`"${query}" ${formatReason(skipped.title || "건너뜀")}`);
} else if (resultCount === 0) {
results.push(`"${query}" 결과 없음`);
} else {
results.push(`"${query}" 완료(근거 ${resultCount}건)`);
}
} catch (errorValue) {
results.push(`"${query}" 실패: ${errorValue.message}`);
}
}
suggestedQueryRunSummary = {
caseId: submission.id,
message: `추천 쿼리 실행 — ${results.join(" · ")}`,
};
try {
await refreshFromApi();
} catch (refreshError) {
// Don't let a refresh failure become an unhandled rejection that leaves
// the status stuck at "실행 중…". Re-resolve the element (a render may have
// replaced it) and report honestly alongside the collected results.
const liveStatus = document.getElementById("suggested-query-run-status");
if (liveStatus) {
liveStatus.textContent = `새로고침 실패: ${refreshError.message} · 실행 결과: ${results.join(" · ")}`;
}
}
} finally {
isSuggestedQueryRunActive = false;
}
}
function filteredSubmissions() {
const query = state.filters.query.trim().toLowerCase();
const filtered = submissions.filter((submission) => {
const riskOk = state.filters.risk === "all" || submission.riskBand === state.filters.risk;
const sourceOk = sourceMatches(submission, state.filters.source);
const decisionOk = state.filters.decision === "all" || submission.decisionStatus === state.filters.decision;
const queryOk =
!query ||
submission.id.toLowerCase().includes(query) ||
submission.title.toLowerCase().includes(query) ||
submission.reasons.join(" ").toLowerCase().includes(query);
return riskOk && sourceOk && decisionOk && queryOk;
});
return filtered.sort((left, right) => {
if (state.filters.sort === "newest") return right.submittedEpoch - left.submittedEpoch;
if (state.filters.sort === "oldest") return left.submittedEpoch - right.submittedEpoch;
if (state.filters.sort === "analysis_failure") {
return Number(right.riskBand === "failed") - Number(left.riskBand === "failed");
}
if (state.filters.sort === "provider_failure") {
return Number(sourceMatches(right, "failure")) - Number(sourceMatches(left, "failure"));
}
return right.riskScore - left.riskScore;
});
}
function renderQueue() {
const rows = filteredSubmissions();
const queueLabel = state.submissionQueue?.label || "";
const queueMeta = queueLabel ? ` (${escapeHtml(queueLabel)})` : "";
const body = document.getElementById("queue-body");
document.getElementById("queue-health").innerHTML = state.apiError
? `
${escapeHtml(state.apiError)}
`
: `
${rows.length}건 / 고위험 ${submissions.filter((item) => item.riskBand === "high").length}건${queueMeta}
`;
if (!rows.length) {
body.innerHTML = `
|
현재 필터에 맞는 심사 건이 없습니다.
|
`;
return;
}
body.innerHTML = rows
.map((submission) => {
const providerHtml = visibleProviderStateEntries(submission.providerState)
.map(
([provider, status]) => {
const fullLabel = `${providerLabels[provider] || provider} ${formatProviderStatus(status)}`;
return `${escapeHtml(formatQueueProviderStatus(provider, status))}`;
},
)
.join("");
const reasonHtml = submission.reasons
.slice(0, 2)
.map((reason) => `${escapeHtml(formatReason(reason))}`)
.join("");
const submittedTime = formatQueueTimestamp(submission.submittedAt);
return `
|
}) |
${duplicateBadgeHtml(submission)}
|
${riskLabels[submission.riskBand]} ${submission.riskScore}
|
${reasonHtml} |
${providerHtml} |
${escapeHtml(submission.applicantStatus)} |
${decisionLabels[submission.decisionStatus]} |
${escapeHtml(submittedTime)}
|
`;
})
.join("");
}
function renderImportStatus() {
const target = document.getElementById("submission-import-status");
if (!target) return;
const tone = state.importStatus.tone === "bad" ? "bad" : state.importStatus.tone === "warn" ? "warn" : "ok";
target.innerHTML = `
${escapeHtml(state.importStatus.message)}
`;
}
function renderNoSelectedCase() {
document.getElementById("case-title").textContent = "선택된 제출 이미지가 없습니다";
document.getElementById("case-image").removeAttribute("src");
document.getElementById("case-image").alt = "";
document.getElementById("image-derivative-note").textContent = "API 연결 후 제출 이미지를 불러오면 이 영역에 분석 대상이 표시됩니다.";
document.getElementById("case-score").className = "score-pill risk-pending";
document.getElementById("case-score").textContent = "대기";
document.getElementById("floating-case-score").className = "score-pill risk-pending";
document.getElementById("floating-case-score").textContent = "대기";
document.getElementById("file-facts").innerHTML = "";
document.getElementById("similar-strip").innerHTML = "";
document.getElementById("face-crop-strip").innerHTML = "";
activeRerunAddedIds = new Set();
document.getElementById("rerun-diff-summary").innerHTML = "";
document.getElementById("case-reasons").innerHTML = `표시할 케이스가 없습니다.
`;
document.getElementById("evidence-groups").innerHTML = `API 연결 실패 또는 제출 이미지 없음 상태입니다.
`;
document.getElementById("recommendation-box").innerHTML = `실제 API 데이터가 로드될 때까지 판정하지 않습니다.`;
document.getElementById("derived-preview").innerHTML = `자동 후보 생성 없음`;
// Clear the evidence-gap recommendations too: they belong to the case that was
// just deselected, and leaving them rendered invited running suggested queries
// against nothing.
const nextActions = document.getElementById("evidence-next-actions");
if (nextActions) nextActions.innerHTML = "";
const llmDraft = document.getElementById("llm-draft");
if (llmDraft) {
llmDraft.hidden = true;
llmDraft.innerHTML = "";
}
}
function renderCaseReview() {
const submission = getSelectedCase();
if (!submission) {
renderNoSelectedCase();
return;
}
renderLlmDraft(submission);
document.getElementById("case-title").textContent = `${submission.id} · ${submission.title}`;
const caseImageElement = document.getElementById("case-image");
caseImageElement.src = safeUrl(submission.asset);
caseImageElement.alt = `${submission.id} 제출 이미지`;
// Reset the view controls (rotate / fit) so a new case starts unrotated.
caseImageElement.dataset.rotation = "0";
caseImageElement.style.transform = "";
caseImageElement.style.objectFit = "";
document.getElementById("image-derivative-note").textContent = submission.derivativeNote;
document.getElementById("case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
document.getElementById("case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`;
document.getElementById("floating-case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
document.getElementById("floating-case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`;
document.getElementById("file-facts").innerHTML = Object.entries(submission.fileFacts)
.map(([label, value]) => `${escapeHtml(formatFactLabel(label))}${escapeHtml(value)}
`)
.join("");
document.getElementById("similar-strip").innerHTML = submission.similar
.map(
(item) => `
${escapeHtml(formatReason(item.label))}
`,
)
.join("");
document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
.map(
(crop) => `
얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다
`,
)
.join("");
activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
document.getElementById("rerun-diff-summary").innerHTML = submission.lastRerunDiff
? renderRerunDiffSummary(submission.lastRerunDiff)
: "";
document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission);
document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(submission);
document.getElementById("evidence-groups").innerHTML = renderEvidenceGroups(submission);
document.getElementById("recommendation-box").innerHTML = `
${riskLabels[submission.riskBand]}
${escapeHtml(submission.recommendation.label)}
${escapeHtml(formatReason(submission.recommendation.detail))}
현재 운영 결정: ${decisionLabels[submission.decisionStatus]}
`;
document.getElementById("derived-preview").innerHTML = `
${submission.derivedPreview.automatic ? "자동 후보 생성 예정" : "자동 이력 없음"}
${escapeHtml(submission.derivedPreview.entryName)}
${escapeHtml(submission.derivedPreview.effect)}
`;
}
function evidenceGroupDefinitions() {
return [
{ id: "watchlist", label: "주의 후보 근거", description: "보류/반려 이력에서 생성된 주의 후보 또는 유사 이미지입니다.", tone: "strong" },
{ id: "face_web", label: "얼굴 영역 웹 근거", description: "얼굴 영역만으로 찾은 웹 검색 결과입니다. 동일 인물 판정은 아닙니다.", tone: "search" },
{ id: "full", label: "동일 이미지", description: "원본 이미지 직접 매칭", tone: "strong" },
{ id: "partial", label: "부분 매칭", description: "일부 영역 또는 파생 이미지 매칭", tone: "strong" },
{ id: "page", label: "출처 페이지", description: "같은 이미지가 포함된 웹페이지", tone: "strong" },
{ id: "naver", label: "네이버 검색", description: "텍스트 기반 이미지 검색 결과", tone: "search" },
{ id: "face", label: "얼굴/인물 감지", description: "동일 인물 판정은 아니며 얼굴 또는 사람 존재만 표시합니다.", tone: "local" },
{ id: "provenance", label: "출처 표기", description: "파일에 들어 있던 권리 표기와 화면 캡처 신호입니다. 자기신고 정보이며 점수에 반영하지 않습니다.", tone: "local" },
{ id: "visual", label: "유사 이미지", description: "시각적으로 유사한 참고 결과", tone: "weak" },
{ id: "weak", label: "약한 라벨", description: "점수에 직접 반영하지 않는 힌트", tone: "weak" },
{ id: "llm", label: "내부 요약", description: "출처 연결 요약 메모", tone: "note" },
{ id: "internal", label: "내부 지문", description: "로컬 지문 기반 내부 처리 기록", tone: "local" },
{ id: "failure", label: "실패/건너뜀", description: "외부 검색 도구 실패 또는 정책상 스킵", tone: "failure" },
];
}
function evidenceGroupFor(evidence) {
if (evidence.knowledgeEntryStatus === "watchlist") return "watchlist";
if (evidence.faceCropSearch) return "face_web";
if (evidence.source === "google") {
if (evidence.matchType === "full") return "full";
if (evidence.matchType === "partial") return "partial";
if (evidence.matchType === "page") return "page";
if (evidence.matchType === "visual") return "visual";
if (evidence.matchType === "weak_label" || evidence.status === "weak") return "weak";
return "visual";
}
if (evidence.source === "naver") return "naver";
if (evidence.source === "face") return "face";
if (evidence.source === "llm") return "llm";
if (evidence.source === "failure") return "failure";
return "internal";
}
function groupedEvidence(evidence) {
return evidence.reduce((collection, item) => {
const group = evidenceGroupFor(item);
if (!collection[group]) collection[group] = [];
collection[group].push(item);
return collection;
}, {});
}
function evidencePriorityScore(evidence) {
const groupPriority = {
full: 100,
watchlist: 95,
partial: 85,
page: 75,
naver: 65,
face_web: 58,
face: 55,
visual: 35,
weak: 10,
llm: 8,
internal: 6,
failure: 4,
};
return (groupPriority[evidenceGroupFor(evidence)] || 0) + Number(evidence.confidence || 0);
}
function sortEvidenceItems(items) {
return [...items].sort((left, right) => evidencePriorityScore(right) - evidencePriorityScore(left));
}
function renderRerunDiffSummary(diff) {
const scoreBefore = Number(diff.scoreBefore || 0);
const scoreAfter = Number(diff.scoreAfter || 0);
const delta = scoreAfter - scoreBefore;
const direction = delta > 0 ? `상승 ${delta}` : delta < 0 ? `하락 ${Math.abs(delta)}` : "변동 없음";
const removedSummaries = diff.removedSummaries || [];
const removedBlock = removedSummaries.length
? `
이번 재분석에서 제거됨 · ${removedSummaries.length}개
${removedSummaries
.map((item) => `- ${escapeHtml(sourceLabels[item.source] || item.source || "내부")} · ${escapeHtml(formatReason(item.reason || ""))}
`)
.join("")}
`
: "";
return `
재분석 ${escapeHtml(diff.at || "")}
점수 ${escapeHtml(String(scoreBefore))} → ${escapeHtml(String(scoreAfter))} (${escapeHtml(direction)})
신규 증거 ${(diff.addedEvidenceIds || []).length}개
${removedBlock}
`;
}
function renderEvidenceSummary(submission) {
const groups = groupedEvidence(submission.evidence);
const count = (group) => (groups[group] || []).length;
const directCount = count("full") + count("partial") + count("page");
const searchCount = directCount + count("naver");
const primaryJudgment = directCount
? "직접 매칭 우선 확인"
: count("visual")
? "유사 이미지 참고 확인"
: count("face")
? "인물 신호만 확인됨"
: "증거 부족";
const summaryCards = [
["동일", count("full")],
["부분", count("partial")],
["페이지", count("page")],
["유사", count("visual")],
["네이버", count("naver")],
["인물", count("face")],
];
return `
${riskLabels[submission.riskBand]} ${submission.riskScore}
${escapeHtml(primaryJudgment)}
${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거
${summaryCards
.map(
([label, value]) => `
${escapeHtml(label)}
${value}
`,
)
.join("")}
`;
}
function renderEvidenceGroups(submission) {
if (!submission.evidence.length) {
return `아직 분석 증거가 없습니다. 외부 검색 도구 상태를 확인하고 증거 재수집을 실행하세요.
`;
}
const groups = groupedEvidence(submission.evidence);
return evidenceGroupDefinitions()
.filter((group) => groups[group.id]?.length)
.map((group) => renderEvidenceGroup(group, sortEvidenceItems(groups[group.id])))
.join("");
}
function renderEvidenceGroup(group, items) {
const topItems = items.slice(0, 3);
const detailItems = items.slice(3);
return `
${escapeHtml(group.label)}
${escapeHtml(group.description)}
${items.length}개
${topItems.map(renderEvidenceRow).join("")}
${
detailItems.length
? `
자세히 보기 · ${detailItems.length}개
${detailItems.map(renderEvidenceRow).join("")}
`
: ""
}
`;
}
function renderEvidenceRow(evidence) {
const isNewFromRerun = activeRerunAddedIds.has(evidence.id);
const newChip = isNewFromRerun ? `신규` : "";
const sourceEvidenceIds = evidence.sourceEvidenceIds || [];
const sourceIds = sourceEvidenceIds.length
? `근거 ID: ${sourceEvidenceIds.join(", ")}`
: "근거 ID 없음";
const contribution = evidence.contributed ? "점수 반영" : "참고";
const normalizedOperatorStatus = normalizeEvidenceOperatorStatus(evidence.operatorStatus);
const operatorStatus = normalizedOperatorStatus
? `${escapeHtml(formatEvidenceStatus(normalizedOperatorStatus))}`
: "";
const confidence = typeof evidence.confidence === "number" ? evidence.confidence.toFixed(2) : evidence.confidence;
const matchType = evidence.matchType ? `${escapeHtml(formatMatchType(evidence.matchType))}` : "";
const pageTitle = evidence.pageTitle ? `${escapeHtml(evidence.pageTitle)}` : "";
const faceCropMeta = evidence.faceCropSearch
? `얼굴 영역 ${escapeHtml(evidence.cropIndex || "")}${escapeHtml(evidence.privacyNote || "동일 인물 판정은 아닙니다")}`
: "";
const watchlistMeta =
evidence.knowledgeEntryStatus === "watchlist"
? `주의 후보: ${escapeHtml(evidence.knowledgeEntryName || evidence.knowledgeEntryId || "-")}원 케이스 ${escapeHtml(evidence.sourceSubmissionId || "-")}`
: "";
const providerScore =
evidence.providerScore !== undefined && evidence.providerScore !== null && evidence.providerScore !== "" ? `검색 도구 점수 ${escapeHtml(evidence.providerScore)}` : "";
const similarity =
evidence.similarity !== undefined && evidence.similarity !== ""
? `이미지 유사도 ${escapeHtml(Number(evidence.similarity).toFixed(2))}`
: "";
const queryStrategy = evidence.queryStrategy ? `${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}` : "";
const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
const searchType = evidence.searchType ? `${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}` : "";
const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
const preview = renderEvidencePreview(evidence);
const link = renderEvidenceLink(evidence);
const statusButtons = [
["used_for_judgment", "사용"],
["ignored", "미사용"],
]
.map(
([status, label]) => `
`,
)
.join("");
return `
${preview}
${escapeHtml(sourceLabels[evidence.source] || evidence.source)}
${newChip}
${escapeHtml(formatEvidenceTitle(evidence))}
신뢰도 ${escapeHtml(confidence)}
${escapeHtml(contribution)}
${operatorStatus}
${matchType}
${faceCropMeta}
${watchlistMeta}
${providerScore}
${similarity}
${queryStrategy}
${searchType}
${pageTitle}
${escapeHtml(evidence.query || "쿼리 없음")}
${escapeHtml(formatDomain(evidence.domain))}
${escapeHtml(evidence.retrievedAt)}
${escapeHtml(sourceIds)}
${link}
${statusButtons}
`;
}
function renderEvidencePreview(evidence) {
const previewUrl = evidence.thumbnailUrl || evidence.imageUrl;
const targetUrl = evidence.imageUrl || evidence.url || previewUrl;
if (!previewUrl) return ``;
return `
`;
}
function renderEvidenceLink(evidence) {
const url = evidence.url || evidence.imageUrl || "";
if (!url) return "";
const label = evidence.pageTitle || evidence.url || evidence.imageUrl;
return `
${escapeHtml(label)}
`;
}
function renderEvidenceSearch() {
const submission = getSelectedCase();
const queryHistory = document.getElementById("query-history");
const results = document.getElementById("search-results");
if (!submission) {
document.getElementById("query-case-label").textContent = "-";
queryHistory.innerHTML = `표시할 케이스가 없습니다.
`;
results.innerHTML = `API 연결 후 검색 근거를 확인할 수 있습니다.
`;
return;
}
document.getElementById("query-case-label").textContent = submission.id;
queryHistory.innerHTML = submission.queryHistory.length
? submission.queryHistory
.map(
(query) => `
${escapeHtml(providerLabels[query.provider] || query.provider)}
${escapeHtml(query.query)}
${escapeHtml(formatQueryStatus(query.status))}
${query.strategy ? `${escapeHtml(formatQueryStrategy(query.strategy))}` : ""}
${query.source ? `출처 ${escapeHtml(query.source)}` : ""}
${escapeHtml(query.timestamp)}
${query.count}건
`,
)
.join("")
: `아직 실행한 검색 쿼리가 없습니다.
`;
activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
const searchableEvidence = searchableEvidenceItems(submission);
results.innerHTML = searchableEvidence.length
? renderEvidenceGroups({ ...submission, evidence: searchableEvidence })
: `현재 케이스에는 검색 결과가 없습니다.
`;
}
function filteredKnowledgeEntries() {
const query = knowledgeFilters.query.trim().toLowerCase();
return knowledgeEntries.filter((entry) => {
const typeOk = knowledgeFilters.type === "all" || entry.type === knowledgeFilters.type;
const statusOk = knowledgeFilters.status === "all" || (entry.entryStatus || "confirmed") === knowledgeFilters.status;
const activeOk = knowledgeFilters.active === "all" || (knowledgeFilters.active === "active") === Boolean(entry.active);
const haystack = [entry.name, ...(entry.aliases || []), ...(entry.keywords || [])].join(" ").toLowerCase();
const queryOk = !query || haystack.includes(query);
return typeOk && statusOk && activeOk && queryOk;
});
}
function renderKnowledgeBase() {
const entries = filteredKnowledgeEntries();
document.getElementById("knowledge-list").innerHTML = entries.length
? entries
.map((entry) => {
const aliases = entry.aliases || [];
const keywords = entry.keywords || [];
const entryStatus = entry.entryStatus || "confirmed";
const statusMeta =
entryStatus === "watchlist"
? `원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}미래 검출 ${escapeHtml(entry.contributionCount || 0)}건`
: "";
const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `${escapeHtml(entry.memo)}` : "";
const memoBlock = memoInline ? "" : `${escapeHtml(entry.memo)}
`;
const lifecycleActions =
entryStatus === "watchlist"
? `
`
: entryStatus === "excluded"
? ""
: entry.active
? ``
: ``;
const editForm =
editingKnowledgeEntryId === entry.id
? `
`
: "";
return `
${
entry.imageAsset
? `
`
: ``
}
${escapeHtml(entry.name)}
${escapeHtml(formatProvenance(entry.provenance))}
${escapeHtml(formatKnowledgeStatus(entryStatus))}
${escapeHtml(formatKnowledgeType(entry.type))}
별칭: ${escapeHtml(aliases.join(", ") || "-")}
키워드: ${escapeHtml(keywords.join(", ") || "-")}
샘플 지문 ${(entry.sampleFingerprints || []).length}
${statusMeta}
${memoInline}
${memoBlock}
${editForm}
${lifecycleActions}
`;
})
.join("")
: `조건에 맞는 기준 항목이 없습니다.
`;
renderCollectionCandidates();
}
function renderCollectionCandidates() {
const target = document.getElementById("collection-candidates");
if (!target) return;
const visibleCandidates = visibleCollectionCandidates();
target.innerHTML = visibleCandidates.length
? visibleCandidates
.map((candidate) => {
const promoted = candidate.status === "promoted";
const canPromote = !promoted && (candidate.sampleFingerprints || []).length;
return `
${
candidate.imageAsset
? `
`
: ``
}
${escapeHtml(providerLabels[candidate.provider] || candidate.provider || "검색")}
${escapeHtml(candidate.title || candidate.query)}
${escapeHtml(candidate.query)}
${escapeHtml(formatCandidateSourceType(candidate.sourceCandidateType))}
순위 ${escapeHtml(candidate.rank || "-")}
${escapeHtml(formatCandidateStatus(candidate.status))}
${
candidate.sourceUrl
? `
출처 열기`
: ""
}
`;
})
.join("")
: `검색어와 제공자를 선택하면 기준 DB 후보가 표시됩니다.
`;
}
function visibleCollectionCandidates() {
return collectionCandidates.filter(
(candidate) =>
candidate.status === "candidate" &&
candidate.query === state.currentCollectionQuery &&
(!state.currentCollectionProvider || candidate.provider === state.currentCollectionProvider),
);
}
function renderCorrections() {
document.getElementById("corrections-list").innerHTML = corrections
.map(
(correction) => `
보정
${escapeHtml(correction.decisionId)} · ${escapeHtml(correction.submissionId)}
${escapeHtml(correction.previous)} -> ${escapeHtml(correction.current)}
${escapeHtml(correction.operator)}
${escapeHtml(correction.timestamp)}
${escapeHtml(correction.reason)}
${(correction.derivedEntries || [])
.map(
(entry) =>
`${escapeHtml(entry.name)} · ${entry.active ? "활성" : "비활성"}`,
)
.join("")}
`,
)
.join("");
}
function renderProviderEnvStatus(provider) {
const requiredEnv = Array.isArray(provider.requiredEnv) ? provider.requiredEnv : [];
if (!requiredEnv.length) return "";
const configuredEnv = provider.configuredEnv || {};
const missing = requiredEnv.filter((key) => !configuredEnv[key]);
if (!missing.length) {
return `환경 변수 확인됨: ${escapeHtml(requiredEnv.join(", "))}`;
}
return `누락된 환경 변수: ${escapeHtml(missing.join(", "))}`;
}
function visibleProviderControls() {
return providers.filter((provider) => !retiredProviderIds.has(provider.id));
}
function renderProviderControls() {
document.getElementById("providers-list").innerHTML = visibleProviderControls()
.map((provider) => {
const percent = provider.quota ? Math.min(100, Math.round((provider.usage / provider.quota) * 100)) : 0;
const providerName = formatProviderName(provider);
const envStatus = renderProviderEnvStatus(provider);
return `
${escapeHtml(providerName)}
${provider.enabled ? "활성" : "중지"}
${escapeHtml(formatReason(provider.compliance))}
최근 성공: ${escapeHtml(formatReason(provider.lastSuccess))}
최근 실패: ${escapeHtml(formatReason(provider.lastFailure))}
${escapeHtml(formatReason(provider.boundary))}
${envStatus}
${provider.usage} / ${provider.quota == null ? "무제한" : provider.quota}${percent}%
`;
})
.join("");
}
function renderAuditLog() {
document.getElementById("audit-body").innerHTML = auditEvents
.map(
(event) => `
| ${escapeHtml(event.timestamp)} |
${escapeHtml(event.actor)} |
${escapeHtml(formatReason(event.event))} |
${escapeHtml(formatReason(event.object))} |
${escapeHtml(formatReason(event.change))} |
`,
)
.join("");
}
function renderProviderPulse() {
const visibleProviders = visibleProviderControls();
const failed = visibleProviders.filter((provider) => provider.enabled && provider.lastFailure !== "없음").length;
const disabled = visibleProviders.filter((provider) => !provider.enabled).length;
const className = disabled || failed ? "warn" : "ok";
document.getElementById("provider-pulse").innerHTML = `
외부 검색 tool ${visibleProviders.filter((provider) => provider.enabled).length}/${visibleProviders.length} 활성 · 실패 ${failed}
`;
}
function renderAll() {
renderOperatorSearchProviderOptions();
renderProviderPulse();
renderCoverageTabs();
renderImportStatus();
renderQueue();
renderCaseReview();
renderEvidenceSearch();
renderKnowledgeBase();
labelCollectionCandidateActions();
renderCorrections();
renderProviderControls();
renderAuditLog();
renderMemoPresets();
renderBulkDecisionCount();
renderReviewProgress();
}
function labelCollectionCandidateActions() {
document.querySelectorAll("[data-promote-candidate]").forEach((button) => {
const candidate = collectionCandidates.find((item) => item.id === button.dataset.promoteCandidate);
button.textContent = candidate?.status === "promoted" ? "반영됨" : "기준 DB 반영";
});
}
function switchView(view, updateHash = true) {
if (view === "case" || view === "evidence") view = "workbench";
let knowledgeTabToApply = "";
if (view === "corrections") {
view = "knowledge";
// Setting state alone left the visible panel on whatever knowledge tab was
// last shown — the tab DOM is switched by switchKnowledgeTab, which must run
// (applied at the end, after the view panels are toggled).
knowledgeTabToApply = "corrections";
}
if (!viewNames.has(view)) return;
state.currentView = view;
// Stats are read on demand (a fresh cross-tab each open) rather than shipped
// in every bootstrap payload.
if (view === "stats") {
void loadDecisionStats();
}
document.querySelectorAll(".nav-button").forEach((button) => {
const active = button.dataset.view === view;
button.classList.toggle("active", active);
if (active) {
button.setAttribute("aria-current", "page");
} else {
button.removeAttribute("aria-current");
}
});
document.querySelectorAll("[data-view-panel]").forEach((panel) => {
const active = panel.dataset.viewPanel === view;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
if (knowledgeTabToApply) {
switchKnowledgeTab(knowledgeTabToApply);
}
if (updateHash && window.location.hash.replace("#", "") !== view) {
window.history.replaceState(null, "", `#${view}`);
}
}
function switchKnowledgeTab(tab) {
const nextTab = ["collect", "registered", "manual", "corrections"].includes(tab) ? tab : "collect";
state.knowledgeTab = nextTab;
document.querySelectorAll("[data-knowledge-tab]").forEach((button) => {
const active = button.dataset.knowledgeTab === nextTab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
});
document.querySelectorAll("[data-knowledge-panel]").forEach((panel) => {
const active = panel.dataset.knowledgePanel === nextTab;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
}
function switchWorkbenchTab(tab) {
const nextTab = ["evidence", "queries"].includes(tab) ? tab : "evidence";
state.workbenchTab = nextTab;
document.querySelectorAll("[data-workbench-tab]").forEach((button) => {
const active = button.dataset.workbenchTab === nextTab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
});
document.querySelectorAll("[data-workbench-panel]").forEach((panel) => {
const panelName = panel.dataset.workbenchPanel;
const active = panelName === nextTab;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
}
function selectCase(caseId) {
state.selectedCaseId = caseId;
suggestedQueryRunSummary = null;
// Reset per-case inputs. The decision memo is submitted with whatever case is
// selected at click time, so a memo typed for case A and left in the box was
// recorded verbatim as the rejection/correction reason for case B.
resetCaseScopedInputs();
switchView("workbench");
switchWorkbenchTab("evidence");
renderAll();
}
function resetCaseScopedInputs() {
const memo = document.getElementById("decision-memo");
if (memo) {
memo.value = "";
}
const memoError = document.getElementById("memo-error");
if (memoError) {
memoError.textContent = "";
}
const manualQuery = document.getElementById("manual-query");
if (manualQuery) {
manualQuery.value = "";
}
const manualStatus = document.getElementById("manual-query-status");
if (manualStatus) {
manualStatus.textContent = "";
}
}
let isDecisionInFlight = false;
async function setDecision(decision, requiresMemo = false) {
// In-flight guard: a double-click, or clicking two different decisions
// quickly, would otherwise POST two conflicting decisions concurrently.
if (isDecisionInFlight) return;
const submission = getSelectedCase();
const memo = document.getElementById("decision-memo").value.trim();
const error = document.getElementById("memo-error");
if (!submission) {
error.textContent = "판정할 제출 이미지가 없습니다.";
return;
}
if (requiresMemo && !memo) {
error.textContent = "반려 또는 보정 결정에는 메모가 필요합니다.";
return;
}
error.textContent = "";
isDecisionInFlight = true;
try {
await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/decision`, {
method: "POST",
body: JSON.stringify({ decision, memo }),
});
document.getElementById("decision-memo").value = "";
await refreshFromApi();
} catch (errorValue) {
error.textContent = errorValue.message;
} finally {
isDecisionInFlight = false;
}
}
let isManualSearchActive = false;
async function runManualSearch(event) {
event.preventDefault();
if (isManualSearchActive) return;
const submission = getSelectedCase();
const provider = document.getElementById("manual-query-provider").value;
const queryInput = document.getElementById("manual-query");
const status = document.getElementById("manual-query-status");
const query = queryInput.value.trim();
if (!submission) {
status.textContent = "검색을 연결할 제출 이미지가 없습니다.";
return;
}
if (!query) {
status.textContent = "쿼리를 입력하세요.";
return;
}
const providerConfig = providers.find((item) => item.id === provider);
if (!providerConfig || !providerConfig.enabled) {
status.textContent = `${providerLabels[provider] || provider} 외부 검색 tool이 비활성입니다.`;
return;
}
isManualSearchActive = true;
try {
const targetCaseId = submission.id;
await apiJson("/api/search/manual", {
method: "POST",
body: JSON.stringify({ submission_id: targetCaseId, provider, query }),
});
// The operator may have moved to another case while the search ran. Naming
// the case the evidence actually landed on prevents "added to the current
// case" from pointing at the wrong one, and the input is only cleared when
// it still belongs to the case we searched for.
if (state.selectedCaseId === targetCaseId) {
queryInput.value = "";
status.textContent = `${providerLabels[provider]} 쿼리 결과가 현재 케이스 증거에 추가되었습니다.`;
} else {
status.textContent = `${providerLabels[provider]} 쿼리 결과가 케이스 ${targetCaseId}에 추가되었습니다.`;
}
await refreshFromApi();
} catch (errorValue) {
status.textContent = errorValue.message;
} finally {
isManualSearchActive = false;
}
}
function normalizeManualSearchProvider(provider) {
return window.OperatorSearch.normalizeManualSearchProvider(provider);
}
function rerunHistoricalQuery(button) {
const queryInput = document.getElementById("manual-query");
const providerSelect = document.getElementById("manual-query-provider");
const provider = normalizeManualSearchProvider(button.dataset.rerunProvider);
queryInput.value = button.dataset.rerunQuery || "";
providerSelect.value = provider;
void runManualSearch(new Event("submit"));
}
async function rerunEnrichment() {
const submission = getSelectedCase();
const button = document.getElementById("rerun-enrichment");
if (!submission) {
showApiError("재분석할 제출 이미지가 없습니다.");
return;
}
if (button) button.disabled = true;
try {
await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/rerun-enrichment`, {
method: "POST",
body: JSON.stringify({}),
});
await refreshFromApi();
} catch (errorValue) {
showApiError(errorValue.message);
} finally {
if (button) button.disabled = false;
}
}
function selectedBulkSubmissionIds() {
const ids = Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map((input) => input.dataset.bulkId);
return ids.length ? ids : state.selectedCaseId ? [state.selectedCaseId] : [];
}
// --- Keyboard review mode ------------------------------------------------
const KEYBOARD_HELP = [
"J / ↓ : 다음 케이스",
"K / ↑ : 이전 케이스",
"A : 승인 H : 보류 R : 반려(메모로 이동)",
"N : 다음 미심사 케이스",
"? : 이 도움말 열기/닫기",
"Esc : 도움말 닫기",
].join("\n");
function isTypingTarget(target) {
if (!target) return false;
const tag = String(target.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function moveSelection(offset) {
const rows = filteredSubmissions();
if (!rows.length) return;
const index = rows.findIndex((item) => item.id === state.selectedCaseId);
// From "no selection", J lands on the first row and K on the last.
const nextIndex = index === -1
? (offset > 0 ? 0 : rows.length - 1)
: Math.min(rows.length - 1, Math.max(0, index + offset));
selectCase(rows[nextIndex].id);
}
function moveToNextUnreviewed() {
const rows = filteredSubmissions();
const index = rows.findIndex((item) => item.id === state.selectedCaseId);
const ordered = [...rows.slice(index + 1), ...rows.slice(0, Math.max(0, index + 1))];
const next = ordered.find((item) => item.decisionStatus === "unreviewed");
if (next) {
selectCase(next.id);
} else {
setImportStatus("미심사 케이스가 없습니다.", "ok");
}
}
function renderReviewProgress() {
const target = document.getElementById("review-progress");
if (!target) return;
const rows = filteredSubmissions();
const position = rows.findIndex((item) => item.id === state.selectedCaseId) + 1;
const remaining = rows.filter((item) => item.decisionStatus === "unreviewed").length;
target.textContent = rows.length
? `${position || "-"}/${rows.length} · 남은 미심사 ${remaining}건 · ? 도움말`
: "";
}
function handleReviewShortcut(event) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
const key = event.key;
if (key === "Escape") {
const help = document.getElementById("keyboard-help");
if (help && !help.hidden) help.hidden = true;
return;
}
if (key === "?") {
const help = document.getElementById("keyboard-help");
if (help) {
help.textContent = KEYBOARD_HELP;
help.hidden = !help.hidden;
}
event.preventDefault();
return;
}
// Navigation works from the queue and the workbench; decisions only make
// sense once a case is open.
if (key === "j" || key === "J" || key === "ArrowDown") {
moveSelection(1);
event.preventDefault();
return;
}
if (key === "k" || key === "K" || key === "ArrowUp") {
moveSelection(-1);
event.preventDefault();
return;
}
if (key === "n" || key === "N") {
moveToNextUnreviewed();
event.preventDefault();
return;
}
if (!state.selectedCaseId) return;
if (key === "a" || key === "A") {
setDecision("approved").then(() => moveToNextUnreviewed());
event.preventDefault();
return;
}
if (key === "h" || key === "H") {
setDecision("held").then(() => moveToNextUnreviewed());
event.preventDefault();
return;
}
if (key === "r" || key === "R") {
// Rejection needs a memo, so hand the operator the field instead of firing
// a request that would fail validation.
switchView("workbench");
const memo = document.getElementById("decision-memo");
if (memo) memo.focus();
event.preventDefault();
}
}
// --- Duplicate / prior-case grouping ------------------------------------
function duplicateBadgeHtml(submission) {
const group = submission.duplicateGroup;
if (!group || !group.size) return "";
const priors = group.priorDecisions || [];
const priorText = priors.length
? `선례: ${priors
.map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`)
.join(", ")}`
: "아직 판정된 선례 없음";
const tone = group.conflicting ? " conflicting" : "";
const conflictNote = group.conflicting ? " · 판정 불일치" : "";
return ``;
}
function selectDuplicateGroup(submissionId) {
// Tick the whole group so the operator can hand it to batch re-analysis or a
// batch decision in one move.
const submission = submissions.find((item) => item.id === submissionId);
const group = submission && submission.duplicateGroup;
if (!group) return;
const ids = new Set([submissionId, ...(group.peerIds || [])]);
document.querySelectorAll("input[data-bulk-id]").forEach((input) => {
input.checked = ids.has(input.dataset.bulkId);
});
renderBulkDecisionCount();
const status = document.getElementById("bulk-decision-status");
if (status) {
const priors = group.priorDecisions || [];
status.textContent = priors.length
? `동일 이미지 ${ids.size}건 선택 · 선례 ${priors
.map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`)
.join(", ")}`
: `동일 이미지 ${ids.size}건을 선택했습니다.`;
}
}
// --- Batch decisions ---------------------------------------------------
function checkedBulkSubmissionIds() {
// Unlike selectedBulkSubmissionIds(), this never falls back to the current
// case: a batch decision must act on an explicit selection only.
return Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map(
(input) => input.dataset.bulkId,
);
}
function renderBulkDecisionCount() {
const target = document.getElementById("bulk-decision-count");
if (!target) return;
const ids = checkedBulkSubmissionIds();
const highRisk = ids.filter((id) => {
const submission = submissions.find((item) => item.id === id);
return submission && submission.riskBand === "high";
}).length;
target.textContent = highRisk
? `선택 ${ids.length}건 (고위험 ${highRisk}건)`
: `선택 ${ids.length}건`;
}
let isBulkDecisionInFlight = false;
async function applyBulkDecision(decision) {
if (isBulkDecisionInFlight) return;
const ids = checkedBulkSubmissionIds();
const status = document.getElementById("bulk-decision-status");
const setStatus = (message) => {
if (status) status.textContent = message;
};
if (!ids.length) {
setStatus("판정할 케이스를 먼저 선택하세요.");
return;
}
const memo = document.getElementById("decision-memo")?.value.trim() || "";
if (decision === "rejected" && !memo) {
setStatus("일괄 반려에는 결정 메모가 필요합니다. 메모를 입력한 뒤 다시 시도하세요.");
return;
}
const highRisk = ids.filter((id) => {
const submission = submissions.find((item) => item.id === id);
return submission && submission.riskBand === "high";
});
// Guardrail: approving a high-risk case in bulk is the one irreversible-feeling
// action here, so name the count instead of asking a generic "are you sure".
if (highRisk.length) {
const label = decisionLabels[decision] || decision;
if (!window.confirm(`선택 ${ids.length}건 중 고위험 ${highRisk.length}건이 포함되어 있습니다. 그대로 ${label} 처리할까요?`)) {
setStatus("일괄 판정을 취소했습니다.");
return;
}
}
isBulkDecisionInFlight = true;
setStatus(`${ids.length}건 판정 중…`);
try {
const payload = await apiJson("/api/submissions/decision-batch", {
method: "POST",
body: JSON.stringify({ submission_ids: ids, decision, memo }),
});
const result = payload.batchDecision || { applied: [], failed: [] };
applyBootstrap(payload);
renderAll();
const failures = result.failed || [];
setStatus(
failures.length
? `${(result.applied || []).length}건 적용 · ${failures.length}건 실패: ${failures
.map((item) => `${item.submissionId}(${item.error})`)
.join(", ")}`
: `${(result.applied || []).length}건 판정을 적용했습니다.`,
);
} catch (errorValue) {
setStatus(`일괄 판정 실패: ${errorValue.message}`);
} finally {
isBulkDecisionInFlight = false;
}
}
// --- Decision memo presets ---------------------------------------------
function renderMemoPresets() {
const target = document.getElementById("memo-presets");
if (!target) return;
const chips = decisionMemoPresets
.map(
(preset) =>
``,
)
.join("");
target.innerHTML = `${chips}`;
}
function applyMemoPreset(presetId) {
const preset = decisionMemoPresets.find((item) => item.id === presetId);
const memo = document.getElementById("decision-memo");
if (!preset || !memo) return;
memo.value = memo.value.trim() ? `${memo.value.trim()} ${preset.body}` : preset.body;
memo.focus();
}
async function saveCurrentMemoAsPreset() {
const memo = document.getElementById("decision-memo");
const error = document.getElementById("memo-error");
const body = memo?.value.trim() || "";
if (!body) {
if (error) error.textContent = "저장할 메모 내용이 없습니다.";
return;
}
const label = window.prompt("프리셋 이름 (예: 연예인 초상)", body.slice(0, 20));
if (!label) return;
try {
const presets = await apiJson("/api/decision-memo-presets", {
method: "POST",
body: JSON.stringify({ label, body }),
});
replaceCollection(decisionMemoPresets, presets);
renderMemoPresets();
if (error) error.textContent = "";
} catch (errorValue) {
if (error) error.textContent = `프리셋 저장 실패: ${errorValue.message}`;
}
}
// --- Decision export ----------------------------------------------------
async function exportDecisions() {
const status = document.getElementById("bulk-decision-status");
const audience = window.confirm(
"내부 보관용(증거·점수 포함)으로 내보내려면 확인, 신청 채널 전달용(판정·메모만)으로 내보내려면 취소를 누르세요.",
)
? "internal"
: "applicant";
try {
const payload = await apiJson(
`/api/decisions/export?audience=${encodeURIComponent(audience)}`,
);
if (!payload.rows.length) {
if (status) status.textContent = "내보낼 판정 완료 케이스가 없습니다.";
return;
}
downloadTextFile(
`copyrighter-decisions-${audience}.csv`,
payload.csv,
"text/csv;charset=utf-8",
);
if (status) status.textContent = `${payload.rows.length}건을 ${audience === "internal" ? "내부 보관용" : "신청 채널 전달용"} CSV로 내보냈습니다.`;
} catch (errorValue) {
if (status) status.textContent = `내보내기 실패: ${errorValue.message}`;
}
}
function downloadTextFile(filename, text, mimeType) {
// BOM so Excel opens the Korean CSV as UTF-8 instead of the system codepage.
const blob = new Blob([`${text}`], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// --- Backup -------------------------------------------------------------
async function createBackupSnapshot() {
const status = document.getElementById("bulk-decision-status");
const button = document.getElementById("create-backup");
if (button) button.disabled = true;
if (status) status.textContent = "백업 스냅샷을 만드는 중…";
try {
const result = await apiJson("/api/admin/backup", { method: "POST" });
const megabytes = (Number(result.sizeBytes || 0) / (1024 * 1024)).toFixed(1);
if (status) {
status.textContent = `백업 완료: ${result.file} (${megabytes}MB · 미디어 ${result.mediaFiles}개)`;
}
await refreshFromApi();
} catch (errorValue) {
if (status) status.textContent = `백업 실패: ${errorValue.message}`;
} finally {
if (button) button.disabled = false;
}
}
// --- Cleared reference (positive knowledge) -----------------------------
async function registerClearedReference() {
const submission = getSelectedCase();
if (!submission) return;
const label = window.prompt(
"클리어 기준 이름 (예: 자사 보유 스톡 · 라이선스 확인됨)",
submission.title || submission.id,
);
if (label === null) return;
const note = window.prompt("메모(선택) — 왜 클리어인지", "") || "";
try {
const payload = await apiJson(
`/api/submissions/${encodeURIComponent(submission.id)}/clear-reference`,
{ method: "POST", body: JSON.stringify({ label: label.trim(), note }) },
);
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
// --- LLM draft (persist + show + pull into memo) ------------------------
function llmSummaryFor(submission) {
const evidence = submission && Array.isArray(submission.evidence) ? submission.evidence : [];
// The freshest LLM summary that actually carries body text.
return (
evidence
.filter((item) => item.source === "llm" && String(item.summary || "").trim())
.slice(-1)[0] || null
);
}
let currentLlmDraftText = "";
function renderLlmDraft(submission) {
const box = document.getElementById("llm-draft");
if (!box) return;
const summary = llmSummaryFor(submission);
if (!summary) {
box.hidden = true;
box.innerHTML = "";
currentLlmDraftText = "";
return;
}
currentLlmDraftText = String(summary.summary || "");
const urls = Array.isArray(summary.sourceUrls) ? summary.sourceUrls : [];
const sourceNote = urls.length ? `연결 근거 ${urls.length}건` : "연결 근거 없음";
box.hidden = false;
box.innerHTML = `
내부 요약(초안)
${escapeHtml(sourceNote)} · 최종 판정은 운영자
${escapeHtml(currentLlmDraftText)}
`;
}
function pullLlmDraftIntoMemo() {
const memo = document.getElementById("decision-memo");
if (!memo || !currentLlmDraftText) return;
memo.value = memo.value.trim()
? `${memo.value.trim()}\n${currentLlmDraftText}`
: currentLlmDraftText;
memo.focus();
}
// --- Decision statistics / calibration ----------------------------------
const DECISION_STAT_LABELS = {
approved: "승인",
held: "보류",
rejected: "반려",
corrected: "보정",
unreviewed: "미심사",
};
const BAND_STAT_LABELS = {
high: "고위험",
medium: "중위험",
low: "저위험",
failed: "분석실패",
pending: "대기",
};
async function loadDecisionStats() {
const body = document.getElementById("stats-body");
if (!body) return;
body.innerHTML = `통계를 불러오는 중…
`;
try {
const stats = await apiJson("/api/stats/decisions");
renderDecisionStats(stats);
} catch (errorValue) {
body.innerHTML = `통계를 불러오지 못했습니다: ${escapeHtml(errorValue.message)}
`;
}
}
function calibrationTone(rate, decided) {
// Not enough decisions to judge -> neutral, don't cry miscalibration on n=1.
if (decided < 5) return "neutral";
if (rate >= 0.4) return "bad";
if (rate >= 0.2) return "warn";
return "good";
}
function renderDecisionStats(stats) {
const body = document.getElementById("stats-body");
if (!body) return;
const cal = stats.calibration || {};
const throughput = stats.throughput || { byDay: [] };
const backlog = stats.backlog || { unreviewed: 0, total: 0 };
const overTone = calibrationTone(cal.highBandApprovalRate || 0, cal.highBandDecided || 0);
const underTone = calibrationTone(cal.lowBandRejectionRate || 0, cal.lowBandDecided || 0);
const tiles = `
과탐 — 고위험인데 승인
${formatPercent(cal.highBandApprovalRate)}
고위험 판정 ${cal.highBandDecided || 0}건 기준
미탐 — 저위험인데 반려
${formatPercent(cal.lowBandRejectionRate)}
저위험 판정 ${cal.lowBandDecided || 0}건 기준
미심사 백로그
${backlog.unreviewed}
전체 ${backlog.total}건 중
누적 판정
${throughput.totalDecisions || 0}
감사 로그 기준
`;
body.innerHTML = `
${tiles}
밴드 × 판정 매트릭스
각 위험 밴드가 실제로 어떻게 판정됐는지. 대각선을 벗어난 값이 보정 오차입니다.
${renderStatsMatrix(stats)}
최근 결정 추이
최근 14일 일별 판정 건수.
${renderThroughputBars(throughput.byDay || [])}
`;
}
function renderStatsMatrix(stats) {
const bands = stats.bands || [];
const decisions = stats.decisions || [];
const matrix = stats.matrix || {};
const head = decisions.map((d) => `${escapeHtml(DECISION_STAT_LABELS[d] || d)} | `).join("");
const rows = bands
.map((band) => {
const row = matrix[band] || {};
const total = decisions.reduce((sum, d) => sum + (row[d] || 0), 0);
const cells = decisions
.map((d) => {
const value = row[d] || 0;
const strong = value > 0 ? " has-value" : "";
return `${value} | `;
})
.join("");
return `| ${escapeHtml(BAND_STAT_LABELS[band] || band)} | ${cells}${total} |
`;
})
.join("");
return `
| 밴드 \\ 판정 | ${head}합계 |
${rows}
`;
}
function renderThroughputBars(byDay) {
if (!byDay.length) {
return `아직 기록된 판정이 없습니다.
`;
}
const max = Math.max(...byDay.map((entry) => entry.count), 1);
const bars = byDay
.map((entry) => {
const height = Math.round((entry.count / max) * 100);
return `
${entry.count}
${escapeHtml(entry.day.slice(5))}
`;
})
.join("");
return `${bars}
`;
}
function formatPercent(rate) {
return `${Math.round((Number(rate) || 0) * 100)}%`;
}
async function rerunSelectedEnrichment() {
const ids = selectedBulkSubmissionIds();
const button = document.getElementById("bulk-rerun");
if (!ids.length) {
setImportStatus("재분석할 제출이 없습니다.", "bad");
return;
}
if (button) button.disabled = true;
setImportStatus(`${ids.length}건 재분석을 시작합니다.`, "warn");
try {
for (const [index, submissionId] of ids.entries()) {
setImportStatus(`${index + 1}/${ids.length} 재분석 중 · ${submissionId}`, "warn");
await apiJson(`/api/submissions/${encodeURIComponent(submissionId)}/rerun-enrichment`, {
method: "POST",
body: JSON.stringify({}),
});
}
state.selectedCaseId = ids[0];
await refreshFromApi();
setImportStatus(`${ids.length}건 재분석 완료`, "ok");
} catch (errorValue) {
// Best-effort refresh so already-rerun submissions aren't left stale in the
// UI when the loop fails partway through.
try { await refreshFromApi(); } catch (_refreshError) { /* keep original error */ }
setImportStatus(errorValue.message, "bad");
} finally {
if (button) button.disabled = false;
}
}
function resetQueueFiltersForImport() {
state.filters.risk = "all";
state.filters.source = "all";
state.filters.decision = "all";
state.filters.sort = "newest";
state.filters.query = "";
document.querySelectorAll("#risk-filter button").forEach((button) => {
button.classList.toggle("active", button.dataset.risk === "all");
});
document.getElementById("source-filter").value = "all";
document.getElementById("decision-filter").value = "all";
document.getElementById("queue-sort").value = "newest";
document.getElementById("queue-search").value = "";
}
function importedFolderStatusMessage(payload, fallbackLabel) {
return window.OperatorSubmissionImport.importedFolderStatusMessage(payload, fallbackLabel);
}
function importedSubmissionStatusMessage(payload, submissionId) {
return window.OperatorSubmissionImport.importedSubmissionStatusMessage(payload, submissionId);
}
async function reloadSubmissions() {
const button = document.getElementById("reload-submissions");
const folderInput = document.getElementById("submission-folder");
const folderPath = (folderInput?.value || "").trim();
const endpoint = folderPath ? "/api/submissions/import-folder" : "/api/submissions/reload";
const body = folderPath ? { path: folderPath } : {};
button.disabled = true;
setImportStatus("제출 폴더를 읽는 중입니다.", "warn");
try {
const payload = await apiJson(endpoint, {
method: "POST",
body: JSON.stringify(body),
});
// Snapshot the pre-reload ids BEFORE applyBootstrap replaces `submissions`,
// otherwise the imported-diff below always compares against the new list and
// is always empty (no new case is ever auto-selected).
const existingIds = new Set(submissions.map((submission) => submission.id));
applyBootstrap(payload);
if (folderPath) {
folderInput.value = "";
const importedFolderSubmissions = payload.submissions || [];
if (importedFolderSubmissions[0]) {
state.selectedCaseId = importedFolderSubmissions[0].id;
resetQueueFiltersForImport();
setImportStatus(importedFolderStatusMessage(payload, folderPath), "ok");
} else {
setImportStatus("추가된 제출 없음", "idle");
}
} else {
const importedSubmissions = (payload.submissions || []).filter((submission) => !existingIds.has(submission.id));
if (importedSubmissions.length) {
state.selectedCaseId = importedSubmissions[0].id;
resetQueueFiltersForImport();
setImportStatus(importedSubmissionStatusMessage(payload, importedSubmissions[0].id), "ok");
} else {
setImportStatus("추가된 제출 없음", "idle");
}
}
renderAll();
} catch (errorValue) {
setImportStatus(errorValue.message, "bad");
} finally {
button.disabled = false;
}
}
function updateSubmissionImageName() {
const input = document.getElementById("submission-image");
const target = document.getElementById("submission-image-name");
if (!input || !target) return;
target.textContent = input.files[0]?.name || "선택된 파일 없음";
}
async function uploadSubmissionImage() {
const input = document.getElementById("submission-image");
const button = document.getElementById("upload-submission-image");
const file = input?.files?.[0];
if (!file) {
setImportStatus("추가할 사진을 선택하세요.", "bad");
return;
}
button.disabled = true;
setImportStatus("사진을 현재 제출 폴더에 넣는 중입니다.", "warn");
try {
const image = await readKnowledgeImage(file);
const payload = await apiJson("/api/submissions/upload-image", {
method: "POST",
body: JSON.stringify({ image }),
});
applyBootstrap(payload);
input.value = "";
updateSubmissionImageName();
if (payload.uploadedSubmissionId) {
state.selectedCaseId = payload.uploadedSubmissionId;
resetQueueFiltersForImport();
switchView("workbench");
switchWorkbenchTab("evidence");
}
setImportStatus(`${payload.uploadedSubmissionId || file.name} 사진이 추가되었습니다. 새 심사 건으로 바로 선택했습니다.`, "ok");
renderAll();
} catch (errorValue) {
setImportStatus(errorValue.message, "bad");
} finally {
button.disabled = false;
}
}
async function readKnowledgeImage(file) {
return window.OperatorSubmissionImport.fileToImagePayload(file);
}
function updateKnowledgeImageName() {
const input = document.getElementById("knowledge-image");
const target = document.getElementById("knowledge-image-name");
if (!input || !target) return;
target.textContent = input.files[0]?.name || "선택된 파일 없음";
}
async function addKnowledgeEntry(event) {
event.preventDefault();
const name = document.getElementById("knowledge-name").value.trim();
const type = document.getElementById("knowledge-type").value;
const aliases = document
.getElementById("knowledge-aliases")
.value.split(",")
.map((item) => item.trim())
.filter(Boolean);
const keywords = document
.getElementById("knowledge-keywords")
.value.split(",")
.map((item) => item.trim())
.filter(Boolean);
const memo = document.getElementById("knowledge-memo").value.trim();
const imageInput = document.getElementById("knowledge-image");
const status = document.getElementById("knowledge-entry-status");
const submitButton = event.target.querySelector('button[type="submit"]');
if (!name || !memo) return;
submitButton.disabled = true;
status.textContent = "기준 DB에 저장하는 중입니다.";
try {
const image = await readKnowledgeImage(imageInput.files[0]);
const payload = await apiJson("/api/knowledge/manual", {
method: "POST",
body: JSON.stringify({ name, type, aliases, keywords, memo, image }),
});
applyBootstrap(payload);
event.target.reset();
updateKnowledgeImageName();
status.textContent = image ? "참조 이미지와 이미지 지문을 저장했습니다." : "기준 DB에 저장했습니다.";
renderAll();
} catch (errorValue) {
status.textContent = errorValue.message;
} finally {
submitButton.disabled = false;
}
}
function clearCollectionCandidatesForSearch() {
collectionCandidates.splice(0, collectionCandidates.length);
renderCollectionCandidates();
}
function clearCollectionCandidatesAfterAction() {
collectionCandidates.splice(0, collectionCandidates.length);
state.currentCollectionQuery = "";
state.currentCollectionProvider = "";
renderCollectionCandidates();
}
async function dismissCollectionCandidate(candidateId) {
const status = document.getElementById("collection-status");
if (status) status.textContent = "후보를 무시하는 중입니다.";
try {
const payload = await apiJson(`/api/collections/candidates/${encodeURIComponent(candidateId)}/dismiss`, {
method: "POST",
body: JSON.stringify({}),
});
applyBootstrap(payload);
renderAll();
if (status) status.textContent = "후보를 무시했습니다.";
} catch (errorValue) {
if (status) status.textContent = errorValue.message;
}
}
async function runCandidateCollection(event) {
event.preventDefault();
const provider = document.getElementById("collection-provider").value;
const queryInput = document.getElementById("collection-query");
const status = document.getElementById("collection-status");
const submitButton = event.target.querySelector('button[type="submit"]');
const query = queryInput.value.trim();
if (!query) {
state.currentCollectionQuery = "";
state.currentCollectionProvider = "";
renderCollectionCandidates();
status.textContent = "수집할 키워드를 입력하세요.";
return;
}
const providerConfig = providers.find((item) => item.id === provider);
if (!providerConfig || !providerConfig.enabled) {
status.textContent = `${providerLabels[provider] || provider} 외부 검색 tool이 비활성입니다.`;
return;
}
submitButton.disabled = true;
status.textContent = "키워드 후보를 수집하는 중입니다.";
try {
const payload = await apiJson("/api/collections/keyword", {
method: "POST",
body: JSON.stringify({ provider, query }),
});
// Commit the new query/provider and swap in the new candidates only after the
// request succeeds, so a failed collection doesn't wipe the previous view.
state.currentCollectionQuery = query;
state.currentCollectionProvider = provider;
applyBootstrap(payload);
renderAll();
status.textContent = `${payload.collected || 0}개후보를 수집했습니다.`;
} catch (errorValue) {
status.textContent = errorValue.message;
} finally {
submitButton.disabled = false;
}
}
function selectableCollectionCandidateInputs() {
return Array.from(document.querySelectorAll("input[data-collection-candidate-id]:not(:disabled)"));
}
function selectedCollectionCandidateIds() {
return selectableCollectionCandidateInputs()
.filter((input) => input.checked)
.map((input) => input.dataset.collectionCandidateId);
}
function setAllCollectionCandidateSelection(selected) {
const inputs = selectableCollectionCandidateInputs();
const status = document.getElementById("collection-status");
inputs.forEach((input) => {
input.checked = selected;
});
if (!inputs.length) {
status.textContent = "선택된 후보가 없습니다.";
return;
}
status.textContent = selected ? `${inputs.length}개 후보를 선택했습니다.` : "후보 선택을 모두 해제했습니다.";
}
function commaSeparatedValues(elementId) {
return document
.getElementById(elementId)
.value.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
async function promoteSelectedCollectionCandidates(event) {
event.preventDefault();
const candidateIds = selectedCollectionCandidateIds();
const status = document.getElementById("collection-status");
const submitButton = document.getElementById("promote-selected-candidates");
if (!candidateIds.length) {
status.textContent = "반영할 후보를 선택하세요.";
return;
}
submitButton.disabled = true;
status.textContent = "선택한 후보를 기준 DB에 반영하는 중입니다.";
try {
const payload = await apiJson("/api/collections/candidates/promote-batch", {
method: "POST",
body: JSON.stringify({
candidate_ids: candidateIds,
name: document.getElementById("collection-promotion-name").value.trim(),
type: document.getElementById("collection-promotion-type").value,
aliases: commaSeparatedValues("collection-promotion-aliases"),
keywords: commaSeparatedValues("collection-promotion-keywords"),
memo: document.getElementById("collection-promotion-memo").value.trim(),
}),
});
applyBootstrap(payload);
clearCollectionCandidatesAfterAction();
event.target.reset();
renderAll();
status.textContent = `${candidateIds.length}개후보를 기준 DB에 반영했습니다.`;
} catch (errorValue) {
status.textContent = errorValue.message;
} finally {
submitButton.disabled = false;
}
}
async function promoteCollectionCandidate(candidateId) {
const status = document.getElementById("collection-status");
status.textContent = "후보를 기준 DB에 반영하는 중입니다.";
try {
const payload = await apiJson(`/api/collections/candidates/${encodeURIComponent(candidateId)}/promote`, {
method: "POST",
body: JSON.stringify({}),
});
applyBootstrap(payload);
clearCollectionCandidatesAfterAction();
renderAll();
status.textContent = "후보를 기준 DB에 반영했습니다.";
} catch (errorValue) {
status.textContent = errorValue.message;
}
}
function addKnowledgeEntryFromCase() {
// The control is labelled "주의 후보 보기" (view watchlist candidates), so show
// the registered list filtered to watchlist — it previously opened the blank
// manual-registration form, which is a different action entirely.
switchView("knowledge");
switchKnowledgeTab("registered");
knowledgeFilters.status = "watchlist";
const statusFilter = document.getElementById("knowledge-status-filter");
if (statusFilter) statusFilter.value = "watchlist";
renderAll();
}
async function deactivateCorrectionEntry(correctionId) {
const correction = corrections.find((item) => item.id === correctionId);
if (!correction) return;
// Deactivate the derived knowledge entries through the real backend endpoint
// so the change is persisted and audited server-side. The previous version
// only mutated local state and appended a fake audit event, so the action was
// silently discarded on the next refreshFromApi().
// Only entries that are still active need deactivating. Skipping already-
// inactive ones avoids the server's "이미 비활성 상태입니다" 400, which used to
// abort the whole loop on the first inactive entry.
const entryIds = (correction.derivedEntries || [])
.filter((entry) => entry && entry.id && entry.active)
.map((entry) => entry.id);
if (!entryIds.length) {
showApiError("이 보정 항목에는 비활성화할 활성 파생 지식 항목이 없습니다.");
return;
}
const failures = [];
try {
for (const entryId of entryIds) {
try {
await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
method: "POST",
body: JSON.stringify({ reason: "보정 파생 항목 일괄 비활성화" }),
});
} catch (_perEntryError) {
// One entry failing must not strand the rest.
failures.push(entryId);
}
}
} finally {
// Always refresh so the UI reflects whatever was actually persisted, even on
// a partial failure. Guard it so a refresh error isn't an unhandled rejection.
try {
await refreshFromApi();
} catch (_refreshError) {
/* keep the per-entry failure summary below */
}
}
if (failures.length) {
showApiError(`일부 파생 항목 비활성화에 실패했습니다 (${failures.length}건).`);
}
}
async function toggleProvider(providerId) {
const provider = providers.find((item) => item.id === providerId);
if (!provider) return;
try {
await apiJson(`/api/providers/${encodeURIComponent(providerId)}`, {
method: "PATCH",
body: JSON.stringify({ enabled: !provider.enabled }),
});
await refreshFromApi();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function setEvidenceStatus(evidenceId, status, note = "") {
const submission = getSelectedCase();
if (!submission || !evidenceId) return;
try {
await apiJson(`/api/evidence/${encodeURIComponent(evidenceId)}/status`, {
method: "POST",
body: JSON.stringify({ submission_id: submission.id, status: evidenceStatusPayload(status), note }),
});
await refreshFromApi();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
function markEvidenceIrrelevant(evidenceId) {
const submission = getSelectedCase();
if (!submission) return;
const fallback = submission.evidence.find((item) => item.contributed);
const targetId = evidenceId || fallback?.id;
void setEvidenceStatus(targetId, "ignored", "운영상 미사용 처리");
}
async function saveKnowledgeEntryEdit(form) {
const entryId = form.dataset.knowledgeEditForm;
const body = {
aliases: form.elements.aliases.value.split(",").map((item) => item.trim()).filter(Boolean),
keywords: form.elements.keywords.value.split(",").map((item) => item.trim()).filter(Boolean),
memo: form.elements.memo.value.trim(),
};
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}`, {
method: "PATCH",
body: JSON.stringify(body),
});
editingKnowledgeEntryId = "";
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function deactivateKnowledgeEntry(entryId) {
const reason = window.prompt("비활성 사유 메모(선택)");
if (reason === null) return;
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
method: "POST",
body: JSON.stringify({ reason: reason.trim() }),
});
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function reactivateKnowledgeEntry(entryId) {
const reason = window.prompt("재활성 사유 메모(필수)") || "";
if (!reason.trim()) {
showApiError("재활성에는 사유 메모가 필요합니다.");
return;
}
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/reactivate`, {
method: "POST",
body: JSON.stringify({ reason: reason.trim() }),
});
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function promoteWatchlistEntry(entryId) {
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/promote-watchlist`, {
method: "POST",
body: JSON.stringify({}),
});
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function excludeWatchlistEntry(entryId) {
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/exclude-watchlist`, {
method: "POST",
body: JSON.stringify({ reason: "오탐 확인" }),
});
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function emergencyDisableExternalProviders() {
try {
await apiJson("/api/providers/emergency-disable", {
method: "POST",
body: JSON.stringify({}),
});
await refreshFromApi();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
function bindEvents() {
document.querySelectorAll(".nav-button").forEach((button) => {
button.addEventListener("click", () => switchView(button.dataset.view));
});
document.querySelectorAll("#risk-filter button").forEach((button) => {
button.addEventListener("click", () => {
state.filters.risk = button.dataset.risk;
document.querySelectorAll("#risk-filter button").forEach((item) => item.classList.toggle("active", item === button));
renderQueue();
});
});
document.getElementById("source-filter").addEventListener("change", (event) => {
state.filters.source = event.target.value;
renderCoverageTabs();
renderQueue();
});
document.getElementById("decision-filter").addEventListener("change", (event) => {
state.filters.decision = event.target.value;
renderQueue();
});
document.getElementById("queue-sort").addEventListener("change", (event) => {
state.filters.sort = event.target.value;
renderQueue();
});
document.getElementById("queue-search").addEventListener("input", (event) => {
state.filters.query = event.target.value;
renderQueue();
});
document.getElementById("queue-body").addEventListener("click", (event) => {
const duplicateButton = event.target.closest("[data-duplicate-group]");
if (duplicateButton) {
selectDuplicateGroup(duplicateButton.dataset.duplicateGroup);
return;
}
const selectButton = event.target.closest("[data-select-case]");
if (selectButton) {
selectCase(selectButton.dataset.selectCase);
return;
}
const row = event.target.closest("[data-row-case]");
if (row && !event.target.matches("input")) {
selectCase(row.dataset.rowCase);
}
});
// Keep the batch-decision counter in step with the row checkboxes.
document.getElementById("queue-body").addEventListener("change", (event) => {
if (event.target.matches("input[data-bulk-id]")) {
renderBulkDecisionCount();
}
});
document.getElementById("coverage-tabs").addEventListener("click", (event) => {
const tab = event.target.closest("[data-coverage-filter]");
if (!tab) return;
applyCoverageFilter(tab.dataset.coverageFilter);
});
document.querySelectorAll("[data-workbench-tab]").forEach((button) => {
button.addEventListener("click", () => switchWorkbenchTab(button.dataset.workbenchTab));
});
document.querySelectorAll("[data-knowledge-tab]").forEach((button) => {
button.addEventListener("click", () => switchKnowledgeTab(button.dataset.knowledgeTab));
});
document.querySelectorAll("[data-decision]").forEach((button) => {
button.addEventListener("click", () => {
const requiresMemo = button.dataset.requiresMemo === "true";
setDecision(button.dataset.decision, requiresMemo);
});
});
document.getElementById("manual-query-form").addEventListener("submit", runManualSearch);
document.getElementById("candidate-collection-form").addEventListener("submit", runCandidateCollection);
document.getElementById("collection-promotion-form").addEventListener("submit", promoteSelectedCollectionCandidates);
document.getElementById("select-all-candidates").addEventListener("click", () => setAllCollectionCandidateSelection(true));
document.getElementById("clear-selected-candidates").addEventListener("click", () => setAllCollectionCandidateSelection(false));
document.getElementById("knowledge-form").addEventListener("submit", addKnowledgeEntry);
document.getElementById("knowledge-search").addEventListener("input", (event) => {
knowledgeFilters.query = event.target.value;
renderKnowledgeBase();
});
[
["knowledge-type-filter", "type"],
["knowledge-status-filter", "status"],
["knowledge-active-filter", "active"],
].forEach(([elementId, key]) => {
document.getElementById(elementId).addEventListener("change", (event) => {
knowledgeFilters[key] = event.target.value;
renderKnowledgeBase();
});
});
document.addEventListener("submit", (event) => {
const editFormElement = event.target.closest("[data-knowledge-edit-form]");
if (!editFormElement) return;
event.preventDefault();
void saveKnowledgeEntryEdit(editFormElement);
});
document.getElementById("knowledge-image").addEventListener("change", updateKnowledgeImageName);
document.getElementById("reload-submissions").addEventListener("click", reloadSubmissions);
document.getElementById("submission-image").addEventListener("change", updateSubmissionImageName);
document.getElementById("upload-submission-image").addEventListener("click", uploadSubmissionImage);
document.getElementById("rerun-enrichment").addEventListener("click", rerunEnrichment);
document.getElementById("add-from-case").addEventListener("click", addKnowledgeEntryFromCase);
document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant());
document.getElementById("disable-derived").addEventListener("click", () => {
// Scope the deactivation to the currently-selected case. Picking the first
// automatic entry globally could deactivate an entry that belongs to a
// different submission entirely.
const selected = getSelectedCase();
const automaticEntry = selected
? knowledgeEntries.find(
(entry) =>
entry.provenance === "automatic" &&
entry.active &&
(entry.entryStatus || "confirmed") === "confirmed" &&
entry.sourceSubmissionId === selected.id,
)
: undefined;
if (automaticEntry) {
void deactivateKnowledgeEntry(automaticEntry.id);
} else {
showApiError("이 케이스에서 비활성화할 자동 지식 항목이 없습니다.");
}
});
// Image viewer controls. Without handlers these were dead buttons; they now
// adjust the case image (fit / actual size / rotate) with state on the element
// so renderCaseReview can reset them per case.
document.getElementById("fit-image").addEventListener("click", () => {
const image = document.getElementById("case-image");
if (image) image.style.objectFit = "contain";
});
document.getElementById("actual-image").addEventListener("click", () => {
const image = document.getElementById("case-image");
if (image) image.style.objectFit = "none";
});
document.getElementById("rotate-image").addEventListener("click", () => {
const image = document.getElementById("case-image");
if (!image) return;
const next = ((Number(image.dataset.rotation || "0") + 90) % 360 + 360) % 360;
image.dataset.rotation = String(next);
image.style.transform = `rotate(${next}deg)`;
});
document.getElementById("open-correction").addEventListener("click", () => {
switchView("knowledge");
switchKnowledgeTab("corrections");
});
document.getElementById("bulk-rerun").addEventListener("click", rerunSelectedEnrichment);
document.querySelectorAll("[data-bulk-decision]").forEach((button) => {
button.addEventListener("click", () => applyBulkDecision(button.dataset.bulkDecision));
});
document.getElementById("export-decisions").addEventListener("click", exportDecisions);
document.getElementById("create-backup").addEventListener("click", createBackupSnapshot);
document.getElementById("clear-reference").addEventListener("click", registerClearedReference);
document.getElementById("llm-draft").addEventListener("click", (event) => {
if (event.target.closest("#llm-draft-use")) pullLlmDraftIntoMemo();
});
document.getElementById("memo-presets").addEventListener("click", (event) => {
if (event.target.closest("#memo-preset-save")) {
void saveCurrentMemoAsPreset();
return;
}
const chip = event.target.closest("[data-preset-id]");
if (chip) applyMemoPreset(chip.dataset.presetId);
});
document.addEventListener("keydown", handleReviewShortcut);
document.getElementById("emergency-disable").addEventListener("click", emergencyDisableExternalProviders);
document.getElementById("app-main").addEventListener("click", (event) => {
const providerButton = event.target.closest("[data-toggle-provider]");
if (providerButton) {
toggleProvider(providerButton.dataset.toggleProvider);
return;
}
const retryButton = event.target.closest("[data-provider-retry]");
if (retryButton) {
// No provider-wide retry endpoint exists. Tell the operator honestly
// instead of writing a fake "Provider called" audit event that never
// re-ran anything (which falsely implied the failures were retried).
showApiError("제공자 단위 '실패 재시도'는 아직 서버에 구현되지 않았습니다. 개별 케이스의 '재분석'을 사용하세요.");
return;
}
const correctionButton = event.target.closest("[data-deactivate-correction]");
if (correctionButton) {
void deactivateCorrectionEntry(correctionButton.dataset.deactivateCorrection);
return;
}
const editKnowledgeButton = event.target.closest("[data-edit-kb]");
if (editKnowledgeButton) {
editingKnowledgeEntryId = editingKnowledgeEntryId === editKnowledgeButton.dataset.editKb ? "" : editKnowledgeButton.dataset.editKb;
renderKnowledgeBase();
return;
}
const cancelEditKnowledgeButton = event.target.closest("[data-cancel-edit-kb]");
if (cancelEditKnowledgeButton) {
editingKnowledgeEntryId = "";
renderKnowledgeBase();
return;
}
const deactivateKnowledgeButton = event.target.closest("[data-deactivate-kb]");
if (deactivateKnowledgeButton) {
void deactivateKnowledgeEntry(deactivateKnowledgeButton.dataset.deactivateKb);
return;
}
const reactivateKnowledgeButton = event.target.closest("[data-reactivate-kb]");
if (reactivateKnowledgeButton) {
void reactivateKnowledgeEntry(reactivateKnowledgeButton.dataset.reactivateKb);
return;
}
const evidenceStatusButton = event.target.closest("[data-evidence-status]");
if (evidenceStatusButton) {
setEvidenceStatus(evidenceStatusButton.dataset.evidenceId, evidenceStatusButton.dataset.evidenceStatus);
return;
}
const suggestedQueryButton = event.target.closest("[data-suggested-query]");
if (suggestedQueryButton) {
applySuggestedQuery(suggestedQueryButton);
return;
}
const promoteWatchlistButton = event.target.closest("[data-promote-watchlist]");
if (promoteWatchlistButton) {
promoteWatchlistEntry(promoteWatchlistButton.dataset.promoteWatchlist);
return;
}
const excludeWatchlistButton = event.target.closest("[data-exclude-watchlist]");
if (excludeWatchlistButton) {
excludeWatchlistEntry(excludeWatchlistButton.dataset.excludeWatchlist);
return;
}
const promoteCandidateButton = event.target.closest("[data-promote-candidate]");
if (promoteCandidateButton) {
promoteCollectionCandidate(promoteCandidateButton.dataset.promoteCandidate);
return;
}
const dismissCandidateButton = event.target.closest("[data-dismiss-candidate]");
if (dismissCandidateButton) {
void dismissCollectionCandidate(dismissCandidateButton.dataset.dismissCandidate);
return;
}
const runSuggestedButton = event.target.closest("[data-run-suggested-query]");
if (runSuggestedButton) {
void executeSuggestedQueries([runSuggestedButton.dataset.runSuggestedQuery]);
return;
}
const runAllSuggestedButton = event.target.closest("#run-all-suggested-queries");
if (runAllSuggestedButton) {
void executeSuggestedQueries(suggestedEvidenceQueries(getSelectedCase()));
return;
}
const rerunQueryButton = event.target.closest("[data-rerun-query]");
if (rerunQueryButton) {
rerunHistoricalQuery(rerunQueryButton);
}
});
}
async function init() {
const hashView = window.location.hash.replace("#", "");
if (viewNames.has(hashView)) {
state.currentView = hashView;
} else if (hashView === "case" || hashView === "evidence") {
state.currentView = "workbench";
} else if (hashView === "corrections") {
state.currentView = "knowledge";
state.knowledgeTab = "corrections";
}
bindEvents();
try {
await refreshFromApi();
} catch (errorValue) {
clearRuntimeData();
showApiError(`API 연결 실패: ${errorValue.message}`);
renderAll();
}
switchView(state.currentView, false);
switchWorkbenchTab(state.workbenchTab);
switchKnowledgeTab(state.knowledgeTab);
}
document.addEventListener("DOMContentLoaded", () => {
void init();
});
const __uiCopyCompatibility = {
immediateMove: "바로 편입",
reasonPresent: "근거 있음",
noResult: "결과 없음",
notRun: "미실행",
evidenceUsed: "판단에 사용",
falsePositive: "오탐",
externalSearchTool: "외부 검색 tool",
similarImage: "이미지 유사도",
showMore: "자세히 보기",
faceWebReason: "얼굴 영역 웹 근거",
searchImageResult: "검색 이미지 결과",
samePerson: "동일인 판정이 아닙니다",
watchlistNote: "주의 후보 근거",
evidenceUsedStatus: "사용",
evidenceIgnoredStatus: "미사용",
usedForJudgmentTuple: ["used_for_judgment", "사용"],
ignoredTuple: ["ignored", "미사용"],
pageRepresentativeImage: "페이지 대표 이미지",
confidenceLabel: "신뢰도",
queryStrategyByTitle: "구글 페이지 제목 기반",
evidenceId: "근거 ID",
queryStrategyBySubmission: "제출 제목/파일명 기반",
sampleFingerprints: "샘플 지문",
};
window.addEventListener("hashchange", () => switchView(window.location.hash.replace("#", ""), false));