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'))); + }); +});