diff --git a/src/tools/weather.ts b/src/tools/weather.ts index 79797ae..5aab78d 100644 --- a/src/tools/weather.ts +++ b/src/tools/weather.ts @@ -1153,6 +1153,75 @@ async function fetchAwsToday(stnId: number, todayKst: string) { return parseAwsDay(csv); } +// 과거 날짜의 하루 요약. 2026-08-16에 붙였다 — "어제 제주 비 얼마나 왔어"를 물었을 때 +// 도구에 과거 조회가 아예 없어서 모델이 web_search로 새고, 기상청 도움말 페이지만 긁어와 +// "정확한 수치를 찾지 못했다"고 답한 실사례가 있었다. 일자료는 한 번의 호출로 끝난다. +// +// 기간 제약: ASOS 일자료는 "전날까지"만 제공한다(오늘을 넣으면 resultCode 99). +// 오늘치는 type="current"의 AWS RN-DAY가 담당한다. +// 모델이 "어제"를 그대로 넘기는 일이 잦아서 상대어도 받는다. 기준은 KST — 서버가 UTC라 +// new Date()로 날짜를 뽑으면 한국 시각 09시 이전에는 하루 어긋난다. +export function parseKstDate(input: string, now = new Date()): string | null { + const s = String(input || '').trim(); + if (!s) return null; + const kst = new Date(now.getTime() + 9 * 3600 * 1000); + const shift = (days: number) => + new Date(kst.getTime() - days * 86400 * 1000).toISOString().slice(0, 10).replace(/-/g, ''); + if (/^(오늘|today)$/i.test(s)) return shift(0); + if (/^(어제|어저께|yesterday)$/i.test(s)) return shift(1); + if (/^(그제|그저께|엊그제)$/i.test(s)) return shift(2); + const rel = s.match(/^(\d+)\s*일\s*전$/); + if (rel) return shift(parseInt(rel[1], 10)); + const iso = s.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/); + if (iso) return `${iso[1]}${iso[2].padStart(2, '0')}${iso[3].padStart(2, '0')}`; + if (/^\d{8}$/.test(s)) return s; + // "8월 15일" / "08-15" — 연도가 없으면 올해로 본다. + const md = s.match(/^(\d{1,2})\s*월\s*(\d{1,2})\s*일?$/) || s.match(/^(\d{1,2})[-/.](\d{1,2})$/); + if (md) return `${kst.toISOString().slice(0, 4)}${md[1].padStart(2, '0')}${md[2].padStart(2, '0')}`; + return null; +} + +export function parseAsosDaily(item: any): string[] { + const num = (v: any) => { + const s = String(v ?? '').trim(); + return s === '' ? null : (Number.isFinite(parseFloat(s)) ? parseFloat(s) : null); + }; + // sumRn의 빈 값은 결측이 아니라 **그날 비가 안 왔다**는 뜻이다. 시간자료 rn과 같은 규칙이고, + // 실제 데이터로도 확인된다(서울 8/10~8/13은 빈 값이고 그 기간 다른 소스에서도 무강수, + // 8/14는 "0.2"). 이걸 "관측값 없음"으로 내보내면 맑았던 날마다 모델이 "자료가 없다"고 + // 답하게 되므로 0으로 말하되, 미량("0.0")과는 구분해서 표기한다. + const rn = num(item?.sumRn); + const lines = [ + `📅 ${item?.tm} — ${item?.stnNm}`, + rn == null ? ' 강수량: 0 mm (강수 없음)' + : ` 강수량: ${rn} mm${rn === 0 ? ' (미량)' : ''}`, + ]; + const tmax = num(item?.maxTa), tmin = num(item?.minTa), tavg = num(item?.avgTa); + if (tmax != null || tmin != null) lines.push(` 기온: ${tmin ?? '?'}~${tmax ?? '?'}°C${tavg != null ? ` (평균 ${tavg})` : ''}`); + const rhm = num(item?.avgRhm); if (rhm != null) lines.push(` 평균습도: ${rhm}%`); + const ws = num(item?.maxInsWs); if (ws != null) lines.push(` 최대순간풍속: ${ws} m/s`); + const ss = num(item?.sumSsHr); if (ss != null) lines.push(` 일조시간: ${ss} h`); + const snow = num(item?.ddMefs); if (snow) lines.push(` 최심신적설: ${snow} cm`); + return lines; +} + +const ASOS_DAILY_BASE = 'http://apis.data.go.kr/1360000/AsosDalyInfoService'; + +async function fetchAsosDaily(stnId: number, startDt: string, endDt: string): Promise { + const apiKey = getKmaApiKey(); + if (!apiKey) throw new Error('기상청 API 키가 없습니다.'); + const p = new URLSearchParams({ + serviceKey: apiKey, dataType: 'JSON', numOfRows: '100', pageNo: '1', + dataCd: 'ASOS', dateCd: 'DAY', stnIds: String(stnId), startDt, endDt, + }); + const res = await fetch(`${ASOS_DAILY_BASE}/getWthrDataList?${p}`, { signal: AbortSignal.timeout(20_000) }); + if (!res.ok) throw new Error(`기상청 일자료 오류 ${res.status}`); + const json: any = await res.json(); + const code = json?.response?.header?.resultCode; + if (code !== '00') throw new Error(`기상청 일자료: ${json?.response?.header?.resultMsg || code}`); + return json?.response?.body?.items?.item || []; +} + const ASOS_BASE = 'http://apis.data.go.kr/1360000/AsosHourlyInfoService'; async function asosFetch(params: Record): Promise { @@ -1321,16 +1390,20 @@ async function fetchOpenMeteoDailySupplement(lat: number, lon: number, lastCover export const weatherKmaTool = { name: 'weather_kma', - description: '기상청 공식 API로 한국 날씨 조회. 초단기실황(현재 기온·강수·바람·습도)에는 1시간 강수량과 함께 **오늘 00시부터 현재까지의 누적 강수량**(시간별 실측 합산)이 포함되므로, "오늘 비 얼마나 왔어" 같은 일 누적 질문도 이 도구 한 번으로 답할 수 있다. 비가 어제 이전부터 이어지고 있으면 **"이번 강수 누적"**(사상 시작 시각부터의 합계)도 함께 나오므로 "이번 비 얼마나 왔어"도 답할 수 있다 — 이 값만 Open-Meteo 추정이고 나머지는 기상청 실측이라, 답할 때 두 숫자를 섞지 말 것. 그리고 단기예보(기상청 3일 + Open-Meteo로 자동 보완된 4~7일차)를 제공. 한국 내 위치에 가장 정확한 기상 데이터. type="forecast"는 이번 주말처럼 3일을 넘는 날짜도 한 번의 호출로 답변 가능(참고용 보완 데이터 자동 포함). API 키(data.go.kr) 필요.', + description: '기상청 공식 API로 한국 날씨 조회. 초단기실황(현재 기온·강수·바람·습도)에는 1시간 강수량과 함께 **오늘 00시부터 현재까지의 누적 강수량**(시간별 실측 합산)이 포함되므로, "오늘 비 얼마나 왔어" 같은 일 누적 질문도 이 도구 한 번으로 답할 수 있다. 비가 어제 이전부터 이어지고 있으면 **"이번 강수 누적"**(사상 시작 시각부터의 합계)도 함께 나오므로 "이번 비 얼마나 왔어"도 답할 수 있다 — 이 값만 Open-Meteo 추정이고 나머지는 기상청 실측이라, 답할 때 두 숫자를 섞지 말 것. **지난 날짜의 강수량·기온을 물으면 type="past"에 date를 넣어 호출할 것**("어제 비 얼마나 왔어", "8월 15일 제주 강수량" 등) — 관측 일자료가 바로 나오므로 web_search로 돌아가지 말 것. 기간은 endDate로 준다. 그리고 단기예보(기상청 3일 + Open-Meteo로 자동 보완된 4~7일차)를 제공. 한국 내 위치에 가장 정확한 기상 데이터. type="forecast"는 이번 주말처럼 3일을 넘는 날짜도 한 번의 호출로 답변 가능(참고용 보완 데이터 자동 포함). API 키(data.go.kr) 필요.', schema: { location: '한국 도시명 (예: "서울", "부산") 또는 "위도,경도" 형식', - type: '"current" (초단기실황, 기본값) 또는 "forecast" (단기예보 3일)', + type: '"current" (초단기실황, 기본값) | "forecast" (단기예보) | "past" (과거 관측)', + date: 'type="past"일 때 조회할 날짜. "어제", "2026-08-15", "8월 15일" 등', + endDate: 'type="past"에서 기간 조회 시 마지막 날짜 (생략하면 date 하루만)', }, jsonSchema: { type: 'object', properties: { location: { type: 'string', description: '한국 도시명 또는 "lat,lon"' }, - type: { type: 'string', description: '"current" 또는 "forecast"' }, + type: { type: 'string', description: '"current" | "forecast" | "past"' }, + date: { type: 'string', description: '과거 날짜 ("어제", "2026-08-15", "8월 15일")' }, + endDate: { type: 'string', description: '기간 조회의 마지막 날짜' }, }, required: ['location'], additionalProperties: false, @@ -1338,7 +1411,10 @@ export const weatherKmaTool = { execute: async (args: any): Promise => { const location = String(args?.location || '').trim(); if (!location) return { success: false, error: 'location이 필요합니다' }; - const type = args?.type === 'forecast' ? 'forecast' : 'current'; + // date만 주고 type을 빠뜨리는 호출이 흔해서, date가 있으면 past로 본다. + const type = args?.type === 'forecast' ? 'forecast' + : (args?.type === 'past' || args?.date) ? 'past' + : 'current'; try { let lat: number, lon: number, displayName: string; @@ -1354,6 +1430,34 @@ export const weatherKmaTool = { const { nx, ny } = getKmaGrid(location, lat, lon); + if (type === 'past') { + const start = parseKstDate(String(args?.date || '어제')); + if (!start) return { success: false, error: `날짜를 해석할 수 없습니다: ${args?.date}. "어제" 또는 "2026-08-15" 형식으로 주세요.` }; + const end = args?.endDate ? parseKstDate(String(args.endDate)) : start; + if (!end) return { success: false, error: `endDate를 해석할 수 없습니다: ${args.endDate}` }; + + const todayKst = new Date(Date.now() + 9 * 3600 * 1000).toISOString().slice(0, 10).replace(/-/g, ''); + if (start >= todayKst) { + // 일자료는 전날까지다. 오늘을 넣으면 resultCode 99가 나므로 미리 갈라준다 — + // 안 그러면 모델이 "조회 실패"로 읽고 web_search로 새버린다(실제로 겪은 경로). + return { success: false, error: `${start.slice(0,4)}-${start.slice(4,6)}-${start.slice(6,8)}은 아직 일자료가 없습니다(전날까지 제공). 오늘 값은 type="current"로 조회하세요.` }; + } + + const station = findAsosStation(location) ?? findAsosStation(displayName) ?? nearestAsosStation(lat, lon); + if (!station) return { success: false, error: `${displayName} 근처 40km 안에 ASOS 관측소가 없어 과거 관측을 조회할 수 없습니다.` }; + + const items = await fetchAsosDaily(station.id, start, end > start ? end : start); + if (!items.length) return { success: false, error: `${displayName}(${station.name})의 해당 기간 관측자료가 없습니다.` }; + + const lines = [`기상청 과거 관측: ${displayName} — ${station.name} 관측소`, '']; + for (const it of items) lines.push(...parseAsosDaily(it), ''); + if (items.length > 1) { + const total = items.reduce((s, it) => s + (parseFloat(String(it?.sumRn ?? '')) || 0), 0); + lines.push(`기간 합계 강수량: ${Math.round(total * 10) / 10} mm (${items.length}일)`); + } + return { success: true, stdout: lines.join('\n').trim(), data: { location: displayName, station, items } }; + } + if (type === 'current') { const { base_date, base_time } = getKmaBaseDateTime('ncst'); const items = await kmaFetch('getUltraSrtNcst', { base_date, base_time, nx: String(nx), ny: String(ny) }); diff --git a/tests/weather-past.test.ts b/tests/weather-past.test.ts new file mode 100644 index 0000000..f338368 --- /dev/null +++ b/tests/weather-past.test.ts @@ -0,0 +1,75 @@ +/** + * weather-past.test.ts + * + * weather_kma의 과거 관측 조회(type="past"). 2026-08-16에 붙였다. + * + * 계기: "어제 제주 비 얼마나 왔어"를 물었더니 도구에 과거 조회가 없어서 모델이 + * web_search로 새고, 기상청 도움말 페이지·2026년 달력 따위만 긁어온 뒤 + * "정확한 수치를 찾아드리지 못해 죄송합니다"로 끝났다. ASOS 일자료는 한 번의 호출로 + * 그 값을 준다(제주 2026-08-15 = 13.9mm). + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseKstDate, parseAsosDaily } from '../src/tools/weather'; + +// 2026-08-16 12:00 KST = 2026-08-16 03:00 UTC +const NOW = new Date('2026-08-16T03:00:00Z'); + +describe('parseKstDate — 날짜 해석', () => { + test('상대어', () => { + // 모델이 "어제"를 그대로 넘기는 일이 잦다. + assert.equal(parseKstDate('오늘', NOW), '20260816'); + assert.equal(parseKstDate('어제', NOW), '20260815'); + assert.equal(parseKstDate('그저께', NOW), '20260814'); + assert.equal(parseKstDate('3일 전', NOW), '20260813'); + }); + + test('절대 날짜 표기들', () => { + assert.equal(parseKstDate('2026-08-15', NOW), '20260815'); + assert.equal(parseKstDate('2026/8/5', NOW), '20260805'); + assert.equal(parseKstDate('20260815', NOW), '20260815'); + assert.equal(parseKstDate('8월 15일', NOW), '20260815'); + assert.equal(parseKstDate('08-15', NOW), '20260815'); + }); + + test('KST 기준이라 UTC 자정 근처에서 하루 안 밀린다', () => { + // 2026-08-16 00:30 KST = 2026-08-15 15:30 UTC. 서버가 UTC라 new Date()로 뽑으면 + // "오늘"이 8/15로 나와 하루 어긋난다. + const lateNight = new Date('2026-08-15T15:30:00Z'); + assert.equal(parseKstDate('오늘', lateNight), '20260816'); + assert.equal(parseKstDate('어제', lateNight), '20260815'); + }); + + test('해석 불가는 null', () => { + assert.equal(parseKstDate('언젠가', NOW), null); + assert.equal(parseKstDate('', NOW), null); + }); +}); + +describe('parseAsosDaily — 일자료 한 줄 요약', () => { + test('강수량과 기온을 낸다', () => { + const out = parseAsosDaily({ tm: '2026-08-15', stnNm: '제주', sumRn: '13.9', maxTa: '29.5', minTa: '24.7', avgTa: '26.5' }); + assert.ok(out[0].includes('2026-08-15')); + assert.ok(out[1].includes('13.9 mm')); + assert.ok(out.some(l => l.includes('24.7~29.5'))); + }); + + test('빈 sumRn은 결측이 아니라 "강수 없음"이다', () => { + // "관측값 없음"으로 내보내면 맑았던 날마다 모델이 "자료가 없다"고 답하게 된다. + const out = parseAsosDaily({ tm: '2026-08-11', stnNm: '구미', sumRn: '' }); + assert.ok(out[1].includes('0 mm')); + assert.ok(out[1].includes('강수 없음')); + }); + + test('"0.0"은 미량 강수로 구분한다', () => { + const out = parseAsosDaily({ tm: '2026-08-13', stnNm: '구미', sumRn: '0.0' }); + assert.ok(out[1].includes('미량')); + }); + + test('없는 항목은 줄을 만들지 않는다', () => { + const out = parseAsosDaily({ tm: '2026-08-15', stnNm: '제주', sumRn: '1.0' }); + assert.equal(out.filter(l => l.includes('평균습도')).length, 0); + assert.equal(out.filter(l => l.includes('일조시간')).length, 0); + }); +});