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:
parent
eb888e19b9
commit
d20e6d667e
8 changed files with 108 additions and 16 deletions
|
|
@ -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)."
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue