From 994852d9206e7da9d914e7155dde1799a41d4831 Mon Sep 17 00:00:00 2001 From: changukyu Date: Mon, 21 Sep 2026 12:18:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=A0=EB=A2=B0=20=EC=B6=9C=EC=B2=98?= =?UTF-8?q?=20=E2=80=94=20=EC=9E=90=EC=82=AC=C2=B7=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EC=B2=98=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=80=20=EC=9C=84?= =?UTF-8?q?=ED=97=98=20=EC=8B=A0=ED=98=B8=EA=B0=80=20=EC=95=84=EB=8B=88?= =?UTF-8?q?=EB=8B=A4=20(SCORER=5FVERSION=20v4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V --- docs/INDEX.md | 2 + docs/PROJECT_MAP.md | 2 +- docs/diagrams/evidence-dataflow.dataflow.json | 11 +-- docs/diagrams/evidence-dataflow.html | Bin 822657 -> 822842 bytes docs/diagrams/manifest.json | 14 +-- docs/diagrams/operator-review-workflow.html | Bin 818137 -> 818137 bytes .../operator-review-workflow.workflow.json | 2 +- .../system-architecture.architecture.json | 2 +- docs/diagrams/system-architecture.html | Bin 816201 -> 816201 bytes src/rights_filter/analysis/risk_scoring.py | 82 ++++++++++++++++- src/rights_filter/domain/records.py | 5 ++ src/rights_filter/server/sqlite_store.py | 2 +- .../server/store_search_candidates.py | 25 +++++- .../analysis/test_risk_scoring.py | 83 ++++++++++++++++++ .../rights_filter/server/test_sqlite_store.py | 73 +++++++++++++++ web/operator-gui/index.html | 2 + web/operator-gui/operator-labels.js | 2 + 17 files changed, 289 insertions(+), 18 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 2a822e5..634afee 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # 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 · 다이어그램 용도를 **내부 문서로 확정**하고 그 기준으로 부채를 재판정. 한글 폰트 미번들·영어 뷰어 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) diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 373b742..fd16df5 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -41,7 +41,7 @@ python run_copyrighter_server.py # 기본 127.0.0.1:9500 | `fingerprints.py` | 이미지 지문. `_perceptual_hash`는 **DCT pHash가 아니라 8x8 평균해시(aHash)** — 이름과 실제가 다름(알려진 부채) | | `face_person_detection.py` | YuNet(ONNX) 얼굴 검출 + 인물 등급(`identifiable`/`background`). 가중치는 `models/`(gitignore) | | `internal_analyzer.py` | 내부 분석 오케스트레이션 | -| `risk_scoring.py` | `RiskScorer.score()` — 점수·밴드 산출. **`SCORER_VERSION` 은 가중치/버킷/밴드 변경 시 반드시 올린다** | +| `risk_scoring.py` | `RiskScorer.score()` — 점수·밴드 산출. **`SCORER_VERSION` 은 가중치/버킷/밴드 변경 시 반드시 올린다**. `RiskScorer(trusted_hosts)` 는 지식DB 의 활성 `trusted_host` 항목(이름·별칭이 도메인)을 받아 그 호스트에서 발견된 웹 탐지 행을 0점 처리한다 — **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다 | | `evidence_normalizer.py`, `evidence_enrichment.py` | 증거 정규화·보강 | | `search_query_generation.py`, `search_result_promoter.py` | 자동 검색 쿼리 생성, 검색 결과의 증거 승격 | | `llm_assistance.py`, `llm_prompts.py` | LLM을 **요약기가 아닌 증거 판독기**로 사용. 입력 화이트리스트 9필드 + 상위 12건 상한, `format=json` 구조화 인용, 라벨 집합 포함 검사로 미인용 claim 폐기 | diff --git a/docs/diagrams/evidence-dataflow.dataflow.json b/docs/diagrams/evidence-dataflow.dataflow.json index 7d2d236..382145a 100644 --- a/docs/diagrams/evidence-dataflow.dataflow.json +++ b/docs/diagrams/evidence-dataflow.dataflow.json @@ -111,8 +111,8 @@ { "id": "knowledge", "type": "database", - "label": "지식DB · 주의목록", - "sublabel": "등록 이미지 지문", + "label": "지식DB · 신뢰 출처", + "sublabel": "지문 · 자사 도메인", "tag": "내부", "stage": 2, "row": 2 @@ -140,7 +140,7 @@ "type": "backend", "label": "RiskScorer", "sublabel": "제공자별 풀 · 버킷", - "tag": "v3", + "tag": "v4", "stage": 2, "row": 1 }, @@ -294,8 +294,9 @@ "title": "주 경로", "items": [ "외부 응답은 데이터 등급 검사를 통과한 필드만 Evidence 가 된다", - "RiskScorer v3 는 풀과 dedup 버킷을 제공자별로 나눈다 (presence 60 / entity 20)", - "점수 · 밴드 · 사유는 payload 로 직렬화되어 evidence 테이블에 남는다" + "RiskScorer v4 는 풀과 dedup 버킷을 제공자별로 나눈다 (presence 60 / entity 20)", + "점수 · 밴드 · 사유는 payload 로 직렬화되어 evidence 테이블에 남는다", + "신뢰 출처(자사·계약처 도메인)에서 발견된 행은 0점이다 — 행 단위 제외라 스크래퍼 게시는 그대로 센다" ] }, { diff --git a/docs/diagrams/evidence-dataflow.html b/docs/diagrams/evidence-dataflow.html index e93c5484ca78766f3012cb147fb783f23912b3dd..d428d3aa9b1a6ccf60b765ced34571ef2ce7dcc3 100644 GIT binary patch delta 490 zcmZ{g&nv@m7{}X>hcUxpUs33jS&YRdjU`SF#6|oSPA+cBmp&yn>@a&KpZy?(TDC^M zOLN&jAa_NM`#h7In|yFEDzI zrY0D~MJG5s)!NJGM3HvxmUjf07e0j A5dZ)H diff --git a/docs/diagrams/manifest.json b/docs/diagrams/manifest.json index 9cbcce0..d66e7c9 100644 --- a/docs/diagrams/manifest.json +++ b/docs/diagrams/manifest.json @@ -6,24 +6,24 @@ "type": "architecture", "spec": "system-architecture.architecture.json", "html": "system-architecture.html", - "specSha256": "8359880ec5a7e64012cf7533169d279fff364b36c84855a1678ea8be2414d699", - "htmlSha256": "f5a4b6e1c6f94ab65bd55d6b348c9d1257db194508285541e3fb94acdfabd44c", + "specSha256": "77b3581a116b2ea6908f254f58b549312da439715012328cabed459ec4a971ce", + "htmlSha256": "d062032d51e40095cee996e4b3afb06337862017f641dc599d54028b18a47c3c", "htmlBytes": 816201 }, { "type": "dataflow", "spec": "evidence-dataflow.dataflow.json", "html": "evidence-dataflow.html", - "specSha256": "7503e9be9f100440d965877f6bb7e9ce8863f85015a5144b7a30404a524ba65a", - "htmlSha256": "a62f0f5cab31f880a98877f0d1b4353d09c86fb1152157fbdebe62bcd491114e", - "htmlBytes": 822657 + "specSha256": "091f63f4b646f5c65ebc9f471d571e412b83572dffddb28f97a7fd016d2e827d", + "htmlSha256": "01f871dd947cf5fcc7675938f64a66926d010843295d5bab9a48ddb66089f09d", + "htmlBytes": 822842 }, { "type": "workflow", "spec": "operator-review-workflow.workflow.json", "html": "operator-review-workflow.html", - "specSha256": "222d2d7565ee06c783a93868775732d96a9300e3baa4e8ddff1f8a57a421e79c", - "htmlSha256": "402914a3d29ad899d136b7e011bbe2aaf7d36a2a2880ee59fa33929bb614974d", + "specSha256": "2925ef821e8e63143e001e0996a67470be1291f75a9d50fc47fce5966082c282", + "htmlSha256": "0cbf42921f6d21322eca82252f520f94d36ad113d2263757d9c7403fc415f337", "htmlBytes": 818137 }, { diff --git a/docs/diagrams/operator-review-workflow.html b/docs/diagrams/operator-review-workflow.html index ce6912d2a8680b5a730e927cb8b0cc8d1ee416d7..ee3ce2ee1d8bb106a2ebb87f1b749ad1537f8268 100644 GIT binary patch delta 105 zcmccl-SFmj!-f{d7N!>F7M3lnoRW+t(+x#fjixt9uu4tuS7sHNo+Zhe0~9q7V3nEf xD#dC(y}*i9dOMJHj0q^*sLra@ZYIkL#B4y!4#XTl%n8I?K+L_}OqOSH8~|{m9EJb@ delta 105 zcmccl-SFmj!-f{d7N!>F7M3lnoRW;j(+x#fjixt9uu4tuS7sHNo+Zhe0~9q7V3nEf xD#dC(y}*i9dOMJHj0q^*sLra@ZYIkL#B4y!4#XTl%n8I?K+L_}OqOSH8~|_U9D@J= diff --git a/docs/diagrams/operator-review-workflow.workflow.json b/docs/diagrams/operator-review-workflow.workflow.json index 3a5c0a5..9310813 100644 --- a/docs/diagrams/operator-review-workflow.workflow.json +++ b/docs/diagrams/operator-review-workflow.workflow.json @@ -175,7 +175,7 @@ "type": "backend", "label": "위험도 산출", "sublabel": "점수 · 밴드 · 사유", - "tag": "v3", + "tag": "v4", "width": 132 }, { diff --git a/docs/diagrams/system-architecture.architecture.json b/docs/diagrams/system-architecture.architecture.json index dc20cec..8b228cc 100644 --- a/docs/diagrams/system-architecture.architecture.json +++ b/docs/diagrams/system-architecture.architecture.json @@ -140,7 +140,7 @@ "type": "backend", "label": "분석 · 채점", "sublabel": "지문 · 얼굴 · 출처", - "tag": "RiskScorer v3", + "tag": "RiskScorer v4", "pos": [ 678, 60 diff --git a/docs/diagrams/system-architecture.html b/docs/diagrams/system-architecture.html index 6e5a24db924f525efc9d27f4d8829bb13c9f3e5c..3b2b049b955da3b305b3b9a2f64cafd0e224fd6e 100644 GIT binary patch delta 85 zcmX^4!tmq^!-f{d7N!>F7M3lnGh`S|rV9wN7EkAqW$gj78QEE7wr`bX6=Vi->eX1a e+CM6=0x=s9vjZ^)5OV@C7Z7uA|ERzd5exvhtsIyD delta 85 zcmX^4!tmq^!-f{d7N!>F7M3lnGh`Tzrwa(O7EkAqW$gj78QEE7wr`bX6=Vi->eX1a e+CM6=0x=s9vjZ^)5OV@C7Z7uA|ERzd5exvh4;+;M diff --git a/src/rights_filter/analysis/risk_scoring.py b/src/rights_filter/analysis/risk_scoring.py index e1be02d..7bd9db0 100644 --- a/src/rights_filter/analysis/risk_scoring.py +++ b/src/rights_filter/analysis/risk_scoring.py @@ -1,5 +1,7 @@ from __future__ import annotations +from urllib.parse import urlparse + from rights_filter.analysis.face_person_detection import TIER_BACKGROUND 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 - # no points, no reason line - whenever Vision had already reported a # 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: + 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: score = 0 reasons: list[str] = [] @@ -107,6 +139,15 @@ class RiskScorer: score += _face_person_points(item) reasons.append(item.reason) 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) if bucket and bucket in counted_web_buckets: continue @@ -205,6 +246,45 @@ _WEB_LABEL = "label" # pooled under WEB_ENTITY_MAX_POINTS _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]: """Points for one web-detection row, and which pool they belong to. diff --git a/src/rights_filter/domain/records.py b/src/rights_filter/domain/records.py index b202cc0..978cf62 100644 --- a/src/rights_filter/domain/records.py +++ b/src/rights_filter/domain/records.py @@ -41,6 +41,11 @@ class KnowledgeEntryType(StrEnum): # (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. 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" diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index 84bc6a4..036b39b 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -388,7 +388,7 @@ class CopyrighterStore( internal_analyzer=analyzer, external_adapter=self.provider_runtime.google_adapter or CloudVisionWebDetectionAdapter(FakeWebDetectionClient()), external_policy=self.provider_runtime.external_policy if vision_allowed else ExternalApiPolicy(disabled=True), - scorer=RiskScorer(), + scorer=RiskScorer(self._trusted_hosts()), ) submissions = [ SubmissionImage(record["id"], image_store.image_payload(record["id"])) diff --git a/src/rights_filter/server/store_search_candidates.py b/src/rights_filter/server/store_search_candidates.py index 2eebee4..ae97f22 100644 --- a/src/rights_filter/server/store_search_candidates.py +++ b/src/rights_filter/server/store_search_candidates.py @@ -718,13 +718,36 @@ class StoreSearchCandidatesMixin: return self._custom_candidate_image_fetcher(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: submission = self._get("submissions", submission_id) evidence = [ _domain_evidence_from_ui(item) 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_score = submission.get("riskScore", "") # The version the LIVE score was last produced by, as opposed to the diff --git a/tests/rights_filter/analysis/test_risk_scoring.py b/tests/rights_filter/analysis/test_risk_scoring.py index 0caafda..ce30a98 100644 --- a/tests/rights_filter/analysis/test_risk_scoring.py +++ b/tests/rights_filter/analysis/test_risk_scoring.py @@ -490,3 +490,86 @@ def test_legacy_web_row_without_a_match_type_keeps_its_original_weight(): assert result.score == 45 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 diff --git a/tests/rights_filter/server/test_sqlite_store.py b/tests/rights_filter/server/test_sqlite_store.py index 2b2e1a1..23291ba 100644 --- a/tests/rights_filter/server/test_sqlite_store.py +++ b/tests/rights_filter/server/test_sqlite_store.py @@ -8647,3 +8647,76 @@ def test_provider_names_are_not_accepted_as_publishing_hosts(): assert hostname_of("internal") == "" assert hostname_of("biz.chosun.com") == "biz.chosun.com" 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 diff --git a/web/operator-gui/index.html b/web/operator-gui/index.html index 78c5bc1..b789ae0 100644 --- a/web/operator-gui/index.html +++ b/web/operator-gui/index.html @@ -404,6 +404,7 @@ + @@ -479,6 +480,7 @@ +