From e38dad83c938018d0f82a53c1d0f0d17522311b3 Mon Sep 17 00:00:00 2001 From: changukyu Date: Fri, 18 Sep 2026 23:54:00 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A6=9D=EA=B1=B0=20=ED=96=89=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=C2=B7=ED=98=B8=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=85=B8=EC=B6=9C=C2=B7=EB=B3=B4=EC=A1=B4=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=84=A4=EB=AA=85,=20=ED=94=84?= =?UTF-8?q?=EB=A1=AC=ED=94=84=ED=8A=B8=20=EC=8A=A4=ED=83=AC=ED=94=84=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 긴 호스트가 제목을 세로 한 줄짜리 컬럼으로 만들었다 .evidence-title 은 flex-wrap 없는 flex 행이고 source-chip·host-chip 둘 다 축소를 거부한다(flex: 0 0 auto). 그래서 제목에 남는 폭이 수십 px 이 되고 overflow-wrap: anywhere 가 그걸 한 단어씩 세로로 쪼갰다. 컨테이너에 flex-wrap, 제목에 min-width: 14ch 를 줘서 제목이 자기 줄로 내려간다. 실렌더 측정 (같은 DOM 에 수정 전 CSS 런타임 주입, Playwright): 360px 제목 51x95px (5줄) -> 246x19px (1줄), 행 높이 95 -> 50 1280px 제목 9x380px (~20줄) -> 205x19px, 행 높이 380 -> 50 주석은 데스크톱에서는 해결됐다고 적혀 있었지만, 45자 CDN 호스트로는 1280px 에서도 재현된다 — 폰 전용 문제가 아니었다. 2) 20ch 로 잘린 호스트를 읽을 방법이 없었다 칩 title 이 "게시 호스트" 고정 문구여서, 20자 접두어가 같은 두 CDN 호스트를 링크를 열지 않고 구분할 수 없었다 — 모노스페이스를 고른 이유가 그 판별이다. 툴팁에 실제 호스트를 싣는다. 3) 클라이언트 호스트 폴백이 서버보다 짧았다 _result_host 는 (url, result_url, page_url, source_page_url, blogger_link, image_url) 를 순회하는데 evidenceHost 는 3개만 봤다. host 키가 생기기 전에 저장된 네이버 블로그 행이 bloggerLink 만 가진 경우 칩이 조용히 사라졌다 — "레거시 증거가 자기 출처에 눈멀지 않게" 라는 폴백의 목적과 반대다. 4) 보존된 케이스가 설명 없는 깨진 이미지였다 assetMissing 을 읽는 소비자가 GUI·API·내보내기 어디에도 없었다. 판정은 DB 에 남지만 콘솔에서는 "판정 후 파일 아카이브" 와 "업로드 손상" 이 구분되지 않았다. 큐 썸네일과 케이스 이미지에 표시 + 파생물 노트에 사유를 붙인다. 5) PROMPT_VERSION 갱신 (ko-v3-structured -> ko-v4-structured-host) 847f7a8 이 PROMPT_DATA_FIELDS 의 domain -> host 로 모든 증거 줄의 내용을 바꿨는데 스탬프는 그대로여서, 한 버전이 서로 다른 두 프롬프트를 가리켰다. 화이트리스트가 다른 모듈에 있어도 프롬프트의 일부라는 점을 llm_prompts 독스트링의 규칙에 명시했다. 렌더된 줄이 host= 를 담는지 보는 행위 테스트도 추가했다(튜플 멤버십 검사로는 안 잡힌다). CLAUDE.md 요구대로 실모델 프로브(qwen2.5:7b, 로컬 ollama): grounded 3 / dropped 0 / verified True, 형식 예시 반향 없음, 인용 라벨 정확(E1=full match). 다만 모델은 host 를 문장에 쓰지 않고 page_title 로 서술했다 — 호스트는 운영자 칩에는 닿지만 서술에는 아직 반영되지 않는다(SYSTEM_PROMPT 과제). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V --- src/rights_filter/analysis/llm_prompts.py | 12 ++++++-- tests/operator_gui/test_static_workbench.py | 23 +++++++++++++++ .../analysis/test_llm_assistance.py | 28 +++++++++++++++++++ web/operator-gui/app-format.js | 12 +++++++- web/operator-gui/app-render-evidence.js | 8 +++++- web/operator-gui/app-render-queue.js | 21 ++++++++++++-- web/operator-gui/styles.css | 21 +++++++++++++- 7 files changed, 116 insertions(+), 9 deletions(-) diff --git a/src/rights_filter/analysis/llm_prompts.py b/src/rights_filter/analysis/llm_prompts.py index b2206b6..0d7dd5c 100644 --- a/src/rights_filter/analysis/llm_prompts.py +++ b/src/rights_filter/analysis/llm_prompts.py @@ -5,14 +5,20 @@ the evidence it produced. Without a version on the row, summaries written before and after a prompt (or model) change are indistinguishable in the database, and "did the new prompt help?" becomes unanswerable after the fact. -Bump PROMPT_VERSION whenever SYSTEM_PROMPT changes in a way that could alter -output — wording, format contract, or language. +Bump PROMPT_VERSION whenever the MODEL INPUT changes in a way that could alter +output — SYSTEM_PROMPT wording, the format contract, the language, or the +PROMPT_DATA_FIELDS whitelist in llm_assistance.py. The whitelist is part of the +prompt even though it lives in another module: changing `domain` to `host` there +rewrote every evidence line the model reads, and leaving the stamp alone made +two different prompts share one version. """ from __future__ import annotations PROMPT_ID = "operator-review-note-ko" -PROMPT_VERSION = "ko-v3-structured" +# ko-v4: evidence lines carry host= where ko-v3 carried +# domain=. +PROMPT_VERSION = "ko-v4-structured-host" # Marks rows written before prompt versioning existed. Never assume the text # below produced them. diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index 99b6b8b..ebe654c 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -779,6 +779,15 @@ def test_operator_gui_shows_the_publishing_host_on_every_evidence_row(): assert "evidenceHost" in script assert "host-chip" in script assert ".host-chip" in styles + # The chip is capped at 20ch, so the full host has to be legible somewhere: + # two CDN hosts sharing a 20-character prefix are identical on screen. + assert 'title="게시 호스트 ${escapeHtml(hostName)}"' in script + # The same candidate list as _result_host in store_serialization.py, so a + # legacy row whose only host-bearing field is bloggerLink still resolves. + assert "evidence.bloggerLink," in script + # The chips beside the title do not shrink, so the row wraps instead of + # breaking the headline one character per line at phone width. + assert "min-width: 14ch;" in styles def test_operator_gui_renders_search_result_links_and_thumbnails(): @@ -946,3 +955,17 @@ def test_rerun_diff_summary_and_new_evidence_badges_are_rendered(): assert "이번 재분석에서 제거됨" in script assert ".evidence-new-chip" in styles assert ".evidence-row-new" in styles + + +def test_operator_gui_explains_a_retained_case_whose_source_file_is_gone(): + # The reload sweep keeps a decided case after its image has been archived out + # of the queue folder. Nothing told the operator: the console rendered a + # broken and "file archived after the decision" was indistinguishable + # from "upload corrupted". + script = _read_app() + styles = _read(STYLES) + + assert "assetMissing" in script + assert "MISSING_ASSET_NOTE" in script + assert "원본 파일 없음" in script + assert ".thumb-missing" in styles diff --git a/tests/rights_filter/analysis/test_llm_assistance.py b/tests/rights_filter/analysis/test_llm_assistance.py index f7d61bf..6fda418 100644 --- a/tests/rights_filter/analysis/test_llm_assistance.py +++ b/tests/rights_filter/analysis/test_llm_assistance.py @@ -116,3 +116,31 @@ def test_ollama_client_sends_the_versioned_system_prompt(): assert result.prompt_version == PROMPT_VERSION assert result.model == "qwen2.5:0.5b-instruct" assert result.latency_ms >= 0 + + +def test_evidence_prompt_lines_name_the_publishing_host_not_the_api(): + # The whitelist is part of the prompt even though it lives in another module. + # Every line used to read domain=google, so the model could not say where an + # image was found - the one thing a rights reviewer most needs from it. A + # membership assertion on PROMPT_DATA_FIELDS would not have caught a rendered + # line that still said "domain". + from rights_filter.analysis.llm_assistance import _evidence_prompt + + prompt = _evidence_prompt( + [ + Evidence( + source=EvidenceSource.WEB_DETECTION, + reason="Google custom web search result found", + confidence=0.35, + data={ + "provider": "google_custom_search", + "domain": "google_custom_search", + "host": "blog.example.test", + "match": "page", + }, + ) + ] + ) + + assert "host=blog.example.test" in prompt + assert "domain=" not in prompt diff --git a/web/operator-gui/app-format.js b/web/operator-gui/app-format.js index e99bfe8..50493d0 100644 --- a/web/operator-gui/app-format.js +++ b/web/operator-gui/app-format.js @@ -590,7 +590,17 @@ function formatDomain(domain) { // 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]) { + // Same candidates, in the same order, as _result_host in + // store_serialization.py. A shorter list here means a legacy Naver blog row + // whose only host-bearing field is bloggerLink shows no chip where the server + // would have resolved one - blind to its own source, which is the failure the + // fallback exists to prevent. + for (const candidate of [ + evidence.url, + evidence.sourcePageUrl, + evidence.bloggerLink, + evidence.imageUrl, + ]) { if (!candidate) continue; try { return new URL(candidate).hostname.toLowerCase().replace(/^www\./, ""); diff --git a/web/operator-gui/app-render-evidence.js b/web/operator-gui/app-render-evidence.js index bfabac4..037facb 100644 --- a/web/operator-gui/app-render-evidence.js +++ b/web/operator-gui/app-render-evidence.js @@ -312,7 +312,13 @@ function renderEvidenceRow(evidence) { // 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)}` : ""; + // The full host goes in the tooltip because the chip is capped at 20ch: two + // CDN hosts sharing a 20-character prefix are otherwise indistinguishable + // without opening the link, which is the discrimination the monospace font + // was chosen for. + const host = hostName + ? `${escapeHtml(hostName)}` + : ""; const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl); const preview = renderEvidencePreview(evidence); const link = renderEvidenceLink(evidence); diff --git a/web/operator-gui/app-render-queue.js b/web/operator-gui/app-render-queue.js index 57fdb0f..c689f81 100644 --- a/web/operator-gui/app-render-queue.js +++ b/web/operator-gui/app-render-queue.js @@ -6,6 +6,12 @@ // global scope, and index.html must keep them in the order listed above/below. // Splitting is a pure line-range extraction of the former single app.js. +// Shown wherever a retained case's image would render. `assetMissing` is set by +// the reload sweep when a decided case's source file has been moved out of the +// queue folder: the decision is kept, the file is gone, and the operator needs +// to be told which of the two happened. +const MISSING_ASSET_NOTE = "원본 파일 없음 (판정 기록 보존)"; + function renderQueue() { const rows = filteredSubmissions(); @@ -96,7 +102,7 @@ function renderQueue() { - ${escapeHtml(submission.title)} 썸네일 + ${escapeHtml(submission.title)} 썸네일 @@ -225,7 +231,10 @@ function renderCaseReview() { caseImageElement.src = safeUrl(submission.asset); - caseImageElement.alt = `${submission.id} 제출 이미지`; + caseImageElement.alt = submission.assetMissing + ? `${submission.id} 제출 이미지 - ${MISSING_ASSET_NOTE}` + : `${submission.id} 제출 이미지`; + caseImageElement.classList.toggle("case-image-missing", Boolean(submission.assetMissing)); // Reset the view controls (rotate / fit) so a new case starts unrotated. caseImageElement.dataset.rotation = "0"; @@ -234,7 +243,13 @@ function renderCaseReview() { caseImageElement.style.objectFit = ""; - document.getElementById("image-derivative-note").textContent = submission.derivativeNote; + // Without this the retained case is a broken with no explanation, and + // the operator cannot tell "file archived after the decision" from "upload + // corrupted". The decision survives in the DB either way; this is what makes + // it readable in the console. + document.getElementById("image-derivative-note").textContent = submission.assetMissing + ? `${submission.derivativeNote} · ${MISSING_ASSET_NOTE}` + : submission.derivativeNote; document.getElementById("case-score").className = `score-pill ${riskClass(submission.riskBand)}`; diff --git a/web/operator-gui/styles.css b/web/operator-gui/styles.css index 6f2050f..73ba44a 100644 --- a/web/operator-gui/styles.css +++ b/web/operator-gui/styles.css @@ -1330,6 +1330,16 @@ tbody tr.selected-row, 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. */ +/* A retained case whose source file is gone. The image cannot be served, so the + frame says so instead of leaving a silently broken thumbnail: the row is still + the record of a real decision and has to stay legible as one. */ +.thumb-missing, +.case-image-missing { + outline: 2px dashed var(--amber-line); + outline-offset: 2px; + opacity: 0.55; +} + .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 @@ -1881,6 +1891,12 @@ tbody tr.selected-row, .evidence-title, .row-title { display: flex; + /* Wraps because the chips beside the title do not. source-chip and host-chip + are both rigid, and at phone width they leave the headline a few dozen + pixels - which `overflow-wrap: anywhere` below then honours by breaking it + one character per row. With wrapping the title drops to its own line + intact, and desktop layout is unchanged because nothing overflows there. */ + flex-wrap: wrap; align-items: center; gap: 7px; min-width: 0; @@ -1896,9 +1912,12 @@ tbody tr.selected-row, /* 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. */ + column. The floor is what makes the container's `flex-wrap` fire: without it + the title shrinks to the residue left by the chips instead of moving to the + next line. */ .evidence-title > span:last-child { flex: 1 1 auto; + min-width: 14ch; } .evidence-meta,