v4.3.37: 날씨 지도 블랙스크린·스트리밍 레이스 수정, 태풍 예상진로(KMA/NHC) 지도 추가
- Windy iframe이 실제 로딩보다 훨씬 먼저 스피너를 걷어 몇 초간 완전 블랙스크린으로
보이던 문제 수정 (반투명 오버레이 + 고정 타이머)
- 스트리밍 중 Windy URL이 아직 다 도착 안 한 상태에서 잘린 좌표로 지도를
이동시키고 잠가버리던 레이스 컨디션 수정
- Windy는 태풍 예상 진로(향후 경로)를 그리지 않는다는 걸 명확히 하고, 실제로
경로를 그리는 기상청 태풍 페이지 + NOAA/NHC 페이지를 지도에 임베드하는 기능 추가
(nhc_active_storms 신규 도구로 이름→공식 forecast-track URL 조회, 대양/슬롯
번호를 모델이 추측하지 않도록 함)
- 채팅창에 원문 도구호출(web_search{...} 등)이 그대로 노출되던 누출 수정 —
기존 정리 로직은 메시지 전체/끝부분만 잡아서 중간에 낀 누출은 못 걸렀음
- Windy/NHC URL 정규식이 마크다운 괄호·별표까지 삼켜 URL을 깨뜨리던 과매칭 버그 수정
- 모델이 NHC URL의 슬롯 번호를 잘못 베끼는 경우에 대비해, 도구가 찾은 검증된
URL을 직접 스트림에 실어보내 지도가 항상 정확한 폭풍을 가리키도록 함
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -161,3 +161,7 @@ tests/.golden-progress.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Valetudo 로봇청소기 (2호기 설치용 보관)
|
||||
valetudo-helper-miioota
|
||||
voicepack_ko/
|
||||
|
||||
@@ -979,16 +979,29 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'weather_map_screenshot',
|
||||
description: 'Capture a real screenshot of a rendered Windy weather map (radar, satellite, wind, precipitation, etc.) so you can actually SEE it before describing it. Call this with the exact embed.windy.com URL you just generated — do not guess what the map shows from the URL parameters alone.',
|
||||
description: 'Capture a real screenshot of a rendered weather map — the Windy embed (radar, satellite, wind, precipitation, etc.), the KMA typhoon forecast-track page, or an NHC forecast-track page (URL from nhc_active_storms) — so you can actually SEE it before describing it. Call this with the exact URL you just generated — do not guess what the map shows from the URL parameters alone.',
|
||||
parameters: {
|
||||
type: 'object', required: ['url'],
|
||||
properties: {
|
||||
url: { type: 'string', description: 'The exact https://embed.windy.com/embed2.html?... URL to capture (must match the one shown to the user)' },
|
||||
url: { type: 'string', description: 'The exact https://embed.windy.com/embed2.html?... URL, the KMA typhoon track URL, or an https://www.nhc.noaa.gov/graphics_XXN.shtml URL from nhc_active_storms (must match the one shown to the user)' },
|
||||
wait_ms: { type: 'number', description: 'Milliseconds to wait for map tiles to finish loading before capturing (default 4000, max 8000)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'nhc_active_storms',
|
||||
description: 'Look up NOAA/NHC active tropical cyclones outside Korea\'s warning area (Atlantic, Eastern Pacific, Central Pacific hurricanes/storms) and get the official forecast-track-cone page URL for a specific storm by name. Use this before showing a non-Korean storm\'s forecast track — never guess the graphics_XXN.shtml URL yourself, the basin/slot number in it isn\'t derivable from the name.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Storm name to look up, e.g. "Genevieve" (case-insensitive substring match). Omit to list all currently active storms across all basins.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
|
||||
@@ -1398,6 +1398,14 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
const imgMatch = _resultText.match(/!\[[^\]]*\]\(\/api\/files\/satellite-images\/[^)]+\)/);
|
||||
if (imgMatch) sendSSE?.('token', { text: `\n${imgMatch[0]}\n` });
|
||||
}
|
||||
// nhc_active_storms: observed the model retyping the forecast-track URL with a
|
||||
// wrong basin/slot digit (e.g. graphics_ep2.shtml → graphics_ep5.shtml) instead of
|
||||
// copying it verbatim — silently pointing at an unrelated or dissipated storm. When
|
||||
// exactly one storm matched, the tool exposes its own verified URL via tr.data.url;
|
||||
// push that directly so the map is right regardless of what the model's prose says.
|
||||
if (name === 'nhc_active_storms' && tr.success && typeof tr.data?.url === 'string') {
|
||||
sendSSE?.('token', { text: `\n${tr.data.url}\n` });
|
||||
}
|
||||
return { name, args, result: _resultText, error: !tr.success, ...(_hasImageMd ? { isImage: true } : {}), ...(tr.data !== undefined ? { data: tr.data } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
|
||||
// model there has already generated the embed.windy.com URL to capture) — unlike the
|
||||
// other weather tools, not offered via the meteorologist skill in general chat.
|
||||
if (name === 'weather_map_screenshot' && !isWeatherAppSession) return false;
|
||||
if (name === 'nhc_active_storms' && !isWeatherAppSession) return false;
|
||||
if (ollamaWebToolNames.has(name) && !hasOllamaWebKeyword) return false;
|
||||
if (legalToolNames.has(name) && !isLawyerAppSession && !(lawyerEnabled && hasLegalKeyword)) return false;
|
||||
if ((name === 'excel_read' || name === 'excel_write') && !isAccountantAppSession && !hasExcelKeyword) return false;
|
||||
|
||||
@@ -243,6 +243,69 @@ function parseOrphanTrailingToolJson(content: string, tools: any[] | undefined):
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a leaked raw tool-call invocation (e.g. literal `web_search{"query":"..."}` typed
|
||||
* as text) from ANYWHERE in the content, even with substantial real prose both before and
|
||||
* after it. parseEmbeddedToolCalls/parseOrphanTrailingToolJson above only fire when the
|
||||
* whole message (or its very end) is essentially just the call — they intentionally bail
|
||||
* out the moment there's real surrounding prose, so a model that leaks a malformed call
|
||||
* mid-response and then keeps writing a normal answer around it slips past both. This is
|
||||
* safe to run unconditionally because — unlike those two, which match any lowercase
|
||||
* identifier — it only touches a span whose identifier is an EXACT registered tool name,
|
||||
* so it can't mistake a code sample or JSON example in a legitimate answer for a leak.
|
||||
*/
|
||||
function stripEmbeddedToolCallLeaks(content: string, tools: any[] | undefined): string {
|
||||
if (!Array.isArray(tools) || !tools.length || !content.includes('{')) return content;
|
||||
const toolNames = new Set(tools.map((t: any) => t?.function?.name).filter(Boolean));
|
||||
if (!toolNames.size) return content;
|
||||
|
||||
const nameRe = /\b([a-z][a-z0-9_]*)\{/g;
|
||||
let out = '';
|
||||
let pos = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let changed = false;
|
||||
|
||||
while ((m = nameRe.exec(content)) !== null) {
|
||||
const matchIndex = m.index, matchLen = m[0].length, toolName = m[1];
|
||||
if (matchIndex < pos) continue; // inside a span already consumed
|
||||
if (!toolNames.has(toolName)) continue;
|
||||
|
||||
const jsonStart = matchIndex + matchLen - 1;
|
||||
let depth = 0, inStr = false, escaped = false, i = jsonStart;
|
||||
for (; i < content.length; i++) {
|
||||
const ch = content[i];
|
||||
if (escaped) { escaped = false; continue; }
|
||||
if (ch === '\\' && inStr) { escaped = true; continue; }
|
||||
if (ch === '"') { inStr = !inStr; continue; }
|
||||
if (inStr) continue;
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') { depth--; if (depth === 0) break; }
|
||||
}
|
||||
if (depth !== 0 || i >= content.length) continue; // unterminated — leave as-is
|
||||
|
||||
const jsonStr = content.slice(jsonStart, i + 1);
|
||||
let args: any;
|
||||
try { args = JSON.parse(jsonStr); } catch { continue; }
|
||||
if (typeof args !== 'object' || Array.isArray(args) || args === null) continue;
|
||||
|
||||
const tool = tools.find((t: any) => t?.function?.name === toolName);
|
||||
const params = tool?.function?.parameters;
|
||||
const props = params?.properties ? Object.keys(params.properties) : [];
|
||||
const required = Array.isArray(params?.required) ? params.required : [];
|
||||
const keys = Object.keys(args);
|
||||
if (!required.every((r: string) => keys.includes(r))) continue;
|
||||
if (!keys.every((k) => props.includes(k))) continue;
|
||||
|
||||
// Genuine leak — excise the "name{...}" span entirely.
|
||||
out += content.slice(pos, matchIndex);
|
||||
pos = i + 1;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return content;
|
||||
out += content.slice(pos);
|
||||
return out.replace(/[ \t]{2,}/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Repair malformed JSON: close truncated brackets, strip trailing garbage. */
|
||||
function repairJson(input: string): string {
|
||||
let s = input.trim();
|
||||
@@ -442,6 +505,12 @@ export class OllamaAdapter implements LLMProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Catches a leaked call sitting in the MIDDLE of otherwise-normal prose (the two
|
||||
// checks above only fire when the whole message, or its tail, is basically just
|
||||
// the call — a model that leaks one and then keeps writing past it slips through).
|
||||
if (message.content) {
|
||||
message.content = stripEmbeddedToolCallLeaks(String(message.content), options?.tools);
|
||||
}
|
||||
|
||||
// If we got no content and no tool_calls, try the next think candidate.
|
||||
// Some models (e.g. glm-5.1:cloud) default to thinking-only mode when the
|
||||
@@ -626,6 +695,12 @@ export class OllamaAdapter implements LLMProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Catches a leaked call sitting in the MIDDLE of otherwise-normal prose (the two
|
||||
// checks above only fire when the whole message, or its tail, is basically just
|
||||
// the call — a model that leaks one and then keeps writing past it slips through).
|
||||
if (message.content) {
|
||||
message.content = stripEmbeddedToolCallLeaks(String(message.content), options?.tools);
|
||||
}
|
||||
|
||||
// If we got no content and no tool_calls, try the next think candidate.
|
||||
// Some models (e.g. glm-5.1:cloud) default to thinking-only mode when the
|
||||
|
||||
@@ -21,7 +21,7 @@ import { imageGenerateTool, videoGenerateTool, imageStyleTransformTool } from '.
|
||||
import { audioTranscribeTool } from './audio-transcribe.js';
|
||||
import { pythonEvalTool } from './python.js';
|
||||
import { sqliteTool } from './sqlite.js';
|
||||
import { weatherSearchTool, weatherAirPollutionTool, weatherOpenMeteoTool, weatherKmaTool, weatherAirKoreaTool, weatherNasaPowerTool, weatherEra5Tool, weatherCdsTool, weatherCmip6Tool, weatherMapScreenshotTool } from './weather.js';
|
||||
import { weatherSearchTool, weatherAirPollutionTool, weatherOpenMeteoTool, weatherKmaTool, weatherAirKoreaTool, weatherNasaPowerTool, weatherEra5Tool, weatherCdsTool, weatherCmip6Tool, weatherMapScreenshotTool, nhcActiveStormsTool } from './weather.js';
|
||||
import { eonetEventsTool, satelliteSnapshotTool } from './satellite.js';
|
||||
import { newsSearchTool } from './news.js';
|
||||
import { emailListTool, emailReadTool, emailSendTool, emailSearchTool, emailDeleteTool } from './email.js';
|
||||
@@ -224,6 +224,7 @@ class ToolRegistry {
|
||||
this.registerSafe(weatherCdsTool);
|
||||
this.registerSafe(weatherCmip6Tool);
|
||||
this.registerSafe(weatherMapScreenshotTool);
|
||||
this.registerSafe(nhcActiveStormsTool);
|
||||
// NASA EONET (natural event tracker) + Worldview Snapshots (satellite imagery) — both public, no API key
|
||||
this.registerSafe(eonetEventsTool);
|
||||
this.registerSafe(satelliteSnapshotTool);
|
||||
|
||||
+152
-47
@@ -3,6 +3,7 @@ import { getConfig } from '../config/config.js';
|
||||
import { getVault } from '../security/vault.js';
|
||||
import { getPaths, getWorkspacePath } from '../config/paths.js';
|
||||
import { buildImageMarkdown } from './image.js';
|
||||
import { splitOpenMeteoVariables, formatOpenMeteoReport } from './weather-openmeteo-format.js';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
@@ -176,29 +177,40 @@ function parseExplicitCountry(name: string): { city: string; countryCode?: strin
|
||||
return { city: name };
|
||||
}
|
||||
|
||||
async function geocodeCity(name: string): Promise<{ lat: number; lon: number; displayName: string } | null> {
|
||||
export async function geocodeCity(name: string): Promise<{ lat: number; lon: number; displayName: string } | null> {
|
||||
const key = name.trim().toLowerCase();
|
||||
if (KR_CITY_COORDS[key]) {
|
||||
const [lat, lon] = KR_CITY_COORDS[key];
|
||||
return { lat, lon, displayName: name };
|
||||
}
|
||||
const { city, countryCode } = parseExplicitCountry(name);
|
||||
try {
|
||||
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=10&language=ko&format=json`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) return null;
|
||||
const data: any = await res.json();
|
||||
const results: any[] = data.results || [];
|
||||
if (!results.length) return null;
|
||||
// Prefer an explicit country match; otherwise fall back to the most populous
|
||||
// candidate rather than index 0 (Open-Meteo often ranks a small US namesake
|
||||
// above the internationally-known city for a bare query like "Rome").
|
||||
let r = countryCode ? results.find((x: any) => String(x.country_code || '').toUpperCase() === countryCode) : undefined;
|
||||
if (!r) r = results.reduce((best: any, cur: any) => (cur.population || 0) > (best.population || 0) ? cur : best, results[0]);
|
||||
return { lat: r.latitude, lon: r.longitude, displayName: `${r.name}${r.country ? ', ' + r.country : ''}` };
|
||||
} catch {
|
||||
return null;
|
||||
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=10&language=ko&format=json`;
|
||||
// Open-Meteo geocoding throws a transient network/5xx blip now and then that
|
||||
// clears on an immediate retry. Observed 2026-07-29: a single Madrid call in
|
||||
// the middle of a 6-city burst returned "위치를 찾을 수 없습니다" while every
|
||||
// surrounding call and 20/20 direct retries succeeded — a one-off blip, not a
|
||||
// real miss. Without a retry that blip hardens into a permanent "city not
|
||||
// found" in the answer. Retry only on transient causes (throw / non-ok); a
|
||||
// 200 with no results is a genuine "not found" and returns immediately.
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) throw new Error(`geocoding HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
const results: any[] = data.results || [];
|
||||
if (!results.length) return null; // API answered "not found" — retrying won't change it
|
||||
// Prefer an explicit country match; otherwise fall back to the most populous
|
||||
// candidate rather than index 0 (Open-Meteo often ranks a small US namesake
|
||||
// above the internationally-known city for a bare query like "Rome").
|
||||
let r = countryCode ? results.find((x: any) => String(x.country_code || '').toUpperCase() === countryCode) : undefined;
|
||||
if (!r) r = results.reduce((best: any, cur: any) => (cur.population || 0) > (best.population || 0) ? cur : best, results[0]);
|
||||
return { lat: r.latitude, lon: r.longitude, displayName: `${r.name}${r.country ? ', ' + r.country : ''}` };
|
||||
} catch (err: any) {
|
||||
if (attempt >= 3) break;
|
||||
await new Promise(r => setTimeout(r, 300 * attempt)); // 300ms, 600ms
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCoords(location: string): [number, number] | null {
|
||||
@@ -556,18 +568,20 @@ const WMO_CODES: Record<number, string> = {
|
||||
|
||||
export const weatherOpenMeteoTool = {
|
||||
name: 'weather_openmeteo',
|
||||
description: 'Get detailed hourly weather forecast using Open-Meteo API (free, no API key). Powered by ECMWF, GFS, and other NWP models. Supports temperature, precipitation, wind, UV index, soil temp, and more for up to 16 days.',
|
||||
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',
|
||||
hours: 'Number of hours to forecast (default: 24, max: 384)',
|
||||
variables: 'Comma-separated Open-Meteo hourly variables (default: temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code)',
|
||||
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, sunrise, …) are routed to the daily block automatically; everything else is treated as hourly. Default: temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: { type: 'string', description: 'City name or "lat,lon"' },
|
||||
hours: { type: 'number', description: 'Hours ahead (1–384, default 24)' },
|
||||
variables: { type: 'string', description: 'Comma-separated hourly variable names' },
|
||||
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)' },
|
||||
},
|
||||
required: ['location'],
|
||||
additionalProperties: false,
|
||||
@@ -577,7 +591,12 @@ export const weatherOpenMeteoTool = {
|
||||
if (!location) return { success: false, error: 'location is required' };
|
||||
|
||||
const hours = Math.min(Math.max(1, Math.round(Number(args?.hours) || 24)), 384);
|
||||
const days = Math.min(Math.max(1, Math.round(Number(args?.days) || 3)), 16);
|
||||
const vars = String(args?.variables || 'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code');
|
||||
// Daily-only names used to be posted to `hourly=`, which Open-Meteo accepts — it returns
|
||||
// an hourly series under a daily name, so `temperature_2m_max` produced numbers that were
|
||||
// not maxima at all. Route them before the request instead.
|
||||
const { hourly: hourlyVars, daily: dailyVars } = splitOpenMeteoVariables(vars);
|
||||
|
||||
try {
|
||||
let lat: number, lon: number, displayName: string;
|
||||
@@ -594,34 +613,41 @@ export const weatherOpenMeteoTool = {
|
||||
const params = new URLSearchParams({
|
||||
latitude: String(lat),
|
||||
longitude: String(lon),
|
||||
hourly: vars,
|
||||
forecast_hours: String(hours),
|
||||
timezone: 'Asia/Seoul',
|
||||
// 'auto' resolves the timezone from the coordinates. This was hardcoded to
|
||||
// Asia/Seoul, which stamped every foreign forecast with Korean day boundaries and
|
||||
// made "today's high" unanswerable for anywhere outside KST.
|
||||
timezone: 'auto',
|
||||
wind_speed_unit: 'ms',
|
||||
});
|
||||
if (hourlyVars.length) {
|
||||
params.set('hourly', hourlyVars.join(','));
|
||||
params.set('forecast_hours', String(hours));
|
||||
}
|
||||
if (dailyVars.length) {
|
||||
params.set('daily', dailyVars.join(','));
|
||||
params.set('forecast_days', String(days));
|
||||
}
|
||||
const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!res.ok) throw new Error(`Open-Meteo API error ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
|
||||
const times: string[] = data.hourly?.time || [];
|
||||
const varKeys = vars.split(',').map(v => v.trim());
|
||||
const units = data.hourly_units || {};
|
||||
const SHOW = Math.min(hours, 48);
|
||||
|
||||
const headerCols = ['시각', ...varKeys.map(k => k.replace('_', ' '))];
|
||||
const rows = times.slice(0, SHOW).map((t, i) => {
|
||||
const vals = varKeys.map(k => {
|
||||
const v = data.hourly?.[k]?.[i];
|
||||
if (v == null) return '-';
|
||||
const text = formatOpenMeteoReport({
|
||||
displayName,
|
||||
timezone: data.timezone || 'auto',
|
||||
utcOffsetSeconds: Number(data.utc_offset_seconds) || 0,
|
||||
hours,
|
||||
hourlyVars,
|
||||
dailyVars,
|
||||
hourlyBlock: data.hourly,
|
||||
dailyBlock: data.daily,
|
||||
hourlyUnits: data.hourly_units || {},
|
||||
dailyUnits: data.daily_units || {},
|
||||
renderValue: (k, v) => {
|
||||
if (k === 'weather_code') return WMO_CODES[v as number] ?? String(v);
|
||||
if (k === 'wind_direction_10m') return `${windDirectionKo(v)}(${v}°)`;
|
||||
if (k === 'wind_direction_10m' || k === 'wind_direction_10m_dominant') return `${windDirectionKo(v)}(${v}°)`;
|
||||
return String(v);
|
||||
});
|
||||
return [t.replace('T', ' '), ...vals].join('\t');
|
||||
},
|
||||
});
|
||||
|
||||
const unitLine = `단위: ${varKeys.map(k => `${k}=${units[k] || '-'}`).join(', ')}`;
|
||||
const text = [`Open-Meteo 예보: ${displayName} (${hours}시간)`, unitLine, '', headerCols.join('\t'), ...rows].join('\n');
|
||||
return { success: true, stdout: text, data: { location: displayName, lat, lon, raw: data } };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
@@ -1517,17 +1543,27 @@ async function getMapBrowser(): Promise<any> {
|
||||
return _mapBrowser;
|
||||
}
|
||||
|
||||
// KMA's typhoon page is the only place that actually plots a forecast track (the
|
||||
// cone-of-uncertainty + path line) — Windy's embed never does. Allowlisted by exact
|
||||
// path (not just hostname) since weather.go.kr hosts far more than this one page.
|
||||
const KMA_TYPHOON_TRACK_URL = 'https://www.weather.go.kr/w/typhoon/ko/weather/typhoon_02.jsp';
|
||||
// NOAA/NHC's equivalent for storms outside KMA's warning area. The URL is basin+slot
|
||||
// specific (graphics_ep2.shtml, graphics_at1.shtml, ...) so it can't be a single fixed
|
||||
// string like KMA's — nhc_active_storms resolves the right one, this just validates the
|
||||
// shape stays within nhc.noaa.gov's own graphics pages (no arbitrary path/host).
|
||||
const NHC_TRACK_URL_RE = /^https:\/\/www\.nhc\.noaa\.gov\/graphics_[a-z]+\d\.shtml(?:\?.*)?$/;
|
||||
|
||||
export const weatherMapScreenshotTool = {
|
||||
name: 'weather_map_screenshot',
|
||||
description: 'Capture a real screenshot of a rendered Windy weather map (radar, satellite, wind, precipitation, etc.) so you can actually SEE it before describing it. Call this with the exact embed.windy.com URL you just generated — do not guess what the map shows from the URL parameters alone.',
|
||||
description: 'Capture a real screenshot of a rendered weather map — the Windy embed (radar, satellite, wind, precipitation, etc.), the KMA typhoon forecast-track page, or an NHC forecast-track page (from nhc_active_storms) — so you can actually SEE it before describing it. Call this with the exact URL you just generated — do not guess what the map shows from the URL parameters alone.',
|
||||
schema: {
|
||||
url: 'The exact https://embed.windy.com/embed2.html?... URL to capture (must match the one shown to the user)',
|
||||
url: `The exact https://embed.windy.com/embed2.html?... URL, the literal ${KMA_TYPHOON_TRACK_URL} for KMA, or an https://www.nhc.noaa.gov/graphics_XXN.shtml URL from nhc_active_storms`,
|
||||
wait_ms: 'Milliseconds to wait for map tiles to finish loading before capturing (default 4000, max 8000)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'The exact embed.windy.com URL to capture' },
|
||||
url: { type: 'string', description: 'The exact embed.windy.com, KMA typhoon track, or NHC graphics_XXN.shtml URL to capture' },
|
||||
wait_ms: { type: 'number', description: 'Wait time in ms for tiles to load (default 4000, max 8000)' },
|
||||
},
|
||||
required: ['url'],
|
||||
@@ -1539,8 +1575,10 @@ export const weatherMapScreenshotTool = {
|
||||
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(urlStr); } catch { return { success: false, error: 'Invalid URL' }; }
|
||||
if (parsed.hostname !== 'embed.windy.com') {
|
||||
return { success: false, error: 'Only embed.windy.com URLs can be captured' };
|
||||
const isKmaTrack = urlStr === KMA_TYPHOON_TRACK_URL;
|
||||
const isNhcTrack = NHC_TRACK_URL_RE.test(urlStr);
|
||||
if (parsed.hostname !== 'embed.windy.com' && !isKmaTrack && !isNhcTrack) {
|
||||
return { success: false, error: `Only embed.windy.com, ${KMA_TYPHOON_TRACK_URL}, or nhc.noaa.gov graphics_XXN.shtml URLs can be captured` };
|
||||
}
|
||||
|
||||
const waitMs = Math.min(Math.max(Number(args?.wait_ms) || 4000, 1000), 8000);
|
||||
@@ -1566,11 +1604,14 @@ export const weatherMapScreenshotTool = {
|
||||
const filePath = path.join(outDir, `windy-${Date.now()}.jpg`);
|
||||
fs.writeFileSync(filePath, buf);
|
||||
|
||||
const overlay = parsed.searchParams.get('overlay') || 'map';
|
||||
const zoom = parsed.searchParams.get('zoom') || '?';
|
||||
const label = isKmaTrack
|
||||
? 'KMA 태풍 예상진로 페이지 캡처 완료'
|
||||
: isNhcTrack
|
||||
? 'NHC 허리케인 예상진로 페이지 캡처 완료'
|
||||
: `Windy 지도 캡처 완료 (overlay=${parsed.searchParams.get('overlay') || 'map'}, zoom=${parsed.searchParams.get('zoom') || '?'})`;
|
||||
return {
|
||||
success: true,
|
||||
stdout: `Windy 지도 캡처 완료 (overlay=${overlay}, zoom=${zoom}):\n\n${buildImageMarkdown(filePath, workspacePath)}\n\n위 이미지에 실제로 보이는 내용만 근거로 설명하세요. 보이지 않는 세부사항을 추측하지 마세요.`,
|
||||
stdout: `${label}:\n\n${buildImageMarkdown(filePath, workspacePath)}\n\n위 이미지에 실제로 보이는 내용만 근거로 설명하세요. 보이지 않는 세부사항을 추측하지 마세요.`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `지도 캡처 실패: ${err.message}` };
|
||||
@@ -1579,3 +1620,67 @@ export const weatherMapScreenshotTool = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ── nhc_active_storms (NOAA/NHC forecast-track lookup for non-Korean storms) ──────
|
||||
// KMA's typhoon page (KMA_TYPHOON_TRACK_URL above) only ever shows storms in KMA's own
|
||||
// warning area — a hurricane near Mexico never appears there. NHC's forecast-cone page
|
||||
// URL is basin+slot specific (e.g. graphics_ep2.shtml = 2nd Eastern Pacific storm this
|
||||
// season) and that slot number isn't guessable from a storm's name alone. NHC publishes
|
||||
// a small official JSON feed of all currently active storms with their basin/slot and
|
||||
// the exact forecast-graphics URL already resolved — this tool just looks a name up in it,
|
||||
// so the model never has to guess or scrape for the right graphics_XXN.shtml URL.
|
||||
export const nhcActiveStormsTool = {
|
||||
name: 'nhc_active_storms',
|
||||
description: 'Look up NOAA/NHC (National Hurricane Center) active tropical cyclones outside Korea\'s warning area (Atlantic, Eastern Pacific, Central Pacific hurricanes/storms) and get the official forecast-track-cone page URL for a specific storm by name. Use this before showing a non-Korean storm\'s forecast track — do not guess the graphics_XXN.shtml URL yourself.',
|
||||
schema: {
|
||||
name: 'Storm name to look up (e.g. "Genevieve"), case-insensitive substring match. Omit to list all currently active storms across all basins.',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Storm name to look up, e.g. "Genevieve". Omit to list all active storms.' },
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
try {
|
||||
const res = await fetch('https://www.nhc.noaa.gov/CurrentStorms.json', { signal: AbortSignal.timeout(15_000) });
|
||||
if (!res.ok) return { success: false, error: `NHC 조회 실패: HTTP ${res.status}` };
|
||||
const data: any = await res.json();
|
||||
const all = Array.isArray(data?.activeStorms) ? data.activeStorms : [];
|
||||
const nameFilter = String(args?.name || '').trim().toLowerCase();
|
||||
const matches = nameFilter ? all.filter((s: any) => String(s?.name || '').toLowerCase().includes(nameFilter)) : all;
|
||||
|
||||
if (all.length === 0) {
|
||||
return { success: true, stdout: '현재 NHC(대서양·동태평양·중태평양) 관할 구역에 활성화된 열대저기압이 없습니다.' };
|
||||
}
|
||||
if (nameFilter && matches.length === 0) {
|
||||
const names = all.map((s: any) => s.name).join(', ');
|
||||
return { success: true, stdout: `"${args.name}"이라는 이름의 활성 태풍/허리케인을 NHC 목록에서 찾지 못했습니다. 현재 NHC 활성 목록: ${names}. 이름을 다시 확인하거나, 이미 소멸했을 가능성을 검토하세요.` };
|
||||
}
|
||||
|
||||
const lines = matches.map((s: any) => {
|
||||
const track = s?.forecastGraphics?.url || '(예보 그래픽 URL 없음 — 열대저압부 단계라 아직 진로 원뿔이 없을 수 있음)';
|
||||
return [
|
||||
`이름: ${s.name} (${s.classification || '?'}, basin/slot ${s.binNumber || '?'})`,
|
||||
`위치: ${s.latitude ?? '?'}, ${s.longitude ?? '?'}`,
|
||||
`세력: 최대풍속 ${s.intensity ?? '?'}kt, 중심기압 ${s.pressure ?? '?'}hPa`,
|
||||
`이동: 방위각 ${s.movementDir ?? '?'}°, 시속 ${s.movementSpeed ?? '?'}kt`,
|
||||
`마지막 업데이트: ${s.lastUpdate || '?'}`,
|
||||
`예상진로 페이지 URL: ${track}`,
|
||||
].join('\n');
|
||||
});
|
||||
// Models have been observed retyping this URL with a wrong digit (e.g. ep2 → ep5)
|
||||
// instead of copying it verbatim, which silently points at an unrelated/dissipated
|
||||
// storm. When exactly one match has a real graphics URL, expose it as structured
|
||||
// data so the caller can push the tool's own (guaranteed-correct) URL directly
|
||||
// instead of trusting the model's prose to reproduce it — same reasoning as the
|
||||
// satellite_snapshot auto-push in execute-tool.ts.
|
||||
const singleUrl = matches.length === 1 ? matches[0]?.forecastGraphics?.url : undefined;
|
||||
return { success: true, stdout: lines.join('\n---\n'), ...(singleUrl ? { data: { url: singleUrl } } : {}) };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `NHC 조회 실패: ${err.message}` };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
+114
-16
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user