"지금 태풍 돌핀 어디 있지?"에 도구를 하나도 부르지 않고 좌표·기압·풍속을 단언했다(로그 12065행, TOOL 호출 0건). 같은 수치가 일주일 전 답변(9119행, 8월 2일)과 글자까지 동일해서, 검색이 아니라 대화 기억에서 나온 값임이 드러났다. 실제로는 돌핀이 이미 오키나와를 통과한 뒤였다. 가드 세 개가 전부 통과시켰다: isLiveDataRequest false isFactualInfoRequest false looksLikeUnverifiedSpec false - 입력측: 라이브 데이터 패턴이 날씨/기온/환율/주가뿐이라 태풍이 없었다. 태풍·호우·폭우·폭설·한파·폭염·지진·해일·산불·홍수·가뭄·열대저압부 추가. - 출력측: 스펙 단위 목록이 GB/TFLOPS/원/% 계열뿐이라 hPa·m/s·위경도가 어디에도 없었다. 검증할 대상 자체를 못 본 것. 관측 단위(hPa/m/s/kt/km/h/mm/cm/℃)와 좌표(북위·동경 N.N°)를 추가했다. 관측 단위를 넣으면 정상 날씨 답변까지 되던질 위험이 있는데, 재검색 조건에 !hasGroundingToolCall이 있고 weather_가 grounding으로 인정되므로 도구로 받아온 31.5°C·2mm는 영향받지 않는다. 그 반대 방향을 테스트로 못박았다 — 이쪽이 깨지면 날씨 기능이 무한 재검색에 빠진다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
236 lines
11 KiB
TypeScript
236 lines
11 KiB
TypeScript
/**
|
|
* prompt-gates.test.ts
|
|
*
|
|
* Regression tests for the hardcoded regex gates that force/suppress model behavior.
|
|
* These are pure string->boolean functions with no I/O, which makes them the highest-value
|
|
* thing in the codebase to pin down: every one of them exists because a real production
|
|
* failure got through, and a silent regex regression re-opens that exact hole with no
|
|
* crash and no stack trace to notice it by.
|
|
*
|
|
* Cases marked [2026-07-29] come from a production log audit of a GPU/PCIe hardware chat
|
|
* where the model fabricated tok/s figures and benchmark percentages. They were verified by
|
|
* hand against the real transcript before being written here — do not "simplify" them into
|
|
* synthetic strings, the awkward phrasing is the point.
|
|
*/
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
looksLikeUnverifiedSpecClaim,
|
|
isExemptFromVerification,
|
|
isFactualInfoRequest,
|
|
isLiveDataRequest,
|
|
isNewsRequest,
|
|
isExecutionLikeRequest,
|
|
isGreetingLikeMessage,
|
|
isUsableGroundingResult,
|
|
claimsMessageSent,
|
|
isMessagingRequest,
|
|
isCancelIntent,
|
|
isRerunIntent,
|
|
} from '../src/gateway/guards/prompt-gates';
|
|
|
|
describe('looksLikeUnverifiedSpecClaim — 출력에서 검증 안 된 수치 감지', () => {
|
|
test('명시적 스펙 단위는 단독으로 걸린다', () => {
|
|
assert.equal(looksLikeUnverifiedSpecClaim('약 70~80GB 정도 필요합니다.'), true);
|
|
assert.equal(looksLikeUnverifiedSpecClaim('31 tok/s가 나옵니다.'), true);
|
|
assert.equal(looksLikeUnverifiedSpecClaim('약 15 TFLOPS 수준입니다.'), true);
|
|
});
|
|
|
|
// [2026-08-09] 실제 로그(12065행)의 태풍 답변. 기압/풍속/위경도는 어느 단위 목록에도 없어서
|
|
// 출력측 가드가 검증할 대상 자체를 못 봤다. GB/TFLOPS만큼이나 스펙적인 수치이고, 그것들과
|
|
// 달리 시시각각 변하므로 기억해서 쓴 값은 언제나 틀리다.
|
|
test('[2026-08-09] 기압·풍속·위경도 같은 관측 수치도 걸린다', () => {
|
|
assert.equal(
|
|
looksLikeUnverifiedSpecClaim(
|
|
'현재 태풍 돌핀의 위치는 북위 18.5°, 동경 150.0° 부근이며 중심기압 905hPa, 최대풍속 58m/s입니다.',
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(looksLikeUnverifiedSpecClaim('중심기압 905hPa를 유지하고 있습니다.'), true);
|
|
assert.equal(looksLikeUnverifiedSpecClaim('최대풍속 58m/s입니다.'), true);
|
|
assert.equal(looksLikeUnverifiedSpecClaim('북위 18.5° 부근에 있습니다.'), true);
|
|
});
|
|
|
|
test('[2026-07-29] 단위 없는 "N토큰"도 걸린다 — /초 접미사 없는 실제 사례', () => {
|
|
assert.equal(
|
|
looksLikeUnverifiedSpecClaim(
|
|
'PCIe 3.0 슬롯의 대역폭 한계로 인해 GPU 간 통신 병목이 발생하여, 70B나 31B 같은 대형 모델 구동 시 15토큰 안팎의 속도가 나오는 것이 맞습니다.',
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('[2026-07-29] 퍼센트 벤치마크는 스펙 문맥과 함께일 때 걸린다', () => {
|
|
assert.equal(
|
|
looksLikeUnverifiedSpecClaim(
|
|
'RTX 5060 Ti는 PCIe 5.0 x8 규격이라 PCIe 3.0 환경에서 구동할 때 성능 손실은 약 5% 안팎 수준입니다.',
|
|
),
|
|
true,
|
|
);
|
|
// 프로덕션 원문 그대로 — "GPU"가 들어있어서 스펙 문맥으로 걸린다.
|
|
assert.equal(
|
|
looksLikeUnverifiedSpecClaim(
|
|
'RTX 5060 Ti 2장을 묶어 텐서 병렬(Tensor Parallelism)로 LLM을 돌릴 때, PCIe 3.0 환경은 4.0에 비해 GPU 간 통신 병목으로 인해 토큰 처리 속도 손실이 약 15%~25% 정도까지 벌어집니다.',
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('[2026-07-29-2] 하드웨어 명사 없는 처리량 주장도 걸린다 — 이 테스트가 찾아낸 구멍', () => {
|
|
// 위 원문을 한 단어(GPU) 빼고 바꿔 쓰면 통과해버리던 케이스.
|
|
// SPEC_CONTEXT_KEYWORD에 토큰/대역폭/추론/처리속도를 추가해 막았다.
|
|
assert.equal(
|
|
looksLikeUnverifiedSpecClaim(
|
|
'PCIe 3.0은 4.0 대비 토큰 처리 속도 손실이 약 15%~25%까지 벌어집니다.',
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
looksLikeUnverifiedSpecClaim('메모리 대역폭이 약 40% 낮습니다.'),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('퍼센트가 스펙 문맥 없이 쓰이면 걸리지 않는다 (오탐 방지)', () => {
|
|
// % 를 STRICT tier에 넣었다면 이 셋이 전부 헛검색을 유발했을 것 — 이 테스트가 그 회귀를 막는다.
|
|
assert.equal(looksLikeUnverifiedSpecClaim('오늘 강수확률은 20%입니다.'), false);
|
|
assert.equal(looksLikeUnverifiedSpecClaim('이 상품은 지금 30% 할인 중이에요.'), false);
|
|
assert.equal(looksLikeUnverifiedSpecClaim('배터리가 15% 남았습니다.'), false);
|
|
});
|
|
|
|
test('일반 대화/숫자 없는 설명은 걸리지 않는다', () => {
|
|
assert.equal(looksLikeUnverifiedSpecClaim('저는 GLM 모델이며, 1+1은 2입니다.'), false);
|
|
assert.equal(looksLikeUnverifiedSpecClaim('오늘 날씨는 20도 정도입니다.'), false);
|
|
assert.equal(looksLikeUnverifiedSpecClaim('이 노트북은 90만원 정도 합니다.'), false);
|
|
assert.equal(looksLikeUnverifiedSpecClaim('트랜스포머는 어텐션 기반 구조입니다.'), false);
|
|
});
|
|
|
|
test('한국어 헤지 표현은 숫자 없이도 걸린다', () => {
|
|
assert.equal(looksLikeUnverifiedSpecClaim('대략 그 정도일 것으로 예상됩니다.'), true);
|
|
assert.equal(looksLikeUnverifiedSpecClaim('정확하진 않지만 그렇게 추정됩니다.'), true);
|
|
});
|
|
|
|
test('코드블록 안의 숫자는 무시한다', () => {
|
|
assert.equal(looksLikeUnverifiedSpecClaim('아래 코드입니다:\n```py\nSIZE_GB = 80\n```'), false);
|
|
});
|
|
|
|
test('빈 입력/널은 안전하게 false', () => {
|
|
assert.equal(looksLikeUnverifiedSpecClaim(''), false);
|
|
assert.equal(looksLikeUnverifiedSpecClaim(null as any), false);
|
|
assert.equal(looksLikeUnverifiedSpecClaim(undefined as any), false);
|
|
});
|
|
});
|
|
|
|
describe('isExemptFromVerification — 개인 인프라 별칭', () => {
|
|
test('사용자 개인 서버 별칭은 검색 강제에서 면제', () => {
|
|
assert.equal(isExemptFromVerification('지서버는 PCIe 3.0인가?'), true);
|
|
assert.equal(isExemptFromVerification('클로서버 사양 알려줘'), true);
|
|
});
|
|
|
|
test('일반 제품 질문은 면제 아님', () => {
|
|
assert.equal(isExemptFromVerification('RTX 5090 스펙 알려줘'), false);
|
|
});
|
|
});
|
|
|
|
describe('isFactualInfoRequest — 검증이 필요한 질문 분류', () => {
|
|
test('비교/스펙/가격 질문을 잡는다', () => {
|
|
assert.equal(isFactualInfoRequest('A100 vs V100 비교'), true);
|
|
assert.equal(isFactualInfoRequest('RTX 5090 가격은?'), true);
|
|
assert.equal(isFactualInfoRequest('실제 속도는 얼마나 되나 비교 도표'), true);
|
|
});
|
|
|
|
test('코딩/실행 요청은 제외 (검색이 아니라 실행할 일)', () => {
|
|
assert.equal(isFactualInfoRequest('로그인 페이지 만들어줘'), false);
|
|
assert.equal(isFactualInfoRequest('이 버그 고쳐줘'), false);
|
|
});
|
|
|
|
test('개인 인프라 질문은 제외 — 검색해도 나올 수 없음', () => {
|
|
assert.equal(isFactualInfoRequest('지서버 사양 비교해줘'), false);
|
|
});
|
|
|
|
test('인사말은 제외', () => {
|
|
assert.equal(isFactualInfoRequest('hello'), false);
|
|
assert.equal(isFactualInfoRequest(''), false);
|
|
});
|
|
});
|
|
|
|
describe('isNewsRequest / isLiveDataRequest', () => {
|
|
test('뉴스 요청을 잡는다', () => {
|
|
assert.equal(isNewsRequest('오늘 미국 주요 뉴스'), true);
|
|
assert.equal(isNewsRequest('속보 있어?'), true);
|
|
});
|
|
|
|
test('"무슨 소식 있어?"류 안부 인사는 뉴스가 아니다', () => {
|
|
// 이 관용구 예외가 없으면 잡담이 news_search 강제로 이어진다.
|
|
assert.equal(isNewsRequest('무슨 좋은 소식 있어?'), false);
|
|
assert.equal(isNewsRequest('별 소식 없지?'), false);
|
|
});
|
|
|
|
test('안부 관용구 + 실제 뉴스 키워드가 섞이면 뉴스로 본다', () => {
|
|
assert.equal(isNewsRequest('오늘 무슨 뉴스 있어?'), true);
|
|
});
|
|
|
|
test('날씨/환율 등 라이브 데이터', () => {
|
|
assert.equal(isLiveDataRequest('내일 구미 날씨'), true);
|
|
assert.equal(isLiveDataRequest('환율 알려줘'), true);
|
|
assert.equal(isLiveDataRequest('파이썬 리스트 정렬법'), false);
|
|
});
|
|
|
|
// [2026-08-09] 실제 로그(12065행): "지금 태풍 돌핀 어디 있지?"가 이 게이트를 통과해
|
|
// 검색 없이 답변됐고, 모델이 일주일 전(9119행, 8월 2일) 대화와 똑같은 좌표·기압·풍속을
|
|
// 그대로 다시 뱉었다. 재해 현황은 날씨만큼이나 시시각각 변한다.
|
|
test('태풍 등 재해 현황도 라이브 데이터다', () => {
|
|
assert.equal(isLiveDataRequest('지금 태풍 돌핀 어디 있지?'), true);
|
|
assert.equal(isLiveDataRequest('태풍 돌핀 세력이 어느 정도야?'), true);
|
|
assert.equal(isLiveDataRequest('방금 지진 났어?'), true);
|
|
assert.equal(isLiveDataRequest('내일 호우 예보 있나'), true);
|
|
});
|
|
});
|
|
|
|
describe('isUsableGroundingResult — 빈 검색결과 판별', () => {
|
|
test('실제 내용이 있으면 사용 가능', () => {
|
|
assert.equal(isUsableGroundingResult({ result: '[1] 어떤 기사 제목' }), true);
|
|
});
|
|
|
|
test('에러/빈문자/센티넬은 사용 불가 — 이게 false여야 정직한 재답변이 강제된다', () => {
|
|
assert.equal(isUsableGroundingResult({ error: true, result: 'x' }), false);
|
|
assert.equal(isUsableGroundingResult({ result: '' }), false);
|
|
assert.equal(isUsableGroundingResult({ result: ' ' }), false);
|
|
assert.equal(isUsableGroundingResult({ result: '(no articles found)' }), false);
|
|
assert.equal(isUsableGroundingResult({ result: '에 대한 검색 결과가 없습니다.' }), false);
|
|
});
|
|
});
|
|
|
|
describe('메시징 완료 주장 검증', () => {
|
|
test('전송 요청을 잡는다', () => {
|
|
assert.equal(isMessagingRequest('이메일 보내줘'), true);
|
|
assert.equal(isMessagingRequest('카톡으로 전송해줘'), true);
|
|
assert.equal(isMessagingRequest('이메일 읽어줘'), false);
|
|
});
|
|
|
|
test('보냈다는 주장을 잡는다 — 도구 호출 없이 이러면 재확인이 강제된다', () => {
|
|
assert.equal(claimsMessageSent('메일 보냈습니다.'), true);
|
|
assert.equal(claimsMessageSent('전송 완료했어요.'), true);
|
|
assert.equal(claimsMessageSent('보낼까요?'), false);
|
|
});
|
|
});
|
|
|
|
describe('의도 분류', () => {
|
|
test('취소/재실행 의도', () => {
|
|
assert.equal(isCancelIntent('취소해'), true);
|
|
assert.equal(isCancelIntent('그만해'), true);
|
|
assert.equal(isRerunIntent('다시 실행해줘'), true);
|
|
assert.equal(isRerunIntent('안녕'), false);
|
|
});
|
|
});
|
|
|
|
describe('보조 분류기', () => {
|
|
test('실행형 요청 / 인사말', () => {
|
|
assert.equal(isExecutionLikeRequest('파일 만들어줘'), true);
|
|
assert.equal(isExecutionLikeRequest('오늘 날씨'), false);
|
|
assert.equal(isGreetingLikeMessage('hello'), true);
|
|
assert.equal(isGreetingLikeMessage('hello, search the web for X'), false);
|
|
});
|
|
});
|