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:
+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