Files
homeclaw/tests/retry-decisions.test.ts
T
kimandClaude Opus 5 305f93d08b fix: 검증 강제 가드의 오탐 3종 — 사용자가 준 숫자, NHC/EONET, 붙여넣기 본문
실사용 로그와 세션 104건을 가드에 직접 돌려서 찾은 오탐들이다.

1) 사용자가 질문에 직접 적은 숫자로 AUTO-RECOVER가 걸렸다.

   로그 실례: "북위 27.7도, 동경 124.8도 여기가 어디쯤이지?" — 그 좌표 자체가
   한 턴 전 web_search 결과였고 사용자가 그대로 옮겨 물은 것이다. 모델이
   정답("동중국해")을 냈는데 답에 좌표를 되풀이했다는 이유로 COORDINATE_CLAIM이
   걸려 재생성 + "latitude 27.7 longitude 124.8 location" 검색을 태웠고,
   돌아온 건 "농림통계연보(2005).hwp"였다. 그러고서 같은 답을 다시 썼다.

   이 가드가 잡으려는 건 "모델이 지어낸 숫자"인데, 사용자가 적어 넣은 숫자는
   정의상 모델이 만든 값이 아니다. looksLikeUnverifiedSpecClaim에 userMessage를
   받아 스캔 전에 지운다. 모델이 새로 덧붙인 수치는 그대로 남는다. 한 자리 수는
   지우지 않는다 — 메시지의 "2" 하나로 답변의 모든 "2"가 지워지면 가드가 통째로
   무력화된다(회귀 테스트 포함).

2) nhc_active_storms/eonet_events가 GROUNDING_TOOL_PATTERN에 없었다.

   둘 다 권위 있는 원본(미 국립허리케인센터, NASA EONET) 직결이고 태풍 질문이
   실제로 도는 도구인데, 08-09에 추가한 MEASUREMENT_UNITS/COORDINATE_CLAIM이
   바로 그 도구들이 돌려주는 값(hPa·m/s·북위)에 반응한다. 그래서 NHC 데이터로만
   답한 턴이 "근거 도구 미호출"로 분류돼, 방금 읽은 권위 있는 출처를 의심하라고
   web_search 재시도를 걸었다. memory_stats 때와 같은 구멍이다.

3) 긴 붙여넣기 문서 본문의 우연한 키워드로 강제검색이 걸렸다.

   7,583자짜리 고소장 작성 요청이 본문 한가운데(2578자 지점)의 "시세"(장물
   시세) 때문에 isLiveDataRequest에 걸렸다. 문서를 써 달라는 턴에 "메모리에서
   답하지 말고 검색부터 하라"는 리마인더가 붙는 건 토큰 낭비를 넘어 작업 방향을
   반대로 민다.

   처음엔 "길다 + isExecutionLikeRequest"로 막으려 했는데 회귀 테스트가 그게
   틀렸음을 잡아냈다 — 그 메시지가 실행형으로 분류된 근거가 본문의 "화면" 한
   단어였다. 막으려는 문제와 똑같은 우연 위에 얹은 수정이었다. 대신 위치를
   본다: 사람은 지시를 자료의 앞이나 뒤에 쓰지 한가운데 묻지 않는다.
   600자 초과 메시지는 앞 400자 + 뒤 300자만 스캔한다(instructionZone).
   검증 강제 게이트끼리 예외가 어긋나면 안 되므로 isFactualInfoRequest에도
   같이 적용했다.

실사용 104건 대조: isLiveDataRequest 오탐 3건 제거하고 정당한 25건은 전부
유지, 출력측 가드는 로그의 그 좌표 케이스가 정확히 하나 걸러졌다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:40:50 +09:00

221 lines
11 KiB
TypeScript

/**
* retry-decisions.test.ts
*
* These three checks decide whether an answer the model already wrote gets thrown away.
* They all fail OPEN — a regression produces no error and no log line, just an unverified
* answer delivered to the user as fact. That is precisely the class of bug this codebase has
* repeatedly shipped (see the 2026-07-29 log audit), so both directions are pinned:
* must-retry cases AND must-not-retry cases.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
decideAutoRecover,
shouldForceMessagingRetry,
shouldForceEmptyGroundingRetry,
} from '../src/gateway/chat/retry-decisions';
const recover = (message: string, content: string, called: string[] = [], available: string[] = ['web_search']) =>
decideAutoRecover({ message, content, availableToolNames: available, calledToolNames: called });
describe('AUTO-RECOVER — 근거 없이 답한 경우 재시도', () => {
test('스펙 질문에 검색 없이 답하면 재시도', () => {
assert.equal(recover('RTX 5090 스펙 알려줘', 'VRAM은 32GB입니다.').shouldRetry, true);
});
test('[2026-07-29] 입력이 평범해도 답변에 지어낸 수치가 있으면 재시도', () => {
// 실제 사고 패턴: 질문은 캐주얼한데 답에 확정적 수치가 나옴.
const d = recover('이거 어떨까?', '초당 약 15~22 토큰 정도 나옵니다.');
assert.equal(d.shouldRetry, true);
assert.match(d.reason, /unverified spec/);
});
test('이미 검색했으면 재시도하지 않는다', () => {
assert.equal(recover('RTX 5090 스펙', 'VRAM 32GB입니다.', ['web_search']).shouldRetry, false);
});
test('무관한 도구를 불렀다고 무장해제되지 않는다', () => {
// 미끼 도구 하나로 남은 턴 전체가 풀리면 안 된다.
assert.equal(recover('RTX 5090 스펙', 'VRAM 32GB입니다.', ['coder_list_files']).shouldRetry, true);
});
test('개인 인프라 질문은 재시도 없음 — 검색해도 나올 수 없다', () => {
assert.equal(recover('지서버 사양이 어떻게 되지?', '3060 12GB 2장, 40GB RAM입니다.').shouldRetry, false);
});
test('코딩 요청 안의 숫자는 재시도를 유발하지 않는다', () => {
assert.equal(recover('파이썬 스크립트 만들어줘', '버퍼는 64GB로 잡았습니다.').shouldRetry, false);
});
test('숫자 없는 일반 대화는 재시도 없음', () => {
assert.equal(recover('안녕', '안녕하세요! 무엇을 도와드릴까요?').shouldRetry, false);
});
test('안전 거부 문구 + 도구 필요한 요청이면 재시도', () => {
const d = recover('이 사이트 좀 찾아줘', "I'm sorry, I can't help with that.");
assert.equal(d.shouldRetry, true);
});
// [2026-08-09] 실제 사고: "지금 태풍 돌핀 어디 있지?"에 도구를 하나도 안 부르고
// 좌표·기압·풍속을 단언했다(로그 12065행). 같은 수치가 일주일 전 답변(9119행)과
// 글자까지 동일해서, 검색이 아니라 대화 기억에서 나온 값임이 드러났다.
test('[2026-08-09] 태풍 위치를 검색 없이 답하면 재시도', () => {
const d = recover(
'지금 태풍 돌핀 어디 있지?',
'현재 태풍 돌핀의 위치는 북위 18.5°, 동경 150.0° 부근이며 중심기압 905hPa, 최대풍속 58m/s입니다.',
);
assert.equal(d.shouldRetry, true);
assert.equal(d.kind, 'search');
});
// 반대 방향이 더 중요하다: 관측 단위(℃/mm/m/s)를 가드에 추가했으므로, 도구로 받아온
// 정상 예보까지 되던지면 날씨 기능 자체가 무한 재검색에 빠진다.
test('[2026-08-09] 날씨 도구로 받은 기온·강수량 답변은 되던지지 않는다', () => {
assert.equal(
recover('런던 오늘 최고기온?', '런던의 오늘 최고기온은 31.5°C이고 강수량은 2mm 예상됩니다.', ['weather_openmeteo'])
.shouldRetry,
false,
);
});
test('[2026-08-09] 검색 후의 태풍 답변도 되던지지 않는다', () => {
assert.equal(
recover('지금 태풍 돌핀 어디 있지?', '태풍 돌핀은 북위 18.5°, 동경 150.0° 부근에 있습니다.', ['web_search'])
.shouldRetry,
false,
);
});
});
describe('AUTO-RECOVER — 재생성을 낭비하지 않아야 하는 경우', () => {
// [2026-08-10] 로그에 실제로 잡힌 오탐. 사용자가 좌표를 직접 적어 물었고(그 좌표 자체가 한 턴
// 전 web_search 결과였다), 모델이 정답을 냈는데 답에 좌표를 되풀이했다는 이유로 재생성이 걸렸다.
// 버려진 답 대신 돌아온 검색 결과는 "농림통계연보(2005).hwp"였고, 결국 같은 답을 다시 썼다.
test('사용자가 질문에 직접 적은 숫자를 되풀이한 것만으로는 재시도하지 않는다', () => {
const d = recover('북위 27.7도, 동경 124.8도 여기가 어디쯤이지?', '북위 27.7도, 동경 124.8도는 일본 오키나와 제도 근처의 동중국해 해상입니다.');
assert.equal(d.shouldRetry, false);
});
test('사용자가 준 숫자여도 모델이 새 수치를 덧붙이면 여전히 재시도', () => {
// 가드가 통째로 무력화되면 안 된다 — 질문에 없던 905hPa가 검증 대상으로 남아야 한다.
const d = recover('북위 27.7도 여기가 어디야?', '북위 27.7도 부근이고, 중심기압은 905hPa입니다.');
assert.equal(d.shouldRetry, true);
});
test('한 자리 수는 지우지 않는다 — 가드 전체가 무력화되는 것을 막는다', () => {
// 사용자 메시지의 "2" 하나로 답변의 모든 "2"가 지워지면 32GB도 통과해버린다.
assert.equal(recover('2번은 어때?', 'VRAM은 32GB입니다.').shouldRetry, true);
});
// [2026-08-10] nhc_active_storms/eonet_events가 GROUNDING_TOOL_PATTERN에 없어서, NHC 원본
// 수치로만 답한 태풍 턴은 hPa·m/s·북위가 전부 매칭되는데도 "근거 도구 미호출"로 취급됐다.
test('NHC/EONET로 근거를 댄 태풍 답변은 재시도하지 않는다', () => {
const answer = '중심기압 905hPa, 최대풍속 43m/s로 북위 18.5도 부근을 지나고 있습니다.';
assert.equal(recover('태풍 지금 위치', answer, ['nhc_active_storms']).shouldRetry, false);
assert.equal(recover('지금 진행 중인 자연재해 알려줘', answer, ['eonet_events']).shouldRetry, false);
// 대조군: 아무 근거 도구도 안 불렀으면 그대로 재시도여야 한다.
assert.equal(recover('태풍 지금 위치', answer, ['coder_list_files']).shouldRetry, true);
});
});
describe('AUTO-RECOVER — 어떤 도구를 요구할지 (kind)', () => {
test('기본은 검색', () => {
assert.equal(recover('오늘 뉴스', '어제 이런 일이...').kind, 'search');
});
test('브라우저 도구가 스키마에 있을 때만 browser로 분기', () => {
const msg = 'github.com 열어서 확인해줘';
assert.equal(recover(msg, '음...', [], ['web_search']).kind, 'search');
assert.equal(recover(msg, '음...', [], ['web_search', 'browser_open']).kind, 'browser');
});
test('브라우저 분기면 web_search를 이미 불렀어도 재시도한다 — 근거 도구가 다르다', () => {
// 거부 문구여야 게이트가 성립한다. 짧고 무의미한 답("음...")은 재시도 대상이 아님 —
// 회복할 게 없기 때문. 아래 테스트가 그 경계를 따로 고정한다.
const refusal = "I'm sorry, I can't use your computer to do that.";
const d = recover('github.com 열어줘', refusal, ['web_search'], ['browser_open']);
assert.equal(d.kind, 'browser');
assert.equal(d.shouldRetry, true, 'web_search는 브라우저 요청의 근거가 되지 못한다');
});
test('짧고 애매한 답변은 재시도 대상이 아니다 — 회복할 내용이 없다', () => {
assert.equal(recover('github.com 열어줘', '음...', [], ['browser_open']).shouldRetry, false);
});
});
describe('MESSAGING — 보냈다는 거짓 주장 차단', () => {
const send = (name: string, error = false) => [{ name, error }];
test('전송 요청 + 보냈다 주장 + 도구 미호출 → 재시도', () => {
assert.equal(shouldForceMessagingRetry({
message: '이메일 보내줘', candidateText: '메일 보냈습니다.', toolResults: [],
}), true);
});
test('실제로 보냈으면 재시도 없음', () => {
assert.equal(shouldForceMessagingRetry({
message: '이메일 보내줘', candidateText: '메일 보냈습니다.', toolResults: send('email_send'),
}), false);
});
test('전송이 실패했으면 "보냈다"는 여전히 거짓 — 재시도', () => {
assert.equal(shouldForceMessagingRetry({
message: '이메일 보내줘', candidateText: '메일 보냈습니다.', toolResults: send('email_send', true),
}), true);
});
test('보냈다고 하지 않았으면 재시도 없음', () => {
assert.equal(shouldForceMessagingRetry({
message: '이메일 보내줘', candidateText: '어떤 내용으로 보낼까요?', toolResults: [],
}), false);
});
test('전송 요청이 아니면 재시도 없음', () => {
assert.equal(shouldForceMessagingRetry({
message: '메일 읽어줘', candidateText: '3통 있습니다.', toolResults: [],
}), false);
});
});
describe('EMPTY-GROUNDING — 빈 검색결과로 답한 경우', () => {
test('검색은 했지만 전부 빈 결과면 재시도', () => {
assert.equal(shouldForceEmptyGroundingRetry({
message: '오늘 뉴스',
toolResults: [{ name: 'news_search', result: '(no articles found)' }],
}), true);
});
test('쓸만한 결과가 하나라도 있으면 재시도 없음', () => {
assert.equal(shouldForceEmptyGroundingRetry({
message: '오늘 뉴스',
toolResults: [
{ name: 'news_search', result: '(no articles found)' },
{ name: 'web_search', result: '[1] 실제 기사 제목' },
],
}), false);
});
test('검색을 아예 안 했으면 이 검사 대상이 아니다 (AUTO-RECOVER 담당)', () => {
assert.equal(shouldForceEmptyGroundingRetry({ message: '오늘 뉴스', toolResults: [] }), false);
});
test('근거 도구가 아닌 것만 돌았으면 대상 아님', () => {
assert.equal(shouldForceEmptyGroundingRetry({
message: '오늘 뉴스', toolResults: [{ name: 'coder_list_files', result: '' }],
}), false);
});
test('라이브 데이터 질문이 아니면 대상 아님', () => {
assert.equal(shouldForceEmptyGroundingRetry({
message: '시 한 편 써줘', toolResults: [{ name: 'web_search', result: '' }],
}), false);
});
test('에러로 끝난 검색도 빈 결과로 친다', () => {
assert.equal(shouldForceEmptyGroundingRetry({
message: '내일 날씨', toolResults: [{ name: 'weather_kma', error: true, result: 'boom' }],
}), true);
});
});