diff --git a/docs/INDEX.md b/docs/INDEX.md index 6335702..1aa1177 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -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) diff --git a/docs/reviews/2026-08-08-utility-assessment.md b/docs/reviews/2026-08-08-utility-assessment.md index 68a221d..a568ac7 100644 --- a/docs/reviews/2026-08-08-utility-assessment.md +++ b/docs/reviews/2026-08-08-utility-assessment.md @@ -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`에 근거 주석이 있다. diff --git a/src/rights_filter/analysis/llm_assistance.py b/src/rights_filter/analysis/llm_assistance.py index 94476cd..f82762a 100644 --- a/src/rights_filter/analysis/llm_assistance.py +++ b/src/rights_filter/analysis/llm_assistance.py @@ -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", diff --git a/src/rights_filter/analysis/provenance.py b/src/rights_filter/analysis/provenance.py index e9cd5d4..c848c25 100644 --- a/src/rights_filter/analysis/provenance.py +++ b/src/rights_filter/analysis/provenance.py @@ -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 diff --git a/src/rights_filter/server/store_serialization.py b/src/rights_filter/server/store_serialization.py index 873198d..6ea4c6a 100644 --- a/src/rights_filter/server/store_serialization.py +++ b/src/rights_filter/server/store_serialization.py @@ -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", ""), diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index 9d9c2e5..99b6b8b 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -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() diff --git a/tests/rights_filter/test_review_fixes.py b/tests/rights_filter/test_review_fixes.py index 9c156d2..728d3cf 100644 --- a/tests/rights_filter/test_review_fixes.py +++ b/tests/rights_filter/test_review_fixes.py @@ -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 diff --git a/web/operator-gui/app-format.js b/web/operator-gui/app-format.js index f8e0632..e99bfe8 100644 --- a/web/operator-gui/app-format.js +++ b/web/operator-gui/app-format.js @@ -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 || "-"; } diff --git a/web/operator-gui/app-render-evidence.js b/web/operator-gui/app-render-evidence.js index a34fb43..bfabac4 100644 --- a/web/operator-gui/app-render-evidence.js +++ b/web/operator-gui/app-render-evidence.js @@ -308,6 +308,11 @@ function renderEvidenceRow(evidence) { const queryStrategy = evidence.queryStrategy ? `${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}` : ""; const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" }; const searchType = evidence.searchType ? `${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}` : ""; + // 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 ? `${escapeHtml(hostName)}` : ""; const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl); const preview = renderEvidencePreview(evidence); const link = renderEvidenceLink(evidence); @@ -331,6 +336,7 @@ function renderEvidenceRow(evidence) {
${escapeHtml(sourceLabels[evidence.source] || evidence.source)} ${newChip} + ${host} ${escapeHtml(formatEvidenceTitle(evidence))}
diff --git a/web/operator-gui/styles.css b/web/operator-gui/styles.css index fcbf9be..6f2050f 100644 --- a/web/operator-gui/styles.css +++ b/web/operator-gui/styles.css @@ -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;