fix: 운영자 콘솔 실렌더 검증에서 드러난 결함 5종

코드 리딩으로는 나오지 않는 결함들이다. 서버를 실제로 띄우고 브라우저로
케이스 화면을 열어서 찾았다.

## 1. 서버가 기동조차 하지 않았다 (납품 치명)

COPYRIGHTER_AUTH_TOKEN 없이 실행하면 경고 문구 안의 em dash 가 한국어
Windows 콘솔(cp949)에서 UnicodeEncodeError 를 일으켜, 포트를 바인딩하기도
전에 프로세스가 죽었다. 납품 대상이 정확히 그 환경이다.

기동 진단 문자열을 ASCII 로 바꾸고, stdout/stderr 를 errors="replace" 로
재설정한다. 진단 메시지가 기동을 막을 수 있어서는 안 된다 — 죽는 것은
프로세스가 아니라 표현 불가능한 문자여야 한다.

## 2. 요약이 모델이 본 적 없는 근거를 인용했다 (거버넌스)

프롬프트는 MAX_PROMPT_EVIDENCE 로 잘리는데 source_urls /
source_evidence_ids 는 여전히 전체 증거에서 뽑혔다. 실측: 79행짜리 케이스가
12행만 읽은 요약을 만들고 화면에는 "연결 근거 78건" 이 떴다. 정책 검사는
통과했고 숫자는 허구였다.

앞선 커밋의 입력 경계는 이 구멍의 절반만 막았다. 이제 연결 근거는 검증된
claim 이 실제로 인용한 행이고, 인용이 하나도 살아남지 못하면 모델에게
보여준 행으로 폴백한다. verified 도 이제 "요약이 검증됐다" 를 뜻한다 —
이전에는 "케이스에 증거가 있다" 의 재진술이었다.

## 3. 조작 감지 지표가 저장되지 않았다

grounded_claims / dropped_claims 를 evidence.data 에는 넣었으나
_evidence_payload 에 매핑하지 않아 영속화되지 않았다. 모델이 지어내기
시작해도 아무도 볼 수 없는 상태였다.

## 4. 클라이언트가 서버의 그룹 분류를 무시했다

서버가 group=provenance 를 보내는데 app.js 의 evidenceGroupFor 가 자체
매핑으로 internal 로 재분류했다. 배관 목록에 서버 쪽만 넣고 클라이언트
쪽을 빠뜨렸다. 증거 출처 필터 드롭다운에도 항목이 없었다.

## 5. 기본 모델이 두 곳에서 서로 달랐다

OllamaGenerateLlmClient.__init__ 는 qwen2.5:3b-instruct(미설치), env_clients
는 qwen2.5:7b. 런타임 404 가 날 때까지 아무것도 실패하지 않았다. 클라이언트가
값을 소유하고 env_clients 가 재수출하도록 바꿔 드리프트를 구조적으로 막는다.

## 실렌더 확인 결과

- 출처 표기 그룹이 자체 섹션으로 렌더 (제목/건수/설명/칩)
- 요약이 [E1, E2, ...] 인용이 붙은 한국어 2단으로 렌더
- sourceIds 78 -> 10, verified=True, grounded=2 dropped=0
- qwen2.5:7b 6.0초, initialRiskScore 21/low vs 실시간 100/high 분기 확인

pytest 534 passed.
This commit is contained in:
changukyu 2026-07-26 00:29:54 +09:00
parent eb888e19b9
commit d20e6d667e
8 changed files with 108 additions and 16 deletions

View file

@ -6,7 +6,7 @@ import sys
# surfaces this friendly message instead of a raw SyntaxError/ImportError.
if sys.version_info < (3, 13):
sys.exit(
"Copyrighter requires Python 3.13+ — activate the offline venv "
"Copyrighter requires Python 3.13+ - activate the offline venv "
"(see docs/operations/offline-install.md)."
)

View file

@ -29,6 +29,12 @@ logger = logging.getLogger(__name__)
# row as its source. Capping the input is what makes the citation honest.
MAX_PROMPT_EVIDENCE = 12
# Single source of truth for the model. env_clients re-exports this rather than
# declaring its own: two defaults that disagree is how the client ended up
# pointing at a model that was never installed while the wiring pointed
# elsewhere, and nothing failed until a request 404'd at runtime.
DEFAULT_MODEL = "qwen2.5:7b"
# Data fields the model is allowed to see. A whitelist rather than a blocklist:
# evidence payloads grow, and a field nobody vetted must not reach the prompt by
# default. Notably absent — promotion_reason (the model copies it verbatim and
@ -79,7 +85,7 @@ class OllamaGenerateLlmClient:
def __init__(
self,
base_url: str = "http://127.0.0.1:11434",
model: str = "qwen2.5:3b-instruct",
model: str = DEFAULT_MODEL,
transport: Any | None = None,
timeout: int = 120,
connect_timeout: float = 2,
@ -185,8 +191,26 @@ class InternalLlmAssistant:
"dropped_claims": result.dropped_claims,
}
source_urls = _source_urls(evidence)
source_evidence_ids = _source_evidence_ids(evidence)
# The linked sources must be what the summary actually rests on, not the
# whole case. Deriving them from the full evidence list was the original
# defect: the prompt is capped at MAX_PROMPT_EVIDENCE, so a case with 79
# rows produced a summary read from 12 while the UI reported 78 "linked
# sources". The policy check passed and the number was fiction.
#
# Cited rows first (what the verified claims point at); the shown rows as
# a fallback when nothing verified, so the row is still storable and the
# count still describes something the model actually read.
shown = _select_prompt_evidence(evidence)
labels = _evidence_labels(shown)
cited_labels = {
label for claim in result.claims for label in claim.get("evidence", [])
}
cited = [labels[label] for label in sorted(cited_labels) if label in labels]
grounded = bool(cited)
source_evidence = cited or shown
source_urls = _source_urls(source_evidence)
source_evidence_ids = _source_evidence_ids(source_evidence)
if not source_urls and not source_evidence_ids:
return Evidence(
source=EvidenceSource.LLM_SUMMARY,
@ -211,7 +235,11 @@ class InternalLlmAssistant:
"summary": result.text,
"source_urls": source_urls,
"source_evidence_ids": source_evidence_ids,
"verified": True,
# True only when a claim survived citation checking. Previously
# this was True whenever any source existed, which made it a
# restatement of "the case has evidence" rather than a statement
# about the summary.
"verified": grounded,
**stamp,
},
)

View file

@ -7,7 +7,11 @@ from typing import Any, Mapping
logger = logging.getLogger(__name__)
from rights_filter.analysis.llm_assistance import InternalLlmAssistant, OllamaGenerateLlmClient
from rights_filter.analysis.llm_assistance import (
DEFAULT_MODEL,
InternalLlmAssistant,
OllamaGenerateLlmClient,
)
from rights_filter.integrations.cloud_vision_web_detection import (
CloudVisionWebDetectionAdapter,
GoogleVisionRestClient,
@ -27,7 +31,9 @@ from rights_filter.integrations.search_policy import SearchApiPolicy
# qwen3:8b 35.7s — held the contract but 4x slower (thinking)
# 7b is also already present on the build machine, so the default costs no
# additional model transfer across the air gap.
DEFAULT_OLLAMA_MODEL = "qwen2.5:7b"
#
# Re-exported, not redeclared: the client owns the value so the two cannot drift.
DEFAULT_OLLAMA_MODEL = DEFAULT_MODEL
@dataclass

View file

@ -27,13 +27,21 @@ def _provider_readiness(runtime) -> str:
def main() -> None:
# When the server is started as a service with stdout redirected to a file,
# Python switches to block buffering and the startup diagnostics below —
# including the "token not set" security warning — sit in the buffer
# Python switches to block buffering and the startup diagnostics below -
# including the "token not set" security warning - sit in the buffer
# indefinitely (the process never exits). Line-buffer so they reach the log
# immediately.
#
# errors="replace" is not cosmetic: the delivery target is a Korean Windows
# host whose console codepage is cp949, and a single character outside it
# (an em dash in a warning string was enough) raised UnicodeEncodeError from
# print() and killed the server before it ever bound the port. A diagnostic
# message must never be able to prevent startup - degrade the character, not
# the process.
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, OSError):
sys.stdout.reconfigure(line_buffering=True, errors="replace")
sys.stderr.reconfigure(errors="replace")
except (AttributeError, OSError, ValueError):
pass
parser = argparse.ArgumentParser(description="Run the local Copyrighter API server.")
@ -61,7 +69,7 @@ def main() -> None:
env_path = Path(args.env)
load_env_file(env_path, os.environ)
# Log the resolved env path (and whether it was found) so an operator running
# from an unexpected directory can see which .env — if any — was actually
# from an unexpected directory can see which .env - if any - was actually
# loaded, rather than silently getting no token / disabled providers.
print(f".env: {env_path.resolve()} ({'loaded' if env_path.exists() else 'not found'})")
provider_runtime = build_provider_runtime(os.environ)
@ -73,17 +81,17 @@ def main() -> None:
auth_token = os.environ.get("COPYRIGHTER_AUTH_TOKEN", "").strip() or None
if auth_token is None:
print("WARNING: COPYRIGHTER_AUTH_TOKEN not set — data routes are unauthenticated.")
print("WARNING: COPYRIGHTER_AUTH_TOKEN not set - data routes are unauthenticated.")
elif len(auth_token) < 32:
print(
"WARNING: COPYRIGHTER_AUTH_TOKEN is shorter than 32 characters — "
"WARNING: COPYRIGHTER_AUTH_TOKEN is shorter than 32 characters - "
"use a longer, high-entropy token (e.g. `python -c \"import secrets; print(secrets.token_urlsafe(32))\"`)."
)
static_path = Path(args.static)
if not static_path.exists():
print(
f"WARNING: static GUI directory not found: {static_path.resolve()} — "
f"WARNING: static GUI directory not found: {static_path.resolve()} - "
"the operator UI will return 404. Pass --static or run from the repo root."
)

View file

@ -346,6 +346,12 @@ def _evidence_payload(submission_id: str, evidence: Evidence) -> dict[str, Any]:
"promptId": str(evidence.data.get("prompt_id", "")),
"promptVersion": str(evidence.data.get("prompt_version", "")),
"latencyMs": evidence.data.get("latency_ms", ""),
# Fabrication counters. Without these on the payload the metric exists
# only in memory and the operator/admin never sees a model that has
# started inventing citations.
"groundedClaims": evidence.data.get("grounded_claims", ""),
"droppedClaims": evidence.data.get("dropped_claims", ""),
"verified": evidence.data.get("verified", ""),
"status": "active",
"submission_id": submission_id,
}
@ -403,6 +409,9 @@ def _domain_evidence_from_ui(payload: dict[str, Any]) -> Evidence:
"prompt_id": payload.get("promptId", ""),
"prompt_version": payload.get("promptVersion", ""),
"latency_ms": payload.get("latencyMs", ""),
"grounded_claims": payload.get("groundedClaims", ""),
"dropped_claims": payload.get("droppedClaims", ""),
"verified": payload.get("verified", ""),
"provenance_kind": payload.get("provenanceKind", ""),
"rights_fields": payload.get("rightsFields", {}),
"capture_categories": payload.get("captureCategories", []),
@ -505,7 +514,7 @@ def _ui_source(source: EvidenceSource) -> str:
def _ui_group(source: EvidenceSource) -> str:
ui_source = _ui_source(source)
if ui_source in {"fingerprint", "face", "provenance"}:
if ui_source in {"fingerprint", "face"}:
return "internal"
return ui_source

View file

@ -225,3 +225,41 @@ def test_assistant_records_how_much_survived_verification(field):
evidence = InternalLlmAssistant(client).summarize("SUB-1", [_web()])
assert evidence.data[field] == 1
# --- linked sources must describe what the model read -------------------------
def _many(count):
return [_web(url=f"https://example.test/{index}") for index in range(count)]
def test_linked_sources_never_exceed_what_the_model_was_shown():
# The original defect: a 79-row case produced a summary read from 12 rows
# while the UI reported 78 "linked sources" and the policy check passed.
client, _ = _client({"claims": []})
evidence = _many(40)
result_evidence = InternalLlmAssistant(client).summarize("SUB-1", evidence)
assert len(result_evidence.data["source_evidence_ids"]) <= MAX_PROMPT_EVIDENCE
assert len(result_evidence.data["source_urls"]) <= MAX_PROMPT_EVIDENCE
def test_linked_sources_are_the_cited_rows_when_claims_verify():
client, _ = _client({"claims": [{"text": "근거 있음.", "evidence": ["E1"]}]})
result_evidence = InternalLlmAssistant(client).summarize("SUB-1", _many(20))
assert len(result_evidence.data["source_urls"]) == 1
assert result_evidence.data["verified"] is True
def test_verified_is_false_when_nothing_survived_citation_checking():
# "verified" used to mean "the case has evidence", which is not a statement
# about the summary at all.
client, _ = _client({"claims": [{"text": "지어냄.", "evidence": ["E99"]}]})
result_evidence = InternalLlmAssistant(client).summarize("SUB-1", _many(20))
assert result_evidence.data["verified"] is False

View file

@ -1632,6 +1632,8 @@ function evidenceGroupFor(evidence) {
if (evidence.source === "face") return "face";
if (evidence.source === "provenance") return "provenance";
if (evidence.source === "llm") return "llm";
if (evidence.source === "failure") return "failure";

View file

@ -151,6 +151,7 @@
<option value="google">구글 근거</option>
<option value="fingerprint">이미지 유사도</option>
<option value="face">얼굴/인물 감지</option>
<option value="provenance">출처 표기</option>
<option value="llm">내부 요약</option>
<option value="failure">외부 검색 tool 실패</option>
</select>