From ae6e347339d14205f5fb2c0ec2396d150207c90d Mon Sep 17 00:00:00 2001 From: kim Date: Fri, 7 Aug 2026 01:23:55 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A7=80=EC=84=9C=EB=B2=84=20=EB=AF=B8?= =?UTF-8?q?=EC=84=B8=EB=A8=BC=EC=A7=80=20=EB=8F=84=EA=B5=AC=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=9B=90=EC=9D=B8=20=EA=B7=9C=EB=AA=85=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B0=95=EC=88=98=ED=99=95=EB=A5=A0=20=ED=95=98=EB=93=9C?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/gateway/chat/build-tools.ts | 12 +++---- src/gateway/chat/handle-chat.ts | 13 ++++++- src/tools/weather.ts | 61 ++++++++++++++++++++++++++++++--- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/gateway/chat/build-tools.ts b/src/gateway/chat/build-tools.ts index 644b146..53f9493 100644 --- a/src/gateway/chat/build-tools.ts +++ b/src/gateway/chat/build-tools.ts @@ -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)' }, }, }, diff --git a/src/gateway/chat/handle-chat.ts b/src/gateway/chat/handle-chat.ts index adc9ba1..54deb12 100644 --- a/src/gateway/chat/handle-chat.ts +++ b/src/gateway/chat/handle-chat.ts @@ -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, diff --git a/src/tools/weather.ts b/src/tools/weather.ts index 2ff3c4e..5aa0d28 100644 --- a/src/tools/weather.ts +++ b/src/tools/weather.ts @@ -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 = { '경북': '경북', '경상북도': '경북', '경남': '경남', '경상남도': '경남', '제주': '제주', '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 = { '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);