diff --git a/docs/INDEX.md b/docs/INDEX.md index a26949e..2a822e5 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # Project INDEX +- 2026-09-21 · **선례 승계** — 재제출을 다시 심사하지 않게 한다. 8/8 실효성 평가의 미해결 결론("검색·기록 노동은 줄여주나 심사 건수는 못 줄인다")을 직접 겨냥한 첫 기능이다. 중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능인데(재저장·80%/50%축소·밝기 변형 24/24 그룹핑, 같은 24건 SHA-256 일치 0건), 서버가 계산해 내려보내던 `priorDecisions`·`conflicting` 이 **큐 배지의 title 속성에만** 있었다 — 마우스를 올린 사람만 보는 자리라 아무것도 막지 못했다. 서버 변경 없이 콘솔만 고쳤다: 배지 본문에 선례 표시, 판정 패널(결정 메모 바로 위)에 선례 배너 + `선례 승계` 1타. 승계는 별도 경로를 만들지 않고 메모를 채워 기존 `setDecision` 을 타므로 반려 메모 필수·판정 스냅샷·감사 이벤트가 그대로 유지된다. **판정 불일치(conflicting)면 승계를 제안하지 않는다** — 그때가 사람이 봐야 하는 상황이다. 실렌더 확인(Playwright, 1440×900): 세 상태(승계 가능/불일치/선례 없음) 배너·버튼 유무가 의도대로였고 승계 클릭 시 `{"decision":"rejected","memo":"선례 승계: 동일 이미지 SUB-OLD1, SUB-OLD2 와 같은 판정"}` 전송, 콘솔 에러 0. **ruff clean · pytest 622 passed**(정적 3건 추가) + - 2026-09-21 · 다이어그램 용도를 **내부 문서로 확정**하고 그 기준으로 부채를 재판정. 한글 폰트 미번들·영어 뷰어 UI 는 archify 한계라 spec 으로 못 고치는데, 사내 머신에는 한글 폰트가 있으므로 low 로 내렸다(인수물로 승격하면 둘이 먼저 blocker — PROJECT_MAP 다이어그램 절에 승격 조건으로 명시). 이어서 **코드↔그림 리터럴 드리프트 게이트** `tests/test_diagram_facts.py` 19건 신설 — 그림에 하드카피된 코드 유래 값(SCORER_VERSION · SCHEMA_VERSION · 믹스인 8개 · `app-*.js` 15개 · 바디 10MB · 인증 10회/60초 · 프롬프트 12행 · 풀 상한 60/20 · 기본 모델 · 런타임 의존성 3종)을 실제 코드 상수와 대조하고, 상수가 아닌 "지문 유사도 0.9+ → 80점" 은 실제로 채점해 확인한다. SCORER_VERSION v3→v4 와 MAX_PROMPT_EVIDENCE 12→16 을 실제로 넣어 **영향받는 세 다이어그램 전부에서 차단됨**을 실증. 발견 가능성도 정리 — README 문서 색인에 등재, `docs/diagrams/README.md` 신설(폴더 안에 '직접 수정 금지'·재생성 명령·CSP 주의). **ruff clean · pytest 619 passed**(사실 게이트 +19) - 2026-09-21 · 다이어그램 감사 후 **드리프트 게이트 신설**. 에이전트 6축 감사가 주간 한도로 절반(2축)만 완주했고 검증 단계도 죽어, 완주분 15건을 저널에서 복구한 뒤 결정적 주장은 직접 재검증했다. 확정된 구멍 — 그림 4종의 재생성 절차가 **문서대로는 실행 불가**(`archify` 실행파일이 PATH 에 없음), 소스↔산출물 동기화를 보는 장치 0개, 사이드카 24개가 개발 머신 절대경로를 담은 채 미추적·비ignore, 임베드 폰트에 **한글 구간 0건**(`U+AC00` 매치 0 · 폴백은 중국어 폰트뿐). 실수정: `scripts/rebuild-diagrams.ps1`(validate→deliver→visual-check→manifest, 실행 확인)과 `scripts/update_diagram_manifest.py`, 수신증 `docs/diagrams/manifest.json`, 게이트 `tests/test_diagram_sync.py` 14건(HTML 직접 수정·spec 만 수정 두 시나리오 모두 실제로 차단됨을 실증), `.gitignore` 에 사이드카 제외(미추적 24→0), `.gitattributes` 에 `linguist-generated -diff merge=binary`. 재생성해도 4종 sha256 불변 — 바이트 결정성 재확인. 한글 폰트·영어 뷰어 UI·architecture 좌표 하드코딩·lifecycle 손튜닝 8개소는 PROJECT_MAP 부채 표에 등재. **ruff clean · pytest 600 passed**(게이트 +14) diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index ebe654c..aa887af 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -969,3 +969,40 @@ def test_operator_gui_explains_a_retained_case_whose_source_file_is_gone(): assert "MISSING_ASSET_NOTE" in script assert "원본 파일 없음" in script assert ".thumb-missing" in styles +def test_operator_gui_surfaces_a_prior_decision_on_the_same_image(): + # 중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능인데, 그 결과인 + # priorDecisions 는 큐 배지의 title 속성에만 있었다 — 마우스를 올린 사람만 + # 볼 수 있는 자리라 재제출을 처음부터 다시 심사하는 것을 막지 못했다. + script = _read_app() + markup = _read(INDEX) + styles = _read(STYLES) + + assert "priorDecisionSummary" in script + assert "setPriorDecisionBox" in script + assert 'id="prior-decision-box"' in markup + assert ".prior-decision" in styles + + # 배지 본문에도 선례가 실려야 한다(title 에만 있으면 훑는 동안 안 보인다). + assert "priorNote" in script + + +def test_operator_gui_inherits_a_prior_decision_through_the_normal_decision_path(): + # 승계가 별도 판정 경로를 만들면 반려 메모 필수 검사·판정 스냅샷·감사 이벤트를 + # 우회하게 된다. setDecision 을 그대로 타야 한다. + script = _read_app() + + assert "async function inheritPriorDecision" in script + assert "data-inherit-decision" in script + assert "await setDecision(decision" in script + assert "선례 승계" in script + + +def test_operator_gui_does_not_offer_inheritance_when_prior_decisions_conflict(): + # 같은 이미지에 서로 다른 판정이 있으면 자동 제안 대상이 아니다 — + # 그때가 바로 사람이 봐야 하는 상황이다. + script = _read_app() + + assert "conflicting" in script + assert "prior-decision-conflict" in script + # 만장일치일 때만 inheritable 이 채워진다. + assert "unanimous ? priors[0].decision : null" in script diff --git a/web/operator-gui/app-bootstrap.js b/web/operator-gui/app-bootstrap.js index 7681f55..db468a2 100644 --- a/web/operator-gui/app-bootstrap.js +++ b/web/operator-gui/app-bootstrap.js @@ -198,6 +198,15 @@ function bindEvents() { document.getElementById("upload-submission-image").addEventListener("click", uploadSubmissionImage); document.getElementById("rerun-enrichment").addEventListener("click", rerunEnrichment); + // 선례 승계 버튼은 케이스마다 다시 그려지므로 컨테이너에 위임한다. + document.getElementById("prior-decision-box").addEventListener("click", (event) => { + + const button = event.target.closest("[data-inherit-decision]"); + + if (button) inheritPriorDecision(button.dataset.inheritDecision); + + }); + document.getElementById("add-from-case").addEventListener("click", addKnowledgeEntryFromCase); document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant()); diff --git a/web/operator-gui/app-keyboard-batch.js b/web/operator-gui/app-keyboard-batch.js index 04b8c1e..3b37fe5 100644 --- a/web/operator-gui/app-keyboard-batch.js +++ b/web/operator-gui/app-keyboard-batch.js @@ -244,7 +244,21 @@ function duplicateBadgeHtml(submission) { const conflictNote = group.conflicting ? " · 판정 불일치" : ""; - return ``; + // 선례를 배지 본문에 싣는다. title 에만 두면 마우스를 올린 사람만 알 수 있고, + // 큐를 훑는 동안에는 "이미 판정한 이미지"가 보이지 않는다. + const summary = priorDecisionSummary(submission); + + const priorNote = group.conflicting + + ? conflictNote + + : summary && summary.inheritable + + ? ` · 선례 ${decisionLabels[summary.inheritable] || summary.inheritable}` + + : ""; + + return ``; } diff --git a/web/operator-gui/app-navigation.js b/web/operator-gui/app-navigation.js index 00caee0..868f01c 100644 --- a/web/operator-gui/app-navigation.js +++ b/web/operator-gui/app-navigation.js @@ -448,3 +448,29 @@ function selectedBulkSubmissionIds() { } + +// 선례 승계. 별도 판정 경로를 만들지 않고 메모만 채워 setDecision 에 넘긴다 — +// 반려 메모 필수 검사, 판정 시점 스냅샷, 감사 이벤트 같은 기존 보호장치를 그대로 +// 타야 하기 때문이다. 승계했다는 사실은 메모로 감사에 남는다. +async function inheritPriorDecision(decision) { + + const submission = getSelectedCase(); + + const summary = submission ? priorDecisionSummary(submission) : null; + + // 화면이 그 사이 바뀌었으면(다른 케이스 선택, 선례 판정 변경) 조용히 무시한다. + if (!summary || summary.inheritable !== decision) return; + + const memoField = document.getElementById("decision-memo"); + + const peers = summary.priors.map((item) => item.submissionId).join(", "); + + if (memoField && !memoField.value.trim()) { + + memoField.value = `선례 승계: 동일 이미지 ${peers} 와 같은 판정`; + + } + + await setDecision(decision, decision === "rejected"); + +} diff --git a/web/operator-gui/app-render-queue.js b/web/operator-gui/app-render-queue.js index c689f81..608a6f5 100644 --- a/web/operator-gui/app-render-queue.js +++ b/web/operator-gui/app-render-queue.js @@ -197,6 +197,8 @@ function renderNoSelectedCase() { document.getElementById("case-reasons").innerHTML = `