fix: 게시 호스트를 영속화에서 복구하고 콘솔·LLM 에 노출

P1 3번("매칭 호스트 기반 신뢰출처 구분")을 착수하며 전제를 검증했더니 틀렸다.
호스트는 "이미 저장돼 있고 점수만 안 쓰는" 게 아니라 저장 시점에 파괴되고
있었다.

  google_custom_search.py:240,296 이 실호스트(displayLink)를 data["domain"] 에
  넣는데, store_serialization.py:298 이 data["provider"] 를 우선하므로 그 값이
  API 이름으로 덮어써진다. Vision·Naver 는 호스트를 아예 안 넣고 URL 만 준다.
  결과적으로 저장된 모든 행의 domain 이 "google" 이고, LLM 프롬프트 12줄이 전부
  domain=google 이었다 — 저작권 심사에서 가장 결정적인 "어디에 떠 있나"를 모델도
  운영자도 볼 수 없었다. 같은 케이스의 payload 55행 중 41행이 url 을 갖고 있는데도.

domain 필드는 재사용할 수 없다. provider 커버리지 동기화와 CSE 쿼리 시그니처
매칭이 그 값을 키로 쓰고(store_serialization.py:195, 599) 콘솔도 provider
라벨로 렌더한다. 그래서 host 를 별도 키로 신설했다.

  _result_host(): data["domain"] 이 provider 와 다르면 그것을, 아니면 결과/페이지
  URL 에서 호스트를 파싱한다. www 제거·소문자화. 파싱 실패는 "호스트 없음"이지
  영속화 실패가 아니다.

  점수에는 영향이 없다 — RiskScorer 는 domain 도 host 도 읽지 않는다. 증거 id 도
  안 바뀐다: _evidence_id 는 도메인 객체의 data 를 해싱하고 payload 를 해싱하지
  않으므로 payload 키 추가가 id 를 움직이지 않는다.

  PROMPT_DATA_FIELDS 의 domain -> host. 화이트리스트 크기는 그대로.

  콘솔은 증거 카드 제목 줄에 monospace host-chip 을 붙인다. 기존 저장 행에는 host
  키가 없으므로 evidenceHost() 가 저장된 URL 에서 파생한다 — 292행 마이그레이션도,
  bootstrap 마다 행당 파싱도 필요 없다(렌더되는 행만 지나간다).

실렌더 검증(127.0.0.1:9512, 실 DB)에서만 드러난 레이아웃 결함 2건을 고쳤다.
코드 리딩으로는 보이지 않았다.
  1) 긴 CDN 호스트(cloudfront-ap-northeast-1.images.arcpublishing.com)가 제목 줄
     폭을 다 먹어, evidence-title 의 overflow-wrap:anywhere 와 결합해 제목이 한
     글자씩 세로로 깨졌다. 칩을 max-width 20ch 로 묶고 제목에 flex:1 1 auto 를 줘
     남는 폭을 갖게 했다.
  2) 그 수정 후에는 반대로 biz.chosun.com(14자)까지 잘렸다. 칩을 flex:0 0 auto 로
     고정했다 — 제목("구글 full image match found")은 전 행이 동일한 상용구이고
     호스트가 그 행의 실제 내용이므로, 들어갈 수 있는 호스트가 잘리는 쪽이 잘못이다.
  최종: 칩 82개 전부 유효 호스트 형태(TLD com/edu/hk/jp/kr/net/org), 가시 칩 41개 중
  잘리는 것 4개(모두 20자 초과 CDN), 콘솔 에러 0.

PROVENANCE 배점은 보류한다. 두 조건이 다 안 맞는다. (a) detect_screen_capture 의
카테고리가 geometry 와 subtitle_band 둘뿐인데 2개를 요구하므로 라벨의 실제 의미는
"geometry AND 자막"이고, 자막 없는 방송 캡처는 구조적으로 도달 불가다 — 임계를
낮추는 게 아니라 독립적인 세 번째 신호가 필요하고 그건 새 탐지기다. 한계를 실측과
함께 상수 옆에 기록했다. (b) "새 탐지 신호는 0점 라벨로 출시한 뒤 decision_stats
상관을 보고 가중치 부여"가 2026-07-25 결정이고, 판정 0건인 지금 배점하면 그 결정을
위반한다.

신뢰출처 판정 자체는 "어느 도메인이 우리 것/라이선스 계약처인가"라는 운영자
데이터가 있어야 한다. 호스트가 살아났으므로 목록만 정해지면 cleared_reference 와
같은 패턴(점수 0 + 사유 노출)으로 붙는다.

테스트 4건 추가. pytest 574 passed(착수 전 570), 린트 클린.
This commit is contained in:
changukyu 2026-08-09 08:51:12 +09:00
parent 150e729aef
commit 847f7a852d
10 changed files with 229 additions and 3 deletions

View file

@ -1,5 +1,7 @@
# Project INDEX
- 2026-08-09 · P1 2차 — **게시 호스트 복구**. P1 3번의 전제("호스트는 저장돼 있고 점수만 안 쓴다")가 틀렸다: CSE 가 실호스트를 `data["domain"]` 에 넣는데 `store_serialization.py:298` 이 `provider` 를 우선해 **저장 시점에 덮어썼고**, Vision·Naver 는 URL 만 준다. 결과적으로 LLM 프롬프트 12줄이 전부 `domain=google` 이라 "어디에 떠 있나"를 모델도 운영자도 못 봤다. `domain` 은 provider 커버리지·CSE 시그니처 매칭의 키라 재사용 불가 → **`host` 별도 키 신설**(`_result_host`: CSE displayLink 또는 URL 파싱, www 제거·소문자). 점수 무영향·`_evidence_id` 무변화(도메인 객체 해싱이라 payload 키 추가가 id 를 안 바꿈). `PROMPT_DATA_FIELDS` 의 `domain`→`host`, 콘솔 증거 카드에 monospace `host-chip`(레거시 행은 `evidenceHost()` 가 저장 URL 에서 파생 — 마이그레이션 불요). **실렌더 검증**(실 DB, 칩 82개 전부 유효 호스트, 콘솔 에러 0)에서만 드러난 결함 2건 수정: 긴 CDN 호스트가 제목을 한 글자씩 세로로 깨뜨림 → 칩 20ch 상한 + 제목 `flex:1 1 auto`, 이후 짧은 호스트까지 잘림 → 칩 `flex:0 0 auto` 고정. PROVENANCE 배점은 **보류**(카테고리가 2종뿐이라 라벨이 사실상 "geometry AND 자막"이고, "새 신호는 0점 출시 후 상관 확인" 결정과 충돌 — 한계를 `provenance.py` 상수 옆에 기록). **pytest 574 passed**(착수 전 570)
- 2026-08-09 · P1 1차 — 판별력 복구를 위한 신호 풀 도입, **`SCORER_VERSION` v1 → v2** — 포화 동인이 둘이었다: `category` 없는 raw Vision 라벨이 이름별 버킷으로 무한 누적(`WEB_ENTITY_MAX_POINTS=20`, medium 미만으로 캡)되고, full/partial/page/visual 이 **하나의 사실을 4개 상관된 뷰로** 보고하는데 합산돼 125→100 포화(`WEB_PRESENCE_MAX_POINTS=60`, high 미만으로 캡). `category`(character/celebrity/work) 엔티티는 권리 분류이므로 풀에서 제외하고 +45 유지. 원칙: **웹에 존재한다는 사실만으로는 high 가 되지 않고, 두 번째 종류의 신호가 필요하다.** 224조합 재측정 — 웹 신호만으로 high 가 되는 조합 **2/7 → 0/7**, 100점 포화 66.5%→51.3%(전체 high 비율 82.6%→76.3%은 격자가 균등샘플이라 대리값이 아님을 명기). 웹에 없는 오리지널·cleared 자산이 100/high → 20/low 로 교정. 라운드트립 버킷 보존 테스트는 캡에 가려지지 않도록 저신뢰도로 재작성. **pytest 570 passed**(착수 전 565) → [평가 리포트 6절](reviews/2026-08-08-utility-assessment.md)
- 2026-08-08 · 기능 실효성 평가(6축 조사 + 축별 적대적 반증, 에이전트 12) 후 P0 4종 수정 — 결론은 **"검색·기록 노동은 줄여주나 심사 건수는 못 줄인다"**(신호조합 224개 전수 시 high 77.7%·low 3.6%, 지배 동인은 match 스택이 아니라 **web entity 누적** — 이미지 매칭 0건이어도 entity 3개면 100/high, 웹에 없는 오리지널도 66/medium). 실수정: **폴더 정리가 판정 기록을 지우던 경로 차단**(판정 완료 가드 + `assetMissing` 1회 마킹; 실증으로 export 1행→0행 확인됨), **비상 차단 스위치 실배선**(`_external_calls_blocked` 신설 → 자동 Naver/CSE/Vision/얼굴크롭 5개 경로; 이전엔 눌러도 외부 호출 5회 동일), **생체 크롭의 보존기간 초과 생존 차단**(스냅샷 제외 + 레거시 아카이브 복구 스킵 — 복구가 mtime을 리셋해 90일을 재시작시켰음), **오프라인 설치 문서의 계약 위반 모델 정정**(0.5b→7b + ollama 바이너리·GPU 전제·거짓 disable 서술). DB 6건은 첫 커밋보다 2주 앞선 구빌드 스냅샷이라 검증 코퍼스가 아님을 확인. **pytest 565 passed**(착수 전 562), 린트 클린 → [docs/reviews/2026-08-08-utility-assessment.md](reviews/2026-08-08-utility-assessment.md)

View file

@ -137,8 +137,14 @@ purge가 mtime을 보므로 파기 대상이 복구 직후부터 다시 90일을
두 분기를 갈라 라벨만 캡했으므로 그 테스트는 개정 없이 통과한다.
2. PROVENANCE(EXIF/IPTC 권리 표기)에 배점. 단 실이미지 56장에서 증거 행 0건이었고,
방송캡처 분기는 `MIN_CAPTURE_CATEGORIES=2`에서 geometry가 1개 카테고리라 **자막 없는 방송 캡처는 구조적으로 도달 불가**다
(`provenance.py:85-88`). 배점 전에 이 분기부터 고칠 것.
3. 매칭 호스트 기반 신뢰출처 구분. 호스트 데이터는 이미 전 매칭 행에 저장돼 있고(실측 105행에서 도메인 추출 가능) 점수만 안 쓴다.
(`provenance.py:85-88`). **보류 — 조건이 둘 다 안 맞는다.**
(a) 카테고리가 `geometry`와 `subtitle_band` 둘뿐인데 2개를 요구하므로 라벨의 실제 의미는
"geometry **AND** 자막"이다. 임계를 낮추는 게 아니라 **독립적인 세 번째 신호**(채널 로고·하단 띠)가
필요하고 그건 새 탐지기다. 한계를 실측과 함께 `provenance.py` 상수 옆에 기록해 두었다.
(b) "새 탐지 신호는 0점 라벨로 출시한 뒤 `decision_stats` 상관을 보고 가중치 부여"가 2026-07-25
사용자 결정이다. 판정 0건인 지금 배점하면 그 결정을 위반한다. 순서는 **분기 수정 → 0점 출시 → 상관 → 배점**.
3. ~~매칭 호스트 기반 신뢰출처 구분~~ → **전제가 틀렸다(7절).** 호스트는 "저장돼 있는데 점수만 안 쓰는" 게
아니라 **영속화 시점에 파괴되고 있었다.** 파괴를 먼저 멈췄다.
4. 밴드 재캘리브레이션은 판정 30~50건이 쌓인 뒤. `decision_stats` 교차표는 이미 구현돼 있다.
**P2 — 운영**
@ -192,6 +198,41 @@ purge가 mtime을 보므로 파기 대상이 복구 직후부터 다시 90일을
풀 상한 60/20 은 신호의 구조적 성질에서 나온 값이지 캘리브레이션 결과가 아니다. 판정 30~50건이 쌓이면
`decision_stats` 교차표로 임계와 함께 재조정해야 한다.
## 7. P1 2차 — 게시 호스트 복구 (2026-08-09)
P1 3번의 전제("호스트는 이미 저장돼 있고 점수만 안 쓴다")는 **틀렸다.** 실제로는:
- `google_custom_search.py:240,296`이 실호스트(`displayLink`)를 `data["domain"]`에 넣는다.
- `store_serialization.py:298`이 `data["provider"]`를 우선하므로 그 호스트가 **저장 시점에 덮어써진다.**
- Vision·Naver는 호스트를 아예 안 넣는다. URL만 있다.
- 결과: 저장된 모든 행의 `domain`이 API 이름(`google`)이고, LLM 프롬프트 12줄이 전부 `domain=google`이었다.
저작권 심사에서 가장 중요한 "어디에 떠 있나"를 **모델도 운영자도 볼 수 없었다.**
`domain` 필드는 재사용할 수 없다 — provider 커버리지 동기화와 CSE 쿼리 시그니처 매칭이 그 값을 키로 쓰고
(`store_serialization.py:195, 599`), 콘솔도 provider 라벨로 렌더한다. 그래서 **`host`를 별도 키로 신설**했다:
- `_result_host()` — `data["domain"]`이 provider와 다르면 그것을, 아니면 결과/페이지 URL에서 호스트를 파싱.
`www.` 제거, 소문자화. 파싱 실패는 "호스트 없음"이지 영속화 실패가 아니다.
- 점수에는 영향 없다(`RiskScorer`는 `domain`도 `host`도 읽지 않는다). 라운드트립 불변식 유지.
- `_evidence_id`는 도메인 객체의 `data`를 해싱하고 payload를 해싱하지 않으므로 **기존 증거 id가 바뀌지 않는다.**
- `PROMPT_DATA_FIELDS`의 `domain` → `host`. 화이트리스트 크기는 그대로.
- 콘솔: 증거 카드 제목 줄에 monospace `host-chip`. 기존 저장 행에는 `host` 키가 없으므로
`evidenceHost()`가 저장된 URL에서 파생한다 — 292행 마이그레이션도, bootstrap마다 행당 파싱도 필요 없다.
실렌더 검증(127.0.0.1:9512, 실 DB): 케이스 1건에서 **호스트 칩 82개 전부 유효 호스트 형태**(TLD com/edu/hk/jp/kr/net/org),
콘솔 에러 0. 예: `biz.chosun.com`, `thumb.mt.co.kr`, `cloudfront-ap-northeast-1.images.arcpublishing.com`.
**실렌더에서만 드러난 결함 2건을 고쳤다** — 코드 리딩으로는 안 보였다:
1. 긴 CDN 호스트가 제목 줄 폭을 다 먹어 **제목이 한 글자씩 세로로 깨졌다**(`overflow-wrap: anywhere`와 결합).
→ 칩을 `max-width: 20ch`로 묶고 제목에 `flex: 1 1 auto`를 줘 남는 폭을 갖게 했다.
2. 그 수정 후에는 반대로 `biz.chosun.com`(14자)까지 잘렸다. → 칩을 `flex: 0 0 auto`로 고정.
제목("구글 full image match found")은 전 행이 동일한 상용구이고 호스트가 그 행의 실제 내용이므로,
들어갈 수 있는 호스트가 잘리는 쪽이 잘못이다. 최종: 가시 칩 41개 중 잘리는 것 4개(모두 20자 초과 CDN).
**남은 것**: 신뢰출처 판정 자체. 이건 "어느 도메인이 우리 것/라이선스 계약처인가"라는 **운영자 데이터**가
있어야 하고, 그 목록을 `.env`에 둘지 지식DB 항목으로 둘지는 운영 워크플로 결정이다. 호스트가 살아났으므로
목록만 정해지면 `cleared_reference`와 같은 패턴(점수 0 + 사유 노출)으로 바로 붙는다.
**적대적 검증에서 반증된 항목** (되돌리려는 시도 방지용 기록)
- ~~폐쇄망에서 증거 썸네일이 안 보인다~~ → 런타임 외부 호출은 허용된 설계이고 `http_app.py:510-511`에 근거 주석이 있다.

View file

@ -44,7 +44,11 @@ PROMPT_DATA_FIELDS = (
"match",
"similarity",
"page_title",
"domain",
# The publishing host, not the provider. "domain" used to sit here and it
# resolved to the API name, so every prompt line read domain=google and the
# model could not say where an image was found — the one thing a rights
# reviewer most needs from it.
"host",
"query",
"entity",
"label",

View file

@ -86,6 +86,14 @@ SUBTITLE_MIN_EDGE_DENSITY = 0.01
# (letterbox + broadcast size) is ONE category: a letterboxed 1920x1080 frame is
# one observation stated twice, and counting it twice would let a cinematic crop
# alone trigger the label.
#
# Consequence, measured 2026-08-08: only two categories exist, so requiring two
# means the label is really "geometry AND burned-in subtitles". A broadcast
# screenshot without subtitles can never be labelled, which is why 56 real images
# produced zero PROVENANCE rows. That is a coverage limit, not a bug — the
# alternative (geometry alone) was rejected above for precision. Lifting it needs
# a genuinely independent third signal (channel bug / lower-third bar), not a
# lower threshold. Until then treat capture forensics as near-zero recall.
MIN_CAPTURE_CATEGORIES = 2

View file

@ -263,6 +263,46 @@ def _face_crop_web_evidence(
)
def _result_host(evidence: Evidence) -> str:
"""Where this result is actually published, as a bare hostname.
The payload's "domain" field cannot carry it: that field identifies the
PROVIDER (provider-coverage sync and the google_custom_search query-signature
match both key off it), and it is written provider-first, which overwrote the
real host that Google custom search returns as displayLink. So the host gets
its own key.
It is the single most decision-relevant fact in a rights review — a copy on
the licensor's own site and a copy on a scraper blog are not the same finding
— and until now neither the operator's evidence card nor the LLM prompt could
see it: every row read "domain=google".
"""
provider = str(evidence.data.get("provider", "")).strip()
explicit = str(evidence.data.get("domain", "")).strip()
if explicit and explicit != provider:
# Google custom search: a bare host ("biz.chosun.com"), no scheme.
return _hostname_of(explicit)
for key in ("url", "result_url", "page_url", "source_page_url", "blogger_link", "image_url"):
host = _hostname_of(str(evidence.data.get(key, "")))
if host:
return host
return ""
def _hostname_of(value: str) -> str:
text = value.strip()
if not text:
return ""
if "://" not in text:
text = f"//{text}"
try:
host = urlparse(text).hostname or ""
except ValueError:
# A malformed URL is "no host", not a reason to fail persistence.
return ""
return host.lower().removeprefix("www.")
def _evidence_payload(submission_id: str, evidence: Evidence) -> dict[str, Any]:
source = _ui_source(evidence.source)
result_url = str(evidence.data.get("url", evidence.data.get("result_url", "")))
@ -296,6 +336,7 @@ def _evidence_payload(submission_id: str, evidence: Evidence) -> dict[str, Any]:
"querySource": str(evidence.data.get("query_source", "")),
"searchType": str(evidence.data.get("search_type", "")),
"domain": str(evidence.data.get("provider", evidence.data.get("domain", "internal"))),
"host": _result_host(evidence),
"url": result_url,
"imageUrl": image_url,
"thumbnailUrl": thumbnail_url,
@ -371,6 +412,9 @@ def _domain_evidence_from_ui(payload: dict[str, Any]) -> Evidence:
"query_source": payload.get("querySource", ""),
"search_type": payload.get("searchType", ""),
"domain": payload.get("domain", ""),
# Rows written before "host" existed fall back to deriving it from
# the stored URL, so legacy evidence is not blind to its own source.
"host": payload.get("host", "") or _hostname_of(str(payload.get("url", ""))),
"url": payload.get("url", ""),
"result_url": payload.get("url", ""),
"image_url": payload.get("imageUrl", ""),

View file

@ -769,6 +769,18 @@ def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
assert "샘플 지문" in script
def test_operator_gui_shows_the_publishing_host_on_every_evidence_row():
# "Where is this published?" is the fact an operator scans an evidence list
# for. It was unavailable: the only host-ish field on the row rendered the
# API name ("google") for every result.
script = _read_app()
styles = _read(APP_DIR / "styles.css")
assert "evidenceHost" in script
assert "host-chip" in script
assert ".host-chip" in styles
def test_operator_gui_renders_search_result_links_and_thumbnails():
script = _read_app()

View file

@ -559,6 +559,68 @@ def test_promoted_naver_evidence_scores_after_serialization_roundtrip():
assert RiskScorer().score([restored]).score > 0
def test_publishing_host_survives_serialization_for_every_provider_shape():
from rights_filter.server.store_serialization import _evidence_payload
# Google custom search is the only adapter that reports the host directly,
# and it lands in data["domain"] — which the payload writes provider-first,
# so the real host used to be overwritten with the API name.
cse = _evidence_payload(
"SUB-HOST",
Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="Custom search result",
confidence=0.7,
data={
"provider": "google_custom_search",
"domain": "biz.chosun.com",
"result_url": "https://biz.chosun.com/article/123",
},
),
)
assert cse["domain"] == "google_custom_search" # provider identity unchanged
assert cse["host"] == "biz.chosun.com"
# Vision and Naver report no host at all, only URLs.
vision = _evidence_payload(
"SUB-HOST",
Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="Full match",
confidence=0.9,
data={"provider": "google", "match": "full", "url": "https://WWW.Example.test/a/b.jpg"},
),
)
assert vision["host"] == "example.test"
internal = _evidence_payload(
"SUB-HOST",
Evidence(EvidenceSource.FINGERPRINT, "Image fingerprints generated", 1.0, {}),
)
assert internal["host"] == ""
def test_legacy_evidence_without_host_derives_it_from_the_stored_url():
from rights_filter.server.store_serialization import _domain_evidence_from_ui
# Rows written before the field existed must not be blind to their own
# source: every stored row already carries the result URL.
restored = _domain_evidence_from_ui(
{"title": "Full match", "domainSource": "web_detection", "url": "https://thumb.mt.co.kr/x.jpg"}
)
assert restored.data["host"] == "thumb.mt.co.kr"
def test_llm_prompt_carries_the_publishing_host_not_the_api_name():
from rights_filter.analysis.llm_assistance import PROMPT_DATA_FIELDS
# Every prompt line used to read "domain=google", so the model could not
# answer the question a rights reviewer most needs answered.
assert "host" in PROMPT_DATA_FIELDS
assert "domain" not in PROMPT_DATA_FIELDS
def test_web_entity_evidence_scores_after_serialization_roundtrip():
from rights_filter.analysis.risk_scoring import RiskScorer

View file

@ -585,6 +585,23 @@ function formatDomain(domain) {
}
// Evidence stored before the server persisted `host` still carries its result
// URL, so derive the hostname here rather than migrating 292+ rows or paying a
// parse per row on every bootstrap. Only rendered rows reach this.
function evidenceHost(evidence) {
if (evidence.host) return evidence.host;
for (const candidate of [evidence.url, evidence.sourcePageUrl, evidence.imageUrl]) {
if (!candidate) continue;
try {
return new URL(candidate).hostname.toLowerCase().replace(/^www\./, "");
} catch (error) {
// Not a parseable absolute URL — try the next one.
}
}
return "";
}
function formatQueueTimestamp(value) {
return value || "-";
}

View file

@ -308,6 +308,11 @@ function renderEvidenceRow(evidence) {
const queryStrategy = evidence.queryStrategy ? `<span>${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}</span>` : "";
const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
const searchType = evidence.searchType ? `<span>${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}</span>` : "";
// Where the match is published. Read before the title in a rights review: a
// copy on the licensor's own site and one on a scraper blog are not the same
// finding. Kept out of last-child position so the title keeps its wrap rule.
const hostName = evidenceHost(evidence);
const host = hostName ? `<span class="host-chip" title="게시 호스트">${escapeHtml(hostName)}</span>` : "";
const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
const preview = renderEvidencePreview(evidence);
const link = renderEvidenceLink(evidence);
@ -331,6 +336,7 @@ function renderEvidenceRow(evidence) {
<div class="evidence-title">
<span class="source-chip ${sourceClass(evidence.source)}">${escapeHtml(sourceLabels[evidence.source] || evidence.source)}</span>
${newChip}
${host}
<span>${escapeHtml(formatEvidenceTitle(evidence))}</span>
</div>
<div class="evidence-meta">

View file

@ -1326,6 +1326,29 @@ tbody tr.selected-row,
white-space: nowrap;
}
/* Publishing host. Monospace so hostnames align down a long evidence list and
near-identical domains (cdn.example.com vs cdn-example.com) separate at a
glance; quieter than the source chip because it sits next to it on every row
and two competing chips would flatten the hierarchy. */
.host-chip {
/* Bounded, then rigid. Bounded because CDN hostnames run past 45 characters
and an unbounded chip ate the whole title line, leaving the headline to
wrap one character per row. Rigid within that bound because the title beside
it is boilerplate ("구글 full image match found" on every row) while this is
the row's actual content — a host short enough to fit must never be the
thing that gets truncated. */
flex: 0 0 auto;
max-width: 20ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
color: var(--ink-soft);
}
.evidence-status-chip,
.watchlist-chip.watchlist {
border-color: var(--amber-line);
@ -1871,6 +1894,13 @@ tbody tr.selected-row,
overflow-wrap: anywhere;
}
/* The title claims the space the chips beside it do not, so `overflow-wrap:
anywhere` above stays a wrapping rule and never becomes a one-character
column. */
.evidence-title > span:last-child {
flex: 1 1 auto;
}
.evidence-meta,
.row-meta {
display: flex;