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:
changukyu 2026-09-21 18:07:08 +09:00
parent 975c252201
commit 0c60369da0
12 changed files with 291 additions and 5 deletions

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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 —

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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;
}

View file

@ -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);

View file

@ -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>

View file

@ -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;