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 e93c548..d428d3a 100644
Binary files a/docs/diagrams/evidence-dataflow.html and b/docs/diagrams/evidence-dataflow.html differ
diff --git a/docs/diagrams/manifest.json b/docs/diagrams/manifest.json
index 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 ce6912d..ee3ce2e 100644
Binary files a/docs/diagrams/operator-review-workflow.html and b/docs/diagrams/operator-review-workflow.html differ
diff --git a/docs/diagrams/operator-review-workflow.workflow.json b/docs/diagrams/operator-review-workflow.workflow.json
index 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 6e5a24d..3b2b049 100644
Binary files a/docs/diagrams/system-architecture.html and b/docs/diagrams/system-architecture.html differ
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 @@
+