Files
homeclaw/tests/intent-only-reply.test.ts
T
kimandClaude Sonnet 5 88ba78c537 fix: 메인챗 두 가지 — 검색어 세대 추측 + "잠시만 기다려" 후 정지
2026-09-01 맥미니 M5/M6 세션 사후분석에서 나온 두 버그.

1. 세대 추측 검색어 오염: 사용자가 "맥미니 신형"(칩 미지정)이라 물었는데
   gemma4가 학습시점 지식으로 web_search("M4 Mac mini …")를 던졌고, SearXNG는
   시킨 대로 M4(2024) 기사를 정확히 반환 → 답 전체가 구세대 기준으로 틀어짐.
   - system-prompt TEMPORAL CONSISTENCY에 "신형/최신인데 세대 미지정이면
     쿼리에 기억 속 세대를 넣지 말고 현재 연도로 검색" 규칙
   - handle-chat: hasUnversionedNewestIntent + assumedGenerationTokenInQuery로
     감지해 1회 재프롬프트(오염된 검색 실행 전 차단)

2. "잠시만 기다려 주세요" 후 턴 종료: 사용자가 오류 지적하자 모델이
   "다시 확인해 보겠습니다. 잠시만 기다려 주세요."만 내고 도구 0개로 정지.
   isIntentOnlyReply는 답 속 "2026년 9월"을 실질내용으로 오인, lastRoundWas
   GuardReprompt도 false라 기존 넛지 미발동.
   - isDeferralPromiseReply 신규(좁은 "wait for me" 패턴), 가드 재프롬프트
     선행 없이도 1회 강제 continuation

tests/intent-only-reply.test.ts +12 케이스. 전체 456 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
2026-09-01 17:29:19 +09:00

113 lines
5.8 KiB
TypeScript

/**
* intent-only-reply.test.ts
*
* "계산하다가 멈춘다"는 신고(2026-08-18)의 정체는 생성 중단이 아니었다. 로그에는 답변이
* 끝까지 있었고, 문제는 **가드 재프롬프트에 모델이 예고만 하고 끝낸 것**이었다:
*
* NUMERIC-GROUNDING POST-CHECK (1/1) → "다시 검색해서 그 결과로 답하라"
* 모델 → "죄송합니다… 다시 한번 정밀하게 검색해 보겠습니다." (도구 호출 없음)
* 재시도 예산 소진 → 그 예고문이 그대로 최종 답변으로 나감
*
* isIntentOnlyReply는 그 응답만 좁게 집어낸다. 넓게 잡으면 멀쩡한 답변까지 재시도로 끌고 가
* 불필요한 도구 호출이 늘어난다 — 이 가드의 옛 버전이 정확히 그 이유로 꺼져 있었다.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
isIntentOnlyReply,
isDeferralPromiseReply,
hasUnversionedNewestIntent,
assumedGenerationTokenInQuery,
} from '../src/gateway/guards/prompt-gates';
describe('isIntentOnlyReply', () => {
test('실제로 멈췄던 그 응답을 잡는다', () => {
assert.equal(isIntentOnlyReply(
'죄송합니다. 제가 검색 결과에 없다는 이유만으로 데이터가 "존재하지 않는다"고 단정 지어 답변했습니다. '
+ '이는 명백한 오류이며, 제 지식의 한계를 사실처럼 표현하여 혼란을 드렸습니다.\n\n'
+ '다시 한번 구체적인 수치나 관련 연구 보고서가 있는지 정밀하게 검색해 보겠습니다.'), true);
});
test('영문 예고도 잡는다', () => {
assert.equal(isIntentOnlyReply("Let me search for that now."), true);
assert.equal(isIntentOnlyReply("I'll look this up and get back to you."), true);
});
test('예고가 없으면 아니다', () => {
assert.equal(isIntentOnlyReply('검색 결과를 찾지 못했습니다.'), false);
assert.equal(isIntentOnlyReply(''), false);
});
test('수치가 있으면 실질 답변이다 — 예고 문구가 섞여 있어도', () => {
// "설명드리겠습니다" 같은 관용구로 시작하는 정상 답변을 재시도로 끌고 가면 안 된다.
assert.equal(isIntentOnlyReply('계산해 보겠습니다. 총 배출량은 50,000톤입니다.'), false);
});
test('목록·코드·링크가 있으면 실질 답변이다', () => {
assert.equal(isIntentOnlyReply('정리해 드리겠습니다.\n- 첫째 항목\n- 둘째 항목'), false);
assert.equal(isIntentOnlyReply('만들어 드리겠습니다.\n```py\nx=1\n```'), false);
assert.equal(isIntentOnlyReply('찾아보겠습니다. https://example.com 참고'), false);
});
test('길면 이미 설명을 한 것이므로 아니다', () => {
const long = '확인해 보겠습니다. ' + '가'.repeat(500);
assert.equal(isIntentOnlyReply(long), false);
});
});
/**
* 2026-09-01 맥미니 M5/M6 세션: 사용자가 검색 결과 오류를 지적하자 모델이
* "…다시 정확하게 확인해 보겠습니다. 잠시만 기다려 주세요."만 내놓고 도구 0개로 턴 종료.
* isIntentOnlyReply 는 답 속 "2026년 9월"을 hasSubstance 로 오인해 놓쳤고,
* lastRoundWasGuardReprompt 도 false 라 기존 넛지가 안 걸렸다.
*/
describe('isDeferralPromiseReply', () => {
test('실제로 멈췄던 그 응답을 잡는다 — 우연한 날짜가 섞여 있어도', () => {
assert.equal(isDeferralPromiseReply(
'죄송합니다. 제가 최신 출시 정보를 놓쳤습니다. 현재 시점(2026년 9월) 기준으로 M5, M6 칩을 탑재한 신형 맥미니가 '
+ '출시된 상황이군요. 최신 모델인 M5, M6 맥미니의 시장 반응을 다시 정확하게 확인해 보겠습니다. 잠시만 기다려 주세요.'), true);
});
test('영문 "get back to you" 도 잡는다', () => {
assert.equal(isDeferralPromiseReply('Let me get back to you on that.'), true);
assert.equal(isDeferralPromiseReply('One moment — checking now.'), true);
});
test('바로 실행하겠다는 예고는 아니다 (기존 intent-only 경로가 처리)', () => {
assert.equal(isDeferralPromiseReply('알겠습니다. 바로 검색해서 정리해 드리겠습니다.'), false);
});
test('정상 답변은 아니다', () => {
assert.equal(isDeferralPromiseReply('네, RTX 5090의 대역폭은 약 1790 GB/s입니다.'), false);
assert.equal(isDeferralPromiseReply(''), false);
});
});
describe('hasUnversionedNewestIntent / assumedGenerationTokenInQuery', () => {
test('세대 없이 "신형/최신"을 물으면 true', () => {
assert.equal(hasUnversionedNewestIntent('맥미니 신형이 나왔는데 시장반응은 어때?'), true);
assert.equal(hasUnversionedNewestIntent('아이폰 최신 모델 가격'), true);
});
test('사용자가 세대를 직접 대면 false', () => {
assert.equal(hasUnversionedNewestIntent('mac m5 메모리 대역폭이 얼마나 높아졌지?'), false);
assert.equal(hasUnversionedNewestIntent('RTX 5090 최신 가격'), false);
});
test('신형 얘기가 아니면 false', () => {
assert.equal(hasUnversionedNewestIntent('서브프라임 사태 원인이 뭐야?'), false);
});
test('쿼리가 사용자가 안 말한 세대를 박으면 그 토큰을 돌려준다', () => {
assert.equal(
assumedGenerationTokenInQuery('M4 Mac mini release market reaction reviews', '맥미니 신형이 나왔는데 시장반응은 어때?', ''),
'M4');
});
test('사용자가 말한 세대거나 연도만 있으면 null', () => {
assert.equal(assumedGenerationTokenInQuery('Apple M5 chip memory bandwidth specs', 'mac m5 메모리 대역폭', ''), null);
assert.equal(assumedGenerationTokenInQuery('Mac mini 2026 latest review', '맥미니 신형', ''), null);
});
});