feat: weather_kma에 과거 날짜 조회(type="past") 추가
"어제 제주 비 얼마나 왔어"를 물었더니 도구에 과거 조회가 없어서 모델이
web_search로 새고, 2026년 달력·기상청 도움말 페이지만 긁어온 뒤 "정확한
수치를 찾아드리지 못해 죄송합니다"로 끝났다. 검색 실패가 아니라 도구
공백이었다. ASOS 일자료는 한 번의 호출로 그 값을 준다(제주 8/15 = 13.9mm).
date는 "어제"·"그저께"·"3일 전"·"2026-08-15"·"8월 15일"·"08-15"·"20260815"를
받는다. 기준은 KST — 서버가 UTC라 new Date()로 날짜를 뽑으면 한국 시각
09시 이전에 하루 어긋난다. endDate를 주면 일별 + 기간 합계를 낸다.
모델 실수 두 가지를 코드에서 흡수한다:
- date만 주고 type을 빠뜨리는 호출이 흔해서, date가 있으면 past로 본다.
- 오늘 날짜를 넣으면 그냥 실패시키지 않고 "일자료는 전날까지, 오늘은
type=current" 라고 안내한다. 맨 실패로 두면 또 web_search로 샌다.
sumRn의 빈 값은 결측이 아니라 그날 비가 안 왔다는 뜻이다(시간자료 rn과
같은 규칙, 서울 8/10~8/13이 빈 값이고 그 기간 무강수인 것으로 확인).
처음엔 "관측값 없음"으로 내보냈는데 그대로 뒀으면 맑았던 날마다 모델이
"자료가 없습니다"라고 답했을 것이라 0으로 말하되 미량("0.0")과 구분한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+108
-4
@@ -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<any[]> {
|
||||
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<string, string>): Promise<any[]> {
|
||||
@@ -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<ToolResult> => {
|
||||
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) });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user