Files
homeclaw/tests/search-abandonment.test.ts
T
kimandClaude Opus 5 f042a4cb4e feat: 검색 결과를 다 읽지 않고 "못 찾았다"고 하면 재프롬프트
실사고(2026-08-10, "태풍 15호 현재 위치는 어디지?"): web_search가 5건을
돌려줬는데 1번이 기상청 쿠키 설정 안내 조각이었다. 모델은 1번만 읽고 검색이
아무것도 못 찾았다고 결론냈다. 같은 응답의 4~5번에 태풍 이름과 진행 방향이
있었고, 동일한 결과를 받은 클라우드 대형 모델은 그걸 제대로 썼다.

이건 원래 프롬프트로 넣었던 것이다 — 지서버 모델 프로필의 1,531자 안에 이
사고를 쿼리와 쿠키 결과까지 명시해 적어놨는데, 08-12에 또 났다. 이틀간
다섯 번째로 프롬프트가 코드에 진 사례이고, 오늘 실험이 이유를 설명한다:
격리 조건에선 지시를 다 지키지만 제약이 경쟁하면 주 목표만 남기고 버린다.
가드는 주의력을 놓고 경쟁하지 않는다.

**핵심 난점은 "못 찾았다"가 자주 맞는 말이라는 것.** 같은 로그의 정상 답변은
태풍을 찾아내고 좌표만 없다고 밝혔다 — 이걸 재시도시키면 순수 낭비다.
구분은 첫 문장에 있다: 포기는 실패로 시작하고, 진짜 답변은 발견으로 시작한 뒤
빈 곳을 뒤에 붙인다. 그래서 판정은 lead sentence에서만 한다.

- countSubstantiveResults: 스니펫 40자 미만은 세지 않는다. 쓰레기 5건이
  재시도 근거가 되면 결과가 정말 부실할 때도 발동한다
- 임계값 3건: 2건이면 "여기 쓸 게 없다"가 정직한 독해인 경우가 잦다.
  3건 이상인데 못 찾았다면 목록 위쪽만 본 것이 거의 확실하다
- 재시도 1회 상한

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:16:28 +09:00

114 lines
4.9 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);
});
});