v4.1.2: 기상도(Windy) 캡처 화질 개선 + 지도 읽는 법·좌표·태풍 트리거 추가

- weather_map_screenshot 캡처 해상도 900x620→1280x880, PNG→JPEG(품질90)로 변경.
  300KB 넘으면 vision 파이프라인이 600x600으로 강제 축소하던 걸(server-v2.ts
  resolveToolImageContent) 파일 크기를 낮춰서 회피 — 지명·기온 숫자가 선명해짐
- 기상전문가 프롬프트에 lat/lon을 서울로 고정 복사하지 않고 실제 언급된 지역
  기준으로 치환하도록 명시 + 주요 도시 12곳 좌표표 추가
- 오버레이 9종(바람/강수/기온/구름/기압/대기질/레이더/적설/파고)별 색상 범례
  해석 가이드("지도 읽는 법") 추가 — 이미지는 받아도 읽는 법이 없어 해석이
  부실했던 문제
- "태풍" 계열 단어를 Windy 지도 트리거에 추가(기존엔 없어서 뜨는 게 랜덤했음)
  + 태풍 요청 시 좌표를 먼저 검색해 그 위치로 지도를 센터링하도록 지시

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-13 22:47:43 +09:00
co-authored by Claude Sonnet 5
parent c1d72a2a2d
commit 1f112fd227
3 changed files with 161 additions and 14 deletions
+115 -1
View File
@@ -1,9 +1,11 @@
import { ToolResult } from '../types.js';
import { getConfig } from '../config/config.js';
import { getVault } from '../security/vault.js';
import { getPaths } from '../config/paths.js';
import { getPaths, getWorkspacePath } from '../config/paths.js';
import { buildImageMarkdown } from './image.js';
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
import https from 'https';
// IPv4-forced HTTPS GET — used for hosts whose IPv6 is unreachable from this server
@@ -1361,3 +1363,115 @@ export const weatherCmip6Tool = {
}
},
};
// ── weather_map_screenshot (headless capture of Windy embed map) ─────────────
// The Windy iframe embedded in the weather app UI is rendered client-side for
// the human user only — the model never sees it. This tool actually opens the
// same embed URL in a headless browser and screenshots it, so the model can
// look at the rendered map instead of guessing what its own coordinates show.
let _mapPlaywrightModule: any = null;
let _mapBrowser: any = null;
let _mapChromiumPath: string | null | undefined = undefined; // undefined = not probed yet
async function findMapChromiumExecutable(): Promise<string | null> {
const candidates = [
process.env.CHROME_PATH,
'/usr/bin/chromium-browser',
'/usr/bin/chromium',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/snap/bin/chromium',
].filter(Boolean) as string[];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return null;
}
async function getMapBrowser(): Promise<any> {
if (_mapBrowser) {
try {
await _mapBrowser.version();
return _mapBrowser;
} catch {
_mapBrowser = null;
}
}
if (!_mapPlaywrightModule) {
if (!process.env.PLAYWRIGHT_BROWSERS_PATH) {
const os = await import('os');
process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(os.homedir(), '.playwright-browsers');
}
_mapPlaywrightModule = await (Function('return import("playwright")')() as Promise<any>);
}
if (_mapChromiumPath === undefined) _mapChromiumPath = await findMapChromiumExecutable();
_mapBrowser = await _mapPlaywrightModule.chromium.launch({
headless: true,
...(_mapChromiumPath ? { executablePath: _mapChromiumPath } : {}),
});
return _mapBrowser;
}
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.',
schema: {
url: 'The exact https://embed.windy.com/embed2.html?... URL to capture (must match the one shown to the user)',
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' },
wait_ms: { type: 'number', description: 'Wait time in ms for tiles to load (default 4000, max 8000)' },
},
required: ['url'],
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const urlStr = String(args?.url || '').trim();
if (!urlStr) return { success: false, error: 'url is required' };
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 waitMs = Math.min(Math.max(Number(args?.wait_ms) || 4000, 1000), 8000);
let context: any = null;
try {
const browser = await getMapBrowser();
// Bigger viewport than before (was 900x620) for more legible city labels/numbers —
// safe because JPEG capture below keeps the file small despite the larger canvas.
context = await browser.newContext({ viewport: { width: 1280, height: 880 } });
const page = await context.newPage();
await page.goto(parsed.toString(), { waitUntil: 'load', timeout: 20_000 });
await page.waitForTimeout(waitMs);
// Capture as JPEG (not PNG) so the file lands under the 300KB threshold in
// resolveToolImageContent (server-v2.ts) that otherwise re-downscales anything
// bigger to a blurry 600x600 thumbnail — losing exactly the city-name/temperature
// text and color-band precision the model needs to read the map accurately.
const buf: Buffer = await page.screenshot({ type: 'jpeg', quality: 90 });
const workspacePath = getWorkspacePath(args);
const outDir = path.join(workspacePath, 'weather-maps');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
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') || '?';
return {
success: true,
stdout: `Windy 지도 캡처 완료 (overlay=${overlay}, zoom=${zoom}):\n\n${buildImageMarkdown(filePath, workspacePath)}\n\n위 이미지에 실제로 보이는 내용만 근거로 설명하세요. 보이지 않는 세부사항을 추측하지 마세요.`,
};
} catch (err: any) {
return { success: false, error: `지도 캡처 실패: ${err.message}` };
} finally {
try { if (context) await context.close(); } catch {}
}
},
};