feat(saju-lab): main.py + routers (saju 6 + compat 5) + route tests

Phase 2 백엔드 마지막 task — FastAPI app · 11 endpoint · 10 route tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 20:28:08 +09:00
parent 6d752acbe1
commit 8ec3abb800
4 changed files with 482 additions and 0 deletions

33
saju-lab/app/main.py Normal file
View File

@@ -0,0 +1,33 @@
"""saju-lab FastAPI app."""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import CORS_ALLOW_ORIGINS
from .routers import saju, compat
from . import db as db_module
app = FastAPI(title="saju-lab")
_origins = [o.strip() for o in CORS_ALLOW_ORIGINS.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_credentials=False,
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type"],
)
@app.on_event("startup")
def _init():
db_module.init_db()
@app.get("/health")
def health():
return {"ok": True}
app.include_router(saju.router)
app.include_router(compat.router)

View File

@@ -0,0 +1,107 @@
"""compat API — /api/saju/compat/* 5 endpoints."""
from fastapi import APIRouter, HTTPException
from typing import Optional
from ..models import (
CompatInterpretRequest,
CompatInterpretResponse,
CompatPatchRequest,
)
from ..interpret import pipeline
from ..calculator.core import calculate_saju
from ..calculator.analysis import perform_full_analysis
from ..calculator.compatibility import calculate_compatibility
from ..calculator.lunar import lunar_to_solar
from .. import db as db_module
router = APIRouter(prefix="/api/saju/compat")
def _calc_one(p) -> tuple[dict, dict]:
"""한 사람의 입력 → (saju, analysis)."""
if p.calendar_type == "lunar":
sy, sm, sd = lunar_to_solar(p.year, p.month, p.day, p.is_leap_month)
else:
sy, sm, sd = p.year, p.month, p.day
saju = calculate_saju(sy, sm, sd, p.hour, p.gender)
analysis = perform_full_analysis(saju, 2026)
return saju, analysis
@router.post("/interpret", response_model=CompatInterpretResponse)
async def interpret_compat_endpoint(req: CompatInterpretRequest):
try:
saju_a, analysis_a = _calc_one(req.person_a)
saju_b, analysis_b = _calc_one(req.person_b)
compat = calculate_compatibility(saju_a, saju_b)
except Exception as e:
raise HTTPException(status_code=400, detail=f"계산 실패: {e}")
try:
interp_result = await pipeline.interpret_compat(
saju_a, saju_b, analysis_a, analysis_b,
compat["score"], compat["breakdown"],
)
except pipeline.SajuError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
rid = db_module.save_compat_record({
"person_a": req.person_a.model_dump(),
"person_b": req.person_b.model_dump(),
"saju_a": saju_a, "saju_b": saju_b,
"score": compat["score"],
"breakdown": compat["breakdown"],
"interpretation_json": interp_result["interpretation_json"],
"model": interp_result["model"],
"tokens_in": interp_result["tokens_in"],
"tokens_out": interp_result["tokens_out"],
"cost_usd": interp_result["cost_usd"],
"latency_ms": interp_result["latency_ms"],
"reroll_count": interp_result["reroll_count"],
})
return {
"saju_a": saju_a, "saju_b": saju_b,
"score": compat["score"],
"breakdown": compat["breakdown"],
"interpretation_json": interp_result["interpretation_json"],
"reading_id": rid,
"model": interp_result["model"],
"tokens_in": interp_result["tokens_in"],
"tokens_out": interp_result["tokens_out"],
"cost_usd": interp_result["cost_usd"],
"latency_ms": interp_result["latency_ms"],
"reroll_count": interp_result["reroll_count"],
}
@router.get("/readings")
async def list_readings(page: int = 1, size: int = 20, favorite: Optional[bool] = None):
return db_module.list_compat_records(page=page, size=size, favorite=favorite)
@router.get("/readings/{reading_id}")
async def get_reading(reading_id: int):
row = db_module.get_compat_record(reading_id)
if not row:
raise HTTPException(status_code=404, detail="reading not found")
return row
@router.patch("/readings/{reading_id}")
async def patch_reading(reading_id: int, req: CompatPatchRequest):
row = db_module.get_compat_record(reading_id)
if not row:
raise HTTPException(status_code=404, detail="reading not found")
db_module.update_compat_record(reading_id, **req.model_dump(exclude_none=True))
return {"ok": True}
@router.delete("/readings/{reading_id}")
async def delete_reading(reading_id: int):
row = db_module.get_compat_record(reading_id)
if not row:
raise HTTPException(status_code=404, detail="reading not found")
db_module.delete_compat_record(reading_id)
return {"ok": True}

View File

@@ -0,0 +1,122 @@
"""saju API — /api/saju/* 6 endpoints."""
from fastapi import APIRouter, HTTPException, Query
from typing import Optional
from ..models import (
SajuInterpretRequest,
SajuInterpretResponse,
SajuPatchRequest,
)
from ..interpret import pipeline
from ..calculator.core import calculate_saju
from ..calculator.analysis import perform_full_analysis
from ..calculator.daeun import calculate_daeun
from ..calculator.lunar import lunar_to_solar
from ..config import SAJU_MODEL
from .. import db as db_module
router = APIRouter(prefix="/api/saju")
@router.post("/interpret", response_model=SajuInterpretResponse)
async def interpret_saju_endpoint(req: SajuInterpretRequest):
"""사주 입력 → 계산 + AI 해석 + DB 저장."""
# 음력 입력 시 양력 변환
if req.calendar_type == "lunar":
sy, sm, sd = lunar_to_solar(req.year, req.month, req.day, req.is_leap_month)
else:
sy, sm, sd = req.year, req.month, req.day
try:
saju = calculate_saju(sy, sm, sd, req.hour, req.gender)
analysis = perform_full_analysis(saju, 2026)
daeun = calculate_daeun(sy, sm, sd, req.gender, saju["month"]["stem"], saju["month"]["branch"])
except Exception as e:
raise HTTPException(status_code=400, detail=f"계산 실패: {e}")
try:
interp_result = await pipeline.interpret_saju(saju, analysis, daeun, 2026)
except pipeline.SajuError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
# DB 저장
rid = db_module.save_saju_record({
"birth_year": req.year, "birth_month": req.month, "birth_day": req.day,
"birth_hour": req.hour, "gender": req.gender,
"calendar_type": req.calendar_type,
"saju_data": saju,
"analysis_data": analysis,
"daeun_data": daeun,
"interpretation_json": interp_result["interpretation_json"],
"model": interp_result["model"],
"tokens_in": interp_result["tokens_in"],
"tokens_out": interp_result["tokens_out"],
"cost_usd": interp_result["cost_usd"],
"latency_ms": interp_result["latency_ms"],
"reroll_count": interp_result["reroll_count"],
})
return {
"saju": saju,
"analysis": analysis,
"daeun": daeun,
"interpretation_json": interp_result["interpretation_json"],
"reading_id": rid,
"model": interp_result["model"],
"tokens_in": interp_result["tokens_in"],
"tokens_out": interp_result["tokens_out"],
"cost_usd": interp_result["cost_usd"],
"latency_ms": interp_result["latency_ms"],
"reroll_count": interp_result["reroll_count"],
}
@router.get("/readings")
async def list_readings(
page: int = 1, size: int = 20,
favorite: Optional[bool] = None,
):
return db_module.list_saju_records(page=page, size=size, favorite=favorite)
@router.get("/readings/{reading_id}")
async def get_reading(reading_id: int):
row = db_module.get_saju_record(reading_id)
if not row:
raise HTTPException(status_code=404, detail="reading not found")
return row
@router.patch("/readings/{reading_id}")
async def patch_reading(reading_id: int, req: SajuPatchRequest):
row = db_module.get_saju_record(reading_id)
if not row:
raise HTTPException(status_code=404, detail="reading not found")
db_module.update_saju_record(reading_id, **req.model_dump(exclude_none=True))
return {"ok": True}
@router.delete("/readings/{reading_id}")
async def delete_reading(reading_id: int):
row = db_module.get_saju_record(reading_id)
if not row:
raise HTTPException(status_code=404, detail="reading not found")
db_module.delete_saju_record(reading_id)
return {"ok": True}
@router.get("/current-fortune")
async def current_fortune(reading_id: int = Query(...)):
"""저장된 사주의 오늘 세운 (실시간 계산, AI 호출 없음)."""
row = db_module.get_saju_record(reading_id)
if not row:
raise HTTPException(status_code=404, detail="reading not found")
from datetime import datetime
saju = row["saju_data"]
current_year = datetime.now().year
from ..calculator.analysis import calculate_seun
seun = calculate_seun(current_year, saju)
return {"reading_id": reading_id, "year": current_year, "seun": seun}