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
151 lines
6.9 KiB
TypeScript
151 lines
6.9 KiB
TypeScript
/**
|
|
* dead-end-fallback — 검색이 연달아 헛돌 때 지식으로 끊기
|
|
*
|
|
* 표본은 2026-09-04 메인챗 세션 0c367bd7의 "옵션 프리미엄" 스레드다. 5턴 · 검색 6회 · 답 0건.
|
|
* 이 테스트가 고정하는 건 "언제 끊는가"이고, 두 방향 모두 중요하다:
|
|
* - 한 번 못 찾은 것으로는 끊지 않는다 (검색에 정말 없을 수 있고, 그때 정직하게 말하는 건 정답).
|
|
* - 두 번째부터는 끊는다 (검색 경로가 이 질문에 안 맞는다는 신호).
|
|
*/
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { decideDeadEndFallback, answerEndedInDeadEnd, deadEndFallbackPrompt } from '../src/gateway/chat/dead-end-fallback';
|
|
|
|
/** 실제 로그 그대로. 첫 턴부터 마지막 턴까지 시간순. */
|
|
const OPTION_THREAD = [
|
|
'검색 결과에는 옵션 프리미엄(티켓값)이 실물 가격의 몇 퍼센트인지에 대한 구체적인 수치나 비율이 명시되어 있지 않아 정확한 값을 단정하기 어렵습니다.',
|
|
'비슷한 느낌이지만, 성격은 조금 다릅니다. 계약금은 나중에 잔금을 치르고 물건을 가져가기 위해 걸어두는 돈이지만, 옵션 프리미엄은 소멸성 비용에 가깝습니다.',
|
|
'검색 결과에는 옵션 프리미엄이 실물 가격의 몇 퍼센트인지에 대한 구체적인 수치가 포함되어 있지 않아, 계약금보다 적은지 여부를 수치로 단정하기 어렵습니다.',
|
|
];
|
|
|
|
|
|
describe('answerEndedInDeadEnd — 지난 턴이 결국 빈손이었나', () => {
|
|
test('포기로 시작한 턴을 센다', () => {
|
|
assert.equal(answerEndedInDeadEnd(OPTION_THREAD[0]), true);
|
|
});
|
|
|
|
test('발견을 말하고 빈 곳을 덧붙인 턴은 세지 않는다 — 일부러 좁게 잡은 지점', () => {
|
|
// 이 답변과, 실제로 헛돌았던 "HTS/MTS로 확인 가능… 다만 수치는 없습니다" 턴은 텍스트로
|
|
// 구별되지 않는다(둘 다 마지막이 "…있지 않습니다"). 후자를 놓치는 쪽을 택했다 — 놓치면
|
|
// 가드가 한 턴 늦고, 반대로 넓히면 이 정상 답변에 재프롬프트가 붙는다.
|
|
assert.equal(answerEndedInDeadEnd(
|
|
'검색 결과에 따르면 제15호 태풍 찬홈은 일본 도쿄 동남동쪽 해상에 있습니다. 다만 정확한 좌표는 명시되어 있지 않습니다.',
|
|
), false);
|
|
});
|
|
|
|
test('제대로 답한 턴은 세지 않는다', () => {
|
|
assert.equal(answerEndedInDeadEnd(OPTION_THREAD[1]), false);
|
|
});
|
|
});
|
|
|
|
describe('decideDeadEndFallback — 언제 끊는가', () => {
|
|
test('첫 빈손으로는 끊지 않는다', () => {
|
|
const d = decideDeadEndFallback({
|
|
answer: OPTION_THREAD[0],
|
|
priorAssistantAnswers: ['선물은 의무이고 옵션은 권리입니다. 가장 핵심적인 차이는 그것입니다.'],
|
|
abandonmentRetrying: false,
|
|
calledGroundingTool: true,
|
|
});
|
|
assert.equal(d.fallback, false);
|
|
assert.equal(d.deadEndStreak, 1);
|
|
});
|
|
|
|
test('두 번째 빈손에서 끊는다 — 실제 스레드에서는 여기서 3턴을 아낀다', () => {
|
|
const d = decideDeadEndFallback({
|
|
answer: OPTION_THREAD[2],
|
|
priorAssistantAnswers: OPTION_THREAD.slice(0, 2),
|
|
abandonmentRetrying: false,
|
|
calledGroundingTool: true,
|
|
});
|
|
assert.equal(d.fallback, true);
|
|
assert.equal(d.deadEndStreak, 2);
|
|
});
|
|
|
|
test('헛돈 턴이 붙어 있지 않아도 최근 창 안이면 센다', () => {
|
|
// 실제 스레드가 이 모양이었다 — 중간에 절반짜리 답이 하나 끼어서 연속이 끊긴다.
|
|
const d = decideDeadEndFallback({
|
|
answer: OPTION_THREAD[2],
|
|
priorAssistantAnswers: ['무관한 턴', OPTION_THREAD[0], OPTION_THREAD[1]],
|
|
abandonmentRetrying: false,
|
|
calledGroundingTool: true,
|
|
});
|
|
assert.equal(d.fallback, true);
|
|
});
|
|
|
|
test('SEARCH-ABANDONMENT가 가져가는 턴에서는 비켜선다', () => {
|
|
const d = decideDeadEndFallback({
|
|
answer: OPTION_THREAD[2],
|
|
priorAssistantAnswers: OPTION_THREAD.slice(0, 2),
|
|
abandonmentRetrying: true,
|
|
calledGroundingTool: true,
|
|
});
|
|
assert.equal(d.fallback, false);
|
|
});
|
|
|
|
test('검색을 아예 안 한 턴은 대상이 아니다 — 검색 실패가 아니라 다른 문제다', () => {
|
|
const d = decideDeadEndFallback({
|
|
answer: OPTION_THREAD[2],
|
|
priorAssistantAnswers: OPTION_THREAD.slice(0, 2),
|
|
abandonmentRetrying: false,
|
|
calledGroundingTool: false,
|
|
});
|
|
assert.equal(d.fallback, false);
|
|
});
|
|
|
|
test('이번 턴이 제대로 답했으면 지난 턴이 헛돌았어도 건드리지 않는다', () => {
|
|
const d = decideDeadEndFallback({
|
|
answer: OPTION_THREAD[1],
|
|
priorAssistantAnswers: [OPTION_THREAD[0], OPTION_THREAD[0]],
|
|
abandonmentRetrying: false,
|
|
calledGroundingTool: true,
|
|
});
|
|
assert.equal(d.fallback, false);
|
|
});
|
|
|
|
test('오래된 헛턴은 창 밖으로 나간다', () => {
|
|
const d = decideDeadEndFallback({
|
|
answer: OPTION_THREAD[2],
|
|
priorAssistantAnswers: [OPTION_THREAD[0], 'a', 'b', 'c', 'd'],
|
|
abandonmentRetrying: false,
|
|
calledGroundingTool: true,
|
|
});
|
|
assert.equal(d.fallback, false);
|
|
});
|
|
|
|
test('이력이 없어도 터지지 않는다', () => {
|
|
const d = decideDeadEndFallback({
|
|
answer: OPTION_THREAD[0], priorAssistantAnswers: [], abandonmentRetrying: false, calledGroundingTool: true,
|
|
});
|
|
assert.equal(d.fallback, false);
|
|
});
|
|
});
|
|
|
|
describe('deadEndFallbackPrompt — 재프롬프트 문구', () => {
|
|
test('숫자를 요구하지 않는다 — NUMERIC-GROUNDING과 부딪히면 안 된다', () => {
|
|
const p = deadEndFallbackPrompt(2);
|
|
assert.match(p, /mechanism/i);
|
|
assert.match(p, /general knowledge that this turn's search did not confirm/i);
|
|
assert.match(p, /never present it as something a tool returned/i);
|
|
assert.match(p, /2th answer in a row/);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 2026-09-04 감사 회귀. SEARCH-ABANDONMENT에 관련성 관문이 붙으면서, 사전 뜻풀이처럼
|
|
* "길이는 충분하지만 주제를 안 다룬" 결과에서 두 가드가 서로 비켜설 뻔했다:
|
|
* abandonment는 주제 불일치로 서고, dead-end는 substantive>=3이라 "저쪽 몫"이라며 섰다.
|
|
* 이제 dead-end는 abandonment의 판정 결과를 그대로 받으므로 그 구멍이 없다.
|
|
*/
|
|
describe('두 가드 사이에 빠지는 턴이 없어야 한다', () => {
|
|
test('abandonment가 비켜서면 dead-end가 받는다 — 알맹이 수와 무관하게', () => {
|
|
const d = decideDeadEndFallback({
|
|
answer: '검색 결과에는 옵션 프리미엄 수치가 포함되어 있지 않습니다.',
|
|
priorAssistantAnswers: ['검색 결과에는 관련 수치가 명시되어 있지 않습니다.'],
|
|
abandonmentRetrying: false, // 사전 뭉치 → 주제 불일치로 abandonment 비켜섬
|
|
calledGroundingTool: true,
|
|
});
|
|
assert.equal(d.fallback, true);
|
|
});
|
|
});
|
|
|