psnc Claude Sonnet 4.6 commited on
Commit
83ce8ff
·
1 Parent(s): 98b7874

chore: untrack .env.example and add timesfm phase1 plan doc

Browse files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

docs/superpowers/plans/2026-04-03-timesfm-phase1.md ADDED
@@ -0,0 +1,1074 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TimesFM Phase 1 Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** 기존 FastAPI 백엔드에 TimesFM 2.5 예측 모델을 추가하고 전용 UI 페이지를 제공한다.
6
+
7
+ **Architecture:** `timesfm_service.py`에서 싱글톤 패턴으로 모델을 한 번만 로드하고, `main.py`의 lifespan 이벤트로 앱 시작 시 백그라운드 로딩을 실행한다. 기존 7개 모델 코드는 일절 수정하지 않는다.
8
+
9
+ **Tech Stack:** timesfm[torch], anthropic SDK, FastAPI lifespan, Chart.js, Vanilla JS
10
+
11
+ ---
12
+
13
+ ## 파일 맵
14
+
15
+ | 작업 | 경로 | 비고 |
16
+ |------|------|------|
17
+ | 수정 | `requirements.txt` | timesfm[torch], huggingface_hub 추가 |
18
+ | 신규 | `src/domains/time_series_prediction/timesfm_service.py` | 싱글톤 모델 + 예측 + Claude 해석 |
19
+ | 수정 | `main.py` | lifespan 추가, 4개 라우트 추가, 3개 import 추가 |
20
+ | 신규 | `static/timesfm.html` | TimesFM 전용 다크 테마 UI |
21
+ | 수정 | `Dockerfile` | HF 캐시 디렉터리 설정 |
22
+
23
+ ---
24
+
25
+ ## Task 1: requirements.txt에 패키지 추가
26
+
27
+ **Files:**
28
+ - Modify: `requirements.txt`
29
+
30
+ - [ ] **Step 1: requirements.txt 하단에 추가**
31
+
32
+ 기존 내용 유지, 파일 맨 끝에 다음 블록 추가:
33
+
34
+ ```
35
+ # TimesFM 관련 추가 패키지
36
+ timesfm[torch]
37
+ huggingface_hub>=0.23.0
38
+ ```
39
+
40
+ > `anthropic>=0.25.0`은 이미 있으므로 추가하지 않는다.
41
+
42
+ - [ ] **Step 2: 커밋**
43
+
44
+ ```bash
45
+ git add requirements.txt
46
+ git commit -m "chore: add timesfm and huggingface_hub to requirements"
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Task 2: timesfm_service.py 신규 생성
52
+
53
+ **Files:**
54
+ - Create: `src/domains/time_series_prediction/timesfm_service.py`
55
+
56
+ - [ ] **Step 1: 파일 생성**
57
+
58
+ ```python
59
+ """
60
+ TimesFM 2.5 예측 서비스
61
+ 싱글톤 패턴으로 모델을 한 번만 로드하고 재사용한다.
62
+ """
63
+ import os
64
+ import logging
65
+ import threading
66
+ import math
67
+ import numpy as np
68
+ import pandas as pd
69
+
70
+ logger = logging.getLogger(__name__)
71
+
72
+ # ─── 싱글톤 모델 상태 ────────────────────────────────────────
73
+ _model = None
74
+ _model_status = "not_loaded" # "not_loaded" | "loading" | "loaded" | "error:<msg>"
75
+ _lock = threading.Lock()
76
+
77
+
78
+ def load_model():
79
+ """앱 시작 시 1회 호출. CPU 백엔드로 TimesFM 2.5 로드."""
80
+ global _model, _model_status
81
+ with _lock:
82
+ if _model_status in ("loading", "loaded"):
83
+ return
84
+ _model_status = "loading"
85
+ try:
86
+ import timesfm
87
+ model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
88
+ "google/timesfm-2.5-200m-pytorch"
89
+ )
90
+ model.compile(
91
+ timesfm.ForecastConfig(
92
+ max_context=1024,
93
+ max_horizon=256,
94
+ normalize_inputs=True,
95
+ use_continuous_quantile_head=True,
96
+ infer_is_positive=True,
97
+ fix_quantile_crossing=True,
98
+ )
99
+ )
100
+ with _lock:
101
+ _model = model
102
+ _model_status = "loaded"
103
+ logger.info("TimesFM 2.5 모델 로딩 완료")
104
+ except Exception as e:
105
+ with _lock:
106
+ _model_status = f"error: {str(e)}"
107
+ logger.error("TimesFM 모델 로딩 실패: %s", e)
108
+
109
+
110
+ def get_status() -> str:
111
+ """현재 모델 로딩 상태 반환."""
112
+ return _model_status
113
+
114
+
115
+ # ─── 주파수 인디케이터 변환 ──────────────────────────────────
116
+ _FREQ_HIGH = {"D", "H", "T", "MIN", "S"}
117
+ _FREQ_LOW = {"YS", "Y", "A", "AS"}
118
+
119
+
120
+ def get_freq_indicator(freq_str: str) -> int:
121
+ """pandas freq string → TimesFM freq indicator (0/1/2)."""
122
+ upper = freq_str.upper() if freq_str else "MS"
123
+ if upper in _FREQ_HIGH:
124
+ return 0
125
+ if upper in _FREQ_LOW:
126
+ return 2
127
+ return 1 # W, M, MS, QS, Q 등 중빈도
128
+
129
+
130
+ # ─── 메트릭 계산 ─────────────────────────────────────────────
131
+ def _calc_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
132
+ """MAE, RMSE, MAPE, R² 계산."""
133
+ mae = float(np.mean(np.abs(y_true - y_pred)))
134
+ rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
135
+
136
+ # MAPE: y_true=0인 행 제외
137
+ mask = y_true != 0
138
+ mape = float(np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask]))) if mask.any() else 0.0
139
+
140
+ ss_res = np.sum((y_true - y_pred) ** 2)
141
+ ss_tot = np.sum((y_true - np.mean(y_true)) ** 2)
142
+ r2 = float(1 - ss_res / ss_tot) if ss_tot != 0 else 0.0
143
+
144
+ return {"mae": round(mae, 4), "rmse": round(rmse, 4), "mape": round(mape, 4), "r2": round(r2, 4)}
145
+
146
+
147
+ # ─── 예측 메인 함수 ─────────────────────────────��────────────
148
+ def predict_with_timesfm(df: pd.DataFrame, params: dict) -> dict:
149
+ """
150
+ TimesFM 2.5로 시계열 예측 수행.
151
+
152
+ params:
153
+ horizon (int): 예측 스텝 수 (기본 12)
154
+ freq (str): pandas freq string (기본 "MS")
155
+ context_len (int): 사용할 최대 과거 포인트 수 (기본 512)
156
+ test_size (float): 백테스트 비율 (기본 0.2)
157
+ quantile_low (int): 하한 분위수 % (기본 10)
158
+ quantile_high (int): 상한 분위수 % (기본 90)
159
+ """
160
+ if _model_status != "loaded" or _model is None:
161
+ return {"success": False, "error": f"모델 미준비: {_model_status}"}
162
+
163
+ horizon = int(params.get("horizon", 12))
164
+ freq = str(params.get("freq", "MS"))
165
+ context_len = int(params.get("context_len", 512))
166
+ test_size = float(params.get("test_size", 0.2))
167
+ q_low_pct = int(params.get("quantile_low", 10))
168
+ q_high_pct = int(params.get("quantile_high", 90))
169
+
170
+ try:
171
+ # 1. 날짜/값 정규화
172
+ ds_col = "ds" if "ds" in df.columns else "date"
173
+ y_col = "y" if "y" in df.columns else "value"
174
+ df = df[[ds_col, y_col]].copy()
175
+ df.columns = ["ds", "y"]
176
+ df["ds"] = pd.to_datetime(df["ds"])
177
+ df = df.sort_values("ds").reset_index(drop=True)
178
+ df["y"] = pd.to_numeric(df["y"], errors="coerce")
179
+ df = df.dropna(subset=["y"])
180
+
181
+ # 2. context_len 슬라이싱
182
+ if len(df) > context_len:
183
+ df = df.iloc[-context_len:].reset_index(drop=True)
184
+
185
+ n = len(df)
186
+ test_n = max(1, int(n * test_size))
187
+ train_n = n - test_n
188
+
189
+ if train_n < 10:
190
+ return {"success": False, "error": f"학습 데이터 부족: {train_n}개 (최소 10개 필요)"}
191
+
192
+ train_values = df["y"].values[:train_n].tolist()
193
+ test_values = df["y"].values[train_n:].tolist()
194
+ freq_indicator = get_freq_indicator(freq)
195
+
196
+ # 3. test_n + horizon 스텝 예측 (test 구간 + 미래 포함)
197
+ predict_steps = test_n + horizon
198
+ point_forecast, quantile_forecast = _model.forecast(
199
+ horizon=predict_steps,
200
+ inputs=[train_values],
201
+ freq=[freq_indicator],
202
+ )
203
+ # point_forecast: (1, predict_steps)
204
+ # quantile_forecast: (1, predict_steps, 10) → 0=10%, 4=50%, 8=90%
205
+ pf = point_forecast[0] # (predict_steps,)
206
+ qf = quantile_forecast[0] # (predict_steps, 10)
207
+
208
+ # 4. 분위수 인덱스 계산 (10개 분위: 10,20,...,90,100)
209
+ q_low_idx = max(0, min(9, (q_low_pct // 10) - 1))
210
+ q_high_idx = max(0, min(9, (q_high_pct // 10) - 1))
211
+
212
+ # 5. test 구간 metrics 계산
213
+ y_true = np.array(test_values)
214
+ y_pred = pf[:test_n]
215
+ metrics = _calc_metrics(y_true, y_pred)
216
+
217
+ # 6. 결과 데이터 조립
218
+ # - 학습 구간: 실제값, yhat=None (차트에서 과거 실제값으로 표시)
219
+ # - test 구간: 실제값 + 예측값
220
+ # - 미래 구간: 실제값=None + 예측값
221
+ data = []
222
+
223
+ # 학습 구간
224
+ for i in range(train_n):
225
+ row = df.iloc[i]
226
+ ds_str = row["ds"].strftime("%Y-%m-%d") if hasattr(row["ds"], "strftime") else str(row["ds"])
227
+ data.append({
228
+ "ds": ds_str,
229
+ "y": float(row["y"]),
230
+ "yhat": None,
231
+ "yhat_lower": None,
232
+ "yhat_upper": None,
233
+ "is_future": False,
234
+ })
235
+
236
+ # test 구간
237
+ for i in range(test_n):
238
+ row = df.iloc[train_n + i]
239
+ ds_str = row["ds"].strftime("%Y-%m-%d") if hasattr(row["ds"], "strftime") else str(row["ds"])
240
+ data.append({
241
+ "ds": ds_str,
242
+ "y": float(row["y"]),
243
+ "yhat": round(float(pf[i]), 4),
244
+ "yhat_lower": round(float(qf[i][q_low_idx]), 4),
245
+ "yhat_upper": round(float(qf[i][q_high_idx]), 4),
246
+ "is_future": False,
247
+ })
248
+
249
+ # 미래 구간 — 마지막 날짜 기준으로 freq offset 생성
250
+ last_date = df["ds"].iloc[-1]
251
+ try:
252
+ future_dates = pd.date_range(start=last_date, periods=horizon + 1, freq=freq)[1:]
253
+ except Exception:
254
+ future_dates = pd.date_range(start=last_date, periods=horizon + 1, freq="MS")[1:]
255
+
256
+ for i in range(horizon):
257
+ ds_str = future_dates[i].strftime("%Y-%m-%d")
258
+ fi = test_n + i
259
+ data.append({
260
+ "ds": ds_str,
261
+ "y": None,
262
+ "yhat": round(float(pf[fi]), 4),
263
+ "yhat_lower": round(float(qf[fi][q_low_idx]), 4),
264
+ "yhat_upper": round(float(qf[fi][q_high_idx]), 4),
265
+ "is_future": True,
266
+ })
267
+
268
+ return {
269
+ "success": True,
270
+ "data": data,
271
+ "metrics": metrics,
272
+ "model_version": "timesfm-2.5-200m",
273
+ "test_size": test_n,
274
+ }
275
+
276
+ except Exception as e:
277
+ logger.exception("TimesFM 예측 오류")
278
+ return {"success": False, "error": str(e)}
279
+
280
+
281
+ # ─── Claude 해석 ─────────────────────────────────────────────
282
+ def interpret_forecast(forecast_summary: dict, domain_hint: str = "기타") -> dict:
283
+ """
284
+ Claude claude-sonnet-4-6 API로 예측 결과 한국어 해석 생성.
285
+ ANTHROPIC_API_KEY 환경변수 필요.
286
+ """
287
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
288
+ if not api_key:
289
+ return {"error": "ANTHROPIC_API_KEY 환경변수가 설정되지 않았습니다."}
290
+
291
+ try:
292
+ import anthropic
293
+ client = anthropic.Anthropic(api_key=api_key)
294
+ message = client.messages.create(
295
+ model="claude-sonnet-4-6",
296
+ max_tokens=512,
297
+ system=(
298
+ "당신은 공공데이터 시계열 분석 전문가입니다.\n"
299
+ "시계열 예측 결과를 받아 다음 내용을 한국어로 작성하세요.\n"
300
+ "1. 예측 신뢰도 평가 (MAPE 기준: <5% 매우 높음, 5-15% 높음, 15-30% 보통, >30% 낮음)\n"
301
+ "2. 핵심 트렌드 요약 (2-3문장)\n"
302
+ "3. 활용 제안 또는 주의사항 (1-2문장)\n\n"
303
+ 'JSON만 반환하세요 (마크다운 없이):\n'
304
+ '{"reliability": "매우 높음", "reliability_reason": "...", "summary": "...", "notes": "..."}'
305
+ ),
306
+ messages=[
307
+ {
308
+ "role": "user",
309
+ "content": f"도메인: {domain_hint}\n예측 요약: {forecast_summary}",
310
+ }
311
+ ],
312
+ )
313
+ import json
314
+ text = message.content[0].text
315
+ return json.loads(text)
316
+ except Exception as e:
317
+ logger.error("Claude 해석 오류: %s", e)
318
+ return {"error": str(e)}
319
+ ```
320
+
321
+ - [ ] **Step 2: 커밋**
322
+
323
+ ```bash
324
+ git add src/domains/time_series_prediction/timesfm_service.py
325
+ git commit -m "feat: add timesfm_service.py with singleton model loading"
326
+ ```
327
+
328
+ ---
329
+
330
+ ## Task 3: main.py — lifespan 및 라우트 추가
331
+
332
+ **Files:**
333
+ - Modify: `main.py` (line 29, 96-100, 끝부분)
334
+
335
+ **세 곳을 수정한다:**
336
+
337
+ ### 3-A. import 추가 (line 29 수정)
338
+
339
+ - [ ] **Step 1: fastapi import 라인에 Request 추가 (line 29)**
340
+
341
+ 기존:
342
+ ```python
343
+ from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
344
+ ```
345
+ 변경 후:
346
+ ```python
347
+ from contextlib import asynccontextmanager
348
+ import asyncio
349
+
350
+ from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
351
+ ```
352
+
353
+ ### 3-B. app 선언 전에 lifespan 함수 추가, app 선언 수정
354
+
355
+ - [ ] **Step 2: app = FastAPI(...) 블록 교체 (line 96-100)**
356
+
357
+ 기존:
358
+ ```python
359
+ app = FastAPI(
360
+ title="시계열 예측 API",
361
+ description="N-BEATS, Prophet, ARIMA/SARIMA, XGBoost, ETS, LightGBM, Theta 기반 시계열 예측 서비스",
362
+ version="3.0.0",
363
+ )
364
+ ```
365
+
366
+ 변경 후:
367
+ ```python
368
+ @asynccontextmanager
369
+ async def lifespan(app: FastAPI):
370
+ """앱 시작 시 TimesFM 모델을 백그라운드 스레드에서 로드"""
371
+ from src.domains.time_series_prediction import timesfm_service
372
+ loop = asyncio.get_event_loop()
373
+ loop.run_in_executor(None, timesfm_service.load_model)
374
+ yield
375
+
376
+
377
+ app = FastAPI(
378
+ title="시계열 예측 API",
379
+ description="N-BEATS, Prophet, ARIMA/SARIMA, XGBoost, ETS, LightGBM, Theta 기반 시계열 예측 서비스",
380
+ version="3.0.0",
381
+ lifespan=lifespan,
382
+ )
383
+ ```
384
+
385
+ ### 3-C. 파일 끝에 TimesFM 라우트 추가
386
+
387
+ - [ ] **Step 3: main.py 맨 끝(line 886~)에 아래 코드 추가**
388
+
389
+ ```python
390
+
391
+ # ─── TimesFM 라우트 ──────────────────────────────────────────
392
+
393
+ @app.get("/timesfm")
394
+ async def timesfm_page():
395
+ """TimesFM 2.5 예측 페이지"""
396
+ return _serve_html("timesfm.html")
397
+
398
+
399
+ @app.get("/api/timesfm/status")
400
+ async def timesfm_status():
401
+ """TimesFM 모델 로딩 상태 확인"""
402
+ from src.domains.time_series_prediction import timesfm_service
403
+ return {"status": timesfm_service.get_status()}
404
+
405
+
406
+ @app.post("/api/timesfm/predict")
407
+ async def timesfm_predict(
408
+ file: UploadFile = File(...),
409
+ params: str = Form("{}"),
410
+ ):
411
+ """TimesFM 2.5로 시계열 예측"""
412
+ from src.domains.time_series_prediction import timesfm_service
413
+ try:
414
+ content = await file.read()
415
+ df, error = process_uploaded_file(content, file.filename or "data.csv")
416
+ if error:
417
+ raise HTTPException(status_code=400, detail=error)
418
+ is_valid, validation_error = validate_data(df)
419
+ if not is_valid:
420
+ raise HTTPException(status_code=400, detail=validation_error)
421
+ params_dict = json.loads(params) if params.strip() else {}
422
+ result = timesfm_service.predict_with_timesfm(df, params_dict)
423
+ if not result.get("success"):
424
+ raise HTTPException(status_code=422, detail=result.get("error", "예측 실패"))
425
+ return result
426
+ except HTTPException:
427
+ raise
428
+ except Exception as e:
429
+ logger.exception("TimesFM 예측 오류")
430
+ raise HTTPException(status_code=500, detail=str(e))
431
+
432
+
433
+ @app.post("/api/timesfm/interpret")
434
+ async def timesfm_interpret(request: Request):
435
+ """Claude API로 TimesFM 예측 결과 한국어 해석"""
436
+ from src.domains.time_series_prediction import timesfm_service
437
+ try:
438
+ body = await request.json()
439
+ result = timesfm_service.interpret_forecast(
440
+ body.get("forecast_summary"),
441
+ body.get("domain_hint", "기타"),
442
+ )
443
+ return result
444
+ except Exception as e:
445
+ logger.exception("TimesFM 해석 오류")
446
+ raise HTTPException(status_code=500, detail=str(e))
447
+ ```
448
+
449
+ - [ ] **Step 4: 서버 기동 확인**
450
+
451
+ ```bash
452
+ cd /Users/pjkk/Developer/Projects/nbeats
453
+ uvicorn main:app --port 8000 --reload
454
+ ```
455
+
456
+ 예상 출력 (timesfm 패키지 미설치 환경에서는 load_model이 error 상태로 전환):
457
+ ```
458
+ INFO: Application startup complete.
459
+ ```
460
+ `http://localhost:8000/api/timesfm/status` 응답: `{"status": "loading"}` 또는 `{"status": "error: ..."}`
461
+
462
+ - [ ] **Step 5: 커밋**
463
+
464
+ ```bash
465
+ git add main.py
466
+ git commit -m "feat: add TimesFM lifespan loader and API routes to main.py"
467
+ ```
468
+
469
+ ---
470
+
471
+ ## Task 4: static/timesfm.html 신규 생성
472
+
473
+ **Files:**
474
+ - Create: `static/timesfm.html`
475
+
476
+ - [ ] **Step 1: 파일 생성**
477
+
478
+ 아래 전체 내용을 `static/timesfm.html`로 저장한다. 기존 index.html의 CSS 변수·네비·카드 구조를 그대로 사용한다.
479
+
480
+ ```html
481
+ <!DOCTYPE html>
482
+ <html lang="ko">
483
+ <head>
484
+ <meta charset="UTF-8">
485
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
486
+ <title>TimesFM 2.5 - 시계열 예측</title>
487
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔮</text></svg>">
488
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
489
+ <script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
490
+ <link rel="preconnect" href="https://fonts.googleapis.com">
491
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
492
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
493
+ <style>
494
+ :root {
495
+ --bg: #0f0f12;
496
+ --surface: #1a1a1f;
497
+ --surface-hover: #222228;
498
+ --border: #2a2a32;
499
+ --text: #e8e8ed;
500
+ --text-muted: #8b8b96;
501
+ --accent: #6366f1;
502
+ --accent-hover: #818cf8;
503
+ --success: #22c55e;
504
+ --error: #ef4444;
505
+ --warning: #f59e0b;
506
+ --sky: #38bdf8;
507
+ --radius: 12px;
508
+ }
509
+ * { box-sizing: border-box; margin: 0; padding: 0; }
510
+ body { font-family: 'Outfit', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; line-height: 1.6; }
511
+
512
+ /* 네비게이션 */
513
+ .top-nav { background: var(--surface); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 100; }
514
+ .nav-inner { max-width: 1060px; margin: 0 auto; padding: 0 2rem; display: flex; align-items: center; gap: 2rem; height: 56px; overflow-x: auto; }
515
+ .nav-brand { font-weight: 700; font-size: 1rem; color: var(--text); text-decoration: none; letter-spacing: -0.01em; white-space: nowrap; }
516
+ .nav-links { display: flex; gap: 0.25rem; list-style: none; }
517
+ .nav-links a { display: flex; align-items: center; gap: 0.4rem; padding: 0.5rem 0.75rem; border-radius: 8px; text-decoration: none; font-size: 0.82rem; font-weight: 500; white-space: nowrap; color: var(--text-muted); transition: all 0.15s; }
518
+ .nav-links a:hover { color: var(--text); background: var(--surface-hover); }
519
+ .nav-links a.active { color: var(--sky); background: rgba(56, 189, 248, 0.1); }
520
+ .model-tag { font-size: 0.6rem; padding: 0.1rem 0.35rem; border-radius: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; }
521
+ .tag-dl { background: rgba(99,102,241,0.15); color: #a5b4fc; }
522
+ .tag-stat { background: rgba(34,197,94,0.15); color: #86efac; }
523
+ .tag-ml { background: rgba(245,158,11,0.15); color: #fcd34d; }
524
+ .tag-ets { background: rgba(16,185,129,0.15); color: #6ee7b7; }
525
+ .tag-theta { background: rgba(168,85,247,0.15); color: #c4b5fd; }
526
+ .tag-fm { background: rgba(56,189,248,0.15); color: #7dd3fc; }
527
+
528
+ /* 레이아웃 */
529
+ .container { max-width: 1060px; margin: 0 auto; padding: 2rem; }
530
+ header { margin-bottom: 2rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--border); display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; }
531
+ h1 { font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em; background: linear-gradient(135deg, #fff 0%, #7dd3fc 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
532
+ .subtitle { margin-top: 0.25rem; font-size: 0.875rem; color: var(--text-muted); }
533
+
534
+ /* 상태 뱃지 */
535
+ .status-badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.35rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; border: 1px solid; white-space: nowrap; }
536
+ .badge-loading { background: rgba(99,102,241,0.1); color: var(--accent); border-color: rgba(99,102,241,0.3); }
537
+ .badge-loaded { background: rgba(34,197,94,0.1); color: var(--success); border-color: rgba(34,197,94,0.3); }
538
+ .badge-error { background: rgba(239,68,68,0.1); color: var(--error); border-color: rgba(239,68,68,0.3); }
539
+ .badge-not_loaded { background: rgba(139,139,150,0.1); color: var(--text-muted); border-color: var(--border); }
540
+
541
+ /* 카드 */
542
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; margin-bottom: 1.25rem; }
543
+ .card-title { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 1rem; }
544
+
545
+ /* 업로드 */
546
+ .upload-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 1.75rem; text-align: center; cursor: pointer; transition: all 0.2s; background: rgba(56,189,248,0.03); }
547
+ .upload-zone:hover, .upload-zone.dragover { border-color: var(--sky); background: rgba(56,189,248,0.08); }
548
+ .upload-zone input { display: none; }
549
+ .upload-zone .icon { font-size: 2.25rem; margin-bottom: 0.35rem; opacity: 0.7; }
550
+ .upload-zone p { color: var(--text-muted); font-size: 0.875rem; }
551
+ .upload-zone .file-name { margin-top: 0.4rem; font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: var(--sky); }
552
+
553
+ /* 옵션 행 */
554
+ .options-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; align-items: flex-end; }
555
+ .opt-group { display: flex; flex-direction: column; gap: 0.3rem; }
556
+ .opt-group label { font-size: 0.75rem; color: var(--text-muted); font-weight: 500; }
557
+ .opt-group input[type="number"],
558
+ .opt-group select { padding: 0.5rem 0.7rem; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--text); font-size: 0.9rem; font-family: inherit; min-width: 110px; }
559
+ .opt-group input[type="number"]:focus,
560
+ .opt-group select:focus { outline: none; border-color: var(--sky); }
561
+
562
+ /* 버튼 */
563
+ .actions { display: flex; gap: 0.75rem; margin-top: 1rem; flex-wrap: wrap; }
564
+ .btn { padding: 0.6rem 1.2rem; border: none; border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: all 0.2s; font-family: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: 0.3rem; }
565
+ .btn-primary { background: var(--sky); color: #0f172a; }
566
+ .btn-primary:hover:not(:disabled) { background: #7dd3fc; transform: translateY(-1px); }
567
+ .btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
568
+ .btn-secondary { background: var(--surface-hover); color: var(--text); border: 1px solid var(--border); }
569
+ .btn-secondary:hover { background: var(--border); }
570
+ .btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
571
+
572
+ /* 상태 메시지 */
573
+ .status-msg { margin-top: 1rem; padding: 0.7rem 1rem; border-radius: 8px; font-size: 0.875rem; display: none; }
574
+ .status-msg.error { display: block; background: rgba(239,68,68,0.12); color: var(--error); border: 1px solid rgba(239,68,68,0.25); }
575
+ .status-msg.loading { display: block; background: rgba(56,189,248,0.08); color: var(--sky); border: 1px solid rgba(56,189,248,0.2); }
576
+ .status-msg.success { display: block; background: rgba(34,197,94,0.08); color: var(--success); border: 1px solid rgba(34,197,94,0.2); }
577
+
578
+ /* 결과 섹션 */
579
+ .results-section { margin-top: 1.5rem; display: none; }
580
+ .results-section.visible { display: block; }
581
+
582
+ /* 메트릭 */
583
+ .metrics-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.6rem; }
584
+ @media (max-width: 600px) { .metrics-grid { grid-template-columns: repeat(2, 1fr); } }
585
+ .metric-card { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 0.9rem 1rem; }
586
+ .metric-label { font-size: 0.7rem; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
587
+ .metric-value { font-family: 'JetBrains Mono', monospace; font-size: 1.3rem; font-weight: 700; margin-top: 0.2rem; color: var(--sky); }
588
+
589
+ /* 차트 */
590
+ .chart-container { height: 340px; }
591
+
592
+ /* AI 해석 */
593
+ .ai-section { margin-top: 1.25rem; }
594
+ .ai-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; }
595
+ .reliability-badge { padding: 0.25rem 0.7rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; }
596
+ .rel-very-high { background: rgba(34,197,94,0.15); color: #4ade80; }
597
+ .rel-high { background: rgba(56,189,248,0.15); color: #7dd3fc; }
598
+ .rel-medium { background: rgba(245,158,11,0.15); color: #fcd34d; }
599
+ .rel-low { background: rgba(239,68,68,0.15); color: #f87171; }
600
+ .ai-text { font-size: 0.9rem; color: var(--text); line-height: 1.7; margin-bottom: 0.5rem; }
601
+ .ai-notes { font-size: 0.85rem; color: var(--text-muted); font-style: italic; }
602
+ .ai-loading { color: var(--text-muted); font-size: 0.875rem; display: flex; align-items: center; gap: 0.5rem; }
603
+
604
+ /* 스피너 */
605
+ .spinner { display: inline-block; width: 1em; height: 1em; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; vertical-align: -0.15em; margin-right: 0.4em; }
606
+ @keyframes spin { to { transform: rotate(360deg); } }
607
+ </style>
608
+ </head>
609
+ <body>
610
+ <nav class="top-nav">
611
+ <div class="nav-inner">
612
+ <a href="/" class="nav-brand">시계열 예측</a>
613
+ <ul class="nav-links">
614
+ <li><a href="/">N-BEATS <span class="model-tag tag-dl">DL</span></a></li>
615
+ <li><a href="/prophet">Prophet <span class="model-tag tag-stat">Stat</span></a></li>
616
+ <li><a href="/xgboost">XGBoost <span class="model-tag tag-ml">ML</span></a></li>
617
+ <li><a href="/lightgbm">LightGBM <span class="model-tag tag-ml">ML</span></a></li>
618
+ <li><a href="/arima">ARIMA <span class="model-tag tag-stat">Classical</span></a></li>
619
+ <li><a href="/theta">Theta <span class="model-tag tag-theta">Classical</span></a></li>
620
+ <li><a href="/ets">ETS <span class="model-tag tag-ets">Smoothing</span></a></li>
621
+ <li><a href="/hybrid">Hybrid <span class="model-tag" style="background:rgba(236,72,153,0.15);color:#f472b6;">LLM</span></a></li>
622
+ <li><a href="/timesfm" class="active">TimesFM <span class="model-tag tag-fm">FM</span></a></li>
623
+ </ul>
624
+ </div>
625
+ </nav>
626
+
627
+ <div class="container">
628
+ <header>
629
+ <div>
630
+ <h1>TimesFM 2.5 시계열 예측</h1>
631
+ <p class="subtitle">Google Research Foundation Model · Zero-Shot · 200M params · 신뢰구간 지원</p>
632
+ </div>
633
+ <div id="modelStatusBadge" class="status-badge badge-not_loaded">● 모델 미로딩</div>
634
+ </header>
635
+
636
+ <!-- 업로드 + 옵션 -->
637
+ <section class="card">
638
+ <div class="card-title">데이터 업로드 및 옵션</div>
639
+ <div class="upload-zone" id="uploadZone">
640
+ <input type="file" id="fileInput" accept=".csv,.xlsx,.xls,.json">
641
+ <div class="icon">📁</div>
642
+ <p>CSV, Excel, JSON 파일을 드래그하거나 클릭하여 선택</p>
643
+ <p class="file-name" id="fileName"></p>
644
+ </div>
645
+
646
+ <div class="options-row">
647
+ <div class="opt-group">
648
+ <label for="domainSelect">도메인</label>
649
+ <select id="domainSelect">
650
+ <option value="교통">교통</option>
651
+ <option value="재정">재정</option>
652
+ <option value="인구">인구</option>
653
+ <option value="기타" selected>기타</option>
654
+ </select>
655
+ </div>
656
+ <div class="opt-group">
657
+ <label for="horizonInput">예측 기간 (스텝)</label>
658
+ <input type="number" id="horizonInput" value="12" min="1" max="256">
659
+ </div>
660
+ <div class="opt-group">
661
+ <label for="freqSelect">데이터 주기</label>
662
+ <select id="freqSelect">
663
+ <option value="D">일별 (D)</option>
664
+ <option value="W">주별 (W)</option>
665
+ <option value="MS" selected>월별 (MS)</option>
666
+ <option value="QS">분기별 (QS)</option>
667
+ <option value="YS">연별 (YS)</option>
668
+ </select>
669
+ </div>
670
+ <div class="opt-group">
671
+ <label for="ciSelect">신뢰구간</label>
672
+ <select id="ciSelect">
673
+ <option value="80">80%</option>
674
+ <option value="90" selected>90%</option>
675
+ <option value="95">95%</option>
676
+ </select>
677
+ </div>
678
+ </div>
679
+
680
+ <div class="actions">
681
+ <button class="btn btn-primary" id="predictBtn" disabled>🔮 예측 실행</button>
682
+ <button class="btn btn-secondary" id="resetBtn">초기화</button>
683
+ <button class="btn btn-secondary" id="exportCsvBtn" disabled>CSV 다운로드</button>
684
+ <button class="btn btn-secondary" id="exportXlsxBtn" disabled>Excel 다운로드</button>
685
+ </div>
686
+
687
+ <div class="status-msg" id="statusMsg"></div>
688
+ </section>
689
+
690
+ <!-- 결과 -->
691
+ <section class="results-section" id="resultsSection">
692
+ <!-- 메트릭 -->
693
+ <div class="card">
694
+ <div class="card-title">예측 성능 지표 (백테스트)</div>
695
+ <div class="metrics-grid">
696
+ <div class="metric-card"><div class="metric-label">MAE</div><div class="metric-value" id="metMAE">—</div></div>
697
+ <div class="metric-card"><div class="metric-label">RMSE</div><div class="metric-value" id="metRMSE">—</div></div>
698
+ <div class="metric-card"><div class="metric-label">MAPE</div><div class="metric-value" id="metMAPE">—</div></div>
699
+ <div class="metric-card"><div class="metric-label">R²</div><div class="metric-value" id="metR2">—</div></div>
700
+ </div>
701
+ </div>
702
+
703
+ <!-- 차트 -->
704
+ <div class="card">
705
+ <div class="card-title">예측 결과 차트</div>
706
+ <div class="chart-container">
707
+ <canvas id="forecastChart"></canvas>
708
+ </div>
709
+ </div>
710
+
711
+ <!-- AI 해석 -->
712
+ <div class="card ai-section">
713
+ <div class="card-title">AI 해석 (Claude claude-sonnet-4-6)</div>
714
+ <div id="aiContent">
715
+ <div class="ai-loading"><span class="spinner"></span> 해석 생성 중...</div>
716
+ </div>
717
+ </div>
718
+ </section>
719
+ </div>
720
+
721
+ <script>
722
+ (() => {
723
+ // ── 상태 폴링 ─────────────────────────────────────────
724
+ let modelReady = false;
725
+ let forecastData = null;
726
+ let chartInstance = null;
727
+
728
+ function updateStatusBadge(status) {
729
+ const badge = document.getElementById('modelStatusBadge');
730
+ badge.className = 'status-badge';
731
+ if (status === 'loaded') {
732
+ badge.classList.add('badge-loaded');
733
+ badge.textContent = '● 모델 준비됨';
734
+ modelReady = true;
735
+ const f = document.getElementById('fileInput').files[0];
736
+ document.getElementById('predictBtn').disabled = !f;
737
+ } else if (status === 'loading') {
738
+ badge.classList.add('badge-loading');
739
+ badge.innerHTML = '<span class="spinner"></span> 모델 로딩 중...';
740
+ modelReady = false;
741
+ document.getElementById('predictBtn').disabled = true;
742
+ } else if (status && status.startsWith('error')) {
743
+ badge.classList.add('badge-error');
744
+ badge.textContent = '● 로딩 오류';
745
+ modelReady = false;
746
+ } else {
747
+ badge.classList.add('badge-not_loaded');
748
+ badge.textContent = '● 모델 미로딩';
749
+ }
750
+ }
751
+
752
+ async function pollStatus() {
753
+ try {
754
+ const res = await fetch('/api/timesfm/status');
755
+ const { status } = await res.json();
756
+ updateStatusBadge(status);
757
+ if (status !== 'loaded') setTimeout(pollStatus, 2000);
758
+ } catch { setTimeout(pollStatus, 3000); }
759
+ }
760
+ pollStatus();
761
+
762
+ // ── 파일 업로드 ───────────────────────────────────────
763
+ const uploadZone = document.getElementById('uploadZone');
764
+ const fileInput = document.getElementById('fileInput');
765
+ const fileName = document.getElementById('fileName');
766
+
767
+ uploadZone.addEventListener('click', () => fileInput.click());
768
+ uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
769
+ uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
770
+ uploadZone.addEventListener('drop', e => {
771
+ e.preventDefault(); uploadZone.classList.remove('dragover');
772
+ if (e.dataTransfer.files[0]) { fileInput.files = e.dataTransfer.files; onFileChange(); }
773
+ });
774
+ fileInput.addEventListener('change', onFileChange);
775
+
776
+ function onFileChange() {
777
+ const f = fileInput.files[0];
778
+ if (f) {
779
+ fileName.textContent = f.name;
780
+ document.getElementById('predictBtn').disabled = !modelReady;
781
+ }
782
+ }
783
+
784
+ // ── 예측 실행 ─────────────────────────────────────────
785
+ document.getElementById('predictBtn').addEventListener('click', async () => {
786
+ const f = fileInput.files[0];
787
+ if (!f) return;
788
+
789
+ const horizon = parseInt(document.getElementById('horizonInput').value) || 12;
790
+ const freq = document.getElementById('freqSelect').value;
791
+ const ci = parseInt(document.getElementById('ciSelect').value);
792
+ const domain = document.getElementById('domainSelect').value;
793
+
794
+ const ciLow = 100 - ci;
795
+ const ciHigh = ci;
796
+
797
+ setStatus('loading', '예측 중...');
798
+ document.getElementById('predictBtn').disabled = true;
799
+
800
+ const fd = new FormData();
801
+ fd.append('file', f);
802
+ fd.append('params', JSON.stringify({ horizon, freq, quantile_low: ciLow, quantile_high: ciHigh }));
803
+
804
+ try {
805
+ const res = await fetch('/api/timesfm/predict', { method: 'POST', body: fd });
806
+ const result = await res.json();
807
+ if (!res.ok || !result.success) {
808
+ setStatus('error', result.detail || result.error || '예측 실패');
809
+ return;
810
+ }
811
+ forecastData = result;
812
+ renderResults(result, domain);
813
+ setStatus('success', `예측 완료 · 모델: ${result.model_version} · 백테스트: ${result.test_size}개`);
814
+ } catch (e) {
815
+ setStatus('error', `네트워크 오류: ${e.message}`);
816
+ } finally {
817
+ document.getElementById('predictBtn').disabled = false;
818
+ }
819
+ });
820
+
821
+ // ── 결과 렌더링 ───────────────────────────────────────
822
+ function renderResults(result, domain) {
823
+ // 메트릭
824
+ const m = result.metrics;
825
+ document.getElementById('metMAE').textContent = m.mae != null ? m.mae.toFixed(3) : '—';
826
+ document.getElementById('metRMSE').textContent = m.rmse != null ? m.rmse.toFixed(3) : '—';
827
+ document.getElementById('metMAPE').textContent = m.mape != null ? (m.mape * 100).toFixed(1) + '%' : '—';
828
+ document.getElementById('metR2').textContent = m.r2 != null ? m.r2.toFixed(3) : '—';
829
+
830
+ // 차트
831
+ renderChart(result.data);
832
+
833
+ // 결과 섹션 표시
834
+ const sec = document.getElementById('resultsSection');
835
+ sec.classList.add('visible');
836
+
837
+ // 다운로드 버튼 활성화
838
+ document.getElementById('exportCsvBtn').disabled = false;
839
+ document.getElementById('exportXlsxBtn').disabled = false;
840
+
841
+ // AI 해석
842
+ document.getElementById('aiContent').innerHTML =
843
+ '<div class="ai-loading"><span class="spinner"></span> 해석 생성 중...</div>';
844
+ fetchInterpretation(m, domain);
845
+ }
846
+
847
+ function renderChart(data) {
848
+ const labels = data.map(d => d.ds);
849
+ const actuals = data.map(d => d.y);
850
+ const predicted = data.map(d => d.yhat);
851
+ const lowers = data.map(d => d.yhat_lower);
852
+ const uppers = data.map(d => d.yhat_upper);
853
+
854
+ // 신뢰구간 fill: lower → upper
855
+ const ciUpper = data.map((d, i) => d.yhat_upper);
856
+ const ciLower = data.map((d, i) => d.yhat_lower);
857
+
858
+ if (chartInstance) chartInstance.destroy();
859
+ const ctx = document.getElementById('forecastChart').getContext('2d');
860
+ chartInstance = new Chart(ctx, {
861
+ type: 'line',
862
+ data: {
863
+ labels,
864
+ datasets: [
865
+ {
866
+ label: '신뢰구간 상한',
867
+ data: ciUpper,
868
+ borderColor: 'transparent',
869
+ backgroundColor: 'rgba(56,189,248,0.12)',
870
+ pointRadius: 0,
871
+ fill: '+1',
872
+ tension: 0.3,
873
+ },
874
+ {
875
+ label: '신뢰구간 하한',
876
+ data: ciLower,
877
+ borderColor: 'transparent',
878
+ backgroundColor: 'rgba(56,189,248,0.12)',
879
+ pointRadius: 0,
880
+ fill: false,
881
+ tension: 0.3,
882
+ },
883
+ {
884
+ label: '실제값',
885
+ data: actuals,
886
+ borderColor: '#e8e8ed',
887
+ backgroundColor: 'transparent',
888
+ pointRadius: 2,
889
+ borderWidth: 2,
890
+ tension: 0.3,
891
+ spanGaps: false,
892
+ },
893
+ {
894
+ label: '예측값',
895
+ data: predicted,
896
+ borderColor: '#38bdf8',
897
+ backgroundColor: 'transparent',
898
+ pointRadius: 2,
899
+ borderWidth: 2,
900
+ borderDash: [],
901
+ tension: 0.3,
902
+ spanGaps: false,
903
+ },
904
+ ],
905
+ },
906
+ options: {
907
+ responsive: true,
908
+ maintainAspectRatio: false,
909
+ interaction: { mode: 'index', intersect: false },
910
+ plugins: {
911
+ legend: { labels: { color: '#8b8b96', font: { size: 12 } } },
912
+ tooltip: { backgroundColor: '#1a1a1f', borderColor: '#2a2a32', borderWidth: 1, titleColor: '#e8e8ed', bodyColor: '#e8e8ed' },
913
+ },
914
+ scales: {
915
+ x: { ticks: { color: '#8b8b96', maxTicksLimit: 12 }, grid: { color: 'rgba(255,255,255,0.04)' } },
916
+ y: { ticks: { color: '#8b8b96' }, grid: { color: 'rgba(255,255,255,0.04)' } },
917
+ },
918
+ },
919
+ });
920
+ }
921
+
922
+ // ── Claude 해석 ───────────────────────────────────────
923
+ async function fetchInterpretation(metrics, domain) {
924
+ try {
925
+ const res = await fetch('/api/timesfm/interpret', {
926
+ method: 'POST',
927
+ headers: { 'Content-Type': 'application/json' },
928
+ body: JSON.stringify({ forecast_summary: metrics, domain_hint: domain }),
929
+ });
930
+ const data = await res.json();
931
+ if (data.error) { renderAiError(data.error); return; }
932
+ renderAiResult(data);
933
+ } catch (e) { renderAiError(e.message); }
934
+ }
935
+
936
+ function reliabilityClass(r) {
937
+ if (r === '매우 높음') return 'rel-very-high';
938
+ if (r === '높음') return 'rel-high';
939
+ if (r === '보통') return 'rel-medium';
940
+ return 'rel-low';
941
+ }
942
+
943
+ function renderAiResult(d) {
944
+ document.getElementById('aiContent').innerHTML = `
945
+ <div class="ai-header">
946
+ <span class="reliability-badge ${reliabilityClass(d.reliability)}">${d.reliability}</span>
947
+ <span style="color:var(--text-muted);font-size:0.85rem;">${d.reliability_reason || ''}</span>
948
+ </div>
949
+ <p class="ai-text">${d.summary || ''}</p>
950
+ <p class="ai-notes">${d.notes || ''}</p>
951
+ `;
952
+ }
953
+
954
+ function renderAiError(msg) {
955
+ document.getElementById('aiContent').innerHTML =
956
+ `<p style="color:var(--text-muted);font-size:0.875rem;">해석 불가: ${msg}</p>`;
957
+ }
958
+
959
+ // ── 초기화 ────────────────────────────────────────────
960
+ document.getElementById('resetBtn').addEventListener('click', () => {
961
+ fileInput.value = '';
962
+ fileName.textContent = '';
963
+ forecastData = null;
964
+ document.getElementById('predictBtn').disabled = true;
965
+ document.getElementById('exportCsvBtn').disabled = true;
966
+ document.getElementById('exportXlsxBtn').disabled = true;
967
+ document.getElementById('resultsSection').classList.remove('visible');
968
+ setStatus('', '');
969
+ if (chartInstance) { chartInstance.destroy(); chartInstance = null; }
970
+ });
971
+
972
+ // ── 다운로드 ─────────────────────────────────────────
973
+ document.getElementById('exportCsvBtn').addEventListener('click', () => {
974
+ if (!forecastData) return;
975
+ const rows = ['ds,y,yhat,yhat_lower,yhat_upper,is_future'];
976
+ forecastData.data.forEach(d => {
977
+ rows.push([d.ds, d.y ?? '', d.yhat ?? '', d.yhat_lower ?? '', d.yhat_upper ?? '', d.is_future].join(','));
978
+ });
979
+ download('timesfm_forecast.csv', rows.join('\n'), 'text/csv');
980
+ });
981
+
982
+ document.getElementById('exportXlsxBtn').addEventListener('click', () => {
983
+ if (!forecastData) return;
984
+ const ws_data = [['날짜', '실제값', '예측값', '신뢰구간 하한', '신뢰구간 상한', '미래여부']];
985
+ forecastData.data.forEach(d => ws_data.push([d.ds, d.y, d.yhat, d.yhat_lower, d.yhat_upper, d.is_future]));
986
+ const ws = XLSX.utils.aoa_to_sheet(ws_data);
987
+ const wb = XLSX.utils.book_new();
988
+ XLSX.utils.book_append_sheet(wb, ws, 'TimesFM');
989
+ XLSX.writeFile(wb, 'timesfm_forecast.xlsx');
990
+ });
991
+
992
+ function download(name, content, type) {
993
+ const a = Object.assign(document.createElement('a'), {
994
+ href: URL.createObjectURL(new Blob([content], { type })),
995
+ download: name,
996
+ });
997
+ a.click();
998
+ }
999
+
1000
+ // ── 유틸 ─────────────────────────────────────────────
1001
+ function setStatus(type, msg) {
1002
+ const el = document.getElementById('statusMsg');
1003
+ el.className = 'status-msg';
1004
+ if (type) { el.classList.add(type); el.innerHTML = type === 'loading' ? `<span class="spinner"></span>${msg}` : msg; }
1005
+ }
1006
+ })();
1007
+ </script>
1008
+ </body>
1009
+ </html>
1010
+ ```
1011
+
1012
+ - [ ] **Step 2: 브라우저 확인**
1013
+
1014
+ `http://localhost:8000/timesfm` 접속 → 헤더, 네비, 업로드 영역, 상태 뱃지 표시 확인.
1015
+
1016
+ - [ ] **Step 3: 커밋**
1017
+
1018
+ ```bash
1019
+ git add static/timesfm.html
1020
+ git commit -m "feat: add timesfm.html UI with confidence intervals and AI interpretation"
1021
+ ```
1022
+
1023
+ ---
1024
+
1025
+ ## Task 5: Dockerfile 수정
1026
+
1027
+ **Files:**
1028
+ - Modify: `Dockerfile`
1029
+
1030
+ - [ ] **Step 1: `useradd` 라인 전에 ENV + mkdir 삽입**
1031
+
1032
+ 기존 (line 33-35):
1033
+ ```dockerfile
1034
+ # 비루트 사용자 (Hugging Face 보안 정책)
1035
+ RUN useradd -m -u 1000 appuser && chown -R appuser /app
1036
+ USER appuser
1037
+ ```
1038
+
1039
+ 변경 후:
1040
+ ```dockerfile
1041
+ # HuggingFace 모델 캐시 디렉터리 (appuser 접근 가능)
1042
+ ENV HF_HOME=/app/.cache/huggingface
1043
+ ENV TRANSFORMERS_CACHE=/app/.cache/huggingface
1044
+ RUN mkdir -p /app/.cache/huggingface
1045
+
1046
+ # 비루트 사용자 (Hugging Face 보안 정책)
1047
+ RUN useradd -m -u 1000 appuser && chown -R appuser /app
1048
+ USER appuser
1049
+ ```
1050
+
1051
+ - [ ] **Step 2: 커밋**
1052
+
1053
+ ```bash
1054
+ git add Dockerfile
1055
+ git commit -m "chore: add HF cache dir env vars to Dockerfile"
1056
+ ```
1057
+
1058
+ ---
1059
+
1060
+ ## Task 6: 기존 HTML 파일 nav에 TimesFM 링크 추가
1061
+
1062
+ > **주의:** 스펙에서 "기존 `static/*.html` 파일 수정 금지"라고 명시되어 있으므로, 이 Task는 **건너뛴다**.
1063
+ > 기존 파일들은 수정하지 않는다. `/timesfm` 경로는 네비바에 새로 추가한 `timesfm.html`에서만 표시된다.
1064
+
1065
+ ---
1066
+
1067
+ ## Self-Review 체크리스트
1068
+
1069
+ - [x] **1-1** requirements.txt: timesfm[torch], huggingface_hub 추가 ✓
1070
+ - [x] **1-2** timesfm_service.py: load_model, get_status, predict_with_timesfm, get_freq_indicator, interpret_forecast ✓
1071
+ - [x] **1-3** main.py: lifespan 추가 + 4개 라우트 (/timesfm, /api/timesfm/status, /api/timesfm/predict, /api/timesfm/interpret) ✓
1072
+ - [x] **1-4** static/timesfm.html: 상태 뱃지, 파일 업로드, 옵션 패널, 차트(신뢰구간), 메트릭, AI 해석, 다운로드 ✓
1073
+ - [x] **1-5** Dockerfile: HF_HOME, TRANSFORMERS_CACHE, mkdir ✓
1074
+ - [x] **금지 사항**: 기존 *_service.py 미수정, 기존 static/*.html 미수정 ✓
timesfm-frontend/.env.example DELETED
@@ -1,3 +0,0 @@
1
- # HF Space URL — 서버사이드 프록시 라우트(/api/hf/*)에서 사용 (CORS 회피)
2
- HF_API_BASE=https://jkpaul-time-series-prediction.hf.space
3
- ANTHROPIC_API_KEY=
 
 
 
 
timesfm-frontend/.gitignore CHANGED
@@ -32,7 +32,7 @@ yarn-error.log*
32
 
33
  # env files (can opt-in for committing if needed)
34
  .env*
35
- !.env.example
36
 
37
  # vercel
38
  .vercel
 
32
 
33
  # env files (can opt-in for committing if needed)
34
  .env*
35
+ .env.example
36
 
37
  # vercel
38
  .vercel