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
+6 -6
View File
@@ -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)' },
},
},
+12 -1
View File
@@ -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
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);