| """ |
| ์ข
ํฉ ์ง๋จ ์๋ํฌ์ธํธ. |
| |
| GET /schools/{schul_code}/diagnose |
| โ ์ง์ ๊ฐ๋ฅ์ฑ ์ง๋จ (์์ธก + SHAP + ์ ์ + ์๋ฎฌ๋ ์ด์
) |
| |
| POST /schools/{schul_code}/simulate |
| โ ์ฌ์ฉ์ ์ ์ ์ ์ฑ
์๋ฎฌ๋ ์ด์
(overrides ์ ์ฉ ํ ์ ์ ์ฌ์ฐ์ถ) |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
|
|
| import pandas as pd |
| from fastapi import APIRouter, HTTPException, Query |
|
|
| from src.analytics.diagnostics import DiagnosticsEngine |
| from src.analytics.scorer import SustainabilityScorer |
| from src.api.schemas import ( |
| DiagnosticsResponse, |
| ForecastResponse, |
| ShapResultResponse, |
| ShapValueItem, |
| SimulationRequest, |
| SustainabilityScoreResponse, |
| SimulationResult, |
| ) |
| from src.ingestion.cleaner import DataCleaner |
| from src.ingestion.data_go_kr import DataGoKrClient |
| from src.ingestion.standardizer import DataStandardizer |
|
|
| router = APIRouter(prefix="/schools", tags=["diagnostics"]) |
| logger = logging.getLogger(__name__) |
|
|
| _standardizer = DataStandardizer() |
| _cleaner = DataCleaner() |
| _engine = DiagnosticsEngine() |
| _scorer = SustainabilityScorer() |
|
|
| _HISTORY_YEARS = 7 |
|
|
|
|
| async def _collect_school_data(schul_code: str, end_year: int) -> tuple[dict, pd.Series]: |
| """ |
| ํ๊ต ๊ธฐ๋ณธ ์ ๋ณด + ์ฐ๋๋ณ ํ์ ์ ์๊ณ์ด์ ์์งํ์ฌ ๋ฐํํฉ๋๋ค. |
| |
| Returns |
| ------- |
| (school_data dict, student timeseries pd.Series) |
| """ |
| school_data: dict = {"sd_schul_code": schul_code} |
| yearly_counts: dict[int, float] = {} |
| start_year = max(end_year - _HISTORY_YEARS + 1, 2000) |
|
|
| async with DataGoKrClient() as client: |
| |
| info_rows = await client.fetch_school_info(schul_code=schul_code) |
| if info_rows: |
| try: |
| info_df = _standardizer.standardize_school_info(info_rows) |
| if schul_code in info_df.index: |
| school_data.update(info_df.loc[schul_code].to_dict()) |
| |
| est_date = str(school_data.get("established_date", "") or "") |
| if est_date and len(est_date) >= 4: |
| try: |
| school_data["established_year"] = int(est_date[:4]) |
| except ValueError: |
| pass |
| except Exception as exc: |
| logger.warning("ํ๊ต๊ธฐ๋ณธ์ ๋ณด ํ์คํ ์คํจ: %s", exc) |
|
|
| |
| for year in range(start_year, end_year + 1): |
| s_rows = await client.fetch_student_count(year=year, schul_code=schul_code) |
| t_rows = await client.fetch_teacher_count(year=year, schul_code=schul_code) |
| if not s_rows and not t_rows: |
| continue |
| stats = _standardizer.build_school_stats(s_rows, t_rows, year=year) |
| if stats.empty or schul_code not in stats.index.get_level_values("sd_schul_code"): |
| continue |
| row = stats.xs(schul_code, level="sd_schul_code").iloc[0] |
| if "student_count" in row and pd.notna(row["student_count"]): |
| yearly_counts[year] = float(row["student_count"]) |
| |
| if year == end_year: |
| for col in ["teacher_count", "temp_teacher_count", "class_count"]: |
| if col in row and pd.notna(row[col]): |
| school_data[col] = float(row[col]) |
|
|
| timeseries = pd.Series(yearly_counts).sort_index().astype(float) |
| return school_data, timeseries |
|
|
|
|
| @router.get( |
| "/{schul_code}/diagnose", |
| response_model=DiagnosticsResponse, |
| summary="ํ๊ต ์ข
ํฉ ์ง๋จ", |
| description=( |
| "์๊ณ์ด ์์ธก, SHAP ๊ธฐ์ฌ๋ ๋ถ์, ์ง์ ๊ฐ๋ฅ์ฑ ์ ์ ์ฐ์ถ, " |
| "์ ์ฑ
์๋ฎฌ๋ ์ด์
์ ํตํฉํ์ฌ ํ๊ต ์ข
ํฉ ์ง๋จ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํํฉ๋๋ค." |
| ), |
| ) |
| async def diagnose_school( |
| schul_code: str, |
| horizon_years: int = Query(default=5, ge=1, le=10, description="์์ธก ๊ธฐ๊ฐ (๋
)"), |
| end_year: int = Query(default=2023, ge=2000, le=2100, description="ํ์คํ ๋ฆฌ ๋ง์ง๋ง ํ๋
๋"), |
| run_simulations: bool = Query(default=True, description="์ ์ฑ
์๋ฎฌ๋ ์ด์
์คํ ์ฌ๋ถ"), |
| ) -> DiagnosticsResponse: |
| """ํ๊ต ์ข
ํฉ ์ง๋จ์ ์ํํฉ๋๋ค.""" |
| logger.info( |
| "์ง๋จ ์์ฒญ: schul_code=%s horizon=%dy end_year=%d", |
| schul_code, |
| horizon_years, |
| end_year, |
| ) |
|
|
| try: |
| school_data, timeseries = await _collect_school_data(schul_code, end_year) |
| except Exception as exc: |
| logger.error("๋ฐ์ดํฐ ์์ง ์คํจ: %s", exc) |
| raise HTTPException(status_code=502, detail=f"๋ฐ์ดํฐ ์์ง ์คํจ: {exc}") from exc |
|
|
| if len(timeseries.dropna()) < 2: |
| raise HTTPException( |
| status_code=422, |
| detail=f"์์ธก์ ํ์ํ ์๊ณ์ด ๋ฐ์ดํฐ๊ฐ ๋ถ์กฑํฉ๋๋ค (์์ง ์ฐ๋ ์: {len(timeseries)}).", |
| ) |
|
|
| try: |
| result = _engine.diagnose( |
| schul_code=schul_code, |
| school_data=school_data, |
| timeseries=timeseries, |
| horizon_years=horizon_years, |
| run_simulations=run_simulations, |
| ) |
| except ValueError as exc: |
| raise HTTPException(status_code=422, detail=str(exc)) from exc |
| except Exception as exc: |
| logger.error("์ง๋จ ์์ง ์ค๋ฅ: %s", exc) |
| raise HTTPException(status_code=500, detail=f"์ง๋จ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: {exc}") from exc |
|
|
| raw = result.to_dict() |
|
|
| |
| forecast_resp = ForecastResponse(**raw["forecast"]) |
| shap_resp = ShapResultResponse( |
| **{ |
| **raw["shap_result"], |
| "top_factors": [ShapValueItem(**f) for f in raw["shap_result"]["top_factors"]], |
| } |
| ) |
| score_resp = SustainabilityScoreResponse(**raw["score_detail"]) |
| sim_resp = {k: SimulationResult(**v) for k, v in raw["simulations"].items()} |
|
|
| return DiagnosticsResponse( |
| schul_code=raw["schul_code"], |
| school_name=raw["school_name"], |
| status_label=raw["status_label"], |
| status_code=raw["status_code"], |
| sustainability_score=raw["sustainability_score"], |
| forecast=forecast_resp, |
| shap_result=shap_resp, |
| score_detail=score_resp, |
| simulations=sim_resp, |
| metadata=raw["metadata"], |
| ) |
|
|
|
|
| @router.post( |
| "/{schul_code}/simulate", |
| response_model=SustainabilityScoreResponse, |
| summary="์ฌ์ฉ์ ์ ์ ์ ์ฑ
์๋ฎฌ๋ ์ด์
", |
| description=( |
| "school_data_overrides ์ ๋ณ๊ฒฝํ ์งํ๋ฅผ ์ง์ ํ๋ฉด " |
| "ํด๋น ๊ฐ์ ์ ์ฉํ ์ง์ ๊ฐ๋ฅ์ฑ ์ ์๋ฅผ ๋ฐํํฉ๋๋ค." |
| ), |
| ) |
| async def simulate_policy( |
| schul_code: str, |
| request: SimulationRequest, |
| end_year: int = Query(default=2023, ge=2000, le=2100, description="๊ธฐ์ค ํ๋
๋"), |
| ) -> SustainabilityScoreResponse: |
| """์ฌ์ฉ์ ์ ์ ์ ์ฑ
์๋ฎฌ๋ ์ด์
์ ์ํํฉ๋๋ค.""" |
| logger.info( |
| "์๋ฎฌ๋ ์ด์
์์ฒญ: schul_code=%s overrides=%s", |
| schul_code, |
| request.school_data_overrides, |
| ) |
|
|
| try: |
| school_data, _ = await _collect_school_data(schul_code, end_year) |
| except Exception as exc: |
| logger.error("๋ฐ์ดํฐ ์์ง ์คํจ: %s", exc) |
| raise HTTPException(status_code=502, detail=f"๋ฐ์ดํฐ ์์ง ์คํจ: {exc}") from exc |
|
|
| |
| modified = dict(school_data) |
| for key, value in request.school_data_overrides.items(): |
| modified[key] = value |
|
|
| try: |
| score = _scorer.compute(schul_code=schul_code, school_data=modified) |
| except Exception as exc: |
| logger.error("์ ์ ์ฐ์ถ ์คํจ: %s", exc) |
| raise HTTPException(status_code=500, detail=f"์ ์ ์ฐ์ถ ์คํจ: {exc}") from exc |
|
|
| return SustainabilityScoreResponse(**score.to_dict()) |
|
|