From 57f2a895295ec0a2a4763661df0ee1d5f81dbac7 Mon Sep 17 00:00:00 2001 From: kim Date: Mon, 10 Aug 2026 17:46:23 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20weather=5Fkma=EA=B0=80=203=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B4=ED=9B=84=20=EC=98=88=EB=B3=B4=EB=A5=BC=20Open-Meteo?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=90=EB=8F=99=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제 사고(2026-08-10): "이번 주말 날씨 어때?"(5일 후 질문)에 weather_kma만 불렀고, 기상청 단기예보가 3일까지만 커버한다는 걸 확인하자 "금요일쯤 다시 물어봐 주세요"라며 끝냈다. weather_kma 자체 설명에 "3일 넘으면 weather_openmeteo로 보완할 것"이라고 이미 적혀 있었는데도 두 번째 도구를 안 불렀다. 같은 날 news_search에서 겪은 것과 정확히 같은 패턴 — 프롬프트/ 스키마 설명 지침을 아무리 정확히 적어도 이 모델은 세부 지시를 안 따랐다. 그래서 같은 해법을 적용했다: 모델이 두 번째 도구를 기억해서 불러주길 바라는 대신, weather_kma가 자기 응답 안에서 Open-Meteo 4~7일차 예보(기온·강수확률)를 자동으로 붙여서 반환한다. 한 번의 호출로 끝나므로 두 번째 호출을 빼먹을 여지가 없다. 기상청이 실제로 커버한 마지막 날짜 이후만 보완하도록 날짜 경계를 계산하고(Open-Meteo가 이미 아는 날짜를 중복 표시하지 않음), 네트워크 실패 시엔 조용히 생략(기상청 데이터는 그대로 반환) — 보완 기능 하나가 핵심 예보 응답을 절대 깨뜨리지 않는다. 순수 필터링/포맷 로직(formatDailySupplement)을 네트워크 호출과 분리해 테스트 가능하게 했다 — link-validator.ts/news.ts와 같은 패턴, 회귀 테스트 4개 추가. Co-Authored-By: Claude Opus 5 --- src/tools/weather.ts | 74 +++++++++++++++++++++++++++- tests/weather-kma-supplement.test.ts | 50 +++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 tests/weather-kma-supplement.test.ts diff --git a/src/tools/weather.ts b/src/tools/weather.ts index d438ea5..feff7ec 100644 --- a/src/tools/weather.ts +++ b/src/tools/weather.ts @@ -762,9 +762,68 @@ async function kmaFetch(endpoint: string, params: Record): Promi return json?.response?.body?.items?.item || []; } +// Pure formatting/filtering half of the KMA→Open-Meteo forecast merge — split out (2026-08-10) +// so it's unit-testable without a live API call. Given Open-Meteo's daily arrays and the last +// YYYYMMDD date KMA's own forecast already covered, returns the lines for whatever days remain +// beyond that, or null if there's nothing beyond what KMA already had. +export function formatDailySupplement( + dates: string[], + tmax: (number | null | undefined)[], + tmin: (number | null | undefined)[], + pop: (number | null | undefined)[], + lastCoveredYmd: string, +): string[] | null { + const lastCoveredIso = lastCoveredYmd + ? `${lastCoveredYmd.slice(0, 4)}-${lastCoveredYmd.slice(4, 6)}-${lastCoveredYmd.slice(6, 8)}` + : ''; + const lines: string[] = []; + for (let i = 0; i < dates.length; i++) { + if (lastCoveredIso && dates[i] <= lastCoveredIso) continue; // KMA already covered this day + lines.push(`📅 ${dates[i]}`); + lines.push(` 기온: ${tmin[i] ?? '?'}~${tmax[i] ?? '?'}°C`); + if (pop[i] != null) lines.push(` 강수확률: ${pop[i]}%`); + } + return lines.length ? ['── 3일 이후 예보 (Open-Meteo, 참고용) ──', ...lines] : null; +} + +// weather_kma's forecast type is structurally capped at ~3-4 days (KMA's getVilageFcst just +// doesn't return more, and the caller loop above only reads the first 4 groups it does return). +// 2026-08-10 real incident: "이번 주말 날씨 어때?" (5 days out) hit this ceiling — the model +// called only weather_kma, saw the range fell short, and told the user to ask again closer to +// the weekend instead of calling weather_openmeteo (up to 16 days), even though weather_kma's +// own description already said to do exactly that for >3-day requests. Prose guidance +// repeatedly failed to change this model's behavior today (see news_search's git history for +// the same pattern with different symptoms), so this fetches the extension itself and merges +// it into one answer — no second tool call for the model to remember or skip. +// Best-effort: any failure here (network, malformed response) just means no supplement — never +// breaks the KMA forecast this is attached to. +async function fetchOpenMeteoDailySupplement(lat: number, lon: number, lastCoveredYmd: string): Promise { + try { + const params = new URLSearchParams({ + latitude: String(lat), + longitude: String(lon), + timezone: 'auto', + daily: 'temperature_2m_max,temperature_2m_min,precipitation_probability_max', + forecast_days: '7', + }); + const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) return null; + const data: any = await res.json(); + return formatDailySupplement( + data.daily?.time || [], + data.daily?.temperature_2m_max || [], + data.daily?.temperature_2m_min || [], + data.daily?.precipitation_probability_max || [], + lastCoveredYmd, + ); + } catch { + return null; + } +} + export const weatherKmaTool = { name: 'weather_kma', - description: '기상청 공식 API로 한국 날씨 조회. 초단기실황(현재 기온·강수·바람·습도)과 단기예보(3일)를 제공. 한국 내 위치에 가장 정확한 기상 데이터. API 키(data.go.kr) 필요.', + description: '기상청 공식 API로 한국 날씨 조회. 초단기실황(현재 기온·강수·바람·습도)과 단기예보(기상청 3일 + Open-Meteo로 자동 보완된 4~7일차)를 제공. 한국 내 위치에 가장 정확한 기상 데이터. type="forecast"는 이번 주말처럼 3일을 넘는 날짜도 한 번의 호출로 답변 가능(참고용 보완 데이터 자동 포함). API 키(data.go.kr) 필요.', schema: { location: '한국 도시명 (예: "서울", "부산") 또는 "위도,경도" 형식', type: '"current" (초단기실황, 기본값) 또는 "forecast" (단기예보 3일)', @@ -835,7 +894,9 @@ export const weatherKmaTool = { `발표: ${base_date.slice(0,4)}-${base_date.slice(4,6)}-${base_date.slice(6,8)} ${base_time.slice(0,2)}:${base_time.slice(2,4)} KST`, '', ]; + const coveredDays: string[] = []; for (const [day, cats] of Object.entries(byDay).slice(0, 4)) { + coveredDays.push(day); const ymd = `${day.slice(0,4)}-${day.slice(4,6)}-${day.slice(6,8)}`; const tmx = cats.TMX?.[0]; const tmn = cats.TMN?.[0]; @@ -852,6 +913,17 @@ export const weatherKmaTool = { else if (dominantSky) lines.push(` 날씨: ${SKY_LABEL[dominantSky] ?? dominantSky}`); } lines.push('', '(미세먼지/PM2.5/PM10/황사는 이 데이터에 없음 — weather_airkorea 사용)'); + // 2026-08-10 real incident: "이번 주말 날씨 어때?" (5 days out) hit this exact 3-4일 + // ceiling — the model called only weather_kma, saw the range fell short, and told the + // user to ask again closer to the weekend instead of calling weather_openmeteo (up to + // 16 days), even though weather_kma's own description already says to do exactly that + // for >3-day requests. Prose guidance repeatedly failed to change this model's behavior + // today (see news_search's git history for the same pattern with different symptoms), + // so instead of asking the model to remember a second tool call, fetch the extension + // here and merge it into one answer — no second call, no chance to skip it. + const lastCovered = coveredDays.sort().slice(-1)[0]; + const supplement = await fetchOpenMeteoDailySupplement(lat, lon, lastCovered); + if (supplement) lines.push('', ...supplement); return { success: true, stdout: lines.join('\n'), data: { location: displayName, nx, ny } }; } } catch (err: any) { diff --git a/tests/weather-kma-supplement.test.ts b/tests/weather-kma-supplement.test.ts new file mode 100644 index 0000000..4f06b8c --- /dev/null +++ b/tests/weather-kma-supplement.test.ts @@ -0,0 +1,50 @@ +/** + * weather-kma-supplement.test.ts + * + * formatDailySupplement was split out of weather_kma's forecast path (2026-08-10) after a real + * incident: "이번 주말 날씨 어때?" (5 days out) hit weather_kma's ~3-day ceiling, and the model + * told the user to ask again later instead of calling weather_openmeteo — even though + * weather_kma's own description already said to do exactly that for >3-day requests. Prose + * guidance kept failing to change this model's behavior (same pattern hit news_search earlier + * the same day), so weather_kma now fetches the Open-Meteo extension itself and merges it in. + * This pure function is the merge/filter logic — no network call, so it's directly testable. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatDailySupplement } from '../src/tools/weather'; + +describe('formatDailySupplement — KMA 3일 이후를 Open-Meteo로 보완', () => { + test('KMA가 8/10까지 커버했으면 그 이후 날짜만 남긴다', () => { + const dates = ['2026-08-09', '2026-08-10', '2026-08-11', '2026-08-12', '2026-08-15']; + const tmax = [33, 34, 32, 31, 29]; + const tmin = [24, 25, 23, 22, 21]; + const pop = [20, 10, 30, 40, 60]; + const out = formatDailySupplement(dates, tmax, tmin, pop, '20260810'); + assert.ok(out, '보완 결과가 없으면 안 됨'); + assert.ok(!out!.some(l => l.includes('2026-08-09')), 'KMA가 이미 커버한 과거 날짜가 남으면 안 됨'); + assert.ok(!out!.some(l => l.includes('2026-08-10')), 'KMA가 이미 커버한 마지막 날짜가 남으면 안 됨'); + assert.ok(out!.some(l => l.includes('2026-08-11'))); + assert.ok(out!.some(l => l.includes('2026-08-15')), '이번 주말(8/15) 데이터가 포함돼야 함'); + }); + + test('KMA가 이미 전체 범위를 커버했으면(추가 날짜 없음) null', () => { + const dates = ['2026-08-09', '2026-08-10']; + const out = formatDailySupplement(dates, [33, 34], [24, 25], [20, 10], '20260810'); + assert.equal(out, null); + }); + + test('lastCoveredYmd가 비어 있으면(경계 모름) 전체를 보완으로 취급', () => { + const dates = ['2026-08-10', '2026-08-11']; + const out = formatDailySupplement(dates, [34, 32], [25, 23], [10, 30], ''); + assert.ok(out); + assert.equal(out!.filter(l => l.startsWith('📅')).length, 2); + }); + + test('강수확률이 없어도(null) 깨지지 않는다', () => { + const out = formatDailySupplement(['2026-08-15'], [29], [21], [null], '20260810'); + assert.ok(out); + assert.ok(!out!.some(l => l.includes('강수확률')), 'null 강수확률은 줄 자체를 안 넣어야 함'); + assert.ok(out!.some(l => l.includes('기온: 21~29°C'))); + }); +});