fix: "아직 출시되지 않았다" 같은 미검증 부재 주장도 근거 검증 대상에 포함

RTX 4080 vs 5060 비교 요청에서 모델이 답을 못 주는 이유로
"RTX 5060의 미출시 상태"를 단정했다. 실제로는 이미 출시돼 사용자
지서버에 꽂혀 있는 카드다.

기존 가드 셋이 전부 통과시킨 이유:
- AUTO-RECOVER: 검색을 하긴 했다 (도구 호출 있음)
- EMPTY-GROUNDING: 결과가 비어있지 않았다
- NUMERIC-GROUNDING: 답변에 수치가 0건이라 검증할 주장 자체가 없었다

세 가드 모두 숫자를 전제로 하는데, 이 답변의 유일한 거짓말에는
단위가 없었다.

부재 주장은 별도로 검증할 값어치가 있다. 모델은 "학습 데이터 시점
기준으로 출시 안 됨"만 알 수 있을 뿐 출시 여부 자체를 알 수 없다
(5060은 이 모델 컷오프 4개월 뒤 출시). 기억에 없는 것을 세상에 없는
것으로 바꿔 말하는 건 사실 판단이 아니라 범주 오류다. 검증 방법도
날짜 검증과 같다 — 출처가 정말 미출시라고 하면 그 표현이 출처 텍스트에
있고, 없으면 모델이 지어낸 것이다.

- checkNumericGrounding에 nonexistenceClaim/nonexistenceUnsupported 추가
- 출처 지지 판정(NONEXISTENCE_SUPPORT)은 느슨하게 잡았다. 느슨한 쪽이
  재시도를 억제할 뿐 만들어내지는 않는 안전한 실패 방향이다
- 사용자가 질문에 적은 전제("그거 아직 안 나왔지?")를 되풀이한 건
  숫자와 동일하게 검증 대상에서 제외
- 재프롬프트 문구를 분리했다. 기존 "출처에 있는 숫자만 써서 다시 써라"는
  이 경우 헛도는 지시다 — 고칠 숫자가 없고, 문제는 정반대(지어낸 사실을
  구실로 답변을 거절)이기 때문

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-11 13:05:15 +09:00
co-authored by Claude Opus 5
parent 9ab4da34e5
commit 23cd4886af
3 changed files with 137 additions and 1 deletions
+14
View File
@@ -1739,6 +1739,20 @@ async function handleChat(
&& numericGroundingForcedRetries < MAX_NUMERIC_GROUNDING_FORCED_RETRIES;
if (shouldForceNumericRetry) {
numericGroundingForcedRetries++;
// The "rewrite using only sourced numbers" wording below is the wrong instruction for a
// non-existence claim — that answer has no figures to rewrite, and its problem is the
// opposite (it declined to answer, using a made-up fact as the excuse). Give it its own.
if (_numeric.nonexistenceUnsupported && _numeric.nonexistenceClaim) {
const _claim = _numeric.nonexistenceClaim;
console.log(`[v2] NUMERIC-GROUNDING POST-CHECK (${numericGroundingForcedRetries}/${MAX_NUMERIC_GROUNDING_FORCED_RETRIES}): unsupported non-existence claim ["${_claim}"] not found in tool output. Re-prompting...`);
sendSSE('info', { message: 'Re-prompting model: "not released yet" claim was not in the search results...' });
messages.push({ role: 'assistant', content: candidateText });
messages.push({
role: 'user',
content: `You wrote "${_claim}", but nothing in this turn's tool results says that — you got it from your training data. You CANNOT know that something has not been released: your training data has a cutoff date, and anything released after it is missing from your memory whether it exists or not. Absence from your memory is not evidence of non-existence, and stating it as fact misleads the user. Search again for the thing itself (its release date, its specs) using its plain name, and answer from what comes back. If you still find nothing, say you could not verify it — never that it does not exist or has not been released.`,
});
continue;
}
const _badFigures = _numeric.dateUnsupported && _numeric.primaryDate
? [_numeric.primaryDate.text]
: _numeric.unsupported.map((c) => c.text);
+58 -1
View File
@@ -34,6 +34,20 @@
*
* False positives cost one wasted retry; false negatives ship an invented specification as a
* measurement, which is the failure this exists to catch.
*
* NOT ONLY NUMBERS (2026-08-11). Despite the filename, the checkable claim here is not always a
* figure. Asked to compare an RTX 4080 with an RTX 5060, the model searched, got pages it could
* not read, correctly refused to state any number — and then explained itself with "RTX 5060은
* 아직 출시되지 않은 차세대 모델이기 때문입니다". Every guard passed it: a tool WAS called, results
* were NOT empty, and an answer containing zero figures has zero claims to verify. The one false
* statement in it carried no unit.
*
* Non-existence claims are worth their own check because they are a knowledge-cutoff artifact with
* a fixed shape: a model can only ever know that something was unreleased AS OF its training data,
* so "hasn't been released" asserted from memory is a category error, not a fact (the 5060 shipped
* four months after this model's cutoff — and is in the user's own machine). They are also cheap
* to verify the same way dates are: if the sources really say a thing is unreleased, that phrasing
* is in the source text; if they don't, the model supplied it.
*/
/** Units whose numbers are worth verifying — the fabrication-prone, checkable kind. */
@@ -59,6 +73,10 @@ export interface NumericGroundingResult {
primaryDate: DateClaim | null;
/** True when that date does not appear in the sources in any recognised format. */
dateUnsupported: boolean;
/** The "hasn't been released / doesn't exist" phrase the answer used, if any. */
nonexistenceClaim: string | null;
/** True when that claim appears nowhere in the sources — the model supplied it from memory. */
nonexistenceUnsupported: boolean;
/** The unit the question asked about, when it named one. */
askedUnit: string | null;
/** True when every claim in scope is unsupported — the caller's retry trigger. */
@@ -91,6 +109,33 @@ const ASKS_FOR_DATE = /몇\s*년|언제|출시\s*(일|날짜|시기)|며칠|when
export interface DateClaim { year: string; month: string; text: string; }
/**
* "X는 아직 출시되지 않았다" / "doesn't exist yet" and friends. Kept narrow on purpose: only
* release/announcement/existence verbs, not every negation. "한국에는 정식 발매되지 않았습니다"
* is the same class of claim and is caught too — it is equally unverifiable from memory.
*/
const NONEXISTENCE_CLAIM =
/(출시|발매|공개|발표)\s*(되지|된\s*적이|하지|한\s*적이)\s*(않|없)|미출시|출시\s*전\s*(이|인|의|상태)|아직\s*나오지\s*않|나온\s*적이?\s*없|존재하지\s*않|(?:is|are|has|have|had)\s*(?:not|n't)\s*(?:yet\s*)?(?:been\s*)?(?:released|launched|announced|available)|(?:hasn't|haven't|isn't|aren't)\s*(?:yet\s*)?(?:been\s*)?(?:released|launched|announced|available)|does(?:\s*not|n't)\s*exist|unreleased|not\s*yet\s*(?:released|launched|announced|out)/i;
/**
* The sources themselves saying a thing is unreleased/upcoming. Deliberately generous — a loose
* match here means the guard stays quiet, which is the safe direction: it only ever suppresses a
* retry, never produces one. The costly direction is claiming support that isn't there.
*/
const NONEXISTENCE_SUPPORT =
/미출시|출시\s*예정|공개\s*예정|발표\s*예정|출시되지\s*않|아직\s*출시|unreleased|not\s*yet\s*(released|launched|announced|available|out)|upcoming|rumou?red|leak(s|ed)?\b|expected\s*(to\s*)?(launch|release|arrive)/i;
/** Does the answer assert that something has not been released / does not exist? */
export function statesNonexistence(answer: string): string | null {
const m = withoutCode(answer).match(NONEXISTENCE_CLAIM);
return m ? m[0].trim() : null;
}
/** Do the sources back up a non-existence claim, in any phrasing? */
export function isNonexistenceSupported(source: string): boolean {
return NONEXISTENCE_SUPPORT.test(String(source || ''));
}
export function extractDateClaims(text: string): DateClaim[] {
const out: DateClaim[] = [];
const re = new RegExp(DATE_CLAIM.source, 'g');
@@ -207,15 +252,27 @@ export function checkNumericGrounding(
const primaryDate = dateClaims.filter(d => !askedDates.has(d.text))[0] || null;
const dateUnsupported = !!primaryDate && source.trim().length > 0 && !isDateSupported(primaryDate, source);
// A non-existence claim the user themselves put in the question ("5060은 아직 안 나왔지?") is the
// premise being echoed, not something the model asserted — same exemption the numbers get above.
const nonexistenceClaim = statesNonexistence(String(userMessage || ''))
? null
: statesNonexistence(answer);
const nonexistenceUnsupported =
!!nonexistenceClaim && source.trim().length > 0 && !isNonexistenceSupported(source);
return {
claims,
unsupported,
primaryClaim,
primaryDate,
dateUnsupported,
nonexistenceClaim,
nonexistenceUnsupported,
askedUnit: askedUnits ? askedUnits[0] : null,
// No claims at all → nothing to verify. Sources empty is handled upstream by the
// empty-grounding check, so treat it as "cannot verify" rather than "all fabricated".
allUnsupported: (!!primaryClaim && source.trim().length > 0 && unsupported.includes(primaryClaim)) || dateUnsupported,
allUnsupported: (!!primaryClaim && source.trim().length > 0 && unsupported.includes(primaryClaim))
|| dateUnsupported
|| nonexistenceUnsupported,
};
}
+65
View File
@@ -178,3 +178,68 @@ describe('순위는 의도적으로 검증하지 않는다', () => {
assert.equal(r.allUnsupported, false);
});
});
describe('"아직 출시 안 됐다" 주장 — 숫자가 없어도 검증한다', () => {
// 2026-08-11 실제 사고. RTX 4080 vs 5060 비교 요청에 모델이 검색은 했고(→AUTO-RECOVER 통과),
// 결과도 비어있지 않았고(→EMPTY-GROUNDING 통과), 수치를 하나도 안 썼다(→기존 숫자검증에 걸릴
// 주장 0건). 그러면서 "RTX 5060의 미출시 상태"를 답을 못 주는 이유로 단정했다 — 실제로는
// 사용자 지서버에 꽂혀 있는 카드다. 모델 학습 컷오프(2025-01) 4개월 뒤 출시라 기억에 없을 뿐인데,
// "내 기억에 없다"를 "세상에 없다"로 바꿔 말한 것이다.
const REAL_ANSWER = `현재 검색 결과만으로는 RTX 4080과 RTX 5060의 구체적인 성능 차이를 확정적으로 말씀드리기 어렵습니다.
- 비교 데이터 부재: 기술적 비교를 시도한 페이지는 현재 접속이 불가능한 상태입니다.
- RTX 5060의 미출시 상태: NVIDIA 공식 페이지에서는 RTX 50 시리즈에 대한 일반적인 소개만 있을 뿐입니다.`;
test('출처에 근거가 없는 미출시 단정을 잡는다', () => {
const r = checkNumericGrounding(
REAL_ANSWER,
['GeForce RTX 50 시리즈 - Blackwell 아키텍처 기반 그래픽카드 라인업 소개'],
'5060 보다 얼마나 빠르지 4080?',
);
assert.equal(r.nonexistenceUnsupported, true);
assert.equal(r.allUnsupported, true);
});
test('출처가 실제로 미출시라고 하면 발동하지 않는다', () => {
const r = checkNumericGrounding(
'RTX 6090은 아직 출시되지 않았습니다.',
['RTX 6090 is not yet released; NVIDIA has announced an expected launch in late 2027.'],
'RTX 6090 나왔어?',
);
assert.equal(r.nonexistenceUnsupported, false);
assert.equal(r.allUnsupported, false);
});
test('사용자가 질문에 적은 전제를 되풀이한 것은 모델의 주장이 아니다', () => {
const r = checkNumericGrounding(
'말씀하신 대로 아직 출시되지 않은 제품이라 벤치마크가 없습니다.',
['관련 정보를 찾을 수 없습니다'],
'그거 아직 출시되지 않았지?',
);
assert.equal(r.nonexistenceClaim, null);
assert.equal(r.nonexistenceUnsupported, false);
});
test('출처가 아예 없으면 판정하지 않는다 (EMPTY-GROUNDING이 담당)', () => {
const r = checkNumericGrounding('아직 출시되지 않았습니다.', [], '나왔어?');
assert.equal(r.nonexistenceUnsupported, false);
});
test('영어 표현도 잡는다', () => {
const r = checkNumericGrounding(
'The RTX 5060 has not been released yet, so no benchmarks exist.',
['NVIDIA GeForce graphics cards overview page'],
'how much faster is the 4080 than the 5060?',
);
assert.equal(r.nonexistenceUnsupported, true);
});
test('부정문이라고 다 잡지는 않는다 — 출시/존재 동사만', () => {
const r = checkNumericGrounding(
'이 기능은 무료 요금제에서는 지원되지 않습니다.',
['Free plan feature comparison table'],
'무료로 쓸 수 있어?',
);
assert.equal(r.nonexistenceClaim, null);
assert.equal(r.allUnsupported, false);
});
});