fix: 형식 예시 반향 차단, 기본 모델을 실측으로 qwen2.5:7b 로 확정
실모델 프로브에서 인용 검증만으로는 못 잡는 실패 모드가 드러났다. ## 형식 예시 반향(template echo) qwen2.5:0.5b 는 프롬프트의 형식 예시를 그대로 돌려주면서(claim text 가 "<한국어 문장>") 실제 라벨 12개를 전부 인용했다. 집합 포함 검사는 이것을 완벽히 통과시킨다 — 인용은 전부 진짜이기 때문이다. 결과적으로 완전히 비어 있는 출력이 "근거 있음" 판정을 받았다. 그라운딩 검증은 "인용이 진짜인가"와 "내용이 진짜인가"를 둘 다 봐야 한다. 형식 예시 문자열을 llm_prompts 의 상수로 두고 그 반향을 거부한다. 앵글 브래킷으로 감싼 다른 스탠드인도 같은 실패로 취급한다. 가짜 transport 테스트로는 이 결함을 잡을 수 없었다 — 스크립트된 응답은 언제나 잘 만들어진 JSON 이기 때문이다. ## 기본 모델 저장된 실제 증거(제출물 #3, 54행 중 12행 선별, 프롬프트 1,507자)로 GPU 타깃에서 측정: qwen2.5:0.5b-instruct 55.7s 형식 예시 반향, 계약 위반 qwen2.5:7b 8.8s 계약 준수, claim 2 + 확인필요 1 <- 채택 qwen3:8b 35.7s 계약 준수, thinking 모델이라 4배 느림 3b 는 빌드 머신에 없고 7b 는 이미 있어 에어갭 반입 비용이 0 이다. 브레인스토밍 문서의 "7B 는 인터랙티브 불가" 는 CPU 전제였고 이 배포에는 해당하지 않는다. pytest 517 passed.
This commit is contained in:
parent
5c05382944
commit
6bbaaf6e23
6 changed files with 96 additions and 10 deletions
10
.env.example
10
.env.example
|
|
@ -45,10 +45,12 @@ COPYRIGHTER_GOOGLE_CUSTOM_SEARCH_DAILY_LIMIT=100
|
||||||
COPYRIGHTER_LLM_DAILY_LIMIT=100
|
COPYRIGHTER_LLM_DAILY_LIMIT=100
|
||||||
|
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
# 3B is the floor for reading evidence and citing it. 0.5B ignored the output
|
# Measured against real stored evidence on the GPU target:
|
||||||
# contract (answered in English, reprinted the evidence list). Pull it on the
|
# qwen2.5:0.5b-instruct 55.7s — returned the prompt's shape example verbatim
|
||||||
# build machine: `ollama pull qwen2.5:3b-instruct`.
|
# qwen2.5:7b 8.8s — held the contract <- default
|
||||||
OLLAMA_MODEL=qwen2.5:3b-instruct
|
# qwen3:8b 35.7s — held the contract, 4x slower (thinking model)
|
||||||
|
# Pull on the build machine: `ollama pull qwen2.5:7b`.
|
||||||
|
OLLAMA_MODEL=qwen2.5:7b
|
||||||
# Context window. Ollama defaults to a couple of thousand tokens and silently
|
# Context window. Ollama defaults to a couple of thousand tokens and silently
|
||||||
# truncates past it — the summary would then cite evidence the model never read.
|
# truncates past it — the summary would then cite evidence the model never read.
|
||||||
OLLAMA_NUM_CTX=8192
|
OLLAMA_NUM_CTX=8192
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,29 @@ FWC = FEASIBLE_WITH_CHANGES(수정 조건부 실현가능). 24건 중 verdict가
|
||||||
|
|
||||||
**D. 투입 후 라벨 축적 뒤** — `decision_stats` 교차표로 0점 라벨들의 실제 반려 상관 측정 → 가중치 부여 → 임계값 스윕. 학습 모델은 T0-3 스냅샷이 수백 건 쌓인 뒤.
|
**D. 투입 후 라벨 축적 뒤** — `decision_stats` 교차표로 0점 라벨들의 실제 반려 상관 측정 → 가중치 부여 → 임계값 스윕. 학습 모델은 T0-3 스냅샷이 수백 건 쌓인 뒤.
|
||||||
|
|
||||||
|
### 실기동 실측 (2026-07-25, GPU 타깃) — 추정치를 대체함
|
||||||
|
|
||||||
|
이 문서의 지연 수치는 전부 CPU 개발 머신 실측의 외삽이었다. 실제 GPU 타깃에서
|
||||||
|
저장된 실제 증거(제출물 #3, 증거 54행 중 12행 선별, 프롬프트 1,507자)로 재측정한 결과:
|
||||||
|
|
||||||
|
| 모델 | 지연 | 계약 준수 | 비고 |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| `qwen2.5:0.5b-instruct` | 55.7s | ❌ | **프롬프트의 형식 예시(`<한국어 문장>`)를 그대로 출력**하면서 실제 라벨 12개를 전부 인용 |
|
||||||
|
| `qwen2.5:7b` | **8.8s** | ✅ | 근거 claim 2건 + 확인필요 1건. **채택** |
|
||||||
|
| `qwen3:8b` | 35.7s | ✅ | claim 4건이지만 4배 느림(thinking 모델) |
|
||||||
|
|
||||||
|
**이 실측이 바꾼 것 3가지**:
|
||||||
|
|
||||||
|
1. **기본 모델을 3B가 아니라 `qwen2.5:7b`로 정했다.** 3B는 이 빌드 머신에 없고 7B는
|
||||||
|
이미 있어서 에어갭 반입 비용이 0이며, GPU에서 8.8초면 인터랙티브다. 문서 상단의
|
||||||
|
"7B는 현재 근거로 인터랙티브 불가"는 CPU 전제였고 이 배포에는 적용되지 않는다.
|
||||||
|
2. **인용 검증만으로는 부족하다는 것이 드러났다.** 0.5B의 출력은 실제 라벨을 인용했으므로
|
||||||
|
집합 포함 검사를 **완벽히 통과**했다 — 완전히 비어 있는데도 "근거 있음"으로 판정됐다.
|
||||||
|
형식 예시 문자열을 상수로 두고 그 반향(echo)을 거부하는 가드를 추가했다.
|
||||||
|
*교훈: 그라운딩 검증은 "인용이 진짜인가"와 "내용이 진짜인가"를 둘 다 봐야 한다.*
|
||||||
|
3. **가짜 transport 테스트만으로는 이 결함을 절대 못 잡았다.** 스크립트된 응답은 언제나
|
||||||
|
잘 만들어진 JSON이기 때문이다. 실모델 프로브가 필요했다.
|
||||||
|
|
||||||
### 남은 결정 1개
|
### 남은 결정 1개
|
||||||
|
|
||||||
**임베딩 저장 금지 정책의 개정 여부** — 의미지문 인덱스(22위)만 이 결정에 걸려 있고, 나머지 A~D는 전부 이 결정 없이 진행된다. 급하지 않으므로 D 시점에 재론해도 된다.
|
**임베딩 저장 금지 정책의 개정 여부** — 의미지문 인덱스(22위)만 이 결정에 걸려 있고, 나머지 A~D는 전부 이 결정 없이 진행된다. 급하지 않으므로 D 시점에 재론해도 된다.
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@ from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from rights_filter.analysis.llm_prompts import (
|
from rights_filter.analysis.llm_prompts import (
|
||||||
|
CLAIM_TEXT_PLACEHOLDER,
|
||||||
EMPTY_SUMMARY_TEXT,
|
EMPTY_SUMMARY_TEXT,
|
||||||
EVIDENCE_LABEL_PREFIX,
|
EVIDENCE_LABEL_PREFIX,
|
||||||
LEGACY_PROMPT_VERSION,
|
LEGACY_PROMPT_VERSION,
|
||||||
NEEDS_CHECK_HEADING,
|
NEEDS_CHECK_HEADING,
|
||||||
|
NEEDS_CHECK_PLACEHOLDER,
|
||||||
PROMPT_ID,
|
PROMPT_ID,
|
||||||
PROMPT_VERSION,
|
PROMPT_VERSION,
|
||||||
SUMMARY_HEADING,
|
SUMMARY_HEADING,
|
||||||
|
|
@ -317,8 +319,9 @@ def _verified_claims(
|
||||||
text = str(entry.get("text", "")).strip()
|
text = str(entry.get("text", "")).strip()
|
||||||
cited = _normalized_labels(entry.get("evidence"))
|
cited = _normalized_labels(entry.get("evidence"))
|
||||||
grounded = [label for label in cited if label in valid_labels]
|
grounded = [label for label in cited if label in valid_labels]
|
||||||
if not text or not grounded:
|
if not text or not grounded or _is_template_echo(text):
|
||||||
# No text, or every cited label was invented. Either way there is
|
# No text, every cited label was invented, or the model copied the
|
||||||
|
# shape example through instead of filling it in. Either way there is
|
||||||
# nothing the operator can check it against.
|
# nothing the operator can check it against.
|
||||||
dropped += 1
|
dropped += 1
|
||||||
continue
|
continue
|
||||||
|
|
@ -327,11 +330,26 @@ def _verified_claims(
|
||||||
needs_check = tuple(
|
needs_check = tuple(
|
||||||
str(item).strip()
|
str(item).strip()
|
||||||
for item in (document.get("needs_check") or [])
|
for item in (document.get("needs_check") or [])
|
||||||
if str(item).strip()
|
if str(item).strip() and not _is_template_echo(str(item))
|
||||||
)
|
)
|
||||||
return tuple(claims), dropped, needs_check
|
return tuple(claims), dropped, needs_check
|
||||||
|
|
||||||
|
|
||||||
|
def _is_template_echo(text: str) -> bool:
|
||||||
|
"""True when the model returned our own shape example instead of content.
|
||||||
|
|
||||||
|
Citation checking alone does not catch this: qwen2.5:0.5b echoed the
|
||||||
|
placeholder as the claim text while citing every real label, so the claim was
|
||||||
|
perfectly "grounded" and completely empty. The placeholder is ours, so the
|
||||||
|
check can be exact rather than heuristic.
|
||||||
|
"""
|
||||||
|
stripped = text.strip()
|
||||||
|
if CLAIM_TEXT_PLACEHOLDER in stripped or NEEDS_CHECK_PLACEHOLDER in stripped:
|
||||||
|
return True
|
||||||
|
# Any other angle-bracket-wrapped stand-in is the same failure.
|
||||||
|
return stripped.startswith("<") and stripped.endswith(">")
|
||||||
|
|
||||||
|
|
||||||
def _load_json_object(raw: str) -> dict[str, Any] | None:
|
def _load_json_object(raw: str) -> dict[str, Any] | None:
|
||||||
text = (raw or "").strip()
|
text = (raw or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,19 @@ EVIDENCE_LABEL_PREFIX = "E"
|
||||||
# carries the labels it rests on as a field, so verification is a set-membership
|
# carries the labels it rests on as a field, so verification is a set-membership
|
||||||
# test on that field — not an attempt to parse Korean sentences and guess which
|
# test on that field — not an attempt to parse Korean sentences and guess which
|
||||||
# words a trailing citation covers.
|
# words a trailing citation covers.
|
||||||
|
# The placeholders used in the shape example below. A small model can copy them
|
||||||
|
# through verbatim instead of filling them in — observed with qwen2.5:0.5b, which
|
||||||
|
# returned "<한국어 문장>" as the claim text while citing every real label, so the
|
||||||
|
# citation check passed it. Naming them here lets the verifier reject the echo.
|
||||||
|
CLAIM_TEXT_PLACEHOLDER = "<한국어 문장>"
|
||||||
|
NEEDS_CHECK_PLACEHOLDER = "<한국어 구절>"
|
||||||
|
|
||||||
SYSTEM_PROMPT = (
|
SYSTEM_PROMPT = (
|
||||||
"You draft review notes for an internal image-rights operator, grounded "
|
"You draft review notes for an internal image-rights operator, grounded "
|
||||||
"ONLY in the labelled source evidence given to you.\n"
|
"ONLY in the labelled source evidence given to you.\n"
|
||||||
"Reply with a JSON object and nothing else, in exactly this shape:\n"
|
"Reply with a JSON object and nothing else, in exactly this shape:\n"
|
||||||
'{"claims": [{"text": "<한국어 문장>", "evidence": ["E1"]}], '
|
'{"claims": [{"text": "' + CLAIM_TEXT_PLACEHOLDER + '", "evidence": ["E1"]}], '
|
||||||
'"needs_check": ["<한국어 구절>"]}\n'
|
'"needs_check": ["' + NEEDS_CHECK_PLACEHOLDER + '"]}\n'
|
||||||
"Rules:\n"
|
"Rules:\n"
|
||||||
"- Write every 'text' value in Korean.\n"
|
"- Write every 'text' value in Korean.\n"
|
||||||
"- Every claim MUST cite at least one evidence label it rests on, using "
|
"- Every claim MUST cite at least one evidence label it rests on, using "
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,14 @@ from rights_filter.integrations.google_custom_search import (
|
||||||
from rights_filter.integrations.naver_search import NaverOpenApiSearchClient, NaverSearchAdapter
|
from rights_filter.integrations.naver_search import NaverOpenApiSearchClient, NaverSearchAdapter
|
||||||
from rights_filter.integrations.search_policy import SearchApiPolicy
|
from rights_filter.integrations.search_policy import SearchApiPolicy
|
||||||
|
|
||||||
DEFAULT_OLLAMA_MODEL = "qwen2.5:3b-instruct"
|
# Chosen by measurement against real stored evidence (54 rows, 12 selected) on
|
||||||
|
# the GPU target, not by parameter count:
|
||||||
|
# qwen2.5:0.5b-instruct 55.7s — echoed the shape example as its claim text
|
||||||
|
# qwen2.5:7b 8.8s — held the contract, 2 grounded claims
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ from rights_filter.analysis.llm_assistance import (
|
||||||
_evidence_prompt,
|
_evidence_prompt,
|
||||||
_select_prompt_evidence,
|
_select_prompt_evidence,
|
||||||
)
|
)
|
||||||
|
from rights_filter.analysis.llm_prompts import (
|
||||||
|
CLAIM_TEXT_PLACEHOLDER,
|
||||||
|
NEEDS_CHECK_PLACEHOLDER,
|
||||||
|
)
|
||||||
from rights_filter.domain.records import Evidence, EvidenceSource
|
from rights_filter.domain.records import Evidence, EvidenceSource
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -81,6 +85,31 @@ def test_claim_citing_an_invented_label_is_dropped():
|
||||||
assert "지어낸" not in result.text
|
assert "지어낸" not in result.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_that_echoes_the_shape_example_is_dropped():
|
||||||
|
# Observed with qwen2.5:0.5b against real evidence: it returned the
|
||||||
|
# placeholder as the claim text while citing every real label, so citation
|
||||||
|
# checking alone passed it as perfectly grounded and completely empty.
|
||||||
|
client, _ = _client(
|
||||||
|
{"claims": [{"text": CLAIM_TEXT_PLACEHOLDER, "evidence": ["E1"]}],
|
||||||
|
"needs_check": [NEEDS_CHECK_PLACEHOLDER]}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = client.summarize_evidence_detailed([_web()])
|
||||||
|
|
||||||
|
assert result.claims == ()
|
||||||
|
assert result.dropped_claims == 1
|
||||||
|
assert result.needs_check == ()
|
||||||
|
assert CLAIM_TEXT_PLACEHOLDER not in result.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_any_angle_bracket_placeholder_is_dropped():
|
||||||
|
client, _ = _client({"claims": [{"text": "<summary here>", "evidence": ["E1"]}]})
|
||||||
|
|
||||||
|
result = client.summarize_evidence_detailed([_web()])
|
||||||
|
|
||||||
|
assert result.claims == ()
|
||||||
|
|
||||||
|
|
||||||
def test_claim_without_any_citation_is_dropped():
|
def test_claim_without_any_citation_is_dropped():
|
||||||
client, _ = _client({"claims": [{"text": "근거 없는 단정.", "evidence": []}]})
|
client, _ = _client({"claims": [{"text": "근거 없는 단정.", "evidence": []}]})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue