feat: 점수 구성 패널 — "증거 51개인데 왜 35점"에 답한다
이건 내가 만든 부채를 갚는 커밋이다. 스코어러에 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를 넣으면서 증거 개수와 점수의 간극이 그 어느 때보다 벌어졌는데, 그 이유가 화면 어디에도 없었다. 운영자가 숫자를 믿지 못하면 그 우선순위는 우선순위가 아니고, 그러면 앞의 두 기능이 오히려 불신을 만든다. ScoreResult.breakdown 을 신설했다. reasons 가 "어떤 증거가 말했는가"라면 breakdown 은 "그래서 몇 점인가"다. 지식DB 지문 매칭 80 웹 게시 (google) 60 원래 125 · 상한 60 웹 엔티티 라벨 20 원래 114 · 상한 20 신뢰 출처 posar.info 3행 제외 합계 115점이 상한 100에 걸렸습니다 — 이 구간의 케이스는 서로 구별되지 않습니다 마지막 줄이 특히 중요하다. 100 에 걸린 케이스들은 서로 구별되지 않으므로 우선순위 판단에 쓸 수 없는데, 지금까지는 그 사실이 숨어 있었다. 자리는 증거 요약과 증거 목록 사이다 - "51개 증거"를 읽은 직후, 목록을 훑기 직전이 바로 그 질문이 생기는 지점이다. 점수는 하나도 바뀌지 않는다. 계산 흐름에 기록만 붙였고 기존 스코어러 테스트 31건이 그대로 통과한다. 가중치·버킷·밴드가 불변이므로 SCORER_VERSION 은 v4 를 유지한다(설명을 추가하는 것은 스탬프 규칙의 대상이 아니다). 분해 합계가 항상 점수와 일치하는지를 테스트로 고정했다. 분해가 점수와 다른 이야기를 하면 그건 분해가 아니라 또 하나의 주장이 된다. 실렌더(Playwright 1440x900): 패널이 의도한 자리에 서고 콘솔 에러 0. 검증: ruff clean, pytest 642 passed (스코어러 5건 + 정적 1건 추가). 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
975c252201
commit
0c60369da0
12 changed files with 291 additions and 5 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
# Project INDEX
|
# 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 · **증거 일괄 표시** (`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)
|
- 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)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
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
|
||||||
|
|
@ -69,6 +70,16 @@ WEB_PRESENCE_MAX_POINTS = 60
|
||||||
SCORER_VERSION = "v4"
|
SCORER_VERSION = "v4"
|
||||||
|
|
||||||
|
|
||||||
|
# 점수 분해에 쓰는 사람이 읽는 이름. 키는 코드가, 라벨은 운영자가 본다.
|
||||||
|
_COMPONENT_LABELS = {
|
||||||
|
"fingerprint": "지식DB 지문 매칭",
|
||||||
|
"face": "인물",
|
||||||
|
"naver": "네이버 승격 히트",
|
||||||
|
"failure": "외부 검색 실패",
|
||||||
|
"web_other": "웹 탐지(기타)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class RiskScorer:
|
class RiskScorer:
|
||||||
def __init__(self, trusted_hosts: frozenset[str] | None = None) -> None:
|
def __init__(self, trusted_hosts: frozenset[str] | None = None) -> None:
|
||||||
"""trusted_hosts: publishing hosts the team owns or licenses.
|
"""trusted_hosts: publishing hosts the team owns or licenses.
|
||||||
|
|
@ -105,6 +116,9 @@ class RiskScorer:
|
||||||
counted_face_person = False
|
counted_face_person = False
|
||||||
web_entity_points = 0
|
web_entity_points = 0
|
||||||
web_presence_points: dict[str, int] = {}
|
web_presence_points: dict[str, int] = {}
|
||||||
|
# 점수 분해용 누적. 점수 계산에는 참여하지 않고 같은 흐름을 기록만 한다.
|
||||||
|
components: dict[str, int] = {}
|
||||||
|
excluded: 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"}:
|
||||||
|
|
@ -124,9 +138,11 @@ class RiskScorer:
|
||||||
similarity = _safe_float(item.data.get("similarity", 0))
|
similarity = _safe_float(item.data.get("similarity", 0))
|
||||||
if similarity >= 0.9:
|
if similarity >= 0.9:
|
||||||
score += 80
|
score += 80
|
||||||
|
components["fingerprint"] = components.get("fingerprint", 0) + 80
|
||||||
reasons.append(item.reason)
|
reasons.append(item.reason)
|
||||||
elif item.reason != "Image fingerprints generated":
|
elif item.reason != "Image fingerprints generated":
|
||||||
score += 30
|
score += 30
|
||||||
|
components["fingerprint"] = components.get("fingerprint", 0) + 30
|
||||||
reasons.append(item.reason)
|
reasons.append(item.reason)
|
||||||
elif item.source == EvidenceSource.FACE_PERSON:
|
elif item.source == EvidenceSource.FACE_PERSON:
|
||||||
# "A person is present" is one signal no matter how many rows
|
# "A person is present" is one signal no matter how many rows
|
||||||
|
|
@ -136,7 +152,9 @@ class RiskScorer:
|
||||||
if counted_face_person:
|
if counted_face_person:
|
||||||
continue
|
continue
|
||||||
counted_face_person = True
|
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)
|
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
|
# 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)
|
trusted = self._trusted_host_of(item)
|
||||||
if trusted:
|
if trusted:
|
||||||
reasons.append(f"신뢰 출처 {trusted} — 위험 점수에서 제외")
|
reasons.append(f"신뢰 출처 {trusted} — 위험 점수에서 제외")
|
||||||
|
excluded[trusted] = excluded.get(trusted, 0) + 1
|
||||||
continue
|
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:
|
||||||
|
|
@ -160,6 +179,8 @@ class RiskScorer:
|
||||||
web_presence_points[pool] = web_presence_points.get(pool, 0) + points
|
web_presence_points[pool] = web_presence_points.get(pool, 0) + points
|
||||||
else:
|
else:
|
||||||
score += points
|
score += points
|
||||||
|
if points:
|
||||||
|
components["web_other"] = components.get("web_other", 0) + points
|
||||||
if points:
|
if points:
|
||||||
reasons.append(item.reason)
|
reasons.append(item.reason)
|
||||||
if bucket:
|
if bucket:
|
||||||
|
|
@ -175,6 +196,7 @@ class RiskScorer:
|
||||||
if bucket and bucket in counted_naver_buckets:
|
if bucket and bucket in counted_naver_buckets:
|
||||||
continue
|
continue
|
||||||
score += points
|
score += points
|
||||||
|
components["naver"] = components.get("naver", 0) + points
|
||||||
reasons.append(item.reason)
|
reasons.append(item.reason)
|
||||||
if bucket:
|
if bucket:
|
||||||
counted_naver_buckets.add(bucket)
|
counted_naver_buckets.add(bucket)
|
||||||
|
|
@ -203,6 +225,7 @@ class RiskScorer:
|
||||||
continue
|
continue
|
||||||
if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}:
|
if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}:
|
||||||
score += 30
|
score += 30
|
||||||
|
components["failure"] = components.get("failure", 0) + 30
|
||||||
reasons.append(item.reason)
|
reasons.append(item.reason)
|
||||||
# SEARCH_SKIPPED / EXTERNAL_SKIPPED contribute zero points and are
|
# SEARCH_SKIPPED / EXTERNAL_SKIPPED contribute zero points and are
|
||||||
# operational status ("quota reached", "provider disabled"), not
|
# 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
|
# Every distinct row still surfaces as a reason; only the combined weight
|
||||||
# of each pool is bounded.
|
# of each pool is bounded.
|
||||||
for pooled_presence in web_presence_points.values():
|
pools: list[dict[str, Any]] = []
|
||||||
score += min(pooled_presence, WEB_PRESENCE_MAX_POINTS)
|
for pool, pooled_presence in sorted(web_presence_points.items()):
|
||||||
score += min(web_entity_points, WEB_ENTITY_MAX_POINTS)
|
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)
|
score = min(100, score)
|
||||||
if score >= 70:
|
if score >= 70:
|
||||||
band = "high"
|
band = "high"
|
||||||
|
|
@ -222,7 +268,27 @@ class RiskScorer:
|
||||||
else:
|
else:
|
||||||
band = "low"
|
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:
|
def _face_person_points(item: Evidence) -> int:
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,11 @@ class ScoreResult:
|
||||||
score: int
|
score: int
|
||||||
band: str
|
band: str
|
||||||
reasons: list[str] = field(default_factory=list)
|
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
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,7 @@ class CopyrighterStore(
|
||||||
score.band if score else "low",
|
score.band if score else "low",
|
||||||
score.reasons if score else [],
|
score.reasons if score else [],
|
||||||
self.provider_runtime.provider_payloads,
|
self.provider_runtime.provider_payloads,
|
||||||
|
score.breakdown if score else {},
|
||||||
)
|
)
|
||||||
submission["queueId"] = queue_id
|
submission["queueId"] = queue_id
|
||||||
self._put("submissions", submission["id"], submission)
|
self._put("submissions", submission["id"], submission)
|
||||||
|
|
|
||||||
|
|
@ -763,6 +763,7 @@ class StoreSearchCandidatesMixin:
|
||||||
submission["riskScore"] = score.score
|
submission["riskScore"] = score.score
|
||||||
submission["riskBand"] = score.band
|
submission["riskBand"] = score.band
|
||||||
submission["reasons"] = score.reasons or ["분석 근거 없음"]
|
submission["reasons"] = score.reasons or ["분석 근거 없음"]
|
||||||
|
submission["riskBreakdown"] = score.breakdown
|
||||||
if "initialRiskScore" not in submission:
|
if "initialRiskScore" not in submission:
|
||||||
# Rows created before the stamp existed. Their original score was
|
# Rows created before the stamp existed. Their original score was
|
||||||
# already overwritten by this same method, so it is unrecoverable —
|
# already overwritten by this same method, so it is unrecoverable —
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ def _submission_payload(
|
||||||
band: str,
|
band: str,
|
||||||
reasons: list[str],
|
reasons: list[str],
|
||||||
provider_payloads: dict[str, dict[str, Any]] | None = None,
|
provider_payloads: dict[str, dict[str, Any]] | None = None,
|
||||||
|
breakdown: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
submission_id = record["id"]
|
submission_id = record["id"]
|
||||||
title = record.get("title", submission_id)
|
title = record.get("title", submission_id)
|
||||||
|
|
@ -110,6 +111,10 @@ def _submission_payload(
|
||||||
"asset": record["asset"],
|
"asset": record["asset"],
|
||||||
"riskScore": score,
|
"riskScore": score,
|
||||||
"riskBand": band,
|
"riskBand": band,
|
||||||
|
# 점수가 어떻게 나왔는지. reasons 는 어떤 증거가 말했는지를 말하고,
|
||||||
|
# 이것은 각 종류가 얼마를 냈고 어디서 풀 상한이나 신뢰 출처 제외가
|
||||||
|
# 점수를 삼켰는지를 말한다.
|
||||||
|
"riskBreakdown": breakdown or {},
|
||||||
# The score as first computed, never touched again. _rescore_submission
|
# The score as first computed, never touched again. _rescore_submission
|
||||||
# overwrites riskScore/riskBand from 12 call sites, so without this stamp
|
# 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
|
# the stored score is always a round-tripped score and comparing it to a
|
||||||
|
|
|
||||||
|
|
@ -1020,3 +1020,20 @@ def test_operator_gui_can_mark_a_whole_evidence_group_at_once():
|
||||||
|
|
||||||
# 그룹 전체를 "오탐"으로 단정하지 않는다 - 무관이 정직한 주장이다.
|
# 그룹 전체를 "오탐"으로 단정하지 않는다 - 무관이 정직한 주장이다.
|
||||||
assert 'data-evidence-group-status="irrelevant"' in script
|
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
|
||||||
|
|
|
||||||
|
|
@ -573,3 +573,69 @@ def test_trust_does_not_touch_entity_rows():
|
||||||
result = RiskScorer(frozenset({"posar.info"})).score([_label("강아지", 0.9)])
|
result = RiskScorer(frozenset({"posar.info"})).score([_label("강아지", 0.9)])
|
||||||
|
|
||||||
assert result.score == 20
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -465,3 +465,75 @@ function renderEvidenceSearch() {
|
||||||
: `<div class="empty-state">현재 케이스에는 검색 결과가 없습니다.</div>`;
|
: `<div class="empty-state">현재 케이스에는 검색 결과가 없습니다.</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 점수 분해. reasons 는 "어떤 증거가 말했는가"를, 이건 "그래서 몇 점인가"를
|
||||||
|
// 말한다.
|
||||||
|
//
|
||||||
|
// 이 패널이 필요해진 것은 스코어러가 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를
|
||||||
|
// 갖게 되면서다. 증거 51행이 35점이 되는 이유가 화면 어디에도 없으면 운영자는
|
||||||
|
// 숫자를 믿을 수 없고, 믿지 못하는 우선순위는 우선순위가 아니다.
|
||||||
|
function setScoreBreakdown(submission) {
|
||||||
|
|
||||||
|
const box = document.getElementById("score-breakdown");
|
||||||
|
|
||||||
|
if (!box) return;
|
||||||
|
|
||||||
|
const breakdown = submission && submission.riskBreakdown;
|
||||||
|
|
||||||
|
const components = (breakdown && breakdown.components) || [];
|
||||||
|
|
||||||
|
const excluded = (breakdown && breakdown.excluded) || [];
|
||||||
|
|
||||||
|
if (!components.length && !excluded.length) {
|
||||||
|
|
||||||
|
// 이 필드가 생기기 전에 저장된 케이스. 빈 패널보다 없는 편이 낫다.
|
||||||
|
box.innerHTML = "";
|
||||||
|
|
||||||
|
box.hidden = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = components
|
||||||
|
|
||||||
|
.map((item) => {
|
||||||
|
|
||||||
|
// 풀 상한에 걸린 항목은 원래 몇 점이었는지가 핵심 정보다.
|
||||||
|
const capped = item.raw !== undefined && item.raw > item.points
|
||||||
|
|
||||||
|
? `<span class="muted">원래 ${item.raw} · 상한 ${item.cap}</span>`
|
||||||
|
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<li><span>${escapeHtml(item.label)}</span><strong>${item.points}</strong>${capped}</li>`;
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const excludedRows = excluded
|
||||||
|
|
||||||
|
.map((item) => `<li class="score-breakdown-excluded"><span>${escapeHtml(item.label)}</span><strong>${item.rows}행 제외</strong></li>`)
|
||||||
|
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const clamped = breakdown.clamped
|
||||||
|
|
||||||
|
? `<p class="score-breakdown-clamped">합계 ${breakdown.total}점이 상한 100에 걸렸습니다 — 이 구간의 케이스는 서로 구별되지 않습니다.</p>`
|
||||||
|
|
||||||
|
: "";
|
||||||
|
|
||||||
|
box.innerHTML = `
|
||||||
|
|
||||||
|
<h4>점수 구성 <strong>${breakdown.final}</strong></h4>
|
||||||
|
|
||||||
|
<ul>${rows}${excludedRows}</ul>
|
||||||
|
|
||||||
|
${clamped}
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
box.hidden = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,8 @@ function renderNoSelectedCase() {
|
||||||
activeRerunAddedIds = new Set();
|
activeRerunAddedIds = new Set();
|
||||||
document.getElementById("rerun-diff-summary").innerHTML = "";
|
document.getElementById("rerun-diff-summary").innerHTML = "";
|
||||||
document.getElementById("case-reasons").innerHTML = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
|
document.getElementById("case-reasons").innerHTML = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
|
||||||
|
|
||||||
|
setScoreBreakdown(null);
|
||||||
document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`;
|
document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`;
|
||||||
document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`;
|
document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`;
|
||||||
|
|
||||||
|
|
@ -307,6 +309,8 @@ function renderCaseReview() {
|
||||||
|
|
||||||
document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission);
|
document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission);
|
||||||
|
|
||||||
|
setScoreBreakdown(submission);
|
||||||
|
|
||||||
document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(submission);
|
document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(submission);
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="rerun-diff-summary" class="rerun-diff-summary"></div>
|
<div id="rerun-diff-summary" class="rerun-diff-summary"></div>
|
||||||
<div id="case-reasons" class="reason-list"></div>
|
<div id="case-reasons" class="reason-list"></div>
|
||||||
|
<div id="score-breakdown" class="score-breakdown" hidden></div>
|
||||||
<div id="evidence-next-actions" class="evidence-next-actions"></div>
|
<div id="evidence-next-actions" class="evidence-next-actions"></div>
|
||||||
<div id="evidence-groups" class="evidence-groups"></div>
|
<div id="evidence-groups" class="evidence-groups"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -3064,6 +3064,52 @@ tbody tr.selected-row,
|
||||||
|
|
||||||
/* 그룹 헤더의 개수와 일괄 액션. 개수 옆에 두어 "이 51건을 한 번에"가 한 시선에
|
/* 그룹 헤더의 개수와 일괄 액션. 개수 옆에 두어 "이 51건을 한 번에"가 한 시선에
|
||||||
읽히게 한다 — 행마다 누르던 노동이 여기서 없어진다. */
|
읽히게 한다 — 행마다 누르던 노동이 여기서 없어진다. */
|
||||||
|
/* 점수 구성. 증거 목록과 점수 사이의 간극을 메우는 자리라, 증거 요약 바로
|
||||||
|
아래에 둔다. */
|
||||||
|
.score-breakdown {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--rail-ink);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface, #fff);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown h4 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown li {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 0;
|
||||||
|
border-top: 1px solid var(--rail-ink-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown li span:first-child {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown-excluded strong {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown-clamped {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
.evidence-group-actions {
|
.evidence-group-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue