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:
@@ -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}` });
|
||||
|
||||
@@ -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 "마두로 잡혀간 이후
|
||||
// 어때?"에 세 턴 연속 도구 호출 없이 "체포되지 않았다"고 확신에 차 답했는데, 실제로는
|
||||
// 사용자가 맞았다(마두로는 미국에 체포·압송된 상태). 다음 턴에 검색을 하긴 했지만 카테고리만
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user