fix: 가드가 맞는 답을 지우던 경로 — 무관한 검색 결과에 대고 판정하던 문제

2026-09-04 메인챗 세션(0c367bd7) 감사. 사용자가 "옵션 프리미엄이 실물의 몇 %냐"를
다섯 턴에 걸쳐 물었고, 검색 6회에 산출물 0건으로 끝났다. 로그를 보니 원인이 모델이
아니라 우리 가드였다:

  TOOL: web_search("typical option premium as percentage of underlying asset price")
  TOOL OK: [1] TYPICAL Definition & Meaning - Merriam-Webster
  NUMERIC-GROUNDING POST-CHECK (1/1): 3 figure(s) not found in tool output [10%, 1%, 2%]

모델은 매 턴 답을 냈다(10%/1%/2%, 다음 턴엔 3~10%/0.1~1%). 검색엔진이 "typical"의
사전 뜻풀이를 물어왔고, 그 뭉치에 숫자가 없다는 이유로 답이 날조 판정을 받아 지워졌다.
이 가드의 전제는 "검색이 그 주제를 다뤘는데 숫자가 없다 → 지어냈다"인데, 앞부분을
한 번도 확인한 적이 없었다. 무관한 말뭉치에서의 부재는 날조의 증거가 아니다.

* 관련성 관문(sourceCoversQuery): 검색어의 내용어 중 결과에 등장하는 비율이 0.34
  미만이면 판정을 포기한다. 실측 — 사전 뭉치 0.14 / 정상 결과 0.86. 수치·날짜·부재주장
  세 갈래가 같은 전제 위에 있어 한꺼번에 게이트했다. 검색어를 못 넘기는 호출부는 기존
  동작 유지.

* SEARCH-ABANDONMENT도 같은 결함이 있었다(감사 중 발견). countSubstantiveResults는
  스니펫 길이만 봐서 사전 페이지 4건을 전부 "알맹이 있음"으로 세고, 모델에게 "그걸로
  답하라"고 민다 — 답을 지우는 대신 틀린 답을 강요하는, 같은 전제의 반대 방향 피해다.
  같은 관문을 공유시켜 기준이 갈라지지 않게 했다.

* 그 가드는 애초에 죽어 있었다. NOTHING_FOUND가 "정보가 없다"류만 담고 있어, gemma4가
  실제로 쓰는 "결과에 …이 포함되어 있지 않다"(주어가 결과)를 하나도 못 잡았다. 이
  세션의 포기 답변 6건 중 0건 탐지. 사과로 시작하면 판정 대상인 첫 문장이 "죄송합니다."가
  되는 두 번째 구멍도 함께 수정(한글 뒤 \b 미매칭 함정 주의).

* dead-end-fallback (신규): 두 번째로 빈손이면 검색을 끊고 "아는 것을 검증 못 했다고
  라벨 붙여 말하라"로 돌린다. 문제의 스레드에서 3턴 앞서 끊긴다. 재프롬프트가 숫자를
  요구하지 않아 NUMERIC-GROUNDING과 충돌하지 않는다.

* 제품·모델 선택 질문이 isFactualInfoRequest를 통째로 빠져나갔다("모델이 있을까",
  "고른다면", "신형/최신"). 그래서 소설-모델 3턴이 도구 0개로 굴러가 2026년 9월에
  "Gemma 2 27B"를 로컬 추천으로 냈다. 주제어가 앞 턴에만 있는 짧은 후속 질문용
  isFactualThreadFollowUp 추가.

* confirmsUserGuessWithoutGrounding: 직전 턴에 확인 못 했다고 해놓고 사용자 추측에
  "네, 맞습니다"로 동조하는 경로 차단(맥미니 M6 건). 4중 조건으로 좁혀 정당한 맞장구는
  건드리지 않는다.

* cms_hospital_compare 게이트가 "병원"에만 걸려 있어 사용자가 내내 쓴 "의료법인"으로는
  8턴 동안 스코프에 들지 않았다. 그 사이 공공 비중 답이 45~50%→39~40%→약 50%로 흔들렸다.

테스트 521개 통과(+65). 실사용 로그의 실제 발화·답변을 표본으로 고정했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
This commit is contained in:
kim
2026-09-04 17:51:12 +09:00
co-authored by Claude Opus 5
parent e658f6a634
commit db6a8634c3
11 changed files with 879 additions and 12 deletions
+90
View File
@@ -29,6 +29,8 @@ import {
isCancelIntent,
isRerunIntent,
isDisasterRequest,
isFactualThreadFollowUp,
confirmsUserGuessWithoutGrounding,
} from '../src/gateway/guards/prompt-gates';
describe('looksLikeUnverifiedSpecClaim — 출력에서 검증 안 된 수치 감지', () => {
@@ -363,3 +365,91 @@ describe('보조 분류기', () => {
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);
});
});