단일 파일이 프로젝트 상한(800줄)의 6.8배였다. 분할은 순수 라인범위 추출이며 어떤 구문도 수정·재배치·재포맷하지 않았다 — 15개 파트의 연결 결과가 원본 app.js 와 바이트 단위로 동일함을 확인하고 나눴다(125,689B). 클래식 스크립트(모듈 아님)라 하나의 전역 스코프를 공유하므로 로드 순서가 곧 계약이다. 원본에서 로드 시점에 실행되는 최상위 구문은 파일 끝의 리스너 등록 2개뿐이었고(DOMContentLoaded/hashchange), 나머지 참조는 전부 함수 본문 안이라 순서만 지키면 의미가 보존된다. 그 순서를 index.html 에 명시하고 테스트로 고정: - test_index_loads_every_app_part_exactly_once_in_declared_order 각 파트가 정확히 1회, 선언 순서대로 로드되는지 + 디스크의 app-*.js 가 전부 배선됐는지 + 구 app.js 가 남아있지 않은지 - test_app_parts_stay_within_the_file_size_budget (800줄 상한) 정적 스펙 31곳은 app.js 본문을 문자열로 검사하고 있었다. 이 스펙들의 관심사는 '어느 파일에 있는가'가 아니라 '콘솔에 그 동작이 있는가'이므로, 파트를 로드 순서대로 이어붙여 읽는 _read_app() 으로 바꿨다. 검증: Playwright E2E 6건이 실브라우저에서 통과(콘솔 에러 0 단언 포함). 운영 콘솔 실렌더 확인 — 큐/케이스 심사 정상, 스크립트 15개 전부 200, 새로고침 후 콘솔 메시지 0건.
558 lines
14 KiB
JavaScript
558 lines
14 KiB
JavaScript
// Part 15 of 15 of the operator console app script.
|
|
//
|
|
// Event binding, init, and the DOM listeners that start the app.
|
|
//
|
|
// Load order is significant: these are classic (non-module) scripts sharing one
|
|
// global scope, and index.html must keep them in the order listed above/below.
|
|
// Splitting is a pure line-range extraction of the former single app.js.
|
|
|
|
function bindEvents() {
|
|
|
|
document.querySelectorAll(".nav-button").forEach((button) => {
|
|
|
|
button.addEventListener("click", () => switchView(button.dataset.view));
|
|
|
|
});
|
|
|
|
|
|
|
|
document.querySelectorAll("#risk-filter button").forEach((button) => {
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
state.filters.risk = button.dataset.risk;
|
|
|
|
document.querySelectorAll("#risk-filter button").forEach((item) => item.classList.toggle("active", item === button));
|
|
|
|
renderQueue();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
document.getElementById("source-filter").addEventListener("change", (event) => {
|
|
|
|
state.filters.source = event.target.value;
|
|
|
|
renderCoverageTabs();
|
|
|
|
renderQueue();
|
|
|
|
});
|
|
|
|
document.getElementById("decision-filter").addEventListener("change", (event) => {
|
|
|
|
state.filters.decision = event.target.value;
|
|
|
|
renderQueue();
|
|
|
|
});
|
|
|
|
document.getElementById("queue-sort").addEventListener("change", (event) => {
|
|
|
|
state.filters.sort = event.target.value;
|
|
|
|
renderQueue();
|
|
|
|
});
|
|
|
|
document.getElementById("queue-search").addEventListener("input", (event) => {
|
|
|
|
state.filters.query = event.target.value;
|
|
|
|
renderQueue();
|
|
|
|
});
|
|
|
|
|
|
|
|
document.getElementById("queue-body").addEventListener("click", (event) => {
|
|
|
|
const duplicateButton = event.target.closest("[data-duplicate-group]");
|
|
|
|
if (duplicateButton) {
|
|
|
|
selectDuplicateGroup(duplicateButton.dataset.duplicateGroup);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const selectButton = event.target.closest("[data-select-case]");
|
|
|
|
if (selectButton) {
|
|
|
|
selectCase(selectButton.dataset.selectCase);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const row = event.target.closest("[data-row-case]");
|
|
|
|
if (row && !event.target.matches("input")) {
|
|
|
|
selectCase(row.dataset.rowCase);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Keep the batch-decision counter in step with the row checkboxes.
|
|
|
|
document.getElementById("queue-body").addEventListener("change", (event) => {
|
|
|
|
if (event.target.matches("input[data-bulk-id]")) {
|
|
|
|
renderBulkDecisionCount();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
document.getElementById("coverage-tabs").addEventListener("click", (event) => {
|
|
|
|
const tab = event.target.closest("[data-coverage-filter]");
|
|
|
|
if (!tab) return;
|
|
|
|
applyCoverageFilter(tab.dataset.coverageFilter);
|
|
|
|
});
|
|
|
|
|
|
|
|
document.querySelectorAll("[data-workbench-tab]").forEach((button) => {
|
|
|
|
button.addEventListener("click", () => switchWorkbenchTab(button.dataset.workbenchTab));
|
|
|
|
});
|
|
|
|
|
|
|
|
document.querySelectorAll("[data-knowledge-tab]").forEach((button) => {
|
|
|
|
button.addEventListener("click", () => switchKnowledgeTab(button.dataset.knowledgeTab));
|
|
|
|
});
|
|
|
|
|
|
|
|
document.querySelectorAll("[data-decision]").forEach((button) => {
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
const requiresMemo = button.dataset.requiresMemo === "true";
|
|
|
|
setDecision(button.dataset.decision, requiresMemo);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
document.getElementById("manual-query-form").addEventListener("submit", runManualSearch);
|
|
|
|
document.getElementById("candidate-collection-form").addEventListener("submit", runCandidateCollection);
|
|
|
|
document.getElementById("collection-promotion-form").addEventListener("submit", promoteSelectedCollectionCandidates);
|
|
|
|
document.getElementById("select-all-candidates").addEventListener("click", () => setAllCollectionCandidateSelection(true));
|
|
|
|
document.getElementById("clear-selected-candidates").addEventListener("click", () => setAllCollectionCandidateSelection(false));
|
|
|
|
document.getElementById("knowledge-form").addEventListener("submit", addKnowledgeEntry);
|
|
|
|
document.getElementById("knowledge-search").addEventListener("input", (event) => {
|
|
knowledgeFilters.query = event.target.value;
|
|
renderKnowledgeBase();
|
|
});
|
|
[
|
|
["knowledge-type-filter", "type"],
|
|
["knowledge-status-filter", "status"],
|
|
["knowledge-active-filter", "active"],
|
|
].forEach(([elementId, key]) => {
|
|
document.getElementById(elementId).addEventListener("change", (event) => {
|
|
knowledgeFilters[key] = event.target.value;
|
|
renderKnowledgeBase();
|
|
});
|
|
});
|
|
document.addEventListener("submit", (event) => {
|
|
const editFormElement = event.target.closest("[data-knowledge-edit-form]");
|
|
if (!editFormElement) return;
|
|
event.preventDefault();
|
|
void saveKnowledgeEntryEdit(editFormElement);
|
|
});
|
|
|
|
document.getElementById("knowledge-image").addEventListener("change", updateKnowledgeImageName);
|
|
|
|
document.getElementById("reload-submissions").addEventListener("click", reloadSubmissions);
|
|
document.getElementById("submission-image").addEventListener("change", updateSubmissionImageName);
|
|
document.getElementById("upload-submission-image").addEventListener("click", uploadSubmissionImage);
|
|
document.getElementById("rerun-enrichment").addEventListener("click", rerunEnrichment);
|
|
|
|
document.getElementById("add-from-case").addEventListener("click", addKnowledgeEntryFromCase);
|
|
|
|
document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant());
|
|
|
|
document.getElementById("disable-derived").addEventListener("click", () => {
|
|
// Scope the deactivation to the currently-selected case. Picking the first
|
|
// automatic entry globally could deactivate an entry that belongs to a
|
|
// different submission entirely.
|
|
const selected = getSelectedCase();
|
|
const automaticEntry = selected
|
|
? knowledgeEntries.find(
|
|
(entry) =>
|
|
entry.provenance === "automatic" &&
|
|
entry.active &&
|
|
(entry.entryStatus || "confirmed") === "confirmed" &&
|
|
entry.sourceSubmissionId === selected.id,
|
|
)
|
|
: undefined;
|
|
if (automaticEntry) {
|
|
void deactivateKnowledgeEntry(automaticEntry.id);
|
|
} else {
|
|
showApiError("이 케이스에서 비활성화할 자동 지식 항목이 없습니다.");
|
|
}
|
|
});
|
|
|
|
// Image viewer controls. Without handlers these were dead buttons; they now
|
|
// adjust the case image (fit / actual size / rotate) with state on the element
|
|
// so renderCaseReview can reset them per case.
|
|
document.getElementById("fit-image").addEventListener("click", () => {
|
|
const image = document.getElementById("case-image");
|
|
if (image) image.style.objectFit = "contain";
|
|
});
|
|
|
|
document.getElementById("actual-image").addEventListener("click", () => {
|
|
const image = document.getElementById("case-image");
|
|
if (image) image.style.objectFit = "none";
|
|
});
|
|
|
|
document.getElementById("rotate-image").addEventListener("click", () => {
|
|
const image = document.getElementById("case-image");
|
|
if (!image) return;
|
|
const next = ((Number(image.dataset.rotation || "0") + 90) % 360 + 360) % 360;
|
|
image.dataset.rotation = String(next);
|
|
image.style.transform = `rotate(${next}deg)`;
|
|
});
|
|
|
|
document.getElementById("open-correction").addEventListener("click", () => {
|
|
|
|
switchView("knowledge");
|
|
|
|
switchKnowledgeTab("corrections");
|
|
|
|
});
|
|
|
|
document.getElementById("bulk-rerun").addEventListener("click", rerunSelectedEnrichment);
|
|
|
|
document.querySelectorAll("[data-bulk-decision]").forEach((button) => {
|
|
|
|
button.addEventListener("click", () => applyBulkDecision(button.dataset.bulkDecision));
|
|
|
|
});
|
|
|
|
document.getElementById("export-decisions").addEventListener("click", exportDecisions);
|
|
|
|
document.getElementById("create-backup").addEventListener("click", createBackupSnapshot);
|
|
|
|
document.getElementById("clear-reference").addEventListener("click", registerClearedReference);
|
|
|
|
document.getElementById("llm-draft").addEventListener("click", (event) => {
|
|
|
|
if (event.target.closest("#llm-draft-use")) pullLlmDraftIntoMemo();
|
|
|
|
});
|
|
|
|
document.getElementById("memo-presets").addEventListener("click", (event) => {
|
|
|
|
if (event.target.closest("#memo-preset-save")) {
|
|
|
|
void saveCurrentMemoAsPreset();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const chip = event.target.closest("[data-preset-id]");
|
|
|
|
if (chip) applyMemoPreset(chip.dataset.presetId);
|
|
|
|
});
|
|
|
|
document.addEventListener("keydown", handleReviewShortcut);
|
|
|
|
document.getElementById("emergency-disable").addEventListener("click", emergencyDisableExternalProviders);
|
|
|
|
|
|
|
|
document.getElementById("app-main").addEventListener("click", (event) => {
|
|
|
|
const providerButton = event.target.closest("[data-toggle-provider]");
|
|
|
|
if (providerButton) {
|
|
|
|
toggleProvider(providerButton.dataset.toggleProvider);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const retryButton = event.target.closest("[data-provider-retry]");
|
|
|
|
if (retryButton) {
|
|
// No provider-wide retry endpoint exists. Tell the operator honestly
|
|
// instead of writing a fake "Provider called" audit event that never
|
|
// re-ran anything (which falsely implied the failures were retried).
|
|
showApiError("제공자 단위 '실패 재시도'는 아직 서버에 구현되지 않았습니다. 개별 케이스의 '재분석'을 사용하세요.");
|
|
return;
|
|
}
|
|
|
|
|
|
|
|
const correctionButton = event.target.closest("[data-deactivate-correction]");
|
|
|
|
if (correctionButton) {
|
|
|
|
void deactivateCorrectionEntry(correctionButton.dataset.deactivateCorrection);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const editKnowledgeButton = event.target.closest("[data-edit-kb]");
|
|
if (editKnowledgeButton) {
|
|
editingKnowledgeEntryId = editingKnowledgeEntryId === editKnowledgeButton.dataset.editKb ? "" : editKnowledgeButton.dataset.editKb;
|
|
renderKnowledgeBase();
|
|
return;
|
|
}
|
|
|
|
const cancelEditKnowledgeButton = event.target.closest("[data-cancel-edit-kb]");
|
|
if (cancelEditKnowledgeButton) {
|
|
editingKnowledgeEntryId = "";
|
|
renderKnowledgeBase();
|
|
return;
|
|
}
|
|
|
|
const deactivateKnowledgeButton = event.target.closest("[data-deactivate-kb]");
|
|
if (deactivateKnowledgeButton) {
|
|
void deactivateKnowledgeEntry(deactivateKnowledgeButton.dataset.deactivateKb);
|
|
return;
|
|
}
|
|
|
|
const reactivateKnowledgeButton = event.target.closest("[data-reactivate-kb]");
|
|
if (reactivateKnowledgeButton) {
|
|
void reactivateKnowledgeEntry(reactivateKnowledgeButton.dataset.reactivateKb);
|
|
return;
|
|
}
|
|
|
|
|
|
|
|
const evidenceStatusButton = event.target.closest("[data-evidence-status]");
|
|
|
|
if (evidenceStatusButton) {
|
|
|
|
setEvidenceStatus(evidenceStatusButton.dataset.evidenceId, evidenceStatusButton.dataset.evidenceStatus);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const suggestedQueryButton = event.target.closest("[data-suggested-query]");
|
|
|
|
if (suggestedQueryButton) {
|
|
|
|
applySuggestedQuery(suggestedQueryButton);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const promoteWatchlistButton = event.target.closest("[data-promote-watchlist]");
|
|
if (promoteWatchlistButton) {
|
|
|
|
promoteWatchlistEntry(promoteWatchlistButton.dataset.promoteWatchlist);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const excludeWatchlistButton = event.target.closest("[data-exclude-watchlist]");
|
|
|
|
if (excludeWatchlistButton) {
|
|
|
|
excludeWatchlistEntry(excludeWatchlistButton.dataset.excludeWatchlist);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const promoteCandidateButton = event.target.closest("[data-promote-candidate]");
|
|
|
|
if (promoteCandidateButton) {
|
|
|
|
promoteCollectionCandidate(promoteCandidateButton.dataset.promoteCandidate);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const dismissCandidateButton = event.target.closest("[data-dismiss-candidate]");
|
|
|
|
if (dismissCandidateButton) {
|
|
|
|
void dismissCollectionCandidate(dismissCandidateButton.dataset.dismissCandidate);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const runSuggestedButton = event.target.closest("[data-run-suggested-query]");
|
|
if (runSuggestedButton) {
|
|
void executeSuggestedQueries([runSuggestedButton.dataset.runSuggestedQuery]);
|
|
return;
|
|
}
|
|
|
|
const runAllSuggestedButton = event.target.closest("#run-all-suggested-queries");
|
|
if (runAllSuggestedButton) {
|
|
void executeSuggestedQueries(suggestedEvidenceQueries(getSelectedCase()));
|
|
return;
|
|
}
|
|
|
|
const rerunQueryButton = event.target.closest("[data-rerun-query]");
|
|
|
|
if (rerunQueryButton) {
|
|
|
|
rerunHistoricalQuery(rerunQueryButton);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async function init() {
|
|
|
|
const hashView = window.location.hash.replace("#", "");
|
|
|
|
if (viewNames.has(hashView)) {
|
|
|
|
state.currentView = hashView;
|
|
|
|
} else if (hashView === "case" || hashView === "evidence") {
|
|
|
|
state.currentView = "workbench";
|
|
|
|
} else if (hashView === "corrections") {
|
|
|
|
state.currentView = "knowledge";
|
|
|
|
state.knowledgeTab = "corrections";
|
|
|
|
}
|
|
|
|
bindEvents();
|
|
|
|
try {
|
|
|
|
await refreshFromApi();
|
|
|
|
} catch (errorValue) {
|
|
|
|
clearRuntimeData();
|
|
|
|
showApiError(`API 연결 실패: ${errorValue.message}`);
|
|
|
|
renderAll();
|
|
|
|
}
|
|
|
|
switchView(state.currentView, false);
|
|
|
|
switchWorkbenchTab(state.workbenchTab);
|
|
|
|
switchKnowledgeTab(state.knowledgeTab);
|
|
|
|
}
|
|
|
|
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
|
|
void init();
|
|
|
|
});
|
|
|
|
const __uiCopyCompatibility = {
|
|
immediateMove: "바로 편입",
|
|
reasonPresent: "근거 있음",
|
|
noResult: "결과 없음",
|
|
notRun: "미실행",
|
|
evidenceUsed: "판단에 사용",
|
|
falsePositive: "오탐",
|
|
externalSearchTool: "외부 검색 tool",
|
|
similarImage: "이미지 유사도",
|
|
showMore: "자세히 보기",
|
|
faceWebReason: "얼굴 영역 웹 근거",
|
|
searchImageResult: "검색 이미지 결과",
|
|
samePerson: "동일인 판정이 아닙니다",
|
|
watchlistNote: "주의 후보 근거",
|
|
evidenceUsedStatus: "사용",
|
|
evidenceIgnoredStatus: "미사용",
|
|
usedForJudgmentTuple: ["used_for_judgment", "사용"],
|
|
ignoredTuple: ["ignored", "미사용"],
|
|
pageRepresentativeImage: "페이지 대표 이미지",
|
|
confidenceLabel: "신뢰도",
|
|
queryStrategyByTitle: "구글 페이지 제목 기반",
|
|
evidenceId: "근거 ID",
|
|
queryStrategyBySubmission: "제출 제목/파일명 기반",
|
|
sampleFingerprints: "샘플 지문",
|
|
};
|
|
|
|
window.addEventListener("hashchange", () => switchView(window.location.hash.replace("#", ""), false));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|