2026-09-04 메인챗 세션(0c367bd7) 감사. 사용자가 "옵션 프리미엄이 실물의 몇 %냐"를
다섯 턴에 걸쳐 물었고, 검색 6회에 산출물 0건으로 끝났다. 로그를 보니 원인이 모델이
아니라 우리 가드였다:
TOOL: web_search("typical option premium as percentage of underlying asset price")
TOOL OK: [1] TYPICAL Definition & Meaning - Merriam-Webster
NUMERIC-GROUNDING POST-CHECK (1/1): 3 figure(s) not found in tool output [10%, 1%, 2%]
모델은 매 턴 답을 냈다(10%/1%/2%, 다음 턴엔 3~10%/0.1~1%). 검색엔진이 "typical"의
사전 뜻풀이를 물어왔고, 그 뭉치에 숫자가 없다는 이유로 답이 날조 판정을 받아 지워졌다.
이 가드의 전제는 "검색이 그 주제를 다뤘는데 숫자가 없다 → 지어냈다"인데, 앞부분을
한 번도 확인한 적이 없었다. 무관한 말뭉치에서의 부재는 날조의 증거가 아니다.
* 관련성 관문(sourceCoversQuery): 검색어의 내용어 중 결과에 등장하는 비율이 0.34
미만이면 판정을 포기한다. 실측 — 사전 뭉치 0.14 / 정상 결과 0.86. 수치·날짜·부재주장
세 갈래가 같은 전제 위에 있어 한꺼번에 게이트했다. 검색어를 못 넘기는 호출부는 기존
동작 유지.
* SEARCH-ABANDONMENT도 같은 결함이 있었다(감사 중 발견). countSubstantiveResults는
스니펫 길이만 봐서 사전 페이지 4건을 전부 "알맹이 있음"으로 세고, 모델에게 "그걸로
답하라"고 민다 — 답을 지우는 대신 틀린 답을 강요하는, 같은 전제의 반대 방향 피해다.
같은 관문을 공유시켜 기준이 갈라지지 않게 했다.
* 그 가드는 애초에 죽어 있었다. NOTHING_FOUND가 "정보가 없다"류만 담고 있어, gemma4가
실제로 쓰는 "결과에 …이 포함되어 있지 않다"(주어가 결과)를 하나도 못 잡았다. 이
세션의 포기 답변 6건 중 0건 탐지. 사과로 시작하면 판정 대상인 첫 문장이 "죄송합니다."가
되는 두 번째 구멍도 함께 수정(한글 뒤 \b 미매칭 함정 주의).
* dead-end-fallback (신규): 두 번째로 빈손이면 검색을 끊고 "아는 것을 검증 못 했다고
라벨 붙여 말하라"로 돌린다. 문제의 스레드에서 3턴 앞서 끊긴다. 재프롬프트가 숫자를
요구하지 않아 NUMERIC-GROUNDING과 충돌하지 않는다.
* 제품·모델 선택 질문이 isFactualInfoRequest를 통째로 빠져나갔다("모델이 있을까",
"고른다면", "신형/최신"). 그래서 소설-모델 3턴이 도구 0개로 굴러가 2026년 9월에
"Gemma 2 27B"를 로컬 추천으로 냈다. 주제어가 앞 턴에만 있는 짧은 후속 질문용
isFactualThreadFollowUp 추가.
* confirmsUserGuessWithoutGrounding: 직전 턴에 확인 못 했다고 해놓고 사용자 추측에
"네, 맞습니다"로 동조하는 경로 차단(맥미니 M6 건). 4중 조건으로 좁혀 정당한 맞장구는
건드리지 않는다.
* cms_hospital_compare 게이트가 "병원"에만 걸려 있어 사용자가 내내 쓴 "의료법인"으로는
8턴 동안 스코프에 들지 않았다. 그 사이 공공 비중 답이 45~50%→39~40%→약 50%로 흔들렸다.
테스트 521개 통과(+65). 실사용 로그의 실제 발화·답변을 표본으로 고정했다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
210 lines
11 KiB
TypeScript
210 lines
11 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);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 2026-09-04 감사 회귀. 아래 6건은 메인챗 세션 0c367bd7의 실제 답변이고, 패치 전에는 **6건 전부**
|
|
* declaresNothingFound=false 였다 — 2026-08-12 태풍 사고 이후 이 가드는 사실상 한 번도 발동하지
|
|
* 않고 있었다. 원인 두 가지: (1) gemma4는 "정보가 없다"가 아니라 "결과에 …이 포함되어 있지 않다"
|
|
* 라고 쓴다 (2) 사과로 시작하면 판정 대상인 첫 문장이 "죄송합니다."가 되어 근거가 안 들어온다.
|
|
*/
|
|
describe('실사용 포기 답변 (2026-09-04 세션 감사)', () => {
|
|
const REAL_GIVEUPS = [
|
|
'죄송합니다. 이번 검색 결과에서는 미국 내 영리 및 비영리 의료법인의 구체적인 점유 비율(%)에 대한 수치가 포함되어 있지 않았습니다.',
|
|
'검색 결과들을 다시 면밀히 검토했으나, 안타깝게도 현재 시점(2026년 9월)의 신형 맥미니에 대한 시장 반응이나 리뷰 내용은 포함되어 있지 않았습니다.',
|
|
'검색 결과들을 다시 꼼꼼히 읽어보았으나, M6 맥미니의 시장 반응이나 비판 내용에 대해 직접적으로 언급한 내용은 단 하나도 없었습니다.',
|
|
'검색 결과에는 Gemma 4 27B 모델의 FP8 양자화 시 구체적인 VRAM 필요량이나 32GB 환경에서의 구동 가능 여부에 대한 수치가 포함되어 있지 않습니다.',
|
|
'검색 결과에는 옵션 프리미엄(티켓값)이 실물 가격의 몇 퍼센트인지에 대한 구체적인 수치나 비율이 명시되어 있지 않아 정확한 값을 단정하기 어렵습니다.',
|
|
'검색 결과에는 옵션 프리미엄이 실물 가격의 몇 퍼센트인지에 대한 구체적인 수치가 전혀 포함되어 있지 않아, 정확히 얼마인지 단정할 수 없습니다.',
|
|
];
|
|
|
|
for (const answer of REAL_GIVEUPS) {
|
|
test(`포기로 판정: ${answer.slice(0, 26)}…`, () => {
|
|
assert.equal(declaresNothingFound(answer), true);
|
|
});
|
|
}
|
|
|
|
test('사과로 시작해도 그 다음 문장에서 판정한다', () => {
|
|
assert.equal(leadSentence('죄송합니다. 검색 결과에 수치가 포함되어 있지 않았습니다.'),
|
|
'검색 결과에 수치가 포함되어 있지 않았습니다.');
|
|
});
|
|
|
|
test('사과로 시작하되 결론이 포기가 아니면 그대로 통과', () => {
|
|
// 같은 세션의 정정 답변 — 사과하고 나서 사실을 인정했지 포기한 게 아니다.
|
|
assert.equal(declaresNothingFound(
|
|
'죄송합니다. 제가 최신 출시 정보를 놓쳤습니다. 현재 시점 기준으로 M5, M6 칩을 탑재한 신형 맥미니가 출시된 상황이군요.',
|
|
), false);
|
|
});
|
|
|
|
test('건너뛰기는 두 문장까지만 — 발견 뒤 빈 곳을 덧붙인 답변을 삼키면 안 된다', () => {
|
|
assert.equal(declaresNothingFound(
|
|
'검색 결과에 따르면 제15호 태풍 찬홈은 일본 도쿄 동남동쪽 해상에 있습니다. 다만 정확한 좌표는 명시되어 있지 않습니다.',
|
|
), false);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 관련성 관문 (2026-09-04 감사). 이 가드는 스니펫 길이만으로 "알맹이 있음"을 판정했는데,
|
|
* 사전 뜻풀이 페이지의 스니펫은 넉넉히 40자를 넘는다. 문제의 옵션 프리미엄 턴에서
|
|
* Merriam-Webster/Cambridge/위키낱말사전 4건이 전부 substantive로 세어져, 모델에게
|
|
* "결과 4건에 알맹이가 있으니 그걸로 답하라"고 밀 참이었다 — 옵션 프리미엄을 'typical'의
|
|
* 사전 정의로 답하라는 지시가 된다. NUMERIC-GROUNDING과 정확히 같은 전제 위의 같은 결함이다.
|
|
*/
|
|
describe('무관한 결과에는 재시도를 강요하지 않는다', () => {
|
|
const DICT_QUERY = 'typical option premium as percentage of underlying asset price';
|
|
const DICT = [
|
|
'[1] TYPICAL Definition & Meaning - Merriam-Webster',
|
|
' https://www.merriam-webster.com/dictionary/typical',
|
|
' The meaning of TYPICAL is combining or exhibiting the essential characteristics of a group.',
|
|
'',
|
|
'[2] TYPICAL | English meaning - Cambridge Dictionary',
|
|
' https://dictionary.cambridge.org/dictionary/english/typical',
|
|
' TYPICAL definition: showing all the characteristics you would usually expect from a group.',
|
|
'',
|
|
'[3] typical 뜻과 사용법 | RedKiwi Words',
|
|
' https://redkiwiapp.com/ko/english-guide/words/typical',
|
|
' This car is typical of its kind, with good gas mileage and a comfortable ride. 전형적인 차입니다.',
|
|
'',
|
|
'[4] typical - 위키낱말사전',
|
|
' https://ko.wiktionary.org/wiki/typical',
|
|
' 전형적인, 대표적인. 특유의. 분류 영어 형용사 전형적이라는 뜻으로 널리 쓰이는 단어입니다.',
|
|
].join('\n');
|
|
const GIVE_UP = '검색 결과에는 관련 수치가 포함되어 있지 않습니다.';
|
|
|
|
test('길이로는 알맹이 4건이지만, 주제를 안 다뤘으므로 재시도하지 않는다', () => {
|
|
assert.equal(countSubstantiveResults([DICT]), 4);
|
|
const d = decideSearchAbandonmentRetry({
|
|
answer: GIVE_UP, sourceTexts: [DICT], searchQueries: [DICT_QUERY],
|
|
});
|
|
assert.equal(d.sourceOnTopic, false);
|
|
assert.equal(d.retry, false);
|
|
});
|
|
|
|
test('주제를 다룬 결과에서는 그대로 재시도 — 원래 태풍 사고를 계속 잡아야 한다', () => {
|
|
const d = decideSearchAbandonmentRetry({
|
|
answer: GIVE_UP,
|
|
sourceTexts: RESULTS,
|
|
searchQueries: ['제15호 태풍 찬홈 현재 위치 예상 경로'],
|
|
});
|
|
assert.equal(d.sourceOnTopic, true);
|
|
assert.equal(d.retry, true);
|
|
});
|
|
|
|
test('검색어를 안 넘기면 예전 동작 그대로', () => {
|
|
const d = decideSearchAbandonmentRetry({ answer: GIVE_UP, sourceTexts: [DICT] });
|
|
assert.equal(d.sourceOnTopic, true);
|
|
assert.equal(d.retry, true);
|
|
});
|
|
});
|
|
|