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
"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=<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
# 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 "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 <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.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

View file

@ -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\./, "");

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
// 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>` : "";
// 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 preview = renderEvidencePreview(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.
// 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() {
<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>
@ -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 <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)}`;

View file

@ -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,