From 6bbaaf6e23cf3f03bab54f045ea8b1957df3bffb Mon Sep 17 00:00:00 2001 From: changukyu Date: Sat, 25 Jul 2026 23:48:23 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=98=95=EC=8B=9D=20=EC=98=88=EC=8B=9C?= =?UTF-8?q?=20=EB=B0=98=ED=96=A5=20=EC=B0=A8=EB=8B=A8,=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EB=AA=A8=EB=8D=B8=EC=9D=84=20=EC=8B=A4=EC=B8=A1?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20qwen2.5:7b=20=EB=A1=9C=20=ED=99=95?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실모델 프로브에서 인용 검증만으로는 못 잡는 실패 모드가 드러났다. ## 형식 예시 반향(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. --- .env.example | 10 ++++--- ...7-25-ai-capability-expansion-brainstorm.md | 23 +++++++++++++++ src/rights_filter/analysis/llm_assistance.py | 24 +++++++++++++-- src/rights_filter/analysis/llm_prompts.py | 11 +++++-- src/rights_filter/integrations/env_clients.py | 9 +++++- .../analysis/test_llm_grounding.py | 29 +++++++++++++++++++ 6 files changed, 96 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index d130123..6e4c5b5 100644 --- a/.env.example +++ b/.env.example @@ -45,10 +45,12 @@ COPYRIGHTER_GOOGLE_CUSTOM_SEARCH_DAILY_LIMIT=100 COPYRIGHTER_LLM_DAILY_LIMIT=100 OLLAMA_BASE_URL=http://localhost:11434 -# 3B is the floor for reading evidence and citing it. 0.5B ignored the output -# contract (answered in English, reprinted the evidence list). Pull it on the -# build machine: `ollama pull qwen2.5:3b-instruct`. -OLLAMA_MODEL=qwen2.5:3b-instruct +# Measured against real stored evidence on the GPU target: +# qwen2.5:0.5b-instruct 55.7s — returned the prompt's shape example verbatim +# qwen2.5:7b 8.8s — held the contract <- default +# 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 # truncates past it — the summary would then cite evidence the model never read. OLLAMA_NUM_CTX=8192 diff --git a/docs/brainstorms/2026-07-25-ai-capability-expansion-brainstorm.md b/docs/brainstorms/2026-07-25-ai-capability-expansion-brainstorm.md index 66c0336..a3f334f 100644 --- a/docs/brainstorms/2026-07-25-ai-capability-expansion-brainstorm.md +++ b/docs/brainstorms/2026-07-25-ai-capability-expansion-brainstorm.md @@ -375,6 +375,29 @@ FWC = FEASIBLE_WITH_CHANGES(수정 조건부 실현가능). 24건 중 verdict가 **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개 **임베딩 저장 금지 정책의 개정 여부** — 의미지문 인덱스(22위)만 이 결정에 걸려 있고, 나머지 A~D는 전부 이 결정 없이 진행된다. 급하지 않으므로 D 시점에 재론해도 된다. diff --git a/src/rights_filter/analysis/llm_assistance.py b/src/rights_filter/analysis/llm_assistance.py index fa471f2..7c62286 100644 --- a/src/rights_filter/analysis/llm_assistance.py +++ b/src/rights_filter/analysis/llm_assistance.py @@ -7,10 +7,12 @@ from dataclasses import dataclass, field from typing import Any from rights_filter.analysis.llm_prompts import ( + CLAIM_TEXT_PLACEHOLDER, EMPTY_SUMMARY_TEXT, EVIDENCE_LABEL_PREFIX, LEGACY_PROMPT_VERSION, NEEDS_CHECK_HEADING, + NEEDS_CHECK_PLACEHOLDER, PROMPT_ID, PROMPT_VERSION, SUMMARY_HEADING, @@ -317,8 +319,9 @@ def _verified_claims( text = str(entry.get("text", "")).strip() cited = _normalized_labels(entry.get("evidence")) grounded = [label for label in cited if label in valid_labels] - if not text or not grounded: - # No text, or every cited label was invented. Either way there is + if not text or not grounded or _is_template_echo(text): + # 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. dropped += 1 continue @@ -327,11 +330,26 @@ def _verified_claims( needs_check = tuple( str(item).strip() 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 +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: text = (raw or "").strip() if not text: diff --git a/src/rights_filter/analysis/llm_prompts.py b/src/rights_filter/analysis/llm_prompts.py index 88d522a..b2206b6 100644 --- a/src/rights_filter/analysis/llm_prompts.py +++ b/src/rights_filter/analysis/llm_prompts.py @@ -28,12 +28,19 @@ EVIDENCE_LABEL_PREFIX = "E" # 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 # 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 = ( "You draft review notes for an internal image-rights operator, grounded " "ONLY in the labelled source evidence given to you.\n" "Reply with a JSON object and nothing else, in exactly this shape:\n" - '{"claims": [{"text": "<한국어 문장>", "evidence": ["E1"]}], ' - '"needs_check": ["<한국어 구절>"]}\n' + '{"claims": [{"text": "' + CLAIM_TEXT_PLACEHOLDER + '", "evidence": ["E1"]}], ' + '"needs_check": ["' + NEEDS_CHECK_PLACEHOLDER + '"]}\n' "Rules:\n" "- Write every 'text' value in Korean.\n" "- Every claim MUST cite at least one evidence label it rests on, using " diff --git a/src/rights_filter/integrations/env_clients.py b/src/rights_filter/integrations/env_clients.py index 203f904..2b43c54 100644 --- a/src/rights_filter/integrations/env_clients.py +++ b/src/rights_filter/integrations/env_clients.py @@ -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.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 diff --git a/tests/rights_filter/analysis/test_llm_grounding.py b/tests/rights_filter/analysis/test_llm_grounding.py index a71a2c1..517c43a 100644 --- a/tests/rights_filter/analysis/test_llm_grounding.py +++ b/tests/rights_filter/analysis/test_llm_grounding.py @@ -17,6 +17,10 @@ from rights_filter.analysis.llm_assistance import ( _evidence_prompt, _select_prompt_evidence, ) +from rights_filter.analysis.llm_prompts import ( + CLAIM_TEXT_PLACEHOLDER, + NEEDS_CHECK_PLACEHOLDER, +) 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 +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": "", "evidence": ["E1"]}]}) + + result = client.summarize_evidence_detailed([_web()]) + + assert result.claims == () + + def test_claim_without_any_citation_is_dropped(): client, _ = _client({"claims": [{"text": "근거 없는 단정.", "evidence": []}]})