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 # 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 · **실이미지 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** - 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)** — 이름과 실제가 다름(알려진 부채) | | `fingerprints.py` | 이미지 지문. `_perceptual_hash`는 **DCT pHash가 아니라 8x8 평균해시(aHash)** — 이름과 실제가 다름(알려진 부채) |
| `face_person_detection.py` | YuNet(ONNX) 얼굴 검출 + 인물 등급(`identifiable`/`background`). 가중치는 `models/`(gitignore) | | `face_person_detection.py` | YuNet(ONNX) 얼굴 검출 + 인물 등급(`identifiable`/`background`). 가중치는 `models/`(gitignore) |
| `internal_analyzer.py` | 내부 분석 오케스트레이션 | | `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` | 증거 정규화·보강 | | `evidence_normalizer.py`, `evidence_enrichment.py` | 증거 정규화·보강 |
| `search_query_generation.py`, `search_result_promoter.py` | 자동 검색 쿼리 생성, 검색 결과의 증거 승격 | | `search_query_generation.py`, `search_result_promoter.py` | 자동 검색 쿼리 생성, 검색 결과의 증거 승격 |
| `llm_assistance.py`, `llm_prompts.py` | LLM을 **요약기가 아닌 증거 판독기**로 사용. 입력 화이트리스트 9필드 + 상위 12건 상한, `format=json` 구조화 인용, 라벨 집합 포함 검사로 미인용 claim 폐기 | | `llm_assistance.py`, `llm_prompts.py` | LLM을 **요약기가 아닌 증거 판독기**로 사용. 입력 화이트리스트 9필드 + 상위 12건 상한, `format=json` 구조화 인용, 라벨 집합 포함 검사로 미인용 claim 폐기 |

View file

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

Binary file not shown.

View file

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

Binary file not shown.

View file

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

Binary file not shown.

View file

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

Binary file not shown.

View file

@ -103,32 +103,56 @@ python scripts/sweep_thresholds.py data/corpus.sqlite3
읽는 법: `over` = high 로 분류했는데 승인한 건수(과탐, 많으면 high 임계가 낮다). 읽는 법: `over` = high 로 분류했는데 승인한 건수(과탐, 많으면 high 임계가 낮다).
`under` = low 로 분류했는데 반려한 건수(미탐, 0 이 아니면 medium 임계가 높다). `under` = low 로 분류했는데 반려한 건수(미탐, 0 이 아니면 medium 임계가 높다).
## 참고 — 2026-09-22 시점의 구조적 거동 (판정 없이 측정한 것) ## 참고 — 구조적 거동 실측 (판정 없이 측정한 것)
`scripts/score_distribution.py` 로 432조합을 전수 계산한 결과(스코어러 v4): `scripts/score_distribution.py` 432조합 전수 + `data/corpus.sqlite3` 실이미지 20장.
스코어러를 바꿀 때마다 이 두 표를 다시 채운다.
| | 신뢰 출처 미등록 | 신뢰 출처 등록 |
|---|---|---|
| high | 92% | 63% |
| medium | 6% | 29% |
| low | 0% | 7% |
| 100점 포화 | 80% | 38% |
| 웹 신호만으로 high | 13/24 | 0/24 |
**이 격자는 신호 축을 균등 샘플링하므로 실제 큐 분포가 아니다.** 92% 가 "실제 큐의 **이 격자는 신호 축을 균등 샘플링하므로 실제 큐 분포가 아니다.** 92% 가 "실제 큐의
92% 가 high"라는 뜻이 아니며, 임계가 맞는지도 말해주지 않는다. 구조적 거동의 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 가 되지 않는다. 두 번째 **종류**의 신호가 필요하다"였는데, "웹 존재만으로는 high 가 되지 않는다. 두 번째 **종류**의 신호가 필요하다"였는데,
presence 풀(최대 60)과 entity 풀(최대 20)의 합이 80 이라 `full+partial` + 엔티티 1개 presence 풀(최대 60)과 entity 풀(최대 20)의 합이 80 이라 `full+partial` + 엔티티 1개
만으로 high 가 된다. 두 풀이 각각 임계 아래로 캡됐지만 합은 캡되지 않는다. 만으로 high 가 됐다. 두 풀이 각각 임계 아래로 캡됐지만 합은 캡되지 않았다.
2. **v3 의 제공자별 풀링이 그 경향을 키웠다.** 독립 제공자가 하나 더 있으면 웹 전용 2. **v3 의 제공자별 풀링이 그 경향을 키웠다.** 독립 제공자가 하나 더 있으면 웹 전용
조합의 high 가 4/12 → 9/12 로, 전체 high 비율이 88% → 97% 로 올라간다. 교차 확증이 조합의 high 가 4/12 → 9/12 로 올라갔다. 교차 확증이 high 에 도달해야 한다는 v3 의
high 에 도달해야 한다는 v3 의 의도대로 동작한 결과지만, 총량 통제가 없다. 의도대로 동작한 결과지만, 총량 통제가 없었다.
3. **실이미지가 보인 세 번째 원인.** 검색결과 사본 유사도가 행마다 +80, 무상한이었다
(코퍼스 평균 216점/장). 캡 없는 두 축만 캡하는 시뮬레이션(80/50, 60/40)으로도 20/20 이
그대로 100 — 축 하나가 아니라 예산 **합**(245 vs 100)의 문제였다.
대응 후보(미적용, 결정 필요): 웹 유래 점수 총합에 상한을 두어 제공자 수와 무관하게 v5 는 축 예산을 총합 기준으로 재배분했다(등록 일치 80 · 웹 존재 40 **한 풀** · 인물 30/10 ·
웹 신호만으로는 high 에 닿지 않게 한다. 적용하면 `SCORER_VERSION` 을 올리고 이 표를 검색 승격 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.analysis.face_person_detection import TIER_BACKGROUND
from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult 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 FACE_PERSON_BACKGROUND_POINTS = 10
# Ceiling on what UNCATEGORIZED Google Vision web entities can contribute in KNOWLEDGE_MATCH_MAX_POINTS = 80
# total. Vision returns up to maxResults descriptive labels for every image KNOWLEDGE_MATCH_STRONG_POINTS = 80 # 유사도 0.9 이상
# ("Dog", "Pet", "Grass"), so a bare label describes what the picture shows, not KNOWLEDGE_MATCH_WEAK_POINTS = 30
# 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
# Ceiling on the combined weight of web-presence match types. One Vision WEB_PRESENCE_MAX_POINTS = 40
# response reports a single underlying fact — "this image is on the web" — WEB_COPY_EXACT_POINTS = 40 # 검색결과 사본 유사도 0.99 이상 · Vision full
# through four correlated views (full copy / partial copy / hosting page / WEB_COPY_NEAR_POINTS = 30 # 유사도 0.9~0.99 · Vision partial/page · CSE page · 레거시 url 행
# lookalike), and a widely published image trips all four at once. Summed, that WEB_COPY_WEAK_POINTS = 10 # 유사도 0.9 미만 · Vision visual
# 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. NAVER_PROMOTED_POINTS = 15 # 쿼리 버킷당 round(15 * confidence)
# Pooled and capped below the high threshold (70), presence still ranks by how NAVER_MAX_POINTS = 15
# 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. WEB_ENTITY_LABEL_POINTS = 10 # 라벨당 round(10 * confidence)
# Existence on the web is not itself infringement; that is the operator's call. WEB_ENTITY_MAX_POINTS = 10
#
# 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 # 사람이 봐야 하므로 medium 하한까지 끌어올리되, 이미 그 위에 있는 케이스에는 아무것도
# two ways; Google custom search independently returning a published page is a # 더하지 않는다 - 더하면 네이버 장애 하루가 웹에 있는 모든 이미지를 high 로 밀어 올린다.
# different finding, and pooling the two together capped genuine cross-provider FAILURE_FLOOR_POINTS = 30
# corroboration - the very second signal the paragraph above says should reach
# high - at 60/medium.
WEB_PRESENCE_MAX_POINTS = 60
# Stamped onto every submission at creation so a later weight change is # Stamped onto every submission at creation so a later weight change is
# distinguishable from a scoring bug. Bump this whenever the weights, the bucket # distinguishable from a scoring bug. Bump this whenever the weights, the bucket
# rules, or the band thresholds below change — the stamp is what lets # rules, or the band thresholds below change - the stamp is what lets
# initialRiskScore be compared against a rescore and mean something. # initialRiskScore be compared against a rescore and mean something.
# v2: uncategorized web entities capped in aggregate (WEB_ENTITY_MAX_POINTS) # v2: uncategorized web entities capped in aggregate (WEB_ENTITY_MAX_POINTS)
# and web-presence match types pooled under WEB_PRESENCE_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 # 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 # 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. # 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 = { _COMPONENT_LABELS = {
"fingerprint": "지식DB 지문 매칭", "knowledge": "등록 권리 대상 일치",
"web_presence": "웹 존재 (사본 · 매치)",
"face": "인물", "face": "인물",
"naver": "네이버 승격 히트", "naver": "이름 있는 검색 승격",
"failure": "외부 검색 실패", "web_entity": "웹 엔티티 라벨",
"web_other": "웹 탐지(기타)", "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 "" return ""
def score(self, evidence: list[Evidence]) -> ScoreResult: def score(self, evidence: list[Evidence]) -> ScoreResult:
score = 0
reasons: list[str] = [] reasons: list[str] = []
counted_web_buckets: set[str] = set() counted_web_buckets: set[str] = set()
counted_naver_buckets: set[str] = set() counted_naver_buckets: set[str] = set()
counted_face_person = False counted_face_person = False
web_entity_points = 0 face_points = 0
web_presence_points: dict[str, int] = {} failure_seen = False
# 점수 분해용 누적. 점수 계산에는 참여하지 않고 같은 흐름을 기록만 한다. # 축별 원점수. 상한은 루프가 끝난 뒤 한 번에 건다 - 같은 사실을 몇 행이
components: dict[str, int] = {} # 말하든 축의 예산을 넘지 못한다.
pools: dict[str, int] = {}
excluded: 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: for item in evidence:
if item.data.get("operator_status") in {"irrelevant", "false_positive"}: if item.data.get("operator_status") in {"irrelevant", "false_positive"}:
continue continue
@ -128,33 +156,45 @@ class RiskScorer:
continue continue
# A match against a CLEARED reference is a positive signal: the # A match against a CLEARED reference is a positive signal: the
# team already confirmed this image is safe to use. It must not # 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 # add risk. Surface it as a reason so the operator sees the prior
# band). Surface it as a reason so the operator sees the prior
# clearance, but contribute no points. # clearance, but contribute no points.
if item.data.get("knowledge_entry_type") == "cleared_reference": if item.data.get("knowledge_entry_type") == "cleared_reference":
if item.reason: if item.reason:
reasons.append(item.reason) reasons.append(item.reason)
continue continue
if item.reason == "Image fingerprints generated":
continue
similarity = _safe_float(item.data.get("similarity", 0)) similarity = _safe_float(item.data.get("similarity", 0))
if similarity >= 0.9: if _is_search_result_copy(item):
score += 80 # A copy found among search results is the strongest view of
components["fingerprint"] = components.get("fingerprint", 0) + 80 # "this image is on the web" - the same fact Vision's match
reasons.append(item.reason) # rows report, so it shares their pool. Trusted hosts apply
elif item.reason != "Image fingerprints generated": # here as they do to every other published-on-the-web row.
score += 30 trusted = self._trusted_host_of(item)
components["fingerprint"] = components.get("fingerprint", 0) + 30 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) reasons.append(item.reason)
elif item.source == EvidenceSource.FACE_PERSON: elif item.source == EvidenceSource.FACE_PERSON:
# "A person is present" is one signal no matter how many rows # "A person is present" is one signal no matter how many rows
# assert it. Re-analysis appends a fresh FACE_PERSON row while the # assert it. Re-analysis appends a fresh FACE_PERSON row while the
# previous one remains, so without this bucket the same image # 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: if counted_face_person:
continue continue
counted_face_person = True counted_face_person = True
face_points = _face_person_points(item) face_points = _face_person_points(item)
score += face_points
components["face"] = components.get("face", 0) + face_points
reasons.append(item.reason) reasons.append(item.reason)
elif item.source == EvidenceSource.WEB_DETECTION: elif item.source == EvidenceSource.WEB_DETECTION:
# Found on a host the team controls or licenses. "It is on the # 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: if bucket and bucket in counted_web_buckets:
continue continue
points, kind = _web_detection_points(item) points, kind = _web_detection_points(item)
# Pooled kinds are capped after the loop, not added here.
if kind == _WEB_LABEL: if kind == _WEB_LABEL:
web_entity_points += points pool("web_entity", points)
elif kind == _WEB_PRESENCE: elif kind == _WEB_PRESENCE:
pool = _web_presence_pool(item) pool("web_presence", points)
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
if points: if points:
reasons.append(item.reason) reasons.append(item.reason)
if bucket: if bucket:
@ -190,13 +224,11 @@ class RiskScorer:
if not points: if not points:
continue continue
# Count one promoted hit per executed query. Multiple promoted # Count one promoted hit per executed query. Multiple promoted
# results from the same search are one signal, not N stacked +40s # results from the same search are one signal, not N.
# (which would push a single query straight to the high band).
bucket = _naver_search_bucket(item) bucket = _naver_search_bucket(item)
if bucket and bucket in counted_naver_buckets: if bucket and bucket in counted_naver_buckets:
continue continue
score += points pool("naver", points)
components["naver"] = components.get("naver", 0) + points
reasons.append(item.reason) reasons.append(item.reason)
if bucket: if bucket:
counted_naver_buckets.add(bucket) counted_naver_buckets.add(bucket)
@ -220,43 +252,42 @@ class RiskScorer:
# An LLM-summary failure only means narration didn't run; it is # 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 # not a search/coverage gap, so it must not add risk or surface
# as a reason. Genuine provider failures (search / web-detection, # 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"): if item.reason.startswith("LLM assistance failed"):
continue continue
if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}: if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}:
score += 30 failure_seen = True
components["failure"] = components.get("failure", 0) + 30
reasons.append(item.reason) reasons.append(item.reason)
# SEARCH_SKIPPED / EXTERNAL_SKIPPED contribute zero points and are # SEARCH_SKIPPED / EXTERNAL_SKIPPED contribute zero points and are
# operational status ("quota reached", "provider disabled"), not # operational status ("quota reached", "provider disabled"), not
# risk signals — keep them out of the operator-facing reasons list. # risk signals - keep them out of the operator-facing reasons list.
# Every distinct row still surfaces as a reason; only the combined weight # Every distinct row still surfaces as a reason; only the combined weight
# of each pool is bounded. # of each axis is bounded. Components follow the budget table above so
pools: list[dict[str, Any]] = [] # the operator reads the panel in the same order the comment explains.
for pool, pooled_presence in sorted(web_presence_points.items()): components: list[dict[str, Any]] = []
capped = min(pooled_presence, WEB_PRESENCE_MAX_POINTS) 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 score += capped
pools.append( components.append(
{ {"key": key, "label": _COMPONENT_LABELS[key], "points": capped, "raw": raw, "cap": _POOL_CAPS[key]}
"key": f"web_presence:{pool}",
"label": f"웹 게시 ({pool})",
"points": capped,
"raw": pooled_presence,
"cap": WEB_PRESENCE_MAX_POINTS,
}
) )
capped_entities = min(web_entity_points, WEB_ENTITY_MAX_POINTS) if failure_seen and score < FAILURE_FLOOR_POINTS:
score += capped_entities # 바닥이지 가산이 아니다: 하한까지 모자란 만큼만 채운다. 이미 그 위에
if web_entity_points: # 있는 케이스는 실패 사유만 남고 점수는 움직이지 않는다.
pools.append( fill = FAILURE_FLOOR_POINTS - score
{ score = FAILURE_FLOOR_POINTS
"key": "web_entity", components.append(
"label": "웹 엔티티 라벨", {"key": "failure", "label": _COMPONENT_LABELS["failure"], "points": fill, "floor": FAILURE_FLOOR_POINTS}
"points": capped_entities,
"raw": web_entity_points,
"cap": WEB_ENTITY_MAX_POINTS,
}
) )
raw_total = score raw_total = score
@ -271,17 +302,10 @@ class RiskScorer:
breakdown = { breakdown = {
"total": raw_total, "total": raw_total,
"final": score, "final": score,
# 100 상한에 걸렸다는 사실 자체가 정보다 — 걸린 케이스들은 서로 # 100 상한에 걸렸다는 사실 자체가 정보다 - 걸린 케이스들은 서로
# 구별되지 않으므로 우선순위 판단에 쓸 수 없다. # 구별되지 않으므로 우선순위 판단에 쓸 수 없다.
"clamped": raw_total > score, "clamped": raw_total > score,
"components": [ "components": components,
*[
{"key": key, "label": _COMPONENT_LABELS.get(key, key), "points": points}
for key, points in sorted(components.items())
if points
],
*pools,
],
"excluded": [ "excluded": [
{"label": f"신뢰 출처 {host}", "rows": rows} {"label": f"신뢰 출처 {host}", "rows": rows}
for host, rows in sorted(excluded.items()) for host, rows in sorted(excluded.items())
@ -291,6 +315,29 @@ class RiskScorer:
return ScoreResult(score=score, band=band, reasons=_unique(reasons), breakdown=breakdown) 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: def _face_person_points(item: Evidence) -> int:
# A recognizable subject and a bystander in a crowd are not the same portrait # 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 # 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]: def _web_detection_points(item: Evidence) -> tuple[int, str]:
"""Points for one web-detection row, and which pool they belong to. """Points for one web-detection row, and which pool they belong to.
Three kinds are scored differently because they carry different amounts of Every presence view lands in the one web-presence pool; only the strength
information: the match types are correlated views of one fact (the image is of the view differs. Bare Vision entities describe the subject rather than
on the web), bare Vision entities describe the subject rather than its its rights and pool separately.
rights, and a classified entity is an actual rights judgement.
""" """
if item.data.get("weak_hint"): if item.data.get("weak_hint"):
return 0, _WEB_STANDALONE 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 # matching pHash FINGERPRINT evidence carries the score). Fresh evidence has
# no bare "url" key so it scores 0, but the persistence round-trip aliases # 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 # 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 # score-preservation invariant and inflating bands on any query that returns
# anything. # anything.
if item.data.get("match") == "search_image": match = item.data.get("match")
if match == "search_image":
return 0, _WEB_STANDALONE return 0, _WEB_STANDALONE
if item.data.get("match") == "full": if match == "full":
return 45, _WEB_PRESENCE return WEB_COPY_EXACT_POINTS, _WEB_PRESENCE
if item.data.get("match") in {"partial", "page"}: if match in {"partial", "page"}:
return 35, _WEB_PRESENCE return WEB_COPY_NEAR_POINTS, _WEB_PRESENCE
if item.data.get("match") == "visual": if match == "visual":
return 10, _WEB_PRESENCE return WEB_COPY_WEAK_POINTS, _WEB_PRESENCE
# Legacy rows only: every current producer sets `match`, so a web-detection # 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 # 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 # keeps a presence weight rather than being dropped - and it must stay below
# predates - and it must stay below the `match` checks above, because the # the `match` checks above, because the persistence round-trip aliases
# persistence round-trip aliases result_url -> url on every row. # result_url -> url on every row.
if not item.data.get("match") and item.data.get("url"): if not match and item.data.get("url"):
return 45, _WEB_PRESENCE return WEB_COPY_NEAR_POINTS, _WEB_PRESENCE
if item.data.get("entity"): 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 return 0, _WEB_STANDALONE
@ -419,7 +466,7 @@ def _web_detection_bucket(item: Evidence) -> str:
def _naver_search_points(item: Evidence) -> int: def _naver_search_points(item: Evidence) -> int:
if item.data.get("promoted"): if item.data.get("promoted"):
return round(50 * item.confidence) return round(NAVER_PROMOTED_POINTS * item.confidence)
return 0 return 0

View file

@ -56,7 +56,9 @@ def test_enrichment_adds_naver_evidence_llm_summary_and_score():
assert summary.executed_searches == 3 assert summary.executed_searches == 3
assert any(item.source == EvidenceSource.NAVER_SEARCH for item in latest.evidence) 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 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(): def test_enrichment_is_idempotent_for_same_query_signature():

View file

@ -1,5 +1,11 @@
from rights_filter.analysis.risk_scoring import ( from rights_filter.analysis.risk_scoring import (
FACE_PERSON_BACKGROUND_POINTS,
FACE_PERSON_IDENTIFIABLE_POINTS, FACE_PERSON_IDENTIFIABLE_POINTS,
FAILURE_FLOOR_POINTS,
NAVER_MAX_POINTS,
WEB_COPY_NEAR_POINTS,
WEB_ENTITY_MAX_POINTS,
WEB_PRESENCE_MAX_POINTS,
RiskScorer, RiskScorer,
) )
from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult
@ -120,10 +126,10 @@ def test_failure_reason_does_not_lower_existing_high_risk_score():
assert isinstance(with_failure, ScoreResult) assert isinstance(with_failure, ScoreResult)
def test_promoted_naver_search_evidence_can_raise_review_risk(): def test_promoted_naver_search_evidence_raises_risk_but_cannot_band_a_case_alone():
result = RiskScorer().score( # 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.
Evidence( promoted = Evidence(
source=EvidenceSource.NAVER_SEARCH, source=EvidenceSource.NAVER_SEARCH,
reason="Naver result linked named work", reason="Naver result linked named work",
confidence=0.8, confidence=0.8,
@ -132,12 +138,16 @@ def test_promoted_naver_search_evidence_can_raise_review_risk():
"promotion_reason": "named person/work search evidence", "promotion_reason": "named person/work search evidence",
}, },
) )
]
alone = RiskScorer().score([promoted])
with_face = RiskScorer().score(
[promoted, Evidence(EvidenceSource.FACE_PERSON, "Identifiable person", 0.9, {"tier": "identifiable"})]
) )
assert result.band == "medium" assert 0 < alone.score <= NAVER_MAX_POINTS
assert result.score >= 35 assert alone.band == "low"
assert "Naver result linked named work" in result.reasons 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(): 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(): 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( result = RiskScorer().score(
[ [
Evidence(source=EvidenceSource.FACE_PERSON, reason="Face/person detected", confidence=0.8, data={"face_count": 1}), Evidence(source=EvidenceSource.FACE_PERSON, reason="Face/person detected", confidence=0.8, data={"face_count": 1}),
_fingerprint(0.5), _fingerprint(0.5),
_label("Dog", 0.5),
] ]
) )
assert result.score == 65 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(): 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( result = RiskScorer().score(
[ [
Evidence(source=EvidenceSource.FACE_PERSON, reason="Face/person detected", confidence=0.8, data={"face_count": 1}), 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 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(): 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( result = RiskScorer().score(
[ [
_fingerprint(0.95), _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. # that is the only place the operator sees there were two.
result = RiskScorer().score([_label("캐릭터 A", 0.94), _label("유명인 B", 0.94)]) 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 캐릭터 A" in result.reasons
assert "Web entity matched 유명인 B" 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"] 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" assert result.band == "low"
# Capping the weight must not hide the labels from the operator. # Capping the weight must not hide the labels from the operator.
assert len(result.reasons) == 5 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)]) 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]: def _presence_set() -> list[Evidence]:
@ -368,12 +380,12 @@ def _presence_set() -> list[Evidence]:
def test_web_presence_alone_cannot_reach_the_high_band(): 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 # Summed, the four match types once reached 125 -> clamped to 100, so any
# any image with a web presence was indistinguishable from a clear # image with a web presence was indistinguishable from a clear infringement.
# infringement. Existence on the web is not itself infringement. # Existence on the web is not itself infringement.
result = RiskScorer().score(_presence_set()) result = RiskScorer().score(_presence_set())
assert result.score == 60 assert result.score == WEB_PRESENCE_MAX_POINTS
assert result.band == "medium" assert result.band == "medium"
assert len(result.reasons) == 4 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" 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" assert result.band == "medium"
def test_two_providers_each_finding_a_published_copy_reaches_high(): def test_two_providers_corroborating_a_published_copy_is_still_web_presence_only():
# Two independent providers each locating a published copy is the corroborated # v3 let a second provider's page hit stack on Vision's pool, which is how
# web presence the pool's own rationale says should reach high. Pooled # web-only evidence reached high (13/24 web-only grid combinations at v4).
# together with Vision's correlated views it capped at 60/medium instead. # 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( result = RiskScorer().score(
[_vision("Full match", "full", 0.9), _custom_search_page("Google custom web search result found")] [_vision("Full match", "full", 0.9), _custom_search_page("Google custom web search result found")]
) )
assert result.score == 45 + 35 assert result.score == WEB_PRESENCE_MAX_POINTS
assert result.band == "high" 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(): 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 "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(): def test_legacy_web_row_without_a_match_type_still_counts_as_presence():
# Rows written before every producer set `match`. Rescoring them against the # Rows written before every producer set `match`. They must not be dropped
# current rules would rewrite history; the round-trip has to reproduce the # to zero just because the current producers describe themselves better.
# score they were created with.
legacy = Evidence( legacy = Evidence(
source=EvidenceSource.WEB_DETECTION, source=EvidenceSource.WEB_DETECTION,
reason="Matching image URL found", 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]) result = RiskScorer().score([legacy])
assert result.score == 45 assert result.score == WEB_COPY_NEAR_POINTS
assert result.band == "medium" 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) trusted = RiskScorer(frozenset({"posar.info"})).score(evidence)
untrusted = RiskScorer().score(evidence) untrusted = RiskScorer().score(evidence)
assert untrusted.score == 45 + 35 assert untrusted.score == WEB_PRESENCE_MAX_POINTS
assert trusted.score == 35 assert trusted.score == WEB_COPY_NEAR_POINTS
assert "blog.example.test 에서 발견" in trusted.reasons assert "blog.example.test 에서 발견" in trusted.reasons
@ -540,15 +557,15 @@ def test_a_lookalike_domain_is_not_trusted():
# posar.info.evil.test 는 접미사가 아니라 다른 도메인이다. # posar.info.evil.test 는 접미사가 아니라 다른 도메인이다.
result = RiskScorer(frozenset({"posar.info"})).score([_published_on("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(): def test_entries_that_are_not_hostnames_are_ignored():
# "com" 하나가 웹 전체를 신뢰 처리하면 안 된다. # "com" 하나가 웹 전체를 신뢰 처리하면 안 된다.
evidence = [_published_on("posar.info")] evidence = [_published_on("posar.info")]
assert RiskScorer(frozenset({"com"})).score(evidence).score == 45 assert RiskScorer(frozenset({"com"})).score(evidence).score == WEB_PRESENCE_MAX_POINTS
assert RiskScorer(frozenset({"", " ", "posar info"})).score(evidence).score == 45 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(): 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)]) 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(): 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"]} 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"] assert sum(points.values()) == result.breakdown["total"]
def test_breakdown_shows_what_a_pool_ceiling_swallowed(): def test_breakdown_shows_what_a_pool_ceiling_swallowed():
# 운영자가 보는 증거는 4행인데 점수는 60이다. 왜 125가 아닌지 말해주지 않으면 # 운영자가 보는 증거는 4행인데 점수는 40이다. 왜 110이 아닌지 말해주지 않으면
# 숫자를 믿을 수 없다. # 숫자를 믿을 수 없다.
result = RiskScorer().score( 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:")) pool = next(item for item in result.breakdown["components"] if item["key"] == "web_presence")
assert pool["points"] == 60 assert pool["points"] == WEB_PRESENCE_MAX_POINTS
assert pool["raw"] == 125 assert pool["raw"] == 40 + 30 + 30 + 10
assert pool["cap"] == 60 assert pool["cap"] == WEB_PRESENCE_MAX_POINTS
def test_breakdown_reports_the_hundred_point_ceiling(): 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.score == 100
assert result.breakdown["total"] == 115 assert result.breakdown["total"] == 80 + FACE_PERSON_IDENTIFIABLE_POINTS
assert result.breakdown["clamped"] is True 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)], [_published_on("a.test"), _label("강아지", 0.9)],
[_vision("Full match", "full", 0.9), _custom_search_page("CSE")], [_vision("Full match", "full", 0.9), _custom_search_page("CSE")],
[Evidence(EvidenceSource.NAVER_SEARCH, "승격", 0.8, {"promoted": True})], [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) result = RiskScorer().score(evidence)
assert result.breakdown["final"] == result.score assert result.breakdown["final"] == result.score
assert sum(item["points"] for item in result.breakdown["components"]) == result.breakdown["total"] 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, face_person_reason,
) )
from rights_filter.analysis.preprocessing import ImagePayload, build_face_crop_derivatives 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.domain.records import Evidence, EvidenceSource
from rights_filter.server.image_store import LocalSubmissionImageStore from rights_filter.server.image_store import LocalSubmissionImageStore
from rights_filter.server import sqlite_store as sqlite_store_module 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") store._rescore_submission("SUB-DB1")
review = store.review("SUB-DB1") review = store.review("SUB-DB1")
assert review["riskScore"] == 35 assert review["riskScore"] == FACE_PERSON_IDENTIFIABLE_POINTS
assert review["riskBand"] == "medium" assert review["riskBand"] == "medium"
assert IDENTIFIABLE_FACE_REASON in review["reasons"] assert IDENTIFIABLE_FACE_REASON in review["reasons"]
assert "재분석 요청이 접수됨" not 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") 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["riskBand"] == "medium"
assert review["reasons"] == [IDENTIFIABLE_FACE_REASON] assert review["reasons"] == [IDENTIFIABLE_FACE_REASON]
assert weak_label["title"] == "Google weak label gentleman" 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") review = reopened.review("SUB-DB1")
weak_label = next(item for item in review["evidence"] if item["id"] == "ev-old-google-label") 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 review["riskBand"] == "medium"
assert IDENTIFIABLE_FACE_REASON in review["reasons"] assert IDENTIFIABLE_FACE_REASON in review["reasons"]
assert "Best guess label gentleman" not in review["reasons"] assert "Best guess label gentleman" not in review["reasons"]

View file

@ -8,7 +8,7 @@ from pathlib import Path
import pytest import pytest
from rights_filter.analysis.fingerprints import FingerprintService 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.analysis.search_result_promoter import SearchResultPromoter
from rights_filter.domain.records import Evidence, EvidenceSource from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.integrations.external_policy import ExternalApiPolicy 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) 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(): def test_degenerate_perceptual_hashes_do_not_produce_a_near_match():