feat: 한 줄 요약(headline) — 운영자의 첫 1초를 사유 원문 덤프에서 사실·규모로

코퍼스 DB 로 실 콘솔을 띄워 보니 큐의 '상위 근거' 칸이 '구글 visually s... 구글 페이지
with ...'(사유 원문 둘을 잘라 붙인 것)이었고 증거 제목도 '구글 full image match found' 로
절반 영어였다.

스코어러가 점수 계산과 별개로 집계(_Tally)해 riskBreakdown.headline 에
'웹 완전일치 사본 4곳 · 식별 가능 인물 · 이름 있는 검색 승격 2건 · 라벨 9개' 를 싣는다.
순서는 예산표, 웹은 가장 강한 뷰만 호스트 수(곳)로, 등록 일치·클리어 이력·미수행·실패·
신뢰 출처 제외까지 한 줄. 큐 행과 케이스 요약 보드의 첫 줄이 이것으로 바뀌고
headline 없는 옛 케이스만 예전 배치. formatReason 에 Vision 매치 4종·검색결과 사본·
기준 DB 유사도 한국어 매핑. 요약 보드 첫 줄은 word-break: keep-all 로 구 단위 줄바꿈.

점수·밴드 변화 없음(SCORER_VERSION 유지). 실렌더로 큐·케이스 확인.
verify.ps1: ruff clean, 663 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
changukyu 2026-09-22 12:08:00 +09:00
parent 4b74f9577a
commit e3298d149c
9 changed files with 200 additions and 14 deletions

View file

@ -1,5 +1,7 @@
# Project INDEX
- 2026-09-22 · **한 줄 요약(headline) — 운영자의 첫 1초를 사유 원문 덤프에서 사실·규모로.** 코퍼스 DB 로 실 콘솔을 띄워 보니 큐의 '상위 근거' 칸이 `구글 visually s... 구글 페이지 with ...`(사유 원문 둘을 잘라 붙인 것)이었고 증거 제목도 `구글 full image match found` 로 절반 영어였다. 스코어러가 점수 계산과 별개로 집계(`_Tally`)해 `riskBreakdown.headline` 에 `웹 완전일치 사본 4곳 · 식별 가능 인물 · 이름 있는 검색 승격 2건 · 라벨 9개` 를 싣는다 — 순서는 예산표, 웹은 가장 강한 뷰만 **호스트 수(곳)** 로, 등록 일치·클리어 이력·미수행·실패·신뢰 출처 제외까지 한 줄. 큐 행과 케이스 요약 보드의 첫 줄이 이것으로 바뀌고 옛 케이스(headline 없음)만 예전 배치. `formatReason` 에 Vision 매치 4종·검색결과 사본·기준 DB 유사도 한국어 매핑 추가. 실렌더(claude-in-chrome, 코퍼스 20장)로 큐·케이스 확인 — 점수·밴드 변화 없음(SCORER_VERSION 유지).
- 2026-09-22 · **스코어러 v6 — 외부 검색 미수행의 조용한 통과를 막는다.** 배포 판단에서 파일럿 전 유일한 차단으로 꼽은 구멍: 네이버 일일 한도 100 = 20장이라 21장째부터 검색이 `SEARCH_SKIPPED` 0점·무사유로 빠져 점수가 **낮게** 나왔다(키 만료·비상 차단도 같은 경로). 정책 거절 사유 5종 중 하루 안에서 케이스마다 달라지고 어디에도 안 보이는 하나(`usage limit reached`)만 v5 의 실패 하한 30 에 합류시키고 사유에 `<provider> 미수행 — <사유>` 를 남긴다. `disabled` 는 키 미설정 배포와 비상 차단이 같은 사유를 쓰고 둘 다 콘솔 providerState 에 보이므로 그대로 0점·무사유(하한을 걸면 키 없는 배포의 큐 전체가 medium 이 된다 — 기본 스토어 테스트가 잡아냈다); 거버넌스 스위치 셋(compliance · metadata logging · online mode)도 배포 전체에 균일해 0점·무사유. 라운드트립은 신선 점수를 재현(테스트). 다이어그램 태그 v6 재생성.
- 2026-09-22 · **스코어러 v5 — 축 예산을 총합 기준으로 재배분, 실이미지 100점 포화 20/20 → 0/20.** 첫 실측이 보인 원인은 축 예산의 **합**(245 vs 상한 100)이었고, 캡 없는 두 축만 캡하는 시뮬레이션(80/50 · 60/40)으로는 20/20 이 그대로 100 이라 축 하나 손보기로는 안 됐다. 새 예산: 등록 권리 대상 일치 80(단독 high) · 웹 존재 40(검색결과 사본 유사도·Vision 매치·CSE 페이지가 제공자 무관 **한 풀** — 같은 사실의 다른 뷰, 뷰 강도로만 40/30/10 차등) · 인물 30/10 · 이름 있는 검색 승격 15 · 라벨 10 → 등록 제외 최대 95. 외부 검색 실패는 +30/행 가산에서 medium **하한**으로(네이버 장애 하루가 웹에 있는 모든 이미지를 high 로 밀던 경로 차단). 검색결과 사본 행은 `knowledge_entry_id` 부재 + `url`/`source_evidence_ids` 로 판별(둘 다 라운드트립 생존). 격자 432: high 92→75%, 포화 80→44%(남은 포화는 전부 등록 강한 일치 조합), 웹 신호만 high 13/24→**0/24** — v2 원칙 복원. 실이미지 20장 리스코어: 100점 20→**0**, 구별 점수 1→4(75/80/92/95), 밴드는 전부 high 유지 — 전부 웹에 있는 인물 사진이라 그것이 맞는 답이고 medium/low 임계는 여전히 미검증(저위험 기대 이미지 0장). 스코어러 테스트 45(신규 10) · 다른 파일의 인물 단독 35 고정값 4곳은 상수 참조로 · 다이어그램 spec 3종 v5(+카드 문구) · `calibration-corpus.md` 전후표. 다이어그램 4종 재생성(archify 2.17.0-dev.1) — `operator-review-workflow` 는 HEAD spec 그대로 새 `desktop-readability` 검사에 걸려(viewBox 1112 > 1085, 7px 라벨 투영 5.89px) 노드 폭 132→124 로 통과시켰으나, 그만큼 확대되어 1440×900 에서 세로 13px 넘침(visual-check containment fail; HEAD 는 같은 뷰포트에서 readability fail). 검증 9/9·showcase 는 통과, 사실 게이트·매니페스트도 통과 — 잔여는 툴체인 드리프트로 기록.

View file

@ -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` 은 가중치/버킷/밴드 변경 시 반드시 올린다**. `RiskScorer(trusted_hosts)` 는 지식DB 의 활성 `trusted_host` 항목(이름·별칭이 도메인)을 받아 그 호스트에서 발견된 웹 탐지 행을 0점 처리한다 — **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다. v5/v6 축 예산표(등록 일치 80 · 웹 존재 40 한 풀 · 인물 30/10 · 검색 승격 15 · 라벨 10 · 미수행(일일 한도)·실패는 하한 30, disabled·거버넌스 스위치 미수행은 0)는 파일 상단 주석이 정본 — 검색결과 사본 행은 `knowledge_entry_id` 부재 + `url`/`source_evidence_ids` 로 웹 풀에 간다 |
| `risk_scoring.py` | `RiskScorer.score()` — 점수·밴드 산출. **`SCORER_VERSION` 은 가중치/버킷/밴드 변경 시 반드시 올린다**. `RiskScorer(trusted_hosts)` 는 지식DB 의 활성 `trusted_host` 항목(이름·별칭이 도메인)을 받아 그 호스트에서 발견된 웹 탐지 행을 0점 처리한다 — **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다. v5/v6 축 예산표(등록 일치 80 · 웹 존재 40 한 풀 · 인물 30/10 · 검색 승격 15 · 라벨 10 · 미수행(일일 한도)·실패는 하한 30, disabled·거버넌스 스위치 미수행은 0)는 파일 상단 주석이 정본 — 검색결과 사본 행은 `knowledge_entry_id` 부재 + `url`/`source_evidence_ids` 로 웹 풀에 간다. `breakdown.headline` 은 점수와 무관한 한 줄 요약(`_Tally`·`_headline`) — 큐 '상위 근거' 칸과 케이스 요약 보드 첫 줄이 이것을 쓴다 |
| `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 폐기 |

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import urlparse
@ -148,6 +149,9 @@ class RiskScorer:
# 말하든 축의 예산을 넘지 못한다.
pools: dict[str, int] = {}
excluded: dict[str, int] = {}
# 한 줄 요약(headline)용 집계. 점수에는 관여하지 않는다. 사유 목록은 신호
# 원문의 덤프라 운영자가 첫 1초에 "무엇이 나왔나"를 읽는 역할을 못 한다.
tally = _Tally()
def pool(key: str, points: int) -> None:
pools[key] = pools.get(key, 0) + points
@ -163,6 +167,7 @@ class RiskScorer:
# add risk. Surface it as a reason so the operator sees the prior
# clearance, but contribute no points.
if item.data.get("knowledge_entry_type") == "cleared_reference":
tally.cleared = True
if item.reason:
reasons.append(item.reason)
continue
@ -179,15 +184,18 @@ class RiskScorer:
reasons.append(f"신뢰 출처 {trusted} — 위험 점수에서 제외")
excluded[trusted] = excluded.get(trusted, 0) + 1
continue
pool("web_presence", _search_result_copy_points(similarity))
else:
copy_points = _search_result_copy_points(similarity)
pool("web_presence", copy_points)
tally.presence[_presence_tier(copy_points)].add(_presence_key(item))
elif similarity >= 0.9:
# A match against an operator-registered rights subject. Not a
# fact about the web but a judgement the team recorded, so it
# is the one axis that reaches high on its own.
pool(
"knowledge",
KNOWLEDGE_MATCH_STRONG_POINTS if similarity >= 0.9 else KNOWLEDGE_MATCH_WEAK_POINTS,
)
pool("knowledge", KNOWLEDGE_MATCH_STRONG_POINTS)
tally.knowledge_best = max(tally.knowledge_best, similarity)
else:
pool("knowledge", KNOWLEDGE_MATCH_WEAK_POINTS)
tally.knowledge_weak = True
reasons.append(item.reason)
elif item.source == EvidenceSource.FACE_PERSON:
# "A person is present" is one signal no matter how many rows
@ -199,6 +207,7 @@ class RiskScorer:
continue
counted_face_person = True
face_points = _face_person_points(item)
tally.face = _face_label(item)
reasons.append(item.reason)
elif item.source == EvidenceSource.WEB_DETECTION:
# Found on a host the team controls or licenses. "It is on the
@ -211,12 +220,18 @@ class RiskScorer:
reasons.append(f"신뢰 출처 {trusted} — 위험 점수에서 제외")
excluded[trusted] = excluded.get(trusted, 0) + 1
continue
points, kind = _web_detection_points(item)
if kind == _WEB_PRESENCE and points:
# Tallied per host BEFORE the dedup bucket: the bucket keeps
# one match type from scoring twice, but "found on 5 hosts"
# is exactly what the headline has to say.
tally.presence[_presence_tier(points)].add(_presence_key(item))
bucket = _web_detection_bucket(item)
if bucket and bucket in counted_web_buckets:
continue
points, kind = _web_detection_points(item)
if kind == _WEB_LABEL:
pool("web_entity", points)
tally.labels += 1
elif kind == _WEB_PRESENCE:
pool("web_presence", points)
if points:
@ -233,6 +248,7 @@ class RiskScorer:
if bucket and bucket in counted_naver_buckets:
continue
pool("naver", points)
tally.naver_hits += 1
reasons.append(item.reason)
if bucket:
counted_naver_buckets.add(bucket)
@ -261,6 +277,7 @@ class RiskScorer:
continue
if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}:
failure_seen = True
tally.failures += 1
reasons.append(item.reason)
elif _is_transient_skip(item):
# A provider that normally runs hit its daily quota before
@ -269,6 +286,7 @@ class RiskScorer:
# image of the day scored lower than the 20th for no reason
# anyone could see.
failure_seen = True
tally.skips += 1
reasons.append(f"{_skip_provider(item) or '외부 검색'} 미수행 — {item.reason}")
# Every other skip applies to every case alike while it lasts -
# "disabled" (no key in this deployment, or the emergency stop the
@ -325,11 +343,82 @@ class RiskScorer:
{"label": f"신뢰 출처 {host}", "rows": rows}
for host, rows in sorted(excluded.items())
],
"headline": _headline(tally, sum(excluded.values())),
}
return ScoreResult(score=score, band=band, reasons=_unique(reasons), breakdown=breakdown)
@dataclass
class _Tally:
"""What the headline counts. Scoring never reads it."""
knowledge_best: float = 0.0
knowledge_weak: bool = False
cleared: bool = False
presence: dict[str, set[str]] = field(default_factory=lambda: {"exact": set(), "near": set(), "weak": set()})
face: str = ""
naver_hits: int = 0
labels: int = 0
failures: int = 0
skips: int = 0
def _presence_tier(points: int) -> str:
if points >= WEB_COPY_EXACT_POINTS:
return "exact"
if points >= WEB_COPY_NEAR_POINTS:
return "near"
return "weak"
def _presence_key(item: Evidence) -> str:
# One "place" per host. A row that names no URL at all counts as its own place.
return _evidence_host(item) or str(item.data.get("url") or item.data.get("result_url") or id(item))
def _face_label(item: Evidence) -> str:
tier = str(item.data.get("tier", "")).strip()
if tier == TIER_BACKGROUND:
return "배경 인물"
return "식별 가능 인물" if tier else "인물"
def _headline(tally: _Tally, excluded_rows: int) -> str:
"""운영자가 첫 1초에 읽는 한 줄. 사유 목록의 압축이고 순서는 위 예산표를 따른다.
실 콘솔에서 큐의 '상위 근거' 칸은 `구글 visually s... 구글 페이지 with ...` 처럼
잘린 영어 조각 둘이었다 - 신호 원문은 개발자용이지 운영자용이 아니다. 여기서는
"웹 완전일치 사본 3곳 · 식별 가능 인물" 처럼 발견된 **사실과 규모**만 말한다.
"""
parts: list[str] = []
if tally.knowledge_best:
parts.append(f"등록 권리 대상 일치 {tally.knowledge_best:.2f}")
elif tally.knowledge_weak:
parts.append("등록 권리 대상 약한 일치")
if tally.cleared:
parts.append("클리어 이력 일치")
if tally.presence["exact"]:
parts.append(f"웹 완전일치 사본 {len(tally.presence['exact'])}곳")
elif tally.presence["near"]:
parts.append(f"웹 부분·페이지 일치 {len(tally.presence['near'])}곳")
elif tally.presence["weak"]:
parts.append(f"웹 유사 이미지 {len(tally.presence['weak'])}건")
if tally.face:
parts.append(tally.face)
if tally.naver_hits:
parts.append(f"이름 있는 검색 승격 {tally.naver_hits}건")
if tally.labels:
parts.append(f"라벨 {tally.labels}개")
if tally.failures:
parts.append("외부 검색 실패")
if tally.skips:
parts.append("일일 한도로 일부 미수행")
if excluded_rows:
parts.append(f"신뢰 출처 제외 {excluded_rows}행")
return " · ".join(parts) if parts else "신호 없음"
def _is_search_result_copy(item: Evidence) -> bool:
"""A FINGERPRINT row that matched a SEARCH RESULT image, not a knowledge entry.

View file

@ -1037,6 +1037,22 @@ def test_operator_gui_explains_how_the_score_was_reached():
assert "원래" in script and "상한" in script
# 100 포화는 그 자체로 알려야 한다 - 그 구간은 서로 구별되지 않는다.
assert "score-breakdown-clamped" in script
def test_operator_gui_leads_with_the_scorer_headline_instead_of_raw_reason_fragments():
# 실 코퍼스 콘솔에서 큐의 '상위 근거' 칸은 `구글 visually s... 구글 페이지 with ...`
# 였다 - 사유 원문 둘을 잘라 붙인 것. 스코어러가 주는 한 줄 요약(riskBreakdown.headline,
# "웹 완전일치 사본 3곳 · 식별 가능 인물")이 큐와 케이스 요약의 첫 줄이 되고, 요약이
# 없는 옛 케이스만 예전 배치로 떨어진다.
script = _read_app()
assert "riskBreakdown.headline" in script
assert script.count("riskBreakdown.headline") >= 2 # 큐 행 + 케이스 요약 보드
assert ".slice(0, 2)" in script # 옛 케이스 폴백은 남아 있어야 한다
# 증거 제목도 절반 영어(`구글 full image match found`)로 남지 않는다.
for korean in ("구글 동일 이미지 발견", "구글 게시 페이지 발견", "검색 결과 사본 유사도"):
assert korean in script
def test_operator_gui_completes_the_rejection_path_from_the_keyboard():
# 승인·보류는 1타인데 반려만 메모 필수라 손이 마우스로 갔다 — 법적으로 가장
# 중요한 경로가 가장 느렸다. R 로 사유를 고르면 두 키로 끝난다.

View file

@ -807,6 +807,59 @@ def test_promoted_hits_from_distinct_queries_pool_under_one_ceiling():
assert len(result.reasons) == 3
# --- headline: 운영자가 첫 1초에 읽는 한 줄 ---------------------------------------
def _headline_of(evidence: list[Evidence], trusted: frozenset[str] = frozenset()) -> str:
return RiskScorer(trusted).score(evidence).breakdown["headline"]
def test_headline_compresses_the_reasons_into_one_korean_line_in_budget_order():
# 실 콘솔의 큐 '상위 근거' 칸은 `구글 visually s... 구글 페이지 with ...` 였다.
evidence = [
_search_result_copy(1.0, "https://a.test/1.jpg"),
_search_result_copy(1.0, "https://b.test/1.jpg"),
_vision("Full match", "full", 0.9), # vision.example.test - a third place
_face(FACE_PERSON_IDENTIFIABLE_POINTS),
_promoted("q1"),
_promoted("q2"),
_label("A", 0.9),
_label("B", 0.9),
_label("C", 0.9),
]
assert _headline_of(evidence) == "웹 완전일치 사본 3곳 · 식별 가능 인물 · 이름 있는 검색 승격 2건 · 라벨 3개"
def test_headline_counts_places_not_rows():
# 같은 호스트에서 찾은 사본 3행은 1곳이다.
same_host = [_search_result_copy(1.0, f"https://a.test/{i}.jpg") for i in range(3)]
assert _headline_of(same_host) == "웹 완전일치 사본 1곳"
def test_headline_names_only_the_strongest_web_view():
assert _headline_of([_vision("Page hosts image", "page", 0.8)]) == "웹 부분·페이지 일치 1곳"
assert _headline_of([_vision("Visually similar", "visual", 0.6)]) == "웹 유사 이미지 1건"
assert _headline_of(_presence_set()) == "웹 완전일치 사본 1곳"
def test_headline_leads_with_a_registered_match_and_ends_with_the_gaps():
evidence = [
_fingerprint(0.95),
_face(FACE_PERSON_BACKGROUND_POINTS),
_failure(),
_skipped("search API usage limit reached"),
]
assert _headline_of(evidence) == "등록 권리 대상 일치 0.95 · 배경 인물 · 외부 검색 실패 · 일일 한도로 일부 미수행"
def test_headline_reports_trusted_exclusions_and_silence():
assert _headline_of([_published_on("posar.info")], frozenset({"posar.info"})) == "신뢰 출처 제외 1행"
assert _headline_of([]) == "신호 없음"
def test_a_provider_failure_is_a_review_floor_not_a_bonus():
# "확인 못 했다"는 사람이 봐야 하므로 medium 까지 올린다. 그러나 이미 그 위에
# 있는 케이스에 더하면 네이버 장애 하루가 웹에 있는 모든 이미지를 high 로

View file

@ -341,6 +341,16 @@ function formatReason(reason) {
if (text.startsWith("Google face crop web evidence: ")) {
return `얼굴 영역 웹 근거: ${formatReason(text.replace("Google face crop web evidence: ", ""))}`;
}
if (text === "Google full image match found") return "구글 동일 이미지 발견";
if (text === "Google partial image match found") return "구글 부분 일치 이미지 발견";
if (text === "Google page with matching image found") return "구글 게시 페이지 발견";
if (text === "Google visually similar image found") return "구글 유사 이미지 발견";
if (text.startsWith("Search result image similarity ")) {
return `검색 결과 사본 유사도 ${text.replace("Search result image similarity ", "")}`;
}
if (text.startsWith("Knowledge base image similarity ")) {
return `기준 DB 이미지 유사도 ${text.replace("Knowledge base image similarity ", "")}`;
}
if (text.startsWith("Web entity matched ")) return `웹 엔티티 일치: ${text.replace("Web entity matched ", "")}`;
if (text.startsWith("Google weak label ")) return `구글 약한 라벨 ${text.replace("Google weak label ", "")}`;
if (text.startsWith("Best guess label ")) return `구글 추정 라벨 ${text.replace("Best guess label ", "")}`;

View file

@ -174,14 +174,19 @@ function renderEvidenceSummary(submission) {
["네이버", count("naver")],
["인물", count("face")],
];
// 무엇이 나왔나(headline)가 첫 줄, 무엇을 할지(primaryJudgment)와 규모가 둘째 줄.
// headline 이 없는 옛 케이스는 예전 배치 그대로.
const headline = submission.riskBreakdown && submission.riskBreakdown.headline;
const lead = headline ? escapeHtml(headline) : escapeHtml(primaryJudgment);
const sub = `${headline ? `${escapeHtml(primaryJudgment)} · ` : ""}${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거`;
return `
<section class="evidence-summary-board" aria-label="판단 요약">
<div class="summary-lead">
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]} ${submission.riskScore}</span>
<div>
<strong>${escapeHtml(primaryJudgment)}</strong>
<span>${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거</span>
<strong>${lead}</strong>
<span>${sub}</span>
</div>
</div>
<div class="summary-stat-grid">

View file

@ -86,7 +86,15 @@ function renderQueue() {
.join("");
const reasonHtml = submission.reasons
// 스코어러가 준 한 줄 요약("웹 완전일치 사본 3곳 · 식별 가능 인물")이 있으면
// 그것이 상위 근거다. 사유 원문 둘을 잘라 붙이면 `구글 visually s...` 가 된다.
const headline = submission.riskBreakdown && submission.riskBreakdown.headline;
const reasonHtml = headline
? `<span title="${escapeHtml(headline)}">${escapeHtml(headline)}</span>`
: submission.reasons
.slice(0, 2)

View file

@ -1635,7 +1635,10 @@ tbody tr.selected-row,
.summary-lead strong {
display: block;
font-size: 17px;
line-height: 1.35;
letter-spacing: -0.01em;
/* 한 줄 요약은 "라벨 10개" 같은 구를 줄 사이에서 쪼개지 않는다 — 구분점(·) 단위로만 접힌다. */
word-break: keep-all;
}
.summary-lead span:not(.risk-badge) {