Files
homeclaw/tests/dead-end-fallback.test.ts
kimandClaude Opus 5 db6a8634c3 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
2026-09-04 17:51:12 +09:00

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);
});
});