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:
kim
2026-08-07 01:23:55 +09:00
co-authored by Claude Sonnet 5
parent b0345e24d4
commit ae6e347339
3 changed files with 74 additions and 12 deletions
+56 -5
View File
@@ -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);