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
This commit is contained in:
changukyu 2026-09-21 18:41:22 +09:00
parent 0c60369da0
commit e169e24584
7 changed files with 216 additions and 8 deletions

View file

@ -1,5 +1,7 @@
# Project INDEX
- 2026-09-21 · **반려 키보드 완결** — 승인·보류는 1타인데 반려만 메모 필수라 손이 마우스로 갔다(8/8 평가 P2-10). `R` 이 메모 필드에 포커스만 주고, 입력 중에는 전역 단축키가 막혀서 거기서부터는 **마우스로만 제출**할 수 있는 구조였다 — 법적으로 가장 중요한 경로가 가장 느렸다. `R` → 사유 선택 모드(프리셋 1~9 번호 표시) → 숫자 1타로 반려 완료, 자유 서술은 메모 안에서 `Ctrl+Enter`. 화면의 번호가 곧 단축키가 되도록 프리셋 칩에 키 배지를 붙였다(외울 필요 없음). **숫자는 사유 선택 모드 안에서만 의미를 갖는다** — 모드 밖에서 잘못 누른 키 하나로 반려가 나가면 안 되고, 그 가드 순서를 테스트로 고정했다. 실렌더(Playwright): `R`→`2` 로 `{"decision":"rejected","memo":"라이선스 확인 불가로 반려"}`, 메모+`Ctrl+Enter` 로 `{"decision":"rejected","memo":"자유 서술 사유"}`, 콘솔 에러 0. 반려 후 승인·보류와 같이 다음 미심사 케이스로 이동한다. **ruff clean · pytest 645 passed**(정적 3건)
- 2026-09-21 · **점수 구성 패널** — "증거 51개인데 왜 35점"에 답한다. 스코어러가 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를 갖게 되면서 증거 개수와 점수의 간극이 벌어졌는데, 그 이유가 화면 어디에도 없었다 — 제가 그 간극을 만든 장본인이라 자체 부채였다. `ScoreResult.breakdown` 을 신설해 종류별 기여·풀 상한에 삼켜진 점수·제외된 행을 기록하고, 제출물에 `riskBreakdown` 으로 영속화한 뒤 증거 요약과 증거 목록 **사이**(그 질문이 생기는 지점)에 렌더한다. 실렌더 예: `지식DB 지문 매칭 80 / 웹 게시(google) 60 (원래 125 · 상한 60) / 웹 엔티티 라벨 20 (원래 114 · 상한 20) / 신뢰 출처 posar.info 3행 제외 / 합계 115점이 상한 100에 걸렸습니다`. **점수는 하나도 안 바뀐다** — 기존 스코어러 테스트 31건이 그대로 통과하므로 `SCORER_VERSION` 은 v4 유지(가중치·버킷·밴드 불변). 분해 합계가 항상 점수와 일치하는지도 테스트로 고정했다(분해가 다른 이야기를 하면 분해가 아니라 또 하나의 주장이 된다). **ruff clean · pytest 642 passed**(스코어러 5 + 정적 1)
- 2026-09-21 · **증거 일괄 표시** (`POST /api/evidence/status-batch`) — 운영자 되먹임이 순증 노동이던 문제를 끝낸다. 8/8 평가 기준으로 실 DB 제출물 하나를 100→35 로 내리려면 google 증거 **51행을 개별 클릭**해야 했다(단건 API 만 존재). 증거 그룹 헤더의 개수 옆에 `전부 무관` 1타를 붙였다. 서버는 **한 트랜잭션 · 리스코어 1회 · 감사 이벤트 1건** — 51행이면 감사도 51건이 되어 500행 캡을 한 번의 클릭으로 먹는다. 행별 who/when/note 는 그대로 남으므로 개별 행이 설명 불가가 되지는 않는다. 남의 증거 id 가 섞이면 **아무것도 표시하지 않고 전체 실패**(절반만 반영되면 운영자가 무엇이 됐는지 알 수 없다), 상한 500(일괄 판정 200 과 단위가 다름 — 그쪽은 케이스 수, 이쪽은 한 케이스의 증거 행 수). 그룹 액션은 `irrelevant`(무관)로 보낸다 — 행별 "미사용"은 `false_positive`(오탐)에 매핑되는데, 제공자 결과 전체를 오탐이라 단정하는 건 과한 주장이다. 실렌더(Playwright 1440×900): 51행 그룹에서 1타로 `{"status":"irrelevant","evidence_ids":[51개]}` 전송, 콘솔 에러 0. **ruff clean · pytest 636 passed**

View file

@ -1037,3 +1037,39 @@ def test_operator_gui_explains_how_the_score_was_reached():
assert "원래" in script and "상한" in script
# 100 포화는 그 자체로 알려야 한다 - 그 구간은 서로 구별되지 않는다.
assert "score-breakdown-clamped" in script
def test_operator_gui_completes_the_rejection_path_from_the_keyboard():
# 승인·보류는 1타인데 반려만 메모 필수라 손이 마우스로 갔다 — 법적으로 가장
# 중요한 경로가 가장 느렸다. R 로 사유를 고르면 두 키로 끝난다.
script = _read_app()
markup = _read(INDEX)
styles = _read(STYLES)
assert "openRejectPrompt" in script
assert "rejectWithPreset" in script
assert "closeRejectPrompt" in script
assert 'id="reject-prompt"' in markup
assert ".reject-prompt" in styles
# 자유 서술은 메모 안에서 끝나야 한다 — 입력 중에는 전역 단축키가 막힌다.
assert 'event.key !== "Enter"' in script
assert "R : 반려 사유 선택" in script
def test_operator_gui_only_treats_digits_as_reject_reasons_inside_the_prompt():
# 모드 밖에서 숫자에 의미를 주면 잘못 누른 키 하나로 반려가 나간다.
script = _read_app()
assert "isRejectPromptOpen" in script
prompt_guard = script.index("if (isRejectPromptOpen) {")
digit_branch = script.index('if (key >= "1" && key <= "9") {')
assert prompt_guard < digit_branch, "숫자 처리는 반드시 모드 가드 안에 있어야 한다"
def test_operator_gui_numbers_the_memo_presets_to_match_the_shortcuts():
# 화면의 번호와 눌러야 할 키가 다르면 단축키는 외워야 하는 것이 된다.
script = _read_app()
styles = _read(STYLES)
assert "memo-preset-key" in script
assert ".memo-preset-key" in styles
assert "index + 1" in script

View file

@ -198,6 +198,19 @@ function bindEvents() {
document.getElementById("upload-submission-image").addEventListener("click", uploadSubmissionImage);
document.getElementById("rerun-enrichment").addEventListener("click", rerunEnrichment);
// 입력 중 전역 단축키가 막히는 것은 의도된 동작이다(J/K 가 글자로 들어가면
// 안 된다). 다만 그 때문에 자유 서술 반려는 메모를 친 뒤 마우스로 가야 했다.
// Ctrl+Enter 하나만 열어 그 경로를 키보드로 끝낸다.
document.getElementById("decision-memo").addEventListener("keydown", (event) => {
if (!(event.ctrlKey || event.metaKey) || event.key !== "Enter") return;
event.preventDefault();
setDecision("rejected", true).then(() => moveToNextUnreviewed());
});
// 선례 승계 버튼은 케이스마다 다시 그려지므로 컨테이너에 위임한다.
document.getElementById("prior-decision-box").addEventListener("click", (event) => {

View file

@ -20,9 +20,11 @@ function renderMemoPresets() {
.map(
(preset) =>
// 화면의 번호가 곧 단축키다. R 로 사유 선택에 들어간 뒤 같은 숫자를
// 누르면 그 사유로 반려된다. 10번째부터는 번호 없이 마우스로만 쓴다.
(preset, index) =>
`<button type="button" class="memo-preset-chip" data-preset-id="${escapeHtml(preset.id)}" title="${escapeHtml(preset.body)}">${escapeHtml(preset.label)}</button>`,
`<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>`,
)

View file

@ -16,7 +16,9 @@ const KEYBOARD_HELP = [
"K / ↑ : 이전 케이스",
"A : 승인 H : 보류 R : 반려(메모로 이동)",
"A : 승인 H : 보류 R : 반려 사유 선택",
" 사유 선택 중 — 1~9 : 사유 선택 후 즉시 반려 · M : 직접 입력 · Esc : 취소",
" 메모 입력 중 — Ctrl+Enter : 반려 제출",
"N : 다음 미심사 케이스",
@ -199,17 +201,62 @@ function handleReviewShortcut(event) {
}
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") {
// Rejection needs a memo, so hand the operator the field instead of firing
// a request that would fail validation.
// 반려는 메모가 필수라 한 키로 끝낼 수 없다. 그렇다고 메모 필드에 포커스만
// 주면 거기서부터는 마우스로만 제출할 수 있다(입력 중에는 단축키가 막힌다) -
// 법적으로 가장 중요한 경로가 가장 느린 이유였다. 사유 선택을 한 단계 두어
// 자주 쓰는 사유는 두 키로, 자유 서술은 Ctrl+Enter 로 끝나게 한다.
switchView("workbench");
if (!openRejectPrompt()) {
const memo = document.getElementById("decision-memo");
if (memo) memo.focus();
}
event.preventDefault();
}
@ -462,3 +509,69 @@ async function applyBulkDecision(decision) {
}
// --- 반려 사유 선택 -----------------------------------------------------------
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());
}

View file

@ -259,6 +259,7 @@
<textarea id="decision-memo" rows="3" placeholder="반려 또는 보정 시 메모 필수"></textarea>
</label>
<div class="memo-presets" id="memo-presets" aria-label="자주 쓰는 사유"></div>
<div class="reject-prompt" id="reject-prompt" hidden aria-live="polite"></div>
<p class="form-error" id="memo-error" aria-live="assertive"></p>
<div class="decision-actions">
<button class="approve-action" type="button" data-decision="approved">승인</button>

View file

@ -3066,6 +3066,47 @@ tbody tr.selected-row,
읽히게 한다 — 행마다 누르던 노동이 여기서 없어진다. */
/* 점수 구성. 증거 목록과 점수 사이의 간극을 메우는 자리라, 증거 요약 바로
아래에 둔다. */
/* 반려 사유 선택. 승인·보류가 1타인데 반려만 마우스로 끝나던 비대칭을 없앤다 —
법적으로 가장 중요한 경로가 가장 느릴 이유가 없다. */
.reject-prompt {
margin-top: 8px;
padding: 8px 10px;
border: 1px solid var(--red-line);
border-radius: 8px;
background: var(--red-tint);
font-size: 0.8rem;
}
.reject-prompt ol {
margin: 6px 0;
padding: 0;
list-style: none;
}
.reject-prompt li {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
}
/* 화면의 번호가 곧 단축키다. 눌러야 할 키를 키처럼 보이게 한다. */
.memo-preset-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
margin-right: 4px;
padding: 0 3px;
border: 1px solid var(--rail-ink);
border-radius: 3px;
background: var(--rail-ink-strong);
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--ink-soft);
}
.score-breakdown {
margin-top: 10px;
padding: 10px 12px;