Address commit security review: the same-origin branch of safeUrl accepted //host and /\host, which browsers normalize to an external host (open redirect). Allow only true same-origin paths.
3926 lines
96 KiB
JavaScript
3926 lines
96 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 text = await response.text();
|
|
|
|
let payload = null;
|
|
|
|
if (text) {
|
|
|
|
try {
|
|
|
|
payload = JSON.parse(text);
|
|
|
|
} catch (error) {
|
|
|
|
payload = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
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) {
|
|
|
|
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("&", "&")
|
|
|
|
.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 `<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(safeUrl(targetUrl))}" target="_blank" rel="noreferrer" aria-label="검색 이미지 열기">
|
|
|
|
<img src="${escapeHtml(safeUrl(previewUrl))}" alt="" onerror="this.style.display='none'">
|
|
|
|
</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(safeUrl(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(safeUrl(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));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|