feat: 신뢰 출처 — 자사·계약처 도메인은 위험 신호가 아니다 (SCORER_VERSION v4)
8/8 실효성 평가가 "남은 것: 신뢰출처 판정 자체. 목록만 정해지면
cleared_reference 와 같은 패턴으로 바로 붙는다"로 남겨둔 항목이다. 전제였던
"호스트가 저장 시점에 파괴되는" 문제는 9/18 에 고쳤고, 저장 위치는 지식DB 로
정해졌다.
지식DB 의 trusted_host 항목 — 이름이 호스트, 별칭이 같은 주체의 다른 도메인
신뢰 철회는 기존 활성/비활성 수명주기 그대로 (두 번째 스위치를 만들지 않는다)
핵심은 **행 단위 제외**라는 것이다. 케이스 단위로 끄면 자사 사본이 있다는
이유로 스크래퍼 게시까지 덮인다. 실측: 우리 CDN 완전일치 + 블로그 게시가 같이
있을 때 신뢰 등록 전 80/high, 등록 후 35/medium — 블로그만 남는다.
제외된 행은 점수에서 빠지되 사유로는 남는다("신뢰 출처 posar.info — 위험
점수에서 제외"). 증거 목록에는 그 행이 그대로 보이므로, 왜 점수가 증거보다
조용한지 운영자에게 말해주지 않으면 도구를 불신하게 된다.
경계 처리:
서브도메인은 같은 주체다 (cdn.posar.info)
posar.info.evil.test 는 접미사가 아니라 다른 도메인이다
점 없는 값은 무시한다 — "com" 하나가 웹 전체를 신뢰 처리하면 안 된다
엔티티 라벨은 영향 없다 — "어디에 떠 있나"가 아니라 "무엇이 찍혔나"다
신선한 CSE 행은 host 키가 없어 domain(displayLink)에서 해석한다
호스트 해석이 store_serialization._result_host 와 따로 사는 이유는 층 때문이다.
analysis 는 server 를 import 할 수 없고, 한쪽은 값을 영속화하고 다른 쪽은
운영자 목록과 대조한다. 우선순위는 같게 맞추고 그 사실을 양쪽 주석에 적었다.
빈 목록이면 v3 과 동작이 같다. 등록하지 않은 배포는 아무것도 달라지지 않는다.
다이어그램 사실 게이트(9/21 신설)가 v3 -> v4 를 3개 다이어그램에서 자동으로
잡았다. 스펙을 고치고 rebuild-diagrams.ps1 로 재생성했다 — 게이트의 첫 실사용이고
의도대로 동작했다.
검증: ruff clean, pytest 632 passed (스코어러 7건 + 스토어 3건 추가).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
parent
4e8e4b3181
commit
994852d920
17 changed files with 289 additions and 18 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
# Project INDEX
|
# Project INDEX
|
||||||
|
|
||||||
|
- 2026-09-21 · **신뢰 출처** (`SCORER_VERSION` v3 → v4) — 자사·계약처 도메인에서 발견된 웹 탐지 행을 0점 처리한다. 8/8 평가가 "목록만 정해지면 `cleared_reference` 패턴으로 바로 붙는다"고 남긴 항목이고, 전제였던 host 파괴는 9/18 에 고쳤다. 저장 위치는 **지식DB**(사용자 결정) — `trusted_host` 항목의 이름이 호스트이고 별칭이 같은 주체의 다른 도메인이다. 신뢰 철회에 별도 스위치를 두지 않고 지식 항목의 기존 활성/비활성 수명주기를 그대로 쓴다. **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다(실측: 신뢰 등록 전 80/high → 후 35/medium). 서브도메인은 접미사 매치로 같은 주체 취급(`cdn.posar.info`), `posar.info.evil.test` 같은 유사 도메인은 불일치, 점 없는 값(`com`)은 무시. 신선한 CSE 행은 `host` 키가 없으므로 `domain`(displayLink)에서 해석한다. 제외된 행은 점수에서 빠지되 **사유로 남는다** — 증거 목록에 그 행이 그대로 보이므로 왜 조용한지 말해줘야 한다. 다이어그램 사실 게이트가 v3→v4 를 **3개 다이어그램에서 자동으로 잡아** 스펙 수정 + 재생성으로 이어졌다(게이트 신설 이틀 만의 첫 실사용). **ruff clean · pytest 632 passed**(스코어러 7 + 스토어 3)
|
||||||
|
|
||||||
- 2026-09-21 · **선례 승계** — 재제출을 다시 심사하지 않게 한다. 8/8 실효성 평가의 미해결 결론("검색·기록 노동은 줄여주나 심사 건수는 못 줄인다")을 직접 겨냥한 첫 기능이다. 중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능인데(재저장·80%/50%축소·밝기 변형 24/24 그룹핑, 같은 24건 SHA-256 일치 0건), 서버가 계산해 내려보내던 `priorDecisions`·`conflicting` 이 **큐 배지의 title 속성에만** 있었다 — 마우스를 올린 사람만 보는 자리라 아무것도 막지 못했다. 서버 변경 없이 콘솔만 고쳤다: 배지 본문에 선례 표시, 판정 패널(결정 메모 바로 위)에 선례 배너 + `선례 승계` 1타. 승계는 별도 경로를 만들지 않고 메모를 채워 기존 `setDecision` 을 타므로 반려 메모 필수·판정 스냅샷·감사 이벤트가 그대로 유지된다. **판정 불일치(conflicting)면 승계를 제안하지 않는다** — 그때가 사람이 봐야 하는 상황이다. 실렌더 확인(Playwright, 1440×900): 세 상태(승계 가능/불일치/선례 없음) 배너·버튼 유무가 의도대로였고 승계 클릭 시 `{"decision":"rejected","memo":"선례 승계: 동일 이미지 SUB-OLD1, SUB-OLD2 와 같은 판정"}` 전송, 콘솔 에러 0. **ruff clean · pytest 622 passed**(정적 3건 추가)
|
- 2026-09-21 · **선례 승계** — 재제출을 다시 심사하지 않게 한다. 8/8 실효성 평가의 미해결 결론("검색·기록 노동은 줄여주나 심사 건수는 못 줄인다")을 직접 겨냥한 첫 기능이다. 중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능인데(재저장·80%/50%축소·밝기 변형 24/24 그룹핑, 같은 24건 SHA-256 일치 0건), 서버가 계산해 내려보내던 `priorDecisions`·`conflicting` 이 **큐 배지의 title 속성에만** 있었다 — 마우스를 올린 사람만 보는 자리라 아무것도 막지 못했다. 서버 변경 없이 콘솔만 고쳤다: 배지 본문에 선례 표시, 판정 패널(결정 메모 바로 위)에 선례 배너 + `선례 승계` 1타. 승계는 별도 경로를 만들지 않고 메모를 채워 기존 `setDecision` 을 타므로 반려 메모 필수·판정 스냅샷·감사 이벤트가 그대로 유지된다. **판정 불일치(conflicting)면 승계를 제안하지 않는다** — 그때가 사람이 봐야 하는 상황이다. 실렌더 확인(Playwright, 1440×900): 세 상태(승계 가능/불일치/선례 없음) 배너·버튼 유무가 의도대로였고 승계 클릭 시 `{"decision":"rejected","memo":"선례 승계: 동일 이미지 SUB-OLD1, SUB-OLD2 와 같은 판정"}` 전송, 콘솔 에러 0. **ruff clean · pytest 622 passed**(정적 3건 추가)
|
||||||
|
|
||||||
- 2026-09-21 · 다이어그램 용도를 **내부 문서로 확정**하고 그 기준으로 부채를 재판정. 한글 폰트 미번들·영어 뷰어 UI 는 archify 한계라 spec 으로 못 고치는데, 사내 머신에는 한글 폰트가 있으므로 low 로 내렸다(인수물로 승격하면 둘이 먼저 blocker — PROJECT_MAP 다이어그램 절에 승격 조건으로 명시). 이어서 **코드↔그림 리터럴 드리프트 게이트** `tests/test_diagram_facts.py` 19건 신설 — 그림에 하드카피된 코드 유래 값(SCORER_VERSION · SCHEMA_VERSION · 믹스인 8개 · `app-*.js` 15개 · 바디 10MB · 인증 10회/60초 · 프롬프트 12행 · 풀 상한 60/20 · 기본 모델 · 런타임 의존성 3종)을 실제 코드 상수와 대조하고, 상수가 아닌 "지문 유사도 0.9+ → 80점" 은 실제로 채점해 확인한다. SCORER_VERSION v3→v4 와 MAX_PROMPT_EVIDENCE 12→16 을 실제로 넣어 **영향받는 세 다이어그램 전부에서 차단됨**을 실증. 발견 가능성도 정리 — README 문서 색인에 등재, `docs/diagrams/README.md` 신설(폴더 안에 '직접 수정 금지'·재생성 명령·CSP 주의). **ruff clean · pytest 619 passed**(사실 게이트 +19)
|
- 2026-09-21 · 다이어그램 용도를 **내부 문서로 확정**하고 그 기준으로 부채를 재판정. 한글 폰트 미번들·영어 뷰어 UI 는 archify 한계라 spec 으로 못 고치는데, 사내 머신에는 한글 폰트가 있으므로 low 로 내렸다(인수물로 승격하면 둘이 먼저 blocker — PROJECT_MAP 다이어그램 절에 승격 조건으로 명시). 이어서 **코드↔그림 리터럴 드리프트 게이트** `tests/test_diagram_facts.py` 19건 신설 — 그림에 하드카피된 코드 유래 값(SCORER_VERSION · SCHEMA_VERSION · 믹스인 8개 · `app-*.js` 15개 · 바디 10MB · 인증 10회/60초 · 프롬프트 12행 · 풀 상한 60/20 · 기본 모델 · 런타임 의존성 3종)을 실제 코드 상수와 대조하고, 상수가 아닌 "지문 유사도 0.9+ → 80점" 은 실제로 채점해 확인한다. SCORER_VERSION v3→v4 와 MAX_PROMPT_EVIDENCE 12→16 을 실제로 넣어 **영향받는 세 다이어그램 전부에서 차단됨**을 실증. 발견 가능성도 정리 — README 문서 색인에 등재, `docs/diagrams/README.md` 신설(폴더 안에 '직접 수정 금지'·재생성 명령·CSP 주의). **ruff clean · pytest 619 passed**(사실 게이트 +19)
|
||||||
|
|
|
||||||
|
|
@ -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` 은 가중치/버킷/밴드 변경 시 반드시 올린다** |
|
| `risk_scoring.py` | `RiskScorer.score()` — 점수·밴드 산출. **`SCORER_VERSION` 은 가중치/버킷/밴드 변경 시 반드시 올린다**. `RiskScorer(trusted_hosts)` 는 지식DB 의 활성 `trusted_host` 항목(이름·별칭이 도메인)을 받아 그 호스트에서 발견된 웹 탐지 행을 0점 처리한다 — **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다 |
|
||||||
| `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 폐기 |
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,8 @@
|
||||||
{
|
{
|
||||||
"id": "knowledge",
|
"id": "knowledge",
|
||||||
"type": "database",
|
"type": "database",
|
||||||
"label": "지식DB · 주의목록",
|
"label": "지식DB · 신뢰 출처",
|
||||||
"sublabel": "등록 이미지 지문",
|
"sublabel": "지문 · 자사 도메인",
|
||||||
"tag": "내부",
|
"tag": "내부",
|
||||||
"stage": 2,
|
"stage": 2,
|
||||||
"row": 2
|
"row": 2
|
||||||
|
|
@ -140,7 +140,7 @@
|
||||||
"type": "backend",
|
"type": "backend",
|
||||||
"label": "RiskScorer",
|
"label": "RiskScorer",
|
||||||
"sublabel": "제공자별 풀 · 버킷",
|
"sublabel": "제공자별 풀 · 버킷",
|
||||||
"tag": "v3",
|
"tag": "v4",
|
||||||
"stage": 2,
|
"stage": 2,
|
||||||
"row": 1
|
"row": 1
|
||||||
},
|
},
|
||||||
|
|
@ -294,8 +294,9 @@
|
||||||
"title": "주 경로",
|
"title": "주 경로",
|
||||||
"items": [
|
"items": [
|
||||||
"외부 응답은 데이터 등급 검사를 통과한 필드만 Evidence 가 된다",
|
"외부 응답은 데이터 등급 검사를 통과한 필드만 Evidence 가 된다",
|
||||||
"RiskScorer v3 는 풀과 dedup 버킷을 제공자별로 나눈다 (presence 60 / entity 20)",
|
"RiskScorer v4 는 풀과 dedup 버킷을 제공자별로 나눈다 (presence 60 / entity 20)",
|
||||||
"점수 · 밴드 · 사유는 payload 로 직렬화되어 evidence 테이블에 남는다"
|
"점수 · 밴드 · 사유는 payload 로 직렬화되어 evidence 테이블에 남는다",
|
||||||
|
"신뢰 출처(자사·계약처 도메인)에서 발견된 행은 0점이다 — 행 단위 제외라 스크래퍼 게시는 그대로 센다"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
BIN
docs/diagrams/evidence-dataflow.html
generated
BIN
docs/diagrams/evidence-dataflow.html
generated
Binary file not shown.
|
|
@ -6,24 +6,24 @@
|
||||||
"type": "architecture",
|
"type": "architecture",
|
||||||
"spec": "system-architecture.architecture.json",
|
"spec": "system-architecture.architecture.json",
|
||||||
"html": "system-architecture.html",
|
"html": "system-architecture.html",
|
||||||
"specSha256": "8359880ec5a7e64012cf7533169d279fff364b36c84855a1678ea8be2414d699",
|
"specSha256": "77b3581a116b2ea6908f254f58b549312da439715012328cabed459ec4a971ce",
|
||||||
"htmlSha256": "f5a4b6e1c6f94ab65bd55d6b348c9d1257db194508285541e3fb94acdfabd44c",
|
"htmlSha256": "d062032d51e40095cee996e4b3afb06337862017f641dc599d54028b18a47c3c",
|
||||||
"htmlBytes": 816201
|
"htmlBytes": 816201
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "dataflow",
|
"type": "dataflow",
|
||||||
"spec": "evidence-dataflow.dataflow.json",
|
"spec": "evidence-dataflow.dataflow.json",
|
||||||
"html": "evidence-dataflow.html",
|
"html": "evidence-dataflow.html",
|
||||||
"specSha256": "7503e9be9f100440d965877f6bb7e9ce8863f85015a5144b7a30404a524ba65a",
|
"specSha256": "091f63f4b646f5c65ebc9f471d571e412b83572dffddb28f97a7fd016d2e827d",
|
||||||
"htmlSha256": "a62f0f5cab31f880a98877f0d1b4353d09c86fb1152157fbdebe62bcd491114e",
|
"htmlSha256": "01f871dd947cf5fcc7675938f64a66926d010843295d5bab9a48ddb66089f09d",
|
||||||
"htmlBytes": 822657
|
"htmlBytes": 822842
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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": "222d2d7565ee06c783a93868775732d96a9300e3baa4e8ddff1f8a57a421e79c",
|
"specSha256": "2925ef821e8e63143e001e0996a67470be1291f75a9d50fc47fce5966082c282",
|
||||||
"htmlSha256": "402914a3d29ad899d136b7e011bbe2aaf7d36a2a2880ee59fa33929bb614974d",
|
"htmlSha256": "0cbf42921f6d21322eca82252f520f94d36ad113d2263757d9c7403fc415f337",
|
||||||
"htmlBytes": 818137
|
"htmlBytes": 818137
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
BIN
docs/diagrams/operator-review-workflow.html
generated
BIN
docs/diagrams/operator-review-workflow.html
generated
Binary file not shown.
|
|
@ -175,7 +175,7 @@
|
||||||
"type": "backend",
|
"type": "backend",
|
||||||
"label": "위험도 산출",
|
"label": "위험도 산출",
|
||||||
"sublabel": "점수 · 밴드 · 사유",
|
"sublabel": "점수 · 밴드 · 사유",
|
||||||
"tag": "v3",
|
"tag": "v4",
|
||||||
"width": 132
|
"width": 132
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@
|
||||||
"type": "backend",
|
"type": "backend",
|
||||||
"label": "분석 · 채점",
|
"label": "분석 · 채점",
|
||||||
"sublabel": "지문 · 얼굴 · 출처",
|
"sublabel": "지문 · 얼굴 · 출처",
|
||||||
"tag": "RiskScorer v3",
|
"tag": "RiskScorer v4",
|
||||||
"pos": [
|
"pos": [
|
||||||
678,
|
678,
|
||||||
60
|
60
|
||||||
|
|
|
||||||
BIN
docs/diagrams/system-architecture.html
generated
BIN
docs/diagrams/system-architecture.html
generated
Binary file not shown.
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
@ -61,10 +63,40 @@ WEB_PRESENCE_MAX_POINTS = 60
|
||||||
# provider. Before v3 a Google custom-search page hit was dropped whole -
|
# provider. Before v3 a Google custom-search page hit was dropped whole -
|
||||||
# no points, no reason line - whenever Vision had already reported a
|
# no points, no reason line - whenever Vision had already reported a
|
||||||
# match="page" row, because both rows landed in one global "page" bucket.
|
# match="page" row, because both rows landed in one global "page" bucket.
|
||||||
SCORER_VERSION = "v3"
|
# v4: web-detection rows published on an operator-registered trusted host score
|
||||||
|
# zero. The exclusion is per ROW, not per case - an image that is on our own
|
||||||
|
# site AND on a scraper blog still counts the scraper.
|
||||||
|
SCORER_VERSION = "v4"
|
||||||
|
|
||||||
|
|
||||||
class RiskScorer:
|
class RiskScorer:
|
||||||
|
def __init__(self, trusted_hosts: frozenset[str] | None = None) -> None:
|
||||||
|
"""trusted_hosts: publishing hosts the team owns or licenses.
|
||||||
|
|
||||||
|
Supplied by the store from active `trusted_host` knowledge entries. The
|
||||||
|
default empty set keeps the scorer's behaviour identical to v3, so a
|
||||||
|
deployment that registers nothing sees no change.
|
||||||
|
"""
|
||||||
|
self.trusted_hosts = frozenset(
|
||||||
|
host for host in (trusted_hosts or frozenset()) if _is_hostname(host)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _trusted_host_of(self, item: Evidence) -> str:
|
||||||
|
"""The registered trusted host this row was published on, if any.
|
||||||
|
|
||||||
|
Subdomains of a registered host belong to the same party (a licensed
|
||||||
|
partner's CDN is still that partner), so a suffix match is intended.
|
||||||
|
"""
|
||||||
|
if not self.trusted_hosts:
|
||||||
|
return ""
|
||||||
|
host = _evidence_host(item)
|
||||||
|
if not host:
|
||||||
|
return ""
|
||||||
|
for trusted in self.trusted_hosts:
|
||||||
|
if host == trusted or host.endswith(f".{trusted}"):
|
||||||
|
return trusted
|
||||||
|
return ""
|
||||||
|
|
||||||
def score(self, evidence: list[Evidence]) -> ScoreResult:
|
def score(self, evidence: list[Evidence]) -> ScoreResult:
|
||||||
score = 0
|
score = 0
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
|
|
@ -107,6 +139,15 @@ class RiskScorer:
|
||||||
score += _face_person_points(item)
|
score += _face_person_points(item)
|
||||||
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
|
||||||
|
# web" is only a risk signal when the web in question is not
|
||||||
|
# ours, so this row contributes nothing - but the operator still
|
||||||
|
# needs to see WHY the case is quieter than the evidence list
|
||||||
|
# looks, hence the reason.
|
||||||
|
trusted = self._trusted_host_of(item)
|
||||||
|
if trusted:
|
||||||
|
reasons.append(f"신뢰 출처 {trusted} — 위험 점수에서 제외")
|
||||||
|
continue
|
||||||
bucket = _web_detection_bucket(item)
|
bucket = _web_detection_bucket(item)
|
||||||
if bucket and bucket in counted_web_buckets:
|
if bucket and bucket in counted_web_buckets:
|
||||||
continue
|
continue
|
||||||
|
|
@ -205,6 +246,45 @@ _WEB_LABEL = "label" # pooled under WEB_ENTITY_MAX_POINTS
|
||||||
_WEB_STANDALONE = "standalone" # added on its own
|
_WEB_STANDALONE = "standalone" # added on its own
|
||||||
|
|
||||||
|
|
||||||
|
def _is_hostname(value: str) -> bool:
|
||||||
|
"""A hostname we can match on has a label separator.
|
||||||
|
|
||||||
|
Without this a trusted entry of "com" would clear the whole web, and a
|
||||||
|
provider name like "google" would match nothing while looking as if it did.
|
||||||
|
"""
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
return bool(text) and "." in text and " " not in text
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_host(item: Evidence) -> str:
|
||||||
|
"""Where this row says the image is published.
|
||||||
|
|
||||||
|
Mirrors the priority in store_serialization._result_host, which is what
|
||||||
|
writes the persisted `host` key: the resolved host wins, a provider-supplied
|
||||||
|
display host (CSE `domain`) comes next, and a URL is parsed only as a
|
||||||
|
fallback. The two live apart on purpose - that one persists a value, this one
|
||||||
|
matches against an operator list - and neither may import the other's layer.
|
||||||
|
"""
|
||||||
|
data = item.data
|
||||||
|
host = str(data.get("host", "")).strip().lower()
|
||||||
|
if _is_hostname(host):
|
||||||
|
return host.removeprefix("www.")
|
||||||
|
domain = str(data.get("domain", "")).strip().lower()
|
||||||
|
if _is_hostname(domain):
|
||||||
|
return domain.removeprefix("www.")
|
||||||
|
for key in ("url", "result_url", "page_url", "source_page_url", "image_url"):
|
||||||
|
raw = str(data.get(key, "")).strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = urlparse(raw if "://" in raw else f"//{raw}").hostname or ""
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if _is_hostname(parsed):
|
||||||
|
return parsed.lower().removeprefix("www.")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ class KnowledgeEntryType(StrEnum):
|
||||||
# (licensed / owned / public domain). A match argues FOR fast approval, not
|
# (licensed / owned / public domain). A match argues FOR fast approval, not
|
||||||
# against it, so the scorer treats it as a cleared signal rather than +80.
|
# against it, so the scorer treats it as a cleared signal rather than +80.
|
||||||
CLEARED_REFERENCE = "cleared_reference"
|
CLEARED_REFERENCE = "cleared_reference"
|
||||||
|
# A publishing host the team controls or licenses (own site, contracted
|
||||||
|
# partner, CDN). Finding an image THERE is not evidence of infringement, so
|
||||||
|
# web-detection rows from that host score zero instead of +45 "it is on the
|
||||||
|
# web". The entry's name is the host; aliases carry the party's other hosts.
|
||||||
|
TRUSTED_HOST = "trusted_host"
|
||||||
OTHER = "other"
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -388,7 +388,7 @@ class CopyrighterStore(
|
||||||
internal_analyzer=analyzer,
|
internal_analyzer=analyzer,
|
||||||
external_adapter=self.provider_runtime.google_adapter or CloudVisionWebDetectionAdapter(FakeWebDetectionClient()),
|
external_adapter=self.provider_runtime.google_adapter or CloudVisionWebDetectionAdapter(FakeWebDetectionClient()),
|
||||||
external_policy=self.provider_runtime.external_policy if vision_allowed else ExternalApiPolicy(disabled=True),
|
external_policy=self.provider_runtime.external_policy if vision_allowed else ExternalApiPolicy(disabled=True),
|
||||||
scorer=RiskScorer(),
|
scorer=RiskScorer(self._trusted_hosts()),
|
||||||
)
|
)
|
||||||
submissions = [
|
submissions = [
|
||||||
SubmissionImage(record["id"], image_store.image_payload(record["id"]))
|
SubmissionImage(record["id"], image_store.image_payload(record["id"]))
|
||||||
|
|
|
||||||
|
|
@ -718,13 +718,36 @@ class StoreSearchCandidatesMixin:
|
||||||
return self._custom_candidate_image_fetcher(url)
|
return self._custom_candidate_image_fetcher(url)
|
||||||
return _fetch_url_bytes(url, referer_url=referer_url)
|
return _fetch_url_bytes(url, referer_url=referer_url)
|
||||||
|
|
||||||
|
def _trusted_hosts(self) -> frozenset[str]:
|
||||||
|
"""Publishing hosts the team owns or licenses, from the knowledge DB.
|
||||||
|
|
||||||
|
Registered as `trusted_host` entries: the entry name is the host and its
|
||||||
|
aliases carry the same party's other hosts (a partner's CDN, a regional
|
||||||
|
domain). Deactivating the entry stops the exclusion, which is the same
|
||||||
|
lifecycle every other knowledge entry already has - no second switch.
|
||||||
|
|
||||||
|
Malformed values are dropped by RiskScorer, so validity lives in one
|
||||||
|
place rather than being re-checked at every call site.
|
||||||
|
"""
|
||||||
|
hosts: set[str] = set()
|
||||||
|
for entry in self._all("knowledge_entries"):
|
||||||
|
if str(entry.get("type", "")) != "trusted_host":
|
||||||
|
continue
|
||||||
|
if not entry.get("active", True):
|
||||||
|
continue
|
||||||
|
for value in [entry.get("name", ""), *(entry.get("aliases") or [])]:
|
||||||
|
text = str(value).strip().lower().removeprefix("www.")
|
||||||
|
if text:
|
||||||
|
hosts.add(text)
|
||||||
|
return frozenset(hosts)
|
||||||
|
|
||||||
def _rescore_submission(self, submission_id: str) -> None:
|
def _rescore_submission(self, submission_id: str) -> None:
|
||||||
submission = self._get("submissions", submission_id)
|
submission = self._get("submissions", submission_id)
|
||||||
evidence = [
|
evidence = [
|
||||||
_domain_evidence_from_ui(item)
|
_domain_evidence_from_ui(item)
|
||||||
for item in self._evidence_for_submission(submission_id)
|
for item in self._evidence_for_submission(submission_id)
|
||||||
]
|
]
|
||||||
score = RiskScorer().score(evidence)
|
score = RiskScorer(self._trusted_hosts()).score(evidence)
|
||||||
previous_band = str(submission.get("riskBand", ""))
|
previous_band = str(submission.get("riskBand", ""))
|
||||||
previous_score = submission.get("riskScore", "")
|
previous_score = submission.get("riskScore", "")
|
||||||
# The version the LIVE score was last produced by, as opposed to the
|
# The version the LIVE score was last produced by, as opposed to the
|
||||||
|
|
|
||||||
|
|
@ -490,3 +490,86 @@ def test_legacy_web_row_without_a_match_type_keeps_its_original_weight():
|
||||||
|
|
||||||
assert result.score == 45
|
assert result.score == 45
|
||||||
assert result.band == "medium"
|
assert result.band == "medium"
|
||||||
|
|
||||||
|
|
||||||
|
def _published_on(
|
||||||
|
host: str, match: str = "full", confidence: float = 0.9, provider: str = "google"
|
||||||
|
) -> Evidence:
|
||||||
|
return Evidence(
|
||||||
|
source=EvidenceSource.WEB_DETECTION,
|
||||||
|
reason=f"{host} 에서 발견",
|
||||||
|
confidence=confidence,
|
||||||
|
data={"match": match, "provider": provider, "host": host},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_trusted_host_row_scores_nothing_but_still_explains_itself():
|
||||||
|
# 자사·계약처 도메인에서 발견된 것은 "웹에 있다"가 아니다. 다만 증거 목록에는
|
||||||
|
# 그 행이 그대로 보이므로, 왜 점수가 조용한지 사유로 말해줘야 한다.
|
||||||
|
result = RiskScorer(frozenset({"posar.info"})).score([_published_on("posar.info")])
|
||||||
|
|
||||||
|
assert result.score == 0
|
||||||
|
assert result.band == "low"
|
||||||
|
assert any("신뢰 출처" in reason for reason in result.reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_is_applied_per_row_not_per_case():
|
||||||
|
# 우리 사이트에도 있고 스크래퍼 블로그에도 있으면, 후자는 그대로 세야 한다.
|
||||||
|
# 실제 형태: Vision 이 우리 사본을, CSE 가 블로그 게시를 따로 찾는다.
|
||||||
|
evidence = [
|
||||||
|
_published_on("posar.info"),
|
||||||
|
_published_on("blog.example.test", "page", 0.35, provider="google_custom_search"),
|
||||||
|
]
|
||||||
|
|
||||||
|
trusted = RiskScorer(frozenset({"posar.info"})).score(evidence)
|
||||||
|
untrusted = RiskScorer().score(evidence)
|
||||||
|
|
||||||
|
assert untrusted.score == 45 + 35
|
||||||
|
assert trusted.score == 35
|
||||||
|
assert "blog.example.test 에서 발견" in trusted.reasons
|
||||||
|
|
||||||
|
|
||||||
|
def test_subdomains_of_a_trusted_host_belong_to_the_same_party():
|
||||||
|
# 계약처의 CDN 은 여전히 그 계약처다.
|
||||||
|
result = RiskScorer(frozenset({"posar.info"})).score([_published_on("cdn.posar.info")])
|
||||||
|
|
||||||
|
assert result.score == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lookalike_domain_is_not_trusted():
|
||||||
|
# posar.info.evil.test 는 접미사가 아니라 다른 도메인이다.
|
||||||
|
result = RiskScorer(frozenset({"posar.info"})).score([_published_on("posar.info.evil.test")])
|
||||||
|
|
||||||
|
assert result.score == 45
|
||||||
|
|
||||||
|
|
||||||
|
def test_entries_that_are_not_hostnames_are_ignored():
|
||||||
|
# "com" 하나가 웹 전체를 신뢰 처리하면 안 된다.
|
||||||
|
evidence = [_published_on("posar.info")]
|
||||||
|
|
||||||
|
assert RiskScorer(frozenset({"com"})).score(evidence).score == 45
|
||||||
|
assert RiskScorer(frozenset({"", " ", "posar info"})).score(evidence).score == 45
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fresh_custom_search_row_resolves_its_host_from_the_provider_field():
|
||||||
|
# 신선한 CSE 행에는 host 키가 없고 displayLink 가 domain 으로 들어온다.
|
||||||
|
fresh = Evidence(
|
||||||
|
source=EvidenceSource.WEB_DETECTION,
|
||||||
|
reason="Google custom web search result found",
|
||||||
|
confidence=0.35,
|
||||||
|
data={
|
||||||
|
"match": "page",
|
||||||
|
"provider": "google_custom_search",
|
||||||
|
"domain": "news.posar.info",
|
||||||
|
"result_url": "https://news.posar.info/article",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert RiskScorer(frozenset({"posar.info"})).score([fresh]).score == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_does_not_touch_entity_rows():
|
||||||
|
# 엔티티 라벨은 "어디에 떠 있나"가 아니라 "무엇이 찍혔나"다.
|
||||||
|
result = RiskScorer(frozenset({"posar.info"})).score([_label("강아지", 0.9)])
|
||||||
|
|
||||||
|
assert result.score == 20
|
||||||
|
|
|
||||||
|
|
@ -8647,3 +8647,76 @@ def test_provider_names_are_not_accepted_as_publishing_hosts():
|
||||||
assert hostname_of("internal") == ""
|
assert hostname_of("internal") == ""
|
||||||
assert hostname_of("biz.chosun.com") == "biz.chosun.com"
|
assert hostname_of("biz.chosun.com") == "biz.chosun.com"
|
||||||
assert hostname_of("https://www.Example.co.kr/a.jpg") == "example.co.kr"
|
assert hostname_of("https://www.Example.co.kr/a.jpg") == "example.co.kr"
|
||||||
|
|
||||||
|
|
||||||
|
def _web_evidence_on(store: CopyrighterStore, submission_id: str, host: str) -> None:
|
||||||
|
"""제출물에 '이 호스트에서 완전일치를 찾았다' 증거 한 행을 붙인다."""
|
||||||
|
evidence = Evidence(
|
||||||
|
source=EvidenceSource.WEB_DETECTION,
|
||||||
|
reason=f"{host} 에서 완전일치",
|
||||||
|
confidence=0.9,
|
||||||
|
data={"match": "full", "provider": "google", "host": host, "url": f"https://{host}/a.jpg"},
|
||||||
|
)
|
||||||
|
payload = store_serialization_module._evidence_payload(submission_id, evidence)
|
||||||
|
store._put("evidence", payload["id"], payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_registered_trusted_host_removes_its_web_presence_from_the_score(tmp_path: Path):
|
||||||
|
# 자사·계약처 도메인에서 발견된 것은 침해 신호가 아니다. 운영자가 그 목록을
|
||||||
|
# 지식DB 에 넣으면 점수가 그것을 반영해야 한다 - 매번 눈으로 "이건 우리
|
||||||
|
# 사이트잖아"를 확인하는 노동이 이 기능의 대상이다.
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(_image_store(image_root))
|
||||||
|
submission_id = str(store.bootstrap()["submissions"][0]["id"])
|
||||||
|
_web_evidence_on(store, submission_id, "cdn.posar.info")
|
||||||
|
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
before = store._get("submissions", submission_id)["riskScore"]
|
||||||
|
|
||||||
|
store.register_manual_knowledge_entry({"name": "posar.info", "type": "trusted_host"})
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
after = store._get("submissions", submission_id)
|
||||||
|
|
||||||
|
assert before > after["riskScore"], "신뢰 출처 등록이 점수를 낮춰야 한다"
|
||||||
|
assert any("신뢰 출처" in reason for reason in after["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_deactivating_a_trusted_host_entry_restores_the_score(tmp_path: Path):
|
||||||
|
# 신뢰 철회에 별도 스위치를 두지 않는다 - 지식 항목의 기존 활성/비활성
|
||||||
|
# 수명주기를 그대로 쓴다.
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(_image_store(image_root))
|
||||||
|
submission_id = str(store.bootstrap()["submissions"][0]["id"])
|
||||||
|
_web_evidence_on(store, submission_id, "posar.info")
|
||||||
|
|
||||||
|
entry = store.register_manual_knowledge_entry({"name": "posar.info", "type": "trusted_host"})
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
trusted_score = store._get("submissions", submission_id)["riskScore"]
|
||||||
|
|
||||||
|
stored = store._get("knowledge_entries", entry["id"])
|
||||||
|
stored["active"] = False
|
||||||
|
store._put("knowledge_entries", entry["id"], stored)
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
|
||||||
|
assert store._get("submissions", submission_id)["riskScore"] > trusted_score
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_hosts_reads_name_and_aliases_of_active_entries(tmp_path: Path):
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
store.register_manual_knowledge_entry(
|
||||||
|
{"name": "posar.info", "type": "trusted_host", "aliases": ["img.partner.example", "www.Partner.example"]}
|
||||||
|
)
|
||||||
|
store.register_manual_knowledge_entry({"name": "someone", "type": "celebrity"})
|
||||||
|
|
||||||
|
hosts = store._trusted_hosts()
|
||||||
|
|
||||||
|
assert "posar.info" in hosts
|
||||||
|
assert "img.partner.example" in hosts
|
||||||
|
# www. 는 저장 시점에 떼어 둔다 - 증거 쪽 호스트도 www 가 없는 형태다.
|
||||||
|
assert "partner.example" in hosts
|
||||||
|
assert "someone" not in hosts
|
||||||
|
|
|
||||||
|
|
@ -404,6 +404,7 @@
|
||||||
<option value="character">캐릭터</option>
|
<option value="character">캐릭터</option>
|
||||||
<option value="game">게임</option>
|
<option value="game">게임</option>
|
||||||
<option value="rejected_image">반려 이미지</option>
|
<option value="rejected_image">반려 이미지</option>
|
||||||
|
<option value="trusted_host">신뢰 출처</option>
|
||||||
<option value="other">기타</option>
|
<option value="other">기타</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -479,6 +480,7 @@
|
||||||
<option value="character">캐릭터</option>
|
<option value="character">캐릭터</option>
|
||||||
<option value="game">게임</option>
|
<option value="game">게임</option>
|
||||||
<option value="rejected_reference">반려 이미지 참조</option>
|
<option value="rejected_reference">반려 이미지 참조</option>
|
||||||
|
<option value="trusted_host">신뢰 출처(이름·별칭에 도메인)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@
|
||||||
game: "게임",
|
game: "게임",
|
||||||
rejected_image: "반려 이미지",
|
rejected_image: "반려 이미지",
|
||||||
rejected_reference: "반려 이미지",
|
rejected_reference: "반려 이미지",
|
||||||
|
cleared_reference: "클리어 기준",
|
||||||
|
trusted_host: "신뢰 출처",
|
||||||
other: "기타",
|
other: "기타",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue