POSA_Copyrighter/web/operator-gui/app.js

3882 lines
95 KiB
JavaScript

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 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", "audit"]);
async function apiJson(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || `API 요청 실패: ${response.status}`);
}
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) {
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);
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 `
<button
class="coverage-tab ${tone} ${active ? "active" : ""}"
type="button"
data-coverage-filter="${escapeHtml(filter)}"
title="${escapeHtml(title || label)}"
aria-pressed="${active ? "true" : "false"}"
>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</button>
`;
}
function renderCoverageTabs() {
const target = document.getElementById("coverage-tabs");
if (!target) return;
const summaries = searchCoverage.submissions || {};
const queries = searchCoverage.queries || {};
const providers = (searchCoverage.providers || []).filter((provider) => !retiredProviderIds.has(provider.id));
const total = Number(summaries.total || submissions.length || 0);
const coverageRate = coveragePercent(summaries.coverageSubmissions || 0, total);
const coverageStatus = coverageTone(coverageRate, "coverage");
const failedQueries = Number(queries.failed || 0);
const providerTabs = [...providers]
.sort((left, right) => (right.querySubmissions || 0) - (left.querySubmissions || 0))
.map((provider) => {
const filter = provider.id && provider.id.includes("google") ? "google" : provider.id || "all";
const queryCount = Number(provider.queryEntries || 0);
const evidenceCount = Number(provider.evidenceSubmissions || 0);
return coverageTabButton({
filter,
label: provider.name || provider.id || "provider",
value: `${queryCount} / ${evidenceCount}`,
tone: evidenceCount ? "ok" : queryCount ? "warn" : "idle",
title: `쿼리 ${queryCount}, 근거 보유 케이스 ${evidenceCount}`,
});
})
.join("");
target.innerHTML = `
${coverageTabButton({ filter: "all", label: "전체", value: `${total}` })}
${coverageTabButton({
filter: "covered",
label: "근거 보유",
value: `${summaries.coverageSubmissions || 0}/${total}`,
tone: coverageStatus,
title: coverageToneLabelConfig("coverage"),
})}
${providerTabs || coverageTabButton({ filter: "all", label: "외부 검색 tool", value: "0" })}
${coverageTabButton({ filter: "failure", label: "실패", value: `${failedQueries}`, tone: failedQueries ? "bad" : "idle" })}
`;
}
function applyCoverageFilter(filter) {
state.filters.source = filter || "all";
const sourceSelect = document.getElementById("source-filter");
sourceSelect.value = [...sourceSelect.options].some((option) => option.value === state.filters.source) ? state.filters.source : "all";
renderCoverageTabs();
renderQueue();
}
function showApiError(message) {
state.apiError = message;
const target = document.getElementById("queue-health");
if (target) target.textContent = message;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function formatReason(reason) {
const text = String(reason || "");
if (!text) return "";
if (text === "Naver search result found") return "네이버 검색 결과 발견";
if (text === "Naver search returned no results") return "네이버 검색 결과 없음";
if (text === "Naver blog search result found") return "네이버 블로그 검색 결과 발견";
if (text === "Naver blog search returned no results") return "네이버 블로그 검색 결과 없음";
if (text === "Naver web search result found") return "네이버 웹문서 검색 결과 발견";
if (text === "Naver web search returned no results") return "네이버 웹문서 검색 결과 없음";
if (text === "Google custom image search result found") return "구글 이미지 검색 결과 발견";
if (text === "Google custom image search returned no results") return "구글 이미지 검색 결과 없음";
if (text === "Google custom web search result found") return "구글 웹 검색 결과 발견";
if (text === "Google custom web search returned no results") return "구글 웹 검색 결과 없음";
if (text.startsWith("Google face crop web evidence: ")) {
return `얼굴 영역 웹 근거: ${formatReason(text.replace("Google face crop web evidence: ", ""))}`;
}
if (text.startsWith("Web entity matched ")) return `웹 엔티티 일치: ${text.replace("Web entity matched ", "")}`;
if (text.startsWith("Google weak label ")) return `구글 약한 라벨 ${text.replace("Google weak label ", "")}`;
if (text.startsWith("Best guess label ")) return `구글 추정 라벨 ${text.replace("Best guess label ", "")}`;
if (text === "LLM summary has no source evidence") return "내부 요약에 연결 근거 없음";
if (text === "LLM summary generated") return "내부 요약 생성";
if (text.startsWith("LLM assistance failed")) return text.replace("LLM assistance failed", "내부 요약 실패");
if (text.includes("Google Web Detection timeout")) return "구글 이미지 감지 시간 초과";
if (text === "local submission") return "로컬 제출 이미지";
if (text.startsWith("naver.example / rank ")) return text.replace("naver.example / rank ", "네이버 예시 / 순위 ");
if (text.startsWith("google.example / page")) return "구글 예시 / 페이지";
if (text.startsWith("prior rejection / ")) return text.replace("prior rejection / ", "이전 반려 / ");
return text
.replaceAll("Google Web Detection", "구글 이미지 감지")
.replaceAll("Google", "구글")
.replaceAll("Naver", "네이버")
.replaceAll("google", "구글")
.replaceAll("naver", "네이버")
.replaceAll("internal", "내부")
.replaceAll("local", "로컬")
.replaceAll("submission", "제출")
.replaceAll("page", "페이지")
.replaceAll("weak", "약함")
.replaceAll("LLM", "내부 요약")
.replaceAll("confidence", "신뢰도")
.replaceAll("sourceEvidenceIds", "근거 ID")
.replaceAll("rank", "순위");
}
function formatEvidenceTitle(evidence) {
return formatReason(evidence.title || evidence.reason || "");
}
function formatProviderStatus(status) {
return providerStatusLabels[status] || status;
}
function formatProviderName(provider) {
return providerLabels[provider.id] || provider.name || provider.id;
}
function formatQueueProviderStatus(provider, status) {
const providerNames = {
internal: "내부",
naver: "네이버",
naver_blog: "블로그",
naver_web: "웹문서",
google: "구글",
google_search: "구글",
llm: "요약",
};
const statusNames = {
ok: "정상",
covered: "근거 있음",
empty: "없음",
failed: "실패",
disabled: "중지",
skipped: "미실행",
pending: "미실행",
not_run: "미실행",
};
return `${providerNames[provider] || providerLabels[provider] || provider} ${statusNames[status] || formatProviderStatus(status)}`;
}
function visibleProviderStateEntries(providerState) {
return Object.entries(providerState || {}).filter(([provider]) => !retiredProviderIds.has(provider));
}
function renderOperatorSearchProviderOptions() {
const optionHtml = operatorSearchProviders
.map((provider) => {
const runtime = providers.find((item) => item.id === provider.id);
const unavailable = Boolean(runtime) && !runtime.enabled;
const label = unavailable ? `${provider.label} (비활성)` : provider.label;
const reason = unavailable ? String(runtime.lastFailure || runtime.compliance || "외부 검색 tool 미연결") : "";
return `<option value="${escapeHtml(provider.id)}" ${unavailable ? "disabled" : ""} title="${escapeHtml(reason)}">${escapeHtml(label)}</option>`;
})
.join("");
["manual-query-provider", "collection-provider"].forEach((elementId) => {
const select = document.getElementById(elementId);
if (!select) return;
const current = select.value;
select.innerHTML = optionHtml;
select.value = operatorSearchProviders.some((provider) => provider.id === current) ? current : operatorSearchProviders[0].id;
if (select.selectedOptions[0] && select.selectedOptions[0].disabled) {
select.value = operatorSearchProviders[0].id;
}
});
}
function formatCandidateStatus(status) {
return candidateStatusLabels[status] || status;
}
function formatCandidateSourceType(sourceType) {
const labels = {
search_result_image: "검색 이미지 결과",
provider_page_image: "페이지 대표 이미지",
html_page_image: "페이지 대표 이미지",
};
return labels[sourceType] || sourceType || "검색 이미지 결과";
}
function formatQueryStatus(status) {
return window.OperatorSearch.formatQueryStatus(status);
}
function formatQueryStrategy(strategy) {
return window.OperatorSearch.formatQueryStrategy(strategy);
}
function formatEvidenceStatus(status) {
return evidenceStatusLabels[normalizeEvidenceOperatorStatus(status)] || status || "자동 판단";
}
function normalizeEvidenceOperatorStatus(status) {
if (status === "used_for_judgment") return "used_for_judgment";
const ignoredStatuses = new Set();
ignoredStatuses.add("irrelevant");
ignoredStatuses.add("false_positive");
ignoredStatuses.add("ignored");
if (ignoredStatuses.has(status)) return "ignored";
return status || "";
}
function evidenceStatusPayload(status) {
return status === "ignored" ? "false_positive" : status;
}
function formatKnowledgeStatus(status) {
return knowledgeStatusLabels[status || "confirmed"] || status;
}
function formatKnowledgeType(type) {
return knowledgeTypeLabels[type] || type;
}
function formatProvenance(provenance) {
return provenanceLabels[provenance] || provenance;
}
function formatMatchType(matchType) {
const labels = {
full: "동일 이미지",
partial: "부분 일치",
page: "포함 페이지",
visual: "유사 이미지",
weak_label: "약한 라벨",
entity: "엔티티",
google_best_guess: "구글 추정 라벨",
google_face_crop_page: "얼굴 영역 웹페이지",
google_face_crop_entity: "얼굴 영역 엔티티",
empty: "결과 없음",
};
labels.search_result_image = "검색 결과 이미지 지목";
labels.search_result_page_image = "검색 결과 페이지 이미지 지목";
return labels[matchType] || matchType;
}
function formatFactLabel(label) {
const labels = {
size: "크기",
format: "형식",
submitted: "제출",
analysis: "분석",
};
return labels[label] || formatReason(label);
}
function formatDomain(domain) {
if (!domain) return "내부";
return formatReason(domain);
}
function formatQueueTimestamp(value) {
return value || "-";
}
function getSelectedCase() {
return submissions.find((item) => item.id === state.selectedCaseId) || submissions[0];
}
function riskClass(riskBand) {
return `risk-${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
? `<div class="inline-status" id="suggested-query-run-result">${escapeHtml(suggestedQueryRunSummary.message)}</div>`
: "";
if (!evidenceNeedsFollowup(submission)) return summary;
const queries = suggestedEvidenceQueries(submission);
const reasons = evidenceFollowupReasons(submission);
if (!queries.length) return summary;
return `
<section class="evidence-next-action-panel" aria-label="근거 보강 추천">
<div>
<strong>근거 보강 추천</strong>
<span>추천 쿼리를 바로 실행하거나, 쿼리 본문을 눌러 수동 검색 입력칸에서 수정할 수 있습니다.</span>
</div>
<ul class="evidence-followup-reasons">
${reasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}
</ul>
<div class="suggested-query-list">
${queries
.map(
(query) => `
<span class="suggested-query-item">
<button class="row-action" type="button" data-suggested-query="${escapeHtml(query)}" data-suggested-provider="naver">
${escapeHtml(query)}
</button>
<button class="row-action" type="button" data-run-suggested-query="${escapeHtml(query)}">바로 실행</button>
</span>
`,
)
.join("")}
</div>
<div class="suggested-query-actions">
<button class="row-action" type="button" id="run-all-suggested-queries">모두 실행</button>
</div>
<div id="suggested-query-run-status" class="inline-status" aria-live="polite"></div>
${summary}
</section>
`;
}
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 }),
});
const historyEntry = (review.queryHistory || [])[0];
const resultCount = historyEntry && historyEntry.query === query ? Number(historyEntry.count || 0) : 0;
const skipped =
resultCount === 0
? (review.evidence || []).find((item) => item.source === "failure" && item.query === query)
: null;
results.push(skipped ? `"${query}" ${formatReason(skipped.title || "건너뜀")}` : `"${query}" 완료(근거 ${resultCount}건)`);
} catch (errorValue) {
results.push(`"${query}" 실패: ${errorValue.message}`);
}
}
suggestedQueryRunSummary = {
caseId: submission.id,
message: `추천 쿼리 실행 — ${results.join(" · ")}`,
};
await refreshFromApi();
} 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
? `
<span class="status-dot warn" aria-hidden="true"></span>
<span>${escapeHtml(state.apiError)}</span>
`
: `
<span class="status-dot ${rows.some((item) => item.riskBand === "failed") ? "warn" : "ok"}" aria-hidden="true"></span>
<span>${rows.length}건 / 고위험 ${submissions.filter((item) => item.riskBand === "high").length}${queueMeta}</span>
`;
if (!rows.length) {
body.innerHTML = `
<tr>
<td colspan="9">
<div class="empty-state">현재 필터에 맞는 심사 건이 없습니다.</div>
</td>
</tr>
`;
return;
}
body.innerHTML = rows
.map((submission) => {
const providerHtml = visibleProviderStateEntries(submission.providerState)
.map(
([provider, status]) => {
const fullLabel = `${providerLabels[provider] || provider} ${formatProviderStatus(status)}`;
return `<span class="provider-chip queue-provider-chip ${providerClass(status)}" title="${escapeHtml(fullLabel)}">${escapeHtml(formatQueueProviderStatus(provider, status))}</span>`;
},
)
.join("");
const reasonHtml = submission.reasons
.slice(0, 2)
.map((reason) => `<span title="${escapeHtml(formatReason(reason))}">${escapeHtml(formatReason(reason))}</span>`)
.join("");
const submittedTime = formatQueueTimestamp(submission.submittedAt);
return `
<tr class="queue-row ${submission.id === state.selectedCaseId ? "selected-row" : ""}" data-row-case="${escapeHtml(submission.id)}">
<td><input type="checkbox" aria-label="${escapeHtml(submission.id)} 선택" data-bulk-id="${escapeHtml(submission.id)}"></td>
<td><img class="thumb" src="${escapeHtml(submission.asset)}" alt="${escapeHtml(submission.title)} 썸네일"></td>
<td>
<div class="queue-submission-cell">
<button class="row-action queue-case-button" type="button" data-select-case="${escapeHtml(submission.id)}" title="${escapeHtml(submission.id)}">
<span class="submission-id">${escapeHtml(submission.id)}</span>
</button>
</div>
</td>
<td>
<div class="score-stack queue-risk-cell" title="${escapeHtml(submission.lastAnalysis)}">
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]} ${submission.riskScore}</span>
</div>
</td>
<td><div class="reason-cell">${reasonHtml}</div></td>
<td><div class="provider-strip queue-provider-strip">${providerHtml}</div></td>
<td>${escapeHtml(submission.applicantStatus)}</td>
<td><span class="decision-badge">${decisionLabels[submission.decisionStatus]}</span></td>
<td>
<div class="queue-time-cell" title="${escapeHtml(submission.fileFacts.submitted)}">
<strong>${escapeHtml(submittedTime)}</strong>
</div>
</td>
</tr>
`;
})
.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 = `
<span class="status-dot ${tone}" aria-hidden="true"></span>
<span>${escapeHtml(state.importStatus.message)}</span>
`;
}
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 = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`;
document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`;
document.getElementById("derived-preview").innerHTML = `<span class="muted">자동 후보 생성 없음</span>`;
}
function renderCaseReview() {
const submission = getSelectedCase();
if (!submission) {
renderNoSelectedCase();
return;
}
document.getElementById("case-title").textContent = `${submission.id} · ${submission.title}`;
document.getElementById("case-image").src = submission.asset;
document.getElementById("case-image").alt = `${submission.id} 제출 이미지`;
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]) => `<div class="fact-item"><span>${escapeHtml(formatFactLabel(label))}</span><strong>${escapeHtml(value)}</strong></div>`)
.join("");
document.getElementById("similar-strip").innerHTML = submission.similar
.map(
(item) => `
<div class="similar-item">
<img src="${escapeHtml(item.asset)}" alt="${escapeHtml(item.label)}">
<span>${escapeHtml(formatReason(item.label))}</span>
</div>
`,
)
.join("");
document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
.map(
(crop) => `
<a class="similar-item face-crop-item face-crop-link" href="${escapeHtml(crop.url)}" target="_blank" rel="noreferrer" aria-label="얼굴 영역 ${escapeHtml(String(crop.index))} 크게 보기">
<img src="${escapeHtml(crop.url)}" alt="얼굴 영역 ${escapeHtml(String(crop.index))} 크롭">
<span>얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다</span>
</a>
`,
)
.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 = `
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]}</span>
<strong>${escapeHtml(submission.recommendation.label)}</strong>
<span class="muted">${escapeHtml(formatReason(submission.recommendation.detail))}</span>
<span class="muted small">현재 운영 결정: ${decisionLabels[submission.decisionStatus]}</span>
`;
document.getElementById("derived-preview").innerHTML = `
<strong>${submission.derivedPreview.automatic ? "자동 후보 생성 예정" : "자동 이력 없음"}</strong>
<span>${escapeHtml(submission.derivedPreview.entryName)}</span>
<span class="muted small">${escapeHtml(submission.derivedPreview.effect)}</span>
`;
}
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: "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
? `
<details class="rerun-removed">
<summary>이번 재분석에서 제거됨 · ${removedSummaries.length}개</summary>
<ul>
${removedSummaries
.map((item) => `<li>${escapeHtml(sourceLabels[item.source] || item.source || "내부")} · ${escapeHtml(formatReason(item.reason || ""))}</li>`)
.join("")}
</ul>
</details>
`
: "";
return `
<section class="rerun-diff-panel" aria-label="재분석 변경 요약">
<strong>재분석 ${escapeHtml(diff.at || "")}</strong>
<span>점수 ${escapeHtml(String(scoreBefore))}${escapeHtml(String(scoreAfter))} (${escapeHtml(direction)})</span>
<span>신규 증거 ${(diff.addedEvidenceIds || []).length}개</span>
${removedBlock}
</section>
`;
}
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 `
<section class="evidence-summary-board" aria-label="판단 요약">
<div class="summary-lead">
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]} ${submission.riskScore}</span>
<div>
<strong>${escapeHtml(primaryJudgment)}</strong>
<span>${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거</span>
</div>
</div>
<div class="summary-stat-grid">
${summaryCards
.map(
([label, value]) => `
<div class="summary-stat ${value ? "has-signal" : ""}">
<span>${escapeHtml(label)}</span>
<strong>${value}</strong>
</div>
`,
)
.join("")}
</div>
</section>
`;
}
function renderEvidenceGroups(submission) {
if (!submission.evidence.length) {
return `<div class="empty-state">아직 분석 증거가 없습니다. 외부 검색 도구 상태를 확인하고 증거 재수집을 실행하세요.</div>`;
}
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 `
<section class="evidence-group evidence-group-${escapeHtml(group.tone)}" aria-label="${escapeHtml(group.label)} 증거">
<div class="evidence-group-head">
<div>
<h3>${escapeHtml(group.label)}</h3>
<span>${escapeHtml(group.description)}</span>
</div>
<strong>${items.length}개</strong>
</div>
<div class="evidence-card-grid">
${topItems.map(renderEvidenceRow).join("")}
</div>
${
detailItems.length
? `
<details class="evidence-details">
<summary>자세히 보기 · ${detailItems.length}개</summary>
<div class="evidence-card-grid evidence-card-grid-detail">
${detailItems.map(renderEvidenceRow).join("")}
</div>
</details>
`
: ""
}
</section>
`;
}
function renderEvidenceRow(evidence) {
const isNewFromRerun = activeRerunAddedIds.has(evidence.id);
const newChip = isNewFromRerun ? `<span class="evidence-new-chip">신규</span>` : "";
const sourceEvidenceIds = evidence.sourceEvidenceIds || [];
const sourceIds = sourceEvidenceIds.length
? `근거 ID: ${sourceEvidenceIds.join(", ")}`
: "근거 ID 없음";
const contribution = evidence.contributed ? "점수 반영" : "참고";
const normalizedOperatorStatus = normalizeEvidenceOperatorStatus(evidence.operatorStatus);
const operatorStatus = normalizedOperatorStatus
? `<span class="evidence-status-chip">${escapeHtml(formatEvidenceStatus(normalizedOperatorStatus))}</span>`
: "";
const confidence = typeof evidence.confidence === "number" ? evidence.confidence.toFixed(2) : evidence.confidence;
const matchType = evidence.matchType ? `<span>${escapeHtml(formatMatchType(evidence.matchType))}</span>` : "";
const pageTitle = evidence.pageTitle ? `<span>${escapeHtml(evidence.pageTitle)}</span>` : "";
const faceCropMeta = evidence.faceCropSearch
? `<span>얼굴 영역 ${escapeHtml(evidence.cropIndex || "")}</span><span>${escapeHtml(evidence.privacyNote || "동일 인물 판정은 아닙니다")}</span>`
: "";
const watchlistMeta =
evidence.knowledgeEntryStatus === "watchlist"
? `<span>주의 후보: ${escapeHtml(evidence.knowledgeEntryName || evidence.knowledgeEntryId || "-")}</span><span>원 케이스 ${escapeHtml(evidence.sourceSubmissionId || "-")}</span>`
: "";
const providerScore =
evidence.providerScore !== undefined && evidence.providerScore !== null && evidence.providerScore !== "" ? `<span>검색 도구 점수 ${escapeHtml(evidence.providerScore)}</span>` : "";
const similarity =
evidence.similarity !== undefined && evidence.similarity !== ""
? `<span>이미지 유사도 ${escapeHtml(Number(evidence.similarity).toFixed(2))}</span>`
: "";
const queryStrategy = evidence.queryStrategy ? `<span>${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}</span>` : "";
const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
const searchType = evidence.searchType ? `<span>${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}</span>` : "";
const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
const preview = renderEvidencePreview(evidence);
const link = renderEvidenceLink(evidence);
const statusButtons = [
["used_for_judgment", "사용"],
["ignored", "미사용"],
]
.map(
([status, label]) => `
<button class="row-action ${normalizedOperatorStatus === status ? "active" : ""}" type="button" data-evidence-status="${status}" data-evidence-id="${escapeHtml(evidence.id)}">
${label}
</button>
`,
)
.join("");
return `
<article class="evidence-row ${hasPreview ? "" : "no-preview"} ${isNewFromRerun ? "evidence-row-new" : ""}" data-evidence-id="${escapeHtml(evidence.id)}">
${preview}
<div class="evidence-main">
<div class="evidence-title">
<span class="source-chip ${sourceClass(evidence.source)}">${escapeHtml(sourceLabels[evidence.source] || evidence.source)}</span>
${newChip}
<span>${escapeHtml(formatEvidenceTitle(evidence))}</span>
</div>
<div class="evidence-meta">
<span>신뢰도 ${escapeHtml(confidence)}</span>
<span>${escapeHtml(contribution)}</span>
${operatorStatus}
${matchType}
${faceCropMeta}
${watchlistMeta}
${providerScore}
${similarity}
${queryStrategy}
${searchType}
${pageTitle}
<span>${escapeHtml(evidence.query || "쿼리 없음")}</span>
<span>${escapeHtml(formatDomain(evidence.domain))}</span>
<span>${escapeHtml(evidence.retrievedAt)}</span>
<span>${escapeHtml(sourceIds)}</span>
</div>
${link}
</div>
<div class="evidence-actions">
${statusButtons}
</div>
</article>
`;
}
function renderEvidencePreview(evidence) {
const previewUrl = evidence.thumbnailUrl || evidence.imageUrl;
const targetUrl = evidence.imageUrl || evidence.url || previewUrl;
if (!previewUrl) return `<div class="evidence-preview evidence-preview-empty" aria-hidden="true"></div>`;
return `
<a class="evidence-preview" href="${escapeHtml(targetUrl)}" target="_blank" rel="noreferrer" aria-label="검색 이미지 열기">
<img src="${escapeHtml(previewUrl)}" alt="">
</a>
`;
}
function renderEvidenceLink(evidence) {
const url = evidence.url || evidence.imageUrl || "";
if (!url) return "";
const label = evidence.pageTitle || evidence.url || evidence.imageUrl;
return `
<a class="evidence-link" href="${escapeHtml(url)}" target="_blank" rel="noreferrer">
${escapeHtml(label)}
</a>
`;
}
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 = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
results.innerHTML = `<div class="empty-state">API 연결 후 검색 근거를 확인할 수 있습니다.</div>`;
return;
}
document.getElementById("query-case-label").textContent = submission.id;
queryHistory.innerHTML = submission.queryHistory.length
? submission.queryHistory
.map(
(query) => `
<article class="query-row">
<div class="row-title">
<span class="source-chip ${sourceClass(query.provider)}">${escapeHtml(providerLabels[query.provider] || query.provider)}</span>
<span>${escapeHtml(query.query)}</span>
</div>
<div class="row-meta">
<span>${escapeHtml(formatQueryStatus(query.status))}</span>
${query.strategy ? `<span>${escapeHtml(formatQueryStrategy(query.strategy))}</span>` : ""}
${query.source ? `<span>출처 ${escapeHtml(query.source)}</span>` : ""}
<span>${escapeHtml(query.timestamp)}</span>
<span>${query.count}건</span>
</div>
<button class="row-action" type="button" data-rerun-query="${escapeHtml(query.query)}" data-rerun-provider="${escapeHtml(query.provider || "")}">쿼리 재실행</button>
</article>
`,
)
.join("")
: `<div class="empty-state">아직 실행한 검색 쿼리가 없습니다.</div>`;
activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
const searchableEvidence = searchableEvidenceItems(submission);
results.innerHTML = searchableEvidence.length
? renderEvidenceGroups({ ...submission, evidence: searchableEvidence })
: `<div class="empty-state">현재 케이스에는 검색 결과가 없습니다.</div>`;
}
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"
? `<span>원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}</span><span>미래 검출 ${escapeHtml(entry.contributionCount || 0)}건</span>`
: "";
const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `<span>${escapeHtml(entry.memo)}</span>` : "";
const memoBlock = memoInline ? "" : `<p class="muted small">${escapeHtml(entry.memo)}</p>`;
const lifecycleActions =
entryStatus === "watchlist"
? `
<button class="row-action" type="button" data-promote-watchlist="${escapeHtml(entry.id)}">확정 DB 반영</button>
<button class="row-action danger" type="button" data-exclude-watchlist="${escapeHtml(entry.id)}">오탐 제외</button>
`
: entryStatus === "excluded"
? ""
: entry.active
? `<button class="row-action danger" type="button" data-deactivate-kb="${escapeHtml(entry.id)}">비활성</button>`
: `<button class="row-action" type="button" data-reactivate-kb="${escapeHtml(entry.id)}">재활성</button>`;
const editForm =
editingKnowledgeEntryId === entry.id
? `
<form class="knowledge-edit-form" data-knowledge-edit-form="${escapeHtml(entry.id)}">
<label>
<span>별칭</span>
<input name="aliases" type="text" value="${escapeHtml(aliases.join(", "))}">
</label>
<label>
<span>검색 키워드</span>
<input name="keywords" type="text" value="${escapeHtml(keywords.join(", "))}">
</label>
<label class="wide-field">
<span>정책 메모</span>
<textarea name="memo" rows="3">${escapeHtml(entry.memo || "")}</textarea>
</label>
<div class="knowledge-edit-actions">
<button class="primary-action" type="submit">저장</button>
<button class="secondary-action" type="button" data-cancel-edit-kb="${escapeHtml(entry.id)}">취소</button>
</div>
</form>
`
: "";
return `
<article class="knowledge-row ${entry.active ? "" : "inactive"} ${entryStatus === "watchlist" ? "watchlist" : ""}">
${
entry.imageAsset
? `<img class="knowledge-thumb" src="${escapeHtml(entry.imageAsset)}" alt="${escapeHtml(entry.name)} 참조 이미지">`
: `<div class="knowledge-thumb empty" aria-hidden="true"></div>`
}
<div class="knowledge-main">
<div class="row-title knowledge-title">
<span>${escapeHtml(entry.name)}</span>
</div>
<div class="knowledge-detail-line">
<div class="knowledge-chip-row">
<span class="provenance-chip ${escapeHtml(entry.provenance)}">${escapeHtml(formatProvenance(entry.provenance))}</span>
<span class="watchlist-chip ${escapeHtml(entryStatus)}">${escapeHtml(formatKnowledgeStatus(entryStatus))}</span>
</div>
<div class="row-meta knowledge-meta">
<span>${escapeHtml(formatKnowledgeType(entry.type))}</span>
<span>별칭: ${escapeHtml(aliases.join(", ") || "-")}</span>
<span>키워드: ${escapeHtml(keywords.join(", ") || "-")}</span>
<span>샘플 지문 ${(entry.sampleFingerprints || []).length}</span>
${statusMeta}
${memoInline}
</div>
</div>
${memoBlock}
${editForm}
</div>
<div class="knowledge-actions">
<button class="row-action" type="button" data-edit-kb="${escapeHtml(entry.id)}">편집</button>
${lifecycleActions}
</div>
</article>
`;
})
.join("")
: `<div class="empty-state">조건에 맞는 기준 항목이 없습니다.</div>`;
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 `
<article class="candidate-card ${promoted ? "promoted" : ""}">
<label class="candidate-select">
<input
type="checkbox"
data-collection-candidate-id="${escapeHtml(candidate.id)}"
${canPromote ? "" : "disabled"}
>
<span class="sr-only">${escapeHtml(candidate.title || candidate.query)} 선택</span>
</label>
${
candidate.imageAsset
? `<img class="candidate-thumb" src="${escapeHtml(candidate.imageAsset)}" alt="${escapeHtml(candidate.title || candidate.query)} 후보 이미지">`
: `<div class="candidate-thumb empty" aria-hidden="true"></div>`
}
<div class="candidate-main">
<div class="row-title">
<span class="source-chip ${sourceClass(candidate.provider)}">${escapeHtml(providerLabels[candidate.provider] || candidate.provider || "검색")}</span>
<span>${escapeHtml(candidate.title || candidate.query)}</span>
</div>
<div class="row-meta">
<span>${escapeHtml(candidate.query)}</span>
<span>${escapeHtml(formatCandidateSourceType(candidate.sourceCandidateType))}</span>
<span>순위 ${escapeHtml(candidate.rank || "-")}</span>
<span>${escapeHtml(formatCandidateStatus(candidate.status))}</span>
</div>
${
candidate.sourceUrl
? `<a class="evidence-link" href="${escapeHtml(candidate.sourceUrl)}" target="_blank" rel="noreferrer">출처 열기</a>`
: ""
}
</div>
<div class="candidate-actions">
<button class="row-action" type="button" data-promote-candidate="${escapeHtml(candidate.id)}" ${canPromote ? "" : "disabled"}>
${promoted ? "반영됨" : "기준 DB 반영"}
</button>
<button class="row-action secondary-action" type="button" data-dismiss-candidate="${escapeHtml(candidate.id)}">무시</button>
</div>
</article>
`;
})
.join("")
: `<div class="empty-state">검색어와 제공자를 선택하면 기준 DB 후보가 표시됩니다.</div>`;
}
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) => `
<article class="correction-row">
<div>
<div class="row-title">
<span class="risk-badge risk-failed">보정</span>
<span>${escapeHtml(correction.decisionId)} · ${escapeHtml(correction.submissionId)}</span>
</div>
<div class="row-meta">
<span>${escapeHtml(correction.previous)} -> ${escapeHtml(correction.current)}</span>
<span>${escapeHtml(correction.operator)}</span>
<span>${escapeHtml(correction.timestamp)}</span>
</div>
<p class="muted small">${escapeHtml(correction.reason)}</p>
<div class="chip-row">
${correction.derivedEntries
.map(
(entry) =>
`<span class="provider-chip ${entry.active ? "failed" : "disabled"}">${escapeHtml(entry.name)} · ${entry.active ? "활성" : "비활성"}</span>`,
)
.join("")}
</div>
</div>
<button class="row-action" type="button" data-deactivate-correction="${escapeHtml(correction.id)}">파생 항목 비활성</button>
</article>
`,
)
.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 `<span class="muted small">환경 변수 확인됨: ${escapeHtml(requiredEnv.join(", "))}</span>`;
}
return `<span class="muted small">누락된 환경 변수: ${escapeHtml(missing.join(", "))}</span>`;
}
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 `
<article class="provider-row">
<div class="provider-summary">
<div class="row-title">
<span class="status-dot ${provider.enabled ? "ok" : "bad"}" aria-hidden="true"></span>
<span>${escapeHtml(providerName)}</span>
</div>
<div class="row-meta">
<span>${provider.enabled ? "활성" : "중지"}</span>
<span>${escapeHtml(formatReason(provider.compliance))}</span>
<span>최근 성공: ${escapeHtml(formatReason(provider.lastSuccess))}</span>
<span>최근 실패: ${escapeHtml(formatReason(provider.lastFailure))}</span>
</div>
<span class="muted small">${escapeHtml(formatReason(provider.boundary))}</span>
${envStatus}
</div>
<div>
<div class="row-meta"><span>${provider.usage} / ${provider.quota == null ? "무제한" : provider.quota}</span><span>${percent}%</span></div>
<div class="quota-meter" aria-label="${escapeHtml(providerName)} 사용량 ${percent}%"><span style="width: ${percent}%"></span></div>
</div>
<div class="provider-actions">
<button type="button" data-toggle-provider="${escapeHtml(provider.id)}">${provider.enabled ? "중지" : "활성"}</button>
<button type="button" data-provider-retry="${escapeHtml(provider.id)}">실패 재시도</button>
</div>
</article>
`;
})
.join("");
}
function renderAuditLog() {
document.getElementById("audit-body").innerHTML = auditEvents
.map(
(event) => `
<tr>
<td>${escapeHtml(event.timestamp)}</td>
<td>${escapeHtml(event.actor)}</td>
<td>${escapeHtml(formatReason(event.event))}</td>
<td>${escapeHtml(formatReason(event.object))}</td>
<td>${escapeHtml(formatReason(event.change))}</td>
</tr>
`,
)
.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 = `
<span class="status-dot ${className}" aria-hidden="true"></span>
<span>외부 검색 tool ${visibleProviders.filter((provider) => provider.enabled).length}/${visibleProviders.length} 활성 · 실패 ${failed}</span>
`;
}
function renderAll() {
renderOperatorSearchProviderOptions();
renderProviderPulse();
renderCoverageTabs();
renderImportStatus();
renderQueue();
renderCaseReview();
renderEvidenceSearch();
renderKnowledgeBase();
labelCollectionCandidateActions();
renderCorrections();
renderProviderControls();
renderAuditLog();
}
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";
if (view === "corrections") {
view = "knowledge";
state.knowledgeTab = "corrections";
}
if (!viewNames.has(view)) return;
state.currentView = view;
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 (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;
switchView("workbench");
switchWorkbenchTab("evidence");
renderAll();
}
async function setDecision(decision, requiresMemo = false) {
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 = "";
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;
}
}
async function runManualSearch(event) {
event.preventDefault();
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;
}
try {
await apiJson("/api/search/manual", {
method: "POST",
body: JSON.stringify({ submission_id: submission.id, provider, query }),
});
queryInput.value = "";
status.textContent = `${providerLabels[provider]} 쿼리 결과가 현재 케이스 증거에 추가되었습니다.`;
await refreshFromApi();
} catch (errorValue) {
status.textContent = errorValue.message;
}
}
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;
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] : [];
}
async function rerunSelectedEnrichment() {
const ids = selectedBulkSubmissionIds();
const button = document.getElementById("bulk-rerun");
if (!ids.length) {
setImportStatus("재분석할 제출이 없습니다.", "bad");
return;
}
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) {
setImportStatus(errorValue.message, "bad");
} finally {
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;
clearCollectionCandidatesForSearch();
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();
}
function dismissCollectionCandidate(candidateId) {
const index = collectionCandidates.findIndex((candidate) => candidate.id === candidateId);
if (index >= 0) collectionCandidates.splice(index, 1);
renderCollectionCandidates();
const status = document.getElementById("collection-status");
if (status) status.textContent = "후보를 무시했습니다.";
}
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;
state.currentCollectionQuery = query;
state.currentCollectionProvider = provider;
clearCollectionCandidatesForSearch();
status.textContent = "키워드 후보를 수집하는 중입니다.";
try {
const payload = await apiJson("/api/collections/keyword", {
method: "POST",
body: JSON.stringify({ provider, query }),
});
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() {
switchView("knowledge");
switchKnowledgeTab("manual");
renderAll();
}
function deactivateCorrectionEntry(correctionId) {
const correction = corrections.find((item) => item.id === correctionId);
if (!correction) return;
correction.derivedEntries.forEach((entry) => {
entry.active = false;
});
auditEvents.unshift({
timestamp: "방금",
actor: "rights.ops",
event: "Knowledge entry deactivated",
object: correction.decisionId,
change: "all derived entries inactive",
});
renderAll();
}
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 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);
}
});
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", () => {
const automaticEntry = knowledgeEntries.find(
(entry) => entry.provenance === "automatic" && entry.active && (entry.entryStatus || "confirmed") === "confirmed",
);
if (automaticEntry) void deactivateKnowledgeEntry(automaticEntry.id);
});
document.getElementById("open-correction").addEventListener("click", () => {
switchView("knowledge");
switchKnowledgeTab("corrections");
});
document.getElementById("bulk-rerun").addEventListener("click", rerunSelectedEnrichment);
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) {
auditEvents.unshift({
timestamp: "방금",
actor: "admin.ops",
event: "Provider called",
object: retryButton.dataset.providerRetry,
change: "retry failed enrichments",
});
renderAll();
return;
}
const correctionButton = event.target.closest("[data-deactivate-correction]");
if (correctionButton) {
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) {
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));