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:
parent
994852d920
commit
975c252201
10 changed files with 285 additions and 2 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
# Project INDEX
|
# 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 · **신뢰 출처** (`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건 추가)
|
- 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건 추가)
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ app-bootstrap.js bindEvents, init, DOM 리스너 ← 유일하게 로드
|
||||||
|
|
||||||
**POST** — `/api/submissions/{id}/search-auto|rerun-enrichment|decision|clear-reference` ·
|
**POST** — `/api/submissions/{id}/search-auto|rerun-enrichment|decision|clear-reference` ·
|
||||||
`/api/submissions/reload|import-folder|upload-image|decision-batch` ·
|
`/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/knowledge/{id}/promote-watchlist|exclude-watchlist|deactivate|reactivate` ·
|
||||||
`/api/collections/keyword` · `/api/collections/candidates/promote-batch` ·
|
`/api/collections/keyword` · `/api/collections/candidates/promote-batch` ·
|
||||||
`/api/collections/candidates/{id}/promote|dismiss` ·
|
`/api/collections/candidates/{id}/promote|dismiss` ·
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,15 @@ def build_server(
|
||||||
elif path.startswith("/api/submissions/") and path.endswith("/search-auto"):
|
elif path.startswith("/api/submissions/") and path.endswith("/search-auto"):
|
||||||
submission_id = unquote(path.split("/")[3])
|
submission_id = unquote(path.split("/")[3])
|
||||||
self._json(store.run_auto_search(submission_id, active_store()))
|
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"):
|
elif path.startswith("/api/evidence/") and path.endswith("/status"):
|
||||||
evidence_id = unquote(path.split("/")[3])
|
evidence_id = unquote(path.split("/")[3])
|
||||||
self._json(
|
self._json(
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,12 @@ logger = logging.getLogger(__name__)
|
||||||
_QUEUE_ID_CONFLICT_EVENT = "Submission id conflict"
|
_QUEUE_ID_CONFLICT_EVENT = "Submission id conflict"
|
||||||
|
|
||||||
|
|
||||||
|
# 한 번의 증거 일괄 표시가 건드릴 수 있는 최대 행 수. 일괄 판정 상한(200)보다
|
||||||
|
# 큰 이유는 단위가 다르기 때문이다 — 그쪽은 케이스 수고 이쪽은 한 케이스 안의
|
||||||
|
# 증거 행 수다(실 DB 에 제공자 하나로 51행짜리 케이스가 있다).
|
||||||
|
MAX_EVIDENCE_STATUS_BATCH = 500
|
||||||
|
|
||||||
|
|
||||||
class CopyrighterStore(
|
class CopyrighterStore(
|
||||||
StorePersistenceMixin,
|
StorePersistenceMixin,
|
||||||
StoreQueueMixin,
|
StoreQueueMixin,
|
||||||
|
|
@ -839,6 +845,71 @@ class CopyrighterStore(
|
||||||
self._rescore_submission(submission_id)
|
self._rescore_submission(submission_id)
|
||||||
return self.review(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]]:
|
def providers(self) -> list[dict[str, Any]]:
|
||||||
return self._all("providers")
|
return self._all("providers")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1006,3 +1006,17 @@ def test_operator_gui_does_not_offer_inheritance_when_prior_decisions_conflict()
|
||||||
assert "prior-decision-conflict" in script
|
assert "prior-decision-conflict" in script
|
||||||
# 만장일치일 때만 inheritable 이 채워진다.
|
# 만장일치일 때만 inheritable 이 채워진다.
|
||||||
assert "unanimous ? priors[0].decision : null" in script
|
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
|
||||||
|
|
|
||||||
|
|
@ -8720,3 +8720,111 @@ def test_trusted_hosts_reads_name_and_aliases_of_active_entries(tmp_path: Path):
|
||||||
# www. 는 저장 시점에 떼어 둔다 - 증거 쪽 호스트도 www 가 없는 형태다.
|
# www. 는 저장 시점에 떼어 둔다 - 증거 쪽 호스트도 www 가 없는 형태다.
|
||||||
assert "partner.example" in hosts
|
assert "partner.example" in hosts
|
||||||
assert "someone" not 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")
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]");
|
const evidenceStatusButton = event.target.closest("[data-evidence-status]");
|
||||||
|
|
||||||
if (evidenceStatusButton) {
|
if (evidenceStatusButton) {
|
||||||
|
|
|
||||||
|
|
@ -235,8 +235,14 @@ function renderEvidenceGroup(group, items) {
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="evidence-group-actions">
|
||||||
|
|
||||||
<strong>${items.length}개</strong>
|
<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>
|
||||||
|
|
||||||
<div class="evidence-card-grid">
|
<div class="evidence-card-grid">
|
||||||
|
|
|
||||||
|
|
@ -3062,6 +3062,14 @@ tbody tr.selected-row,
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 그룹 헤더의 개수와 일괄 액션. 개수 옆에 두어 "이 51건을 한 번에"가 한 시선에
|
||||||
|
읽히게 한다 — 행마다 누르던 노동이 여기서 없어진다. */
|
||||||
|
.evidence-group-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.duplicate-badge {
|
.duplicate-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue