fix: 위험도 점수 v3 — 제공자별 풀링·버킷, 죽은 category 분기 제거

/code-review 지적 3건을 처리했고, 그 과정에서 리뷰가 놓친 라이브 버그 1건이 나왔다.

1) 제공자 간 확증이 dedup 으로 통째로 사라지던 버그 (리뷰 미지적)

   `counted_web_buckets` 가 match 타입만으로 키를 만들었다. CSE 웹 결과는
   google_custom_search.py:301 에서 match="page" 를 달고 오는데, Vision 이
   먼저 match="page" 행을 보고하면 같은 "page" 버킷이라 CSE 히트가 점수도
   사유도 없이 버려졌다 — 운영자는 두 번째 제공자가 이미지를 찾았다는 사실
   자체를 볼 수 없었다. 버킷을 (provider, match) 로 쪼갰다.

2) WEB_PRESENCE 풀을 제공자별로 분리

   풀이 교정하려던 상관은 한 응답 안에 있다. Vision 의 "full copy" 와
   "hosting page" 는 한 사실의 두 시각이지만, CSE 가 독립적으로 찾은 게시
   페이지는 다른 사실이다. 한 풀에 묶으니 주석 자신이 "high 에 도달해야
   한다"고 말하는 교차 확증이 60/medium 에 갇혔다.

   Vision 자기 상관 뷰 4개: 125 -> 60/medium (의도대로 유지)
   Vision full + CSE page: 45+35 = 80/high (이전엔 45/medium — CSE 가 dedup)

3) category 탈출구는 되살리지 않고 제거

   `category in {character, celebrity, work}` 분기는 writer 가 없다. src 전체에
   그 키를 쓰는 곳이 없고(Vision 엔티티 행은 {entity, entity_id, match,
   provider}), MID -> 카테고리 해석은 폐쇄망에서 불가능한 지식그래프 조회다.
   즉 "유명인은 상한 제외"라고 읽히는 주석이 실제로는 도달 불가 경로를
   문서화하고 있었다. 분기를 지우고, 미등록 유명인이 라벨만으로 high 가 안
   되는 것은 의도된 미탐 트레이드임을 명시했다. 실존하는 승격 경로는
   지식DB/주의목록(FINGERPRINT, 풀링 대상 아님)이며 테스트로 고정했다.

4) match 없는 레거시 행의 +45 는 보존

   현재 생산자는 모두 match 를 채우므로 url 폴백은 레거시 전용이다. 라운드
   트립이 result_url -> url 별칭을 만들기 때문에 이 분기를 지우면 과거 행의
   점수가 바뀐다 — 주석으로 레거시 전용임을 못박고 가중치는 유지했다.

SCORER_VERSION v2 -> v3. v2 주석이 WEB_ENTITY_MAX_POINTS 만 적고 두 변경 중
영향이 더 큰 WEB_PRESENCE_MAX_POINTS 를 빠뜨렸던 것도 함께 보강했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
changukyu 2026-09-18 23:53:02 +09:00
parent 847f7a852d
commit b0aa4c5bcc
2 changed files with 191 additions and 61 deletions

View file

@ -16,9 +16,20 @@ FACE_PERSON_BACKGROUND_POINTS = 10
# band was unreachable. Capped below the medium threshold (30), labels can # band was unreachable. Capped below the medium threshold (30), labels can
# colour a case but never band it on their own. # colour a case but never band it on their own.
# #
# Entities carrying a category (character / celebrity / work) are deliberately # The cap applies to EVERY entity, with no escape hatch, because the adapter
# NOT capped: that classification is a rights judgement rather than a raw label, # hands us nothing that separates "Dog" from a celebrity's name: Vision's
# and it keeps its own +45 per distinct entity. # 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 WEB_ENTITY_MAX_POINTS = 20
# Ceiling on the combined weight of web-presence match types. One Vision # Ceiling on the combined weight of web-presence match types. One Vision
@ -31,14 +42,26 @@ WEB_ENTITY_MAX_POINTS = 20
# many ways it was seen, but it takes a SECOND kind of signal — a portrait, a # 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. # knowledge-base fingerprint match, a promoted search hit — to reach high.
# Existence on the web is not itself infringement; that is the operator's call. # Existence on the web is not itself infringement; that is the operator's call.
#
# 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
# two ways; Google custom search independently returning a published page is a
# different finding, and pooling the two together capped genuine cross-provider
# corroboration - the very second signal the paragraph above says should reach
# high - at 60/medium.
WEB_PRESENCE_MAX_POINTS = 60 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)
SCORER_VERSION = "v2" # and web-presence match types pooled under WEB_PRESENCE_MAX_POINTS.
# v3: the presence pool and the match-type dedup bucket are both keyed by
# 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"
class RiskScorer: class RiskScorer:
@ -49,7 +72,7 @@ class RiskScorer:
counted_naver_buckets: set[str] = set() counted_naver_buckets: set[str] = set()
counted_face_person = False counted_face_person = False
web_entity_points = 0 web_entity_points = 0
web_presence_points = 0 web_presence_points: dict[str, int] = {}
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"}:
@ -92,7 +115,8 @@ class RiskScorer:
if kind == _WEB_LABEL: if kind == _WEB_LABEL:
web_entity_points += points web_entity_points += points
elif kind == _WEB_PRESENCE: elif kind == _WEB_PRESENCE:
web_presence_points += points pool = _web_presence_pool(item)
web_presence_points[pool] = web_presence_points.get(pool, 0) + points
else: else:
score += points score += points
if points: if points:
@ -145,7 +169,8 @@ class RiskScorer:
# 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 pool is bounded.
score += min(web_presence_points, WEB_PRESENCE_MAX_POINTS) for pooled_presence in web_presence_points.values():
score += min(pooled_presence, WEB_PRESENCE_MAX_POINTS)
score += min(web_entity_points, WEB_ENTITY_MAX_POINTS) score += min(web_entity_points, WEB_ENTITY_MAX_POINTS)
score = min(100, score) score = min(100, score)
@ -206,19 +231,34 @@ def _web_detection_points(item: Evidence) -> tuple[int, str]:
return 35, _WEB_PRESENCE return 35, _WEB_PRESENCE
if item.data.get("match") == "visual": if item.data.get("match") == "visual":
return 10, _WEB_PRESENCE return 10, _WEB_PRESENCE
if item.data.get("url"): # 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
# keeps the original weight rather than being rescored against a rule it
# predates - and it must stay below the `match` checks above, because the
# persistence round-trip aliases result_url -> url on every row.
if not item.data.get("match") and item.data.get("url"):
return 45, _WEB_PRESENCE return 45, _WEB_PRESENCE
if item.data.get("category") in {"character", "celebrity", "work"}:
return 45, _WEB_STANDALONE
if item.data.get("entity"): if item.data.get("entity"):
return round(40 * item.confidence), _WEB_LABEL return round(40 * item.confidence), _WEB_LABEL
return 0, _WEB_STANDALONE return 0, _WEB_STANDALONE
def _web_presence_pool(item: Evidence) -> str:
# Rows restored from persistence lose `provider` but keep `domain`, which
# held the provider name; an unattributable row pools on its own rather than
# joining someone else's response.
provider = str(item.data.get("provider", "")).strip()
if not provider:
provider = str(item.data.get("domain", "")).strip()
return provider or "unattributed"
def _web_detection_bucket(item: Evidence) -> str: def _web_detection_bucket(item: Evidence) -> str:
match = str(item.data.get("match", "")) match = str(item.data.get("match", ""))
if match in {"full", "partial", "page", "visual"}: if match in {"full", "partial", "page", "visual"}:
return match # Scoped by provider: two providers each reporting a hosting page are two
# findings. Unscoped, the second one deduped away silently.
return f"{_web_presence_pool(item)}:{match}"
# Distinct named entities are independent risk signals (e.g. two different # Distinct named entities are independent risk signals (e.g. two different
# celebrities/characters in one image), so they must not share one "entity" # celebrities/characters in one image), so they must not share one "entity"
# bucket and dedup each other away. Key the bucket by the entity name; only # bucket and dedup each other away. Key the bucket by the entity name; only
@ -227,7 +267,7 @@ def _web_detection_bucket(item: Evidence) -> str:
if match == "entity" or entity: if match == "entity" or entity:
return f"entity:{entity.casefold()}" if entity else "entity" return f"entity:{entity.casefold()}" if entity else "entity"
if item.data.get("url"): if item.data.get("url"):
return "url" return f"{_web_presence_pool(item)}:url"
return "" return ""

View file

@ -45,15 +45,19 @@ def test_operator_false_positive_evidence_is_not_scored():
assert result.reasons == [] assert result.reasons == []
def test_character_web_evidence_can_create_high_risk_for_ai_or_fanart(): def test_known_rights_subject_reaches_high_through_the_knowledge_base_not_labels():
scorer = RiskScorer() # A "character" or "celebrity" Vision entity cannot band a case by itself,
result = scorer.score( # because the adapter gives us nothing that separates it from "Dog" - see
[ # WEB_ENTITY_MAX_POINTS. The route to high for a subject we actually know
# about is a knowledge-base fingerprint match, which is not pooled. Both
# halves are asserted together so a later cap change cannot quietly remove
# the only remaining route out of the label pool.
labels_and_a_hosted_copy = [
Evidence( Evidence(
source=EvidenceSource.WEB_DETECTION, source=EvidenceSource.WEB_DETECTION,
reason="Web entity matched character", reason="Web entity matched character",
confidence=0.94, confidence=0.94,
data={"entity": "유명 웹툰 캐릭터", "category": "character"}, data={"entity": "유명 웹툰 캐릭터", "match": "entity"},
), ),
Evidence( Evidence(
source=EvidenceSource.WEB_DETECTION, source=EvidenceSource.WEB_DETECTION,
@ -62,11 +66,24 @@ def test_character_web_evidence_can_create_high_risk_for_ai_or_fanart():
data={"url": "https://example.com/official-character"}, data={"url": "https://example.com/official-character"},
), ),
] ]
)
assert result.score >= 70 without_knowledge = RiskScorer().score(labels_and_a_hosted_copy)
assert result.band == "high" assert without_knowledge.band != "high"
assert "Web entity matched character" in result.reasons assert "Web entity matched character" in without_knowledge.reasons
with_knowledge = RiskScorer().score(
[
*labels_and_a_hosted_copy,
Evidence(
source=EvidenceSource.FINGERPRINT,
reason="지식DB 등록 이미지와 일치",
confidence=1.0,
data={"similarity": 0.95},
),
]
)
assert with_knowledge.band == "high"
assert "지식DB 등록 이미지와 일치" in with_knowledge.reasons
def test_failure_reason_does_not_lower_existing_high_risk_score(): def test_failure_reason_does_not_lower_existing_high_risk_score():
@ -234,31 +251,17 @@ def test_score_is_clamped_to_100():
assert result.band == "high" assert result.band == "high"
def test_two_distinct_web_entities_stack_into_high_risk(): def test_two_distinct_web_entities_are_two_signals_even_though_pooled():
# Two different named entities in one image are independent signals and must # Two different named entities in one image are independent signals and must
# not dedup each other away. Regression: both collapsed into one "entity" # not dedup each other away. Regression: both collapsed into one "entity"
# bucket, dropping the second +45 and under-scoring a high image to medium. # bucket, dropping the second entirely. The pool caps their combined weight
result = RiskScorer().score( # (they are still bare labels), but each has to surface as its own reason -
[ # that is the only place the operator sees there were two.
Evidence( result = RiskScorer().score([_label("캐릭터 A", 0.94), _label("유명인 B", 0.94)])
source=EvidenceSource.WEB_DETECTION,
reason="Web entity matched character A",
confidence=0.94,
data={"entity": "캐릭터 A", "category": "character"},
),
Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="Web entity matched celebrity B",
confidence=0.94,
data={"entity": "유명인 B", "category": "celebrity"},
),
]
)
assert result.score >= 70 assert result.score == 20
assert result.band == "high" assert "Web entity matched 캐릭터 A" in result.reasons
assert "Web entity matched character A" in result.reasons assert "Web entity matched 유명인 B" in result.reasons
assert "Web entity matched celebrity B" in result.reasons
def test_same_web_entity_repeated_does_not_double_count(): def test_same_web_entity_repeated_does_not_double_count():
@ -270,19 +273,19 @@ def test_same_web_entity_repeated_does_not_double_count():
source=EvidenceSource.WEB_DETECTION, source=EvidenceSource.WEB_DETECTION,
reason="Web entity matched character A", reason="Web entity matched character A",
confidence=0.94, confidence=0.94,
data={"entity": "캐릭터 A", "category": "character"}, data={"entity": "캐릭터 A", "match": "entity"},
), ),
Evidence( Evidence(
source=EvidenceSource.WEB_DETECTION, source=EvidenceSource.WEB_DETECTION,
reason="Web entity matched character A (again)", reason="Web entity matched character A (again)",
confidence=0.94, confidence=0.94,
data={"entity": "캐릭터 A", "category": "character"}, data={"entity": "캐릭터 A", "match": "entity"},
), ),
] ]
) )
assert result.score == 45 assert result.score == 20
assert result.band == "medium" assert result.reasons == ["Web entity matched character A"]
def _label(name: str, confidence: float) -> Evidence: def _label(name: str, confidence: float) -> Evidence:
@ -330,10 +333,14 @@ def test_original_photo_found_nowhere_on_the_web_stays_low():
assert result.band == "low" assert result.band == "low"
def test_classified_entities_are_not_capped_like_bare_labels(): def test_a_category_key_does_not_buy_an_entity_out_of_the_pool():
# character / celebrity / work is a rights classification, not a raw label, # Guards the removal of an escape hatch that shipped dead: the old branch
# so it keeps its own +45 per distinct entity and is excluded from the pool. # read a `category` key nothing in src/ ever wrote (Vision entity rows are
result = RiskScorer().score( # {entity, entity_id, match, provider}), so it read as though celebrities
# were safe from the cap while no real image could reach it. If a writer is
# ever added it needs its own scoring decision, and this test to change with
# it - not a branch that silently does nothing.
categorized = RiskScorer().score(
[ [
Evidence( Evidence(
source=EvidenceSource.WEB_DETECTION, source=EvidenceSource.WEB_DETECTION,
@ -344,8 +351,9 @@ def test_classified_entities_are_not_capped_like_bare_labels():
_label("Portrait", 0.9), _label("Portrait", 0.9),
] ]
) )
bare = RiskScorer().score([_label("유명인 B", 0.94), _label("Portrait", 0.9)])
assert result.score == 45 + 20 assert categorized.score == bare.score == 20
def _presence_set() -> list[Evidence]: def _presence_set() -> list[Evidence]:
@ -400,3 +408,85 @@ def test_search_skipped_reason_is_not_listed_as_a_risk_reason():
assert result.score == 0 assert result.score == 0
assert result.reasons == [] assert result.reasons == []
def _vision(reason: str, match: str, confidence: float) -> Evidence:
return Evidence(
source=EvidenceSource.WEB_DETECTION,
reason=reason,
confidence=confidence,
data={"match": match, "provider": "google", "url": "https://vision.example.test/a.jpg"},
)
def _custom_search_page(reason: str) -> Evidence:
# What integrations/google_custom_search.py:_map_web_response actually emits.
return Evidence(
source=EvidenceSource.WEB_DETECTION,
reason=reason,
confidence=0.35,
data={
"match": "page",
"provider": "google_custom_search",
"search_type": "web",
"result_url": "https://blog.example.test/post/1",
},
)
def test_one_providers_correlated_match_types_stay_pooled():
# The pool exists for exactly this: one Vision response reporting one fact
# ("this image is on the web") through four correlated views.
result = RiskScorer().score(
[
_vision("Full match", "full", 0.9),
_vision("Partial match", "partial", 0.8),
_vision("Page hosts image", "page", 0.8),
_vision("Visually similar", "visual", 0.6),
]
)
assert result.score == 60
assert result.band == "medium"
def test_two_providers_each_finding_a_published_copy_reaches_high():
# Two independent providers each locating a published copy is the corroborated
# web presence the pool's own rationale says should reach high. Pooled
# together with Vision's correlated views it capped at 60/medium instead.
result = RiskScorer().score(
[_vision("Full match", "full", 0.9), _custom_search_page("Google custom web search result found")]
)
assert result.score == 45 + 35
assert result.band == "high"
def test_a_custom_search_page_hit_is_not_deduped_by_a_vision_page_row():
# Regression: the dedup bucket was the bare match type, so a Google custom
# search page hit landed in the same "page" bucket Vision had already filled
# and was dropped whole - no points, and no reason line telling the operator
# a second provider had found the image.
result = RiskScorer().score(
[_vision("Page hosts image", "page", 0.8), _custom_search_page("Google custom web search result found")]
)
assert "Google custom web search result found" in result.reasons
assert result.score == 35 + 35
def test_legacy_web_row_without_a_match_type_keeps_its_original_weight():
# Rows written before every producer set `match`. Rescoring them against the
# current rules would rewrite history; the round-trip has to reproduce the
# score they were created with.
legacy = Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="Matching image URL found",
confidence=0.5,
data={"url": "https://legacy.example.test/a.jpg"},
)
result = RiskScorer().score([legacy])
assert result.score == 45
assert result.band == "medium"