④ 감사 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
478 lines
9.5 KiB
JavaScript
478 lines
9.5 KiB
JavaScript
// Part 8 of 15 of the operator console app script.
|
|
//
|
|
// View/tab switching, case selection, decisions, manual search, rerun.
|
|
//
|
|
// 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 switchView(view, updateHash = true) {
|
|
|
|
if (view === "case" || view === "evidence") view = "workbench";
|
|
|
|
let knowledgeTabToApply = "";
|
|
|
|
if (view === "corrections") {
|
|
|
|
view = "knowledge";
|
|
|
|
// Setting state alone left the visible panel on whatever knowledge tab was
|
|
// last shown — the tab DOM is switched by switchKnowledgeTab, which must run
|
|
// (applied at the end, after the view panels are toggled).
|
|
|
|
knowledgeTabToApply = "corrections";
|
|
|
|
}
|
|
|
|
if (!viewNames.has(view)) return;
|
|
|
|
state.currentView = view;
|
|
|
|
// Stats are read on demand (a fresh cross-tab each open) rather than shipped
|
|
// in every bootstrap payload.
|
|
|
|
if (view === "stats") {
|
|
|
|
void loadDecisionStats();
|
|
|
|
}
|
|
|
|
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 (knowledgeTabToApply) {
|
|
|
|
switchKnowledgeTab(knowledgeTabToApply);
|
|
|
|
}
|
|
|
|
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;
|
|
|
|
// Reset per-case inputs. The decision memo is submitted with whatever case is
|
|
// selected at click time, so a memo typed for case A and left in the box was
|
|
// recorded verbatim as the rejection/correction reason for case B.
|
|
|
|
resetCaseScopedInputs();
|
|
|
|
switchView("workbench");
|
|
|
|
switchWorkbenchTab("evidence");
|
|
renderAll();
|
|
|
|
}
|
|
|
|
|
|
|
|
function resetCaseScopedInputs() {
|
|
|
|
const memo = document.getElementById("decision-memo");
|
|
|
|
if (memo) {
|
|
|
|
memo.value = "";
|
|
|
|
}
|
|
|
|
const memoError = document.getElementById("memo-error");
|
|
|
|
if (memoError) {
|
|
|
|
memoError.textContent = "";
|
|
|
|
}
|
|
|
|
const manualQuery = document.getElementById("manual-query");
|
|
|
|
if (manualQuery) {
|
|
|
|
manualQuery.value = "";
|
|
|
|
}
|
|
|
|
const manualStatus = document.getElementById("manual-query-status");
|
|
|
|
if (manualStatus) {
|
|
|
|
manualStatus.textContent = "";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let isDecisionInFlight = false;
|
|
|
|
async function setDecision(decision, requiresMemo = false) {
|
|
|
|
// In-flight guard: a double-click, or clicking two different decisions
|
|
// quickly, would otherwise POST two conflicting decisions concurrently.
|
|
if (isDecisionInFlight) return;
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
if (!requireOperatorName((message) => { error.textContent = message; })) return;
|
|
|
|
|
|
|
|
error.textContent = "";
|
|
|
|
isDecisionInFlight = true;
|
|
|
|
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;
|
|
|
|
} finally {
|
|
|
|
isDecisionInFlight = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let isManualSearchActive = false;
|
|
|
|
async function runManualSearch(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
if (isManualSearchActive) return;
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
isManualSearchActive = true;
|
|
|
|
try {
|
|
|
|
const targetCaseId = submission.id;
|
|
|
|
await apiJson("/api/search/manual", {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({ submission_id: targetCaseId, provider, query }),
|
|
|
|
});
|
|
|
|
// The operator may have moved to another case while the search ran. Naming
|
|
// the case the evidence actually landed on prevents "added to the current
|
|
// case" from pointing at the wrong one, and the input is only cleared when
|
|
// it still belongs to the case we searched for.
|
|
|
|
if (state.selectedCaseId === targetCaseId) {
|
|
|
|
queryInput.value = "";
|
|
|
|
status.textContent = `${providerLabels[provider]} 쿼리 결과가 현재 케이스 증거에 추가되었습니다.`;
|
|
|
|
} else {
|
|
|
|
status.textContent = `${providerLabels[provider]} 쿼리 결과가 케이스 ${targetCaseId}에 추가되었습니다.`;
|
|
|
|
}
|
|
|
|
await refreshFromApi();
|
|
|
|
} catch (errorValue) {
|
|
|
|
status.textContent = errorValue.message;
|
|
|
|
} finally {
|
|
|
|
isManualSearchActive = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
void 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] : [];
|
|
|
|
}
|
|
|
|
|
|
|
|
// 선례 승계. 별도 판정 경로를 만들지 않고 메모만 채워 setDecision 에 넘긴다 —
|
|
// 반려 메모 필수 검사, 판정 시점 스냅샷, 감사 이벤트 같은 기존 보호장치를 그대로
|
|
// 타야 하기 때문이다. 승계했다는 사실은 메모로 감사에 남는다.
|
|
async function inheritPriorDecision(decision) {
|
|
|
|
const submission = getSelectedCase();
|
|
|
|
const summary = submission ? priorDecisionSummary(submission) : null;
|
|
|
|
// 화면이 그 사이 바뀌었으면(다른 케이스 선택, 선례 판정 변경) 조용히 무시한다.
|
|
if (!summary || summary.inheritable !== decision) return;
|
|
|
|
const memoField = document.getElementById("decision-memo");
|
|
|
|
const peers = summary.priors.map((item) => item.submissionId).join(", ");
|
|
|
|
if (memoField && !memoField.value.trim()) {
|
|
|
|
memoField.value = `선례 승계: 동일 이미지 ${peers} 와 같은 판정`;
|
|
|
|
}
|
|
|
|
await setDecision(decision, decision === "rejected");
|
|
|
|
}
|