8/8 실효성 평가가 "운영자 되먹임이 순증 노동"이라고 적은 항목이다. 스코어러는
점수를 만든 행이 표시돼야 점수를 내려놓는데, 단건 API(/api/evidence/{id}/status)
하나뿐이라 실 DB 제출물 하나를 100 에서 35 로 내리려면 google 증거 51행을
개별로 눌러야 했다. 되먹임이 노동보다 비싸면 아무도 되먹이지 않는다.
증거 그룹 헤더의 개수 옆에 [전부 무관] 을 붙이고, POST /api/evidence/status-batch
를 신설했다.
서버는 한 트랜잭션, 리스코어 1회, 감사 이벤트 1건으로 묶는다. 감사 부분은 그
자체로 중요하다 — 이건 한 번의 운영자 동작이고, 51건을 쓰면 동작을 잘못
표현할 뿐 아니라 500행 감사 캡을 클릭 한 번에 먹는다. 행별 who/when/note 는
각 증거 payload 에 그대로 남으므로 개별 행이 설명 불가가 되지는 않는다.
원자성: 남의 제출물 증거 id 가 섞이면 아무것도 쓰지 않고 전체가 실패한다.
절반만 표시된 상태로 끝나면 운영자는 무엇이 반영됐는지 알 수 없다.
상한은 500 이다. 일괄 판정 상한(200)보다 큰 이유는 단위가 다르기 때문이다 —
그쪽은 케이스 수고 이쪽은 한 케이스 안의 증거 행 수다.
그룹 액션은 irrelevant(무관)로 보낸다. 행별 "미사용" 버튼은 false_positive
(오탐)에 매핑돼 있는데, 한 제공자의 결과 전체를 오탐이라 단정하는 것은 과한
주장이다. 점수 효과는 같고(스코어러는 둘 다 건너뛴다) 주장만 정직해진다.
확인 대화는 두지 않았다. 이 동작은 행마다 되돌릴 수 있고, 여기서 줄이려는
것이 바로 그 클릭이다. 되돌릴 수 없는 느낌이 드는 것은 판정이고 확인 대화는
거기에만 있다.
실렌더(Playwright 1440x900, 실제 서버): 51행 그룹에서 1타로
{"status":"irrelevant","evidence_ids":[51개]} 전송, 콘솔 에러 0.
검증: ruff clean, pytest 636 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
583 lines
15 KiB
JavaScript
583 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);
|
|
|
|
// 선례 승계 버튼은 케이스마다 다시 그려지므로 컨테이너에 위임한다.
|
|
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());
|
|
|
|
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: ["ignored", "미사용"],
|
|
pageRepresentativeImage: "페이지 대표 이미지",
|
|
confidenceLabel: "신뢰도",
|
|
queryStrategyByTitle: "구글 페이지 제목 기반",
|
|
evidenceId: "근거 ID",
|
|
queryStrategyBySubmission: "제출 제목/파일명 기반",
|
|
sampleFingerprints: "샘플 지문",
|
|
};
|
|
|
|
window.addEventListener("hashchange", () => switchView(window.location.hash.replace("#", ""), false));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|