④ 감사 actor 31곳이 'rights.ops' 리터럴이라 누가 판정했는지 답이 없었다. ContextVar 로 요청 범위 운영자를 걸고 스토어는 current_actor() 를 읽는다. 콘솔 좌측 하단 '운영자' 칸 → X-Operator 헤더(한글 percent-encode, 서버에서 제어문자 제거·40자). 이름 없으면 판정이 막힌다. 인증이 아니라 기록 — 권한은 공유 토큰. ⑤ 기준 DB 유형 news_host / scraper_host. 점수는 바꾸지 않고 한 줄 요약에 '(언론·공식 n · 무단 게시처 m)', 증거 호스트 칩 옆에 배지. verify.ps1: ruff clean, 684 passed. 실렌더: 운영자 입력칸 표시 확인(44px). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
602 lines
15 KiB
JavaScript
602 lines
15 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);
|
|
|
|
// 입력 중 전역 단축키가 막히는 것은 의도된 동작이다(J/K 가 글자로 들어가면
|
|
// 안 된다). 다만 그 때문에 자유 서술 반려는 메모를 친 뒤 마우스로 가야 했다.
|
|
// Ctrl+Enter 하나만 열어 그 경로를 키보드로 끝낸다.
|
|
document.getElementById("decision-memo").addEventListener("keydown", (event) => {
|
|
|
|
if (!(event.ctrlKey || event.metaKey) || event.key !== "Enter") return;
|
|
|
|
event.preventDefault();
|
|
|
|
setDecision("rejected", true).then(() => moveToNextUnreviewed());
|
|
|
|
});
|
|
|
|
// 선례 승계 버튼은 케이스마다 다시 그려지므로 컨테이너에 위임한다.
|
|
document.getElementById("prior-decision-box").addEventListener("click", (event) => {
|
|
|
|
const button = event.target.closest("[data-inherit-decision]");
|
|
|
|
if (button) inheritPriorDecision(button.dataset.inheritDecision);
|
|
|
|
});
|
|
|
|
document.getElementById("add-from-case").addEventListener("click", addKnowledgeEntryFromCase);
|
|
|
|
document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant());
|
|
|
|
const operatorField = document.getElementById("operator-name");
|
|
if (operatorField) {
|
|
operatorField.value = operatorName;
|
|
operatorField.addEventListener("change", () => setOperatorName(operatorField.value));
|
|
}
|
|
|
|
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 evidenceGroupStatusButton = event.target.closest("[data-evidence-group-status]");
|
|
|
|
if (evidenceGroupStatusButton) {
|
|
|
|
setEvidenceGroupStatus(
|
|
|
|
evidenceGroupStatusButton.dataset.evidenceGroup,
|
|
|
|
evidenceGroupStatusButton.dataset.evidenceGroupStatus,
|
|
|
|
);
|
|
|
|
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: ["irrelevant", "무관"],
|
|
pageRepresentativeImage: "페이지 대표 이미지",
|
|
confidenceLabel: "신뢰도",
|
|
queryStrategyByTitle: "구글 페이지 제목 기반",
|
|
evidenceId: "근거 ID",
|
|
queryStrategyBySubmission: "제출 제목/파일명 기반",
|
|
sampleFingerprints: "샘플 지문",
|
|
};
|
|
|
|
window.addEventListener("hashchange", () => switchView(window.location.hash.replace("#", ""), false));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|