- 2026-09-25 작업실 실측(로봇 프로젝트 채팅): 모델이 '먼저 GitHub에서 관련 리포지토리를 찾겠습니다'만 내놓고 도구 0개로 턴 종료, 사용자가 '?'로 되물어도 사과+예고만 반복. deferral 넛지는 '잠시만 기다려'류만 잡고 실행형 요청은 아예 제외라 이 케이스가 두 망 다 통과했다. - isActionPromiseNoTool: 답 끝부분(마지막 220자)이 행동 동사+미래 서약 (~하겠습니다/~해 보겠습니다/~시작합니다) 또는 let me / I'll … 로 끝나는지. 과거형·의문형·의례적 맺음말은 제외, 800자 이상 완성 답변도 제외. - handleChat: 이번 턴 도구 호출 0개일 때만 1회 넛지(라운드 소진 방지), 로그줄 '[v2] ACTION-PROMISE POST-CHECK'가 발동 증거. 테스트 3건(실측 문구 포함) 추가. Co-Authored-By: Claude Code <noreply@anthropic.com>
536 lines
29 KiB
TypeScript
536 lines
29 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,
|
|
isDisasterRequest,
|
|
isFactualThreadFollowUp,
|
|
confirmsUserGuessWithoutGrounding,
|
|
isActionPromiseNoTool,
|
|
} 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);
|
|
});
|
|
|
|
// [2026-08-09] "현재 시어엔진 상태 어때?"가 실사용 로그에 나왔는데 이 목록에 없었다.
|
|
// SearXNG의 사용자 별칭([[feedback_searxng_nickname]]) — 지서버/클로서버와 같은 이유로
|
|
// 웹검색이 검증할 수 없는 로컬 인프라다.
|
|
test('시어엔진(SearXNG 별칭)도 면제', () => {
|
|
assert.equal(isExemptFromVerification('현재 시어엔진 상태 어때?'), true);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// [2026-08-11] 실제 사고: "5060 보다 얼마나 빠르지 4080?"에 도구 호출 0건, 모델이 "RTX 5060은
|
|
// 아직 출시되지 않은 차세대 모델"이라고 완전히 지어냈다 — 실제로는 이미 출시돼 지서버가 쓰고
|
|
// 있는 카드(RTX 5060 Ti). "성능"/"비교" 같은 기존 키워드를 하나도 안 써서 게이트를 그냥
|
|
// 통과했다. "A보다 (얼마나) 빠른/느린/좋은" 캐주얼 비교 표현을 형용사로 넓게 잡는다.
|
|
test('[2026-08-11] "A보다 빠른가" 캐주얼 비교 표현도 잡는다', () => {
|
|
assert.equal(isFactualInfoRequest('5060 보다 얼마나 빠르지 4080?'), true);
|
|
assert.equal(isFactualInfoRequest('이게 저것보다 느린가?'), true);
|
|
assert.equal(isFactualInfoRequest('아이폰이 갤럭시보다 나은가'), true);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// [2026-08-09] 실제 사고(로그 12804행 세션 papa@ca71bc1c): "마두로 미국에 잡혀가지
|
|
// 않았나?"에 세 턴 연속 도구 호출 없이 "체포되지 않았다"고 확신에 차 정정했는데, 실제로는
|
|
// 사용자가 맞았다(마두로는 이미 미국에 체포·압송된 상태). 공인의 체포/사임/사망 같은 신상
|
|
// 변동도 날씨·재해만큼 시시각각 변하는데 아무 가드에도 안 걸렸다.
|
|
test('체포·사임 등 공인 신상 변동도 라이브 데이터다', () => {
|
|
assert.equal(isLiveDataRequest('마두로 미국에 잡혀가지 않았나?'), true);
|
|
assert.equal(isLiveDataRequest('얼마전 미국에 잡혀가 구속 상태자너?'), true);
|
|
assert.equal(isLiveDataRequest('그 배우 구속됐대?'), true);
|
|
assert.equal(isLiveDataRequest('대통령 하야했다는게 사실이야?'), true);
|
|
});
|
|
|
|
// "구속"은 DB/코딩에서 "제약조건"(구속조건, ~에 구속받지 않고) 뜻으로도 흔히 쓰인다.
|
|
// 체포 맥락의 활용형(구속됐/구속영장/구속 상태 등)일 때만 걸려야, 정상적인 설계·코딩
|
|
// 질문이 "지금 답하지 말고 검색부터 하라"는 리마인더에 걸려 방해받지 않는다.
|
|
test('"구속"의 비체포 의미(제약조건)는 라이브 데이터로 안 잡는다', () => {
|
|
assert.equal(isLiveDataRequest('이 조건에 구속받지 않고 자유롭게 설계해줘'), false);
|
|
assert.equal(isLiveDataRequest('변수 스코프 구속 범위가 어떻게 되지'), false);
|
|
});
|
|
|
|
// 날씨/재해/체포 같은 화제별 목록은 결국 화이트홀이다 — 다음번엔 제3의 화제에서 또 뚫린다.
|
|
// "최근/지금/현재"가 들어가면 화제를 안 가리고 걸리도록 일반화했다. 근거는 실사용 로그
|
|
// 403건 전수조사: 이 세 표현이 들어간 66건(16.4%) 중 오탐 0건 — 전부 비트코인 시세, 대통령이
|
|
// 누구인지, 서울 날씨 등 정당한 실시간 조회였다. "요즘"은 실사용 예가 없어 후보에서 뺐다.
|
|
test('[2026-08-09] "최근/지금/현재"는 화제 무관하게 라이브 데이터로 본다', () => {
|
|
assert.equal(isLiveDataRequest('비트코인 현재 가격이 얼마야?'), true);
|
|
assert.equal(isLiveDataRequest('지금 한국 대통령이 누구야?'), true);
|
|
assert.equal(isLiveDataRequest('최근에 나온 라마(Llama) 모델 버전이 뭐야?'), true);
|
|
assert.equal(isLiveDataRequest('베네수엘라는 지금 어때?'), true);
|
|
});
|
|
|
|
test('시점표현이 있어도 코딩/실행형 요청은 라이브 데이터가 아니다', () => {
|
|
// "지금 이 코드 확인해줘"에 "검색부터 하라"는 리마인더가 끼어들면 안 된다.
|
|
assert.equal(isLiveDataRequest('지금 이 코드 확인해줘'), false);
|
|
assert.equal(isLiveDataRequest('현재 파일 구조 만들어줘'), false);
|
|
});
|
|
|
|
test('시점표현이 있어도 개인 인프라 질문은 라이브 데이터가 아니다', () => {
|
|
// 지서버/시어엔진은 웹검색으로 검증 불가능하다 — isLiveDataRequest가 이 예외를 아예
|
|
// 거치지 않던 기존 버그도 함께 고쳐졌다.
|
|
assert.equal(isLiveDataRequest('지서버 최근에 왜 이렇게 느려'), false);
|
|
assert.equal(isLiveDataRequest('현재 시어엔진 상태 어때?'), false);
|
|
});
|
|
|
|
// [2026-08-10] 실사용 104건 대조에서 나온 유일한 오탐: 7,583자짜리 고소장 작성 요청이 본문
|
|
// 한가운데(2578자 지점)의 "시세"(장물 시세) 때문에 걸렸다. 매칭된 단어가 사용자의 '요청'이
|
|
// 아니라 붙여넣은 '자료' 쪽에 있었던 것 — 문서를 써 달라는 턴에 "검색부터 하라"는 리마인더가
|
|
// 붙으면 토큰 낭비를 넘어 작업 방향을 반대로 민다.
|
|
test('긴 붙여넣기 문서 한가운데의 우연한 키워드로는 라이브 데이터가 되지 않는다', () => {
|
|
const 고소장 = '다음 사건 정보를 바탕으로 고소장을 작성해주세요.\n\n[사건정보]\n'
|
|
+ '피해자 진술을 정리한 내용입니다. 관련 경위를 시간순으로 적었습니다.\n'.repeat(12)
|
|
+ '피해품의 시세는 최근 거래가 기준으로 산정하였음.\n'
|
|
+ '진술을 계속 정리한 내용입니다. 참고자료를 덧붙였습니다.\n'.repeat(12)
|
|
+ '\n위 내용으로 고소장을 작성해 주세요.';
|
|
assert.ok(고소장.length > 600);
|
|
assert.equal(isLiveDataRequest(고소장), false);
|
|
});
|
|
|
|
// 앞선 시도는 "길다 + 실행형"으로 막으려 했는데, 그 메시지가 실행형으로 분류된 근거가 본문의
|
|
// "화면" 한 단어였다 — 막으려는 문제와 똑같은 우연 위에 얹은 수정이라 이 케이스가 그걸 잡는다.
|
|
test('실행형 키워드가 없는 긴 문서에도 적용된다', () => {
|
|
const 자료 = '아래 회의록을 정리해 주세요.\n'
|
|
+ '참석자들이 논의한 사항을 순서대로 기록하였습니다.\n'.repeat(20)
|
|
+ '환율 관련 언급이 한 차례 있었습니다.\n'
|
|
+ '이후 논의는 다음 안건으로 넘어갔습니다.\n'.repeat(20);
|
|
assert.ok(자료.length > 600);
|
|
assert.equal(isLiveDataRequest(자료), false);
|
|
});
|
|
|
|
test('장문이어도 지시부에 시점표현이 있으면 라이브 데이터다', () => {
|
|
// 길이만으로 끄면 안 된다 — 앞뒤(지시부)에 있으면 그대로 걸려야 한다.
|
|
const 장문질문 = '지금 한반도 주변에 발달한 태풍이 있는지 궁금합니다. '
|
|
+ '지난주에 뉴스에서 얼핏 본 기억이 있어서 배경을 좀 적어봅니다. '.repeat(20);
|
|
assert.ok(장문질문.length > 600);
|
|
assert.equal(isLiveDataRequest(장문질문), true);
|
|
|
|
const 끝에지시 = '아래는 제가 정리해둔 자료입니다.\n'
|
|
+ '작년에 다녀온 여행 기록을 옮겨 적었습니다.\n'.repeat(25)
|
|
+ '\n그건 그렇고 지금 서울 날씨 어때?';
|
|
assert.ok(끝에지시.length > 600);
|
|
assert.equal(isLiveDataRequest(끝에지시), true);
|
|
});
|
|
});
|
|
|
|
// [2026-08-10] isLiveDataRequest의 인라인 재해 정규식을 분리한 것 — handle-chat.ts의 검색
|
|
// 리마인더가 재해 질문에도 news_search를 권하도록 재사용하기 위함. 실제 사고: "태풍 돌핀 지금
|
|
// 어디 있지?"에 web_search가 자체 생성한 "현재 위치 실시간 정보" 쿼리로 검색해 일반적인/내용없는
|
|
// 결과 3개만 받았고, 모델은 정직하게(수치를 지어내지 않고) "이미 소멸했을 가능성이 높다"고
|
|
// 추론했다 — 틀린 결론이었다. 같은 날 같은 사건을 다룬 국내 언론사 기사가 6개 이상 있었는데,
|
|
// "태풍 돌핀 중국 상륙 피해"처럼 상황어를 넣은 쿼리로는 바로 찾아졌다. 추론 자체는 멀쩡했지만
|
|
// 전제(검색해도 안 나온다)가 검색 실패 때문에 거짓이었다 — news_search(NewsData.io, 실제
|
|
// 발행일 보장)가 정확히 이런 "지금 벌어지고 있는 일" 질문을 위한 도구다.
|
|
describe('isDisasterRequest — 재해 질문을 news_search로 유도', () => {
|
|
test('태풍/지진 등 재해 키워드를 잡는다', () => {
|
|
assert.equal(isDisasterRequest('태풍 돌핀 지금 어디 있지?'), true);
|
|
assert.equal(isDisasterRequest('방금 지진 났어?'), true);
|
|
assert.equal(isDisasterRequest('산불 피해 상황 어때'), true);
|
|
});
|
|
|
|
test('재해 키워드 없는 일반 질문은 안 잡는다', () => {
|
|
assert.equal(isDisasterRequest('오늘 서울 날씨 어때'), false);
|
|
assert.equal(isDisasterRequest('파이썬 리스트 정렬법'), false);
|
|
});
|
|
|
|
test('긴 붙여넣기 문서 한가운데의 우연한 재해 키워드로는 안 잡힌다', () => {
|
|
const doc = '회의록을 정리해주세요.\n'
|
|
+ '참석자 발언을 순서대로 기록했습니다.\n'.repeat(20)
|
|
+ '작년 태풍 피해 복구 예산 집행 내역을 참고자료로 첨부합니다.\n'
|
|
+ '이후 논의는 다음 안건으로 넘어갔습니다.\n'.repeat(20);
|
|
assert.ok(doc.length > 600);
|
|
assert.equal(isDisasterRequest(doc), false);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 2026-09-04 메인챗 세션 감사에서 나온 구멍 두 가지.
|
|
* 인용은 전부 세션 0c367bd7의 실제 발화·답변이다.
|
|
*/
|
|
describe('제품·모델 선택 질문 (2026-09-04)', () => {
|
|
// 이 세 턴은 연속으로 도구 호출 0건이었고, 2026년 9월에 "Gemma 2 27B"를 로컬 추천으로 냈다.
|
|
test('"있을까"·"고른다면" 형태를 잡는다 — 패치 전에는 셋 다 놓쳤다', () => {
|
|
assert.equal(isFactualInfoRequest('소설을 특별히 잘 쓰는 모델이 있을까?'), true);
|
|
assert.equal(isFactualInfoRequest('올라마에서 고른다면?'), true);
|
|
assert.equal(isFactualInfoRequest('어떤 게 좋아?'), true);
|
|
});
|
|
|
|
test('"신형/최신"은 정의상 학습 시점 이후를 묻는 것이라 항상 확인 대상', () => {
|
|
assert.equal(isFactualInfoRequest('맥미니 신형이 나왔는데 시장반응은 어때?'), true);
|
|
assert.equal(isFactualInfoRequest('최신 버전 나왔어?'), true);
|
|
});
|
|
|
|
test('잡담·감상·실행 요청은 그대로 통과시킨다', () => {
|
|
for (const m of [
|
|
'모네의 정원 흉내 내고 있지..ㅎ',
|
|
'일종의 폰지 스킴이란거군',
|
|
'분식회계도 많이 하겠군',
|
|
'거기 뭘 심을까 고민중..',
|
|
'소설 한 편 써줘',
|
|
'이 코드 좀 고쳐줘',
|
|
]) {
|
|
assert.equal(isFactualInfoRequest(m), false, m);
|
|
}
|
|
});
|
|
|
|
test('개인 인프라는 계속 예외 — 검색으로 확인할 수 없다', () => {
|
|
assert.equal(isFactualInfoRequest('지서버에 어떤 모델이 있을까?'), false);
|
|
});
|
|
});
|
|
|
|
describe('isFactualThreadFollowUp — 주제어가 앞 턴에만 있는 후속 질문', () => {
|
|
test('명사구만 남은 후속 질문도 확인 대상이 된다', () => {
|
|
assert.equal(isFactualThreadFollowUp('올라마 클라우드 모델은?', '올라마에서 고른다면?'), true);
|
|
assert.equal(isFactualThreadFollowUp('잘 팔리고 있는건가?', '맥미니 신형 시장반응 어때?'), true);
|
|
});
|
|
|
|
test('앞 턴이 사실 질문이 아니면 발동하지 않는다', () => {
|
|
assert.equal(isFactualThreadFollowUp('그래서?', '모네의 정원 흉내 내고 있지..ㅎ'), false);
|
|
});
|
|
|
|
test('명령·요청형은 조회가 아니다', () => {
|
|
assert.equal(isFactualThreadFollowUp('그거 정리해줘', '어떤 모델이 있을까?'), false);
|
|
});
|
|
|
|
test('길면 원래 게이트가 알아서 본다 — 여기서는 짧은 것만', () => {
|
|
const long = '그러면 그 중에서 한국어 처리가 가장 자연스럽고 속도도 괜찮은 걸로 하나만 골라서 이유까지 설명해줄래?';
|
|
assert.equal(isFactualThreadFollowUp(long, '어떤 모델이 있을까?'), false);
|
|
});
|
|
});
|
|
|
|
describe('confirmsUserGuessWithoutGrounding — 근거 없는 동조', () => {
|
|
test('직전에 확인 못 했다고 한 사실을 사용자 추측만으로 확정해 준다', () => {
|
|
assert.equal(confirmsUserGuessWithoutGrounding(
|
|
'아 그럼 M6구나',
|
|
'네, 맞습니다. 최신 라인업 기준으로 보면 이제 M6 세대로 넘어가는 시점이라고 보시는 게 정확합니다.',
|
|
), true);
|
|
});
|
|
|
|
test('질문형은 대상이 아니다 — 답을 요구받은 모델이 답하는 건 정상', () => {
|
|
assert.equal(confirmsUserGuessWithoutGrounding(
|
|
'보통 9월인가?',
|
|
'네, 맞습니다. 보통 3월, 6월, 9월, 12월 이렇게 분기별 마지막 달의 세 번째 금요일이 만기일입니다.',
|
|
), false);
|
|
});
|
|
|
|
test('동의로 시작하지 않으면 대상이 아니다', () => {
|
|
assert.equal(confirmsUserGuessWithoutGrounding(
|
|
'아 그럼 M6구나',
|
|
'검색으로는 M6 맥미니를 확인하지 못했습니다. 단정하기 어렵습니다.',
|
|
), false);
|
|
assert.equal(confirmsUserGuessWithoutGrounding(
|
|
'모네의 정원 흉내 내고 있지..ㅎ',
|
|
'와, 170평의 넓은 대지에 모네의 정원이라니 정말 낭만적입니다!',
|
|
), false);
|
|
});
|
|
|
|
test('빈 입력에서 터지지 않는다', () => {
|
|
assert.equal(confirmsUserGuessWithoutGrounding('', ''), false);
|
|
assert.equal(confirmsUserGuessWithoutGrounding(null as any, undefined as any), false);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 2026-09-04 도달률 감사 — 실사용 로그 1,011건(고유 782건)을 게이트에 통과시켜 나온 오탐/미탐.
|
|
* 인용은 전부 로그의 실제 발화다.
|
|
*/
|
|
describe('도달률 감사 회귀 (2026-09-04)', () => {
|
|
test('isCancelIntent: 지명이 취소 명령으로 읽히면 안 된다', () => {
|
|
// "하지마(波島) 유적지"가 `하지\s*마`에 걸렸다. 이 게이트가 참이면 진행 중이던 작업이
|
|
// 일시정지되고 사용자의 지리 질문은 답 대신 작업제어 응답으로 하이재킹된다.
|
|
assert.equal(isCancelIntent('하지마 유적지는 바다와 떨어져 있는데?'), false);
|
|
assert.equal(isCancelIntent('그만해도 되나 궁금한데 계속 할까?'), false);
|
|
});
|
|
|
|
test('isCancelIntent: 진짜 취소 명령은 계속 잡는다', () => {
|
|
for (const m of ['그거 하지마', '취소해', '작업 중단해', '이거 취소해줘', '그만해!', '멈춰주세요']) {
|
|
assert.equal(isCancelIntent(m), true, m);
|
|
}
|
|
});
|
|
|
|
test('isExecutionLikeRequest: 의료용 "패치"가 실행형으로 분류되면 안 된다', () => {
|
|
// 이 함수는 검증 강제에서 **면제**시키는 쪽이라, 오탐이면 실존 제품의 기능·가격을
|
|
// 검색 없이 기억으로 답해도 아무도 막지 않는다.
|
|
assert.equal(isExecutionLikeRequest('인슐린 패치는 혈당 측정 기능이 없나?'), false);
|
|
assert.equal(isExecutionLikeRequest('패치는 많이 비싼가?'), false);
|
|
});
|
|
|
|
test('isExecutionLikeRequest: 소프트웨어 패치는 계속 실행형', () => {
|
|
for (const m of ['이거 패치해줘', '패치 적용해줘', '패치 파일 어디 있어']) {
|
|
assert.equal(isExecutionLikeRequest(m), true, m);
|
|
}
|
|
});
|
|
|
|
test('isFactualInfoRequest: "얼마" 계열 수량·가격 질문', () => {
|
|
// 표본에서 이 표현으로 물었는데 어떤 검증 게이트에도 안 걸린 5건 — 전부 정당한 사실 질문이었다.
|
|
for (const m of [
|
|
'tesla v100 32gb 메모리 대역폭은 얼마지?',
|
|
'미국 올 해 예산은 전 부 얼마지?',
|
|
'이자 비용은 연간 얼마지?',
|
|
'서울에서 부산까지 거리가 얼마야?',
|
|
'패치는 많이 비싼가?',
|
|
]) {
|
|
assert.equal(isFactualInfoRequest(m), true, m);
|
|
}
|
|
});
|
|
|
|
test('isFactualInfoRequest: 맨 "얼마나"는 넣지 않았다 — 비-사실 용법이 흔하다', () => {
|
|
assert.equal(isFactualInfoRequest('얼마나 걸려?'), false);
|
|
assert.equal(isFactualInfoRequest('시간이 얼마나 남았지'), false);
|
|
});
|
|
});
|
|
|
|
|
|
// ── isActionPromiseNoTool (2026-09-25) ─────────────────────────────────────
|
|
// 작업실 실측(로봇 프로젝트 채팅): "먼저 GitHub에서 관련 리포지토리를 찾겠습니다"만 내놓고
|
|
// 도구 0개로 턴 종료. deferral 넛지가 "잠시만 기다려"류만 잡고 실행형 요청은 제외라 두 망 다
|
|
// 통과한 케이스. 아래 실측 문구는 로그에서 그대로 가져왔다.
|
|
describe('isActionPromiseNoTool — 행동 예고 후 도구 0개 턴', () => {
|
|
test('오늘 실측 문구들', () => {
|
|
assert.equal(isActionPromiseNoTool(
|
|
'죄송합니다. 말만 하고 실제 행동(도구 호출)을 하지 않았습니다. 바로 LanderPi의 오픈소스 자료(URDF, ROS2 패키지, 설계 파일 등)를 검색해서 프로젝트 폴더에 채워 넣겠습니다.\n\n먼저 GitHub 등에서 관련 리포지토리를 찾겠습니다.',
|
|
), true);
|
|
assert.equal(isActionPromiseNoTool('먼저 검색을 시작합니다.'), true);
|
|
assert.equal(isActionPromiseNoTool('Let me search GitHub for the repository.'), true);
|
|
assert.equal(isActionPromiseNoTool("I'll download the URDF files into the project folder."), true);
|
|
assert.equal(isActionPromiseNoTool(' STL을 컴파일해서 CAD/에 저장하겠습니다'), true);
|
|
});
|
|
|
|
test('오탐 방지: 과거형·의문형·의례적 맺음말', () => {
|
|
assert.equal(isActionPromiseNoTool('네, 방금 전 턴에 이미 저장했습니다.'), false);
|
|
assert.equal(isActionPromiseNoTool('어떤 파일을 찾아드릴까요?'), false);
|
|
assert.equal(isActionPromiseNoTool('더 필요하시면 말씀드리겠습니다.'), false);
|
|
assert.equal(isActionPromiseNoTool('검색 결과가 없습니다. 다른 키워드를 시도해 보세요.'), false);
|
|
assert.equal(isActionPromiseNoTool('다운로드는 완료됐습니다. 파일은 CAD/에 있습니다.'), false);
|
|
});
|
|
|
|
test('긴 답변(>800자)은 대상 아님 — 완성된 답을 넛지하지 않는다', () => {
|
|
const long = '정상 답변 본문. '.repeat(95) + '먼저 검색을 시작합니다.';
|
|
assert.equal(isActionPromiseNoTool(long), false);
|
|
});
|
|
});
|