④ 감사 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
579 lines
12 KiB
JavaScript
579 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;
|
|
|
|
}
|
|
|
|
if (!requireOperatorName(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());
|
|
|
|
}
|