From c5c3c0449b39aec2d9439629e3be6a3bb042e9bf Mon Sep 17 00:00:00 2001 From: kim Date: Mon, 10 Aug 2026 15:42:31 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9E=AC=ED=95=B4=20=EC=A7=88=EB=AC=B8?= =?UTF-8?q?=EC=9D=84=20news=5Fsearch=EB=A1=9C=20=EC=9C=A0=EB=8F=84=20?= =?UTF-8?q?=E2=80=94=20web=5Fsearch=20=EA=B8=B0=EB=B3=B8=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EA=B0=80=20=EC=8B=A4=EC=A0=9C=20=EC=82=AC=EA=B1=B4?= =?UTF-8?q?=EC=9D=84=20=EB=86=93=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제 사고(2026-08-10): "태풍 돌핀 지금 어디 있지?"에 web_search가 자체 생성한 "태풍 돌핀 현재 위치 실시간 정보" 쿼리로 검색해 일반적인/내용없는 결과 3개(기상청 빈 포털, 오래된 지도 SPA, 무관한 결과 1개)만 받았다. 모델은 정직하게(좌표를 지어내지 않고) "검색결과가 없으니 이미 소멸했을 가능성이 높다"고 추론했다 — 추론 자체는 멀쩡했지만 전제(검색해도 안 나온다)가 검색 실패 때문에 거짓이었다. 실제로는 돌핀이 막 중국에 상륙해 500mm 폭우 경보, 100만명 이상 대피 중이었고, 같은 사건을 다룬 국내 언론사 기사가 6개 이상 있었다 — "태풍 돌핀 중국 상륙 피해"처럼 상황어를 넣은 쿼리로는 바로 찾아졌다. news_search(NewsData.io, 실제 발행일 보장)가 정확히 이런 "지금 벌어지고 있는 일" 질문을 위한 도구인데, 재해 질문은 뉴스로 분류되지 않아 (isNewsRequest는 "뉴스/속보" 키워드가 있어야 걸림) 이 라우팅을 안 탔다. isLiveDataRequest 안에 있던 재해 정규식을 isDisasterRequest로 분리해 handle-chat.ts의 사전 리마인더와 AUTO-RECOVER 재시도 리마인더 양쪽에 재사용했다 — 이 파일 헤더의 원칙대로, 검증을 강제하는 게이트끼리 어긋나면 한쪽만 고친 게 무의미해진다. 재해 질문엔 news_search를 우선 권하고, web_search를 쓰더라도 "현재 위치/실시간 정보" 같은 일반 쿼리 대신 "...상륙 피해", "...대피" 같은 상황어를 쓰도록 안내한다. Co-Authored-By: Claude Opus 5 --- src/gateway/chat/handle-chat.ts | 23 +++++++++++++++++++--- src/gateway/guards/prompt-gates.ts | 15 ++++++++++++++- tests/prompt-gates.test.ts | 31 ++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/gateway/chat/handle-chat.ts b/src/gateway/chat/handle-chat.ts index 41dfa4b..d91eb7b 100644 --- a/src/gateway/chat/handle-chat.ts +++ b/src/gateway/chat/handle-chat.ts @@ -21,6 +21,7 @@ import { isBrowserToolName, isDesktopToolName, isNewsRequest, + isDisasterRequest, isHighStakesFile, requestedFullTemplate, userRequestedImageEdit, @@ -690,11 +691,24 @@ async function handleChat( // discussing" instead of actual world news, once the model was forced to search for // something and grabbed the nearest available anchor). const newsRequest = isNewsRequest(message); + // A typhoon/earthquake/flood etc. actively unfolding is a breaking-news topic, not a + // reference lookup — but it wasn't routed like one. 2026-08-10 real incident: asked about + // Typhoon Dolphin's status, the model called web_search with its own default query + // ("태풍 돌핀 현재 위치 실시간 정보") and got three generic/navigational pages (KMA's blank + // portal, a stale weather-map SPA, one irrelevant result). It then reasoned — honestly, + // without inventing coordinates — that Dolphin had likely already dissipated. It hadn't: + // Dolphin had just made landfall in China with 500mm rainfall warnings and 1M+ evacuated, + // covered by 6+ major Korean outlets a search away with different phrasing ("태풍 돌핀 중국 + // 상륙 피해"). The reasoning was sound; the premise (nothing found) was false because the + // search never found what was there. news_search (NewsData.io, real publish dates) is + // built for exactly this — "일어나고 있는 일" — where web_search's generic phrasing tends to + // surface reference/navigational pages instead of the actual event coverage. + const disasterRequest = isDisasterRequest(message); messages.push({ role: 'assistant', content: 'Got it — checking now.' }); const _has = (n: string) => tools.some((t: any) => String(t?.function?.name || '') === n); const _weatherHint = ['weather_kma', 'weather_openmeteo', 'weather_search'].filter(_has).join(' / '); const _availableGroundingHint = [ - newsRequest && _has('news_search') ? 'news_search for news' : '', + (newsRequest || disasterRequest) && _has('news_search') ? 'news_search for news' : '', _has('web_search') ? 'web_search for general lookups' : '', _weatherHint ? `${_weatherHint} for weather/forecast` : '', ].filter(Boolean).join(', ') || 'whichever tool above fits'; @@ -707,7 +721,7 @@ async function handleChat( // also explains why weather routing behaved erratically: the model was calling blind. messages.push({ role: 'user', - content: `Reminder: this needs live/current data. Call a tool first (${_availableGroundingHint}) before writing anything. Do NOT answer from memory or training data.${newsRequest ? ' If this message names a specific topic, pass it as news_search\'s query param; otherwise (a bare "뉴스"/"news" request) call news_search several times across different categories (e.g. "top", "business", "technology", "world", "sports") rather than a single bare no-category call — a lone bare call tends to return only 4-5 stories clustered around 1-2 dominant topics; spanning categories like this builds a well-rounded ~8-10 item digest. Do NOT default to unrelated topics from earlier in this conversation unless the user explicitly asks for updates on them.' : ''}`, + content: `Reminder: this needs live/current data. Call a tool first (${_availableGroundingHint}) before writing anything. Do NOT answer from memory or training data.${newsRequest ? ' If this message names a specific topic, pass it as news_search\'s query param; otherwise (a bare "뉴스"/"news" request) call news_search several times across different categories (e.g. "top", "business", "technology", "world", "sports") rather than a single bare no-category call — a lone bare call tends to return only 4-5 stories clustered around 1-2 dominant topics; spanning categories like this builds a well-rounded ~8-10 item digest. Do NOT default to unrelated topics from earlier in this conversation unless the user explicitly asks for updates on them.' : ''}${disasterRequest ? ` Prefer news_search (query: the event name, e.g. "태풍 돌핀") over web_search for this — it guarantees real publish dates instead of stale/generic pages. If you do use web_search, do NOT default to a generic "현재 위치/실시간 정보" query; use situational keywords instead (e.g. "태풍 돌핀 상륙 피해", "지진 피해 현황") — those match actual news coverage, while location-lookup phrasing tends to surface blank reference portals. If the first attempt returns thin/generic results, that means the query was wrong, not that nothing is happening — retry with sharper keywords before concluding the event has ended.` : ''}`, }); } else if (isFactualInfoRequest(message)) { messages.push({ role: 'assistant', content: 'Got it — let me verify that first.' }); @@ -1605,17 +1619,20 @@ async function handleChat( } else { const isWeatherRequest = /(날씨|기온|예보|미세먼지|황사)/.test(message) || /\b(weather|forecast)\b/i.test(message); const newsRequest = isNewsRequest(message); + const disasterRequest = isDisasterRequest(message); // Same rule as the pre-emptive reminder above: name only tools this turn actually // holds, because executeTool() dispatches by name and would run a withheld tool blind. const _avail = (n: string) => tools.some((t: any) => String(t?.function?.name || '') === n); const _weatherAvail = ['weather_kma', 'weather_openmeteo', 'weather_search'].filter(_avail); const toolHint = isWeatherRequest && _weatherAvail.length ? `the weather tool (${_weatherAvail.join(' / ')} — NOT web_search)` - : newsRequest && _avail('news_search') + : (newsRequest || disasterRequest) && _avail('news_search') ? 'the news_search tool (NOT web_search)' : 'the web_search tool'; const scopeHint = newsRequest ? ' If this message names a specific topic, pass it as news_search\'s query param; otherwise (a bare "뉴스"/"news" request) call news_search several times across different categories (e.g. "top", "business", "technology", "world", "sports") rather than a single bare no-category call — a lone bare call tends to return only 4-5 stories clustered around 1-2 dominant topics; spanning categories like this builds a well-rounded ~8-10 item digest. Do NOT default to unrelated topics from earlier in this conversation unless the user explicitly asks for updates on them.' + : disasterRequest + ? ' Pass the event name as news_search\'s query param (e.g. "태풍 돌핀"). If you must use web_search instead, use situational keywords ("...상륙 피해", "...대피") rather than a generic "현재 위치/실시간 정보" query — the latter tends to surface blank reference portals instead of actual coverage.' : ''; messages.push({ role: 'assistant', content: 'Let me check that now.' }); messages.push({ role: 'user', content: `Yes, use ${toolHint} right now. Do NOT think or plan — just call it.${scopeHint}` }); diff --git a/src/gateway/guards/prompt-gates.ts b/src/gateway/guards/prompt-gates.ts index b597be8..aa68808 100644 --- a/src/gateway/guards/prompt-gates.ts +++ b/src/gateway/guards/prompt-gates.ts @@ -94,6 +94,19 @@ export function isNewsRequest(message: string): boolean { return /(뉴스|속보|소식)/.test(m) || /\bnews\b/i.test(m); } +// Split out from isLiveDataRequest's inline regex (2026-08-10) so handle-chat.ts's live-data +// reminder can route disaster queries toward news_search the same way it already does for +// isNewsRequest — see the note at that call site for why: web_search with the model's default +// query phrasing ("태풍 돌핀 현재 위치 실시간 정보") returned three generic/navigational pages +// while the same event was covered by 6+ Korean outlets a query away ("태풍 돌핀 중국 상륙 피해"). +// A currently-unfolding disaster is a breaking-news topic; news_search (NewsData.io, real +// publish dates) is the tool built for exactly that, not general web search. +export const DISASTER_PATTERN = /(태풍|호우|폭우|폭설|한파|폭염|지진|해일|쓰나미|산불|홍수|가뭄|열대저압부)/; + +export function isDisasterRequest(message: string): boolean { + return DISASTER_PATTERN.test(instructionZone(String(message || ''))); +} + export function isLiveDataRequest(message: string): boolean { // 붙여넣은 자료 본문의 우연한 키워드로는 발동하지 않는다. 아래 모든 분기(뉴스/날씨/재해/체포/ // 시점표현)가 같은 오탐을 내므로 분기별이 아니라 입력 단계에서 한 번에 처리한다. @@ -107,7 +120,7 @@ export function isLiveDataRequest(message: string): boolean { // 그냥 통과해 검색 없이 답변됐고(로그에 TOOL 호출 0건), 모델이 일주일 전 대화에서 쓴 것과 // 똑같은 좌표·기압·풍속을 그대로 다시 뱉었다. 날씨만큼이나 시시각각 변하는 값들이다. || /(날씨|기온|예보|미세먼지|황사|환율|주가|금리|시세|장마)/.test(m) - || /(태풍|호우|폭우|폭설|한파|폭염|지진|해일|쓰나미|산불|홍수|가뭄|열대저압부)/.test(m) + || DISASTER_PATTERN.test(m) // 공인의 신상 변동(체포/실각 등)도 같은 실패 패턴을 냈다. 2026-08-09 "마두로 잡혀간 이후 // 어때?"에 세 턴 연속 도구 호출 없이 "체포되지 않았다"고 확신에 차 답했는데, 실제로는 // 사용자가 맞았다(마두로는 미국에 체포·압송된 상태). 다음 턴에 검색을 하긴 했지만 카테고리만 diff --git a/tests/prompt-gates.test.ts b/tests/prompt-gates.test.ts index 1d08949..53c8d62 100644 --- a/tests/prompt-gates.test.ts +++ b/tests/prompt-gates.test.ts @@ -28,6 +28,7 @@ import { isMessagingRequest, isCancelIntent, isRerunIntent, + isDisasterRequest, } from '../src/gateway/guards/prompt-gates'; describe('looksLikeUnverifiedSpecClaim — 출력에서 검증 안 된 수치 감지', () => { @@ -277,6 +278,36 @@ describe('isNewsRequest / isLiveDataRequest', () => { }); }); +// [2026-08-10] isLiveDataRequest의 인라인 재해 정규식을 분리한 것 — handle-chat.ts의 검색 +// 리마인더가 재해 질문에도 news_search를 권하도록 재사용하기 위함. 실제 사고: "태풍 돌핀 지금 +// 어디 있지?"에 web_search가 자체 생성한 "현재 위치 실시간 정보" 쿼리로 검색해 일반적인/내용없는 +// 결과 3개만 받았고, 모델은 정직하게(수치를 지어내지 않고) "이미 소멸했을 가능성이 높다"고 +// 추론했다 — 틀린 결론이었다. 같은 날 같은 사건을 다룬 국내 언론사 기사가 6개 이상 있었는데, +// "태풍 돌핀 중국 상륙 피해"처럼 상황어를 넣은 쿼리로는 바로 찾아졌다. 추론 자체는 멀쩡했지만 +// 전제(검색해도 안 나온다)가 검색 실패 때문에 거짓이었다 — news_search(NewsData.io, 실제 +// 발행일 보장)가 정확히 이런 "지금 벌어지고 있는 일" 질문을 위한 도구다. +describe('isDisasterRequest — 재해 질문을 news_search로 유도', () => { + test('태풍/지진 등 재해 키워드를 잡는다', () => { + assert.equal(isDisasterRequest('태풍 돌핀 지금 어디 있지?'), true); + assert.equal(isDisasterRequest('방금 지진 났어?'), true); + assert.equal(isDisasterRequest('산불 피해 상황 어때'), true); + }); + + test('재해 키워드 없는 일반 질문은 안 잡는다', () => { + assert.equal(isDisasterRequest('오늘 서울 날씨 어때'), false); + assert.equal(isDisasterRequest('파이썬 리스트 정렬법'), false); + }); + + test('긴 붙여넣기 문서 한가운데의 우연한 재해 키워드로는 안 잡힌다', () => { + const doc = '회의록을 정리해주세요.\n' + + '참석자 발언을 순서대로 기록했습니다.\n'.repeat(20) + + '작년 태풍 피해 복구 예산 집행 내역을 참고자료로 첨부합니다.\n' + + '이후 논의는 다음 안건으로 넘어갔습니다.\n'.repeat(20); + assert.ok(doc.length > 600); + assert.equal(isDisasterRequest(doc), false); + }); +}); + describe('isUsableGroundingResult — 빈 검색결과 판별', () => { test('실제 내용이 있으면 사용 가능', () => { assert.equal(isUsableGroundingResult({ result: '[1] 어떤 기사 제목' }), true);