기능 실효성 평가에서 확정된 판별력 붕괴를 고친다. 신호조합 224개 전수에서 77.7% 가 high, 56% 가 정확히 100점에 붙고 low 는 3.6% 뿐이었다. 큐 기본 정렬이 riskScore 내림차순인데 전건이 100 이면 정렬이 no-op 이므로, 점수가 운영자에게 주는 우선순위 정보가 0비트였다. 동인은 하나가 아니라 둘이었고 둘 다 "같은 사실을 여러 행으로 센다"는 같은 형태다. 라벨 풀 (WEB_ENTITY_MAX_POINTS = 20) category 없는 raw Vision 웹 엔티티가 이름별 버킷이라 서로 dedup 되지 않고 round(40*conf) 씩 무한 누적됐다. Vision 은 maxResults=10 이라 모든 이미지에 라벨을 붙이므로, 이미지 매칭이 0건이어도 0.8 짜리 라벨 3개면 100/high 였고 웹에 전혀 없는 오리지널 사진이 66/medium 이었다. 모집단 전체에 상수로 존재하는 신호는 판별에 기여할 수 없다. medium 임계(30) 아래로 묶어 라벨이 케이스를 물들일 수는 있어도 밴드를 정하지는 못하게 했다. category(character/celebrity/work) 가 붙은 엔티티는 풀에서 제외한다 — 그것은 raw 라벨이 아니라 권리 분류이고 엔티티별 +45 를 유지한다. 조사 단계에서 "test_two_distinct_web_entities_stack_into_high_risk 가 이 동작을 고정하므로 함께 개정해야 한다"고 봤으나 틀렸다. 그 테스트는 category 있는 엔티티를 다루므로 두 분기를 가르면 개정 없이 통과한다. 웹 존재 풀 (WEB_PRESENCE_MAX_POINTS = 60) Vision 응답 1회가 "이 이미지가 웹에 있다"는 하나의 사실을 full / partial / page / visual 4개 상관된 뷰로 동시에 보고하는데, 스코어러가 이를 독립 신호로 합산해 45+35+35+10 = 125 -> 상한 100 이 됐다. 널리 게시된 평범한 이미지와 명백한 침해물이 같은 100/high 로 수렴한 이유다. high 임계(70) 아래로 묶었다. 원칙: 웹에 존재한다는 사실만으로는 high 가 되지 않는다. high 가 되려면 두 번째 종류의 신호(인물, 지식DB 지문 매칭, 승격된 검색 히트)가 필요하다. 존재 자체는 침해가 아니고 그 판단은 운영자의 몫이다. 측정 (224조합, v1 -> v2) 웹 신호만으로 high 가 되는 조합 2/7 -> 0/7 100점 포화 66.5% -> 51.3% 웹에 없는 오리지널 사진, 라벨만 100/high -> 20/low 이미 안전 확인한 자산(cleared) 100/high -> 20/low 웹 존재 광범위(4개 매치 타입) 100/high -> 60/medium 완전일치 사본 + 식별가능 인물 80/high -> 80/high (불변) 명백한 침해(전 신호) 100/high -> 100/high (불변) 전체 high 비율은 82.6% -> 76.3% 로 크게 안 움직인다. 이 격자는 신호 축을 균등 샘플링하므로(지식DB 강매칭이 1/4) 실제 큐 분포의 대리값이 아니며, 실 DB 6건도 100x4 -> 95x4 로 밴드가 바뀌지 않는다 — 그 4건은 전부 "완전일치 사본 + 인물" 이라 high 가 맞다. 고친 것은 거짓 high 이지 진짜 high 가 아니다. 두 상한은 신호의 구조적 성질에서 나온 값이지 캘리브레이션 결과가 아니다. 판정 30~50건이 쌓이면 decision_stats 교차표로 임계와 함께 재조정해야 한다. 라운드트립 버킷 보존 테스트(test_review_fixes.py)는 신뢰도 1.0 에서 두 엔티티가 모두 캡에 붙어 성질이 가려졌다. 검증 대상은 점수가 아니라 엔티티 이름 버킷이 직렬화를 넘어 살아남는가이므로, 풀 아래(신뢰도 0.2)에서 재작성하고 동일 엔티티 반복이 여전히 1건으로 접히는지도 함께 본다. 테스트 5건 추가. pytest 570 passed(착수 전 565), 린트 클린.
11 KiB
PROJECT MAP
어디에 무엇이 있는지에 대한 참조 문서. 작업 전 여기서 위치를 찾고 필요한 부분만 연다 (전체 소스 재스캔 금지). 새 파일·라우트·스키마가 생기면 해당 섹션을 갱신한다.
무엇을 했는지의 기록은 INDEX.md에 있다. 이 문서는 현재 구조만 다룬다.
한 줄 요약
이미지 저작권 위험을 자동 분석하고, 운영자가 콘솔에서 근거를 확인해 승인/보류/반려를
판정하는 폐쇄망(air-gapped) 도구. Python 3.13 표준 http.server + SQLite + 정적 SPA.
런타임 서드파티는 이미지 처리 3종(numpy/opencv-python-headless/pillow)뿐.
검증 (단일 명령)
.\verify.ps1 # ruff + pytest 전체 — 커밋 전 게이트, CI도 이 명령
.\verify.ps1 -SkipLint # 빠른 내부 루프
.\verify.ps1 -Coverage # src/ 커버리지 포함
설정은 pytest.ini(testpaths·pythonpath·timeout)와 ruff.toml(py313·line-length 120)에 있다.
실행
python run_copyrighter_server.py # 기본 127.0.0.1:9500
런처가 PYTHONPATH=src와 작업 디렉터리를 고정한다. 옵션: --host --port --db --images --static --env.
소스 지도 (src/rights_filter/)
analysis/ — 이미지 분석과 위험도 산출
| 파일 | 역할 |
|---|---|
preprocessing.py |
디코드, 외부 전송용 파생물 생성(메타데이터 제거), 얼굴 크롭 파생물 |
fingerprints.py |
이미지 지문. _perceptual_hash는 DCT pHash가 아니라 8x8 평균해시(aHash) — 이름과 실제가 다름(알려진 부채) |
face_person_detection.py |
YuNet(ONNX) 얼굴 검출 + 인물 등급(identifiable/background). 가중치는 models/(gitignore) |
internal_analyzer.py |
내부 분석 오케스트레이션 |
risk_scoring.py |
RiskScorer.score() — 점수·밴드 산출. SCORER_VERSION 은 가중치/버킷/밴드 변경 시 반드시 올린다 |
evidence_normalizer.py, evidence_enrichment.py |
증거 정규화·보강 |
search_query_generation.py, search_result_promoter.py |
자동 검색 쿼리 생성, 검색 결과의 증거 승격 |
llm_assistance.py, llm_prompts.py |
LLM을 요약기가 아닌 증거 판독기로 사용. 입력 화이트리스트 9필드 + 상위 12건 상한, format=json 구조화 인용, 라벨 집합 포함 검사로 미인용 claim 폐기 |
provenance.py |
출처 신호 — EXIF/IPTC 권리 표기 추출(화이트리스트 6필드), 방송캡처 포렌식. 둘 다 현재 0점 라벨(캘리브레이션 전) |
derivatives.py, reason_builder.py |
파생물 기록, 사유 문자열 조립 |
server/ — HTTP + 저장소
http_app.py (559줄) 가 라우팅·인증·보안 헤더를 담당하고, 저장소는 CopyrighterStore
하나를 믹스인 8개로 조립한다 (sqlite_store.py:77).
| 파일 | 역할 |
|---|---|
http_app.py |
build_server(), CopyrighterHandler. 라우팅 / Bearer+세션쿠키 인증 / CSP·CSRF / 바디 상한 10MB |
auth_rate_limit.py |
실패한 인증 시도 스로틀(고정 윈도우 10회/60초, 클라이언트 주소별) |
sqlite_store.py |
CopyrighterStore 조립 + 시드·리스코어 |
store_schema.py |
DDL·제약·타입 컬럼 마이그레이션. SCHEMA_VERSION |
store_persistence.py |
_put/_all 등 저수준 upsert (PK 충돌 시 update) |
store_serialization.py |
도메인 ↔ DB payload 왕복. 라운드트립은 원 점수를 재현해야 한다(과거 위반 이력 있음) |
store_enrichment.py |
provider 상태 동기화, 얼굴 증거/크롭, 재분석, 자동 Naver/Google 검색 드라이버 |
store_operations.py |
운영자 액션(판정·메모·재분석 요청) |
store_workflow.py |
일괄 판정(MAX_BATCH_DECISIONS), 워크플로 |
store_search_candidates.py |
검색 후보 수집·유사도 비교 |
store_page_scrape.py |
후보 페이지/스타일시트 파싱 |
store_remote_fetch.py |
네트워크 경계. SSRF 가드 — 사전 URL 검사 + _guarded_create_connection(단일 DNS 해석으로 rebinding TOCTOU 차단) + 리다이렉트 홉 재검증 |
store_queue.py, store_duplicates.py, store_backup.py, store_text.py, store_url_utils.py, store_constants.py |
큐, 지문 중복, 백업/복구, 텍스트·URL 유틸, 상수 |
image_store.py |
제출 이미지 파일 저장소 + manifest |
env_file.py |
.env 로더 (utf-8-sig — BOM이 첫 키에 붙지 않도록) |
integrations/ — 외부 어댑터
naver_search.py, google_custom_search.py, cloud_vision_web_detection.py,
web_detection_result_mapper.py, http_json.py.
쿼터/정책은 search_policy.py(SearchApiPolicy — try_reserve/release 로 원자적 예약)와
external_policy.py(ExternalApiPolicy). 둘 다 날짜 경계 윈도우 리셋 있음.
env_clients.py 가 .env → 어댑터/정책 조립(build_provider_runtime).
domain/ · governance/ · jobs/ · admin/
domain/records.py—Evidence,EvidenceSource,ScoreResult등 핵심 레코드governance/— 접근·보정·보존 정책 (얼굴 크롭 보존기간 등)jobs/batch_analyzer.py— 배치 분석,retry_policy.py,usage_limits.pyadmin/— 운영자 리뷰 핸들러·프레젠터
운영자 콘솔 (web/operator-gui/)
빌드 스텝 없음. 전부 클래식 스크립트(모듈 아님)라 하나의 전역 스코프를 공유한다.
index.html → styles.css + 보조 스크립트 4개(operator-labels.js, submission-import.js,
evidence-guidance.js, operator-search.js) → app-*.js 15개.
app-*.js 로드 순서 (변경 금지 — 순서가 곧 계약)
app-state.js 상태·라벨 임포트 (나머지 전부가 읽는다)
app-api.js 토큰 프롬프트, fetch 래퍼, bootstrap/refresh
app-format.js 커버리지 계산, HTML/URL 이스케이프, 라벨 포맷터
app-selectors.js 상태 파생 조회 (선택 케이스, 위험/출처 클래스, 증거 필터)
app-render-queue.js 큐 목록, 임포트 상태, 케이스 리뷰 셸
app-render-evidence.js 증거 그룹화·정렬·마크업
app-render-panels.js 지식DB·후보·보정·provider·감사 패널, renderAll
app-navigation.js 뷰/탭 전환, 케이스 선택, 판정, 수동 검색, 재분석
app-keyboard-batch.js 키보드 심사 모드, 중복 그룹, 일괄 판정
app-decision-tools.js 메모 프리셋, 판정 내보내기, 백업, cleared reference, LLM 초안
app-stats.js 판정 통계·캘리브레이션
app-submissions.js 제출물 리로드/임포트/업로드, 지식 항목 생성
app-collections.js 후보 수집·기각·선택·승격
app-admin.js 지식/보정/provider/증거상태 관리 액션
app-bootstrap.js bindEvents, init, DOM 리스너 ← 유일하게 로드 시점에 실행됨
이 순서는 tests/operator_gui/test_static_workbench.py 의
test_index_loads_every_app_part_exactly_once_in_declared_order 가 고정한다.
파일을 추가·제거·재배치하면 그 테스트의 APP_SCRIPT_NAMES 와 index.html 을 함께 고친다.
HTTP 라우트 (http_app.py)
인증: COPYRIGHTER_AUTH_TOKEN 설정 시 /api/* 와 미디어 라우트는 Bearer 헤더 또는 세션 쿠키
필요. 정적 셸과 /health 는 열려 있다(운영자가 토큰을 입력할 수 있어야 하므로).
GET — /health · /api/bootstrap · /api/review-queue · /api/submissions/{id}/review ·
/api/providers · /api/providers/health · /api/audit-events · /api/decision-memo-presets ·
/api/decisions/export · /api/admin/backups · /api/stats/decisions ·
/media/ · /knowledge-media/ · /collected-media/ · /face-crop-media/
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/knowledge/{id}/promote-watchlist|exclude-watchlist|deactivate|reactivate ·
/api/collections/keyword · /api/collections/candidates/promote-batch ·
/api/collections/candidates/{id}/promote|dismiss ·
/api/decision-memo-presets (+ /{id}/delete) · /api/admin/backup ·
/api/providers/emergency-disable
PATCH — /api/knowledge/{id}
SQLite 스키마 (store_schema.py, SCHEMA_VERSION = 2)
제약 있는 테이블 7개 — submissions, evidence, providers, knowledge_entries,
collection_candidates, corrections, audit_events. 추가로 submission_queues.
각 테이블은 조회·정렬용 타입 컬럼 + 전체 도메인 객체를 담은 payload JSON 컬럼 구조.
evidence.submission_id 는 submissions(id) 에 on delete cascade.
WAL 적용됨. DB 기본 경로는 data/copyrighter.sqlite3(gitignore).
테스트 (tests/, 562건)
tests/rights_filter/— 단위·통합.conftest.py가src를 sys.path 에 넣는다tests/operator_gui/— 정적 검증 + Playwright E2E.- Page Object:
pages/workbench_page.py(OperatorWorkbench). 업로드 대기는UPLOAD_ANALYSIS_TIMEOUT_MS(30초) — 업로드는 서버에서 실분석을 돌리므로 일반 대기보다 길다 - Chromium 없으면 자동 skip(
importorskip)
- Page Object:
- 정적 테스트는
app-*.js전체를 이어붙여(_read_app()) 내용을 검사한다 — 어느 파일에 있는지가 아니라 콘솔에 그 동작이 있는지를 본다
알려진 부채
| 항목 | 위치 | 비고 |
|---|---|---|
_perceptual_hash 가 pHash가 아닌 aHash |
analysis/fingerprints.py:47 |
균일/저디테일 이미지 유사도 오탐. 교체 시 0.9 임계 재보정 필요 |
| src 4파일이 800줄 초과 | store_page_scrape.py(972) store_operations.py(968) sqlite_store.py(904) store_enrichment.py(853) |
저장소 코어라 분할 리스크 있음. 별도 작업으로 |
| 밴드 임계·풀 상한 미캘리브레이션 | analysis/risk_scoring.py |
판정 이력 0건이라 70/30 임계가 실제 판정과 대조된 적 없다. v2 의 두 풀(WEB_PRESENCE_MAX_POINTS 60 / WEB_ENTITY_MAX_POINTS 20)은 신호의 구조적 성질로 정한 값이고 상관 검증 전이다 — 판정 30~50건 후 decision_stats 교차표가 첫 근거가 된다 |
| 출처 신호 2종 0점 | analysis/provenance.py |
캘리브레이션 전. 실측 코퍼스에서 가치 미입증. 방송캡처는 MIN_CAPTURE_CATEGORIES=2 에서 geometry 가 1개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 |
| 7/11 리뷰 연기 항목 | reviews/2026-07-11-remediation-checklist.md | 각 항목에 연기 근거 기재 |