POSA_Copyrighter/docs/reviews/2026-08-08-utility-assessment.md
changukyu 847f7a852d 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), 린트 클린.
2026-08-09 08:51:12 +09:00

244 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 기능 실효성 평가 — "이 도구가 실제 업무에 도움이 되는가"
2026-08-08 · 6축 조사 + 축별 적대적 반증(에이전트 12). 모든 수치는 실코드·실DB·실모델 실행 결과다.
## 결론
**검색·기록 노동은 실제로 줄여준다. 심사할 건수는 전혀 줄여주지 못한다.**
현재 이 도구는 위험 판별기가 아니라 증거 수집·기록기다.
| 축 | 판정 |
|---|---|
| 증거 수집 자동화 | **작동** — 단, 탐지력 이득이 아니라 배치·영속화·감사 이득 |
| 위험 점수 판별력 | **미작동** — 전건 high 수렴, 우선순위 정보 0비트 |
| LLM 증거 판독 | **격리는 성공, 산출물 가치는 미검증** — 운영 DB 기준 한 번도 표시된 적 없음 |
| 콘솔 작업 경제성 | **승인/보류 루프만 빠름** — 반려·일괄·감사는 병목 |
| 운영 준비도 | **폐쇄망 반입·백업은 견고, 신원·처리량은 미구현** |
## 1. 실제로 도움이 되는 것 (반증 통과)
- **Cloud Vision 웹탐지가 신규 제출 전건에 자동 1회** 실행되고 결과가 케이스에 영구 저장 + 감사기록으로 남는다
(`integrations/cloud_vision_web_detection.py:39-98`, `jobs/batch_analyzer.py:77-84`, `sqlite_store.py:430-446`).
탐지 인덱스는 운영자가 Lens에 직접 넣는 것과 동일하므로 **이득은 탐지력이 아니라 배치·영속화·감사**다.
- **승인/보류 키보드 1타 + 자동 다음 케이스 이동** (`app-keyboard-batch.js:182-200`). 저위험 대량 승인 루프는 실제로 빠르다.
- **중복 탐지가 실증적으로 일한다** — 재저장(JPEG q90)/80%축소/50%축소/밝기+10% 변형 24케이스 중 **24건 그룹핑**,
같은 24케이스에서 SHA-256 완전일치는 **0건**. 지각 해시 레이어가 exact 해시가 못 잡는 재제출을 잡는다.
갭은 5% 크롭 6/6 미탐(해밍 2~5비트 vs 임계 0.97의 1비트 예산).
- **판정 시점 스냅샷 · 메모 없는 반려 차단(서버+클라이언트) · 대상별 내보내기 화이트리스트**
(`store_workflow.py:43-85`, `:21-37` APPLICANT 5필드 vs INTERNAL 10필드).
- **백업/복구 왕복 실증 성공** (`Connection.backup` + pre-restore 구제 사본 + 사이드카 제거).
- **폐쇄망 Python 반입 절차 완결** — 런타임 서드파티 3개 핀 고정, GUI CDN 참조 0건, Pretendard 동봉,
YuNet 가중치 실물이 문서 표의 232,589 bytes와 일치.
- **SSRF 가드 실동작**, 쿼터 원자 예약(try_reserve/release), 쿼리 시그니처 dedup으로 재분석 시 Naver/CSE 재소모 0.
- **LLM이 점수·밴드를 절대 움직이지 못하도록 격리** (`risk_scoring.py:89, 101-102`). Ollama 부재 시 읽을 수 있는
failure 행 1건으로 끝나고 점수 영향 없음(실측 2.01s / 0.19s).
## 2. 판별력 붕괴 — 이 도구가 "심사 건수"를 못 줄이는 이유
신호조합 224개 전수 계산: **high 174건(77.7%) / medium 42건(18.8%) / low 8건(3.6%)**, 126건(56.2%)이 정확히 100점.
실 DB 6건도 100×4 / 45×2, **low 0건**.
지배적 동인은 match 타입 스택이 아니라 **web entity 누적**이다 (`analysis/risk_scoring.py:159-160, 168-174`).
버킷이 entity 이름별로 분리돼 서로 dedup되지 않고 `round(40 × confidence)`씩 쌓인다. Vision은 `maxResults=10`
(`cloud_vision_web_detection.py:47`)이라 모든 이미지가 라벨 10행을 받는다. 실측:
| 입력 | 결과 |
|---|---|
| entity 2개만 (이미지 매칭 0건) | 72 / high |
| entity 3개만 (이미지 매칭 0건) | **100 / high** |
| 웹에 전혀 없는 오리지널 사진 (dog/pet/grass) | 66 / medium |
| full+partial+visual+page 각 1건 (평범한 웹 존재) | 100 / high |
| entity4 + full + partial + visual + page + 얼굴 (명백 침해) | 100 / high |
**이 누적은 사고가 아니라 테스트가 고정한 동작이다** — `test_risk_scoring.py:234-258`이
"서로 다른 엔티티 2개는 반드시 high로 쌓여야 한다"를 assert한다. 따라서 기존 테스트로는 절대 잡히지 않는다.
파생 결과:
- **큐 기본 정렬이 riskScore 내림차순**(`app-selectors.js:304`)인데 전건 100이면 정렬이 no-op다.
- **low 밴드가 사실상 도달 불가** — low 8건은 전부 "아무것도 못 찾음" 조합뿐이라 "안 봐도 된다"는 결론을 못 준다.
- **신호 우선순위 역전** — 법적으로 가장 결정적인 EXIF/IPTC 권리 표기가 0점이라
`(c) Getty Images` 단독 제출은 0점/low인데, Vision의 추측 라벨 3개는 100/high다
(`risk_scoring.py:80-88` PROVENANCE는 reason만 남기고 continue).
- **운영자 되먹임이 순증 노동** — irrelevant 표시는 버킷당 "점수 매기는 첫 미표시 행"만 밀어내므로,
실 DB 제출물 4번을 100→35로 내리려면 google 증거 51행을 **개별로** 표시해야 한다. 일괄 API가 없다(개별 `/api/evidence/{id}/status` 뿐).
- **cleared_reference가 재심사를 못 막는다** — FINGERPRINT 분기에서만 0점이라
[cleared_reference + 평범한 Vision 응답] = 100 high. 외부 호출 쿼터도 그대로 소모한다.
**정답 라벨 0건**: submissions 6건 전부 `unreviewed`, corrections 0행, `Operator decision created` 감사 이벤트 0건.
밴드 임계 70/30이 실제 판정과 한 번도 대조된 적이 없다.
## 3. 검증 코퍼스가 없다 (전제 정정)
`data/copyrighter.sqlite3`의 evidence 292행 중 288행이 **2026-05-26**, 이 리포의 첫 커밋은 2026-06-09이다.
즉 DB는 git 이력보다 2주 앞선 구빌드 스냅샷이며 6~8월 두 달치 변경이 반영돼 있지 않다:
- 저장 entity 행 40개 전부 `entity=null` → 리스코어 시 0점 (현행 코드는 저장함)
- 저장 entity confidence가 14.862 (현행 코드는 [0,1] 클램프)
- 증거 292행 전부 `summary`/`domainSource`/`faceTier`/`verified`/`groundedClaims` 키 부재
- submissions 6건 전부 `scorerVersion` 부재, `submittedEpoch=0`
**판별력 판정을 확정하려면 실이미지 30~50장을 현행 코드로 새로 돌려야 한다.** 그것이 밴드 캘리브레이션의 출발점이기도 하다.
## 4. 이번에 수정한 것 (P0)
### (1) 폴더 정리가 판정 기록을 지우던 문제 — 확정 후 수정
실증: `record_decision('3','rejected')` → export 1행 → 원본 파일 제거 → `_prune_missing_submission_files` →
submissions 0행 / evidence 0행(cascade) / **export 0행**.
`sqlite_store.py:_prune_missing_submission_files`에 판정 완료 가드를 넣었다. 판정된 케이스는 원본 파일이 없어도
보존하고 `assetMissing` 마커 + 감사 이벤트를 **1회만** 남긴다(매 리로드마다 재기록되면 500행 감사 캡을 먹는다).
미판정 케이스의 프루닝 동작은 그대로다.
### (2) 비상 차단 스위치가 장식이던 문제 — 확정 후 수정
대조실험: 스텁 어댑터로 계수 → CONTROL 5회 / `emergency_disable_external_providers()` 후 **5회로 동일**.
원인은 스위치가 DB 플래그만 쓰고 자동 보강 경로가 그 플래그를 아예 읽지 않은 것. 게이트는 수동 검색에만 있었다.
`_external_calls_blocked(provider_id)`를 신설(`store_enrichment.py`)하고 자동 외부 호출 4개 경로 전부에 배선했다:
| 경로 | provider |
|---|---|
| `_auto_naver_search` | `naver` |
| `_auto_google_custom_search` | `google_search` |
| `_rerun_google_image_search` | `google` |
| `_google_face_crop_web_detection` | `google` (생체 전송이라 상위 게이트에 의존하지 않고 독립 게이트) |
| 시드 경로 Vision 배치 (`sqlite_store.py`) | `google` → `ExternalApiPolicy(disabled=True)` |
게이트는 `enabled`가 아니라 **`operatorDisabled`** 를 본다. 전자는 연결성/헬스에도 반응하므로 운영자가 멈추라고
한 적 없는 트래픽까지 죽인다. 후자는 개별 토글과 비상 스위치만 세우는 의도적 정지이고 리로드에도 유지된다.
### (3) 생체정보(얼굴 크롭)가 보존기간을 초과 생존하던 문제 — 확정 후 수정
두 경로가 있었다: (a) 스냅샷이 `face-crops`를 통째로 담는데 프루닝은 나이가 아니라 **개수 10개** 기준이라
90일에 파기됐어야 할 크롭이 암호화 없는 zip 안에 남았다. (b) 복구는 `copyfileobj`로 새로 쓰므로 **mtime이 리셋**되고,
purge가 mtime을 보므로 파기 대상이 복구 직후부터 다시 90일을 산다.
`store_backup.py`의 `_MEDIA_SOURCES`에서 face-crops를 제외하고, 복구 시에도 `_RETENTION_BOUND_MEDIA`로
레거시 아카이브의 크롭을 건너뛴다. 크롭은 원본 이미지에서 재분석으로 재생성되므로 손실 비용이 없다.
### (4) 오프라인 설치 문서가 계약 위반 모델을 지목하던 문제
`offline-install.md`가 `qwen2.5:0.5b-instruct` 반입을 지시했다 — 같은 리포가 55.7초 + 프롬프트 형식 예시 반향으로
**출력 계약 위반** 기록을 남긴 모델이다. 문서대로 반입하면 타깃에서 요약이 조용히 죽는다.
`qwen2.5:7b`로 정정하고, ollama 바이너리 반입 누락·GPU 전제·"env 비우면 LLM 꺼진다"는 거짓 서술
(실측: `build_provider_runtime({})` → `enabled=True`, compliance 문자열까지 참으로 표시)을 함께 고쳤다.
검증: `.\verify.ps1` — ruff 클린, **pytest 565 passed**(착수 전 562, 신규 회귀 테스트 3건).
## 5. 남은 우선순위
**P1 — 판별력 복구 (이것이 "업무 도움" 여부를 가르는 축)**
1. ~~web entity 누적 캡~~ → **완료(2026-08-09, `SCORER_VERSION` v1→v2). 아래 6절 참조.**
조사 단계의 "`test_risk_scoring.py:234-258`이 현 동작을 고정한다"는 예측은 틀렸다 — 그 테스트는
`category`가 붙은 엔티티(+45 분기)를 다루고, 포화를 만든 것은 `category` 없는 raw 라벨(+`round(40*conf)` 분기)이다.
두 분기를 갈라 라벨만 캡했으므로 그 테스트는 개정 없이 통과한다.
2. PROVENANCE(EXIF/IPTC 권리 표기)에 배점. 단 실이미지 56장에서 증거 행 0건이었고,
방송캡처 분기는 `MIN_CAPTURE_CATEGORIES=2`에서 geometry가 1개 카테고리라 **자막 없는 방송 캡처는 구조적으로 도달 불가**다
(`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 — 운영**
5. 운영자 신원. 현재 감사 actor가 전부 하드코딩 리터럴(`rights.ops`/`admin.ops`/`system`)이라 "누가 반려했나"에 답할 수 없다.
6. 레거시 llm 행 마이그레이션. `summary` 키가 없는 9행이 `_provider_item_has_result`를 통과해
`only_if_missing` 경로를 막으므로 **6개 제출물 어디에도 요약이 영원히 생성되지 않는다**. 커버리지 패널은 "했다"고 보고한다.
7. LLM 프롬프트의 `domain`이 실호스트가 아니라 provider 이름(`store_serialization.py:298`이
`data['provider']`를 우선). 저작권 심사에서 가장 중요한 "어디에 떠 있나"를 모델이 원천적으로 말할 수 없다.
같은 케이스 55행 중 41행이 url을 갖고 있는데 프롬프트 12줄 전부 `domain=google`이다.
8. 자동 쿼리 3슬롯을 페이지 제목이 전부 선점(우선순위 100 vs entity 88/82)해 권리 지향 쿼리가 구조적으로 실행되지 않음
+ `_clean_title`이 말줄임표를 제거하지 않아 저장 제목 39개 중 13개(33%)가 잘린 채 검색어가 된다.
9. 판정당 bootstrap 전량 왕복 2회. 300케이스 실측 9.58MB, 유효 델타는 36KB.
POST `/decision` 응답이 이미 완결된 케이스 페이로드인데 버리고 `/api/bootstrap`을 다시 친다.
10. 반려 경로의 키보드 단절(제출 단축키 없음), 감사 로그 500건 고정 상한 + 필터·내보내기 부재.
## 6. P1 1차 — 신호 풀 도입 (2026-08-09, `SCORER_VERSION` v1 → v2)
포화의 동인은 하나가 아니라 둘이었다. 둘 다 "같은 사실을 여러 행으로 세는" 같은 형태다.
| 풀 | 상한 | 무엇을 묶나 | 근거 |
|---|---|---|---|
| `WEB_ENTITY_MAX_POINTS` | **20** (medium 30 미만) | `category` 없는 raw Vision 라벨 | Vision 은 모든 이미지에 라벨을 최대 10개 붙인다. 모집단 전체에 상수로 존재하는 신호는 판별에 기여할 수 없다 |
| `WEB_PRESENCE_MAX_POINTS` | **60** (high 70 미만) | full / partial / page / visual / url | 응답 1회가 "이 이미지가 웹에 있다"는 **하나의 사실**을 4개 상관된 뷰로 보고한다. 합산은 45+35+35+10=125 로 상한을 넘겨 웹에 조금이라도 있으면 전부 100 이 됐다 |
`category`(character/celebrity/work)가 붙은 엔티티는 풀에서 제외했다 — 그것은 raw 라벨이 아니라 권리 분류이고 엔티티별 +45 를 유지한다.
**설계 원칙**: 웹에 존재한다는 사실만으로는 high 가 되지 않는다. high 가 되려면 **두 번째 종류의 신호**
(인물, 지식DB 지문 매칭, 승격된 검색 히트)가 필요하다. 존재 자체는 침해가 아니고, 그 판단이 운영자의 몫이다.
신호조합 224개 전수 재측정 (v1 → v2):
| 케이스 | v1 | v2 |
|---|---|---|
| 웹에 없는 오리지널 사진, 라벨만 | 100 / high | **20 / low** |
| 팀이 이미 안전 확인한 자산(cleared) | 100 / high | **20 / low** |
| 인물 사진, 라벨만 | 100 / high | **55 / medium** |
| 웹 존재 광범위(4개 매치 타입) | 100 / high | **60 / medium** |
| 웹 존재 + 배경 인물 | 100 / high | 70 / high |
| 완전일치 사본 + 식별가능 인물 | 80 / high | 80 / high |
| 지식DB 매칭 + 인물 | 100 / high | 100 / high |
| 명백한 침해(전 신호) | 100 / high | 100 / high |
**다른 신호 없이 웹 신호만으로 high 가 되는 조합: 2/7 → 0/7.** 100점 포화 조합 66.5% → 51.3%.
정직한 한계: 224조합 전수의 high 비율은 82.6% → 76.3% 로 크게 안 움직인다. 이 격자는 신호 축을 **균등**
샘플링하므로(지식DB 강매칭이 1/4을 차지) 실제 큐 분포의 대리값이 아니다. 실 DB 6건도 100×4 → 95×4 로 밴드가
바뀌지 않는다 — 그 4건은 전부 "웹에 완전일치 사본 + 인물"이라 high 가 맞다. v2 가 고친 것은 **거짓 high**
(웹에 없음/라벨만/이미 안전 확인)이지, 진짜 high 를 줄인 게 아니다.
풀 상한 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`에 근거 주석이 있다.
다만 서버 egress와 브라우저 egress는 다른 문제이므로 **납품 전 타깃망에서 썸네일 1장 실로드 확인**은 남긴다.
- ~~중복 그룹핑 backfill이 미작동~~ → reload/import-folder/upload 3개 라우트 전부가 backfill을 거친다. 배지가 안 뜬 건 테스트 이미지 6장이 서로 다른 사진이라서다.
- ~~재분석이 Naver/CSE 쿼터를 재소모~~ → 시그니처 dedup으로 0콜. 실제 재소모는 Vision 쪽(dedup 없음 + 얼굴크롭 최대 3콜).
- ~~쿼터로 스킵된 쿼리가 영구 등재돼 재시도 불가(7/24 H-3)~~ → `_RETRYABLE_SKIP_DOMAIN_SOURCES`로 이미 수정됨.
- ~~판정 내보내기가 decidedAt/memo를 빈 값으로 낸다~~ → 현행 경로는 정상. 실 DB의 공백 63행은 전부 비판정 이벤트다.
- ~~TLS 부재가 취약점~~ → 기본 바인드 127.0.0.1, `0.0.0.0` 참조 0건. 미착수로 문서화된 항목이지 숨은 취약점이 아니다.