papa 세션 실측(2026-09-03): "눈향나무 사진 찾아줘" → search_images가  3개를 정상 반환했는데 gemma4가 "눈향나무 사진들입니다" + 설명만 내고 링크를 통째로 누락. 다음 턴 "사진이 어딧지?"에도 "죄송합니다..." 하며 또 0개. - appendDroppedSearchImages(reply-content.ts): 최종 답변에 이미지 마크다운이 하나도 없고 이번 턴 search_images가 성공했으면, 도구 결과의 를 뒤에 붙임. 하나라도 있으면 모델이 시도한 것으로 보고 안 건드림 - personality-context photo 프롬프트: " 줄을 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
120 lines
5.2 KiB
TypeScript
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) => ``).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';
|
|
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);
|
|
});
|
|
});
|