POSA_Copyrighter/web/operator-gui/app-navigation.js
changukyu 4e8e4b3181 feat: 선례 승계 — 재제출을 다시 심사하지 않는다
8/8 실효성 평가의 결론은 "검색·기록 노동은 줄여주나 심사 건수는 전혀 못 줄인다"
였다. 이 커밋은 그 축을 직접 건드리는 첫 기능이다.

중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능이다 — 재저장(JPEG q90)
/80%축소/50%축소/밝기+10% 변형 24건 중 24건 그룹핑, 같은 24건에서 SHA-256
완전일치는 0건. 그런데 그 결과를 쓰는 곳이 없었다. 서버는 store_duplicates.py
에서 priorDecisions 와 conflicting 을 계산해 내려보내는데, 콘솔은 그것을 큐
배지의 title 속성에만 넣었다. 마우스를 올린 사람만 "이거 지난주에 반려한
이미지"임을 알 수 있었고, 재제출은 처음부터 다시 심사됐다.

서버는 건드리지 않았다. 자리만 옮겼다.

  큐 배지        "동일 이미지 3건 · 선례 반려" (title 이 아니라 본문에)
  판정 패널      결정 메모 바로 위에 선례 배너 + [선례 승계] 1타

승계는 별도 판정 경로를 만들지 않는다. 메모를 채워 기존 setDecision 을 그대로
타므로 반려 메모 필수 검사 · 판정 시점 스냅샷 · 감사 이벤트가 유지되고, 승계
사실은 메모로 감사에 남는다("선례 승계: 동일 이미지 SUB-OLD1, SUB-OLD2 와 같은
판정").

판정 불일치는 승계 대상이 아니다. 같은 이미지에 서로 다른 판정이 있다는 것은
자동 처리할 상황이 아니라 사람이 봐야 하는 신호이므로, conflicting 이면 버튼을
아예 내지 않고 색을 갈라 표시한다.

실렌더 확인(Playwright 1440x900, 실제 서버 + 부트스트랩 스텁):
세 상태(승계 가능 / 판정 불일치 / 선례 없음)의 배너 문구와 버튼 유무가 의도대로,
승계 클릭 시 POST 본문이 위 메모 그대로, 콘솔 에러 0, 가로 오버플로 없음.

검증: ruff clean, pytest 622 passed (정적 3건 추가).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 12:05:38 +09:00

476 lines
9.4 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;
}
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");
}