fix: 증거 행 레이아웃·호스트 노출·보존 케이스 설명, 프롬프트 스탬프 갱신

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
changukyu 2026-09-18 23:54:00 +09:00
parent 4ba83ebc65
commit e38dad83c9
7 changed files with 116 additions and 9 deletions

View file

@ -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 and after a prompt (or model) change are indistinguishable in the database, and
"did the new prompt help?" becomes unanswerable after the fact. "did the new prompt help?" becomes unanswerable after the fact.
Bump PROMPT_VERSION whenever SYSTEM_PROMPT changes in a way that could alter Bump PROMPT_VERSION whenever the MODEL INPUT changes in a way that could alter
output — wording, format contract, or language. 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 from __future__ import annotations
PROMPT_ID = "operator-review-note-ko" PROMPT_ID = "operator-review-note-ko"
PROMPT_VERSION = "ko-v3-structured" # ko-v4: evidence lines carry host=<publishing host> where ko-v3 carried
# domain=<api name>.
PROMPT_VERSION = "ko-v4-structured-host"
# Marks rows written before prompt versioning existed. Never assume the text # Marks rows written before prompt versioning existed. Never assume the text
# below produced them. # below produced them.

View file

@ -779,6 +779,15 @@ def test_operator_gui_shows_the_publishing_host_on_every_evidence_row():
assert "evidenceHost" in script assert "evidenceHost" in script
assert "host-chip" in script assert "host-chip" in script
assert ".host-chip" in styles 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(): 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 "이번 재분석에서 제거됨" in script
assert ".evidence-new-chip" in styles assert ".evidence-new-chip" in styles
assert ".evidence-row-new" 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 <img> 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

View file

@ -116,3 +116,31 @@ def test_ollama_client_sends_the_versioned_system_prompt():
assert result.prompt_version == PROMPT_VERSION assert result.prompt_version == PROMPT_VERSION
assert result.model == "qwen2.5:0.5b-instruct" assert result.model == "qwen2.5:0.5b-instruct"
assert result.latency_ms >= 0 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

View file

@ -590,7 +590,17 @@ function formatDomain(domain) {
// parse per row on every bootstrap. Only rendered rows reach this. // parse per row on every bootstrap. Only rendered rows reach this.
function evidenceHost(evidence) { function evidenceHost(evidence) {
if (evidence.host) return evidence.host; 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; if (!candidate) continue;
try { try {
return new URL(candidate).hostname.toLowerCase().replace(/^www\./, ""); return new URL(candidate).hostname.toLowerCase().replace(/^www\./, "");

View file

@ -312,7 +312,13 @@ function renderEvidenceRow(evidence) {
// copy on the licensor's own site and one on a scraper blog are not the same // 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. // finding. Kept out of last-child position so the title keeps its wrap rule.
const hostName = evidenceHost(evidence); const hostName = evidenceHost(evidence);
const host = hostName ? `<span class="host-chip" title="게시 호스트">${escapeHtml(hostName)}</span>` : ""; // 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
? `<span class="host-chip" title="게시 호스트 ${escapeHtml(hostName)}">${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);

View file

@ -6,6 +6,12 @@
// global scope, and index.html must keep them in the order listed above/below. // 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. // 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() { function renderQueue() {
const rows = filteredSubmissions(); const rows = filteredSubmissions();
@ -96,7 +102,7 @@ function renderQueue() {
<td><input type="checkbox" aria-label="${escapeHtml(submission.id)} 선택" data-bulk-id="${escapeHtml(submission.id)}"></td> <td><input type="checkbox" aria-label="${escapeHtml(submission.id)} 선택" data-bulk-id="${escapeHtml(submission.id)}"></td>
<td><img class="thumb" src="${escapeHtml(submission.asset)}" alt="${escapeHtml(submission.title)} 썸네일"></td> <td><img class="thumb${submission.assetMissing ? " thumb-missing" : ""}" src="${escapeHtml(submission.asset)}" alt="${escapeHtml(submission.title)} 썸네일"${submission.assetMissing ? ` title="${MISSING_ASSET_NOTE}"` : ""}></td>
<td> <td>
@ -225,7 +231,10 @@ function renderCaseReview() {
caseImageElement.src = safeUrl(submission.asset); 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. // Reset the view controls (rotate / fit) so a new case starts unrotated.
caseImageElement.dataset.rotation = "0"; caseImageElement.dataset.rotation = "0";
@ -234,7 +243,13 @@ function renderCaseReview() {
caseImageElement.style.objectFit = ""; caseImageElement.style.objectFit = "";
document.getElementById("image-derivative-note").textContent = submission.derivativeNote; // Without this the retained case is a broken <img> 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)}`; document.getElementById("case-score").className = `score-pill ${riskClass(submission.riskBand)}`;

View file

@ -1330,6 +1330,16 @@ tbody tr.selected-row,
near-identical domains (cdn.example.com vs cdn-example.com) separate at a 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 glance; quieter than the source chip because it sits next to it on every row
and two competing chips would flatten the hierarchy. */ 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 { .host-chip {
/* Bounded, then rigid. Bounded because CDN hostnames run past 45 characters /* Bounded, then rigid. Bounded because CDN hostnames run past 45 characters
and an unbounded chip ate the whole title line, leaving the headline to and an unbounded chip ate the whole title line, leaving the headline to
@ -1881,6 +1891,12 @@ tbody tr.selected-row,
.evidence-title, .evidence-title,
.row-title { .row-title {
display: flex; 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; align-items: center;
gap: 7px; gap: 7px;
min-width: 0; 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: /* 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 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 { .evidence-title > span:last-child {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 14ch;
} }
.evidence-meta, .evidence-meta,