POSA_Copyrighter/web/operator-gui/app-decision-tools.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

363 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Part 10 of 15 of the operator console app script.
//
// Memo presets, decision export, backup, cleared reference, LLM draft.
//
// 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.
// --- Decision memo presets ---------------------------------------------
function renderMemoPresets() {
const target = document.getElementById("memo-presets");
if (!target) return;
const chips = decisionMemoPresets
.map(
// 화면의 번호가 곧 단축키다. R 로 사유 선택에 들어간 뒤 같은 숫자를
// 누르면 그 사유로 반려된다. 10번째부터는 번호 없이 마우스로만 쓴다.
(preset, index) =>
`<button type="button" class="memo-preset-chip" data-preset-id="${escapeHtml(preset.id)}" title="${escapeHtml(preset.body)}">${index < 9 ? `<span class="memo-preset-key">${index + 1}</span>` : ""}${escapeHtml(preset.label)}</button>`,
)
.join("");
target.innerHTML = `${chips}<button type="button" class="memo-preset-chip memo-preset-add" id="memo-preset-save">+ 현재 메모 저장</button>`;
}
function applyMemoPreset(presetId) {
const preset = decisionMemoPresets.find((item) => item.id === presetId);
const memo = document.getElementById("decision-memo");
if (!preset || !memo) return;
memo.value = memo.value.trim() ? `${memo.value.trim()} ${preset.body}` : preset.body;
memo.focus();
}
async function saveCurrentMemoAsPreset() {
const memo = document.getElementById("decision-memo");
const error = document.getElementById("memo-error");
const body = memo?.value.trim() || "";
if (!body) {
if (error) error.textContent = "저장할 메모 내용이 없습니다.";
return;
}
const label = window.prompt("프리셋 이름 (예: 연예인 초상)", body.slice(0, 20));
if (!label) return;
try {
const presets = await apiJson("/api/decision-memo-presets", {
method: "POST",
body: JSON.stringify({ label, body }),
});
replaceCollection(decisionMemoPresets, presets);
renderMemoPresets();
if (error) error.textContent = "";
} catch (errorValue) {
if (error) error.textContent = `프리셋 저장 실패: ${errorValue.message}`;
}
}
// --- Decision export ----------------------------------------------------
async function exportDecisions() {
const status = document.getElementById("bulk-decision-status");
const audience = window.confirm(
"내부 보관용(증거·점수 포함)으로 내보내려면 확인, 신청 채널 전달용(판정·메모만)으로 내보내려면 취소를 누르세요.",
)
? "internal"
: "applicant";
try {
const payload = await apiJson(
`/api/decisions/export?audience=${encodeURIComponent(audience)}`,
);
if (!payload.rows.length) {
if (status) status.textContent = "내보낼 판정 완료 케이스가 없습니다.";
return;
}
downloadTextFile(
`copyrighter-decisions-${audience}.csv`,
payload.csv,
"text/csv;charset=utf-8",
);
if (status) status.textContent = `${payload.rows.length}건을 ${audience === "internal" ? "내부 보관용" : "신청 채널 전달용"} CSV로 내보냈습니다.`;
} catch (errorValue) {
if (status) status.textContent = `내보내기 실패: ${errorValue.message}`;
}
}
function downloadTextFile(filename, text, mimeType) {
// BOM so Excel opens the Korean CSV as UTF-8 instead of the system codepage.
const blob = new Blob([`${text}`], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// --- Backup -------------------------------------------------------------
async function createBackupSnapshot() {
const status = document.getElementById("bulk-decision-status");
const button = document.getElementById("create-backup");
if (button) button.disabled = true;
if (status) status.textContent = "백업 스냅샷을 만드는 중…";
try {
const result = await apiJson("/api/admin/backup", { method: "POST" });
const megabytes = (Number(result.sizeBytes || 0) / (1024 * 1024)).toFixed(1);
if (status) {
status.textContent = `백업 완료: ${result.file} (${megabytes}MB · 미디어 ${result.mediaFiles}개)`;
}
await refreshFromApi();
} catch (errorValue) {
if (status) status.textContent = `백업 실패: ${errorValue.message}`;
} finally {
if (button) button.disabled = false;
}
}
// --- Cleared reference (positive knowledge) -----------------------------
async function registerClearedReference() {
const submission = getSelectedCase();
if (!submission) return;
const label = window.prompt(
"클리어 기준 이름 (예: 자사 보유 스톡 · 라이선스 확인됨)",
submission.title || submission.id,
);
if (label === null) return;
const note = window.prompt("메모(선택) — 왜 클리어인지", "") || "";
try {
const payload = await apiJson(
`/api/submissions/${encodeURIComponent(submission.id)}/clear-reference`,
{ method: "POST", body: JSON.stringify({ label: label.trim(), note }) },
);
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
// --- LLM draft (persist + show + pull into memo) ------------------------
function llmSummaryFor(submission) {
const evidence = submission && Array.isArray(submission.evidence) ? submission.evidence : [];
// The freshest LLM summary that actually carries body text.
return (
evidence
.filter((item) => item.source === "llm" && String(item.summary || "").trim())
.slice(-1)[0] || null
);
}
let currentLlmDraftText = "";
function renderLlmDraft(submission) {
const box = document.getElementById("llm-draft");
if (!box) return;
const summary = llmSummaryFor(submission);
if (!summary) {
box.hidden = true;
box.innerHTML = "";
currentLlmDraftText = "";
return;
}
currentLlmDraftText = String(summary.summary || "");
const urls = Array.isArray(summary.sourceUrls) ? summary.sourceUrls : [];
const sourceNote = urls.length ? `연결 근거 ${urls.length}건` : "연결 근거 없음";
box.hidden = false;
box.innerHTML = `
<div class="llm-draft-head">
<span class="llm-draft-tag">내부 요약(초안)</span>
<span class="muted small">${escapeHtml(sourceNote)} · 최종 판정은 운영자</span>
</div>
<p class="llm-draft-body">${escapeHtml(currentLlmDraftText)}</p>
<button type="button" class="llm-draft-use" id="llm-draft-use">메모 초안으로 넣기</button>
`;
}
function pullLlmDraftIntoMemo() {
const memo = document.getElementById("decision-memo");
if (!memo || !currentLlmDraftText) return;
memo.value = memo.value.trim()
? `${memo.value.trim()}\n${currentLlmDraftText}`
: currentLlmDraftText;
memo.focus();
}