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:
parent
150e729aef
commit
847f7a852d
10 changed files with 229 additions and 3 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
# Project INDEX
|
# 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-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)
|
- 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)
|
||||||
|
|
|
||||||
|
|
@ -137,8 +137,14 @@ purge가 mtime을 보므로 파기 대상이 복구 직후부터 다시 90일을
|
||||||
두 분기를 갈라 라벨만 캡했으므로 그 테스트는 개정 없이 통과한다.
|
두 분기를 갈라 라벨만 캡했으므로 그 테스트는 개정 없이 통과한다.
|
||||||
2. PROVENANCE(EXIF/IPTC 권리 표기)에 배점. 단 실이미지 56장에서 증거 행 0건이었고,
|
2. PROVENANCE(EXIF/IPTC 권리 표기)에 배점. 단 실이미지 56장에서 증거 행 0건이었고,
|
||||||
방송캡처 분기는 `MIN_CAPTURE_CATEGORIES=2`에서 geometry가 1개 카테고리라 **자막 없는 방송 캡처는 구조적으로 도달 불가**다
|
방송캡처 분기는 `MIN_CAPTURE_CATEGORIES=2`에서 geometry가 1개 카테고리라 **자막 없는 방송 캡처는 구조적으로 도달 불가**다
|
||||||
(`provenance.py:85-88`). 배점 전에 이 분기부터 고칠 것.
|
(`provenance.py:85-88`). **보류 — 조건이 둘 다 안 맞는다.**
|
||||||
3. 매칭 호스트 기반 신뢰출처 구분. 호스트 데이터는 이미 전 매칭 행에 저장돼 있고(실측 105행에서 도메인 추출 가능) 점수만 안 쓴다.
|
(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` 교차표는 이미 구현돼 있다.
|
4. 밴드 재캘리브레이션은 판정 30~50건이 쌓인 뒤. `decision_stats` 교차표는 이미 구현돼 있다.
|
||||||
|
|
||||||
**P2 — 운영**
|
**P2 — 운영**
|
||||||
|
|
@ -192,6 +198,41 @@ purge가 mtime을 보므로 파기 대상이 복구 직후부터 다시 90일을
|
||||||
풀 상한 60/20 은 신호의 구조적 성질에서 나온 값이지 캘리브레이션 결과가 아니다. 판정 30~50건이 쌓이면
|
풀 상한 60/20 은 신호의 구조적 성질에서 나온 값이지 캘리브레이션 결과가 아니다. 판정 30~50건이 쌓이면
|
||||||
`decision_stats` 교차표로 임계와 함께 재조정해야 한다.
|
`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`에 근거 주석이 있다.
|
- ~~폐쇄망에서 증거 썸네일이 안 보인다~~ → 런타임 외부 호출은 허용된 설계이고 `http_app.py:510-511`에 근거 주석이 있다.
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,11 @@ PROMPT_DATA_FIELDS = (
|
||||||
"match",
|
"match",
|
||||||
"similarity",
|
"similarity",
|
||||||
"page_title",
|
"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",
|
"query",
|
||||||
"entity",
|
"entity",
|
||||||
"label",
|
"label",
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,14 @@ SUBTITLE_MIN_EDGE_DENSITY = 0.01
|
||||||
# (letterbox + broadcast size) is ONE category: a letterboxed 1920x1080 frame is
|
# (letterbox + broadcast size) is ONE category: a letterboxed 1920x1080 frame is
|
||||||
# one observation stated twice, and counting it twice would let a cinematic crop
|
# one observation stated twice, and counting it twice would let a cinematic crop
|
||||||
# alone trigger the label.
|
# 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
|
MIN_CAPTURE_CATEGORIES = 2
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
def _evidence_payload(submission_id: str, evidence: Evidence) -> dict[str, Any]:
|
||||||
source = _ui_source(evidence.source)
|
source = _ui_source(evidence.source)
|
||||||
result_url = str(evidence.data.get("url", evidence.data.get("result_url", "")))
|
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", "")),
|
"querySource": str(evidence.data.get("query_source", "")),
|
||||||
"searchType": str(evidence.data.get("search_type", "")),
|
"searchType": str(evidence.data.get("search_type", "")),
|
||||||
"domain": str(evidence.data.get("provider", evidence.data.get("domain", "internal"))),
|
"domain": str(evidence.data.get("provider", evidence.data.get("domain", "internal"))),
|
||||||
|
"host": _result_host(evidence),
|
||||||
"url": result_url,
|
"url": result_url,
|
||||||
"imageUrl": image_url,
|
"imageUrl": image_url,
|
||||||
"thumbnailUrl": thumbnail_url,
|
"thumbnailUrl": thumbnail_url,
|
||||||
|
|
@ -371,6 +412,9 @@ def _domain_evidence_from_ui(payload: dict[str, Any]) -> Evidence:
|
||||||
"query_source": payload.get("querySource", ""),
|
"query_source": payload.get("querySource", ""),
|
||||||
"search_type": payload.get("searchType", ""),
|
"search_type": payload.get("searchType", ""),
|
||||||
"domain": payload.get("domain", ""),
|
"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", ""),
|
"url": payload.get("url", ""),
|
||||||
"result_url": payload.get("url", ""),
|
"result_url": payload.get("url", ""),
|
||||||
"image_url": payload.get("imageUrl", ""),
|
"image_url": payload.get("imageUrl", ""),
|
||||||
|
|
|
||||||
|
|
@ -769,6 +769,18 @@ def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
|
||||||
assert "샘플 지문" in script
|
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():
|
def test_operator_gui_renders_search_result_links_and_thumbnails():
|
||||||
script = _read_app()
|
script = _read_app()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -559,6 +559,68 @@ def test_promoted_naver_evidence_scores_after_serialization_roundtrip():
|
||||||
assert RiskScorer().score([restored]).score > 0
|
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():
|
def test_web_entity_evidence_scores_after_serialization_roundtrip():
|
||||||
from rights_filter.analysis.risk_scoring import RiskScorer
|
from rights_filter.analysis.risk_scoring import RiskScorer
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
function formatQueueTimestamp(value) {
|
||||||
return value || "-";
|
return value || "-";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -308,6 +308,11 @@ function renderEvidenceRow(evidence) {
|
||||||
const queryStrategy = evidence.queryStrategy ? `<span>${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}</span>` : "";
|
const queryStrategy = evidence.queryStrategy ? `<span>${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}</span>` : "";
|
||||||
const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
|
const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
|
||||||
const searchType = evidence.searchType ? `<span>${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}</span>` : "";
|
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 hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
|
||||||
const preview = renderEvidencePreview(evidence);
|
const preview = renderEvidencePreview(evidence);
|
||||||
const link = renderEvidenceLink(evidence);
|
const link = renderEvidenceLink(evidence);
|
||||||
|
|
@ -331,6 +336,7 @@ function renderEvidenceRow(evidence) {
|
||||||
<div class="evidence-title">
|
<div class="evidence-title">
|
||||||
<span class="source-chip ${sourceClass(evidence.source)}">${escapeHtml(sourceLabels[evidence.source] || evidence.source)}</span>
|
<span class="source-chip ${sourceClass(evidence.source)}">${escapeHtml(sourceLabels[evidence.source] || evidence.source)}</span>
|
||||||
${newChip}
|
${newChip}
|
||||||
|
${host}
|
||||||
<span>${escapeHtml(formatEvidenceTitle(evidence))}</span>
|
<span>${escapeHtml(formatEvidenceTitle(evidence))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="evidence-meta">
|
<div class="evidence-meta">
|
||||||
|
|
|
||||||
|
|
@ -1326,6 +1326,29 @@ tbody tr.selected-row,
|
||||||
white-space: nowrap;
|
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,
|
.evidence-status-chip,
|
||||||
.watchlist-chip.watchlist {
|
.watchlist-chip.watchlist {
|
||||||
border-color: var(--amber-line);
|
border-color: var(--amber-line);
|
||||||
|
|
@ -1871,6 +1894,13 @@ tbody tr.selected-row,
|
||||||
overflow-wrap: anywhere;
|
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,
|
.evidence-meta,
|
||||||
.row-meta {
|
.row-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue