POSA_Copyrighter/web/operator-gui/app-keyboard-batch.js
changukyu e169e24584 feat: 반려 키보드 완결 — 가장 중요한 경로가 가장 느렸다
승인·보류는 A/H 한 키에 자동 다음 케이스 이동까지 되는데 반려만 메모가
필수라 흐름이 끊겼다. R 은 메모 필드에 포커스만 줬고, 입력 중에는 전역
단축키가 막히므로(J/K 가 글자로 들어가면 안 되니 의도된 동작) 거기서부터는
마우스로만 제출할 수 있었다. 저작권 심사에서 법적으로 가장 중요한 판정이
가장 느린 경로였다.

  R        사유 선택 모드 (프리셋에 1~9 번호가 보인다)
  1~9      그 사유로 즉시 반려 + 다음 미심사 케이스로
  M        직접 입력으로 전환
  Esc      취소
  메모 안에서 Ctrl+Enter   자유 서술 반려 제출

화면의 번호가 곧 눌러야 할 키다. 프리셋 칩에 키 배지를 붙여 단축키를 외우지
않아도 되게 했다.

숫자는 사유 선택 모드 안에서만 의미를 갖는다. 모드 밖에서 숫자에 의미를 주면
잘못 누른 키 하나로 반려가 나간다 — 되돌릴 수 있는 동작이지만 주의목록 항목을
만들고 감사에 남으므로 사고로 일어나서는 안 된다. 가드가 숫자 분기보다 먼저
오는지를 테스트가 위치로 확인한다.

프리셋이 하나도 없으면 고를 것이 없으므로 모드를 열지 않고 예전처럼 메모
필드로 보낸다.

실렌더(Playwright 1440x900, 실제 서버 + 부트스트랩 스텁):
  R -> 2      {"decision":"rejected","memo":"라이선스 확인 불가로 반려"}
  메모 + Ctrl+Enter  {"decision":"rejected","memo":"자유 서술 사유"}
콘솔 에러 0.

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

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

577 lines
12 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 : 반려 사유 선택",
" 사유 선택 중 — 1~9 : 사유 선택 후 즉시 반려 · M : 직접 입력 · Esc : 취소",
" 메모 입력 중 — Ctrl+Enter : 반려 제출",
"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 (isRejectPromptOpen) {
// 사유 선택 중에는 숫자가 곧 사유다. 이 모드 밖에서는 숫자에 아무 의미도
// 주지 않는다 - 잘못 누른 키 하나로 반려가 나가면 안 된다.
if (key >= "1" && key <= "9") {
rejectWithPreset(Number(key) - 1);
event.preventDefault();
return;
}
if (key === "m" || key === "M") {
closeRejectPrompt();
const memoField = document.getElementById("decision-memo");
if (memoField) memoField.focus();
event.preventDefault();
return;
}
if (key === "Escape") {
closeRejectPrompt();
event.preventDefault();
return;
}
}
if (key === "r" || key === "R") {
// 반려는 메모가 필수라 한 키로 끝낼 수 없다. 그렇다고 메모 필드에 포커스만
// 주면 거기서부터는 마우스로만 제출할 수 있다(입력 중에는 단축키가 막힌다) -
// 법적으로 가장 중요한 경로가 가장 느린 이유였다. 사유 선택을 한 단계 두어
// 자주 쓰는 사유는 두 키로, 자유 서술은 Ctrl+Enter 로 끝나게 한다.
switchView("workbench");
if (!openRejectPrompt()) {
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;
}
}
// --- 반려 사유 선택 -----------------------------------------------------------
let isRejectPromptOpen = false;
function openRejectPrompt() {
const box = document.getElementById("reject-prompt");
const presets = decisionMemoPresets.slice(0, 9);
// 고를 것이 없으면 모드를 열지 않는다. 그때는 예전처럼 메모 필드로 보낸다.
if (!box || !presets.length) return false;
box.innerHTML = `
<strong>반려 사유 선택</strong>
<ol>${presets.map((preset, index) => `<li><span class="memo-preset-key">${index + 1}</span>${escapeHtml(preset.label)}</li>`).join("")}</ol>
<span class="muted">M : 직접 입력 · Esc : 취소</span>
`;
box.hidden = false;
isRejectPromptOpen = true;
return true;
}
function closeRejectPrompt() {
const box = document.getElementById("reject-prompt");
if (box) {
box.innerHTML = "";
box.hidden = true;
}
isRejectPromptOpen = false;
}
function rejectWithPreset(index) {
const preset = decisionMemoPresets[index];
if (!preset) return;
const memo = document.getElementById("decision-memo");
if (!memo) return;
closeRejectPrompt();
memo.value = preset.body;
// 승인·보류와 같은 흐름으로 끝낸다 - 판정하고 다음 미심사 케이스로.
setDecision("rejected", true).then(() => moveToNextUnreviewed());
}