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:
parent
352031cf45
commit
6017750bae
16 changed files with 469 additions and 244 deletions
|
|
@ -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**
|
||||||
|
|
|
||||||
|
|
@ -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 폐기 |
|
||||||
|
|
|
||||||
|
|
@ -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점이다 — 행 단위 제외라 스크래퍼 게시는 그대로 센다"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
BIN
docs/diagrams/evidence-dataflow.html
generated
BIN
docs/diagrams/evidence-dataflow.html
generated
Binary file not shown.
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
docs/diagrams/operator-review-workflow.html
generated
BIN
docs/diagrams/operator-review-workflow.html
generated
Binary file not shown.
|
|
@ -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": [
|
||||||
|
|
|
||||||
BIN
docs/diagrams/submission-lifecycle.html
generated
BIN
docs/diagrams/submission-lifecycle.html
generated
Binary file not shown.
|
|
@ -140,7 +140,7 @@
|
||||||
"type": "backend",
|
"type": "backend",
|
||||||
"label": "분석 · 채점",
|
"label": "분석 · 채점",
|
||||||
"sublabel": "지문 · 얼굴 · 출처",
|
"sublabel": "지문 · 얼굴 · 출처",
|
||||||
"tag": "RiskScorer v4",
|
"tag": "RiskScorer v5",
|
||||||
"pos": [
|
"pos": [
|
||||||
678,
|
678,
|
||||||
60
|
60
|
||||||
|
|
|
||||||
BIN
docs/diagrams/system-architecture.html
generated
BIN
docs/diagrams/system-architecture.html
generated
Binary file not shown.
|
|
@ -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` 상단 예산표다.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue