fix: 지서버 미세먼지 도구 오류 원인 규명 및 강수확률 하드코딩 방지
weather_airkorea가 data.go.kr에서 계속 403(SERVICE_KEY_IS_NOT_REGISTERED_ERROR)을 내던 원인을 규명 - 기상청 키는 살아있지만 에어코리아 대기오염정보 조회서비스는 별도 활용신청 승인이 필요함. 에러 메시지를 명확히 하고 모델이 무한 루프 대신 바로 web_search로 넘어가도록 안내. weather_openmeteo 기본 variables에 precipitation_probability가 빠져있어 모델이 강수확률을 임의로 지어내던 문제 수정 - 기본값에 추가하고, precipitation(mm)은 확률이 아니라는 점을 도구 설명에 명시. weather_kma에 미세먼지 데이터 없음 안내 추가, 시/군 이름→시도 자동변환 (구미→경북 등), 시/군 조회 시 해당 측정소를 결과 상단에 노출. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -880,7 +880,7 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
|
||||
properties: {
|
||||
location: { type: 'string', description: 'City name (Korean or English) or "lat,lon"' },
|
||||
hours: { type: 'number', description: 'Hours ahead (1–384, default 24)' },
|
||||
variables: { type: 'string', description: 'Comma-separated Open-Meteo hourly variable names (default: temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code)' },
|
||||
variables: { type: 'string', description: 'Comma-separated Open-Meteo hourly variable names (default: temperature_2m,precipitation,precipitation_probability,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code). precipitation is rainfall in mm, NOT a probability — for "rain chance/%" questions you must include precipitation_probability (or precipitation_probability_max for daily) or there is no probability figure to report; never invent one.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -894,7 +894,7 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
|
||||
// highs ran 1.5-2.5°C cooler (34.0 vs 36.5 for the same day), which matches its far
|
||||
// denser domestic observation network. "current" is a real measurement, not a model
|
||||
// estimate like the other two.
|
||||
description: '한국 위치라면 이 도구를 먼저 쓸 것 (기상청 공식 API). type="current"는 관측 실황(기온·습도·풍향·풍속·강수형태) — 추정치가 아닌 실측값이라 국내에서 가장 신뢰도 높음. type="forecast"는 3일 단기예보(강수확률 포함). 한국 도시명을 그대로 넣으면 된다. 3일을 넘는 예보나 UV·시간별 상세가 필요하면 weather_openmeteo로 보완할 것. 해외 지역에는 사용 불가.',
|
||||
description: '한국 위치라면 이 도구를 먼저 쓸 것 (기상청 공식 API). type="current"는 관측 실황(기온·습도·풍향·풍속·강수형태) — 추정치가 아닌 실측값이라 국내에서 가장 신뢰도 높음. type="forecast"는 3일 단기예보(강수확률 포함). 한국 도시명을 그대로 넣으면 된다. 3일을 넘는 예보나 UV·시간별 상세가 필요하면 weather_openmeteo로 보완할 것. **미세먼지/초미세먼지/PM2.5/PM10/황사/대기질은 이 도구에 없음 — weather_airkorea를 쓸 것** (이 도구를 반복 호출해도 답이 안 나옴). 해외 지역에는 사용 불가.',
|
||||
parameters: {
|
||||
type: 'object', required: ['location'],
|
||||
properties: {
|
||||
@@ -908,11 +908,11 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'weather_airkorea',
|
||||
description: '에어코리아 API로 한국 시도별 실시간 대기질(PM2.5, PM10, O3, NO2, CO, SO2) 조회. data.go.kr API 키 필요.',
|
||||
description: '미세먼지/초미세먼지/황사/대기질(PM2.5, PM10, O3, NO2, CO, SO2) 질문에 쓸 것 — weather_kma/weather_openmeteo에는 이 데이터가 없음. 에어코리아 API로 한국 시도별 실시간 측정소 데이터를 조회. sido는 시도명이지만 "구미", "천안" 같은 시/군 이름도 넣으면 자동으로 소속 시도로 변환해 조회함(주요 도시 기준, 안 되면 시도명으로 다시 시도). data.go.kr API 키 필요.',
|
||||
parameters: {
|
||||
type: 'object', required: ['sido'],
|
||||
properties: {
|
||||
sido: { type: 'string', description: '시도명 (서울/부산/대구/인천/광주/대전/울산/세종/경기/강원/충북/충남/전북/전남/경북/경남/제주)' },
|
||||
sido: { type: 'string', description: '시도명(서울/부산/대구/인천/광주/대전/울산/세종/경기/강원/충북/충남/전북/전남/경북/경남/제주) 또는 그 안에 속한 주요 시/군 이름(예: 구미, 포항, 천안, 전주 등) — 자동 변환됨' },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -990,11 +990,11 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'weather_map_screenshot',
|
||||
description: 'Capture a real screenshot of a rendered weather map — the Windy embed (radar, satellite, wind, precipitation, etc.), the KMA typhoon forecast-track page, or an NHC forecast-track page (URL from nhc_active_storms) — so you can actually SEE it before describing it. Call this with the exact URL you just generated — do not guess what the map shows from the URL parameters alone.',
|
||||
description: 'Capture a real screenshot of a rendered weather map — the Windy embed (radar, satellite, wind, precipitation, etc.), the KMA typhoon forecast-track page, the JMA (RSMC Tokyo) all-active-typhoons forecast-track map, or an NHC forecast-track page (URL from nhc_active_storms) — so you can actually SEE it before describing it. Call this with the exact URL you just generated — do not guess what the map shows from the URL parameters alone.',
|
||||
parameters: {
|
||||
type: 'object', required: ['url'],
|
||||
properties: {
|
||||
url: { type: 'string', description: 'The exact https://embed.windy.com/embed2.html?... URL, the KMA typhoon track URL, or an https://www.nhc.noaa.gov/graphics_XXN.shtml URL from nhc_active_storms (must match the one shown to the user)' },
|
||||
url: { type: 'string', description: 'The exact https://embed.windy.com/embed2.html?... URL, the KMA typhoon track URL, the JMA typhoon track URL, or an https://www.nhc.noaa.gov/graphics_XXN.shtml URL from nhc_active_storms (must match the one shown to the user)' },
|
||||
wait_ms: { type: 'number', description: 'Milliseconds to wait for map tiles to finish loading before capturing (default 4000, max 8000)' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2095,7 +2095,18 @@ async function handleChat(
|
||||
const loopPivotNudge = 'Loop detector: you are looping on this tool, try a different approach or ask the user.';
|
||||
const loopCheck = checkLoopDetection(toolName, toolArgs);
|
||||
if (loopCheck.state === 'block') {
|
||||
const blockMsg = `${loopPivotNudge} Repeated call blocked: ${toolName} with identical arguments has run ${loopCheck.repeats} times (critical threshold ${loopCriticalThreshold}).`;
|
||||
// weather_kma specifically: a real 2026-08-06 case (gemma-4-26b-a4b, 미세먼지 question)
|
||||
// showed the generic pivot nudge alone wasn't enough — the model kept re-calling
|
||||
// weather_kma instead of switching tools even after being told to try something else.
|
||||
// weather_kma is also the single most over-tried tool in this codebase's weather
|
||||
// routing (it's the promoted "try first" default), so spelling out the two most likely
|
||||
// reasons it keeps getting re-called — air quality and >3-day forecasts, neither of
|
||||
// which it can ever answer no matter how many times it's retried — costs nothing on the
|
||||
// vast majority of loop-blocks that aren't this, and directly fixes the ones that are.
|
||||
const toolPivotHint = toolName === 'weather_kma'
|
||||
? ' weather_kma cannot answer air-quality (미세먼지/PM2.5/PM10/황사) questions — use weather_airkorea instead. For forecasts beyond 3 days, use weather_openmeteo instead.'
|
||||
: '';
|
||||
const blockMsg = `${loopPivotNudge}${toolPivotHint} Repeated call blocked: ${toolName} with identical arguments has run ${loopCheck.repeats} times (critical threshold ${loopCriticalThreshold}).`;
|
||||
console.warn(`[v2] LOOP BLOCK: ${toolName}(${JSON.stringify(toolArgs).slice(0, 80)}) x${loopCheck.repeats}`);
|
||||
const blockedResult: ToolResult = {
|
||||
name: toolName,
|
||||
|
||||
+56
-5
@@ -573,7 +573,7 @@ export const weatherOpenMeteoTool = {
|
||||
location: 'City name (Korean or English) or "lat,lon" coordinates',
|
||||
hours: 'Hours of hourly forecast (default: 24, max: 384)',
|
||||
days: 'Days of daily forecast when daily variables are requested (default: 3, max: 16)',
|
||||
variables: 'Comma-separated Open-Meteo variables. Daily ones (temperature_2m_max/min, precipitation_sum, sunrise, …) are routed to the daily block automatically; everything else is treated as hourly. Default: temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code',
|
||||
variables: 'Comma-separated Open-Meteo variables. Daily ones (temperature_2m_max/min, precipitation_sum, precipitation_probability_max, sunrise, …) are routed to the daily block automatically; everything else is treated as hourly. precipitation (mm) is NOT a probability — for "will it rain / rain chance" questions you must include precipitation_probability (hourly) or precipitation_probability_max (daily), or the answer has no probability data at all. Default: temperature_2m,precipitation,precipitation_probability,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
@@ -592,7 +592,7 @@ export const weatherOpenMeteoTool = {
|
||||
|
||||
const hours = Math.min(Math.max(1, Math.round(Number(args?.hours) || 24)), 384);
|
||||
const days = Math.min(Math.max(1, Math.round(Number(args?.days) || 3)), 16);
|
||||
const vars = String(args?.variables || 'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code');
|
||||
const vars = String(args?.variables || 'temperature_2m,precipitation,precipitation_probability,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code');
|
||||
// Daily-only names used to be posted to `hourly=`, which Open-Meteo accepts — it returns
|
||||
// an hourly series under a daily name, so `temperature_2m_max` produced numbers that were
|
||||
// not maxima at all. Route them before the request instead.
|
||||
@@ -726,6 +726,8 @@ export const weatherKmaTool = {
|
||||
`습도: ${get('REH')}%`,
|
||||
`풍향: ${windDirectionKo(vecDeg)} (${get('VEC')}°)`,
|
||||
`풍속: ${get('WSD')} m/s`,
|
||||
'',
|
||||
'(미세먼지/PM2.5/PM10/황사는 이 데이터에 없음 — weather_airkorea 사용)',
|
||||
];
|
||||
return { success: true, stdout: lines.join('\n'), data: { location: displayName, nx, ny, items } };
|
||||
} else {
|
||||
@@ -762,6 +764,7 @@ export const weatherKmaTool = {
|
||||
if (dominantPty) lines.push(` 강수형태: ${PTY_LABEL[dominantPty] ?? dominantPty}`);
|
||||
else if (dominantSky) lines.push(` 날씨: ${SKY_LABEL[dominantSky] ?? dominantSky}`);
|
||||
}
|
||||
lines.push('', '(미세먼지/PM2.5/PM10/황사는 이 데이터에 없음 — weather_airkorea 사용)');
|
||||
return { success: true, stdout: lines.join('\n'), data: { location: displayName, nx, ny } };
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -949,6 +952,21 @@ const SIDO_MAP: Record<string, string> = {
|
||||
'경북': '경북', '경상북도': '경북',
|
||||
'경남': '경남', '경상남도': '경남',
|
||||
'제주': '제주', 'jeju': '제주',
|
||||
// AirKorea's API only accepts province-level (시도) names, but users/models ask about
|
||||
// specific cities/counties ("구미 미세먼지") — without this, that lookup fails outright
|
||||
// (city name doesn't match any 시도) and a model with no other guidance can loop retrying
|
||||
// instead of falling back to the parent province (observed 2026-08-06: gemma-4-26b-a4b hit
|
||||
// its max-step limit doing exactly this on "구미 미세먼지"). Not exhaustive — covers the
|
||||
// more commonly-asked non-metro cities; add more here if a gap surfaces.
|
||||
'구미': '경북', '포항': '경북', '경주': '경북', '안동': '경북', '김천': '경북', '영주': '경북',
|
||||
'천안': '충남', '아산': '충남', '서산': '충남', '당진': '충남',
|
||||
'청주': '충북', '충주': '충북', '제천': '충북',
|
||||
'전주': '전북', '군산': '전북', '익산': '전북',
|
||||
'여수': '전남', '순천': '전남', '목포': '전남', '광양': '전남',
|
||||
'창원': '경남', '진주': '경남', '김해': '경남', '양산': '경남', '거제': '경남', '통영': '경남',
|
||||
'춘천': '강원', '원주': '강원', '강릉': '강원', '속초': '강원',
|
||||
'수원': '경기', '성남': '경기', '용인': '경기', '고양': '경기', '부천': '경기', '안양': '경기',
|
||||
'평택': '경기', '안산': '경기', '화성': '경기',
|
||||
};
|
||||
|
||||
const AK_GRADE: Record<string, string> = { '1': '좋음', '2': '보통', '3': '나쁨', '4': '매우나쁨' };
|
||||
@@ -990,20 +1008,53 @@ export const weatherAirKoreaTool = {
|
||||
ver: '1.0',
|
||||
});
|
||||
const res = await fetch(`${AIRKOREA_BASE}/getCtprvnRltmMesureDnsty?${params}`, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!res.ok) throw new Error(`에어코리아 API 오류 ${res.status}`);
|
||||
if (!res.ok) {
|
||||
// data.go.kr treats each API ("서비스") as a separate approval even under one key —
|
||||
// a working weather_kma key can still 403 here with SERVICE_KEY_IS_NOT_REGISTERED_ERROR
|
||||
// if the account never applied for (or hasn't yet been approved for) AirKorea's
|
||||
// "대기오염정보 조회서비스" specifically. That failure is permanent until approved on
|
||||
// data.go.kr, so say so plainly — otherwise a model just retries or loops on this tool.
|
||||
let detail = '';
|
||||
try {
|
||||
const errJson: any = await res.json();
|
||||
const errMsg = errJson?.OpenAPI_ServiceResponse?.cmmMsgHeader?.returnAuthMsg
|
||||
|| errJson?.OpenAPI_ServiceResponse?.cmmMsgHeader?.errMsg;
|
||||
if (errMsg) detail = ` (${errMsg})`;
|
||||
} catch {}
|
||||
if (res.status === 403) {
|
||||
return {
|
||||
success: false,
|
||||
error: `에어코리아 API 사용 승인이 안 되어 있습니다${detail} — data.go.kr에서 "에어코리아_대기오염정보 조회서비스"를 별도로 활용신청/승인받아야 합니다 (기상청 키와는 별개 승인). 재시도해도 승인 전까지는 계속 실패하므로, 이 요청에는 대신 web_search를 사용할 것.`,
|
||||
};
|
||||
}
|
||||
throw new Error(`에어코리아 API 오류 ${res.status}${detail}`);
|
||||
}
|
||||
const data: any = await res.json();
|
||||
const items: any[] = data?.response?.body?.items || [];
|
||||
if (!items.length) return { success: false, error: `${sido} 측정 데이터가 없습니다` };
|
||||
|
||||
// sidoInput was a city/county name (e.g. "구미") mapped up to its province — the AirKorea
|
||||
// API only returns province-wide station lists, so surface the caller's actual station
|
||||
// first instead of leaving it to fall wherever it lands in API order (previously silently
|
||||
// cut off by the slice(0,15) below if the city wasn't near the top — see SIDO_MAP comment).
|
||||
const isCityLookup = sidoInput !== sido && sidoInput.toLowerCase() !== sido.toLowerCase();
|
||||
let displayItems = items;
|
||||
if (isCityLookup) {
|
||||
const matchIdx = items.findIndex(it => String(it.stationName || '').includes(sidoInput));
|
||||
if (matchIdx > 0) {
|
||||
displayItems = [items[matchIdx], ...items.slice(0, matchIdx), ...items.slice(matchIdx + 1)];
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`에어코리아 실시간 대기질: ${sido}`,
|
||||
`에어코리아 실시간 대기질: ${sido}${isCityLookup ? ` (요청: "${sidoInput}" → ${sido} 소속 측정소들)` : ''}`,
|
||||
`측정 시각: ${items[0]?.dataTime || 'N/A'}`,
|
||||
'',
|
||||
`${'측정소'.padEnd(12)} PM2.5 PM10 O3 NO2 CO SO2 PM2.5등급`,
|
||||
'─'.repeat(75),
|
||||
];
|
||||
|
||||
for (const item of items.slice(0, 15)) {
|
||||
for (const item of displayItems.slice(0, 15)) {
|
||||
const station = String(item.stationName || '').slice(0, 10).padEnd(12);
|
||||
const pm25 = String(item.pm25Value ?? '-').padEnd(7);
|
||||
const pm10 = String(item.pm10Value ?? '-').padEnd(7);
|
||||
|
||||
Reference in New Issue
Block a user