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>
This commit is contained in:
@@ -49,8 +49,17 @@ import {
|
|||||||
// — legal answers routinely cite 원/% figures pulled from these, not memorized.
|
// — legal answers routinely cite 원/% figures pulled from these, not memorized.
|
||||||
// - sqlite_query — a real SQL query against a workspace .db file; whatever number comes back
|
// - sqlite_query — a real SQL query against a workspace .db file; whatever number comes back
|
||||||
// is retrieved from that file, not hallucinated, same logic as memory_stats.
|
// is retrieved from that file, not hallucinated, same logic as memory_stats.
|
||||||
|
//
|
||||||
|
// nhc_active_storms / eonet_events added 2026-08-10 — the same gap, found by auditing rather than
|
||||||
|
// by a user complaint. Both are direct pulls from authoritative feeds (US National Hurricane
|
||||||
|
// Center; NASA EONET), and they are the tools the typhoon questions actually run on. The
|
||||||
|
// MEASUREMENT_UNITS/COORDINATE_CLAIM tiers added on 2026-08-09 fire on exactly what those tools
|
||||||
|
// return (hPa, m/s, 북위/동경), so an answer grounded solely on NHC data matched the output guard
|
||||||
|
// while counting as "no grounding tool called" — forcing a web_search retry to second-guess the
|
||||||
|
// authoritative source it had just read. weather_* was already covered by prefix; these two were
|
||||||
|
// simply never added when they were written.
|
||||||
/** Tools that can actually ground a factual/live-data answer. Anything else does not count. */
|
/** Tools that can actually ground a factual/live-data answer. Anything else does not count. */
|
||||||
export const GROUNDING_TOOL_PATTERN = /^(web_search|web_fetch|weather_|ollama_web_|news_search|memory_stats|pubmed_search|pubmed_fetch|pubmed_fulltext|openalex_search|korean_law_search|korean_law_fetch|us_case_search|sqlite_query)/i;
|
export const GROUNDING_TOOL_PATTERN = /^(web_search|web_fetch|weather_|ollama_web_|news_search|memory_stats|nhc_active_storms|eonet_events|pubmed_search|pubmed_fetch|pubmed_fulltext|openalex_search|korean_law_search|korean_law_fetch|us_case_search|sqlite_query)/i;
|
||||||
|
|
||||||
export interface AutoRecoverInput {
|
export interface AutoRecoverInput {
|
||||||
/** The user's message this turn. */
|
/** The user's message this turn. */
|
||||||
@@ -94,9 +103,11 @@ export function decideAutoRecover(input: AutoRecoverInput): AutoRecoverDecision
|
|||||||
// ahead and stated a spec/price/benchmark number anyway. Execution requests (coding, file ops)
|
// ahead and stated a spec/price/benchmark number anyway. Execution requests (coding, file ops)
|
||||||
// are excluded so numeric literals there don't misfire, and the user's own infra nicknames are
|
// are excluded so numeric literals there don't misfire, and the user's own infra nicknames are
|
||||||
// exempt because web_search cannot verify private hardware.
|
// exempt because web_search cannot verify private hardware.
|
||||||
|
// message를 함께 넘겨야 사용자가 질문에 직접 적어 넣은 숫자를 검증 대상에서 뺀다 — 넘기지
|
||||||
|
// 않으면 사용자가 준 좌표를 되풀이한 것만으로 재생성이 걸린다(2026-08-10 실제 오탐).
|
||||||
const unverifiedSpecClaim = !isExecutionLikeRequest(message)
|
const unverifiedSpecClaim = !isExecutionLikeRequest(message)
|
||||||
&& !isExemptFromVerification(message)
|
&& !isExemptFromVerification(message)
|
||||||
&& looksLikeUnverifiedSpecClaim(content);
|
&& looksLikeUnverifiedSpecClaim(content, message);
|
||||||
const liveDataRequest = isLiveDataRequest(message) || isFactualInfoRequest(message) || unverifiedSpecClaim;
|
const liveDataRequest = isLiveDataRequest(message) || isFactualInfoRequest(message) || unverifiedSpecClaim;
|
||||||
|
|
||||||
const kind: AutoRecoverKind = browserAutomationRequest ? 'browser' : desktopAutomationRequest ? 'desktop' : 'search';
|
const kind: AutoRecoverKind = browserAutomationRequest ? 'browser' : desktopAutomationRequest ? 'desktop' : 'search';
|
||||||
|
|||||||
@@ -41,6 +41,34 @@ export function isExecutionLikeRequest(message: string): boolean {
|
|||||||
|| /(만들어|생성해|구현해|개발해|고쳐|수정해|편집해|디버그|리팩터|리팩토링|패치|설정해|프로젝트|파일|폴더|디렉터리|워크스페이스|코드|바탕화면|화면|마우스|키보드|클립보드)/.test(m);
|
|| /(만들어|생성해|구현해|개발해|고쳐|수정해|편집해|디버그|리팩터|리팩토링|패치|설정해|프로젝트|파일|폴더|디렉터리|워크스페이스|코드|바탕화면|화면|마우스|키보드|클립보드)/.test(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 긴 붙여넣기 자료 + 짧은 지시로 이루어진 메시지에서, 게이트가 볼 '지시' 부분만 잘라낸다.
|
||||||
|
// 탐정/변호사 앱이 보내는 사건정보 템플릿이 대표적이다.
|
||||||
|
//
|
||||||
|
// 키워드 게이트들은 전부 짧은 질문("지금 서울 날씨")을 기준으로 잡혀 있다. 본문이 수천 자면
|
||||||
|
// 어떤 키워드든 언젠가는 우연히 들어가고, 그때 매칭된 단어는 사용자의 '요청'이 아니라 붙여넣은
|
||||||
|
// '자료' 쪽에 있다. 2026-08-10 실사용 104건 대조에서 나온 유일한 오탐이 이것이었다: 7,583자짜리
|
||||||
|
// 고소장 작성 요청이 본문 한가운데의 "시세"(장물 시세) 때문에 isLiveDataRequest에 걸려, 문서를
|
||||||
|
// 써 달라는 턴에 "메모리에서 답하지 말고 검색부터 하라"는 리마인더(~250토큰)가 붙었다. 토큰
|
||||||
|
// 낭비를 넘어 작업 방향을 반대로 미는 오탐이다.
|
||||||
|
//
|
||||||
|
// 처음에는 "길다 + isExecutionLikeRequest"로 막으려 했는데, 회귀 테스트가 그게 틀렸음을 잡아냈다
|
||||||
|
// — 그 메시지가 실행형으로 분류된 이유가 본문 어딘가의 "화면" 한 단어였다. 막으려는 문제와 똑같은
|
||||||
|
// 우연한 키워드 매칭 위에 수정을 얹는 꼴이라, 실행형 키워드가 안 들어간 문서에는 듣지 않는다.
|
||||||
|
//
|
||||||
|
// 대신 위치를 본다. 사람은 지시를 자료의 앞이나 뒤에 쓰지, 한가운데 묻어두지 않는다. 실제로 그
|
||||||
|
// 메시지의 라이브 키워드는 1673·2578·2756자 지점(전부 본문 한복판)에 있었고 앞뒤는 깨끗했다.
|
||||||
|
// 임계값 근거: 같은 표본에서 정당한 실시간 질문 25건은 최소 8자·중앙값 14자·최대 ~120자였다.
|
||||||
|
// 600자는 그 위로 한참 여유를 둔 값이라, 평범한 메시지의 판정은 이전과 완전히 동일하다.
|
||||||
|
const LONG_MESSAGE_CHARS = 600;
|
||||||
|
const INSTRUCTION_HEAD_CHARS = 400;
|
||||||
|
const INSTRUCTION_TAIL_CHARS = 300;
|
||||||
|
|
||||||
|
export function instructionZone(message: string): string {
|
||||||
|
const m = String(message || '');
|
||||||
|
if (m.length <= LONG_MESSAGE_CHARS) return m;
|
||||||
|
return `${m.slice(0, INSTRUCTION_HEAD_CHARS)}\n${m.slice(-INSTRUCTION_TAIL_CHARS)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// "무슨 좋은 소식 있어?" style phrasing is a personal check-in ("got any good news [about
|
// "무슨 좋은 소식 있어?" style phrasing is a personal check-in ("got any good news [about
|
||||||
// you]?"), not a world-news request — without this, bare "소식" (added 2026-07-25 to catch
|
// you]?"), not a world-news request — without this, bare "소식" (added 2026-07-25 to catch
|
||||||
// "오늘 소식" as a news synonym) would force the model into "call news_search, do not answer
|
// "오늘 소식" as a news synonym) would force the model into "call news_search, do not answer
|
||||||
@@ -67,7 +95,9 @@ export function isNewsRequest(message: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isLiveDataRequest(message: string): boolean {
|
export function isLiveDataRequest(message: string): boolean {
|
||||||
const m = String(message || '');
|
// 붙여넣은 자료 본문의 우연한 키워드로는 발동하지 않는다. 아래 모든 분기(뉴스/날씨/재해/체포/
|
||||||
|
// 시점표현)가 같은 오탐을 내므로 분기별이 아니라 입력 단계에서 한 번에 처리한다.
|
||||||
|
const m = instructionZone(message);
|
||||||
// 지서버/클로서버/시어엔진 같은 개인 인프라는 웹검색으로 검증할 수 없다. 이 함수는 지금까지
|
// 지서버/클로서버/시어엔진 같은 개인 인프라는 웹검색으로 검증할 수 없다. 이 함수는 지금까지
|
||||||
// 이 예외를 전혀 거치지 않았다 — "체포/최근" 같은 넓은 패턴을 새로 추가하면서 "지서버 최근에
|
// 이 예외를 전혀 거치지 않았다 — "체포/최근" 같은 넓은 패턴을 새로 추가하면서 "지서버 최근에
|
||||||
// 왜 이렇게 느려" 같은 문장이 웹검색 리마인더에 걸릴 뻔했다.
|
// 왜 이렇게 느려" 같은 문장이 웹검색 리마인더에 걸릴 뻔했다.
|
||||||
@@ -102,7 +132,9 @@ export function isLiveDataRequest(message: string): boolean {
|
|||||||
// (e.g. confidently inventing GPU TFLOPS/VRAM figures). Deliberately excludes anything that
|
// (e.g. confidently inventing GPU TFLOPS/VRAM figures). Deliberately excludes anything that
|
||||||
// looks like a coding/build request or a greeting, since those aren't "look this up" asks.
|
// looks like a coding/build request or a greeting, since those aren't "look this up" asks.
|
||||||
export function isFactualInfoRequest(message: string): boolean {
|
export function isFactualInfoRequest(message: string): boolean {
|
||||||
const m = String(message || '').trim();
|
// isLiveDataRequest와 같은 이유로 지시 부분만 본다 — 이 파일 헤더의 원칙대로, 검증을 강제하는
|
||||||
|
// 게이트들은 "언제 강제하지 않는가"에 대해 서로 어긋나면 안 된다.
|
||||||
|
const m = instructionZone(message).trim();
|
||||||
if (!m) return false;
|
if (!m) return false;
|
||||||
if (isExecutionLikeRequest(m) || isGreetingLikeMessage(m)) return false;
|
if (isExecutionLikeRequest(m) || isGreetingLikeMessage(m)) return false;
|
||||||
if (isExemptFromVerification(m)) return false;
|
if (isExemptFromVerification(m)) return false;
|
||||||
@@ -153,10 +185,44 @@ const AMBIGUOUS_SPEC_UNITS = /\d[\d,.]*\s*(GHz|MHz|watts?|원|달러|USD|%|\$)/i
|
|||||||
// the hardware ones already listed, so they belong in this set.
|
// the hardware ones already listed, so they belong in this set.
|
||||||
const SPEC_CONTEXT_KEYWORD = /(스펙|사양|가격|가격대|출시|모델명|버전|성능|GPU|CPU|VRAM|램|벤치마크|사이즈|용량|토큰|대역폭|추론|처리\s*속도)/i;
|
const SPEC_CONTEXT_KEYWORD = /(스펙|사양|가격|가격대|출시|모델명|버전|성능|GPU|CPU|VRAM|램|벤치마크|사이즈|용량|토큰|대역폭|추론|처리\s*속도)/i;
|
||||||
|
|
||||||
export function looksLikeUnverifiedSpecClaim(content: string): boolean {
|
// 2026-08-10, 실제 오탐(로그 확인): 사용자가 "북위 27.7도, 동경 124.8도 여기가 어디쯤이지?"라고
|
||||||
|
// 물었고 — 그 좌표는 한 턴 전 web_search 결과에서 나온 것을 사용자가 그대로 옮겨 적은 것이다 —
|
||||||
|
// 모델이 정답("동중국해")을 냈는데, 답에 좌표를 되풀이했다는 이유로 COORDINATE_CLAIM이 걸려
|
||||||
|
// AUTO-RECOVER가 발동했다. 맞는 답을 버리고 재생성 + "latitude 27.7 longitude 124.8 location"
|
||||||
|
// 검색을 태웠고, 돌아온 건 "농림통계연보(2005).hwp"였다. 그러고서 같은 답을 다시 썼다.
|
||||||
|
//
|
||||||
|
// 이 가드가 잡으려는 건 "모델이 지어낸 숫자"인데, 사용자가 질문에 직접 적어 넣은 숫자는 정의상
|
||||||
|
// 모델이 만들어낸 값이 아니다. 검증할 대상이 아니므로 스캔 전에 지운다. 모델이 새로 덧붙인
|
||||||
|
// 숫자는 그대로 남으므로 가드의 본래 역할은 줄어들지 않는다.
|
||||||
|
//
|
||||||
|
// 한 자리 수는 제외한다 — 사용자 메시지의 "2" 하나가 답변 전체의 모든 "2"를 지워버리면 가드가
|
||||||
|
// 통째로 무력화된다. 두 자리 이상만 지우는 쪽이 안전한 실패 방향(가드가 계속 발동)이다.
|
||||||
|
const NUMERIC_LITERAL = /\d[\d,.]*/g;
|
||||||
|
|
||||||
|
function blankUserSuppliedNumbers(text: string, userMessage: string): string {
|
||||||
|
const supplied = (String(userMessage || '').match(NUMERIC_LITERAL) || [])
|
||||||
|
.map(s => s.replace(/[.,]+$/, ''))
|
||||||
|
.filter(s => s.length >= 2);
|
||||||
|
let out = text;
|
||||||
|
for (const n of new Set(supplied)) {
|
||||||
|
const esc = n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
// 앞뒤 경계로 자릿수를 막는다 — "27.7"이 "127.75" 안에서 지워지면 안 된다.
|
||||||
|
out = out.replace(new RegExp(`(?<![\\d.])${esc}(?![\\d.])`, 'g'), '');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param userMessage 있으면 사용자가 직접 적은 숫자는 검증 대상에서 뺀다. 호출자가 그 턴의
|
||||||
|
* 사용자 메시지를 가지고 있다면 넘기는 편이 낫다 — 없으면 예전처럼 전부 검사한다.
|
||||||
|
*/
|
||||||
|
export function looksLikeUnverifiedSpecClaim(content: string, userMessage?: string): boolean {
|
||||||
const text = String(content || '');
|
const text = String(content || '');
|
||||||
if (!text) return false;
|
if (!text) return false;
|
||||||
const withoutCode = text.replace(/```[\s\S]*?```/g, '');
|
const withoutCode = blankUserSuppliedNumbers(
|
||||||
|
text.replace(/```[\s\S]*?```/g, ''),
|
||||||
|
String(userMessage || ''),
|
||||||
|
);
|
||||||
return STRICT_SPEC_UNITS.test(withoutCode)
|
return STRICT_SPEC_UNITS.test(withoutCode)
|
||||||
|| MEASUREMENT_UNITS.test(withoutCode)
|
|| MEASUREMENT_UNITS.test(withoutCode)
|
||||||
|| COORDINATE_CLAIM.test(withoutCode)
|
|| COORDINATE_CLAIM.test(withoutCode)
|
||||||
|
|||||||
@@ -236,6 +236,45 @@ describe('isNewsRequest / isLiveDataRequest', () => {
|
|||||||
assert.equal(isLiveDataRequest('지서버 최근에 왜 이렇게 느려'), false);
|
assert.equal(isLiveDataRequest('지서버 최근에 왜 이렇게 느려'), false);
|
||||||
assert.equal(isLiveDataRequest('현재 시어엔진 상태 어때?'), false);
|
assert.equal(isLiveDataRequest('현재 시어엔진 상태 어때?'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// [2026-08-10] 실사용 104건 대조에서 나온 유일한 오탐: 7,583자짜리 고소장 작성 요청이 본문
|
||||||
|
// 한가운데(2578자 지점)의 "시세"(장물 시세) 때문에 걸렸다. 매칭된 단어가 사용자의 '요청'이
|
||||||
|
// 아니라 붙여넣은 '자료' 쪽에 있었던 것 — 문서를 써 달라는 턴에 "검색부터 하라"는 리마인더가
|
||||||
|
// 붙으면 토큰 낭비를 넘어 작업 방향을 반대로 민다.
|
||||||
|
test('긴 붙여넣기 문서 한가운데의 우연한 키워드로는 라이브 데이터가 되지 않는다', () => {
|
||||||
|
const 고소장 = '다음 사건 정보를 바탕으로 고소장을 작성해주세요.\n\n[사건정보]\n'
|
||||||
|
+ '피해자 진술을 정리한 내용입니다. 관련 경위를 시간순으로 적었습니다.\n'.repeat(12)
|
||||||
|
+ '피해품의 시세는 최근 거래가 기준으로 산정하였음.\n'
|
||||||
|
+ '진술을 계속 정리한 내용입니다. 참고자료를 덧붙였습니다.\n'.repeat(12)
|
||||||
|
+ '\n위 내용으로 고소장을 작성해 주세요.';
|
||||||
|
assert.ok(고소장.length > 600);
|
||||||
|
assert.equal(isLiveDataRequest(고소장), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 앞선 시도는 "길다 + 실행형"으로 막으려 했는데, 그 메시지가 실행형으로 분류된 근거가 본문의
|
||||||
|
// "화면" 한 단어였다 — 막으려는 문제와 똑같은 우연 위에 얹은 수정이라 이 케이스가 그걸 잡는다.
|
||||||
|
test('실행형 키워드가 없는 긴 문서에도 적용된다', () => {
|
||||||
|
const 자료 = '아래 회의록을 정리해 주세요.\n'
|
||||||
|
+ '참석자들이 논의한 사항을 순서대로 기록하였습니다.\n'.repeat(20)
|
||||||
|
+ '환율 관련 언급이 한 차례 있었습니다.\n'
|
||||||
|
+ '이후 논의는 다음 안건으로 넘어갔습니다.\n'.repeat(20);
|
||||||
|
assert.ok(자료.length > 600);
|
||||||
|
assert.equal(isLiveDataRequest(자료), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('장문이어도 지시부에 시점표현이 있으면 라이브 데이터다', () => {
|
||||||
|
// 길이만으로 끄면 안 된다 — 앞뒤(지시부)에 있으면 그대로 걸려야 한다.
|
||||||
|
const 장문질문 = '지금 한반도 주변에 발달한 태풍이 있는지 궁금합니다. '
|
||||||
|
+ '지난주에 뉴스에서 얼핏 본 기억이 있어서 배경을 좀 적어봅니다. '.repeat(20);
|
||||||
|
assert.ok(장문질문.length > 600);
|
||||||
|
assert.equal(isLiveDataRequest(장문질문), true);
|
||||||
|
|
||||||
|
const 끝에지시 = '아래는 제가 정리해둔 자료입니다.\n'
|
||||||
|
+ '작년에 다녀온 여행 기록을 옮겨 적었습니다.\n'.repeat(25)
|
||||||
|
+ '\n그건 그렇고 지금 서울 날씨 어때?';
|
||||||
|
assert.ok(끝에지시.length > 600);
|
||||||
|
assert.equal(isLiveDataRequest(끝에지시), true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isUsableGroundingResult — 빈 검색결과 판별', () => {
|
describe('isUsableGroundingResult — 빈 검색결과 판별', () => {
|
||||||
|
|||||||
@@ -88,6 +88,37 @@ describe('AUTO-RECOVER — 근거 없이 답한 경우 재시도', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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)', () => {
|
describe('AUTO-RECOVER — 어떤 도구를 요구할지 (kind)', () => {
|
||||||
test('기본은 검색', () => {
|
test('기본은 검색', () => {
|
||||||
assert.equal(recover('오늘 뉴스', '어제 이런 일이...').kind, 'search');
|
assert.equal(recover('오늘 뉴스', '어제 이런 일이...').kind, 'search');
|
||||||
|
|||||||
Reference in New Issue
Block a user