diff --git a/src/tools/weather.ts b/src/tools/weather.ts index 142f4fd..d438ea5 100644 --- a/src/tools/weather.ts +++ b/src/tools/weather.ts @@ -32,6 +32,56 @@ function httpsGetJsonIPv4(url: string, timeoutMs = 30_000): Promise { const OW_BASE = 'https://api.openweathermap.org/data/2.5'; const KMA_BASE = 'http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0'; const AIRKOREA_BASE = 'http://apis.data.go.kr/B552584/ArpltnInforInqireSvc'; +const AIRKOREA_STATION_BASE = 'http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc'; + +/** + * Station names belonging to a city/county, from AirKorea's 측정소정보 service. + * + * The realtime feed only tags stations with a province (`sidoName`), and station names give no + * hint of their city — "우현동" is 포항, "중방동" is 경산. So a 구미 question used to be answered + * off the province-wide list with whatever rows came first, presenting other cities' readings as + * 구미 (observed 2026-08-09, twice). This is the mapping that makes city filtering possible. + * + * The upstream `addr` query parameter does not filter, so the full list is fetched and matched + * locally. Cached for a day — station rosters change on the order of years. + */ +const _stationCityCache = new Map(); +const STATION_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +async function stationsForCity(city: string, apiKey: string): Promise { + const key = city.trim(); + const hit = _stationCityCache.get(key); + if (hit && Date.now() - hit.at < STATION_CACHE_TTL_MS) return hit.names; + + const params = new URLSearchParams({ + serviceKey: apiKey, returnType: 'json', numOfRows: '700', pageNo: '1', + }); + // data.go.kr answers this endpoint with an intermittent SERVICETIMEOUT_ERROR (~1 in 5 observed + // on 2026-08-09) that clears immediately, so a single failure must not collapse to "no stations". + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const res = await fetch(`${AIRKOREA_STATION_BASE}/getMsrstnList?${params}`, { signal: AbortSignal.timeout(20_000) }); + if (!res.ok) throw new Error(`station list HTTP ${res.status}`); + const data: any = await res.json(); + if (data?.OpenAPI_ServiceResponse) throw new Error(String(data.OpenAPI_ServiceResponse?.cmmMsgHeader?.errMsg || 'service error')); + const items: any[] = data?.response?.body?.items || []; + if (!items.length) throw new Error('empty station list'); + + // Match on the address, not the station name: "구미시" appears in addr, never in the name. + const needle = key.endsWith('시') || key.endsWith('군') || key.endsWith('구') ? key : `${key}시`; + const names = items + .filter(it => String(it.addr || '').includes(needle)) + .map(it => String(it.stationName || '')) + .filter(Boolean); + _stationCityCache.set(key, { names, at: Date.now() }); + return names; + } catch { + if (attempt >= 3) return null; // unknown, not "none" — caller must not claim a city filter + await new Promise(r => setTimeout(r, 400 * attempt)); + } + } + return null; +} // ── API key helpers ─────────────────────────────────────────────────────────── @@ -177,6 +227,43 @@ function parseExplicitCountry(name: string): { city: string; countryCode?: strin return { city: name }; } +/** + * Did the caller pack several places into one `location`? + * + * Asked to compare cities, models batch them into a single argument + * ("London, Berlin, Paris, Rome, Madrid"). That geocodes to nothing, and the bare + * "위치를 찾을 수 없습니다" gave the model no reason to do anything but reissue the identical + * call — the turn then burned its rounds on an unwinnable loop (observed 2026-08-09 09:09). + * + * Only consulted after whole-string geocoding has already failed, so a legitimate + * "Seoul, Korea" never reaches here (parseExplicitCountry resolves it). Requiring the first + * two segments to geocode independently keeps "Springfield, Freedonia" — one real place plus + * an unknown qualifier — from being misread as a list. + */ +async function looksLikeMultipleLocations(location: string): Promise { + const parts = location.split(',').map(p => p.trim()).filter(Boolean); + if (parts.length < 2) return null; + const [first, second] = await Promise.all([geocodeCity(parts[0]), geocodeCity(parts[1])]); + return first && second ? parts : null; +} + +/** + * The "location not found" result every weather tool returns, upgraded to say what to do next + * when the cause is a batched city list. Shared because all six tools geocode the same way and + * would otherwise each loop on the same input. + */ +export async function locationNotFoundError(location: string, toolName: string): Promise { + const cities = await looksLikeMultipleLocations(location); + if (!cities) return { success: false, error: `위치를 찾을 수 없습니다: ${location}` }; + return { + success: false, + error: + `이 도구는 한 번에 한 곳만 조회합니다. ${cities.length}곳이 한꺼번에 전달됐습니다: ${cities.join(', ')}. ` + + `도시마다 ${toolName}을 따로 호출하세요 — 예: {"location":"${cities[0]}"} 다음에 {"location":"${cities[1]}"}. ` + + `같은 인자로 다시 호출하면 똑같이 실패합니다.`, + }; +} + export async function geocodeCity(name: string): Promise<{ lat: number; lon: number; displayName: string } | null> { const key = name.trim().toLowerCase(); if (KR_CITY_COORDS[key]) { @@ -570,7 +657,7 @@ export const weatherOpenMeteoTool = { name: 'weather_openmeteo', description: 'Get weather forecast using Open-Meteo API (free, no API key). Powered by ECMWF, GFS, and other NWP models. Times are returned in the LOCATION\'s local timezone. For "today\'s high/low" use daily variables (temperature_2m_max, temperature_2m_min, precipitation_sum, …) — the hourly series is a rolling window from now and spans two calendar days, so its maximum is NOT the daily high. Hourly variables (temperature_2m, precipitation, wind_speed_10m, uv_index, weather_code, …) are for intra-day detail. Up to 16 days.', schema: { - location: 'City name (Korean or English) or "lat,lon" coordinates', + location: 'A SINGLE city name (Korean or English) or "lat,lon" coordinates. One place per call — to compare several cities, call this tool once per city; passing "London, Berlin, Paris" resolves to nothing and fails.', 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, 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', @@ -578,7 +665,7 @@ export const weatherOpenMeteoTool = { jsonSchema: { type: 'object', properties: { - location: { type: 'string', description: 'City name or "lat,lon"' }, + location: { type: 'string', description: 'A SINGLE city name or "lat,lon". One place per call — never a comma-separated list of cities.' }, hours: { type: 'number', description: 'Hours ahead (1–384, default 24)' }, days: { type: 'number', description: 'Days ahead for daily variables (1–16, default 3)' }, variables: { type: 'string', description: 'Comma-separated variable names (daily and hourly may be mixed)' }, @@ -606,7 +693,7 @@ export const weatherOpenMeteoTool = { displayName = location; } else { const geo = await geocodeCity(location); - if (!geo) return { success: false, error: `위치를 찾을 수 없습니다: ${location}` }; + if (!geo) return await locationNotFoundError(location, 'weather_openmeteo'); ({ lat, lon, displayName } = geo); } @@ -704,7 +791,7 @@ export const weatherKmaTool = { displayName = location; } else { const geo = await geocodeCity(location); - if (!geo) return { success: false, error: `위치를 찾을 수 없습니다: ${location}` }; + if (!geo) return await locationNotFoundError(location, 'weather_kma'); ({ lat, lon, displayName } = geo); } @@ -825,7 +912,7 @@ export const weatherNasaPowerTool = { displayName = location; } else { const geo = await geocodeCity(location); - if (!geo) return { success: false, error: `위치를 찾을 수 없습니다: ${location}` }; + if (!geo) return await locationNotFoundError(location, 'weather_nasa_power'); ({ lat, lon, displayName } = geo); } @@ -973,14 +1060,14 @@ const AK_GRADE: Record = { '1': '좋음', '2': '보통', '3': ' export const weatherAirKoreaTool = { name: 'weather_airkorea', - description: '에어코리아 API로 한국 시도별 실시간 대기질(PM2.5, PM10, 오존, 이산화질소, 일산화탄소, 이산화황) 조회. 전국 관측소 실시간 측정값 제공. data.go.kr API 키 필요.', + description: '에어코리아 API로 한국 실시간 대기질(PM2.5, PM10, 오존, 이산화질소, 일산화탄소, 이산화황) 조회. data.go.kr API 키 필요. 시/군 이름을 주면 그 도시의 측정소만 골라서 반환한다(예: sido="구미" → 구미시 5개 측정소). 시/도 이름을 주면 그 도 전체 측정소가 나오는데, 측정소 이름에는 소속 시/군이 안 들어있으므로(예: "우현동"은 포항, "중방동"은 경산) 그중 일부를 특정 도시 값으로 제시하면 안 된다 — 도시 수치를 물으면 반드시 도시 이름을 그대로 넣을 것.', schema: { - sido: '시도명 (예: "서울", "경기", "부산", "제주" 등)', + sido: '시/군 이름(예: "구미", "포항") 또는 시도명(예: "서울", "경기", "경북")', }, jsonSchema: { type: 'object', properties: { - sido: { type: 'string', description: '시도명 (서울/부산/대구/인천/광주/대전/울산/세종/경기/강원/충북/충남/전북/전남/경북/경남/제주)' }, + sido: { type: 'string', description: '시/군 이름(구미/포항/성남 등) 또는 시도명(서울/부산/대구/인천/광주/대전/울산/세종/경기/강원/충북/충남/전북/전남/경북/경남/제주). 도시를 물으면 도시 이름을 넣을 것.' }, }, required: ['sido'], additionalProperties: false, @@ -990,6 +1077,11 @@ export const weatherAirKoreaTool = { if (!sidoInput) return { success: false, error: 'sido가 필요합니다' }; const sido = SIDO_MAP[sidoInput.toLowerCase()] ?? SIDO_MAP[sidoInput] ?? sidoInput; + // Caller named a city/county (mapped up to its province): resolve which stations actually + // belong to it. Until the 측정소정보 service was approved (2026-08-09) this was impossible — + // the realtime feed carries only sidoName, so a 구미 question got the whole 경북 list and the + // model presented 포항/영주/경산 stations as 구미. See stationsForCity(). + const isCityLookup = sidoInput !== sido && sidoInput.toLowerCase() !== sido.toLowerCase(); const apiKey = getAirKoreaApiKey(); if (!apiKey) { return { @@ -1002,12 +1094,25 @@ export const weatherAirKoreaTool = { const params = new URLSearchParams({ serviceKey: apiKey, returnType: 'json', - numOfRows: '20', + // Must cover the whole province, not just the first page: 경북 alone has 53 stations, and + // at 20 rows a city filter silently found nothing whenever that city's stations sorted + // past the cut (포항 did, so "포항 미세먼지" fell back to the province list). Display is + // still capped further down; this only widens what there is to filter from. + numOfRows: '200', pageNo: '1', sidoName: sido, ver: '1.0', }); - const res = await fetch(`${AIRKOREA_BASE}/getCtprvnRltmMesureDnsty?${params}`, { signal: AbortSignal.timeout(15_000) }); + // data.go.kr answers this endpoint with SERVICETIMEOUT_ERROR (HTTP 504) intermittently — + // measured at roughly half of all calls on 2026-08-09, identically from curl and node, so + // it is upstream and not a client quirk. A single attempt turned that into a hard tool + // failure and the user saw the air-quality feature as simply broken half the time. + let res!: Response; + for (let attempt = 1; attempt <= 4; attempt++) { + res = await fetch(`${AIRKOREA_BASE}/getCtprvnRltmMesureDnsty?${params}`, { signal: AbortSignal.timeout(15_000) }); + if (res.ok || res.status < 500) break; // 4xx (e.g. 403 approval) will not improve on retry + if (attempt < 4) await new Promise(r => setTimeout(r, 500 * attempt)); + } 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 @@ -1027,32 +1132,49 @@ export const weatherAirKoreaTool = { error: `에어코리아 API 사용 승인이 안 되어 있습니다${detail} — data.go.kr에서 "에어코리아_대기오염정보 조회서비스"를 별도로 활용신청/승인받아야 합니다 (기상청 키와는 별개 승인). 재시도해도 승인 전까지는 계속 실패하므로, 이 요청에는 대신 web_search를 사용할 것.`, }; } - throw new Error(`에어코리아 API 오류 ${res.status}${detail}`); + throw new Error(`에어코리아 API 오류 ${res.status}${detail} — 재시도 4회 모두 실패`); } 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(); + // For a city lookup, keep only that city's stations. Resolution can fail (upstream flakiness) + // — that is "unknown", not "no stations", so fall back to the province list WITH the warning + // rather than silently presenting a filtered-looking result that isn't filtered. let displayItems = items; + let cityStations: string[] | null = null; 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)]; + cityStations = await stationsForCity(sidoInput, apiKey); + if (cityStations && cityStations.length) { + const set = new Set(cityStations); + const matched = items.filter(it => set.has(String(it.stationName || ''))); + if (matched.length) displayItems = matched; + else cityStations = null; // city has stations, but none reporting right now + } else if (cityStations && !cityStations.length) { + cityStations = null; // no such city in the roster — do not claim a filter } } + const cityFiltered = isCityLookup && !!cityStations && displayItems !== items; - const lines = [ - `에어코리아 실시간 대기질: ${sido}${isCityLookup ? ` (요청: "${sidoInput}" → ${sido} 소속 측정소들)` : ''}`, - `측정 시각: ${items[0]?.dataTime || 'N/A'}`, - '', + const header = cityFiltered + ? `에어코리아 실시간 대기질: ${sidoInput} (${sido}) — ${sidoInput} 소속 측정소 ${displayItems.length}곳` + : `에어코리아 실시간 대기질: ${sido}${isCityLookup ? ` (요청: "${sidoInput}" — 시/군 필터 실패, ${sido} 전체를 표시)` : ''}`; + + const lines = [header, `측정 시각: ${items[0]?.dataTime || 'N/A'}`, '']; + if (!cityFiltered) { + // Station names carry no city ("우현동" gives no hint it is 포항), so without this a model + // asked about one city presents whatever rows came back as that city's: 2026-08-09 + // "오늘 구미 미세먼지" was answered with 포항 stations, then again with 영주/경산 ones. + lines.push( + `※ 아래는 ${sido} 전체 측정소이며, 특정 시/군만 추린 것이 아닙니다. 측정소 이름만으로는`, + ` 소속 시/군을 알 수 없으므로, 특정 도시의 수치라고 단정해서 답하지 마세요.`, + '', + ); + } + lines.push( `${'측정소'.padEnd(12)} PM2.5 PM10 O3 NO2 CO SO2 PM2.5등급`, '─'.repeat(75), - ]; + ); for (const item of displayItems.slice(0, 15)) { const station = String(item.stationName || '').slice(0, 10).padEnd(12); @@ -1127,7 +1249,7 @@ export const weatherEra5Tool = { displayName = location; } else { const geo = await geocodeCity(location); - if (!geo) return { success: false, error: `위치를 찾을 수 없습니다: ${location}` }; + if (!geo) return await locationNotFoundError(location, 'weather_era5'); ({ lat, lon, displayName } = geo); } @@ -1321,7 +1443,7 @@ export const weatherCdsTool = { if (coords) { [lat, lon] = coords; displayName = location; } else { const geo = await geocodeCity(location); - if (!geo) return { success: false, error: `위치를 찾을 수 없습니다: ${location}` }; + if (!geo) return await locationNotFoundError(location, 'weather_cds'); ({ lat, lon, displayName } = geo); } @@ -1472,7 +1594,7 @@ export const weatherCmip6Tool = { if (coords) { [lat, lon] = coords; displayName = location; } else { const geo = await geocodeCity(location); - if (!geo) return { success: false, error: `위치를 찾을 수 없습니다: ${location}` }; + if (!geo) return await locationNotFoundError(location, 'weather_cmip6'); ({ lat, lon, displayName } = geo); } diff --git a/tests/weather-multi-location.test.ts b/tests/weather-multi-location.test.ts new file mode 100644 index 0000000..a7071d3 --- /dev/null +++ b/tests/weather-multi-location.test.ts @@ -0,0 +1,86 @@ +/** + * weather-multi-location.test.ts + * + * What a weather tool says when `location` holds several cities instead of one. + * + * Asked to compare cities, models batch them into a single argument + * ("London, Berlin, Paris, Rome, Madrid"). That geocodes to nothing, and the old error — + * a bare "위치를 찾을 수 없습니다" — gave the model no reason to do anything but reissue the + * identical call, so the turn looped until it ran out of rounds (observed live 2026-08-09 09:09). + * + * The property under test is not "does it fail" but "does the failure tell the model how to + * recover", plus its mirror: inputs that are legitimately one place must not be mistaken for + * a list. These tests hit the real Open-Meteo geocoding API, so they are skipped when it is + * unreachable rather than failing the suite on a network blip. + */ + +import { test, describe, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { weatherOpenMeteoTool } from '../src/tools/weather'; + +const ask = (location: string) => + weatherOpenMeteoTool.execute({ location, variables: 'temperature_2m_max' }) as Promise<{ + success: boolean; + error?: string; + }>; + +let online = false; +before(async () => { + try { + const res = await fetch('https://geocoding-api.open-meteo.com/v1/search?name=Seoul&count=1', { + signal: AbortSignal.timeout(8_000), + }); + online = res.ok; + } catch { + online = false; + } + if (!online) console.log(' (geocoding API 접속 불가 — 네트워크 의존 테스트 건너뜀)'); +}); + +describe('배치 입력은 복구 방법을 알려준다', () => { + test('여러 도시를 한 번에 넘기면 도구별 호출을 지시한다', async (t) => { + if (!online) return t.skip('offline'); + const r = await ask('London, Berlin, Paris, Rome, Madrid'); + + assert.equal(r.success, false); + const err = r.error ?? ''; + // 무엇이 잘못됐는지 + 어떻게 고치는지 + 재시도가 무의미하다는 것, 세 가지가 다 있어야 + // 모델이 같은 호출을 반복하지 않는다. + assert.match(err, /한 번에 한 곳만/, '단일 위치 제약을 밝혀야 함'); + assert.match(err, /weather_openmeteo/, '어떤 도구를 다시 부를지 알려야 함'); + assert.match(err, /"location":"London"/, '구체적인 재호출 예시가 있어야 함'); + assert.match(err, /다시 호출하면 똑같이 실패/, '동일 재시도가 무의미함을 밝혀야 함'); + // 파싱한 도시를 전부 되돌려줘야 모델이 남은 도시를 안 빠뜨린다. + for (const city of ['London', 'Berlin', 'Paris', 'Rome', 'Madrid']) { + assert.ok(err.includes(city), `${city}가 목록에 없음`); + } + }); +}); + +describe('단일 위치는 목록으로 오해하지 않는다', () => { + test('"City, Country"는 정상 조회된다', async (t) => { + if (!online) return t.skip('offline'); + assert.equal((await ask('Seoul, Korea')).success, true); + }); + + test('"City,CountryCode"는 정상 조회된다', async (t) => { + if (!online) return t.skip('offline'); + assert.equal((await ask('London,GB')).success, true); + }); + + test('쉼표가 있는 좌표는 정상 조회된다', async (t) => { + if (!online) return t.skip('offline'); + assert.equal((await ask('37.5,127.0')).success, true); + }); +}); + +describe('진짜 없는 지명은 기존 메시지를 유지한다', () => { + test('배치가 아닌 미확인 지명에는 배치 안내를 붙이지 않는다', async (t) => { + if (!online) return t.skip('offline'); + const r = await ask('Zzxqwv'); + + assert.equal(r.success, false); + assert.match(r.error ?? '', /위치를 찾을 수 없습니다/); + assert.doesNotMatch(r.error ?? '', /한 번에 한 곳만/, '단일 지명에 배치 안내가 붙으면 안 됨'); + }); +});