""" FastAPI 애플리케이션 진입점. 실행 방법: uvicorn src.api.main:app --host 0.0.0.0 --port 8000 --reload """ from __future__ import annotations import logging import os as _os import time import uuid from contextlib import asynccontextmanager from typing import AsyncGenerator, Callable import structlog from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from src.api.routes import demo, diagnostics, predictions, regions, schools from src.config import get_settings # ── 로깅 설정 ───────────────────────────────────────────────────────────── def _configure_logging(log_level: str) -> None: """structlog 을 JSON 포맷으로 설정합니다.""" structlog.configure( processors=[ structlog.contextvars.merge_contextvars, structlog.processors.add_log_level, structlog.processors.TimeStamper(fmt="iso"), structlog.processors.JSONRenderer(), ], wrapper_class=structlog.make_filtering_bound_logger( logging.getLevelName(log_level) ), logger_factory=structlog.PrintLoggerFactory(), ) # 표준 logging 도 동일 레벨로 설정 logging.basicConfig(level=log_level) # ── 애플리케이션 생명주기 ───────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: cfg = get_settings() _configure_logging(cfg.log_level) logger = structlog.get_logger() logger.info( "서버 시작", env=cfg.app_env, port=cfg.api_port, granite_model=cfg.granite_model_id, ) yield logger.info("서버 종료") # ── FastAPI 앱 생성 ──────────────────────────────────────────────────────── cfg = get_settings() app = FastAPI( title="AI 기반 학교 지속 가능성 진단 시스템", description=( "공공데이터와 IBM Granite Time Series 모델을 결합하여 " "학교의 지속 가능성을 진단하고 예측하는 API 서버입니다.\n\n" "주요 기능:\n" "- 학교기본정보 조회 (NEIS Open API 연동)\n" "- 학생 수 시계열 예측 (Granite TTM / AutoARIMA fallback)\n" "- 종합 진단 (SHAP XAI + 지속 가능성 점수 + 정책 시뮬레이션)\n" "- 지역 위험지도 데이터 제공\n" ), version="1.0.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, ) # ── CORS ────────────────────────────────────────────────────────────────── app.add_middleware( CORSMiddleware, allow_origins=["*"] if cfg.app_env == "development" else [], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ── 요청 ID / 처리 시간 미들웨어 ────────────────────────────────────────── @app.middleware("http") async def request_id_middleware(request: Request, call_next: Callable) -> Response: request_id = str(uuid.uuid4()) structlog.contextvars.clear_contextvars() structlog.contextvars.bind_contextvars(request_id=request_id) start = time.perf_counter() response: Response = await call_next(request) elapsed_ms = round((time.perf_counter() - start) * 1000, 1) response.headers["X-Request-ID"] = request_id response.headers["X-Response-Time-Ms"] = str(elapsed_ms) return response # ── 전역 예외 핸들러 ────────────────────────────────────────────────────── @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: logger = structlog.get_logger() logger.error("처리되지 않은 예외", exc_info=exc, path=str(request.url)) return JSONResponse( status_code=500, content={ "code": "INTERNAL_SERVER_ERROR", "message": "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", "detail": str(exc), }, ) # ── 라우터 등록 ─────────────────────────────────────────────────────────── app.include_router(schools.router) app.include_router(predictions.router) app.include_router(diagnostics.router) app.include_router(regions.router) app.include_router(demo.router) # ── 헬스 체크 ───────────────────────────────────────────────────────────── @app.get("/health", tags=["health"], summary="서버 상태 확인") async def health_check() -> dict: """서버 상태 및 설정 정보를 반환합니다.""" return { "status": "ok", "env": cfg.app_env, "granite_model": cfg.granite_model_id, } # ── 프론트엔드 정적 파일 서빙 ───────────────────────────────────────────── _frontend_dir = _os.path.join(_os.path.dirname(__file__), "..", "..", "frontend") if _os.path.isdir(_frontend_dir): app.mount("/", StaticFiles(directory=_frontend_dir, html=True), name="frontend")