From e0edc8f1e3e828f7476cf172f95ecba1d37b397e Mon Sep 17 00:00:00 2001 From: claude-owner Date: Sat, 23 May 2026 15:42:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=88=EC=B8=A1=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=EC=9B=90=EC=9D=B8=20=EB=85=B8=EC=B6=9C=20+=20/health/models?= =?UTF-8?q?=20=EC=A7=84=EB=8B=A8=20+=20restart-ci.bat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사금향님이 만난 409 'both chronos & lgbm failed' 에러가 원인을 안 보여줘서 디버깅 어려웠음. 세 군데 보강: 1. ensemble.py: 두 모델 다 실패 시 chronos/lgbm 각각의 실제 에러 원문 (type:message) 을 RuntimeError 메시지에 포함. predict.py 가 409 detail 로 그대로 노출하므로 브라우저에서 바로 원인 확인 가능. LGBM 가 None 반환 (체크포인트 없음) 인 경우도 'model checkpoint not found' 로 명시. 2. /health/models 엔드포인트 추가: - chronos.ping() — lazy load 시도 + 디바이스/모델명 반환 - LGBM_MODEL_DIR 의 *.pkl 개수와 샘플 8개 파일명 반환. cold start (체크포인트 0개) 면 'no_checkpoints' 상태로 알림. 3. restart-ci.bat 추가 — restart.bat 에서 pause 빼고 종료 코드로만 알리는 SSH 비대화형 친화 버전. 일반 사용은 그대로 restart.bat. --- backend/app/main.py | 27 ++++++++++++++++ backend/app/models/ensemble.py | 17 ++++++++-- restart-ci.bat | 57 ++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 restart-ci.bat diff --git a/backend/app/main.py b/backend/app/main.py index 2bc1690..b07f1b8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -132,3 +132,30 @@ def health_keys() -> dict[str, object]: "dart": dart_mod.ping(), # huggingface 는 모델 다운로드 시점에 확인 (별도 ping 호출 비용 회피) } + + +@app.get("/health/models") +def health_models() -> dict[str, object]: + """Chronos / LGBM 가용성 진단. + + Chronos: lazy 로드 첫 호출이라 30초~수 분 걸릴 수 있음 (HuggingFace 다운로드). + LGBM: 체크포인트 디렉토리 스캔 — retrain 안 돈 cold start 에선 비어있음. + """ + from pathlib import Path + + from app.models import chronos as chronos_mod + + lgbm_dir = Path(os.environ.get("LGBM_MODEL_DIR", "/app/data/models")) + lgbm_files: list[str] = [] + if lgbm_dir.exists(): + lgbm_files = sorted(p.name for p in lgbm_dir.glob("*.pkl")) + + return { + "chronos": chronos_mod.ping(), + "lgbm": { + "model_dir": str(lgbm_dir), + "checkpoint_count": len(lgbm_files), + "samples": lgbm_files[:8], # 너무 많으면 잘라서. + "status": "ok" if lgbm_files else "no_checkpoints (cold start, run retrain_weekly)", + }, + } diff --git a/backend/app/models/ensemble.py b/backend/app/models/ensemble.py index b3a6b23..25f4e03 100644 --- a/backend/app/models/ensemble.py +++ b/backend/app/models/ensemble.py @@ -87,30 +87,41 @@ def predict(code: str, *, horizons: tuple[int, ...] = (1, 3, 5)) -> EnsemblePred sources_used: list[str] = [] cf: ChronosForecast | None = None + chronos_err: str | None = None try: cf = chronos_forecast(closes, horizon=max_h, num_samples=30) sources_used.append("chronos") except Exception as exc: # noqa: BLE001 - logger.warning("chronos forecast failed for %s: %s", code, exc) + chronos_err = f"{type(exc).__name__}: {exc}" + logger.warning("chronos forecast failed for %s: %s", code, chronos_err) steps: list[EnsembleStep] = [] lgbm_raw: dict[int, LgbmForecast] = {} for h in horizons: lf: LgbmForecast | None = None + lgbm_err: str | None = None try: lf = lgbm_predict(code, h) if lf is not None: sources_used.append(f"lgbm_h{h}") lgbm_raw[h] = lf + else: + # predict_one 이 None 반환 = 체크포인트 파일 없음 (cold start). + lgbm_err = "model checkpoint not found (run retrain_weekly)" except Exception as exc: # noqa: BLE001 - logger.warning("lgbm predict failed for %s h=%d: %s", code, h, exc) + lgbm_err = f"{type(exc).__name__}: {exc}" + logger.warning("lgbm predict failed for %s h=%d: %s", code, h, lgbm_err) # 가중치 (DB 없으면 default 0.6/0.4). w = load_weights(code, h) wc, wl = w.w_chronos, w.w_lgbm # 한쪽이 없으면 다른 쪽 전부. if cf is None and lf is None: - raise RuntimeError(f"both chronos & lgbm failed for {code} h={h}") + # 사용자가 브라우저에서 바로 원인을 보게 두 에러를 그대로 노출. + raise RuntimeError( + f"both chronos & lgbm failed for {code} h={h}; " + f"chronos={chronos_err or 'unknown'}; lgbm={lgbm_err or 'unknown'}" + ) if cf is None: wc, wl = 0.0, 1.0 if lf is None: diff --git a/restart-ci.bat b/restart-ci.bat new file mode 100644 index 0000000..f58aa95 --- /dev/null +++ b/restart-ci.bat @@ -0,0 +1,57 @@ +@echo off +REM stock_chart_site - SSH/CI 친화 재시작 스크립트 +REM +REM restart.bat 과의 차이: pause 가 없음. SSH 비대화형 (예: ssh user@host "restart-ci.bat") +REM 에서 멈추지 않고 끝까지 실행. 에러는 종료 코드로만 알린다. +REM +REM 일반 사용 시엔 restart.bat 을 쓰는게 출력 검토에 편하다. + +setlocal enabledelayedexpansion +cd /d "%~dp0" + +echo === stock_chart_site restart-ci === + +where docker >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] docker not found + exit /b 1 +) +docker info >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Docker Desktop not running + exit /b 1 +) + +set USE_GPU=0 +where nvidia-smi >nul 2>&1 +if not errorlevel 1 ( + nvidia-smi >nul 2>&1 + if not errorlevel 1 set USE_GPU=1 +) + +if "%USE_GPU%"=="1" ( + echo [GPU] using GPU profile + set COMPOSE_FILES=-f docker-compose.yml -f docker-compose.gpu.yml +) else ( + echo [CPU] using CPU profile + set COMPOSE_FILES=-f docker-compose.yml +) + +for /f %%i in ('docker compose %COMPOSE_FILES% ps --status running --quiet backend web 2^>nul ^| find /v /c ""') do set RUN_COUNT=%%i +if "%RUN_COUNT%"=="0" ( + echo [ERROR] backend/web not running. run build.bat first. + exit /b 1 +) + +echo === docker compose up -d --force-recreate --no-deps backend web === +docker compose %COMPOSE_FILES% up -d --force-recreate --no-deps backend web +if errorlevel 1 ( + echo [ERROR] restart failed + exit /b 1 +) + +echo === status === +docker compose %COMPOSE_FILES% ps + +endlocal +exit /b 0