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.
|
# surfaces this friendly message instead of a raw SyntaxError/ImportError.
|
||||||
if sys.version_info < (3, 13):
|
if sys.version_info < (3, 13):
|
||||||
sys.exit(
|
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)."
|
"(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.
|
# row as its source. Capping the input is what makes the citation honest.
|
||||||
MAX_PROMPT_EVIDENCE = 12
|
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:
|
# 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
|
# 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
|
# default. Notably absent — promotion_reason (the model copies it verbatim and
|
||||||
|
|
@ -79,7 +85,7 @@ class OllamaGenerateLlmClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
base_url: str = "http://127.0.0.1:11434",
|
base_url: str = "http://127.0.0.1:11434",
|
||||||
model: str = "qwen2.5:3b-instruct",
|
model: str = DEFAULT_MODEL,
|
||||||
transport: Any | None = None,
|
transport: Any | None = None,
|
||||||
timeout: int = 120,
|
timeout: int = 120,
|
||||||
connect_timeout: float = 2,
|
connect_timeout: float = 2,
|
||||||
|
|
@ -185,8 +191,26 @@ class InternalLlmAssistant:
|
||||||
"dropped_claims": result.dropped_claims,
|
"dropped_claims": result.dropped_claims,
|
||||||
}
|
}
|
||||||
|
|
||||||
source_urls = _source_urls(evidence)
|
# The linked sources must be what the summary actually rests on, not the
|
||||||
source_evidence_ids = _source_evidence_ids(evidence)
|
# 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:
|
if not source_urls and not source_evidence_ids:
|
||||||
return Evidence(
|
return Evidence(
|
||||||
source=EvidenceSource.LLM_SUMMARY,
|
source=EvidenceSource.LLM_SUMMARY,
|
||||||
|
|
@ -211,7 +235,11 @@ class InternalLlmAssistant:
|
||||||
"summary": result.text,
|
"summary": result.text,
|
||||||
"source_urls": source_urls,
|
"source_urls": source_urls,
|
||||||
"source_evidence_ids": source_evidence_ids,
|
"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,
|
**stamp,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ from typing import Any, Mapping
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 (
|
from rights_filter.integrations.cloud_vision_web_detection import (
|
||||||
CloudVisionWebDetectionAdapter,
|
CloudVisionWebDetectionAdapter,
|
||||||
GoogleVisionRestClient,
|
GoogleVisionRestClient,
|
||||||
|
|
@ -27,7 +31,9 @@ from rights_filter.integrations.search_policy import SearchApiPolicy
|
||||||
# qwen3:8b 35.7s — held the contract but 4x slower (thinking)
|
# 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
|
# 7b is also already present on the build machine, so the default costs no
|
||||||
# additional model transfer across the air gap.
|
# 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
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,21 @@ def _provider_readiness(runtime) -> str:
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
# When the server is started as a service with stdout redirected to a file,
|
# When the server is started as a service with stdout redirected to a file,
|
||||||
# Python switches to block buffering and the startup diagnostics below —
|
# Python switches to block buffering and the startup diagnostics below -
|
||||||
# including the "token not set" security warning — sit in the buffer
|
# including the "token not set" security warning - sit in the buffer
|
||||||
# indefinitely (the process never exits). Line-buffer so they reach the log
|
# indefinitely (the process never exits). Line-buffer so they reach the log
|
||||||
# immediately.
|
# 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:
|
try:
|
||||||
sys.stdout.reconfigure(line_buffering=True)
|
sys.stdout.reconfigure(line_buffering=True, errors="replace")
|
||||||
except (AttributeError, OSError):
|
sys.stderr.reconfigure(errors="replace")
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Run the local Copyrighter API server.")
|
parser = argparse.ArgumentParser(description="Run the local Copyrighter API server.")
|
||||||
|
|
@ -61,7 +69,7 @@ def main() -> None:
|
||||||
env_path = Path(args.env)
|
env_path = Path(args.env)
|
||||||
load_env_file(env_path, os.environ)
|
load_env_file(env_path, os.environ)
|
||||||
# Log the resolved env path (and whether it was found) so an operator running
|
# 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.
|
# loaded, rather than silently getting no token / disabled providers.
|
||||||
print(f".env: {env_path.resolve()} ({'loaded' if env_path.exists() else 'not found'})")
|
print(f".env: {env_path.resolve()} ({'loaded' if env_path.exists() else 'not found'})")
|
||||||
provider_runtime = build_provider_runtime(os.environ)
|
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
|
auth_token = os.environ.get("COPYRIGHTER_AUTH_TOKEN", "").strip() or None
|
||||||
if auth_token is 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:
|
elif len(auth_token) < 32:
|
||||||
print(
|
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))\"`)."
|
"use a longer, high-entropy token (e.g. `python -c \"import secrets; print(secrets.token_urlsafe(32))\"`)."
|
||||||
)
|
)
|
||||||
|
|
||||||
static_path = Path(args.static)
|
static_path = Path(args.static)
|
||||||
if not static_path.exists():
|
if not static_path.exists():
|
||||||
print(
|
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."
|
"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", "")),
|
"promptId": str(evidence.data.get("prompt_id", "")),
|
||||||
"promptVersion": str(evidence.data.get("prompt_version", "")),
|
"promptVersion": str(evidence.data.get("prompt_version", "")),
|
||||||
"latencyMs": evidence.data.get("latency_ms", ""),
|
"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",
|
"status": "active",
|
||||||
"submission_id": submission_id,
|
"submission_id": submission_id,
|
||||||
}
|
}
|
||||||
|
|
@ -403,6 +409,9 @@ def _domain_evidence_from_ui(payload: dict[str, Any]) -> Evidence:
|
||||||
"prompt_id": payload.get("promptId", ""),
|
"prompt_id": payload.get("promptId", ""),
|
||||||
"prompt_version": payload.get("promptVersion", ""),
|
"prompt_version": payload.get("promptVersion", ""),
|
||||||
"latency_ms": payload.get("latencyMs", ""),
|
"latency_ms": payload.get("latencyMs", ""),
|
||||||
|
"grounded_claims": payload.get("groundedClaims", ""),
|
||||||
|
"dropped_claims": payload.get("droppedClaims", ""),
|
||||||
|
"verified": payload.get("verified", ""),
|
||||||
"provenance_kind": payload.get("provenanceKind", ""),
|
"provenance_kind": payload.get("provenanceKind", ""),
|
||||||
"rights_fields": payload.get("rightsFields", {}),
|
"rights_fields": payload.get("rightsFields", {}),
|
||||||
"capture_categories": payload.get("captureCategories", []),
|
"capture_categories": payload.get("captureCategories", []),
|
||||||
|
|
@ -505,7 +514,7 @@ def _ui_source(source: EvidenceSource) -> str:
|
||||||
|
|
||||||
def _ui_group(source: EvidenceSource) -> str:
|
def _ui_group(source: EvidenceSource) -> str:
|
||||||
ui_source = _ui_source(source)
|
ui_source = _ui_source(source)
|
||||||
if ui_source in {"fingerprint", "face", "provenance"}:
|
if ui_source in {"fingerprint", "face"}:
|
||||||
return "internal"
|
return "internal"
|
||||||
return ui_source
|
return ui_source
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -225,3 +225,41 @@ def test_assistant_records_how_much_survived_verification(field):
|
||||||
evidence = InternalLlmAssistant(client).summarize("SUB-1", [_web()])
|
evidence = InternalLlmAssistant(client).summarize("SUB-1", [_web()])
|
||||||
|
|
||||||
assert evidence.data[field] == 1
|
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 === "face") return "face";
|
||||||
|
|
||||||
|
if (evidence.source === "provenance") return "provenance";
|
||||||
|
|
||||||
if (evidence.source === "llm") return "llm";
|
if (evidence.source === "llm") return "llm";
|
||||||
|
|
||||||
if (evidence.source === "failure") return "failure";
|
if (evidence.source === "failure") return "failure";
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@
|
||||||
<option value="google">구글 근거</option>
|
<option value="google">구글 근거</option>
|
||||||
<option value="fingerprint">이미지 유사도</option>
|
<option value="fingerprint">이미지 유사도</option>
|
||||||
<option value="face">얼굴/인물 감지</option>
|
<option value="face">얼굴/인물 감지</option>
|
||||||
|
<option value="provenance">출처 표기</option>
|
||||||
<option value="llm">내부 요약</option>
|
<option value="llm">내부 요약</option>
|
||||||
<option value="failure">외부 검색 tool 실패</option>
|
<option value="failure">외부 검색 tool 실패</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue