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:
changukyu 2026-07-25 23:48:23 +09:00
parent 5c05382944
commit 6bbaaf6e23
6 changed files with 96 additions and 10 deletions

View file

@ -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

View file

@ -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 시점에 재론해도 된다.

View file

@ -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:

View file

@ -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 "

View file

@ -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

View file

@ -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": []}]})