diff --git a/docs/INDEX.md b/docs/INDEX.md index f34c805..576cdff 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -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** diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 6641082..b5fc5a2 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -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 폐기 | diff --git a/docs/diagrams/evidence-dataflow.dataflow.json b/docs/diagrams/evidence-dataflow.dataflow.json index 382145a..2b84021 100644 --- a/docs/diagrams/evidence-dataflow.dataflow.json +++ b/docs/diagrams/evidence-dataflow.dataflow.json @@ -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점이다 — 행 단위 제외라 스크래퍼 게시는 그대로 센다" ] diff --git a/docs/diagrams/evidence-dataflow.html b/docs/diagrams/evidence-dataflow.html index d428d3a..18a3c25 100644 Binary files a/docs/diagrams/evidence-dataflow.html and b/docs/diagrams/evidence-dataflow.html differ diff --git a/docs/diagrams/manifest.json b/docs/diagrams/manifest.json index d66e7c9..334aaa3 100644 --- a/docs/diagrams/manifest.json +++ b/docs/diagrams/manifest.json @@ -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 } ] } diff --git a/docs/diagrams/operator-review-workflow.html b/docs/diagrams/operator-review-workflow.html index ee3ce2e..d962854 100644 Binary files a/docs/diagrams/operator-review-workflow.html and b/docs/diagrams/operator-review-workflow.html differ diff --git a/docs/diagrams/operator-review-workflow.workflow.json b/docs/diagrams/operator-review-workflow.workflow.json index 9310813..dbcf231 100644 --- a/docs/diagrams/operator-review-workflow.workflow.json +++ b/docs/diagrams/operator-review-workflow.workflow.json @@ -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": [ diff --git a/docs/diagrams/submission-lifecycle.html b/docs/diagrams/submission-lifecycle.html index eb1532a..6981357 100644 Binary files a/docs/diagrams/submission-lifecycle.html and b/docs/diagrams/submission-lifecycle.html differ diff --git a/docs/diagrams/system-architecture.architecture.json b/docs/diagrams/system-architecture.architecture.json index 8b228cc..0b0dbb6 100644 --- a/docs/diagrams/system-architecture.architecture.json +++ b/docs/diagrams/system-architecture.architecture.json @@ -140,7 +140,7 @@ "type": "backend", "label": "분석 · 채점", "sublabel": "지문 · 얼굴 · 출처", - "tag": "RiskScorer v4", + "tag": "RiskScorer v5", "pos": [ 678, 60 diff --git a/docs/diagrams/system-architecture.html b/docs/diagrams/system-architecture.html index 3b2b049..5136e52 100644 Binary files a/docs/diagrams/system-architecture.html and b/docs/diagrams/system-architecture.html differ diff --git a/docs/operations/calibration-corpus.md b/docs/operations/calibration-corpus.md index 9810f24..bb9e767 100644 --- a/docs/operations/calibration-corpus.md +++ b/docs/operations/calibration-corpus.md @@ -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` 상단 예산표다. diff --git a/src/rights_filter/analysis/risk_scoring.py b/src/rights_filter/analysis/risk_scoring.py index 6e1db94..274a3f9 100644 --- a/src/rights_filter/analysis/risk_scoring.py +++ b/src/rights_filter/analysis/risk_scoring.py @@ -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 diff --git a/tests/rights_filter/analysis/test_evidence_enrichment.py b/tests/rights_filter/analysis/test_evidence_enrichment.py index 5c0cb48..23e8565 100644 --- a/tests/rights_filter/analysis/test_evidence_enrichment.py +++ b/tests/rights_filter/analysis/test_evidence_enrichment.py @@ -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(): diff --git a/tests/rights_filter/analysis/test_risk_scoring.py b/tests/rights_filter/analysis/test_risk_scoring.py index 9450acc..d6baa41 100644 --- a/tests/rights_filter/analysis/test_risk_scoring.py +++ b/tests/rights_filter/analysis/test_risk_scoring.py @@ -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"]) diff --git a/tests/rights_filter/server/test_sqlite_store.py b/tests/rights_filter/server/test_sqlite_store.py index ad20dc0..845b8d0 100644 --- a/tests/rights_filter/server/test_sqlite_store.py +++ b/tests/rights_filter/server/test_sqlite_store.py @@ -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"] diff --git a/tests/rights_filter/test_2026_07_24_review_fixes.py b/tests/rights_filter/test_2026_07_24_review_fixes.py index 172c1e7..914337a 100644 --- a/tests/rights_filter/test_2026_07_24_review_fixes.py +++ b/tests/rights_filter/test_2026_07_24_review_fixes.py @@ -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():