From d51c40484ac22cf0e394d351e3899de0188aeb6e Mon Sep 17 00:00:00 2001 From: kim Date: Fri, 7 Aug 2026 13:16:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=98=EB=A3=8C=20=EC=96=B4=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=84=B4=ED=8A=B8=20=EC=95=B1=20+=20Home=20Assistant?= =?UTF-8?q?=20=EC=9E=84=EB=B2=A0=EB=93=9C=20=EC=95=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 법률 사건관리 앱과 같은 패턴으로 의료 사건(진료기록) 관리 앱 추가 (routes-doctor-cases.ts + doctor-app.html + WOPI 라우트). Home Assistant를 iframe으로 띄우는 얇은 래퍼 앱 추가(ha.applecherry.net, 2026-08-01 통합 완료된 것 URL만 새로 노출). weather_openmeteo의 요청 성형/리포트 포맷팅 로직을 weather-openmeteo-format.ts로 분리(순수함수라 테스트 가능, 2026-07-29 있었던 타임존/롤링윈도우/daily-in-hourly 버그 3건 회귀 방지 테스트 포함) + geocode 재시도 테스트. Co-Authored-By: Claude Sonnet 5 --- src/gateway/routes/routes-doctor-cases.ts | 266 ++++ src/gateway/server.ts | 5 +- src/tools/weather-openmeteo-format.ts | 206 +++ tests/geocode-retry.test.ts | 84 ++ tests/weather-openmeteo-format.test.ts | 267 ++++ web-ui/html/doctor-app.html | 1447 +++++++++++++++++++++ web-ui/html/home-assistant-app.html | 57 + 7 files changed, 2331 insertions(+), 1 deletion(-) create mode 100644 src/gateway/routes/routes-doctor-cases.ts create mode 100644 src/tools/weather-openmeteo-format.ts create mode 100644 tests/geocode-retry.test.ts create mode 100644 tests/weather-openmeteo-format.test.ts create mode 100644 web-ui/html/doctor-app.html create mode 100644 web-ui/html/home-assistant-app.html diff --git a/src/gateway/routes/routes-doctor-cases.ts b/src/gateway/routes/routes-doctor-cases.ts new file mode 100644 index 0000000..f33772b --- /dev/null +++ b/src/gateway/routes/routes-doctor-cases.ts @@ -0,0 +1,266 @@ +/** + * routes-doctor-cases.ts + * Doctor app: personal health record (진료 기록) list + per-record attached + * documents, mirroring routes-lawyer-cases.ts (case-storage.ts holds the + * shared logic — see that file's header). Identical CRUD/upload/download/edit + * behavior to lawyer's routes; only APP_TYPE differs, since case-storage.ts is + * already parameterized by appType. + * + * Out of scope (see doctor-app.html's SKILL_CONTENT): diagnosis/prescription + * generation — this file only stores whatever the user records, same as the + * legal/detective case stores just store what's typed in. + */ +import express from 'express'; +import fs from 'fs'; +import path from 'path'; +import archiver from 'archiver'; +import { execFile } from 'child_process'; +import { getConfig } from '../../config/config'; +import { + casesPath, caseFilesDir, resolveUploadPath, walkCaseFiles, fileCategory, mintWopiToken, +} from './case-storage'; + +const APP_TYPE = 'doctor'; + +export function registerDoctorCaseRoutes( + app: express.Application, + getSessionUser: (req: express.Request) => any, + isPathInsideDir: (base: string, target: string) => boolean, +): void { + + app.get('/api/doctor/cases', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const p = casesPath(session.username, APP_TYPE); + if (!fs.existsSync(p)) return res.json({ cases: [] }); + try { res.json(JSON.parse(fs.readFileSync(p, 'utf-8'))); } catch { res.json({ cases: [] }); } + }); + + app.put('/api/doctor/cases', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const { cases } = req.body || {}; + if (!Array.isArray(cases)) return res.status(400).json({ error: 'cases array required' }); + const p = casesPath(session.username, APP_TYPE); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmp = `${p}.tmp`; + fs.writeFileSync(tmp, JSON.stringify({ cases }, null, 2), 'utf-8'); + fs.renameSync(tmp, p); + res.json({ success: true }); + }); + + app.get('/api/doctor/files', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const caseId = String(req.query.caseId || ''); + if (!/^[a-zA-Z0-9_-]+$/.test(caseId)) return res.status(400).json({ error: 'invalid caseId' }); + const dir = caseFilesDir(session.username, APP_TYPE, caseId); + const files = walkCaseFiles(dir).map(e => { + const segs = e.relPath.split('/'); + return { + name: e.name, + relPath: e.relPath, + folder: segs.length > 1 ? segs[0] : '', + size: e.size, + mtime: e.mtime, + url: `/api/files/${APP_TYPE}/${caseId}/${segs.map(encodeURIComponent).join('/')}`, + category: fileCategory(e.name), + }; + }); + res.json({ files }); + }); + + app.post('/api/doctor/upload', (req, res) => { + const session = getSessionUser(req); + if (!session) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; } + const caseId = String(req.query.caseId || ''); + if (!/^[a-zA-Z0-9_-]+$/.test(caseId)) { res.status(400).json({ success: false, error: 'invalid caseId' }); return; } + const contentType = String(req.headers['content-type'] || ''); + if (!contentType.includes('multipart/form-data')) { + res.status(400).json({ success: false, error: 'Content-Type must be multipart/form-data' }); return; + } + const boundary = contentType.split('boundary=')[1]; + if (!boundary) { res.status(400).json({ success: false, error: 'Missing boundary' }); return; } + + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('binary'); + const boundaryDelim = '--' + boundary; + + let filename = 'upload.bin'; + let filetype = 'application/octet-stream'; + let fileData: Buffer | null = null; + + const parts = raw.split(boundaryDelim); + for (const part of parts) { + if (!part || part.trim() === '--' || part.trim() === '') continue; + const headerEnd = part.indexOf('\r\n\r\n'); + if (headerEnd === -1) continue; + const header = part.substring(0, headerEnd); + if (!header.includes('name="file"')) continue; + + const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i) + ?? header.match(/filename="([^"]+)"/); + if (fnMatch) { + const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1] + ? Buffer.from(fnMatch[1], 'binary').toString('utf-8') + : decodeURIComponent(fnMatch[1]); + filename = raw8; + } + const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i); + if (ctMatch) filetype = ctMatch[1].trim(); + + const bodyStart = headerEnd + 4; + const bodyEnd = part.lastIndexOf('\r\n'); + if (bodyEnd <= bodyStart) continue; + + fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary'); + break; + } + + if (!fileData) { res.status(400).json({ success: false, error: 'No file found in upload' }); return; } + if (fileData.length > 50 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 50MB)' }); return; } + + const dir = caseFilesDir(session.username, APP_TYPE, caseId); + fs.mkdirSync(dir, { recursive: true }); + const { relPath, absPath } = resolveUploadPath(dir, filename); + fs.writeFileSync(absPath, fileData); + + res.json({ + success: true, + name: path.basename(relPath), + relPath, + size: fileData.length, + type: filetype, + url: `/api/files/${APP_TYPE}/${caseId}/${relPath.split('/').map(encodeURIComponent).join('/')}`, + category: fileCategory(relPath), + }); + }); + req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); }); + }); + + app.delete('/api/doctor/files', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const caseId = String(req.body?.caseId || ''); + const relPath = String(req.body?.relPath || req.body?.name || ''); + const segments = relPath.split('/').filter(Boolean); + if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) { + return res.status(400).json({ error: 'invalid' }); + } + const dir = caseFilesDir(session.username, APP_TYPE, caseId); + const targetPath = path.join(dir, ...segments); + if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' }); + if (!fs.existsSync(targetPath)) { res.json({ success: true, alreadyGone: true }); return; } + try { + const stat = fs.statSync(targetPath); + if (stat.isDirectory()) fs.rmSync(targetPath, { recursive: true, force: true }); + else fs.unlinkSync(targetPath); + } catch (e: any) { + res.status(500).json({ success: false, error: String(e?.message || e) }); return; + } + res.json({ success: true }); + }); + + app.get('/api/doctor/files/download-folder', (req, res) => { + const session = getSessionUser(req); + if (!session) { res.status(401).json({ error: 'Unauthorized' }); return; } + const caseId = String(req.query.caseId || ''); + const folder = String(req.query.folder || ''); + const segments = folder.split('/').filter(Boolean); + if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) { + res.status(400).json({ error: 'invalid' }); return; + } + const dir = caseFilesDir(session.username, APP_TYPE, caseId); + const targetPath = path.join(dir, ...segments); + if (!isPathInsideDir(dir, targetPath)) { res.status(403).json({ error: 'Forbidden' }); return; } + if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) { + res.status(404).json({ error: 'Folder not found' }); return; + } + const zipName = `${segments[segments.length - 1]}.zip`; + const encodedName = encodeURIComponent(zipName); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`); + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.on('error', (err: Error) => { + if (!res.headersSent) res.status(500).json({ error: String(err.message || err) }); + }); + archive.pipe(res); + archive.directory(targetPath, false); + archive.finalize(); + }); + + // Overwrites a text document (.txt/.md/.csv) with UTF-8 content — same + // encoding-normalization use case as lawyer's version. + app.put('/api/doctor/files/content', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const caseId = String(req.body?.caseId || ''); + const relPath = String(req.body?.relPath || ''); + const content = req.body?.content; + const segments = relPath.split('/').filter(Boolean); + if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.') || typeof content !== 'string') { + return res.status(400).json({ error: 'invalid' }); + } + const dir = caseFilesDir(session.username, APP_TYPE, caseId); + const targetPath = path.join(dir, ...segments); + if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' }); + if (!fs.existsSync(targetPath)) return res.status(404).json({ error: 'File not found' }); + try { + fs.writeFileSync(targetPath, content, 'utf-8'); + const stat = fs.statSync(targetPath); + res.json({ success: true, size: stat.size, mtime: stat.mtime.toISOString() }); + } catch (e: any) { + res.status(500).json({ error: String(e?.message || e) }); + } + }); + + // Opens a record file in Collabora directly (no NextCloud) — same WOPI-host + // approach as lawyer's collabora-open, sharing the same token map so any + // case-backed app's callback prefix works. .hwp is converted to .odt once + // (Collabora has no HWP filter); .odt opens as-is. + app.get('/api/doctor/collabora-open', async (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).send('Unauthorized'); + const caseId = String(req.query.caseId || ''); + const relPath = String(req.query.relPath || ''); + const segments = relPath.split('/').filter(Boolean); + if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) { + return res.status(400).send('invalid'); + } + const dir = caseFilesDir(session.username, APP_TYPE, caseId); + const srcPath = path.join(dir, ...segments); + if (!isPathInsideDir(dir, srcPath) || !fs.existsSync(srcPath)) return res.status(404).send('File not found'); + + const collaboraCfg = (getConfig().getConfig() as any).collabora || {}; + if (!collaboraCfg.baseUrl || !collaboraCfg.publicUrl) { + return res.status(500).send('Collabora is not configured (collabora.baseUrl/publicUrl)'); + } + + try { + let editPath = srcPath; + if (/\.hwp$/i.test(srcPath)) { + const outDir = path.dirname(srcPath); + const base = path.basename(srcPath, path.extname(srcPath)); + const odtPath = path.join(outDir, `${base}.odt`); + if (!fs.existsSync(odtPath)) { + await new Promise((resolve, reject) => { + execFile('soffice', ['--headless', '--convert-to', 'odt', '--outdir', outDir, srcPath], { timeout: 60_000 }, (err) => { + if (err) reject(err); else resolve(); + }); + }); + if (!fs.existsSync(odtPath)) throw new Error('hwp → odt 변환 결과물이 없습니다'); + } + editPath = odtPath; + } + + const token = mintWopiToken(session.username, editPath); + const wopiSrc = `${collaboraCfg.publicUrl.replace(/\/+$/, '')}/api/doctor/wopi/files/${token}`; + const editorUrl = `${collaboraCfg.baseUrl.replace(/\/+$/, '')}/browser/dist/cool.html?WOPISrc=${encodeURIComponent(wopiSrc)}&access_token=${token}`; + res.redirect(editorUrl); + } catch (e: any) { + res.status(502).send('Collabora open failed: ' + String(e?.message || e)); + } + }); +} diff --git a/src/gateway/server.ts b/src/gateway/server.ts index dfd7c24..a5387aa 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -93,6 +93,7 @@ import { registerWriterRoutes } from './routes/routes-writer'; import { registerDentalRoutes } from './routes/routes-dental'; import { registerDetectiveDateRoutes } from './routes/routes-detective-dates'; import { registerLawyerCaseRoutes } from './routes/routes-lawyer-cases'; +import { registerDoctorCaseRoutes } from './routes/routes-doctor-cases'; import { caseFilesDir, resolveUploadPath, registerWopiRoutes } from './routes/case-storage'; import { getConfig, @@ -4443,6 +4444,8 @@ registerUserRoutes(app, { registerDetectiveRoutes(app, getSessionUser, isPathInsideDir, CONFIG_DIR_PATH); registerLawyerCaseRoutes(app, getSessionUser, isPathInsideDir); registerWopiRoutes(app, '/api/lawyer'); +registerDoctorCaseRoutes(app, getSessionUser, isPathInsideDir); +registerWopiRoutes(app, '/api/doctor'); // Redirect legacy top-level HTML routes into /html/ after web-ui reorganization. @@ -4452,7 +4455,7 @@ const LEGACY_HTML_REDIRECTS = [ '/mind-app.html','/lawyer-app.html','/investor-app.html','/accountant-app.html', '/detective-app.html','/studio-app.html','/pptx-wizard.html','/traffic-app.html', '/android-emulator.html','/arduino-emulator.html','/esp32-flasher.html', - '/python-runner.html','/xlsx-viewer.html','/dental-agent.html' + '/python-runner.html','/xlsx-viewer.html','/dental-agent.html','/doctor-app.html' ]; for (const p of LEGACY_HTML_REDIRECTS) { // req.url retains the original query string (e.g. xlsx-viewer.html?file=...); diff --git a/src/tools/weather-openmeteo-format.ts b/src/tools/weather-openmeteo-format.ts new file mode 100644 index 0000000..96fb2ae --- /dev/null +++ b/src/tools/weather-openmeteo-format.ts @@ -0,0 +1,206 @@ +/** + * weather-openmeteo-format.ts + * + * Pure request-shaping and report-formatting logic for `weather_openmeteo`. + * + * Split out of weather.ts because three silent failures on 2026-07-29 all lived in this + * logic rather than in the HTTP call, and none of them produced an error: + * + * 1. The request pinned `timezone=Asia/Seoul` for every coordinate on earth. A Berlin + * forecast came back stamped in KST (utc_offset 32400), so "today" in the output was + * a Korean day. Nothing downstream could recover the city's own day boundary. + * 2. `forecast_hours` is a rolling window from *now*, not a calendar day. Asked at 22:00 + * KST (15:00 in Berlin), the 24h window reached into the next European afternoon, and + * the max over that window — 34.4°C — was reported as "today's high". The real values + * were 29.8°C today and 36.7°C tomorrow: the answer was tomorrow's weather. + * 3. `temperature_2m_max` is a *daily* variable, but it was passed straight into + * `hourly=`. Open-Meteo accepts it there and returns an hourly series, so a plausible + * name produced plausible numbers that simply were not daily maxima. + * + * The fix for all three is to decide, before the fetch, which block each variable belongs + * to and to label every timestamp with the location's own calendar day. That decision is + * pure, so it is testable — unlike the fetch it feeds. + */ + +/** + * Open-Meteo variables that exist ONLY in the `daily=` block. + * + * `weather_code`, `temperature_2m` and friends are deliberately absent: they are valid + * hourly variables, and a few (weather_code, uv_index) exist in both blocks. Only names + * that are meaningless as an hourly series belong here — those are the ones that silently + * return wrong-shaped data when misrouted. + */ +const DAILY_ONLY_VARIABLES = new Set([ + 'temperature_2m_max', 'temperature_2m_min', 'temperature_2m_mean', + 'apparent_temperature_max', 'apparent_temperature_min', 'apparent_temperature_mean', + 'precipitation_sum', 'rain_sum', 'showers_sum', 'snowfall_sum', 'precipitation_hours', + 'precipitation_probability_max', 'precipitation_probability_min', 'precipitation_probability_mean', + 'wind_speed_10m_max', 'wind_gusts_10m_max', 'wind_direction_10m_dominant', + 'shortwave_radiation_sum', 'et0_fao_evapotranspiration', + 'uv_index_max', 'uv_index_clear_sky_max', + 'sunrise', 'sunset', 'daylight_duration', 'sunshine_duration', +]); + +export interface SplitVariables { + hourly: string[]; + daily: string[]; +} + +/** + * Route each requested variable to the Open-Meteo block that actually serves it. + * + * Duplicates are dropped and order is preserved so the output columns match what the + * caller asked for. + */ +export function splitOpenMeteoVariables(variables: string | string[]): SplitVariables { + const list = (Array.isArray(variables) ? variables : String(variables || '').split(',')) + .map(v => v.trim()) + .filter(Boolean); + + const hourly: string[] = []; + const daily: string[] = []; + for (const v of list) { + const bucket = DAILY_ONLY_VARIABLES.has(v) ? daily : hourly; + if (!bucket.includes(v)) bucket.push(v); + } + return { hourly, daily }; +} + +export function isDailyOnlyVariable(name: string): boolean { + return DAILY_ONLY_VARIABLES.has(String(name || '').trim()); +} + +/** + * Format a UTC offset in seconds as `UTC+02:00`. + * + * Printed next to the timezone name because the offset is the part that makes a + * mislabelled day obvious at a glance — a Berlin forecast reading UTC+09:00 is the exact + * bug this module exists to prevent. + */ +export function formatUtcOffset(seconds: number): string { + const s = Number(seconds); + if (!Number.isFinite(s)) return 'UTC+00:00'; + const sign = s < 0 ? '-' : '+'; + const abs = Math.abs(s); + const hh = String(Math.floor(abs / 3600)).padStart(2, '0'); + const mm = String(Math.floor((abs % 3600) / 60)).padStart(2, '0'); + return `UTC${sign}${hh}:${mm}`; +} + +/** + * Relative label for a local calendar date, as seen from the location's own "today". + * + * Returns null past 모레 rather than inventing labels — an unlabelled date is honest, + * while "3일 후" invites the same off-by-one reading the labels exist to prevent. + */ +export function relativeDayLabel(date: string, todayLocal: string): string | null { + const d = Date.parse(`${date}T00:00:00Z`); + const t = Date.parse(`${todayLocal}T00:00:00Z`); + if (!Number.isFinite(d) || !Number.isFinite(t)) return null; + const diff = Math.round((d - t) / 86_400_000); + if (diff === 0) return '오늘'; + if (diff === 1) return '내일'; + if (diff === 2) return '모레'; + if (diff === -1) return '어제'; + return null; +} + +/** + * The location's current calendar date, derived from the UTC offset Open-Meteo reports. + * + * Deliberately does not use the server's own timezone: the server sits in KST and the + * question is about the city's day, which is the whole point. + */ +export function localTodayFromOffset(utcOffsetSeconds: number, now: Date = new Date()): string { + const shifted = new Date(now.getTime() + (Number(utcOffsetSeconds) || 0) * 1000); + return shifted.toISOString().slice(0, 10); +} + +export interface OpenMeteoReportInput { + displayName: string; + timezone: string; + utcOffsetSeconds: number; + /** Rolling-hour window that was requested, for the hourly section heading. */ + hours: number; + hourlyVars: string[]; + dailyVars: string[]; + /** Raw Open-Meteo response blocks. */ + hourlyBlock?: { time?: string[]; [k: string]: any }; + dailyBlock?: { time?: string[]; [k: string]: any }; + hourlyUnits?: Record; + dailyUnits?: Record; + /** Max hourly rows to print. */ + maxHourlyRows?: number; + /** Per-variable value renderer (WMO code names, wind direction, …). */ + renderValue?: (key: string, value: any) => string; + now?: Date; +} + +/** + * Build the text report. + * + * Every timestamp is labelled with the location's local day and, where applicable, + * 오늘/내일/모레. The daily table comes first when present because that is the direct + * answer to "today's high" — leaving the model to derive it from an hourly series is + * exactly how a rolling window got reported as a calendar day. + */ +export function formatOpenMeteoReport(input: OpenMeteoReportInput): string { + const { + displayName, timezone, utcOffsetSeconds, hours, + hourlyVars, dailyVars, hourlyBlock, dailyBlock, + hourlyUnits = {}, dailyUnits = {}, maxHourlyRows = 48, + renderValue = (_k, v) => String(v), + now, + } = input; + + const todayLocal = localTodayFromOffset(utcOffsetSeconds, now); + const out: string[] = [ + `Open-Meteo 예보: ${displayName}`, + `현지 시간대: ${timezone} (${formatUtcOffset(utcOffsetSeconds)}) — 아래 모든 시각·날짜는 현지 기준`, + ]; + + const dailyTimes: string[] = dailyBlock?.time || []; + if (dailyVars.length && dailyTimes.length) { + out.push('', `[일별] ${dailyVars.length}개 변수 / ${dailyTimes.length}일`); + out.push(`단위: ${dailyVars.map(k => `${k}=${dailyUnits[k] || '-'}`).join(', ')}`); + out.push(['날짜', ...dailyVars.map(k => k.replace(/_/g, ' '))].join('\t')); + for (let i = 0; i < dailyTimes.length; i++) { + const label = relativeDayLabel(dailyTimes[i], todayLocal); + const dateCell = label ? `${dailyTimes[i]} (${label})` : dailyTimes[i]; + const vals = dailyVars.map(k => { + const v = dailyBlock?.[k]?.[i]; + return v == null ? '-' : renderValue(k, v); + }); + out.push([dateCell, ...vals].join('\t')); + } + } + + const hourlyTimes: string[] = hourlyBlock?.time || []; + if (hourlyVars.length && hourlyTimes.length) { + const shown = Math.min(hours, maxHourlyRows, hourlyTimes.length); + // Say plainly that this window starts now, so its max is never mistaken for a daily max. + out.push('', `[시간별] 지금부터 ${hours}시간 (달력상 하루가 아니라 현재 시각 기준 연속 구간)`); + out.push(`단위: ${hourlyVars.map(k => `${k}=${hourlyUnits[k] || '-'}`).join(', ')}`); + out.push(['시각', ...hourlyVars.map(k => k.replace(/_/g, ' '))].join('\t')); + let lastDate = ''; + for (let i = 0; i < shown; i++) { + const raw = hourlyTimes[i]; + const [date, clock = ''] = raw.split('T'); + let timeCell: string; + if (date !== lastDate) { + const label = relativeDayLabel(date, todayLocal); + timeCell = label ? `${date} ${clock} (${label})` : `${date} ${clock}`; + lastDate = date; + } else { + timeCell = `${date} ${clock}`; + } + const vals = hourlyVars.map(k => { + const v = hourlyBlock?.[k]?.[i]; + return v == null ? '-' : renderValue(k, v); + }); + out.push([timeCell, ...vals].join('\t')); + } + } + + return out.join('\n'); +} diff --git a/tests/geocode-retry.test.ts b/tests/geocode-retry.test.ts new file mode 100644 index 0000000..f9c078a --- /dev/null +++ b/tests/geocode-retry.test.ts @@ -0,0 +1,84 @@ +/** + * geocode-retry.test.ts + * + * Pinned to the real 2026-07-29 failure: a `weather_openmeteo({"location":"Madrid"})` + * call in the middle of a 6-city burst returned "위치를 찾을 수 없습니다: Madrid" + * while the 5 surrounding calls and 20/20 direct retries succeeded. The geocoding + * API threw a one-off transient blip; geocodeCity had no retry, so that single + * blip hardened into a permanent "city not found" and the model dropped Madrid. + * + * These tests pin the retry contract: transient failures (throw / non-2xx) get + * retried, a genuine 200-with-empty is answered immediately without wasting retries. + */ + +import { test, describe, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { geocodeCity } from '../src/tools/weather.js'; + +const REAL = (name: string) => ({ + ok: true, + status: 200, + json: async () => ({ results: [{ name, country: '스페인', country_code: 'ES', latitude: 40.4165, longitude: -3.70256, population: 3255944 }] }), +}); + +describe('geocodeCity retry', () => { + let origFetch: typeof fetch; + let calls: number; + let plan: Array<() => Promise>; + + beforeEach(() => { + origFetch = globalThis.fetch; + calls = 0; + plan = []; + globalThis.fetch = (async () => { + const step = plan[calls++] ?? plan[plan.length - 1]; + return step(); + }) as any; + }); + afterEach(() => { globalThis.fetch = origFetch; }); + + test('succeeds on first try, calls fetch once', async () => { + plan = [() => Promise.resolve(REAL('Madrid'))]; + const r = await geocodeCity('Madrid'); + assert.equal(calls, 1); + assert.equal(r?.lat, 40.4165); + assert.equal(r?.displayName, 'Madrid, 스페인'); + }); + + test('retries past a transient 5xx then succeeds', async () => { + plan = [ + () => Promise.resolve({ ok: false, status: 503, json: async () => ({}) }), + () => Promise.resolve({ ok: false, status: 502, json: async () => ({}) }), + () => Promise.resolve(REAL('Madrid')), + ]; + const r = await geocodeCity('Madrid'); + assert.equal(calls, 3, `expected 3 fetches, got ${calls}`); + assert.ok(r, 'should resolve on the third attempt'); + assert.equal(r?.lat, 40.4165); + }); + + test('retries past a network throw then succeeds', async () => { + plan = [ + () => Promise.reject(new Error('ECONNRESET')), + () => Promise.resolve(REAL('Madrid')), + ]; + const r = await geocodeCity('Madrid'); + assert.equal(calls, 2); + assert.ok(r); + assert.equal(r?.lat, 40.4165); + }); + + test('200 with no results = genuine not found, no retry', async () => { + plan = [() => Promise.resolve({ ok: true, status: 200, json: async () => ({ results: [] }) })]; + const r = await geocodeCity('zzqxnotacity'); + assert.equal(calls, 1, 'empty results must not be retried'); + assert.equal(r, null); + }); + + test('three transient failures exhaust retries and return null', async () => { + plan = [() => Promise.reject(new Error('timeout'))]; + const r = await geocodeCity('Madrid'); + assert.equal(calls, 3, `expected 3 attempts, got ${calls}`); + assert.equal(r, null); + }); +}); \ No newline at end of file diff --git a/tests/weather-openmeteo-format.test.ts b/tests/weather-openmeteo-format.test.ts new file mode 100644 index 0000000..27d7728 --- /dev/null +++ b/tests/weather-openmeteo-format.test.ts @@ -0,0 +1,267 @@ +/** + * weather-openmeteo-format.test.ts + * + * Pinned to the real 2026-07-29 failure rather than invented cases. + * + * Asked "오늘 유럽 주요 도시 최고 기온" twice, the gateway answered Berlin 28.2°C once and + * 34.4°C the next time. Open-Meteo's actual daily maxima for those coordinates were + * 29.8°C (07-29) and 36.7°C (07-30) in Europe/Berlin — so one answer was the current + * evening temperature and the other was tomorrow's peak. Neither was today's high, and + * neither call errored. + * + * Both wrong answers trace to this module's inputs: `temperature_2m_max` posted to the + * hourly block, and a rolling 24h window presented as a calendar day under Korean + * timestamps. The fixtures below are the API's own bytes for that request. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { + splitOpenMeteoVariables, + isDailyOnlyVariable, + formatUtcOffset, + relativeDayLabel, + localTodayFromOffset, + formatOpenMeteoReport, +} from '../src/tools/weather-openmeteo-format'; + +const BERLIN_OFFSET = 7200; // Europe/Berlin, UTC+2 in July + +describe('splitOpenMeteoVariables', () => { + test('temperature_2m_max를 daily로 보낸다 — hourly로 보내면 최고기온이 아닌 값이 돌아옴', () => { + const { hourly, daily } = splitOpenMeteoVariables('temperature_2m_max'); + assert.deepEqual(daily, ['temperature_2m_max']); + assert.deepEqual(hourly, []); + }); + + test('일별·시간별을 섞어 보내면 각각의 블록으로 갈린다', () => { + const { hourly, daily } = splitOpenMeteoVariables( + 'temperature_2m,temperature_2m_max,precipitation,precipitation_sum,sunset' + ); + assert.deepEqual(hourly, ['temperature_2m', 'precipitation']); + assert.deepEqual(daily, ['temperature_2m_max', 'precipitation_sum', 'sunset']); + }); + + test('기본 변수 묶음은 전부 hourly로 남는다', () => { + const { hourly, daily } = splitOpenMeteoVariables( + 'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code' + ); + assert.equal(daily.length, 0); + assert.equal(hourly.length, 7); + }); + + test('weather_code·uv_index는 양쪽에 존재하므로 hourly에 남긴다', () => { + // 이 둘을 daily로 옮기면 기본 호출의 시간별 표에서 열이 통째로 사라진다. + const { hourly, daily } = splitOpenMeteoVariables('weather_code,uv_index'); + assert.deepEqual(hourly, ['weather_code', 'uv_index']); + assert.deepEqual(daily, []); + }); + + test('공백·빈 항목·중복을 정리한다', () => { + const { hourly, daily } = splitOpenMeteoVariables(' temperature_2m , ,temperature_2m,, temperature_2m_max '); + assert.deepEqual(hourly, ['temperature_2m']); + assert.deepEqual(daily, ['temperature_2m_max']); + }); + + test('배열로 줘도 동일하게 동작한다', () => { + const { daily } = splitOpenMeteoVariables(['temperature_2m_min', 'temperature_2m']); + assert.deepEqual(daily, ['temperature_2m_min']); + }); + + test('빈 입력은 빈 결과 — 호출부가 hourly 파라미터를 아예 생략할 수 있어야 함', () => { + assert.deepEqual(splitOpenMeteoVariables(''), { hourly: [], daily: [] }); + }); + + test('isDailyOnlyVariable: 실제 오답을 만든 이름들', () => { + assert.ok(isDailyOnlyVariable('temperature_2m_max')); + assert.ok(isDailyOnlyVariable('precipitation_probability_max')); + assert.ok(!isDailyOnlyVariable('temperature_2m')); + assert.ok(!isDailyOnlyVariable('weather_code')); + }); +}); + +describe('formatUtcOffset', () => { + test('베를린 좌표가 UTC+09:00으로 찍히면 눈에 띄어야 한다 (버그의 지문)', () => { + assert.equal(formatUtcOffset(32400), 'UTC+09:00'); + assert.equal(formatUtcOffset(7200), 'UTC+02:00'); + }); + + test('음수 오프셋과 30분 단위', () => { + assert.equal(formatUtcOffset(-18000), 'UTC-05:00'); + assert.equal(formatUtcOffset(19800), 'UTC+05:30'); + assert.equal(formatUtcOffset(0), 'UTC+00:00'); + }); + + test('값이 없으면 UTC로 떨어진다', () => { + assert.equal(formatUtcOffset(NaN as any), 'UTC+00:00'); + }); +}); + +describe('localTodayFromOffset', () => { + test('현지 오프셋으로 "오늘"이 갈린다 — 서버 시간(KST)이 아니라', () => { + // 2026-07-29 22:00 KST = 2026-07-29 13:00 UTC = 2026-07-29 15:00 베를린 + const now = new Date('2026-07-29T13:00:00Z'); + assert.equal(localTodayFromOffset(BERLIN_OFFSET, now), '2026-07-29'); + assert.equal(localTodayFromOffset(32400, now), '2026-07-29'); + }); + + test('날짜 경계를 넘는 경우 서버와 현지의 오늘이 달라진다', () => { + // 2026-07-30 01:00 KST 는 베를린으로는 아직 07-29 저녁 + const now = new Date('2026-07-29T16:00:00Z'); + assert.equal(localTodayFromOffset(32400, now), '2026-07-30'); // 한국 + assert.equal(localTodayFromOffset(BERLIN_OFFSET, now), '2026-07-29'); // 베를린 + }); +}); + +describe('relativeDayLabel', () => { + test('오늘/내일/모레', () => { + assert.equal(relativeDayLabel('2026-07-29', '2026-07-29'), '오늘'); + assert.equal(relativeDayLabel('2026-07-30', '2026-07-29'), '내일'); + assert.equal(relativeDayLabel('2026-07-31', '2026-07-29'), '모레'); + assert.equal(relativeDayLabel('2026-07-28', '2026-07-29'), '어제'); + }); + + test('월 경계를 넘어도 맞는다', () => { + assert.equal(relativeDayLabel('2026-08-01', '2026-07-31'), '내일'); + }); + + test('범위 밖은 라벨 없음 — 억지 라벨보다 무라벨이 안전', () => { + assert.equal(relativeDayLabel('2026-08-05', '2026-07-29'), null); + assert.equal(relativeDayLabel('아무거나', '2026-07-29'), null); + }); +}); + +describe('formatOpenMeteoReport — 베를린 실패 사례', () => { + const now = new Date('2026-07-29T13:00:00Z'); // 질문 시각(22:00 KST / 15:00 베를린) + + test('일별 최고기온이 오늘/내일 라벨과 함께 나온다', () => { + // Open-Meteo 실제 응답: daily=temperature_2m_max, timezone=Europe/Berlin + const text = formatOpenMeteoReport({ + displayName: '베를린, 독일', + timezone: 'Europe/Berlin', + utcOffsetSeconds: BERLIN_OFFSET, + hours: 24, + hourlyVars: [], + dailyVars: ['temperature_2m_max'], + dailyBlock: { time: ['2026-07-29', '2026-07-30'], temperature_2m_max: [29.8, 36.7] }, + dailyUnits: { temperature_2m_max: '°C' }, + now, + }); + + assert.match(text, /Europe\/Berlin \(UTC\+02:00\)/); + assert.match(text, /2026-07-29 \(오늘\)\t29\.8/); + assert.match(text, /2026-07-30 \(내일\)\t36\.7/); + // 실제로 답했던 두 오답이 이 표에는 등장하지 않아야 한다 + assert.ok(!text.includes('28.2'), '오늘 저녁 기온이 최고기온으로 섞여 들어감'); + assert.ok(!text.includes('34.4'), '롤링 창 최댓값이 섞여 들어감'); + }); + + test('시간별 구간은 "달력상 하루가 아님"이 명시된다', () => { + const text = formatOpenMeteoReport({ + displayName: '베를린, 독일', + timezone: 'Europe/Berlin', + utcOffsetSeconds: BERLIN_OFFSET, + hours: 24, + hourlyVars: ['temperature_2m'], + dailyVars: [], + hourlyBlock: { + time: ['2026-07-29T15:00', '2026-07-29T23:00', '2026-07-30T12:00'], + temperature_2m: [28.3, 22.1, 34.4], + }, + hourlyUnits: { temperature_2m: '°C' }, + now, + }); + + assert.match(text, /지금부터 24시간/); + assert.match(text, /달력상 하루가 아니라/); + // 34.4가 오늘이 아니라 내일 값임이 행 라벨로 드러나야 한다 + assert.match(text, /2026-07-30 12:00 \(내일\)\t34\.4/); + }); + + test('일별과 시간별을 함께 요청하면 일별이 먼저 나온다', () => { + const text = formatOpenMeteoReport({ + displayName: '베를린, 독일', + timezone: 'Europe/Berlin', + utcOffsetSeconds: BERLIN_OFFSET, + hours: 24, + hourlyVars: ['temperature_2m'], + dailyVars: ['temperature_2m_max'], + hourlyBlock: { time: ['2026-07-29T15:00'], temperature_2m: [28.3] }, + dailyBlock: { time: ['2026-07-29'], temperature_2m_max: [29.8] }, + hourlyUnits: { temperature_2m: '°C' }, + dailyUnits: { temperature_2m_max: '°C' }, + now, + }); + assert.ok(text.indexOf('[일별]') < text.indexOf('[시간별]'), '직접적인 답이 뒤로 밀림'); + }); + + test('같은 날짜가 이어지면 라벨을 반복하지 않는다', () => { + const text = formatOpenMeteoReport({ + displayName: '베를린, 독일', + timezone: 'Europe/Berlin', + utcOffsetSeconds: BERLIN_OFFSET, + hours: 3, + hourlyVars: ['temperature_2m'], + dailyVars: [], + hourlyBlock: { + time: ['2026-07-29T15:00', '2026-07-29T16:00', '2026-07-29T17:00'], + temperature_2m: [28.3, 28.0, 27.4], + }, + hourlyUnits: { temperature_2m: '°C' }, + now, + }); + assert.equal((text.match(/\(오늘\)/g) || []).length, 1); + }); + + test('renderValue로 WMO 코드·풍향이 사람 말로 바뀐다', () => { + const text = formatOpenMeteoReport({ + displayName: '서울', + timezone: 'Asia/Seoul', + utcOffsetSeconds: 32400, + hours: 1, + hourlyVars: ['weather_code'], + dailyVars: [], + hourlyBlock: { time: ['2026-07-29T22:00'], weather_code: [61] }, + hourlyUnits: { weather_code: 'wmo code' }, + renderValue: (k, v) => (k === 'weather_code' && v === 61 ? '비(약)' : String(v)), + now, + }); + assert.match(text, /비\(약\)/); + }); + + test('결측치는 -로 채우고 행을 건너뛰지 않는다', () => { + const text = formatOpenMeteoReport({ + displayName: '베를린, 독일', + timezone: 'Europe/Berlin', + utcOffsetSeconds: BERLIN_OFFSET, + hours: 2, + hourlyVars: ['temperature_2m', 'uv_index'], + dailyVars: [], + hourlyBlock: { + time: ['2026-07-29T15:00', '2026-07-29T16:00'], + temperature_2m: [28.3, null], + uv_index: [null, 3.1], + }, + now, + }); + assert.match(text, /28\.3\t-/); + assert.match(text, /-\t3\.1/); + }); + + test('maxHourlyRows로 잘라도 헤더는 남는다', () => { + const times = Array.from({ length: 100 }, (_, i) => `2026-07-29T${String(i % 24).padStart(2, '0')}:00`); + const text = formatOpenMeteoReport({ + displayName: '베를린, 독일', + timezone: 'Europe/Berlin', + utcOffsetSeconds: BERLIN_OFFSET, + hours: 100, + hourlyVars: ['temperature_2m'], + dailyVars: [], + hourlyBlock: { time: times, temperature_2m: times.map(() => 20) }, + maxHourlyRows: 5, + now, + }); + assert.match(text, /시각\ttemperature 2m/); + assert.equal(text.split('\n').filter(l => l.startsWith('2026-')).length, 5); + }); +}); diff --git a/web-ui/html/doctor-app.html b/web-ui/html/doctor-app.html new file mode 100644 index 0000000..26bc07c --- /dev/null +++ b/web-ui/html/doctor-app.html @@ -0,0 +1,1447 @@ + + + + + +🩺 의료 기록 어시스턴트 + + + + + + + + +
+ ← 홈 +

🩺 의료 기록 어시스턴트

+ 스킬 로딩 중... +
+ +
+ +
+ + + + + + + +
+
+ +
+ + 100% + + +
+ +
+
+ +
+
+ +
+ + + + + +
+
+
🩺
+

좌측에서 기록을 선택하거나
새 기록을 만들어 증상과
진료 이력을 정리하세요.

+
+ +
+ + +
+ + +
+
+ 🩺 건강기록 AI + 일반 + + +
+
+
+ + + + + +
+
+ +
+ + + + diff --git a/web-ui/html/home-assistant-app.html b/web-ui/html/home-assistant-app.html new file mode 100644 index 0000000..ee0f4ef --- /dev/null +++ b/web-ui/html/home-assistant-app.html @@ -0,0 +1,57 @@ + + + + + +🏠 Home Assistant + + + + + + +
+ ← 홈 +

🏠 Home Assistant

+ + + ↗ 새 탭 + +
+ +
+ +
+
Home Assistant를 불러오지 못했습니다.
+ +
+
+ + + +