feat: 스코어러 v5 — 축 예산을 총합 기준으로 재배분, 실이미지 100점 포화 20/20 → 0/20

첫 실측이 보인 원인은 축 예산의 합(245 vs 상한 100)이었다. 캡 없는 두 축만 캡하는
시뮬레이션(80/50, 60/40)으로는 20/20 이 그대로 100 이라 총합 기준으로 다시 짰다.

예산: 등록 권리 대상 일치 80(단독 high) · 웹 존재 40(검색결과 사본 유사도·Vision 매치·
CSE 페이지가 제공자 무관 한 풀, 뷰 강도로만 40/30/10) · 인물 30/10 · 이름 있는 검색
승격 15 · 라벨 10 → 등록 제외 최대 95. 외부 검색 실패는 +30/행 가산 대신 medium 하한.
검색결과 사본 행은 knowledge_entry_id 부재 + url/source_evidence_ids 로 웹 풀에 간다.

격자 432: high 92→75%, 포화 80→44%(남은 포화는 전부 등록 강한 일치 조합), 웹 신호만
high 13/24→0/24. 실이미지 20장 리스코어: 100점 20→0, 구별 점수 1→4(75/80/92/95),
밴드는 전부 high 유지(전부 웹에 있는 인물 사진 — medium/low 임계는 여전히 미검증).

다이어그램 spec 3종 v5 + 재생성(archify 2.17). operator-review-workflow 는 새
desktop-readability 검사에 HEAD 그대로 걸려 노드 폭 132→124 로 통과시켰고, 그 확대로
1440x900 세로 13px 넘침이 남는다(visual-check containment) — INDEX 에 기록.

verify.ps1: ruff clean, 654 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
changukyu 2026-09-22 10:31:53 +09:00
parent 352031cf45
commit 6017750bae
16 changed files with 469 additions and 244 deletions

View file

@ -1,5 +1,7 @@
# Project INDEX
- 2026-09-22 · **스코어러 v5 — 축 예산을 총합 기준으로 재배분, 실이미지 100점 포화 20/20 → 0/20.** 첫 실측이 보인 원인은 축 예산의 **합**(245 vs 상한 100)이었고, 캡 없는 두 축만 캡하는 시뮬레이션(80/50 · 60/40)으로는 20/20 이 그대로 100 이라 축 하나 손보기로는 안 됐다. 새 예산: 등록 권리 대상 일치 80(단독 high) · 웹 존재 40(검색결과 사본 유사도·Vision 매치·CSE 페이지가 제공자 무관 **한 풀** — 같은 사실의 다른 뷰, 뷰 강도로만 40/30/10 차등) · 인물 30/10 · 이름 있는 검색 승격 15 · 라벨 10 → 등록 제외 최대 95. 외부 검색 실패는 +30/행 가산에서 medium **하한**으로(네이버 장애 하루가 웹에 있는 모든 이미지를 high 로 밀던 경로 차단). 검색결과 사본 행은 `knowledge_entry_id` 부재 + `url`/`source_evidence_ids` 로 판별(둘 다 라운드트립 생존). 격자 432: high 92→75%, 포화 80→44%(남은 포화는 전부 등록 강한 일치 조합), 웹 신호만 high 13/24→**0/24** — v2 원칙 복원. 실이미지 20장 리스코어: 100점 20→**0**, 구별 점수 1→4(75/80/92/95), 밴드는 전부 high 유지 — 전부 웹에 있는 인물 사진이라 그것이 맞는 답이고 medium/low 임계는 여전히 미검증(저위험 기대 이미지 0장). 스코어러 테스트 45(신규 10) · 다른 파일의 인물 단독 35 고정값 4곳은 상수 참조로 · 다이어그램 spec 3종 v5(+카드 문구) · `calibration-corpus.md` 전후표. 다이어그램 4종 재생성(archify 2.17.0-dev.1) — `operator-review-workflow` 는 HEAD spec 그대로 새 `desktop-readability` 검사에 걸려(viewBox 1112 > 1085, 7px 라벨 투영 5.89px) 노드 폭 132→124 로 통과시켰으나, 그만큼 확대되어 1440×900 에서 세로 13px 넘침(visual-check containment fail; HEAD 는 같은 뷰포트에서 readability fail). 검증 9/9·showcase 는 통과, 사실 게이트·매니페스트도 통과 — 잔여는 툴체인 드리프트로 기록.
- 2026-09-22 · **실이미지 20장 첫 실측 — 20/20 이 100점/high, 포화율 100%.** 이 프로젝트 최초의 실이미지 코퍼스(`sample/` 20장 → `data/corpus.sqlite3`, 증거 1,615행, Vision 809 + 네이버 692). 8/8 평가는 합성 격자에서 high 77.7%·포화 56.2%였는데 **실이미지에서는 전부 상한**이다. 탐지 자체는 정상 — 15/20 은 웹에서 **완전일치(similarity 1.00) 사본**이 발견됐고 4장은 근사일치다. 문제는 밴드가 아니라 **순서가 없다는 것**이다(큐 정렬이 riskScore 내림차순인데 전부 100이라 no-op). 근본 원인은 축별 예산의 합이다 — 인물 35 + 웹게시 60 + 엔티티 20 = **115 로 이미 상한 초과**이고, 여기에 캡이 없는 두 축(검색결과 이미지 유사도 장당 평균 216점, 네이버 승격 평균 70점)이 더해진다. 최대 245점 대 상한 100. **캡 없는 두 축에 캡을 씌워도(지문 80/네이버 50, 60/40 모두 시뮬레이션) 20/20 이 그대로 100** — v2~v4 가 축을 하나씩 캡했지만 **합을 한 번도 보지 않았다.** 검색결과 유사도가 +80/건 무제한인 것은 v2 가 고친 것과 같은 오류다(같은 이미지가 웹에 있다는 **하나의 사실**을 URL 수만큼 셈). 코퍼스 한계도 명시: 20장 전부 웹 유통 인물 사진이라 **low 기대군이 0건** — 이 코퍼스로는 medium/low 임계를 영원히 검증할 수 없다(`lowBandRejectionRate` 분모 0). 첫 시도는 `.env` 로딩 누락으로 프로바이더가 전부 비활성이었고(외부 호출 0콜) 그 무효 런이 부수적으로 알려준 것 — **웹 증거 0건이어도 인물만으로 35/medium** 이고 `external_skipped` 는 점수·사유 어디에도 안 뜬다(키 만료·쿼터 고갈이 조용히 지나간다). `sample/`·`corpus/` 를 gitignore 에 추가(저작권 이미지 커밋 방지)
- 2026-09-22 · **캘리브레이션 측정 도구 신설 + 구조적 거동 첫 실측.** `SCORER_VERSION` 이 v1→v4 로 갔는데 v3(제공자별 풀링)·v4(신뢰 출처)는 분포를 한 번도 측정하지 않고 나갔다 — 8/8 평가의 224조합 하네스가 저장소에 없고(그 문서의 두 수치 174/224=77.7% 와 82.6% 가 서로 안 맞아 격자가 달랐음을 시사) 과거와 비교 가능한 재현이 불가능해, 격자를 명시적으로 고정한 `scripts/score_distribution.py`(432조합)를 새 기준선으로 만들었다. **실측 결과(v4)**: 신뢰 출처 미등록 시 high 92% · 100점 포화 80% · **웹 신호만으로 high 13/24**, 신뢰 출처 등록 시 high 63% · 포화 38% · 웹 전용 high 0/24. 드러난 것 — v2 가 세운 "웹 존재만으로 high 불가" 원칙이 깨져 있다(presence 풀 60 + entity 풀 20 = 80, 두 풀의 **합**은 캡되지 않음), 그리고 **v3 의 제공자별 풀링이 이를 키웠다**(웹 전용 high 4/12 → 9/12, 전체 high 88% → 97%). 대응 후보(웹 유래 총합 상한)는 미적용 — 적용하면 또 한 번의 미측정 변경이 되므로 결정 후 진행한다. 함께 `scripts/sweep_thresholds.py`(판정 시점 `decisionSnapshot.riskScore` 기준 임계 스윕, 가짜 판정 6건으로 동작 검증)와 `docs/operations/calibration-corpus.md`(이미지 구성·쿼터 준비·투입·판정·측정 절차) 신설. **실이미지 코퍼스와 판정은 사람이 해야 한다** — 판정을 내가 만들면 측정이 성립하지 않는다. **ruff clean · pytest 645 passed**

View file

@ -41,7 +41,7 @@ python run_copyrighter_server.py # 기본 127.0.0.1:9500
| `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` 은 가중치/버킷/밴드 변경 시 반드시 올린다**. `RiskScorer(trusted_hosts)` 는 지식DB 의 활성 `trusted_host` 항목(이름·별칭이 도메인)을 받아 그 호스트에서 발견된 웹 탐지 행을 0점 처리한다 — **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다 |
| `risk_scoring.py` | `RiskScorer.score()` — 점수·밴드 산출. **`SCORER_VERSION` 은 가중치/버킷/밴드 변경 시 반드시 올린다**. `RiskScorer(trusted_hosts)` 는 지식DB 의 활성 `trusted_host` 항목(이름·별칭이 도메인)을 받아 그 호스트에서 발견된 웹 탐지 행을 0점 처리한다 — **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다. v5 축 예산표(등록 일치 80 · 웹 존재 40 한 풀 · 인물 30/10 · 검색 승격 15 · 라벨 10 · 실패는 하한 30)는 파일 상단 주석이 정본 — 검색결과 사본 행은 `knowledge_entry_id` 부재 + `url`/`source_evidence_ids` 로 웹 풀에 간다 |
| `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 폐기 |

View file

@ -139,8 +139,8 @@
"id": "scorer",
"type": "backend",
"label": "RiskScorer",
"sublabel": "제공자별 풀 · 버킷",
"tag": "v4",
"sublabel": "한 풀 · 제공자별 버킷",
"tag": "v5",
"stage": 2,
"row": 1
},
@ -294,7 +294,7 @@
"title": "주 경로",
"items": [
"외부 응답은 데이터 등급 검사를 통과한 필드만 Evidence 가 된다",
"RiskScorer v4 는 풀과 dedup 버킷을 제공자별로 나눈다 (presence 60 / entity 20)",
"RiskScorer v5 는 웹 존재를 제공자 무관 한 풀로 센다 (presence 40 / entity 10)",
"점수 · 밴드 · 사유는 payload 로 직렬화되어 evidence 테이블에 남는다",
"신뢰 출처(자사·계약처 도메인)에서 발견된 행은 0점이다 — 행 단위 제외라 스크래퍼 게시는 그대로 센다"
]

Binary file not shown.

View file

@ -6,33 +6,33 @@
"type": "architecture",
"spec": "system-architecture.architecture.json",
"html": "system-architecture.html",
"specSha256": "77b3581a116b2ea6908f254f58b549312da439715012328cabed459ec4a971ce",
"htmlSha256": "d062032d51e40095cee996e4b3afb06337862017f641dc599d54028b18a47c3c",
"htmlBytes": 816201
"specSha256": "74b2864bb11457a61f8956a8556d1f24e0e2e0bf8356efa350076bb9f7a415df",
"htmlSha256": "ec9c460515cf0d29d9c0968ee052d09e9eb7a3292ff893cf2a9ce6ecf9d2b05a",
"htmlBytes": 816524
},
{
"type": "dataflow",
"spec": "evidence-dataflow.dataflow.json",
"html": "evidence-dataflow.html",
"specSha256": "091f63f4b646f5c65ebc9f471d571e412b83572dffddb28f97a7fd016d2e827d",
"htmlSha256": "01f871dd947cf5fcc7675938f64a66926d010843295d5bab9a48ddb66089f09d",
"htmlBytes": 822842
"specSha256": "f11b2132d947720bc6558cd14c201df48135d4458673910a050f9b6f11e0ab60",
"htmlSha256": "457f6757fd3f7a755bb769fa6a2e8f34d5e12b1a6fcacbd6a6e733e176980b03",
"htmlBytes": 823181
},
{
"type": "workflow",
"spec": "operator-review-workflow.workflow.json",
"html": "operator-review-workflow.html",
"specSha256": "2925ef821e8e63143e001e0996a67470be1291f75a9d50fc47fce5966082c282",
"htmlSha256": "0cbf42921f6d21322eca82252f520f94d36ad113d2263757d9c7403fc415f337",
"htmlBytes": 818137
"specSha256": "00a2723fb6afa8a0a4f9fc4110267d6ddc0c48dd9bbe0d132a58b4d00424d7d4",
"htmlSha256": "e49518ac5d2a98d4967b809c62e24fc429773be912ed7dd7f445c7d20467b1bd",
"htmlBytes": 818466
},
{
"type": "lifecycle",
"spec": "submission-lifecycle.lifecycle.json",
"html": "submission-lifecycle.html",
"specSha256": "b53d44dc70393c7a0a7746d774563c61e412eceafed5d10fd1a5e0e509aa3ac9",
"htmlSha256": "62e934a32e3b3c5b1660794d8919e2fc1910f3937e82e882f464abebbfbb2634",
"htmlBytes": 812325
"htmlSha256": "ef1930ffbae38a5430c22bc49b3a5a96c259346f8810fb2eb4c347f2212bb6d7",
"htmlBytes": 812648
}
]
}

Binary file not shown.

View file

@ -138,7 +138,7 @@
"type": "external",
"label": "큐 폴더 · 업로드",
"sublabel": "이미지 · manifest",
"width": 132
"width": 124
},
{
"id": "seed",
@ -148,7 +148,7 @@
"label": "시드 · 리로드",
"sublabel": "신규 레코드만",
"tag": "판정 보존",
"width": 132
"width": 124
},
{
"id": "analyze",
@ -157,7 +157,7 @@
"type": "backend",
"label": "내부 분석",
"sublabel": "지문 · 얼굴 · 출처",
"width": 132
"width": 124
},
{
"id": "search",
@ -166,7 +166,7 @@
"type": "backend",
"label": "자동 검색",
"sublabel": "쿼리 생성 · 승격",
"width": 132
"width": 124
},
{
"id": "score",
@ -175,8 +175,8 @@
"type": "backend",
"label": "위험도 산출",
"sublabel": "점수 · 밴드 · 사유",
"tag": "v4",
"width": 132
"tag": "v5",
"width": 124
},
{
"id": "llm",
@ -185,7 +185,7 @@
"type": "backend",
"label": "LLM 증거 판독",
"sublabel": "인용 검증 · 12행",
"width": 132
"width": 124
},
{
"id": "evidence",
@ -194,7 +194,7 @@
"type": "frontend",
"label": "증거 검토",
"sublabel": "호스트 · 썸네일 · 링크",
"width": 132
"width": 124
},
{
"id": "decide",
@ -204,7 +204,7 @@
"label": "판정",
"sublabel": "승인 · 보류 · 반려",
"tag": "감사 이벤트",
"width": 132
"width": 124
},
{
"id": "stop",
@ -213,7 +213,7 @@
"type": "security",
"label": "비상 차단 스위치",
"sublabel": "쿼터 · provider 토글",
"width": 132
"width": 124
},
{
"id": "skipped",
@ -222,7 +222,7 @@
"type": "security",
"label": "검색 건너뜀",
"sublabel": "0점 · 사유만 기록",
"width": 132
"width": 124
},
{
"id": "retained",
@ -232,7 +232,7 @@
"label": "보존된 케이스",
"sublabel": "원본 파일 없음",
"tag": "assetMissing",
"width": 132
"width": 124
}
],
"edges": [

Binary file not shown.

View file

@ -140,7 +140,7 @@
"type": "backend",
"label": "분석 · 채점",
"sublabel": "지문 · 얼굴 · 출처",
"tag": "RiskScorer v4",
"tag": "RiskScorer v5",
"pos": [
678,
60

Binary file not shown.

View file

@ -103,32 +103,56 @@ python scripts/sweep_thresholds.py data/corpus.sqlite3
읽는 법: `over` = high 로 분류했는데 승인한 건수(과탐, 많으면 high 임계가 낮다).
`under` = low 로 분류했는데 반려한 건수(미탐, 0 이 아니면 medium 임계가 높다).
## 참고 — 2026-09-22 시점의 구조적 거동 (판정 없이 측정한 것)
## 참고 — 구조적 거동 실측 (판정 없이 측정한 것)
`scripts/score_distribution.py` 로 432조합을 전수 계산한 결과(스코어러 v4):
| | 신뢰 출처 미등록 | 신뢰 출처 등록 |
|---|---|---|
| high | 92% | 63% |
| medium | 6% | 29% |
| low | 0% | 7% |
| 100점 포화 | 80% | 38% |
| 웹 신호만으로 high | 13/24 | 0/24 |
`scripts/score_distribution.py` 432조합 전수 + `data/corpus.sqlite3` 실이미지 20장.
스코어러를 바꿀 때마다 이 두 표를 다시 채운다.
**이 격자는 신호 축을 균등 샘플링하므로 실제 큐 분포가 아니다.** 92% 가 "실제 큐의
92% 가 high"라는 뜻이 아니며, 임계가 맞는지도 말해주지 않는다. 구조적 거동의
기준선일 뿐이고, 스코어러를 바꿀 때 전후 비교에 쓴다.
여기서 드러난 것 두 가지:
### 격자 432조합 — v4 → v5 (2026-09-22)
1. **웹 신호만으로 high 에 도달하는 조합이 있다.** 2026-08-09 v2 가 세운 설계 원칙은
| | v4 미등록 | v4 등록 | **v5 미등록** | **v5 등록** |
|---|---|---|---|---|
| high | 92% | 63% | **75%** | **40%** |
| medium | 6% | 29% | **22%** | **40%** |
| low | 0% | 7% | **2%** | **18%** |
| 100점 포화 | 80% | 38% | **44%** | **22%** |
| 웹 신호만으로 high | 13/24 | 0/24 | **0/24** | **0/24** |
(등록 = 신뢰 출처 등록.) v5 의 포화 44% 는 전부 격자의 1/3 을 차지하는 "등록 권리 대상
강한 일치(80)" 조합이다 — 등록 일치는 단독 high 로 설계했으므로 그 위의 순서는 의도적으로
포기한 것이고, **등록 일치가 없는 조합의 포화는 0** 이다.
### 실이미지 20장 (`sample/` — 전부 웹에 있는 인물 사진, 저위험 기대 이미지 0장)
| | v4 | v5 |
|---|---|---|
| 밴드 | high 20 | high 20 |
| 100점 | 20/20 | **0/20** |
| 구별되는 점수 | 1 (100) | **4 (75 · 80 · 92 · 95)** |
v5 에서 20장의 순서를 만든 것은 인물 등급(배경 1장 → 75)과 이름 있는 검색 승격
유무(없음 3장 → 80, 약한 승격 4장 → 92)다. 웹 존재(40)와 라벨(10)은 20장 전부 상한에
있어 이 코퍼스 안에서는 순서를 만들지 못한다 — 웹 사본이 있는 인물 사진만 모은
코퍼스의 한계지 스코어러의 한계가 아니다. **medium/low 임계 검증은 여전히 불가**:
1절의 구성대로 자사 오리지널·라이선스 구매분이 들어와야 분모가 생긴다.
### v4 에서 드러났고 v5 가 처리한 것
1. **웹 신호만으로 high 에 도달하는 조합이 있었다.** 2026-08-09 v2 의 설계 원칙은
"웹 존재만으로는 high 가 되지 않는다. 두 번째 **종류**의 신호가 필요하다"였는데,
presence 풀(최대 60)과 entity 풀(최대 20)의 합이 80 이라 `full+partial` + 엔티티 1개
만으로 high 가 된다. 두 풀이 각각 임계 아래로 캡됐지만 합은 캡되지 않는다.
만으로 high 가 됐다. 두 풀이 각각 임계 아래로 캡됐지만 합은 캡되지 않았다.
2. **v3 의 제공자별 풀링이 그 경향을 키웠다.** 독립 제공자가 하나 더 있으면 웹 전용
조합의 high 가 4/12 → 9/12 로, 전체 high 비율이 88% → 97% 로 올라간다. 교차 확증이
high 에 도달해야 한다는 v3 의 의도대로 동작한 결과지만, 총량 통제가 없다.
조합의 high 가 4/12 → 9/12 로 올라갔다. 교차 확증이 high 에 도달해야 한다는 v3 의
의도대로 동작한 결과지만, 총량 통제가 없었다.
3. **실이미지가 보인 세 번째 원인.** 검색결과 사본 유사도가 행마다 +80, 무상한이었다
(코퍼스 평균 216점/장). 캡 없는 두 축만 캡하는 시뮬레이션(80/50, 60/40)으로도 20/20 이
그대로 100 — 축 하나가 아니라 예산 **합**(245 vs 100)의 문제였다.
대응 후보(미적용, 결정 필요): 웹 유래 점수 총합에 상한을 두어 제공자 수와 무관하게
웹 신호만으로는 high 에 닿지 않게 한다. 적용하면 `SCORER_VERSION` 을 올리고 이 표를
다시 측정해야 한다.
v5 는 축 예산을 총합 기준으로 재배분했다(등록 일치 80 · 웹 존재 40 **한 풀** · 인물 30/10 ·
검색 승격 15 · 라벨 10 → 등록 제외 최대 95, 외부 검색 실패는 가산 대신 medium 하한).
정본은 `risk_scoring.py` 상단 예산표다.

View file

@ -6,57 +6,71 @@ from urllib.parse import urlparse
from rights_filter.analysis.face_person_detection import TIER_BACKGROUND
from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult
FACE_PERSON_IDENTIFIABLE_POINTS = 35
# --- v5 축 예산 ----------------------------------------------------------------
#
# 2026-09-22, 실이미지 20장 첫 실측: 20/20 이 100점이었다. 탐지는 정상이었고
# (15장은 웹에서 완전일치 사본이 나왔다) 문제는 순서가 없다는 것이었다 - 큐가
# 점수 내림차순인데 전부 100 이라 정렬이 no-op 이다. 원인은 축별 예산의 **합**이다.
# v2~v4 는 축을 하나씩 캡했지만(웹 게시 60, 라벨 20) 인물 35 를 더하면 캡된 축만으로
# 이미 115 라 상한을 넘고, 캡이 없던 두 축(검색결과 사본 유사도 +80/행, 네이버 승격)이
# 그 위에 쌓였다. 캡 없는 두 축만 캡해도(80/50, 60/40 시뮬레이션) 20/20 이 그대로
# 100 이었다 - 축 하나를 손보는 것으로는 안 되고 예산을 총합 기준으로 다시 짜야 한다.
#
# 등록 권리 대상 일치 80 운영자가 지식DB 에 등록한 판단. 단독 high.
# 웹 존재 40 사본 · Vision 매치 · CSE 페이지가 한 풀. 단독 medium.
# 인물 30 식별 가능 30 / 배경 10
# 이름 있는 검색 승격 15 네이버 텍스트 검색. 단독으로는 밴드 못 만든다.
# 라벨 10 Vision 엔티티. 단독으로는 밴드 못 만든다.
# -------------------------
# 등록 제외 최대 95 → 100 에 닿지 않아 웹에 있는 이미지들 사이에 순서가 남는다.
# 외부 검색 실패 하한 30 점수가 아니라 바닥. 아래 FAILURE_FLOOR_POINTS 참조.
#
# 흔한 케이스(웹 사본 + 식별 가능 인물)가 정확히 70 으로 high 문턱에 서고, 그 위로
# 이름 있는 검색·라벨이, 아래로 근사 사본·배경 인물이 순서를 만든다. 웹 신호만으로는
# 최대 65 라 high 에 닿지 않는다 - v2 가 세웠다가 v3 의 제공자별 풀링이 깨뜨린 원칙이다.
#
# "웹 존재"가 한 풀인 이유: Vision 의 full/partial/page/visual, CSE 의 page,
# 검색결과 사본 유사도 1.00 은 전부 "이 이미지가 웹에 있다"는 **하나의 사실**의
# 다른 뷰다. 발견된 URL 수만큼 세면 그 사실이 여러 번 계산된다 - v2 가 Vision 안에서
# 고친 그 오류가 v4 까지 사본 유사도 행(+80/행, 코퍼스 평균 216/장)에 남아 있었다.
# 풀 안에서는 뷰의 강도가 점수를 정한다(완전일치 40 > 근사·부분·페이지 30 > 유사 10).
# dedup 버킷은 v3 대로 제공자별이라 CSE 의 page 행은 Vision 의 page 행에 먹히지 않고
# 사유로 남는다 - 다만 점수는 같은 풀로 간다.
#
# 등록 권리 대상 일치를 웹 풀과 갈라 둔 이유: 그것은 웹의 사실이 아니라 운영자가
# 등록한 판단이고, 단독으로 high 에 닿는 유일한 축이다. 라벨이 그 길을 대신할 수
# 없는 이유는 그대로다 - Vision 은 "Dog" 와 유명인 이름을 구분할 정보를 주지 않고,
# MID 를 카테고리로 푸는 지식그래프 조회는 폐쇄망에서 불가능하다. 알려진 권리 대상은
# 지식DB 로 올라가고, 모르는 유명인은 라벨만으로 high 가 되지 않는다(의도된 미탐).
# `category` 분기를 쓰는 곳 없이 다시 넣지 말 것 - 예전 것은 아무 producer 도 쓰지 않는
# 키를 읽어 유명인이 안전한 것처럼 읽혔다.
FACE_PERSON_IDENTIFIABLE_POINTS = 30
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.
#
# The cap applies to EVERY entity, with no escape hatch, because the adapter
# hands us nothing that separates "Dog" from a celebrity's name: Vision's
# webEntities carry a description and an opaque MID, and resolving a MID to a
# category would take the knowledge-graph lookup this air-gapped build cannot
# make. A rights subject we actually know about escalates through the knowledge
# base / watchlist path (FINGERPRINT evidence), which is not pooled here; an
# unknown celebrity cannot be banded high on a raw label alone. That is a
# deliberate false-negative trade, not an oversight.
#
# Do NOT re-add a `category` branch without a writer for it. The previous one
# read a key nothing in src/ ever set - every producer in
# cloud_vision_web_detection.py builds entity rows as
# {entity, entity_id, match: "entity", provider} - so it documented an escape
# hatch no real image could reach while reading as though celebrities were safe.
WEB_ENTITY_MAX_POINTS = 20
KNOWLEDGE_MATCH_MAX_POINTS = 80
KNOWLEDGE_MATCH_STRONG_POINTS = 80 # 유사도 0.9 이상
KNOWLEDGE_MATCH_WEAK_POINTS = 30
# 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.
#
# The pool is PER PROVIDER, because the correlation it corrects for lives inside
# one response. Vision saying "full copy" and "hosting page" is one finding read
# two ways; Google custom search independently returning a published page is a
# different finding, and pooling the two together capped genuine cross-provider
# corroboration - the very second signal the paragraph above says should reach
# high - at 60/medium.
WEB_PRESENCE_MAX_POINTS = 60
WEB_PRESENCE_MAX_POINTS = 40
WEB_COPY_EXACT_POINTS = 40 # 검색결과 사본 유사도 0.99 이상 · Vision full
WEB_COPY_NEAR_POINTS = 30 # 유사도 0.9~0.99 · Vision partial/page · CSE page · 레거시 url 행
WEB_COPY_WEAK_POINTS = 10 # 유사도 0.9 미만 · Vision visual
NAVER_PROMOTED_POINTS = 15 # 쿼리 버킷당 round(15 * confidence)
NAVER_MAX_POINTS = 15
WEB_ENTITY_LABEL_POINTS = 10 # 라벨당 round(10 * confidence)
WEB_ENTITY_MAX_POINTS = 10
# 외부 검색 실패는 증거가 아니라 커버리지 공백이다. "확인 못 했다"는 케이스는 반드시
# 사람이 봐야 하므로 medium 하한까지 끌어올리되, 이미 그 위에 있는 케이스에는 아무것도
# 더하지 않는다 - 더하면 네이버 장애 하루가 웹에 있는 모든 이미지를 high 로 밀어 올린다.
FAILURE_FLOOR_POINTS = 30
# Stamped onto every submission at creation so a later weight change is
# 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.
# v2: uncategorized web entities capped in aggregate (WEB_ENTITY_MAX_POINTS)
# and web-presence match types pooled under WEB_PRESENCE_MAX_POINTS.
@ -67,16 +81,27 @@ WEB_PRESENCE_MAX_POINTS = 60
# v4: web-detection rows published on an operator-registered trusted host score
# zero. The exclusion is per ROW, not per case - an image that is on our own
# site AND on a scraper blog still counts the scraper.
SCORER_VERSION = "v4"
# v5: budgets reallocated so every axis but a registered match sums to 95;
# search-result copies join ONE web-presence pool (was +80 per row) shared
# across providers; naver hits and labels are pooled at 15 / 10; a provider
# failure is a 30-point floor instead of +30 per row.
SCORER_VERSION = "v5"
# 점수 분해에 쓰는 사람이 읽는 이름. 키는 코드가, 라벨은 운영자가 본다.
# 점수 분해에 쓰는 사람이 읽는 이름. 키는 코드가, 라벨은 운영자가 본다. 순서는 위 예산표.
_COMPONENT_LABELS = {
"fingerprint": "지식DB 지문 매칭",
"knowledge": "등록 권리 대상 일치",
"web_presence": "웹 존재 (사본 · 매치)",
"face": "인물",
"naver": "네이버 승격 히트",
"failure": "외부 검색 실패",
"web_other": "웹 탐지(기타)",
"naver": "이름 있는 검색 승격",
"web_entity": "웹 엔티티 라벨",
"failure": "외부 검색 실패 (검토 하한)",
}
_POOL_CAPS = {
"knowledge": KNOWLEDGE_MATCH_MAX_POINTS,
"web_presence": WEB_PRESENCE_MAX_POINTS,
"naver": NAVER_MAX_POINTS,
"web_entity": WEB_ENTITY_MAX_POINTS,
}
@ -109,17 +134,20 @@ class RiskScorer:
return ""
def score(self, evidence: list[Evidence]) -> ScoreResult:
score = 0
reasons: list[str] = []
counted_web_buckets: set[str] = set()
counted_naver_buckets: set[str] = set()
counted_face_person = False
web_entity_points = 0
web_presence_points: dict[str, int] = {}
# 점수 분해용 누적. 점수 계산에는 참여하지 않고 같은 흐름을 기록만 한다.
components: dict[str, int] = {}
face_points = 0
failure_seen = False
# 축별 원점수. 상한은 루프가 끝난 뒤 한 번에 건다 - 같은 사실을 몇 행이
# 말하든 축의 예산을 넘지 못한다.
pools: dict[str, int] = {}
excluded: dict[str, int] = {}
def pool(key: str, points: int) -> None:
pools[key] = pools.get(key, 0) + points
for item in evidence:
if item.data.get("operator_status") in {"irrelevant", "false_positive"}:
continue
@ -128,33 +156,45 @@ class RiskScorer:
continue
# A match against a CLEARED reference is a positive signal: the
# team already confirmed this image is safe to use. It must not
# add risk (a +80 here would push a known-safe image to the high
# band). Surface it as a reason so the operator sees the prior
# add risk. Surface it as a reason so the operator sees the prior
# clearance, but contribute no points.
if item.data.get("knowledge_entry_type") == "cleared_reference":
if item.reason:
reasons.append(item.reason)
continue
if item.reason == "Image fingerprints generated":
continue
similarity = _safe_float(item.data.get("similarity", 0))
if similarity >= 0.9:
score += 80
components["fingerprint"] = components.get("fingerprint", 0) + 80
reasons.append(item.reason)
elif item.reason != "Image fingerprints generated":
score += 30
components["fingerprint"] = components.get("fingerprint", 0) + 30
reasons.append(item.reason)
if _is_search_result_copy(item):
# A copy found among search results is the strongest view of
# "this image is on the web" - the same fact Vision's match
# rows report, so it shares their pool. Trusted hosts apply
# here as they do to every other published-on-the-web row.
trusted = self._trusted_host_of(item)
if trusted:
reasons.append(f"신뢰 출처 {trusted} — 위험 점수에서 제외")
excluded[trusted] = excluded.get(trusted, 0) + 1
continue
pool("web_presence", _search_result_copy_points(similarity))
else:
# A match against an operator-registered rights subject. Not a
# fact about the web but a judgement the team recorded, so it
# is the one axis that reaches high on its own.
pool(
"knowledge",
KNOWLEDGE_MATCH_STRONG_POINTS if similarity >= 0.9 else KNOWLEDGE_MATCH_WEAK_POINTS,
)
reasons.append(item.reason)
elif item.source == EvidenceSource.FACE_PERSON:
# "A person is present" is one signal no matter how many rows
# assert it. Re-analysis appends a fresh FACE_PERSON row while the
# previous one remains, so without this bucket the same image
# accrued +35 per rerun and drifted into the high band on its own.
# accrued the portrait weight per rerun and drifted into the high
# band on its own.
if counted_face_person:
continue
counted_face_person = True
face_points = _face_person_points(item)
score += face_points
components["face"] = components.get("face", 0) + face_points
reasons.append(item.reason)
elif item.source == EvidenceSource.WEB_DETECTION:
# Found on a host the team controls or licenses. "It is on the
@ -171,16 +211,10 @@ class RiskScorer:
if bucket and bucket in counted_web_buckets:
continue
points, kind = _web_detection_points(item)
# Pooled kinds are capped after the loop, not added here.
if kind == _WEB_LABEL:
web_entity_points += points
pool("web_entity", points)
elif kind == _WEB_PRESENCE:
pool = _web_presence_pool(item)
web_presence_points[pool] = web_presence_points.get(pool, 0) + points
else:
score += points
if points:
components["web_other"] = components.get("web_other", 0) + points
pool("web_presence", points)
if points:
reasons.append(item.reason)
if bucket:
@ -190,13 +224,11 @@ class RiskScorer:
if not points:
continue
# Count one promoted hit per executed query. Multiple promoted
# results from the same search are one signal, not N stacked +40s
# (which would push a single query straight to the high band).
# results from the same search are one signal, not N.
bucket = _naver_search_bucket(item)
if bucket and bucket in counted_naver_buckets:
continue
score += points
components["naver"] = components.get("naver", 0) + points
pool("naver", points)
reasons.append(item.reason)
if bucket:
counted_naver_buckets.add(bucket)
@ -220,43 +252,42 @@ class RiskScorer:
# An LLM-summary failure only means narration didn't run; it is
# not a search/coverage gap, so it must not add risk or surface
# as a reason. Genuine provider failures (search / web-detection,
# e.g. "External API failed") still contribute the +30 signal.
# e.g. "External API failed") raise the case to the review floor.
if item.reason.startswith("LLM assistance failed"):
continue
if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}:
score += 30
components["failure"] = components.get("failure", 0) + 30
failure_seen = True
reasons.append(item.reason)
# SEARCH_SKIPPED / EXTERNAL_SKIPPED contribute zero points and are
# 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.
pools: list[dict[str, Any]] = []
for pool, pooled_presence in sorted(web_presence_points.items()):
capped = min(pooled_presence, WEB_PRESENCE_MAX_POINTS)
# of each axis is bounded. Components follow the budget table above so
# the operator reads the panel in the same order the comment explains.
components: list[dict[str, Any]] = []
score = 0
for key in ("knowledge", "web_presence", "face", "naver", "web_entity"):
if key == "face":
if face_points:
score += face_points
components.append({"key": key, "label": _COMPONENT_LABELS[key], "points": face_points})
continue
raw = pools.get(key, 0)
if not raw:
continue
capped = min(raw, _POOL_CAPS[key])
score += capped
pools.append(
{
"key": f"web_presence:{pool}",
"label": f"웹 게시 ({pool})",
"points": capped,
"raw": pooled_presence,
"cap": WEB_PRESENCE_MAX_POINTS,
}
components.append(
{"key": key, "label": _COMPONENT_LABELS[key], "points": capped, "raw": raw, "cap": _POOL_CAPS[key]}
)
capped_entities = min(web_entity_points, WEB_ENTITY_MAX_POINTS)
score += capped_entities
if web_entity_points:
pools.append(
{
"key": "web_entity",
"label": "웹 엔티티 라벨",
"points": capped_entities,
"raw": web_entity_points,
"cap": WEB_ENTITY_MAX_POINTS,
}
if failure_seen and score < FAILURE_FLOOR_POINTS:
# 바닥이지 가산이 아니다: 하한까지 모자란 만큼만 채운다. 이미 그 위에
# 있는 케이스는 실패 사유만 남고 점수는 움직이지 않는다.
fill = FAILURE_FLOOR_POINTS - score
score = FAILURE_FLOOR_POINTS
components.append(
{"key": "failure", "label": _COMPONENT_LABELS["failure"], "points": fill, "floor": FAILURE_FLOOR_POINTS}
)
raw_total = score
@ -271,17 +302,10 @@ class RiskScorer:
breakdown = {
"total": raw_total,
"final": score,
# 100 상한에 걸렸다는 사실 자체가 정보다 — 걸린 케이스들은 서로
# 100 상한에 걸렸다는 사실 자체가 정보다 - 걸린 케이스들은 서로
# 구별되지 않으므로 우선순위 판단에 쓸 수 없다.
"clamped": raw_total > score,
"components": [
*[
{"key": key, "label": _COMPONENT_LABELS.get(key, key), "points": points}
for key, points in sorted(components.items())
if points
],
*pools,
],
"components": components,
"excluded": [
{"label": f"신뢰 출처 {host}", "rows": rows}
for host, rows in sorted(excluded.items())
@ -291,6 +315,29 @@ class RiskScorer:
return ScoreResult(score=score, band=band, reasons=_unique(reasons), breakdown=breakdown)
def _is_search_result_copy(item: Evidence) -> bool:
"""A FINGERPRINT row that matched a SEARCH RESULT image, not a knowledge entry.
store_search_candidates writes those rows with the result `url` and the
`source_evidence_ids` of the search hit they came from; a knowledge match
(internal_analyzer) carries `knowledge_entry_id` and neither of those. Both
shapes survive the persistence round-trip under the same keys. A row with
nothing but a similarity is read as a knowledge match, which is what every
caller that predates search-result matching meant by it.
"""
if item.data.get("knowledge_entry_id"):
return False
return bool(item.data.get("source_evidence_ids") or item.data.get("url"))
def _search_result_copy_points(similarity: float) -> int:
if similarity >= 0.99:
return WEB_COPY_EXACT_POINTS
if similarity >= 0.9:
return WEB_COPY_NEAR_POINTS
return WEB_COPY_WEAK_POINTS
def _face_person_points(item: Evidence) -> int:
# A recognizable subject and a bystander in a crowd are not the same portrait
# risk. The old flat +35 gave every photo containing any human enough to
@ -354,10 +401,9 @@ def _evidence_host(item: Evidence) -> str:
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.
Every presence view lands in the one web-presence pool; only the strength
of the view differs. Bare Vision entities describe the subject rather than
its rights and pool separately.
"""
if item.data.get("weak_hint"):
return 0, _WEB_STANDALONE
@ -366,26 +412,27 @@ def _web_detection_points(item: Evidence) -> tuple[int, str]:
# matching pHash FINGERPRINT evidence carries the score). Fresh evidence has
# no bare "url" key so it scores 0, but the persistence round-trip aliases
# result_url -> url; without this explicit gate the round-trip would flip the
# same evidence to +45 via the url fallback below, violating the
# same evidence into the url fallback below, violating the
# score-preservation invariant and inflating bands on any query that returns
# anything.
if item.data.get("match") == "search_image":
match = item.data.get("match")
if match == "search_image":
return 0, _WEB_STANDALONE
if item.data.get("match") == "full":
return 45, _WEB_PRESENCE
if item.data.get("match") in {"partial", "page"}:
return 35, _WEB_PRESENCE
if item.data.get("match") == "visual":
return 10, _WEB_PRESENCE
if match == "full":
return WEB_COPY_EXACT_POINTS, _WEB_PRESENCE
if match in {"partial", "page"}:
return WEB_COPY_NEAR_POINTS, _WEB_PRESENCE
if match == "visual":
return WEB_COPY_WEAK_POINTS, _WEB_PRESENCE
# Legacy rows only: every current producer sets `match`, so a web-detection
# row that has a URL and no match type was written by an older build. It
# keeps the original weight rather than being rescored against a rule it
# predates - and it must stay below the `match` checks above, because the
# persistence round-trip aliases result_url -> url on every row.
if not item.data.get("match") and item.data.get("url"):
return 45, _WEB_PRESENCE
# keeps a presence weight rather than being dropped - and it must stay below
# the `match` checks above, because the persistence round-trip aliases
# result_url -> url on every row.
if not match and item.data.get("url"):
return WEB_COPY_NEAR_POINTS, _WEB_PRESENCE
if item.data.get("entity"):
return round(40 * item.confidence), _WEB_LABEL
return round(WEB_ENTITY_LABEL_POINTS * item.confidence), _WEB_LABEL
return 0, _WEB_STANDALONE
@ -419,7 +466,7 @@ def _web_detection_bucket(item: Evidence) -> str:
def _naver_search_points(item: Evidence) -> int:
if item.data.get("promoted"):
return round(50 * item.confidence)
return round(NAVER_PROMOTED_POINTS * item.confidence)
return 0

View file

@ -56,7 +56,9 @@ def test_enrichment_adds_naver_evidence_llm_summary_and_score():
assert summary.executed_searches == 3
assert any(item.source == EvidenceSource.NAVER_SEARCH for item in latest.evidence)
assert any(item.source == EvidenceSource.LLM_SUMMARY for item in latest.evidence)
assert latest.score.score >= 35
# 리스코어가 돌았고 네이버 축이 점수에 들어갔다 (라벨 9 + 승격 상한 15).
assert any(item["key"] == "naver" for item in latest.score.breakdown["components"])
assert latest.score.score == 24
def test_enrichment_is_idempotent_for_same_query_signature():

View file

@ -1,5 +1,11 @@
from rights_filter.analysis.risk_scoring import (
FACE_PERSON_BACKGROUND_POINTS,
FACE_PERSON_IDENTIFIABLE_POINTS,
FAILURE_FLOOR_POINTS,
NAVER_MAX_POINTS,
WEB_COPY_NEAR_POINTS,
WEB_ENTITY_MAX_POINTS,
WEB_PRESENCE_MAX_POINTS,
RiskScorer,
)
from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult
@ -120,24 +126,28 @@ def test_failure_reason_does_not_lower_existing_high_risk_score():
assert isinstance(with_failure, ScoreResult)
def test_promoted_naver_search_evidence_can_raise_review_risk():
result = RiskScorer().score(
[
Evidence(
source=EvidenceSource.NAVER_SEARCH,
reason="Naver result linked named work",
confidence=0.8,
data={
"promoted": True,
"promotion_reason": "named person/work search evidence",
},
)
]
def test_promoted_naver_search_evidence_raises_risk_but_cannot_band_a_case_alone():
# A promoted text-search hit says a NAMED query found results, not that the
# image itself was found. It orders cases inside a band; it does not make one.
promoted = Evidence(
source=EvidenceSource.NAVER_SEARCH,
reason="Naver result linked named work",
confidence=0.8,
data={
"promoted": True,
"promotion_reason": "named person/work search evidence",
},
)
assert result.band == "medium"
assert result.score >= 35
assert "Naver result linked named work" in result.reasons
alone = RiskScorer().score([promoted])
with_face = RiskScorer().score(
[promoted, Evidence(EvidenceSource.FACE_PERSON, "Identifiable person", 0.9, {"tier": "identifiable"})]
)
assert 0 < alone.score <= NAVER_MAX_POINTS
assert alone.band == "low"
assert "Naver result linked named work" in alone.reasons
assert with_face.score == FACE_PERSON_IDENTIFIABLE_POINTS + alone.score
def test_llm_summary_does_not_directly_affect_score():
@ -216,11 +226,12 @@ def test_score_of_exactly_30_is_medium_not_low():
def test_score_just_below_70_is_medium():
# Face (+35) + weak fingerprint (+30) = 65.
# Face (+30) + weak knowledge match (+30) + one 0.5 label (+5) = 65.
result = RiskScorer().score(
[
Evidence(source=EvidenceSource.FACE_PERSON, reason="Face/person detected", confidence=0.8, data={"face_count": 1}),
_fingerprint(0.5),
_label("Dog", 0.5),
]
)
assert result.score == 65
@ -228,11 +239,12 @@ def test_score_just_below_70_is_medium():
def test_score_of_exactly_70_is_high_not_medium():
# Face (+35) + partial web detection (+35) = 70 — the high boundary.
# Face (+30) + full web copy (+40) = 70 — the high boundary, and the most
# common real case (a published photo of an identifiable person).
result = RiskScorer().score(
[
Evidence(source=EvidenceSource.FACE_PERSON, reason="Face/person detected", confidence=0.8, data={"face_count": 1}),
Evidence(source=EvidenceSource.WEB_DETECTION, reason="Partial match", confidence=0.9, data={"match": "partial"}),
Evidence(source=EvidenceSource.WEB_DETECTION, reason="Full match", confidence=0.9, data={"match": "full"}),
]
)
assert result.score == 70
@ -240,7 +252,7 @@ def test_score_of_exactly_70_is_high_not_medium():
def test_score_is_clamped_to_100():
# Strong fingerprint (+80) + full web detection (+45) = 125 -> clamped to 100.
# Registered match (+80) + full web copy (+40) = 120 -> clamped to 100.
result = RiskScorer().score(
[
_fingerprint(0.95),
@ -259,7 +271,7 @@ def test_two_distinct_web_entities_are_two_signals_even_though_pooled():
# that is the only place the operator sees there were two.
result = RiskScorer().score([_label("캐릭터 A", 0.94), _label("유명인 B", 0.94)])
assert result.score == 20
assert result.score == WEB_ENTITY_MAX_POINTS
assert "Web entity matched 캐릭터 A" in result.reasons
assert "Web entity matched 유명인 B" in result.reasons
@ -284,7 +296,7 @@ def test_same_web_entity_repeated_does_not_double_count():
]
)
assert result.score == 20
assert result.score == 9
assert result.reasons == ["Web entity matched character A"]
@ -313,7 +325,7 @@ def test_uncategorized_web_entities_cannot_band_an_image_on_their_own():
]
)
assert result.score == 20
assert result.score == WEB_ENTITY_MAX_POINTS
assert result.band == "low"
# Capping the weight must not hide the labels from the operator.
assert len(result.reasons) == 5
@ -353,7 +365,7 @@ def test_a_category_key_does_not_buy_an_entity_out_of_the_pool():
)
bare = RiskScorer().score([_label("유명인 B", 0.94), _label("Portrait", 0.9)])
assert categorized.score == bare.score == 20
assert categorized.score == bare.score == WEB_ENTITY_MAX_POINTS
def _presence_set() -> list[Evidence]:
@ -368,12 +380,12 @@ def _presence_set() -> list[Evidence]:
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.
# Summed, the four match types once reached 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.score == WEB_PRESENCE_MAX_POINTS
assert result.band == "medium"
assert len(result.reasons) == 4
@ -388,7 +400,7 @@ def test_web_presence_plus_a_second_signal_kind_reaches_high():
]
)
assert result.score == 60 + FACE_PERSON_IDENTIFIABLE_POINTS
assert result.score == WEB_PRESENCE_MAX_POINTS + FACE_PERSON_IDENTIFIABLE_POINTS
assert result.band == "high"
@ -446,20 +458,25 @@ def test_one_providers_correlated_match_types_stay_pooled():
]
)
assert result.score == 60
assert result.score == WEB_PRESENCE_MAX_POINTS
assert result.band == "medium"
def test_two_providers_each_finding_a_published_copy_reaches_high():
# Two independent providers each locating a published copy is the corroborated
# web presence the pool's own rationale says should reach high. Pooled
# together with Vision's correlated views it capped at 60/medium instead.
def test_two_providers_corroborating_a_published_copy_is_still_web_presence_only():
# v3 let a second provider's page hit stack on Vision's pool, which is how
# web-only evidence reached high (13/24 web-only grid combinations at v4).
# v5: the corroboration is visible in the raw pool and in the reasons, but
# "on the web" is one fact however many providers report it - it takes a
# second KIND of signal to reach high.
result = RiskScorer().score(
[_vision("Full match", "full", 0.9), _custom_search_page("Google custom web search result found")]
)
assert result.score == 45 + 35
assert result.band == "high"
assert result.score == WEB_PRESENCE_MAX_POINTS
assert result.band == "medium"
assert "Google custom web search result found" in result.reasons
pool = next(item for item in result.breakdown["components"] if item["key"] == "web_presence")
assert pool["raw"] == 40 + WEB_COPY_NEAR_POINTS
def test_a_custom_search_page_hit_is_not_deduped_by_a_vision_page_row():
@ -472,13 +489,13 @@ def test_a_custom_search_page_hit_is_not_deduped_by_a_vision_page_row():
)
assert "Google custom web search result found" in result.reasons
assert result.score == 35 + 35
pool = next(item for item in result.breakdown["components"] if item["key"] == "web_presence")
assert pool["raw"] == 2 * WEB_COPY_NEAR_POINTS
def test_legacy_web_row_without_a_match_type_keeps_its_original_weight():
# Rows written before every producer set `match`. Rescoring them against the
# current rules would rewrite history; the round-trip has to reproduce the
# score they were created with.
def test_legacy_web_row_without_a_match_type_still_counts_as_presence():
# Rows written before every producer set `match`. They must not be dropped
# to zero just because the current producers describe themselves better.
legacy = Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="Matching image URL found",
@ -488,7 +505,7 @@ def test_legacy_web_row_without_a_match_type_keeps_its_original_weight():
result = RiskScorer().score([legacy])
assert result.score == 45
assert result.score == WEB_COPY_NEAR_POINTS
assert result.band == "medium"
@ -524,8 +541,8 @@ def test_trust_is_applied_per_row_not_per_case():
trusted = RiskScorer(frozenset({"posar.info"})).score(evidence)
untrusted = RiskScorer().score(evidence)
assert untrusted.score == 45 + 35
assert trusted.score == 35
assert untrusted.score == WEB_PRESENCE_MAX_POINTS
assert trusted.score == WEB_COPY_NEAR_POINTS
assert "blog.example.test 에서 발견" in trusted.reasons
@ -540,15 +557,15 @@ def test_a_lookalike_domain_is_not_trusted():
# posar.info.evil.test 는 접미사가 아니라 다른 도메인이다.
result = RiskScorer(frozenset({"posar.info"})).score([_published_on("posar.info.evil.test")])
assert result.score == 45
assert result.score == WEB_PRESENCE_MAX_POINTS
def test_entries_that_are_not_hostnames_are_ignored():
# "com" 하나가 웹 전체를 신뢰 처리하면 안 된다.
evidence = [_published_on("posar.info")]
assert RiskScorer(frozenset({"com"})).score(evidence).score == 45
assert RiskScorer(frozenset({"", " ", "posar info"})).score(evidence).score == 45
assert RiskScorer(frozenset({"com"})).score(evidence).score == WEB_PRESENCE_MAX_POINTS
assert RiskScorer(frozenset({"", " ", "posar info"})).score(evidence).score == WEB_PRESENCE_MAX_POINTS
def test_a_fresh_custom_search_row_resolves_its_host_from_the_provider_field():
@ -572,7 +589,7 @@ def test_trust_does_not_touch_entity_rows():
# 엔티티 라벨은 "어디에 떠 있나"가 아니라 "무엇이 찍혔나"다.
result = RiskScorer(frozenset({"posar.info"})).score([_label("강아지", 0.9)])
assert result.score == 20
assert result.score == 9
def test_breakdown_names_what_each_kind_contributed():
@ -584,12 +601,12 @@ def test_breakdown_names_what_each_kind_contributed():
)
points = {item["key"]: item["points"] for item in result.breakdown["components"]}
assert points == {"fingerprint": 80, "face": 35}
assert points == {"knowledge": 80, "face": FACE_PERSON_IDENTIFIABLE_POINTS}
assert sum(points.values()) == result.breakdown["total"]
def test_breakdown_shows_what_a_pool_ceiling_swallowed():
# 운영자가 보는 증거는 4행인데 점수는 60이다. 왜 125가 아닌지 말해주지 않으면
# 운영자가 보는 증거는 4행인데 점수는 40이다. 왜 110이 아닌지 말해주지 않으면
# 숫자를 믿을 수 없다.
result = RiskScorer().score(
[
@ -600,10 +617,10 @@ def test_breakdown_shows_what_a_pool_ceiling_swallowed():
]
)
pool = next(item for item in result.breakdown["components"] if item["key"].startswith("web_presence:"))
assert pool["points"] == 60
assert pool["raw"] == 125
assert pool["cap"] == 60
pool = next(item for item in result.breakdown["components"] if item["key"] == "web_presence")
assert pool["points"] == WEB_PRESENCE_MAX_POINTS
assert pool["raw"] == 40 + 30 + 30 + 10
assert pool["cap"] == WEB_PRESENCE_MAX_POINTS
def test_breakdown_reports_the_hundred_point_ceiling():
@ -616,7 +633,7 @@ def test_breakdown_reports_the_hundred_point_ceiling():
)
assert result.score == 100
assert result.breakdown["total"] == 115
assert result.breakdown["total"] == 80 + FACE_PERSON_IDENTIFIABLE_POINTS
assert result.breakdown["clamped"] is True
@ -635,7 +652,140 @@ def test_breakdown_total_always_matches_the_score_before_clamping():
[_published_on("a.test"), _label("강아지", 0.9)],
[_vision("Full match", "full", 0.9), _custom_search_page("CSE")],
[Evidence(EvidenceSource.NAVER_SEARCH, "승격", 0.8, {"promoted": True})],
[_face(FACE_PERSON_BACKGROUND_POINTS), _failure()],
[_search_result_copy(1.0), _face(FACE_PERSON_IDENTIFIABLE_POINTS), _label("강아지", 0.9)],
):
result = RiskScorer().score(evidence)
assert result.breakdown["final"] == result.score
assert sum(item["points"] for item in result.breakdown["components"]) == result.breakdown["total"]
# --- v5: 축 예산 총합 ----------------------------------------------------------
def _face(points: int) -> Evidence:
tier = "identifiable" if points == FACE_PERSON_IDENTIFIABLE_POINTS else "background"
return Evidence(EvidenceSource.FACE_PERSON, f"{tier} person", 0.9, {"tier": tier})
def _failure() -> Evidence:
return Evidence(EvidenceSource.FAILURE, "External API failed: timeout", 1.0, {"provider": "naver"})
def _search_result_copy(similarity: float, url: str = "https://blog.example.test/post/1.jpg") -> Evidence:
# What store_search_candidates writes when a search result's image matches
# the submission: the result url plus the search hit it came from.
return Evidence(
EvidenceSource.FINGERPRINT,
f"Search result image similarity {similarity:.2f}",
similarity,
{"similarity": similarity, "url": url, "result_url": url, "source_evidence_ids": ["ev-1"], "provider": "naver"},
)
def _promoted(query: str, confidence: float = 1.0) -> Evidence:
return Evidence(EvidenceSource.NAVER_SEARCH, f"promoted {query}", confidence, {"promoted": True, "query": query})
def test_a_search_result_copy_is_web_presence_not_a_registered_match():
# 2026-09-22 실측: 실이미지 20장 전부 100점. 검색 결과 사본 유사도가 행마다 +80
# 이었고 한 장에 평균 2.7행이 붙었다. 사본은 "웹에 있다"의 가장 강한 뷰지
# 운영자가 등록한 권리 대상이 아니다 - 웹 존재 풀에 들어가고 단독으로는 medium.
one = RiskScorer().score([_search_result_copy(1.0)])
three = RiskScorer().score([_search_result_copy(1.0, f"https://x.test/{i}.jpg") for i in range(3)])
assert one.score == WEB_PRESENCE_MAX_POINTS
assert one.band == "medium"
assert [item["key"] for item in one.breakdown["components"]] == ["web_presence"]
assert three.score == one.score
assert next(item for item in three.breakdown["components"] if item["key"] == "web_presence")["raw"] == 120
def test_a_search_result_copy_and_a_vision_full_match_are_one_fact():
result = RiskScorer().score([_search_result_copy(1.0), _vision("Full match", "full", 0.9)])
assert result.score == WEB_PRESENCE_MAX_POINTS
pool = next(item for item in result.breakdown["components"] if item["key"] == "web_presence")
assert pool["raw"] == 80
def test_a_search_result_copy_ranks_by_how_close_the_copy_is():
exact = RiskScorer().score([_search_result_copy(1.0)]).score
near = RiskScorer().score([_search_result_copy(0.93)]).score
loose = RiskScorer().score([_search_result_copy(0.85)]).score
assert exact > near > loose > 0
def test_a_search_result_copy_on_a_trusted_host_is_excluded_like_any_other_row():
result = RiskScorer(frozenset({"posar.info"})).score([_search_result_copy(1.0, "https://cdn.posar.info/a.jpg")])
assert result.score == 0
assert result.breakdown["excluded"] == [{"label": "신뢰 출처 posar.info", "rows": 1}]
def test_a_row_with_only_a_similarity_is_read_as_a_registered_match():
# Older callers (and the diagram gate) write knowledge matches without the
# knowledge_entry_id; they must not be demoted into the web pool.
result = RiskScorer().score([_fingerprint(0.95)])
assert result.score == 80
assert [item["key"] for item in result.breakdown["components"]] == ["knowledge"]
def test_every_axis_but_a_registered_match_sums_below_the_ceiling():
# 예산의 존재 이유: 웹에 있는 이미지들 사이에 순서가 남아야 한다. 등록 일치가
# 없는 한 모든 축이 최대여도 100 에 닿지 않는다.
result = RiskScorer().score(
[
_search_result_copy(1.0),
_vision("Full match", "full", 0.9),
_custom_search_page("CSE"),
_face(FACE_PERSON_IDENTIFIABLE_POINTS),
_promoted("q1"),
_promoted("q2"),
_label("A", 1.0),
_label("B", 1.0),
_label("C", 1.0),
]
)
assert result.score == 95
assert result.band == "high"
assert result.breakdown["clamped"] is False
def test_web_signals_of_every_kind_cannot_reach_high_without_a_person_or_a_registered_match():
result = RiskScorer().score(
[_search_result_copy(1.0), _vision("Full match", "full", 0.9), _promoted("q1"), _label("A", 1.0)]
)
assert result.score == 65
assert result.band == "medium"
def test_promoted_hits_from_distinct_queries_pool_under_one_ceiling():
result = RiskScorer().score([_promoted("q1"), _promoted("q2"), _promoted("q3")])
assert result.score == NAVER_MAX_POINTS
assert len(result.reasons) == 3
def test_a_provider_failure_is_a_review_floor_not_a_bonus():
# "확인 못 했다"는 사람이 봐야 하므로 medium 까지 올린다. 그러나 이미 그 위에
# 있는 케이스에 더하면 네이버 장애 하루가 웹에 있는 모든 이미지를 high 로
# 밀어 올린다 - v4 까지 실제로 그랬다 (+30/행, 무상한).
alone = RiskScorer().score([_failure()])
twice = RiskScorer().score([_failure(), Evidence(EvidenceSource.ENRICHMENT_FAILURE, "Enrichment failed", 1.0, {})])
with_background_person = RiskScorer().score([_face(FACE_PERSON_BACKGROUND_POINTS), _failure()])
with_copy = RiskScorer().score([_search_result_copy(1.0), _failure()])
assert alone.score == FAILURE_FLOOR_POINTS
assert alone.band == "medium"
assert twice.score == FAILURE_FLOOR_POINTS
assert with_background_person.score == FAILURE_FLOOR_POINTS
floor = next(item for item in with_background_person.breakdown["components"] if item["key"] == "failure")
assert floor["points"] == FAILURE_FLOOR_POINTS - FACE_PERSON_BACKGROUND_POINTS
assert with_copy.score == WEB_PRESENCE_MAX_POINTS
assert "External API failed: timeout" in with_copy.reasons
assert all(item["key"] != "failure" for item in with_copy.breakdown["components"])

View file

@ -13,7 +13,7 @@ from rights_filter.analysis.face_person_detection import (
face_person_reason,
)
from rights_filter.analysis.preprocessing import ImagePayload, build_face_crop_derivatives
from rights_filter.analysis.risk_scoring import SCORER_VERSION
from rights_filter.analysis.risk_scoring import FACE_PERSON_IDENTIFIABLE_POINTS, SCORER_VERSION
from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.server.image_store import LocalSubmissionImageStore
from rights_filter.server import sqlite_store as sqlite_store_module
@ -1162,7 +1162,7 @@ def test_sqlite_store_rescore_ignores_non_contributing_queued_rerun_marker(tmp_p
store._rescore_submission("SUB-DB1")
review = store.review("SUB-DB1")
assert review["riskScore"] == 35
assert review["riskScore"] == FACE_PERSON_IDENTIFIABLE_POINTS
assert review["riskBand"] == "medium"
assert IDENTIFIABLE_FACE_REASON in review["reasons"]
assert "재분석 요청이 접수됨" not in review["reasons"]
@ -8118,7 +8118,7 @@ def test_sqlite_store_migrates_google_best_guess_labels_to_non_scoring_hints(tmp
weak_label = next(item for item in review["evidence"] if item["source"] == "google")
assert review["riskScore"] == 35
assert review["riskScore"] == FACE_PERSON_IDENTIFIABLE_POINTS
assert review["riskBand"] == "medium"
assert review["reasons"] == [IDENTIFIABLE_FACE_REASON]
assert weak_label["title"] == "Google weak label gentleman"
@ -8162,7 +8162,7 @@ def test_sqlite_store_rescores_existing_saved_google_best_guess_labels(tmp_path:
review = reopened.review("SUB-DB1")
weak_label = next(item for item in review["evidence"] if item["id"] == "ev-old-google-label")
assert review["riskScore"] == 35
assert review["riskScore"] == FACE_PERSON_IDENTIFIABLE_POINTS
assert review["riskBand"] == "medium"
assert IDENTIFIABLE_FACE_REASON in review["reasons"]
assert "Best guess label gentleman" not in review["reasons"]

View file

@ -8,7 +8,7 @@ from pathlib import Path
import pytest
from rights_filter.analysis.fingerprints import FingerprintService
from rights_filter.analysis.risk_scoring import RiskScorer
from rights_filter.analysis.risk_scoring import FACE_PERSON_IDENTIFIABLE_POINTS, RiskScorer
from rights_filter.analysis.search_result_promoter import SearchResultPromoter
from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.integrations.external_policy import ExternalApiPolicy
@ -221,7 +221,7 @@ def test_face_person_evidence_counts_once_however_many_rows_exist():
for _ in range(3)
]
assert RiskScorer().score(rows).score == 35
assert RiskScorer().score(rows).score == FACE_PERSON_IDENTIFIABLE_POINTS
def test_degenerate_perceptual_hashes_do_not_produce_a_near_match():