승인·보류는 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
이건 내가 만든 부채를 갚는 커밋이다. 스코어러에 풀 상한(v2/v3)과 신뢰 출처
제외(v4)를 넣으면서 증거 개수와 점수의 간극이 그 어느 때보다 벌어졌는데,
그 이유가 화면 어디에도 없었다. 운영자가 숫자를 믿지 못하면 그 우선순위는
우선순위가 아니고, 그러면 앞의 두 기능이 오히려 불신을 만든다.
ScoreResult.breakdown 을 신설했다. reasons 가 "어떤 증거가 말했는가"라면
breakdown 은 "그래서 몇 점인가"다.
지식DB 지문 매칭 80
웹 게시 (google) 60 원래 125 · 상한 60
웹 엔티티 라벨 20 원래 114 · 상한 20
신뢰 출처 posar.info 3행 제외
합계 115점이 상한 100에 걸렸습니다 — 이 구간의 케이스는 서로 구별되지 않습니다
마지막 줄이 특히 중요하다. 100 에 걸린 케이스들은 서로 구별되지 않으므로
우선순위 판단에 쓸 수 없는데, 지금까지는 그 사실이 숨어 있었다.
자리는 증거 요약과 증거 목록 사이다 - "51개 증거"를 읽은 직후, 목록을 훑기
직전이 바로 그 질문이 생기는 지점이다.
점수는 하나도 바뀌지 않는다. 계산 흐름에 기록만 붙였고 기존 스코어러 테스트
31건이 그대로 통과한다. 가중치·버킷·밴드가 불변이므로 SCORER_VERSION 은 v4 를
유지한다(설명을 추가하는 것은 스탬프 규칙의 대상이 아니다).
분해 합계가 항상 점수와 일치하는지를 테스트로 고정했다. 분해가 점수와 다른
이야기를 하면 그건 분해가 아니라 또 하나의 주장이 된다.
실렌더(Playwright 1440x900): 패널이 의도한 자리에 서고 콘솔 에러 0.
검증: ruff clean, pytest 642 passed (스코어러 5건 + 정적 1건 추가).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
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
8/8 실효성 평가의 결론은 "검색·기록 노동은 줄여주나 심사 건수는 전혀 못 줄인다"
였다. 이 커밋은 그 축을 직접 건드리는 첫 기능이다.
중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능이다 — 재저장(JPEG q90)
/80%축소/50%축소/밝기+10% 변형 24건 중 24건 그룹핑, 같은 24건에서 SHA-256
완전일치는 0건. 그런데 그 결과를 쓰는 곳이 없었다. 서버는 store_duplicates.py
에서 priorDecisions 와 conflicting 을 계산해 내려보내는데, 콘솔은 그것을 큐
배지의 title 속성에만 넣었다. 마우스를 올린 사람만 "이거 지난주에 반려한
이미지"임을 알 수 있었고, 재제출은 처음부터 다시 심사됐다.
서버는 건드리지 않았다. 자리만 옮겼다.
큐 배지 "동일 이미지 3건 · 선례 반려" (title 이 아니라 본문에)
판정 패널 결정 메모 바로 위에 선례 배너 + [선례 승계] 1타
승계는 별도 판정 경로를 만들지 않는다. 메모를 채워 기존 setDecision 을 그대로
타므로 반려 메모 필수 검사 · 판정 시점 스냅샷 · 감사 이벤트가 유지되고, 승계
사실은 메모로 감사에 남는다("선례 승계: 동일 이미지 SUB-OLD1, SUB-OLD2 와 같은
판정").
판정 불일치는 승계 대상이 아니다. 같은 이미지에 서로 다른 판정이 있다는 것은
자동 처리할 상황이 아니라 사람이 봐야 하는 신호이므로, conflicting 이면 버튼을
아예 내지 않고 색을 갈라 표시한다.
실렌더 확인(Playwright 1440x900, 실제 서버 + 부트스트랩 스텁):
세 상태(승계 가능 / 판정 불일치 / 선례 없음)의 배너 문구와 버튼 유무가 의도대로,
승계 클릭 시 POST 본문이 위 메모 그대로, 콘솔 에러 0, 가로 오버플로 없음.
검증: ruff clean, pytest 622 passed (정적 3건 추가).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
1) 긴 호스트가 제목을 세로 한 줄짜리 컬럼으로 만들었다
.evidence-title 은 flex-wrap 없는 flex 행이고 source-chip·host-chip 둘 다
축소를 거부한다(flex: 0 0 auto). 그래서 제목에 남는 폭이 수십 px 이 되고
overflow-wrap: anywhere 가 그걸 한 단어씩 세로로 쪼갰다. 컨테이너에
flex-wrap, 제목에 min-width: 14ch 를 줘서 제목이 자기 줄로 내려간다.
실렌더 측정 (같은 DOM 에 수정 전 CSS 런타임 주입, Playwright):
360px 제목 51x95px (5줄) -> 246x19px (1줄), 행 높이 95 -> 50
1280px 제목 9x380px (~20줄) -> 205x19px, 행 높이 380 -> 50
주석은 데스크톱에서는 해결됐다고 적혀 있었지만, 45자 CDN 호스트로는
1280px 에서도 재현된다 — 폰 전용 문제가 아니었다.
2) 20ch 로 잘린 호스트를 읽을 방법이 없었다
칩 title 이 "게시 호스트" 고정 문구여서, 20자 접두어가 같은 두 CDN 호스트를
링크를 열지 않고 구분할 수 없었다 — 모노스페이스를 고른 이유가 그 판별이다.
툴팁에 실제 호스트를 싣는다.
3) 클라이언트 호스트 폴백이 서버보다 짧았다
_result_host 는 (url, result_url, page_url, source_page_url, blogger_link,
image_url) 를 순회하는데 evidenceHost 는 3개만 봤다. host 키가 생기기 전에
저장된 네이버 블로그 행이 bloggerLink 만 가진 경우 칩이 조용히 사라졌다 —
"레거시 증거가 자기 출처에 눈멀지 않게" 라는 폴백의 목적과 반대다.
4) 보존된 케이스가 설명 없는 깨진 이미지였다
assetMissing 을 읽는 소비자가 GUI·API·내보내기 어디에도 없었다. 판정은 DB 에
남지만 콘솔에서는 "판정 후 파일 아카이브" 와 "업로드 손상" 이 구분되지
않았다. 큐 썸네일과 케이스 이미지에 표시 + 파생물 노트에 사유를 붙인다.
5) PROMPT_VERSION 갱신 (ko-v3-structured -> ko-v4-structured-host)
847f7a8 이 PROMPT_DATA_FIELDS 의 domain -> host 로 모든 증거 줄의 내용을
바꿨는데 스탬프는 그대로여서, 한 버전이 서로 다른 두 프롬프트를 가리켰다.
화이트리스트가 다른 모듈에 있어도 프롬프트의 일부라는 점을 llm_prompts
독스트링의 규칙에 명시했다. 렌더된 줄이 host= 를 담는지 보는 행위 테스트도
추가했다(튜플 멤버십 검사로는 안 잡힌다).
CLAUDE.md 요구대로 실모델 프로브(qwen2.5:7b, 로컬 ollama): grounded 3 /
dropped 0 / verified True, 형식 예시 반향 없음, 인용 라벨 정확(E1=full match).
다만 모델은 host 를 문장에 쓰지 않고 page_title 로 서술했다 — 호스트는
운영자 칩에는 닿지만 서술에는 아직 반영되지 않는다(SYSTEM_PROMPT 과제).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
P1 3번("매칭 호스트 기반 신뢰출처 구분")을 착수하며 전제를 검증했더니 틀렸다.
호스트는 "이미 저장돼 있고 점수만 안 쓰는" 게 아니라 저장 시점에 파괴되고
있었다.
google_custom_search.py:240,296 이 실호스트(displayLink)를 data["domain"] 에
넣는데, store_serialization.py:298 이 data["provider"] 를 우선하므로 그 값이
API 이름으로 덮어써진다. Vision·Naver 는 호스트를 아예 안 넣고 URL 만 준다.
결과적으로 저장된 모든 행의 domain 이 "google" 이고, LLM 프롬프트 12줄이 전부
domain=google 이었다 — 저작권 심사에서 가장 결정적인 "어디에 떠 있나"를 모델도
운영자도 볼 수 없었다. 같은 케이스의 payload 55행 중 41행이 url 을 갖고 있는데도.
domain 필드는 재사용할 수 없다. provider 커버리지 동기화와 CSE 쿼리 시그니처
매칭이 그 값을 키로 쓰고(store_serialization.py:195, 599) 콘솔도 provider
라벨로 렌더한다. 그래서 host 를 별도 키로 신설했다.
_result_host(): data["domain"] 이 provider 와 다르면 그것을, 아니면 결과/페이지
URL 에서 호스트를 파싱한다. www 제거·소문자화. 파싱 실패는 "호스트 없음"이지
영속화 실패가 아니다.
점수에는 영향이 없다 — RiskScorer 는 domain 도 host 도 읽지 않는다. 증거 id 도
안 바뀐다: _evidence_id 는 도메인 객체의 data 를 해싱하고 payload 를 해싱하지
않으므로 payload 키 추가가 id 를 움직이지 않는다.
PROMPT_DATA_FIELDS 의 domain -> host. 화이트리스트 크기는 그대로.
콘솔은 증거 카드 제목 줄에 monospace host-chip 을 붙인다. 기존 저장 행에는 host
키가 없으므로 evidenceHost() 가 저장된 URL 에서 파생한다 — 292행 마이그레이션도,
bootstrap 마다 행당 파싱도 필요 없다(렌더되는 행만 지나간다).
실렌더 검증(127.0.0.1:9512, 실 DB)에서만 드러난 레이아웃 결함 2건을 고쳤다.
코드 리딩으로는 보이지 않았다.
1) 긴 CDN 호스트(cloudfront-ap-northeast-1.images.arcpublishing.com)가 제목 줄
폭을 다 먹어, evidence-title 의 overflow-wrap:anywhere 와 결합해 제목이 한
글자씩 세로로 깨졌다. 칩을 max-width 20ch 로 묶고 제목에 flex:1 1 auto 를 줘
남는 폭을 갖게 했다.
2) 그 수정 후에는 반대로 biz.chosun.com(14자)까지 잘렸다. 칩을 flex:0 0 auto 로
고정했다 — 제목("구글 full image match found")은 전 행이 동일한 상용구이고
호스트가 그 행의 실제 내용이므로, 들어갈 수 있는 호스트가 잘리는 쪽이 잘못이다.
최종: 칩 82개 전부 유효 호스트 형태(TLD com/edu/hk/jp/kr/net/org), 가시 칩 41개 중
잘리는 것 4개(모두 20자 초과 CDN), 콘솔 에러 0.
PROVENANCE 배점은 보류한다. 두 조건이 다 안 맞는다. (a) detect_screen_capture 의
카테고리가 geometry 와 subtitle_band 둘뿐인데 2개를 요구하므로 라벨의 실제 의미는
"geometry AND 자막"이고, 자막 없는 방송 캡처는 구조적으로 도달 불가다 — 임계를
낮추는 게 아니라 독립적인 세 번째 신호가 필요하고 그건 새 탐지기다. 한계를 실측과
함께 상수 옆에 기록했다. (b) "새 탐지 신호는 0점 라벨로 출시한 뒤 decision_stats
상관을 보고 가중치 부여"가 2026-07-25 결정이고, 판정 0건인 지금 배점하면 그 결정을
위반한다.
신뢰출처 판정 자체는 "어느 도메인이 우리 것/라이선스 계약처인가"라는 운영자
데이터가 있어야 한다. 호스트가 살아났으므로 목록만 정해지면 cleared_reference 와
같은 패턴(점수 0 + 사유 노출)으로 붙는다.
테스트 4건 추가. pytest 574 passed(착수 전 570), 린트 클린.
단일 파일이 프로젝트 상한(800줄)의 6.8배였다. 분할은 순수 라인범위 추출이며
어떤 구문도 수정·재배치·재포맷하지 않았다 — 15개 파트의 연결 결과가 원본
app.js 와 바이트 단위로 동일함을 확인하고 나눴다(125,689B).
클래식 스크립트(모듈 아님)라 하나의 전역 스코프를 공유하므로 로드 순서가 곧
계약이다. 원본에서 로드 시점에 실행되는 최상위 구문은 파일 끝의 리스너 등록
2개뿐이었고(DOMContentLoaded/hashchange), 나머지 참조는 전부 함수 본문 안이라
순서만 지키면 의미가 보존된다. 그 순서를 index.html 에 명시하고 테스트로 고정:
- test_index_loads_every_app_part_exactly_once_in_declared_order
각 파트가 정확히 1회, 선언 순서대로 로드되는지 + 디스크의 app-*.js 가
전부 배선됐는지 + 구 app.js 가 남아있지 않은지
- test_app_parts_stay_within_the_file_size_budget (800줄 상한)
정적 스펙 31곳은 app.js 본문을 문자열로 검사하고 있었다. 이 스펙들의 관심사는
'어느 파일에 있는가'가 아니라 '콘솔에 그 동작이 있는가'이므로, 파트를 로드
순서대로 이어붙여 읽는 _read_app() 으로 바꿨다.
검증: Playwright E2E 6건이 실브라우저에서 통과(콘솔 에러 0 단언 포함).
운영 콘솔 실렌더 확인 — 큐/케이스 심사 정상, 스크립트 15개 전부 200,
새로고침 후 콘솔 메시지 0건.
- Keyboard review mode: J/K to move, A/H to decide and jump to the next
unreviewed case, R to focus the memo, N for next unreviewed, ? for help, with
a live position/backlog indicator; disabled while typing.
- Batch decisions: POST /api/submissions/decision-batch applies one decision to
many selected cases through per-case transactions/audit/watchlist, reports
per-case success and failure, caps at 200, requires a memo for reject and
confirms when high-risk cases are included. Lifts the v1 "no bulk
approve/reject" spec restriction under those guardrails (spec revised, static
layout test updated to assert the guardrails instead of the absence).
- Decision-memo presets: GET/POST /api/decision-memo-presets (upsert by label),
one-click chips in the decision pane, "save current memo" action.
- Submission duplicate / prior-case index: seed backfills fingerprints onto the
submission record (idempotent), exact + pHash>=0.97 clustering, queue badge
and group select, prior-decision chips and a conflicting-decision flag.
- Decision export: GET /api/decisions/export with separate internal and
applicant field whitelists (applicant view never carries score/evidence),
CSV with a BOM.
- Online backup/restore: POST /api/admin/backup snapshots the WAL database via
the sqlite backup API plus the media dirs into a timestamped zip with
retention rotation; `--restore` is an offline CLI that keeps the pre-restore
database. GET /api/admin/backups lists snapshots.
Also folds in the co-located HTTP hardening (non-object/non-UTF8 JSON body ->
400, non-ASCII Authorization/cookie compared as bytes -> 401, media OSError ->
404) and the line-buffered startup logging that share these files.
Verified against a live server (auth gate, seeding, duplicate index, batch
decision guardrails, presets, export, backup + restore round-trip). pytest
tests/rights_filter tests/operator_gui: 457 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E6GD9gxUtfUiVB3JHejGpU
Correctness:
- Make the local-artifact audit test skip on fresh clones (data/ is
gitignored), so the suite passes outside this workstation
- Drop the transform from the viewRise entrance animation: an animated
transform made .view.active a containing block for 320ms and threw
the fixed decision panel off-screen on every workbench entry
- Collapse the queue toolbar at 1380px instead of 1180px; 1280x800
laptops no longer get a horizontal scrollbar (verified live)
- Serve .woff2 as font/woff2 with an immutable cache header so the
2MB bundled font is fetched once, not per page load (with test)
- Clip overflow on top-bar status chips (long apiError strings spilled
over neighbors at 981-1180px)
- Give queue-row selection a selector that outranks the even-row
zebra stripe (selection background was parity-dependent)
Cleanup:
- Replace the stale old-palette focus ring and ::selection literals
with color-mix over var(--teal)
- Delete dead tokens: unused back-compat aliases (the comment claiming
they were referenced was false), --rail-bot, --ochre-deep, and
--font-stamp (identical to --font-ui since the Pretendard switch)
- Tokenize scattered raw colors: rail ink scale, soft tint levels,
inset-well and bevel shadows, naver/internal source-chip triplets
- Remove the asset-preload div and three orphan SVGs nothing renders;
tests now reject reintroducing them
Verified: 359 tests pass; Playwright audit at 1440/1280/390 shows zero
horizontal overflow on all views, Pretendard active, decision panel
fixed at the viewport corner mid-animation.
Image rights / copyright detection system: SQLite store, HTTP app,
search integrations (Naver, Google Custom Search, Google Cloud Vision
web detection), image analysis (fingerprints, face/person detection,
evidence enrichment, risk scoring), an admin/review layer, governance
and retention policies, batch jobs, and a browser-based operator GUI.
This baseline incorporates a full code-review remediation pass
(46 fixes; 358 tests passing). Highlights:
CRITICAL
- Prevent evidence cascade-delete during the schema-constraint
migration by disabling FK enforcement around the table rebuild.
Security
- Sandbox served media (neutralize stored XSS from uploaded/collected
SVGs) via CSP + nosniff on the untrusted media routes.
- Strip embedded EXIF/GPS from external image derivatives before they
are sent to third-party APIs.
- Return a clean 404 (not an uncaught StopIteration) for PATCH on an
unknown provider.
Correctness
- LLM-summary failures no longer add +30 to the risk score.
- Decode only explicit JS escapes so Korean image URLs are not mangled.
- Consume search quota only after a successful request.
- Naver/Google adapters map responses inside the failure boundary, so a
malformed response degrades to evidence instead of crashing enrichment.
- Domain-aware provider attribution; face-box IoU de-duplication; count
searches (not result items); per-box crop isolation; clamp evidence
confidence and Google CSE num; real submittedEpoch; and more.
Robustness
- Offline LLM connect fast-fails (short connect timeout) so seed/reload
requests are not stalled; full read timeout preserved for generation.
- Malformed numeric env vars fall back to defaults instead of crashing
startup.
Performance
- Per-submission evidence reads (no full-table scan per rescore),
audit-log LIMIT, lazy active-store lookup, hoisted timestamps.
Tests
- ~24 regression tests added pinning the above fixes.
Runtime data (data/, outputs/, *.sqlite3, *.log), secrets (.env), and
node_modules are gitignored.