From e169e24584470c2d8b2d2bf4a0434e9e2f09215b Mon Sep 17 00:00:00 2001 From: changukyu Date: Mon, 21 Sep 2026 18:41:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B0=98=EB=A0=A4=20=ED=82=A4=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=99=84=EA=B2=B0=20=E2=80=94=20=EA=B0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=A4=91=EC=9A=94=ED=95=9C=20=EA=B2=BD=EB=A1=9C=EA=B0=80=20?= =?UTF-8?q?=EA=B0=80=EC=9E=A5=20=EB=8A=90=EB=A0=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 승인·보류는 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) Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V --- docs/INDEX.md | 2 + tests/operator_gui/test_static_workbench.py | 36 ++++++ web/operator-gui/app-bootstrap.js | 13 ++ web/operator-gui/app-decision-tools.js | 6 +- web/operator-gui/app-keyboard-batch.js | 125 +++++++++++++++++++- web/operator-gui/index.html | 1 + web/operator-gui/styles.css | 41 +++++++ 7 files changed, 216 insertions(+), 8 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index cb7a42f..7406ed0 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -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** diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index 57daafd..9e48e27 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -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 diff --git a/web/operator-gui/app-bootstrap.js b/web/operator-gui/app-bootstrap.js index b003580..dc98c1a 100644 --- a/web/operator-gui/app-bootstrap.js +++ b/web/operator-gui/app-bootstrap.js @@ -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) => { diff --git a/web/operator-gui/app-decision-tools.js b/web/operator-gui/app-decision-tools.js index 6b12664..795adac 100644 --- a/web/operator-gui/app-decision-tools.js +++ b/web/operator-gui/app-decision-tools.js @@ -20,9 +20,11 @@ function renderMemoPresets() { .map( - (preset) => + // 화면의 번호가 곧 단축키다. R 로 사유 선택에 들어간 뒤 같은 숫자를 + // 누르면 그 사유로 반려된다. 10번째부터는 번호 없이 마우스로만 쓴다. + (preset, index) => - ``, + ``, ) diff --git a/web/operator-gui/app-keyboard-batch.js b/web/operator-gui/app-keyboard-batch.js index 3b37fe5..e059285 100644 --- a/web/operator-gui/app-keyboard-batch.js +++ b/web/operator-gui/app-keyboard-batch.js @@ -16,7 +16,9 @@ const KEYBOARD_HELP = [ "K / ↑ : 이전 케이스", - "A : 승인 H : 보류 R : 반려(메모로 이동)", + "A : 승인 H : 보류 R : 반려 사유 선택", + " 사유 선택 중 — 1~9 : 사유 선택 후 즉시 반려 · M : 직접 입력 · Esc : 취소", + " 메모 입력 중 — Ctrl+Enter : 반려 제출", "N : 다음 미심사 케이스", @@ -199,16 +201,61 @@ 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"); - const memo = document.getElementById("decision-memo"); + if (!openRejectPrompt()) { - if (memo) memo.focus(); + 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 = ` + + 반려 사유 선택 + +
    ${presets.map((preset, index) => `
  1. ${index + 1}${escapeHtml(preset.label)}
  2. `).join("")}
+ + M : 직접 입력 · Esc : 취소 + + `; + + 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()); + +} diff --git a/web/operator-gui/index.html b/web/operator-gui/index.html index 7eda983..9f7753a 100644 --- a/web/operator-gui/index.html +++ b/web/operator-gui/index.html @@ -259,6 +259,7 @@
+

diff --git a/web/operator-gui/styles.css b/web/operator-gui/styles.css index e1dcf57..3dbe0d1 100644 --- a/web/operator-gui/styles.css +++ b/web/operator-gui/styles.css @@ -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;