feat: 증거 일괄 표시 — 51번 클릭하던 되먹임을 1번으로

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
changukyu 2026-09-21 14:34:05 +09:00
parent 994852d920
commit 975c252201
10 changed files with 285 additions and 2 deletions

View file

@ -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건 추가)

View file

@ -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` ·

View file

@ -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(

View file

@ -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")

View file

@ -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

View file

@ -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")

View file

@ -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);
}
}

View file

@ -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) {

View file

@ -235,8 +235,14 @@ function renderEvidenceGroup(group, items) {
</div>
<div class="evidence-group-actions">
<strong>${items.length}개</strong>
<button class="row-action" type="button" data-evidence-group-status="irrelevant" data-evidence-group="${escapeHtml(group.id)}">전부 무관</button>
</div>
</div>
<div class="evidence-card-grid">

View file

@ -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;