diff --git a/docs/INDEX.md b/docs/INDEX.md index 67027aa..cb7a42f 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # Project INDEX +- 2026-09-21 · **점수 구성 패널** — "증거 51개인데 왜 35점"에 답한다. 스코어러가 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를 갖게 되면서 증거 개수와 점수의 간극이 벌어졌는데, 그 이유가 화면 어디에도 없었다 — 제가 그 간극을 만든 장본인이라 자체 부채였다. `ScoreResult.breakdown` 을 신설해 종류별 기여·풀 상한에 삼켜진 점수·제외된 행을 기록하고, 제출물에 `riskBreakdown` 으로 영속화한 뒤 증거 요약과 증거 목록 **사이**(그 질문이 생기는 지점)에 렌더한다. 실렌더 예: `지식DB 지문 매칭 80 / 웹 게시(google) 60 (원래 125 · 상한 60) / 웹 엔티티 라벨 20 (원래 114 · 상한 20) / 신뢰 출처 posar.info 3행 제외 / 합계 115점이 상한 100에 걸렸습니다`. **점수는 하나도 안 바뀐다** — 기존 스코어러 테스트 31건이 그대로 통과하므로 `SCORER_VERSION` 은 v4 유지(가중치·버킷·밴드 불변). 분해 합계가 항상 점수와 일치하는지도 테스트로 고정했다(분해가 다른 이야기를 하면 분해가 아니라 또 하나의 주장이 된다). **ruff clean · pytest 642 passed**(스코어러 5 + 정적 1) + - 2026-09-21 · **증거 일괄 표시** (`POST /api/evidence/status-batch`) — 운영자 되먹임이 순증 노동이던 문제를 끝낸다. 8/8 평가 기준으로 실 DB 제출물 하나를 100→35 로 내리려면 google 증거 **51행을 개별 클릭**해야 했다(단건 API 만 존재). 증거 그룹 헤더의 개수 옆에 `전부 무관` 1타를 붙였다. 서버는 **한 트랜잭션 · 리스코어 1회 · 감사 이벤트 1건** — 51행이면 감사도 51건이 되어 500행 캡을 한 번의 클릭으로 먹는다. 행별 who/when/note 는 그대로 남으므로 개별 행이 설명 불가가 되지는 않는다. 남의 증거 id 가 섞이면 **아무것도 표시하지 않고 전체 실패**(절반만 반영되면 운영자가 무엇이 됐는지 알 수 없다), 상한 500(일괄 판정 200 과 단위가 다름 — 그쪽은 케이스 수, 이쪽은 한 케이스의 증거 행 수). 그룹 액션은 `irrelevant`(무관)로 보낸다 — 행별 "미사용"은 `false_positive`(오탐)에 매핑되는데, 제공자 결과 전체를 오탐이라 단정하는 건 과한 주장이다. 실렌더(Playwright 1440×900): 51행 그룹에서 1타로 `{"status":"irrelevant","evidence_ids":[51개]}` 전송, 콘솔 에러 0. **ruff clean · pytest 636 passed** - 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) diff --git a/src/rights_filter/analysis/risk_scoring.py b/src/rights_filter/analysis/risk_scoring.py index 7bd9db0..6e1db94 100644 --- a/src/rights_filter/analysis/risk_scoring.py +++ b/src/rights_filter/analysis/risk_scoring.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Any from urllib.parse import urlparse from rights_filter.analysis.face_person_detection import TIER_BACKGROUND @@ -69,6 +70,16 @@ WEB_PRESENCE_MAX_POINTS = 60 SCORER_VERSION = "v4" +# 점수 분해에 쓰는 사람이 읽는 이름. 키는 코드가, 라벨은 운영자가 본다. +_COMPONENT_LABELS = { + "fingerprint": "지식DB 지문 매칭", + "face": "인물", + "naver": "네이버 승격 히트", + "failure": "외부 검색 실패", + "web_other": "웹 탐지(기타)", +} + + class RiskScorer: def __init__(self, trusted_hosts: frozenset[str] | None = None) -> None: """trusted_hosts: publishing hosts the team owns or licenses. @@ -105,6 +116,9 @@ class RiskScorer: counted_face_person = False web_entity_points = 0 web_presence_points: dict[str, int] = {} + # 점수 분해용 누적. 점수 계산에는 참여하지 않고 같은 흐름을 기록만 한다. + components: dict[str, int] = {} + excluded: dict[str, int] = {} for item in evidence: if item.data.get("operator_status") in {"irrelevant", "false_positive"}: @@ -124,9 +138,11 @@ class RiskScorer: similarity = _safe_float(item.data.get("similarity", 0)) if similarity >= 0.9: score += 80 + components["fingerprint"] = components.get("fingerprint", 0) + 80 reasons.append(item.reason) elif item.reason != "Image fingerprints generated": score += 30 + components["fingerprint"] = components.get("fingerprint", 0) + 30 reasons.append(item.reason) elif item.source == EvidenceSource.FACE_PERSON: # "A person is present" is one signal no matter how many rows @@ -136,7 +152,9 @@ class RiskScorer: if counted_face_person: continue counted_face_person = True - score += _face_person_points(item) + face_points = _face_person_points(item) + score += face_points + components["face"] = components.get("face", 0) + face_points reasons.append(item.reason) elif item.source == EvidenceSource.WEB_DETECTION: # Found on a host the team controls or licenses. "It is on the @@ -147,6 +165,7 @@ class RiskScorer: trusted = self._trusted_host_of(item) if trusted: reasons.append(f"신뢰 출처 {trusted} — 위험 점수에서 제외") + excluded[trusted] = excluded.get(trusted, 0) + 1 continue bucket = _web_detection_bucket(item) if bucket and bucket in counted_web_buckets: @@ -160,6 +179,8 @@ class RiskScorer: web_presence_points[pool] = web_presence_points.get(pool, 0) + points else: score += points + if points: + components["web_other"] = components.get("web_other", 0) + points if points: reasons.append(item.reason) if bucket: @@ -175,6 +196,7 @@ class RiskScorer: if bucket and bucket in counted_naver_buckets: continue score += points + components["naver"] = components.get("naver", 0) + points reasons.append(item.reason) if bucket: counted_naver_buckets.add(bucket) @@ -203,6 +225,7 @@ class RiskScorer: continue if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}: score += 30 + components["failure"] = components.get("failure", 0) + 30 reasons.append(item.reason) # SEARCH_SKIPPED / EXTERNAL_SKIPPED contribute zero points and are # operational status ("quota reached", "provider disabled"), not @@ -210,10 +233,33 @@ class RiskScorer: # Every distinct row still surfaces as a reason; only the combined weight # of each pool is bounded. - 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) + pools: list[dict[str, Any]] = [] + for pool, pooled_presence in sorted(web_presence_points.items()): + capped = min(pooled_presence, WEB_PRESENCE_MAX_POINTS) + score += capped + pools.append( + { + "key": f"web_presence:{pool}", + "label": f"웹 게시 ({pool})", + "points": capped, + "raw": pooled_presence, + "cap": WEB_PRESENCE_MAX_POINTS, + } + ) + capped_entities = min(web_entity_points, WEB_ENTITY_MAX_POINTS) + score += capped_entities + if web_entity_points: + pools.append( + { + "key": "web_entity", + "label": "웹 엔티티 라벨", + "points": capped_entities, + "raw": web_entity_points, + "cap": WEB_ENTITY_MAX_POINTS, + } + ) + raw_total = score score = min(100, score) if score >= 70: band = "high" @@ -222,7 +268,27 @@ class RiskScorer: else: band = "low" - return ScoreResult(score=score, band=band, reasons=_unique(reasons)) + breakdown = { + "total": raw_total, + "final": score, + # 100 상한에 걸렸다는 사실 자체가 정보다 — 걸린 케이스들은 서로 + # 구별되지 않으므로 우선순위 판단에 쓸 수 없다. + "clamped": raw_total > score, + "components": [ + *[ + {"key": key, "label": _COMPONENT_LABELS.get(key, key), "points": points} + for key, points in sorted(components.items()) + if points + ], + *pools, + ], + "excluded": [ + {"label": f"신뢰 출처 {host}", "rows": rows} + for host, rows in sorted(excluded.items()) + ], + } + + return ScoreResult(score=score, band=band, reasons=_unique(reasons), breakdown=breakdown) def _face_person_points(item: Evidence) -> int: diff --git a/src/rights_filter/domain/records.py b/src/rights_filter/domain/records.py index 978cf62..a2908e2 100644 --- a/src/rights_filter/domain/records.py +++ b/src/rights_filter/domain/records.py @@ -96,6 +96,11 @@ class ScoreResult: score: int band: str reasons: list[str] = field(default_factory=list) + # How the number was arrived at. reasons say WHICH evidence spoke; this says + # what each kind contributed and where a pool ceiling or the trusted-host + # exclusion swallowed points. Without it a case with 51 evidence rows and a + # score of 35 is unexplainable to the operator who has to act on it. + breakdown: dict[str, Any] = field(default_factory=dict) @dataclass diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index 2f88c6d..3f3c57a 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -435,6 +435,7 @@ class CopyrighterStore( score.band if score else "low", score.reasons if score else [], self.provider_runtime.provider_payloads, + score.breakdown if score else {}, ) submission["queueId"] = queue_id self._put("submissions", submission["id"], submission) diff --git a/src/rights_filter/server/store_search_candidates.py b/src/rights_filter/server/store_search_candidates.py index ae97f22..e975d70 100644 --- a/src/rights_filter/server/store_search_candidates.py +++ b/src/rights_filter/server/store_search_candidates.py @@ -763,6 +763,7 @@ class StoreSearchCandidatesMixin: submission["riskScore"] = score.score submission["riskBand"] = score.band submission["reasons"] = score.reasons or ["분석 근거 없음"] + submission["riskBreakdown"] = score.breakdown if "initialRiskScore" not in submission: # Rows created before the stamp existed. Their original score was # already overwritten by this same method, so it is unrecoverable — diff --git a/src/rights_filter/server/store_serialization.py b/src/rights_filter/server/store_serialization.py index 4588a9c..e2777b1 100644 --- a/src/rights_filter/server/store_serialization.py +++ b/src/rights_filter/server/store_serialization.py @@ -93,6 +93,7 @@ def _submission_payload( band: str, reasons: list[str], provider_payloads: dict[str, dict[str, Any]] | None = None, + breakdown: dict[str, Any] | None = None, ) -> dict[str, Any]: submission_id = record["id"] title = record.get("title", submission_id) @@ -110,6 +111,10 @@ def _submission_payload( "asset": record["asset"], "riskScore": score, "riskBand": band, + # 점수가 어떻게 나왔는지. reasons 는 어떤 증거가 말했는지를 말하고, + # 이것은 각 종류가 얼마를 냈고 어디서 풀 상한이나 신뢰 출처 제외가 + # 점수를 삼켰는지를 말한다. + "riskBreakdown": breakdown or {}, # The score as first computed, never touched again. _rescore_submission # overwrites riskScore/riskBand from 12 call sites, so without this stamp # the stored score is always a round-tripped score and comparing it to a diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index 3ce280c..57daafd 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -1020,3 +1020,20 @@ def test_operator_gui_can_mark_a_whole_evidence_group_at_once(): # 그룹 전체를 "오탐"으로 단정하지 않는다 - 무관이 정직한 주장이다. assert 'data-evidence-group-status="irrelevant"' in script +def test_operator_gui_explains_how_the_score_was_reached(): + # 스코어러가 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를 갖게 되면서 증거 개수와 + # 점수의 간극이 벌어졌다. 증거 51행이 35점이 되는 이유가 화면에 없으면 + # 운영자는 숫자를 믿을 수 없고, 믿지 못하는 우선순위는 우선순위가 아니다. + script = _read_app() + markup = _read(INDEX) + styles = _read(STYLES) + + assert "setScoreBreakdown" in script + assert "riskBreakdown" in script + assert 'id="score-breakdown"' in markup + assert ".score-breakdown" in styles + + # 상한에 걸린 항목은 원래 몇 점이었는지가 핵심 정보다. + assert "원래" in script and "상한" in script + # 100 포화는 그 자체로 알려야 한다 - 그 구간은 서로 구별되지 않는다. + assert "score-breakdown-clamped" in script diff --git a/tests/rights_filter/analysis/test_risk_scoring.py b/tests/rights_filter/analysis/test_risk_scoring.py index ce30a98..9450acc 100644 --- a/tests/rights_filter/analysis/test_risk_scoring.py +++ b/tests/rights_filter/analysis/test_risk_scoring.py @@ -573,3 +573,69 @@ def test_trust_does_not_touch_entity_rows(): result = RiskScorer(frozenset({"posar.info"})).score([_label("강아지", 0.9)]) assert result.score == 20 + + +def test_breakdown_names_what_each_kind_contributed(): + result = RiskScorer().score( + [ + Evidence(EvidenceSource.FINGERPRINT, "지식DB 일치", 1.0, {"similarity": 0.95}), + Evidence(EvidenceSource.FACE_PERSON, "식별 가능한 얼굴", 1.0, {"tier": "identifiable"}), + ] + ) + + points = {item["key"]: item["points"] for item in result.breakdown["components"]} + assert points == {"fingerprint": 80, "face": 35} + assert sum(points.values()) == result.breakdown["total"] + + +def test_breakdown_shows_what_a_pool_ceiling_swallowed(): + # 운영자가 보는 증거는 4행인데 점수는 60이다. 왜 125가 아닌지 말해주지 않으면 + # 숫자를 믿을 수 없다. + 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), + ] + ) + + pool = next(item for item in result.breakdown["components"] if item["key"].startswith("web_presence:")) + assert pool["points"] == 60 + assert pool["raw"] == 125 + assert pool["cap"] == 60 + + +def test_breakdown_reports_the_hundred_point_ceiling(): + # 100 에 걸린 케이스들은 서로 구별되지 않는다 - 그 사실이 정보다. + result = RiskScorer().score( + [ + Evidence(EvidenceSource.FINGERPRINT, "지식DB 일치", 1.0, {"similarity": 0.95}), + Evidence(EvidenceSource.FACE_PERSON, "식별 가능한 얼굴", 1.0, {"tier": "identifiable"}), + ] + ) + + assert result.score == 100 + assert result.breakdown["total"] == 115 + assert result.breakdown["clamped"] is True + + +def test_breakdown_counts_rows_dropped_for_a_trusted_host(): + result = RiskScorer(frozenset({"posar.info"})).score( + [_published_on("posar.info"), _published_on("cdn.posar.info", "page")] + ) + + assert result.breakdown["excluded"] == [{"label": "신뢰 출처 posar.info", "rows": 2}] + assert result.breakdown["components"] == [] + + +def test_breakdown_total_always_matches_the_score_before_clamping(): + # 분해가 점수와 다른 이야기를 하면 분해가 아니라 또 하나의 주장이 된다. + for evidence in ( + [_published_on("a.test"), _label("강아지", 0.9)], + [_vision("Full match", "full", 0.9), _custom_search_page("CSE")], + [Evidence(EvidenceSource.NAVER_SEARCH, "승격", 0.8, {"promoted": True})], + ): + result = RiskScorer().score(evidence) + assert result.breakdown["final"] == result.score + assert sum(item["points"] for item in result.breakdown["components"]) == result.breakdown["total"] diff --git a/web/operator-gui/app-render-evidence.js b/web/operator-gui/app-render-evidence.js index 1339692..c96ed0d 100644 --- a/web/operator-gui/app-render-evidence.js +++ b/web/operator-gui/app-render-evidence.js @@ -465,3 +465,75 @@ function renderEvidenceSearch() { : `
합계 ${breakdown.total}점이 상한 100에 걸렸습니다 — 이 구간의 케이스는 서로 구별되지 않습니다.
` + + : ""; + + box.innerHTML = ` + +