fix: 재해 질문을 news_search로 유도 — web_search 기본 쿼리가 실제 사건을 놓침

실제 사고(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 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-10 15:42:31 +09:00
co-authored by Claude Opus 5
parent f972b184d6
commit c5c3c0449b
3 changed files with 65 additions and 4 deletions
+20 -3
View File
@@ -21,6 +21,7 @@ import {
isBrowserToolName, isBrowserToolName,
isDesktopToolName, isDesktopToolName,
isNewsRequest, isNewsRequest,
isDisasterRequest,
isHighStakesFile, isHighStakesFile,
requestedFullTemplate, requestedFullTemplate,
userRequestedImageEdit, userRequestedImageEdit,
@@ -690,11 +691,24 @@ async function handleChat(
// discussing" instead of actual world news, once the model was forced to search for // discussing" instead of actual world news, once the model was forced to search for
// something and grabbed the nearest available anchor). // something and grabbed the nearest available anchor).
const newsRequest = isNewsRequest(message); 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.' }); messages.push({ role: 'assistant', content: 'Got it — checking now.' });
const _has = (n: string) => tools.some((t: any) => String(t?.function?.name || '') === n); 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 _weatherHint = ['weather_kma', 'weather_openmeteo', 'weather_search'].filter(_has).join(' / ');
const _availableGroundingHint = [ 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' : '', _has('web_search') ? 'web_search for general lookups' : '',
_weatherHint ? `${_weatherHint} for weather/forecast` : '', _weatherHint ? `${_weatherHint} for weather/forecast` : '',
].filter(Boolean).join(', ') || 'whichever tool above fits'; ].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. // also explains why weather routing behaved erratically: the model was calling blind.
messages.push({ messages.push({
role: 'user', 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)) { } else if (isFactualInfoRequest(message)) {
messages.push({ role: 'assistant', content: 'Got it — let me verify that first.' }); messages.push({ role: 'assistant', content: 'Got it — let me verify that first.' });
@@ -1605,17 +1619,20 @@ async function handleChat(
} else { } else {
const isWeatherRequest = /(날씨|기온|예보|미세먼지|황사)/.test(message) || /\b(weather|forecast)\b/i.test(message); const isWeatherRequest = /(날씨|기온|예보|미세먼지|황사)/.test(message) || /\b(weather|forecast)\b/i.test(message);
const newsRequest = isNewsRequest(message); const newsRequest = isNewsRequest(message);
const disasterRequest = isDisasterRequest(message);
// Same rule as the pre-emptive reminder above: name only tools this turn actually // 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. // 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 _avail = (n: string) => tools.some((t: any) => String(t?.function?.name || '') === n);
const _weatherAvail = ['weather_kma', 'weather_openmeteo', 'weather_search'].filter(_avail); const _weatherAvail = ['weather_kma', 'weather_openmeteo', 'weather_search'].filter(_avail);
const toolHint = isWeatherRequest && _weatherAvail.length const toolHint = isWeatherRequest && _weatherAvail.length
? `the weather tool (${_weatherAvail.join(' / ')} — NOT web_search)` ? `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 news_search tool (NOT web_search)'
: 'the web_search tool'; : 'the web_search tool';
const scopeHint = newsRequest 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.' ? ' 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: '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}` }); messages.push({ role: 'user', content: `Yes, use ${toolHint} right now. Do NOT think or plan — just call it.${scopeHint}` });
+14 -1
View File
@@ -94,6 +94,19 @@ export function isNewsRequest(message: string): boolean {
return /(뉴스|속보|소식)/.test(m) || /\bnews\b/i.test(m); 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 { export function isLiveDataRequest(message: string): boolean {
// 붙여넣은 자료 본문의 우연한 키워드로는 발동하지 않는다. 아래 모든 분기(뉴스/날씨/재해/체포/ // 붙여넣은 자료 본문의 우연한 키워드로는 발동하지 않는다. 아래 모든 분기(뉴스/날씨/재해/체포/
// 시점표현)가 같은 오탐을 내므로 분기별이 아니라 입력 단계에서 한 번에 처리한다. // 시점표현)가 같은 오탐을 내므로 분기별이 아니라 입력 단계에서 한 번에 처리한다.
@@ -107,7 +120,7 @@ export function isLiveDataRequest(message: string): boolean {
// 그냥 통과해 검색 없이 답변됐고(로그에 TOOL 호출 0건), 모델이 일주일 전 대화에서 쓴 것과 // 그냥 통과해 검색 없이 답변됐고(로그에 TOOL 호출 0건), 모델이 일주일 전 대화에서 쓴 것과
// 똑같은 좌표·기압·풍속을 그대로 다시 뱉었다. 날씨만큼이나 시시각각 변하는 값들이다. // 똑같은 좌표·기압·풍속을 그대로 다시 뱉었다. 날씨만큼이나 시시각각 변하는 값들이다.
|| /(날씨|기온|예보|미세먼지|황사|환율|주가|금리|시세|장마)/.test(m) || /(날씨|기온|예보|미세먼지|황사|환율|주가|금리|시세|장마)/.test(m)
|| /(태풍|호우|폭우|폭설|한파|폭염|지진|해일|쓰나미|산불|홍수|가뭄|열대저압부)/.test(m) || DISASTER_PATTERN.test(m)
// 공인의 신상 변동(체포/실각 등)도 같은 실패 패턴을 냈다. 2026-08-09 "마두로 잡혀간 이후 // 공인의 신상 변동(체포/실각 등)도 같은 실패 패턴을 냈다. 2026-08-09 "마두로 잡혀간 이후
// 어때?"에 세 턴 연속 도구 호출 없이 "체포되지 않았다"고 확신에 차 답했는데, 실제로는 // 어때?"에 세 턴 연속 도구 호출 없이 "체포되지 않았다"고 확신에 차 답했는데, 실제로는
// 사용자가 맞았다(마두로는 미국에 체포·압송된 상태). 다음 턴에 검색을 하긴 했지만 카테고리만 // 사용자가 맞았다(마두로는 미국에 체포·압송된 상태). 다음 턴에 검색을 하긴 했지만 카테고리만
+31
View File
@@ -28,6 +28,7 @@ import {
isMessagingRequest, isMessagingRequest,
isCancelIntent, isCancelIntent,
isRerunIntent, isRerunIntent,
isDisasterRequest,
} from '../src/gateway/guards/prompt-gates'; } from '../src/gateway/guards/prompt-gates';
describe('looksLikeUnverifiedSpecClaim — 출력에서 검증 안 된 수치 감지', () => { 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 — 빈 검색결과 판별', () => { describe('isUsableGroundingResult — 빈 검색결과 판별', () => {
test('실제 내용이 있으면 사용 가능', () => { test('실제 내용이 있으면 사용 가능', () => {
assert.equal(isUsableGroundingResult({ result: '[1] 어떤 기사 제목' }), true); assert.equal(isUsableGroundingResult({ result: '[1] 어떤 기사 제목' }), true);