kr4phy's picture
Sync from GitHub
cff6ac7
Raw
History Blame Contribute Delete
7.88 kB
"""
์ข…ํ•ฉ ์ง„๋‹จ ์—”๋“œํฌ์ธํŠธ.
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())
# established_year ์ถ”์ถœ
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: # noqa: BLE001
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"])
# ์ตœ์‹  ์—ฐ๋„์˜ ๊ต์› ์ •๋ณด๋ฅผ school_data ์— ์ฃผ์ž…
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()
# Pydantic ๋ชจ๋ธ๋กœ ๋ณ€ํ™˜
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())