Files
homeclaw/tests/reply-content.test.ts
T
kimandClaude Sonnet 5 2537b04c7a fix: search_images 결과를 모델이 프로즈로 바꿔치기해 사진이 안 나오던 문제
papa 세션 실측(2026-09-03): "눈향나무 사진 찾아줘" → search_images가 ![alt](url)
3개를 정상 반환했는데 gemma4가 "눈향나무 사진들입니다" + 설명만 내고 링크를 통째로
누락. 다음 턴 "사진이 어딧지?"에도 "죄송합니다..." 하며 또 0개.

- appendDroppedSearchImages(reply-content.ts): 최종 답변에 이미지 마크다운이
  하나도 없고 이번 턴 search_images가 성공했으면, 도구 결과의 ![](url)를 뒤에 붙임.
  하나라도 있으면 모델이 시도한 것으로 보고 안 건드림
- personality-context photo 프롬프트: "![alt](url) 줄을 VERBATIM으로 복사, 프로즈로
  대체 금지"로 강화 (코드가 backstop, 프롬프트는 보조 — [[feedback_local_model_needs_code_backstop]])

참고: 눈향나무 같은 특정 수종은 Pexels/Unsplash 커버리지가 나빠 "빨간 배경 분재"가
매칭되기도 함 — 그건 스톡 API 한계로 별개 이슈.

tests +5 (473 통과).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdchNLTHsyYahp2PVQjoym
2026-09-03 16:00:42 +09:00

120 lines
5.2 KiB
TypeScript

/**
* reply-content.test.ts
*
* Whether a turn counts as having produced a reply. This decides if the user sees the model's
* answer or the generic "죄송합니다, 응답을 생성하지 못했습니다" fallback, so it fails in the
* most visible way possible: the model answers correctly and the user is told it didn't.
*
* The rule was `finalText.length < 5` until 2026-08-09, which is a proxy for the wrong thing.
* Two properties pull against each other:
* 1. short answers are still answers — "2", "네", "서울" must survive
* 2. stripped residue is not an answer — sanitizeFinalReply() can leave bare punctuation,
* and that is exactly what the fallback is for
* Every test below pins one or the other.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { replyLooksEmpty, appendDroppedSearchImages } from '../src/gateway/chat/reply-content';
describe('replyLooksEmpty — 짧지만 정당한 답변', () => {
// Observed live: "1+1은? 숫자만 답해" streamed the token "2", and the length rule
// then replaced it with the failure message.
test('한 자리 숫자 답변은 답변이다', () => {
assert.equal(replyLooksEmpty('2'), false);
});
test('한글 단답(네/예/아니오)은 답변이다', () => {
for (const reply of ['네', '예', '아니오', '응']) {
assert.equal(replyLooksEmpty(reply), false, `"${reply}" was treated as empty`);
}
});
test('짧은 고유명사 답변은 답변이다', () => {
assert.equal(replyLooksEmpty('서울'), false);
});
test('구두점이 붙은 단답도 답변이다', () => {
assert.equal(replyLooksEmpty('2.'), false);
assert.equal(replyLooksEmpty('네!'), false);
});
test('영문 단답과 소수점 숫자도 답변이다', () => {
assert.equal(replyLooksEmpty('OK'), false);
assert.equal(replyLooksEmpty('3.14'), false);
assert.equal(replyLooksEmpty('42'), false);
});
});
describe('replyLooksEmpty — 내용 없는 응답', () => {
test('빈 문자열/공백은 비어있다', () => {
assert.equal(replyLooksEmpty(''), true);
assert.equal(replyLooksEmpty(' '), true);
assert.equal(replyLooksEmpty('\n\n\t'), true);
});
test('null/undefined는 비어있다', () => {
assert.equal(replyLooksEmpty(null), true);
assert.equal(replyLooksEmpty(undefined), true);
});
// sanitizeFinalReply() strips pptx links and meta lines; a reply made only of those
// comes back as leftover punctuation, which is what the fallback message is for.
test('구두점/기호만 남은 잔여물은 비어있다', () => {
for (const residue of ['...', '**', '()', '[]', '-', '—', '·', '. . .']) {
assert.equal(replyLooksEmpty(residue), true, `"${residue}" was treated as content`);
}
});
});
describe('replyLooksEmpty — 비라틴 문자', () => {
// Guards the Unicode property escape: an ASCII-oriented rule (or a \b-based one, which
// never matches after Hangul) would mis-handle these.
test('한자/일본어/키릴 답변도 내용으로 인정한다', () => {
assert.equal(replyLooksEmpty('漢'), false);
assert.equal(replyLooksEmpty('はい'), false);
assert.equal(replyLooksEmpty('да'), false);
});
test('이모지만 있는 응답은 내용이 없다', () => {
assert.equal(replyLooksEmpty('🤔'), true);
});
});
describe('appendDroppedSearchImages', () => {
const imgTool = (n: number) => ({
name: 'search_images',
error: false,
result: Array.from({ length: n }, (_, i) => `![photo ${i + 1}](https://img.example/${i + 1}.jpg)`).join('\n\n'),
});
test('모델이 이미지를 다 빠뜨렸으면 도구 결과에서 끌어와 붙인다', () => {
// 2026-09-03 실제 사고: "눈향나무 사진들입니다" 인데 링크가 0개, 다음 턴 "죄송합니다..."도 0개
const out = appendDroppedSearchImages('눈향나무 사진들입니다. 옆으로 퍼지며 자랍니다.', [imgTool(3)]);
assert.equal((out.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length, 3);
assert.ok(out.startsWith('눈향나무 사진들입니다'));
});
test('모델이 이미지를 하나라도 넣었으면 건드리지 않는다', () => {
const reply = '사진입니다.\n\n![a](https://img.example/1.jpg)';
assert.equal(appendDroppedSearchImages(reply, [imgTool(3)]), reply);
});
test('search_images 호출이 없으면 그대로', () => {
assert.equal(appendDroppedSearchImages('그냥 답변', [{ name: 'web_search', error: false, result: 'x' }]), '그냥 답변');
assert.equal(appendDroppedSearchImages('그냥 답변', undefined), '그냥 답변');
});
test('search_images가 에러였으면 붙이지 않는다', () => {
assert.equal(
appendDroppedSearchImages('사진을 못 찾았습니다.', [{ name: 'search_images', error: true, result: 'No images found' }]),
'사진을 못 찾았습니다.',
);
});
test('여러 search_images 중 마지막 성공분을 쓴다', () => {
const out = appendDroppedSearchImages('사진입니다', [imgTool(1), { name: 'search_images', error: true, result: 'fail' }, imgTool(2)]);
assert.equal((out.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length, 2);
});
});