fix: 신호 풀 도입으로 위험도 포화 해소 (SCORER_VERSION v1 -> v2)
기능 실효성 평가에서 확정된 판별력 붕괴를 고친다. 신호조합 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), 린트 클린.
This commit is contained in:
parent
02395ac3aa
commit
150e729aef
6 changed files with 225 additions and 22 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
# Project INDEX
|
# Project INDEX
|
||||||
|
|
||||||
|
- 2026-08-09 · P1 1차 — 판별력 복구를 위한 신호 풀 도입, **`SCORER_VERSION` v1 → v2** — 포화 동인이 둘이었다: `category` 없는 raw Vision 라벨이 이름별 버킷으로 무한 누적(`WEB_ENTITY_MAX_POINTS=20`, medium 미만으로 캡)되고, full/partial/page/visual 이 **하나의 사실을 4개 상관된 뷰로** 보고하는데 합산돼 125→100 포화(`WEB_PRESENCE_MAX_POINTS=60`, high 미만으로 캡). `category`(character/celebrity/work) 엔티티는 권리 분류이므로 풀에서 제외하고 +45 유지. 원칙: **웹에 존재한다는 사실만으로는 high 가 되지 않고, 두 번째 종류의 신호가 필요하다.** 224조합 재측정 — 웹 신호만으로 high 가 되는 조합 **2/7 → 0/7**, 100점 포화 66.5%→51.3%(전체 high 비율 82.6%→76.3%은 격자가 균등샘플이라 대리값이 아님을 명기). 웹에 없는 오리지널·cleared 자산이 100/high → 20/low 로 교정. 라운드트립 버킷 보존 테스트는 캡에 가려지지 않도록 저신뢰도로 재작성. **pytest 570 passed**(착수 전 565) → [평가 리포트 6절](reviews/2026-08-08-utility-assessment.md)
|
||||||
|
|
||||||
- 2026-08-08 · 기능 실효성 평가(6축 조사 + 축별 적대적 반증, 에이전트 12) 후 P0 4종 수정 — 결론은 **"검색·기록 노동은 줄여주나 심사 건수는 못 줄인다"**(신호조합 224개 전수 시 high 77.7%·low 3.6%, 지배 동인은 match 스택이 아니라 **web entity 누적** — 이미지 매칭 0건이어도 entity 3개면 100/high, 웹에 없는 오리지널도 66/medium). 실수정: **폴더 정리가 판정 기록을 지우던 경로 차단**(판정 완료 가드 + `assetMissing` 1회 마킹; 실증으로 export 1행→0행 확인됨), **비상 차단 스위치 실배선**(`_external_calls_blocked` 신설 → 자동 Naver/CSE/Vision/얼굴크롭 5개 경로; 이전엔 눌러도 외부 호출 5회 동일), **생체 크롭의 보존기간 초과 생존 차단**(스냅샷 제외 + 레거시 아카이브 복구 스킵 — 복구가 mtime을 리셋해 90일을 재시작시켰음), **오프라인 설치 문서의 계약 위반 모델 정정**(0.5b→7b + ollama 바이너리·GPU 전제·거짓 disable 서술). DB 6건은 첫 커밋보다 2주 앞선 구빌드 스냅샷이라 검증 코퍼스가 아님을 확인. **pytest 565 passed**(착수 전 562), 린트 클린 → [docs/reviews/2026-08-08-utility-assessment.md](reviews/2026-08-08-utility-assessment.md)
|
- 2026-08-08 · 기능 실효성 평가(6축 조사 + 축별 적대적 반증, 에이전트 12) 후 P0 4종 수정 — 결론은 **"검색·기록 노동은 줄여주나 심사 건수는 못 줄인다"**(신호조합 224개 전수 시 high 77.7%·low 3.6%, 지배 동인은 match 스택이 아니라 **web entity 누적** — 이미지 매칭 0건이어도 entity 3개면 100/high, 웹에 없는 오리지널도 66/medium). 실수정: **폴더 정리가 판정 기록을 지우던 경로 차단**(판정 완료 가드 + `assetMissing` 1회 마킹; 실증으로 export 1행→0행 확인됨), **비상 차단 스위치 실배선**(`_external_calls_blocked` 신설 → 자동 Naver/CSE/Vision/얼굴크롭 5개 경로; 이전엔 눌러도 외부 호출 5회 동일), **생체 크롭의 보존기간 초과 생존 차단**(스냅샷 제외 + 레거시 아카이브 복구 스킵 — 복구가 mtime을 리셋해 90일을 재시작시켰음), **오프라인 설치 문서의 계약 위반 모델 정정**(0.5b→7b + ollama 바이너리·GPU 전제·거짓 disable 서술). DB 6건은 첫 커밋보다 2주 앞선 구빌드 스냅샷이라 검증 코퍼스가 아님을 확인. **pytest 565 passed**(착수 전 562), 린트 클린 → [docs/reviews/2026-08-08-utility-assessment.md](reviews/2026-08-08-utility-assessment.md)
|
||||||
|
|
||||||
- 2026-08-08 · 프로젝트 분석 후 인프라·구조·보안 정비 — **원격 백업 복구**(20커밋이 로컬에만 있었음: `main` 2 + 작업브랜치 18 → Forgejo push), **단일 verify 게이트 신설**(`verify.ps1` + `pytest.ini` + `ruff.toml`, ruff 0.12.0 dev 핀; 린트 18건 정리), **`app.js` 5,428줄 → `app-*.js` 15개 분할**(순수 라인범위 추출로 바이트 단위 라운드트립 검증, 로드 순서를 정적 테스트로 고정), **보안 2종 실수정**(SSRF DNS-rebinding TOCTOU — 단일 DNS 해석 후 그 답변으로만 연결하는 `_guarded_create_connection`; 실패 인증 스로틀 — 자격증명 제시 실패만 10회/60초, `429`+`Retry-After`), 7/11 체크리스트 재확인(6건은 이미 수정돼 있던 낡은 항목, Batch 8 은 Batch 6 과 중복이라 정리), `CLAUDE.md`·`docs/PROJECT_MAP.md` 신설. E2E 플레이크(업로드 대기 10초 → `UPLOAD_ANALYSIS_TIMEOUT_MS` 30초) 해소. **pytest 562 passed**(착수 전 533/1 failed), 린트 클린. 실렌더 확인: 큐·케이스 심사 정상 렌더, 스크립트 15개 전부 200, 새로고침 후 콘솔 메시지 0
|
- 2026-08-08 · 프로젝트 분석 후 인프라·구조·보안 정비 — **원격 백업 복구**(20커밋이 로컬에만 있었음: `main` 2 + 작업브랜치 18 → Forgejo push), **단일 verify 게이트 신설**(`verify.ps1` + `pytest.ini` + `ruff.toml`, ruff 0.12.0 dev 핀; 린트 18건 정리), **`app.js` 5,428줄 → `app-*.js` 15개 분할**(순수 라인범위 추출로 바이트 단위 라운드트립 검증, 로드 순서를 정적 테스트로 고정), **보안 2종 실수정**(SSRF DNS-rebinding TOCTOU — 단일 DNS 해석 후 그 답변으로만 연결하는 `_guarded_create_connection`; 실패 인증 스로틀 — 자격증명 제시 실패만 10회/60초, `429`+`Retry-After`), 7/11 체크리스트 재확인(6건은 이미 수정돼 있던 낡은 항목, Batch 8 은 Batch 6 과 중복이라 정리), `CLAUDE.md`·`docs/PROJECT_MAP.md` 신설. E2E 플레이크(업로드 대기 10초 → `UPLOAD_ANALYSIS_TIMEOUT_MS` 30초) 해소. **pytest 562 passed**(착수 전 533/1 failed), 린트 클린. 실렌더 확인: 큐·케이스 심사 정상 렌더, 스크립트 15개 전부 200, 새로고침 후 콘솔 메시지 0
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,6 @@ WAL 적용됨. DB 기본 경로는 `data/copyrighter.sqlite3`(gitignore).
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `_perceptual_hash` 가 pHash가 아닌 aHash | `analysis/fingerprints.py:47` | 균일/저디테일 이미지 유사도 오탐. 교체 시 0.9 임계 재보정 필요 |
|
| `_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) | 저장소 코어라 분할 리스크 있음. 별도 작업으로 |
|
| src 4파일이 800줄 초과 | `store_page_scrape.py`(972) `store_operations.py`(968) `sqlite_store.py`(904) `store_enrichment.py`(853) | 저장소 코어라 분할 리스크 있음. 별도 작업으로 |
|
||||||
| 점수가 high 로 포화 | `analysis/risk_scoring.py:159-174` | web entity 가 이름별 버킷이라 dedup 없이 `round(40*conf)` 씩 누적 — 이미지 매칭 0건이어도 entity 3개면 100/high. 신호조합 224개 전수 시 high 77.7%·low 3.6%. `test_risk_scoring.py:234-258` 이 이 동작을 고정하고 있어 함께 개정해야 한다 |
|
| 밴드 임계·풀 상한 미캘리브레이션 | `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개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 |
|
| 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증. 방송캡처는 `MIN_CAPTURE_CATEGORIES=2` 에서 geometry 가 1개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 |
|
||||||
| 7/11 리뷰 연기 항목 | [reviews/2026-07-11-remediation-checklist.md](reviews/2026-07-11-remediation-checklist.md) | 각 항목에 연기 근거 기재 |
|
| 7/11 리뷰 연기 항목 | [reviews/2026-07-11-remediation-checklist.md](reviews/2026-07-11-remediation-checklist.md) | 각 항목에 연기 근거 기재 |
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,10 @@ purge가 mtime을 보므로 파기 대상이 복구 직후부터 다시 90일을
|
||||||
|
|
||||||
**P1 — 판별력 복구 (이것이 "업무 도움" 여부를 가르는 축)**
|
**P1 — 판별력 복구 (이것이 "업무 도움" 여부를 가르는 축)**
|
||||||
|
|
||||||
1. web entity를 지식DB/워치리스트와 대조된 것만 점수화 + 서술 라벨 누적에 캡.
|
1. ~~web entity 누적 캡~~ → **완료(2026-08-09, `SCORER_VERSION` v1→v2). 아래 6절 참조.**
|
||||||
`test_risk_scoring.py:234-258`이 현 동작을 고정하고 있으므로 그 테스트도 함께 개정해야 한다. **`SCORER_VERSION` 을 올린다.**
|
조사 단계의 "`test_risk_scoring.py:234-258`이 현 동작을 고정한다"는 예측은 틀렸다 — 그 테스트는
|
||||||
|
`category`가 붙은 엔티티(+45 분기)를 다루고, 포화를 만든 것은 `category` 없는 raw 라벨(+`round(40*conf)` 분기)이다.
|
||||||
|
두 분기를 갈라 라벨만 캡했으므로 그 테스트는 개정 없이 통과한다.
|
||||||
2. PROVENANCE(EXIF/IPTC 권리 표기)에 배점. 단 실이미지 56장에서 증거 행 0건이었고,
|
2. PROVENANCE(EXIF/IPTC 권리 표기)에 배점. 단 실이미지 56장에서 증거 행 0건이었고,
|
||||||
방송캡처 분기는 `MIN_CAPTURE_CATEGORIES=2`에서 geometry가 1개 카테고리라 **자막 없는 방송 캡처는 구조적으로 도달 불가**다
|
방송캡처 분기는 `MIN_CAPTURE_CATEGORIES=2`에서 geometry가 1개 카테고리라 **자막 없는 방송 캡처는 구조적으로 도달 불가**다
|
||||||
(`provenance.py:85-88`). 배점 전에 이 분기부터 고칠 것.
|
(`provenance.py:85-88`). 배점 전에 이 분기부터 고칠 것.
|
||||||
|
|
@ -153,6 +155,43 @@ purge가 mtime을 보므로 파기 대상이 복구 직후부터 다시 90일을
|
||||||
POST `/decision` 응답이 이미 완결된 케이스 페이로드인데 버리고 `/api/bootstrap`을 다시 친다.
|
POST `/decision` 응답이 이미 완결된 케이스 페이로드인데 버리고 `/api/bootstrap`을 다시 친다.
|
||||||
10. 반려 경로의 키보드 단절(제출 단축키 없음), 감사 로그 500건 고정 상한 + 필터·내보내기 부재.
|
10. 반려 경로의 키보드 단절(제출 단축키 없음), 감사 로그 500건 고정 상한 + 필터·내보내기 부재.
|
||||||
|
|
||||||
|
## 6. P1 1차 — 신호 풀 도입 (2026-08-09, `SCORER_VERSION` v1 → v2)
|
||||||
|
|
||||||
|
포화의 동인은 하나가 아니라 둘이었다. 둘 다 "같은 사실을 여러 행으로 세는" 같은 형태다.
|
||||||
|
|
||||||
|
| 풀 | 상한 | 무엇을 묶나 | 근거 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `WEB_ENTITY_MAX_POINTS` | **20** (medium 30 미만) | `category` 없는 raw Vision 라벨 | Vision 은 모든 이미지에 라벨을 최대 10개 붙인다. 모집단 전체에 상수로 존재하는 신호는 판별에 기여할 수 없다 |
|
||||||
|
| `WEB_PRESENCE_MAX_POINTS` | **60** (high 70 미만) | full / partial / page / visual / url | 응답 1회가 "이 이미지가 웹에 있다"는 **하나의 사실**을 4개 상관된 뷰로 보고한다. 합산은 45+35+35+10=125 로 상한을 넘겨 웹에 조금이라도 있으면 전부 100 이 됐다 |
|
||||||
|
|
||||||
|
`category`(character/celebrity/work)가 붙은 엔티티는 풀에서 제외했다 — 그것은 raw 라벨이 아니라 권리 분류이고 엔티티별 +45 를 유지한다.
|
||||||
|
|
||||||
|
**설계 원칙**: 웹에 존재한다는 사실만으로는 high 가 되지 않는다. high 가 되려면 **두 번째 종류의 신호**
|
||||||
|
(인물, 지식DB 지문 매칭, 승격된 검색 히트)가 필요하다. 존재 자체는 침해가 아니고, 그 판단이 운영자의 몫이다.
|
||||||
|
|
||||||
|
신호조합 224개 전수 재측정 (v1 → v2):
|
||||||
|
|
||||||
|
| 케이스 | v1 | v2 |
|
||||||
|
|---|---|---|
|
||||||
|
| 웹에 없는 오리지널 사진, 라벨만 | 100 / high | **20 / low** |
|
||||||
|
| 팀이 이미 안전 확인한 자산(cleared) | 100 / high | **20 / low** |
|
||||||
|
| 인물 사진, 라벨만 | 100 / high | **55 / medium** |
|
||||||
|
| 웹 존재 광범위(4개 매치 타입) | 100 / high | **60 / medium** |
|
||||||
|
| 웹 존재 + 배경 인물 | 100 / high | 70 / high |
|
||||||
|
| 완전일치 사본 + 식별가능 인물 | 80 / high | 80 / high |
|
||||||
|
| 지식DB 매칭 + 인물 | 100 / high | 100 / high |
|
||||||
|
| 명백한 침해(전 신호) | 100 / high | 100 / high |
|
||||||
|
|
||||||
|
**다른 신호 없이 웹 신호만으로 high 가 되는 조합: 2/7 → 0/7.** 100점 포화 조합 66.5% → 51.3%.
|
||||||
|
|
||||||
|
정직한 한계: 224조합 전수의 high 비율은 82.6% → 76.3% 로 크게 안 움직인다. 이 격자는 신호 축을 **균등**
|
||||||
|
샘플링하므로(지식DB 강매칭이 1/4을 차지) 실제 큐 분포의 대리값이 아니다. 실 DB 6건도 100×4 → 95×4 로 밴드가
|
||||||
|
바뀌지 않는다 — 그 4건은 전부 "웹에 완전일치 사본 + 인물"이라 high 가 맞다. v2 가 고친 것은 **거짓 high**
|
||||||
|
(웹에 없음/라벨만/이미 안전 확인)이지, 진짜 high 를 줄인 게 아니다.
|
||||||
|
|
||||||
|
풀 상한 60/20 은 신호의 구조적 성질에서 나온 값이지 캘리브레이션 결과가 아니다. 판정 30~50건이 쌓이면
|
||||||
|
`decision_stats` 교차표로 임계와 함께 재조정해야 한다.
|
||||||
|
|
||||||
**적대적 검증에서 반증된 항목** (되돌리려는 시도 방지용 기록)
|
**적대적 검증에서 반증된 항목** (되돌리려는 시도 방지용 기록)
|
||||||
|
|
||||||
- ~~폐쇄망에서 증거 썸네일이 안 보인다~~ → 런타임 외부 호출은 허용된 설계이고 `http_app.py:510-511`에 근거 주석이 있다.
|
- ~~폐쇄망에서 증거 썸네일이 안 보인다~~ → 런타임 외부 호출은 허용된 설계이고 `http_app.py:510-511`에 근거 주석이 있다.
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,39 @@ from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult
|
||||||
FACE_PERSON_IDENTIFIABLE_POINTS = 35
|
FACE_PERSON_IDENTIFIABLE_POINTS = 35
|
||||||
FACE_PERSON_BACKGROUND_POINTS = 10
|
FACE_PERSON_BACKGROUND_POINTS = 10
|
||||||
|
|
||||||
|
# Ceiling on what UNCATEGORIZED Google Vision web entities can contribute in
|
||||||
|
# total. Vision returns up to maxResults descriptive labels for every image
|
||||||
|
# ("Dog", "Pet", "Grass"), so a bare label describes what the picture shows, not
|
||||||
|
# who owns it. Scored per label with a per-name bucket they stacked without
|
||||||
|
# bound: three 0.8-confidence labels reached 100/high with no image match at
|
||||||
|
# all, and an entirely original photo that exists nowhere on the web landed at
|
||||||
|
# 66/medium. That is why 77.7% of signal combinations banded high and the low
|
||||||
|
# band was unreachable. Capped below the medium threshold (30), labels can
|
||||||
|
# colour a case but never band it on their own.
|
||||||
|
#
|
||||||
|
# Entities carrying a category (character / celebrity / work) are deliberately
|
||||||
|
# NOT capped: that classification is a rights judgement rather than a raw label,
|
||||||
|
# and it keeps its own +45 per distinct entity.
|
||||||
|
WEB_ENTITY_MAX_POINTS = 20
|
||||||
|
|
||||||
|
# Ceiling on the combined weight of web-presence match types. One Vision
|
||||||
|
# response reports a single underlying fact — "this image is on the web" —
|
||||||
|
# through four correlated views (full copy / partial copy / hosting page /
|
||||||
|
# lookalike), and a widely published image trips all four at once. Summed, that
|
||||||
|
# is 45+35+35+10 = 125, so every image with any web presence pinned to the 100
|
||||||
|
# cap and the ordinary case became indistinguishable from a clear infringement.
|
||||||
|
# Pooled and capped below the high threshold (70), presence still ranks by how
|
||||||
|
# many ways it was seen, but it takes a SECOND kind of signal — a portrait, a
|
||||||
|
# knowledge-base fingerprint match, a promoted search hit — to reach high.
|
||||||
|
# Existence on the web is not itself infringement; that is the operator's call.
|
||||||
|
WEB_PRESENCE_MAX_POINTS = 60
|
||||||
|
|
||||||
# Stamped onto every submission at creation so a later weight change is
|
# Stamped onto every submission at creation so a later weight change is
|
||||||
# distinguishable from a scoring bug. Bump this whenever the weights, the bucket
|
# distinguishable from a scoring bug. Bump this whenever the weights, the bucket
|
||||||
# rules, or the band thresholds below change — the stamp is what lets
|
# rules, or the band thresholds below change — the stamp is what lets
|
||||||
# initialRiskScore be compared against a rescore and mean something.
|
# initialRiskScore be compared against a rescore and mean something.
|
||||||
SCORER_VERSION = "v1"
|
# v2: uncategorized web entities capped in aggregate (WEB_ENTITY_MAX_POINTS).
|
||||||
|
SCORER_VERSION = "v2"
|
||||||
|
|
||||||
|
|
||||||
class RiskScorer:
|
class RiskScorer:
|
||||||
|
|
@ -20,6 +48,8 @@ class RiskScorer:
|
||||||
counted_web_buckets: set[str] = set()
|
counted_web_buckets: set[str] = set()
|
||||||
counted_naver_buckets: set[str] = set()
|
counted_naver_buckets: set[str] = set()
|
||||||
counted_face_person = False
|
counted_face_person = False
|
||||||
|
web_entity_points = 0
|
||||||
|
web_presence_points = 0
|
||||||
|
|
||||||
for item in evidence:
|
for item in evidence:
|
||||||
if item.data.get("operator_status") in {"irrelevant", "false_positive"}:
|
if item.data.get("operator_status") in {"irrelevant", "false_positive"}:
|
||||||
|
|
@ -57,7 +87,13 @@ class RiskScorer:
|
||||||
bucket = _web_detection_bucket(item)
|
bucket = _web_detection_bucket(item)
|
||||||
if bucket and bucket in counted_web_buckets:
|
if bucket and bucket in counted_web_buckets:
|
||||||
continue
|
continue
|
||||||
points = _web_detection_points(item)
|
points, kind = _web_detection_points(item)
|
||||||
|
# Pooled kinds are capped after the loop, not added here.
|
||||||
|
if kind == _WEB_LABEL:
|
||||||
|
web_entity_points += points
|
||||||
|
elif kind == _WEB_PRESENCE:
|
||||||
|
web_presence_points += points
|
||||||
|
else:
|
||||||
score += points
|
score += points
|
||||||
if points:
|
if points:
|
||||||
reasons.append(item.reason)
|
reasons.append(item.reason)
|
||||||
|
|
@ -107,6 +143,11 @@ class RiskScorer:
|
||||||
# operational status ("quota reached", "provider disabled"), not
|
# operational status ("quota reached", "provider disabled"), not
|
||||||
# risk signals — keep them out of the operator-facing reasons list.
|
# risk signals — keep them out of the operator-facing reasons list.
|
||||||
|
|
||||||
|
# Every distinct row still surfaces as a reason; only the combined weight
|
||||||
|
# of each pool is bounded.
|
||||||
|
score += min(web_presence_points, WEB_PRESENCE_MAX_POINTS)
|
||||||
|
score += min(web_entity_points, WEB_ENTITY_MAX_POINTS)
|
||||||
|
|
||||||
score = min(100, score)
|
score = min(100, score)
|
||||||
if score >= 70:
|
if score >= 70:
|
||||||
band = "high"
|
band = "high"
|
||||||
|
|
@ -133,9 +174,22 @@ def _face_person_points(item: Evidence) -> int:
|
||||||
return FACE_PERSON_IDENTIFIABLE_POINTS
|
return FACE_PERSON_IDENTIFIABLE_POINTS
|
||||||
|
|
||||||
|
|
||||||
def _web_detection_points(item: Evidence) -> int:
|
# How a web-detection row's points are combined with the rest.
|
||||||
|
_WEB_PRESENCE = "presence" # pooled under WEB_PRESENCE_MAX_POINTS
|
||||||
|
_WEB_LABEL = "label" # pooled under WEB_ENTITY_MAX_POINTS
|
||||||
|
_WEB_STANDALONE = "standalone" # added on its own
|
||||||
|
|
||||||
|
|
||||||
|
def _web_detection_points(item: Evidence) -> tuple[int, str]:
|
||||||
|
"""Points for one web-detection row, and which pool they belong to.
|
||||||
|
|
||||||
|
Three kinds are scored differently because they carry different amounts of
|
||||||
|
information: the match types are correlated views of one fact (the image is
|
||||||
|
on the web), bare Vision entities describe the subject rather than its
|
||||||
|
rights, and a classified entity is an actual rights judgement.
|
||||||
|
"""
|
||||||
if item.data.get("weak_hint"):
|
if item.data.get("weak_hint"):
|
||||||
return 0
|
return 0, _WEB_STANDALONE
|
||||||
# Google custom IMAGE search results (match="search_image") are context-only:
|
# Google custom IMAGE search results (match="search_image") are context-only:
|
||||||
# existence of a text-query image hit is not itself a rights signal (the
|
# existence of a text-query image hit is not itself a rights signal (the
|
||||||
# matching pHash FINGERPRINT evidence carries the score). Fresh evidence has
|
# matching pHash FINGERPRINT evidence carries the score). Fresh evidence has
|
||||||
|
|
@ -145,20 +199,20 @@ def _web_detection_points(item: Evidence) -> int:
|
||||||
# score-preservation invariant and inflating bands on any query that returns
|
# score-preservation invariant and inflating bands on any query that returns
|
||||||
# anything.
|
# anything.
|
||||||
if item.data.get("match") == "search_image":
|
if item.data.get("match") == "search_image":
|
||||||
return 0
|
return 0, _WEB_STANDALONE
|
||||||
if item.data.get("match") == "full":
|
if item.data.get("match") == "full":
|
||||||
return 45
|
return 45, _WEB_PRESENCE
|
||||||
if item.data.get("match") in {"partial", "page"}:
|
if item.data.get("match") in {"partial", "page"}:
|
||||||
return 35
|
return 35, _WEB_PRESENCE
|
||||||
if item.data.get("match") == "visual":
|
if item.data.get("match") == "visual":
|
||||||
return 10
|
return 10, _WEB_PRESENCE
|
||||||
if item.data.get("url"):
|
if item.data.get("url"):
|
||||||
return 45
|
return 45, _WEB_PRESENCE
|
||||||
if item.data.get("category") in {"character", "celebrity", "work"}:
|
if item.data.get("category") in {"character", "celebrity", "work"}:
|
||||||
return 45
|
return 45, _WEB_STANDALONE
|
||||||
if item.data.get("entity"):
|
if item.data.get("entity"):
|
||||||
return round(40 * item.confidence)
|
return round(40 * item.confidence), _WEB_LABEL
|
||||||
return 0
|
return 0, _WEB_STANDALONE
|
||||||
|
|
||||||
|
|
||||||
def _web_detection_bucket(item: Evidence) -> str:
|
def _web_detection_bucket(item: Evidence) -> str:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
from rights_filter.analysis.risk_scoring import RiskScorer
|
from rights_filter.analysis.risk_scoring import (
|
||||||
|
FACE_PERSON_IDENTIFIABLE_POINTS,
|
||||||
|
RiskScorer,
|
||||||
|
)
|
||||||
from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult
|
from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -282,6 +285,105 @@ def test_same_web_entity_repeated_does_not_double_count():
|
||||||
assert result.band == "medium"
|
assert result.band == "medium"
|
||||||
|
|
||||||
|
|
||||||
|
def _label(name: str, confidence: float) -> Evidence:
|
||||||
|
# A bare Google Vision web entity: descriptive, no category, no image match.
|
||||||
|
return Evidence(
|
||||||
|
source=EvidenceSource.WEB_DETECTION,
|
||||||
|
reason=f"Web entity matched {name}",
|
||||||
|
confidence=confidence,
|
||||||
|
data={"entity": name, "match": "entity"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncategorized_web_entities_cannot_band_an_image_on_their_own():
|
||||||
|
# Vision returns up to maxResults labels for EVERY image, so per-label
|
||||||
|
# scoring with a per-name bucket stacked without bound: three 0.8-confidence
|
||||||
|
# labels reached 100/high with zero image matches. Their pooled contribution
|
||||||
|
# is now capped below the medium threshold.
|
||||||
|
result = RiskScorer().score(
|
||||||
|
[
|
||||||
|
_label("Dog", 0.8),
|
||||||
|
_label("Pet", 0.8),
|
||||||
|
_label("Grass", 0.8),
|
||||||
|
_label("Canidae", 0.8),
|
||||||
|
_label("Snout", 0.8),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.score == 20
|
||||||
|
assert result.band == "low"
|
||||||
|
# Capping the weight must not hide the labels from the operator.
|
||||||
|
assert len(result.reasons) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_original_photo_found_nowhere_on_the_web_stays_low():
|
||||||
|
# The inverse failure of the same defect: a photo that exists nowhere online
|
||||||
|
# scored 66/medium purely from what Vision thought it depicted.
|
||||||
|
result = RiskScorer().score(
|
||||||
|
[
|
||||||
|
_label("Dog", 0.6),
|
||||||
|
_label("Pet", 0.55),
|
||||||
|
_label("Grass", 0.5),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.band == "low"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classified_entities_are_not_capped_like_bare_labels():
|
||||||
|
# character / celebrity / work is a rights classification, not a raw label,
|
||||||
|
# so it keeps its own +45 per distinct entity and is excluded from the pool.
|
||||||
|
result = RiskScorer().score(
|
||||||
|
[
|
||||||
|
Evidence(
|
||||||
|
source=EvidenceSource.WEB_DETECTION,
|
||||||
|
reason="Web entity matched celebrity B",
|
||||||
|
confidence=0.94,
|
||||||
|
data={"entity": "유명인 B", "category": "celebrity"},
|
||||||
|
),
|
||||||
|
_label("Portrait", 0.9),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.score == 45 + 20
|
||||||
|
|
||||||
|
|
||||||
|
def _presence_set() -> list[Evidence]:
|
||||||
|
# What one Vision response looks like for a widely published image: the same
|
||||||
|
# fact reported through all four match types at once.
|
||||||
|
return [
|
||||||
|
Evidence(EvidenceSource.WEB_DETECTION, "Full match", 0.9, {"match": "full"}),
|
||||||
|
Evidence(EvidenceSource.WEB_DETECTION, "Partial match", 0.8, {"match": "partial"}),
|
||||||
|
Evidence(EvidenceSource.WEB_DETECTION, "Page hosts image", 0.8, {"match": "page"}),
|
||||||
|
Evidence(EvidenceSource.WEB_DETECTION, "Visually similar", 0.6, {"match": "visual"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_presence_alone_cannot_reach_the_high_band():
|
||||||
|
# Summed, the four match types were 45+35+35+10 = 125 -> clamped to 100, so
|
||||||
|
# any image with a web presence was indistinguishable from a clear
|
||||||
|
# infringement. Existence on the web is not itself infringement.
|
||||||
|
result = RiskScorer().score(_presence_set())
|
||||||
|
|
||||||
|
assert result.score == 60
|
||||||
|
assert result.band == "medium"
|
||||||
|
assert len(result.reasons) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_presence_plus_a_second_signal_kind_reaches_high():
|
||||||
|
# The pool bounds one kind of signal, not the case: a hosted copy of a photo
|
||||||
|
# of an identifiable person is still high.
|
||||||
|
result = RiskScorer().score(
|
||||||
|
[
|
||||||
|
*_presence_set(),
|
||||||
|
Evidence(EvidenceSource.FACE_PERSON, "Identifiable person", 0.9, {"tier": "identifiable"}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.score == 60 + FACE_PERSON_IDENTIFIABLE_POINTS
|
||||||
|
assert result.band == "high"
|
||||||
|
|
||||||
|
|
||||||
def test_search_skipped_reason_is_not_listed_as_a_risk_reason():
|
def test_search_skipped_reason_is_not_listed_as_a_risk_reason():
|
||||||
# 2026-07-11 #16: a zero-point skip ("quota reached") is operational status,
|
# 2026-07-11 #16: a zero-point skip ("quota reached") is operational status,
|
||||||
# not a risk signal, and must not pollute the operator-facing reasons list.
|
# not a risk signal, and must not pollute the operator-facing reasons list.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from rights_filter.analysis.preprocessing import ImagePayload, build_external_derivative
|
from rights_filter.analysis.preprocessing import ImagePayload, build_external_derivative
|
||||||
|
from rights_filter.analysis.risk_scoring import WEB_ENTITY_MAX_POINTS
|
||||||
from rights_filter.analysis.search_result_promoter import SearchResultPromoter
|
from rights_filter.analysis.search_result_promoter import SearchResultPromoter
|
||||||
from rights_filter.domain.records import Evidence, EvidenceSource
|
from rights_filter.domain.records import Evidence, EvidenceSource
|
||||||
from rights_filter.integrations.naver_search import NaverSearchAdapter
|
from rights_filter.integrations.naver_search import NaverSearchAdapter
|
||||||
|
|
@ -575,19 +576,24 @@ def test_web_entity_evidence_scores_after_serialization_roundtrip():
|
||||||
def test_two_distinct_web_entities_score_after_roundtrip():
|
def test_two_distinct_web_entities_score_after_roundtrip():
|
||||||
from rights_filter.analysis.risk_scoring import RiskScorer
|
from rights_filter.analysis.risk_scoring import RiskScorer
|
||||||
|
|
||||||
|
# Low confidence on purpose: entity points are pooled under
|
||||||
|
# WEB_ENTITY_MAX_POINTS, and at full confidence both one and two entities
|
||||||
|
# would sit on that ceiling, hiding the property under test. What is being
|
||||||
|
# asserted here is that the entity-name BUCKET survives the round-trip, so
|
||||||
|
# keep the pool below its cap where a second entity is still observable.
|
||||||
first = _roundtrip(
|
first = _roundtrip(
|
||||||
"SUB-2E",
|
"SUB-2E",
|
||||||
Evidence(EvidenceSource.WEB_DETECTION, "entity BTS", 1.0, {"entity": "BTS"}),
|
Evidence(EvidenceSource.WEB_DETECTION, "entity BTS", 0.2, {"entity": "BTS"}),
|
||||||
)
|
)
|
||||||
second = _roundtrip(
|
second = _roundtrip(
|
||||||
"SUB-2E",
|
"SUB-2E",
|
||||||
Evidence(EvidenceSource.WEB_DETECTION, "entity BLACKPINK", 1.0, {"entity": "BLACKPINK"}),
|
Evidence(EvidenceSource.WEB_DETECTION, "entity BLACKPINK", 0.2, {"entity": "BLACKPINK"}),
|
||||||
)
|
)
|
||||||
one = RiskScorer().score([first]).score
|
one = RiskScorer().score([first]).score
|
||||||
both = RiskScorer().score([first, second]).score
|
both = RiskScorer().score([first, second]).score
|
||||||
# The entity-name bucket must survive the round-trip so a second distinct
|
assert one < both <= WEB_ENTITY_MAX_POINTS
|
||||||
# entity still adds points instead of being deduped away.
|
# The same entity twice must still collapse to one signal.
|
||||||
assert both > one
|
assert RiskScorer().score([first, first]).score == one
|
||||||
|
|
||||||
|
|
||||||
def test_search_skipped_roundtrip_adds_no_phantom_failure_points():
|
def test_search_skipped_roundtrip_adds_no_phantom_failure_points():
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue