diff --git a/src/gateway/chat/retry-decisions.ts b/src/gateway/chat/retry-decisions.ts index b1f83da..abe78d7 100644 --- a/src/gateway/chat/retry-decisions.ts +++ b/src/gateway/chat/retry-decisions.ts @@ -49,8 +49,17 @@ import { // — 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 // 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. */ -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 { /** 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) // 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. + // message를 함께 넘겨야 사용자가 질문에 직접 적어 넣은 숫자를 검증 대상에서 뺀다 — 넘기지 + // 않으면 사용자가 준 좌표를 되풀이한 것만으로 재생성이 걸린다(2026-08-10 실제 오탐). const unverifiedSpecClaim = !isExecutionLikeRequest(message) && !isExemptFromVerification(message) - && looksLikeUnverifiedSpecClaim(content); + && looksLikeUnverifiedSpecClaim(content, message); const liveDataRequest = isLiveDataRequest(message) || isFactualInfoRequest(message) || unverifiedSpecClaim; const kind: AutoRecoverKind = browserAutomationRequest ? 'browser' : desktopAutomationRequest ? 'desktop' : 'search'; diff --git a/src/gateway/guards/prompt-gates.ts b/src/gateway/guards/prompt-gates.ts index 28524cf..b597be8 100644 --- a/src/gateway/guards/prompt-gates.ts +++ b/src/gateway/guards/prompt-gates.ts @@ -41,6 +41,34 @@ export function isExecutionLikeRequest(message: string): boolean { || /(만들어|생성해|구현해|개발해|고쳐|수정해|편집해|디버그|리팩터|리팩토링|패치|설정해|프로젝트|파일|폴더|디렉터리|워크스페이스|코드|바탕화면|화면|마우스|키보드|클립보드)/.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 // 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 @@ -67,7 +95,9 @@ export function isNewsRequest(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 // looks like a coding/build request or a greeting, since those aren't "look this up" asks. export function isFactualInfoRequest(message: string): boolean { - const m = String(message || '').trim(); + // isLiveDataRequest와 같은 이유로 지시 부분만 본다 — 이 파일 헤더의 원칙대로, 검증을 강제하는 + // 게이트들은 "언제 강제하지 않는가"에 대해 서로 어긋나면 안 된다. + const m = instructionZone(message).trim(); if (!m) return false; if (isExecutionLikeRequest(m) || isGreetingLikeMessage(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. 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(`(? { 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 — 빈 검색결과 판별', () => { diff --git a/tests/retry-decisions.test.ts b/tests/retry-decisions.test.ts index 69a8631..0f2db62 100644 --- a/tests/retry-decisions.test.ts +++ b/tests/retry-decisions.test.ts @@ -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)', () => { test('기본은 검색', () => { assert.equal(recover('오늘 뉴스', '어제 이런 일이...').kind, 'search');