POSA_Copyrighter/web/operator-gui/app-keyboard-batch.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

464 lines
9.2 KiB
JavaScript

// Part 9 of 15 of the operator console app script.
//
// Keyboard review mode, duplicate grouping, and batch decisions.
//
// 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.
// --- Keyboard review mode ------------------------------------------------
const KEYBOARD_HELP = [
"J / ↓ : 다음 케이스",
"K / ↑ : 이전 케이스",
"A : 승인 H : 보류 R : 반려(메모로 이동)",
"N : 다음 미심사 케이스",
"? : 이 도움말 열기/닫기",
"Esc : 도움말 닫기",
].join("\n");
function isTypingTarget(target) {
if (!target) return false;
const tag = String(target.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function moveSelection(offset) {
const rows = filteredSubmissions();
if (!rows.length) return;
const index = rows.findIndex((item) => item.id === state.selectedCaseId);
// From "no selection", J lands on the first row and K on the last.
const nextIndex = index === -1
? (offset > 0 ? 0 : rows.length - 1)
: Math.min(rows.length - 1, Math.max(0, index + offset));
selectCase(rows[nextIndex].id);
}
function moveToNextUnreviewed() {
const rows = filteredSubmissions();
const index = rows.findIndex((item) => item.id === state.selectedCaseId);
const ordered = [...rows.slice(index + 1), ...rows.slice(0, Math.max(0, index + 1))];
const next = ordered.find((item) => item.decisionStatus === "unreviewed");
if (next) {
selectCase(next.id);
} else {
setImportStatus("미심사 케이스가 없습니다.", "ok");
}
}
function renderReviewProgress() {
const target = document.getElementById("review-progress");
if (!target) return;
const rows = filteredSubmissions();
const position = rows.findIndex((item) => item.id === state.selectedCaseId) + 1;
const remaining = rows.filter((item) => item.decisionStatus === "unreviewed").length;
target.textContent = rows.length
? `${position || "-"}/${rows.length} · 남은 미심사 ${remaining}건 · ? 도움말`
: "";
}
function handleReviewShortcut(event) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
const key = event.key;
if (key === "Escape") {
const help = document.getElementById("keyboard-help");
if (help && !help.hidden) help.hidden = true;
return;
}
if (key === "?") {
const help = document.getElementById("keyboard-help");
if (help) {
help.textContent = KEYBOARD_HELP;
help.hidden = !help.hidden;
}
event.preventDefault();
return;
}
// Navigation works from the queue and the workbench; decisions only make
// sense once a case is open.
if (key === "j" || key === "J" || key === "ArrowDown") {
moveSelection(1);
event.preventDefault();
return;
}
if (key === "k" || key === "K" || key === "ArrowUp") {
moveSelection(-1);
event.preventDefault();
return;
}
if (key === "n" || key === "N") {
moveToNextUnreviewed();
event.preventDefault();
return;
}
if (!state.selectedCaseId) return;
if (key === "a" || key === "A") {
setDecision("approved").then(() => moveToNextUnreviewed());
event.preventDefault();
return;
}
if (key === "h" || key === "H") {
setDecision("held").then(() => moveToNextUnreviewed());
event.preventDefault();
return;
}
if (key === "r" || key === "R") {
// Rejection needs a memo, so hand the operator the field instead of firing
// a request that would fail validation.
switchView("workbench");
const memo = document.getElementById("decision-memo");
if (memo) memo.focus();
event.preventDefault();
}
}
// --- Duplicate / prior-case grouping ------------------------------------
function duplicateBadgeHtml(submission) {
const group = submission.duplicateGroup;
if (!group || !group.size) return "";
const priors = group.priorDecisions || [];
const priorText = priors.length
? `선례: ${priors
.map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`)
.join(", ")}`
: "아직 판정된 선례 없음";
const tone = group.conflicting ? " conflicting" : "";
const conflictNote = group.conflicting ? " · 판정 불일치" : "";
// 선례를 배지 본문에 싣는다. title 에만 두면 마우스를 올린 사람만 알 수 있고,
// 큐를 훑는 동안에는 "이미 판정한 이미지"가 보이지 않는다.
const summary = priorDecisionSummary(submission);
const priorNote = group.conflicting
? conflictNote
: summary && summary.inheritable
? ` · 선례 ${decisionLabels[summary.inheritable] || summary.inheritable}`
: "";
return `<button type="button" class="duplicate-badge${tone}" data-duplicate-group="${escapeHtml(submission.id)}" title="${escapeHtml(`${priorText}${conflictNote}`)}">동일 이미지 ${group.size}건${priorNote}</button>`;
}
function selectDuplicateGroup(submissionId) {
// Tick the whole group so the operator can hand it to batch re-analysis or a
// batch decision in one move.
const submission = submissions.find((item) => item.id === submissionId);
const group = submission && submission.duplicateGroup;
if (!group) return;
const ids = new Set([submissionId, ...(group.peerIds || [])]);
document.querySelectorAll("input[data-bulk-id]").forEach((input) => {
input.checked = ids.has(input.dataset.bulkId);
});
renderBulkDecisionCount();
const status = document.getElementById("bulk-decision-status");
if (status) {
const priors = group.priorDecisions || [];
status.textContent = priors.length
? `동일 이미지 ${ids.size}건 선택 · 선례 ${priors
.map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`)
.join(", ")}`
: `동일 이미지 ${ids.size}건을 선택했습니다.`;
}
}
// --- Batch decisions ---------------------------------------------------
function checkedBulkSubmissionIds() {
// Unlike selectedBulkSubmissionIds(), this never falls back to the current
// case: a batch decision must act on an explicit selection only.
return Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map(
(input) => input.dataset.bulkId,
);
}
function renderBulkDecisionCount() {
const target = document.getElementById("bulk-decision-count");
if (!target) return;
const ids = checkedBulkSubmissionIds();
const highRisk = ids.filter((id) => {
const submission = submissions.find((item) => item.id === id);
return submission && submission.riskBand === "high";
}).length;
target.textContent = highRisk
? `선택 ${ids.length}건 (고위험 ${highRisk}건)`
: `선택 ${ids.length}건`;
}
let isBulkDecisionInFlight = false;
async function applyBulkDecision(decision) {
if (isBulkDecisionInFlight) return;
const ids = checkedBulkSubmissionIds();
const status = document.getElementById("bulk-decision-status");
const setStatus = (message) => {
if (status) status.textContent = message;
};
if (!ids.length) {
setStatus("판정할 케이스를 먼저 선택하세요.");
return;
}
const memo = document.getElementById("decision-memo")?.value.trim() || "";
if (decision === "rejected" && !memo) {
setStatus("일괄 반려에는 결정 메모가 필요합니다. 메모를 입력한 뒤 다시 시도하세요.");
return;
}
const highRisk = ids.filter((id) => {
const submission = submissions.find((item) => item.id === id);
return submission && submission.riskBand === "high";
});
// Guardrail: approving a high-risk case in bulk is the one irreversible-feeling
// action here, so name the count instead of asking a generic "are you sure".
if (highRisk.length) {
const label = decisionLabels[decision] || decision;
if (!window.confirm(`선택 ${ids.length}건 중 고위험 ${highRisk.length}건이 포함되어 있습니다. 그대로 ${label} 처리할까요?`)) {
setStatus("일괄 판정을 취소했습니다.");
return;
}
}
isBulkDecisionInFlight = true;
setStatus(`${ids.length}건 판정 중…`);
try {
const payload = await apiJson("/api/submissions/decision-batch", {
method: "POST",
body: JSON.stringify({ submission_ids: ids, decision, memo }),
});
const result = payload.batchDecision || { applied: [], failed: [] };
applyBootstrap(payload);
renderAll();
const failures = result.failed || [];
setStatus(
failures.length
? `${(result.applied || []).length}건 적용 · ${failures.length}건 실패: ${failures
.map((item) => `${item.submissionId}(${item.error})`)
.join(", ")}`
: `${(result.applied || []).length}건 판정을 적용했습니다.`,
);
} catch (errorValue) {
setStatus(`일괄 판정 실패: ${errorValue.message}`);
} finally {
isBulkDecisionInFlight = false;
}
}