Files
homeclaw/tests/search-abandonment.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

210 lines
11 KiB
TypeScript

/**
* search-abandonment — "못 찾았다"가 진짜 포기인지 판정
*
* 이 가드의 난점은 "못 찾았다"가 자주 맞는 말이라는 것이다. 실제 로그의 좋은 답변도 미확인
* 항목을 밝힌다 — 그걸 재시도시키면 순수 낭비다. 그래서 첫 문장만 본다: 포기는 실패로 시작하고,
* 진짜 답변은 발견으로 시작한 뒤 빈 곳을 뒤에 붙인다.
*
* 본문 인용은 2026-08-10 실사고와 같은 로그의 정상 답변에서 가져왔다.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
declaresNothingFound, countSubstantiveResults, decideSearchAbandonmentRetry, leadSentence,
} from '../src/gateway/chat/search-abandonment';
const RESULTS = [
`[1] 태풍 - 기상청 날씨누리
https://www.weather.go.kr/w/hazard/typhoon.do
파이어폭스 (Firefox): 브라우저 상단에 있는 메뉴 버튼을 누른 뒤 설정으로 들어가 쿠키를 허용해 주세요
[2] 제15호 태풍 찬홈 예상 경로 분석
https://example.invalid/2
제15호 태풍 찬홈은 일본 도쿄 동남동쪽 해상에서 발달해 서북서 방향으로 이동하고 있습니다
[3] 태풍 정보 종합
https://example.invalid/3
현재 중심기압과 최대풍속, 예상 진로를 정리한 표를 제공합니다 상세 내용은 본문 참조
[4] 기상 블로그 — 찬홈 실시간
https://example.invalid/4
찬홈의 현재 위치와 향후 48시간 예상 진로를 지도와 함께 설명하고 있습니다`,
];
describe('leadSentence — 판정은 첫 문장에서', () => {
test('마침표로 끊는다', () => {
assert.equal(leadSentence('첫 문장입니다. 두 번째 문장입니다.'), '첫 문장입니다.');
});
test('줄바꿈으로도 끊는다', () => {
assert.equal(leadSentence('요약 한 줄\n\n본문 시작'), '요약 한 줄');
});
test('빈 입력', () => {
assert.equal(leadSentence(''), '');
assert.equal(leadSentence(null as any), '');
});
});
describe('declaresNothingFound — 포기 선언인가', () => {
test('실사고 답변들은 포기로 판정된다', () => {
assert.equal(declaresNothingFound(
'검색 결과, 현재 시점(2026년 8월 12일)의 영남 지역 향후 비 소식에 대한 최신 예보는 확인되지 않습니다. 찾아본 자료 중 일부는 2019년 뉴스입니다.',
), true);
assert.equal(declaresNothingFound('관련 정보를 찾을 수 없습니다.'), true);
});
test('발견으로 시작하고 빈 곳을 뒤에 붙인 답변은 포기가 아니다', () => {
// 같은 로그의 정상 답변. 태풍을 찾아냈고 좌표만 없다고 밝혔다 — 재시도 대상이 아니다.
assert.equal(declaresNothingFound(
"검색 결과에 따르면, 제15호 태풍 '찬홈(CHAN-HOM)'은 현재 일본 도쿄 동남동쪽 해상에서 발달하여 서북서쪽으로 이동 중인 것으로 확인됩니다. "
+ '다만, 검색 결과 내에 정확한 위도와 경도 같은 실시간 좌표값은 명시되어 있지 않습니다.',
), false);
});
test('영어 표현도 잡는다', () => {
assert.equal(declaresNothingFound('I could not find any information about that.'), true);
});
});
describe('countSubstantiveResults — 알맹이 있는 결과만 센다', () => {
test('스니펫이 있는 결과를 센다', () => {
assert.equal(countSubstantiveResults(RESULTS), 4);
});
test('스니펫이 없거나 토막이면 세지 않는다 — 쓰레기 5건이 재시도 근거가 되면 안 된다', () => {
assert.equal(countSubstantiveResults([
`[1] 사이트 A
https://a.invalid
짧음
[2] 사이트 B
https://b.invalid`,
]), 0);
});
test('결과가 없으면 0', () => {
assert.equal(countSubstantiveResults([]), 0);
assert.equal(countSubstantiveResults(['결과 없음']), 0);
});
});
describe('decideSearchAbandonmentRetry — 종합 판정', () => {
test('알맹이 있는 결과가 여럿인데 포기하면 재시도', () => {
const d = decideSearchAbandonmentRetry({ answer: '관련 정보를 찾을 수 없습니다.', sourceTexts: RESULTS });
assert.equal(d.retry, true);
assert.equal(d.substantiveResults, 4);
});
test('찾아낸 답변은 재시도하지 않는다', () => {
const d = decideSearchAbandonmentRetry({
answer: "제15호 태풍 '찬홈'은 일본 도쿄 동남동쪽 해상에 있습니다. 다만 좌표는 확인되지 않습니다.",
sourceTexts: RESULTS,
});
assert.equal(d.retry, false);
});
test('결과가 실제로 부실하면 포기가 맞다 — 재시도하지 않는다', () => {
const d = decideSearchAbandonmentRetry({
answer: '관련 정보를 찾을 수 없습니다.',
sourceTexts: [`[1] 사이트\n https://a.invalid\n 짧음`],
});
assert.equal(d.retry, false);
});
});
/**
* 2026-09-04 감사 회귀. 아래 6건은 메인챗 세션 0c367bd7의 실제 답변이고, 패치 전에는 **6건 전부**
* declaresNothingFound=false 였다 — 2026-08-12 태풍 사고 이후 이 가드는 사실상 한 번도 발동하지
* 않고 있었다. 원인 두 가지: (1) gemma4는 "정보가 없다"가 아니라 "결과에 …이 포함되어 있지 않다"
* 라고 쓴다 (2) 사과로 시작하면 판정 대상인 첫 문장이 "죄송합니다."가 되어 근거가 안 들어온다.
*/
describe('실사용 포기 답변 (2026-09-04 세션 감사)', () => {
const REAL_GIVEUPS = [
'죄송합니다. 이번 검색 결과에서는 미국 내 영리 및 비영리 의료법인의 구체적인 점유 비율(%)에 대한 수치가 포함되어 있지 않았습니다.',
'검색 결과들을 다시 면밀히 검토했으나, 안타깝게도 현재 시점(2026년 9월)의 신형 맥미니에 대한 시장 반응이나 리뷰 내용은 포함되어 있지 않았습니다.',
'검색 결과들을 다시 꼼꼼히 읽어보았으나, M6 맥미니의 시장 반응이나 비판 내용에 대해 직접적으로 언급한 내용은 단 하나도 없었습니다.',
'검색 결과에는 Gemma 4 27B 모델의 FP8 양자화 시 구체적인 VRAM 필요량이나 32GB 환경에서의 구동 가능 여부에 대한 수치가 포함되어 있지 않습니다.',
'검색 결과에는 옵션 프리미엄(티켓값)이 실물 가격의 몇 퍼센트인지에 대한 구체적인 수치나 비율이 명시되어 있지 않아 정확한 값을 단정하기 어렵습니다.',
'검색 결과에는 옵션 프리미엄이 실물 가격의 몇 퍼센트인지에 대한 구체적인 수치가 전혀 포함되어 있지 않아, 정확히 얼마인지 단정할 수 없습니다.',
];
for (const answer of REAL_GIVEUPS) {
test(`포기로 판정: ${answer.slice(0, 26)}…`, () => {
assert.equal(declaresNothingFound(answer), true);
});
}
test('사과로 시작해도 그 다음 문장에서 판정한다', () => {
assert.equal(leadSentence('죄송합니다. 검색 결과에 수치가 포함되어 있지 않았습니다.'),
'검색 결과에 수치가 포함되어 있지 않았습니다.');
});
test('사과로 시작하되 결론이 포기가 아니면 그대로 통과', () => {
// 같은 세션의 정정 답변 — 사과하고 나서 사실을 인정했지 포기한 게 아니다.
assert.equal(declaresNothingFound(
'죄송합니다. 제가 최신 출시 정보를 놓쳤습니다. 현재 시점 기준으로 M5, M6 칩을 탑재한 신형 맥미니가 출시된 상황이군요.',
), false);
});
test('건너뛰기는 두 문장까지만 — 발견 뒤 빈 곳을 덧붙인 답변을 삼키면 안 된다', () => {
assert.equal(declaresNothingFound(
'검색 결과에 따르면 제15호 태풍 찬홈은 일본 도쿄 동남동쪽 해상에 있습니다. 다만 정확한 좌표는 명시되어 있지 않습니다.',
), false);
});
});
/**
* 관련성 관문 (2026-09-04 감사). 이 가드는 스니펫 길이만으로 "알맹이 있음"을 판정했는데,
* 사전 뜻풀이 페이지의 스니펫은 넉넉히 40자를 넘는다. 문제의 옵션 프리미엄 턴에서
* Merriam-Webster/Cambridge/위키낱말사전 4건이 전부 substantive로 세어져, 모델에게
* "결과 4건에 알맹이가 있으니 그걸로 답하라"고 밀 참이었다 — 옵션 프리미엄을 'typical'의
* 사전 정의로 답하라는 지시가 된다. NUMERIC-GROUNDING과 정확히 같은 전제 위의 같은 결함이다.
*/
describe('무관한 결과에는 재시도를 강요하지 않는다', () => {
const DICT_QUERY = 'typical option premium as percentage of underlying asset price';
const DICT = [
'[1] TYPICAL Definition & Meaning - Merriam-Webster',
' https://www.merriam-webster.com/dictionary/typical',
' The meaning of TYPICAL is combining or exhibiting the essential characteristics of a group.',
'',
'[2] TYPICAL | English meaning - Cambridge Dictionary',
' https://dictionary.cambridge.org/dictionary/english/typical',
' TYPICAL definition: showing all the characteristics you would usually expect from a group.',
'',
'[3] typical 뜻과 사용법 | RedKiwi Words',
' https://redkiwiapp.com/ko/english-guide/words/typical',
' This car is typical of its kind, with good gas mileage and a comfortable ride. 전형적인 차입니다.',
'',
'[4] typical - 위키낱말사전',
' https://ko.wiktionary.org/wiki/typical',
' 전형적인, 대표적인. 특유의. 분류 영어 형용사 전형적이라는 뜻으로 널리 쓰이는 단어입니다.',
].join('\n');
const GIVE_UP = '검색 결과에는 관련 수치가 포함되어 있지 않습니다.';
test('길이로는 알맹이 4건이지만, 주제를 안 다뤘으므로 재시도하지 않는다', () => {
assert.equal(countSubstantiveResults([DICT]), 4);
const d = decideSearchAbandonmentRetry({
answer: GIVE_UP, sourceTexts: [DICT], searchQueries: [DICT_QUERY],
});
assert.equal(d.sourceOnTopic, false);
assert.equal(d.retry, false);
});
test('주제를 다룬 결과에서는 그대로 재시도 — 원래 태풍 사고를 계속 잡아야 한다', () => {
const d = decideSearchAbandonmentRetry({
answer: GIVE_UP,
sourceTexts: RESULTS,
searchQueries: ['제15호 태풍 찬홈 현재 위치 예상 경로'],
});
assert.equal(d.sourceOnTopic, true);
assert.equal(d.retry, true);
});
test('검색어를 안 넘기면 예전 동작 그대로', () => {
const d = decideSearchAbandonmentRetry({ answer: GIVE_UP, sourceTexts: [DICT] });
assert.equal(d.sourceOnTopic, true);
assert.equal(d.retry, true);
});
});