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:
parent
4ba83ebc65
commit
e38dad83c9
7 changed files with 116 additions and 9 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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\./, "");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue