fix: 날씨 도구가 도시 단위 대기질을 정확히 조회하도록 수정
"오늘 구미 미세먼지"에 포항 측정소(우현동/장흥동/장량동/대도동) 수치가 구미로
제시됐다. 지적 후 재시도했더니 이번엔 영주·경산 측정소를 구미라고 답했다.
원인은 실시간 대기질 API가 시/도 단위 목록만 주고 응답에 sidoName만 있다는 것.
측정소 이름에는 소속 시/군이 없어("우현동"은 포항, "중방동"은 경산) 모델이
도 전체 목록에서 임의로 골라 특정 도시 값으로 제시할 수밖에 없었다.
- 측정소정보 서비스(MsrstnInfoInqireSvc) 연동. 주소 필드로 시/군을 매칭해
해당 도시 측정소만 반환한다(구미 4곳, 포항 13곳 확인). 목록은 하루 캐시.
이 서비스는 data.go.kr에서 별도 활용신청이 필요하며 2026-08-09 승인됐다.
- 필터가 실패하면 필터된 척하지 않고 "필터 실패, 전체 표시"로 밝히고 경고를 남긴다.
- 실시간 조회 numOfRows 20 → 200. 경북만 53개 측정소라 20행에서는 포항이 잘려
도시 필터가 조용히 빈 결과를 냈다.
- 504 SERVICETIMEOUT에 4회 재시도 추가. 측정 결과 업스트림이 무작위로 실패하며
(연속 8회 중 7성공 / 8초 간격 6회 중 4성공) 호출 간격과 무관해 쿨다운이 아니다.
단발 호출이던 기존 코드는 그때마다 도구가 통째로 실패했다. 4xx는 재시도 안 함.
또한 6개 날씨 도구가 공유하던 "위치를 찾을 수 없습니다"를 실행 가능한 에러로
교체했다. 도시 비교 요청에 모델이 "London, Berlin, Paris, Rome, Madrid"처럼
묶어 넘기면 지오코딩이 실패하는데, 기존 메시지엔 고칠 방법이 없어 같은 인자로
무한 재시도했다. 이제 도시 목록과 재호출 예시, 재시도가 무의미함을 함께 알린다.
첫 두 조각이 각각 지오코딩될 때만 목록으로 판정하므로 "Seoul, Korea"·"London,GB"·
좌표는 영향받지 않는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+149
-27
@@ -32,6 +32,56 @@ function httpsGetJsonIPv4(url: string, timeoutMs = 30_000): Promise<any> {
|
||||
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<string, { names: string[]; at: number }>();
|
||||
const STATION_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
async function stationsForCity(city: string, apiKey: string): Promise<string[] | null> {
|
||||
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<string[] | null> {
|
||||
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<ToolResult> {
|
||||
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<string, string> = { '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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user