From 975c2522012d4bb11f86c99923a669015b9497eb Mon Sep 17 00:00:00 2001 From: changukyu Date: Mon, 21 Sep 2026 14:34:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A6=9D=EA=B1=B0=20=EC=9D=BC=EA=B4=84?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=E2=80=94=2051=EB=B2=88=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=ED=95=98=EB=8D=98=20=EB=90=98=EB=A8=B9=EC=9E=84?= =?UTF-8?q?=EC=9D=84=201=EB=B2=88=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8/8 실효성 평가가 "운영자 되먹임이 순증 노동"이라고 적은 항목이다. 스코어러는 점수를 만든 행이 표시돼야 점수를 내려놓는데, 단건 API(/api/evidence/{id}/status) 하나뿐이라 실 DB 제출물 하나를 100 에서 35 로 내리려면 google 증거 51행을 개별로 눌러야 했다. 되먹임이 노동보다 비싸면 아무도 되먹이지 않는다. 증거 그룹 헤더의 개수 옆에 [전부 무관] 을 붙이고, POST /api/evidence/status-batch 를 신설했다. 서버는 한 트랜잭션, 리스코어 1회, 감사 이벤트 1건으로 묶는다. 감사 부분은 그 자체로 중요하다 — 이건 한 번의 운영자 동작이고, 51건을 쓰면 동작을 잘못 표현할 뿐 아니라 500행 감사 캡을 클릭 한 번에 먹는다. 행별 who/when/note 는 각 증거 payload 에 그대로 남으므로 개별 행이 설명 불가가 되지는 않는다. 원자성: 남의 제출물 증거 id 가 섞이면 아무것도 쓰지 않고 전체가 실패한다. 절반만 표시된 상태로 끝나면 운영자는 무엇이 반영됐는지 알 수 없다. 상한은 500 이다. 일괄 판정 상한(200)보다 큰 이유는 단위가 다르기 때문이다 — 그쪽은 케이스 수고 이쪽은 한 케이스 안의 증거 행 수다. 그룹 액션은 irrelevant(무관)로 보낸다. 행별 "미사용" 버튼은 false_positive (오탐)에 매핑돼 있는데, 한 제공자의 결과 전체를 오탐이라 단정하는 것은 과한 주장이다. 점수 효과는 같고(스코어러는 둘 다 건너뛴다) 주장만 정직해진다. 확인 대화는 두지 않았다. 이 동작은 행마다 되돌릴 수 있고, 여기서 줄이려는 것이 바로 그 클릭이다. 되돌릴 수 없는 느낌이 드는 것은 판정이고 확인 대화는 거기에만 있다. 실렌더(Playwright 1440x900, 실제 서버): 51행 그룹에서 1타로 {"status":"irrelevant","evidence_ids":[51개]} 전송, 콘솔 에러 0. 검증: ruff clean, pytest 636 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V --- docs/INDEX.md | 2 + docs/PROJECT_MAP.md | 2 +- src/rights_filter/server/http_app.py | 9 ++ src/rights_filter/server/sqlite_store.py | 71 ++++++++++++ tests/operator_gui/test_static_workbench.py | 14 +++ .../rights_filter/server/test_sqlite_store.py | 108 ++++++++++++++++++ web/operator-gui/app-admin.js | 49 ++++++++ web/operator-gui/app-bootstrap.js | 16 +++ web/operator-gui/app-render-evidence.js | 8 +- web/operator-gui/styles.css | 8 ++ 10 files changed, 285 insertions(+), 2 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 634afee..67027aa 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # Project INDEX +- 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** + - 2026-09-21 · **신뢰 출처** (`SCORER_VERSION` v3 → v4) — 자사·계약처 도메인에서 발견된 웹 탐지 행을 0점 처리한다. 8/8 평가가 "목록만 정해지면 `cleared_reference` 패턴으로 바로 붙는다"고 남긴 항목이고, 전제였던 host 파괴는 9/18 에 고쳤다. 저장 위치는 **지식DB**(사용자 결정) — `trusted_host` 항목의 이름이 호스트이고 별칭이 같은 주체의 다른 도메인이다. 신뢰 철회에 별도 스위치를 두지 않고 지식 항목의 기존 활성/비활성 수명주기를 그대로 쓴다. **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다(실측: 신뢰 등록 전 80/high → 후 35/medium). 서브도메인은 접미사 매치로 같은 주체 취급(`cdn.posar.info`), `posar.info.evil.test` 같은 유사 도메인은 불일치, 점 없는 값(`com`)은 무시. 신선한 CSE 행은 `host` 키가 없으므로 `domain`(displayLink)에서 해석한다. 제외된 행은 점수에서 빠지되 **사유로 남는다** — 증거 목록에 그 행이 그대로 보이므로 왜 조용한지 말해줘야 한다. 다이어그램 사실 게이트가 v3→v4 를 **3개 다이어그램에서 자동으로 잡아** 스펙 수정 + 재생성으로 이어졌다(게이트 신설 이틀 만의 첫 실사용). **ruff clean · pytest 632 passed**(스코어러 7 + 스토어 3) - 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건 추가) diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index fd16df5..d5b0f1b 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -133,7 +133,7 @@ app-bootstrap.js bindEvents, init, DOM 리스너 ← 유일하게 로드 **POST** — `/api/submissions/{id}/search-auto|rerun-enrichment|decision|clear-reference` · `/api/submissions/reload|import-folder|upload-image|decision-batch` · -`/api/evidence/{id}/status` · `/api/search/manual` · `/api/knowledge/manual` · +`/api/evidence/{id}/status` · `/api/evidence/status-batch` · `/api/search/manual` · `/api/knowledge/manual` · `/api/knowledge/{id}/promote-watchlist|exclude-watchlist|deactivate|reactivate` · `/api/collections/keyword` · `/api/collections/candidates/promote-batch` · `/api/collections/candidates/{id}/promote|dismiss` · diff --git a/src/rights_filter/server/http_app.py b/src/rights_filter/server/http_app.py index 4b252ba..47f5277 100644 --- a/src/rights_filter/server/http_app.py +++ b/src/rights_filter/server/http_app.py @@ -172,6 +172,15 @@ def build_server( elif path.startswith("/api/submissions/") and path.endswith("/search-auto"): submission_id = unquote(path.split("/")[3]) self._json(store.run_auto_search(submission_id, active_store())) + elif path == "/api/evidence/status-batch": + self._json( + store.mark_evidence_status_batch( + str(body.get("submission_id", "")), + [str(item) for item in (body.get("evidence_ids") or [])], + str(body.get("status", "")), + str(body.get("note", "")), + ) + ) elif path.startswith("/api/evidence/") and path.endswith("/status"): evidence_id = unquote(path.split("/")[3]) self._json( diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index 036b39b..2f88c6d 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -74,6 +74,12 @@ logger = logging.getLogger(__name__) _QUEUE_ID_CONFLICT_EVENT = "Submission id conflict" +# 한 번의 증거 일괄 표시가 건드릴 수 있는 최대 행 수. 일괄 판정 상한(200)보다 +# 큰 이유는 단위가 다르기 때문이다 — 그쪽은 케이스 수고 이쪽은 한 케이스 안의 +# 증거 행 수다(실 DB 에 제공자 하나로 51행짜리 케이스가 있다). +MAX_EVIDENCE_STATUS_BATCH = 500 + + class CopyrighterStore( StorePersistenceMixin, StoreQueueMixin, @@ -839,6 +845,71 @@ class CopyrighterStore( self._rescore_submission(submission_id) return self.review(submission_id) + def mark_evidence_status_batch( + self, + submission_id: str, + evidence_ids: list[str], + status: str, + note: str = "", + ) -> dict[str, Any]: + """Mark many evidence rows in one action. + + The per-row endpoint made operator feedback cost more labour than it + saved: dropping one real case from 100 to 35 meant marking 51 google + rows one at a time, because the scorer only sheds points when the rows + carrying them are marked. That is the opposite of the tool's purpose. + + One transaction, one rescore, one audit event. The audit part matters on + its own: this was ONE operator action, and writing 51 events for it would + both misrepresent it and spend the 500-row audit cap on a single click. + The per-row who/when/note still lands on each evidence payload, so + nothing about the individual rows becomes unanswerable. + """ + if status not in EVIDENCE_OPERATOR_STATUSES: + raise ValueError("unsupported evidence status") + + unique_ids = list(dict.fromkeys(str(item).strip() for item in evidence_ids if str(item).strip())) + if not unique_ids: + raise ValueError("evidence ids required") + if len(unique_ids) > MAX_EVIDENCE_STATUS_BATCH: + raise ValueError(f"batch too large: {len(unique_ids)} > {MAX_EVIDENCE_STATUS_BATCH}") + + label = EVIDENCE_OPERATOR_STATUSES[status] + stamped_at = _now_label() + with self._write_lock: + with self._transaction() as conn: + # Read and validate every row before writing any of them: a batch + # that names someone else's evidence fails whole rather than + # leaving half the rows marked. + payloads = [] + for evidence_id in unique_ids: + payload = self._get("evidence", evidence_id, conn=conn) + if str(payload.get("submission_id", "")) != submission_id: + raise ValueError("evidence does not belong to submission") + payloads.append((evidence_id, payload)) + + for evidence_id, payload in payloads: + payload["operatorStatus"] = status + payload["operatorStatusLabel"] = label + payload["operatorStatusNote"] = note.strip() + payload["operatorStatusAt"] = stamped_at + payload["operatorStatusActor"] = "rights.ops" + if status in NON_CONTRIBUTING_OPERATOR_STATUSES: + payload["contributed"] = False + else: + payload["contributed"] = _default_evidence_contribution(payload) + self._put("evidence", evidence_id, payload, conn=conn) + + self.add_audit_event( + actor="rights.ops", + event="Evidence status changed in batch", + object_id=submission_id, + change=f"{label} · {len(payloads)}건 · {note or 'note optional'}", + conn=conn, + ) + self._rescore_submission(submission_id) + return self.review(submission_id) + def providers(self) -> list[dict[str, Any]]: return self._all("providers") diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index aa887af..3ce280c 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -1006,3 +1006,17 @@ def test_operator_gui_does_not_offer_inheritance_when_prior_decisions_conflict() assert "prior-decision-conflict" in script # 만장일치일 때만 inheritable 이 채워진다. assert "unanimous ? priors[0].decision : null" in script +def test_operator_gui_can_mark_a_whole_evidence_group_at_once(): + # 실 DB 제출물 하나를 100 에서 35 로 내리려면 google 증거 51행을 개별로 + # 눌러야 했다. 되먹임이 노동보다 비싸면 아무도 되먹이지 않는다. + script = _read_app() + styles = _read(STYLES) + + assert "setEvidenceGroupStatus" in script + assert "/api/evidence/status-batch" in script + assert "data-evidence-group-status" in script + assert "전부 무관" in script + assert ".evidence-group-actions" in styles + + # 그룹 전체를 "오탐"으로 단정하지 않는다 - 무관이 정직한 주장이다. + assert 'data-evidence-group-status="irrelevant"' in script diff --git a/tests/rights_filter/server/test_sqlite_store.py b/tests/rights_filter/server/test_sqlite_store.py index 23291ba..ad20dc0 100644 --- a/tests/rights_filter/server/test_sqlite_store.py +++ b/tests/rights_filter/server/test_sqlite_store.py @@ -8720,3 +8720,111 @@ def test_trusted_hosts_reads_name_and_aliases_of_active_entries(tmp_path: Path): # www. 는 저장 시점에 떼어 둔다 - 증거 쪽 호스트도 www 가 없는 형태다. assert "partner.example" in hosts assert "someone" not in hosts + + +def _web_rows(store: CopyrighterStore, submission_id: str, count: int) -> list[str]: + """제출물에 점수를 만드는 웹 탐지 행 여러 개를 붙이고 그 id 를 준다.""" + ids = [] + for index in range(count): + evidence = Evidence( + source=EvidenceSource.WEB_DETECTION, + reason=f"결과 {index}", + confidence=0.9, + data={ + "match": "full", + "provider": "google", + "host": f"host{index}.example.test", + "url": f"https://host{index}.example.test/a.jpg", + }, + ) + payload = store_serialization_module._evidence_payload(submission_id, evidence) + store._put("evidence", payload["id"], payload) + ids.append(payload["id"]) + return ids + + +def test_evidence_status_batch_marks_every_row_with_one_audit_event(tmp_path: Path): + # 단건 API 만 있던 동안 운영자 되먹임은 순증 노동이었다 — 한 케이스를 + # 100 에서 35 로 내리려면 증거 51행을 개별로 눌러야 했다. 일괄 표시는 한 번의 + # 동작이므로 감사 이벤트도 1건이어야 한다(행마다 쓰면 500행 캡을 먹는다). + image_root = tmp_path / "submissions" + store = CopyrighterStore(tmp_path / "copyrighter.sqlite3") + store.initialize() + store.seed_from_image_store(_image_store(image_root)) + submission_id = str(store.bootstrap()["submissions"][0]["id"]) + ids = _web_rows(store, submission_id, 5) + store._rescore_submission(submission_id) + before = store._get("submissions", submission_id)["riskScore"] + + store.mark_evidence_status_batch(submission_id, ids, "irrelevant", note="자사 공지 페이지") + + after = store._get("submissions", submission_id) + assert after["riskScore"] < before, "무관 표시가 점수에서 빠져야 한다" + for evidence_id in ids: + payload = store._get("evidence", evidence_id) + assert payload["operatorStatus"] == "irrelevant" + assert payload["contributed"] is False + # 행별 who/when/note 는 그대로 남는다 — 감사 이벤트를 묶었다고 해서 + # 개별 행이 설명 불가가 되지는 않는다. + assert payload["operatorStatusNote"] == "자사 공지 페이지" + assert payload["operatorStatusActor"] == "rights.ops" + + batch_events = [ + event for event in store.audit_events() + if event.get("event") == "Evidence status changed in batch" + ] + assert len(batch_events) == 1 + assert "5건" in batch_events[0]["change"] + + +def test_evidence_status_batch_rejects_a_foreign_evidence_id_without_marking_anything(tmp_path: Path): + # 절반만 표시된 상태로 끝나면 운영자는 무엇이 반영됐는지 알 수 없다. + image_root = tmp_path / "submissions" + store = CopyrighterStore(tmp_path / "copyrighter.sqlite3") + store.initialize() + store.seed_from_image_store(_image_store(image_root)) + submission_id = str(store.bootstrap()["submissions"][0]["id"]) + ids = _web_rows(store, submission_id, 3) + # evidence.submission_id 는 submissions 에 FK 이므로 남의 제출물이 실재해야 한다. + store._put( + "submissions", + "SUB-OTHER", + { + "id": "SUB-OTHER", + "title": "다른 케이스", + "asset": "", + "riskScore": 0, + "riskBand": "low", + "decisionStatus": "unreviewed", + "providerState": {}, + "fileFacts": {}, + "evidence": [], + }, + ) + foreign = store_serialization_module._evidence_payload( + "SUB-OTHER", + Evidence(EvidenceSource.WEB_DETECTION, "남의 증거", 0.9, {"match": "full", "host": "x.example.test"}), + ) + store._put("evidence", foreign["id"], foreign) + + with pytest.raises(ValueError, match="does not belong"): + store.mark_evidence_status_batch(submission_id, [*ids, foreign["id"]], "irrelevant") + + for evidence_id in ids: + assert store._get("evidence", evidence_id).get("operatorStatus", "") != "irrelevant" + + +def test_evidence_status_batch_refuses_an_empty_or_oversized_batch(tmp_path: Path): + store = CopyrighterStore(tmp_path / "copyrighter.sqlite3") + store.initialize() + + with pytest.raises(ValueError, match="evidence ids required"): + store.mark_evidence_status_batch("SUB-1", [], "irrelevant") + + with pytest.raises(ValueError, match="batch too large"): + store.mark_evidence_status_batch( + "SUB-1", [f"ev-{index}" for index in range(sqlite_store_module.MAX_EVIDENCE_STATUS_BATCH + 1)], "irrelevant" + ) + + with pytest.raises(ValueError, match="unsupported evidence status"): + store.mark_evidence_status_batch("SUB-1", ["ev-1"], "nope") diff --git a/web/operator-gui/app-admin.js b/web/operator-gui/app-admin.js index 4cdc767..573cc7c 100644 --- a/web/operator-gui/app-admin.js +++ b/web/operator-gui/app-admin.js @@ -280,3 +280,52 @@ async function emergencyDisableExternalProviders() { } + +// 증거 그룹 전체를 한 번에 표시한다. +// +// 단건 API 만 있던 동안 운영자 되먹임은 순증 노동이었다 — 실 DB 제출물 하나를 +// 100 에서 35 로 내리려면 google 증거 51행을 개별로 눌러야 했다. 확인 대화를 +// 두지 않는 이유는 이 동작이 되돌릴 수 있고(행마다 다시 표시 가능), +// 여기서 줄이려는 것이 바로 그 클릭이기 때문이다. 되돌릴 수 없는 느낌이 드는 +// 것은 판정이고, 확인 대화는 거기에만 있다. +async function setEvidenceGroupStatus(groupId, status, note = "") { + + const submission = getSelectedCase(); + + if (!submission || !groupId) return; + + const items = groupedEvidence(submission.evidence)[groupId] || []; + + const evidenceIds = items.map((item) => item.id).filter(Boolean); + + if (!evidenceIds.length) return; + + try { + + await apiJson("/api/evidence/status-batch", { + + method: "POST", + + body: JSON.stringify({ + + submission_id: submission.id, + + evidence_ids: evidenceIds, + + status: evidenceStatusPayload(status), + + note, + + }), + + }); + + await refreshFromApi(); + + } catch (errorValue) { + + showApiError(errorValue.message); + + } + +} diff --git a/web/operator-gui/app-bootstrap.js b/web/operator-gui/app-bootstrap.js index db468a2..b003580 100644 --- a/web/operator-gui/app-bootstrap.js +++ b/web/operator-gui/app-bootstrap.js @@ -369,6 +369,22 @@ function bindEvents() { + const evidenceGroupStatusButton = event.target.closest("[data-evidence-group-status]"); + + if (evidenceGroupStatusButton) { + + setEvidenceGroupStatus( + + evidenceGroupStatusButton.dataset.evidenceGroup, + + evidenceGroupStatusButton.dataset.evidenceGroupStatus, + + ); + + return; + + } + const evidenceStatusButton = event.target.closest("[data-evidence-status]"); if (evidenceStatusButton) { diff --git a/web/operator-gui/app-render-evidence.js b/web/operator-gui/app-render-evidence.js index 037facb..1339692 100644 --- a/web/operator-gui/app-render-evidence.js +++ b/web/operator-gui/app-render-evidence.js @@ -235,7 +235,13 @@ function renderEvidenceGroup(group, items) { - ${items.length}개 +
+ + ${items.length}개 + + + +
diff --git a/web/operator-gui/styles.css b/web/operator-gui/styles.css index 5d1f4b5..b963051 100644 --- a/web/operator-gui/styles.css +++ b/web/operator-gui/styles.css @@ -3062,6 +3062,14 @@ tbody tr.selected-row, color: var(--amber); } +/* 그룹 헤더의 개수와 일괄 액션. 개수 옆에 두어 "이 51건을 한 번에"가 한 시선에 + 읽히게 한다 — 행마다 누르던 노동이 여기서 없어진다. */ +.evidence-group-actions { + display: flex; + align-items: center; + gap: 8px; +} + .duplicate-badge { display: inline-block; margin-top: 3px;