From 8ec3abb800f3a5930249f2dabb8f637b3f825583 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 25 May 2026 20:28:08 +0900 Subject: [PATCH] feat(saju-lab): main.py + routers (saju 6 + compat 5) + route tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 백엔드 마지막 task — FastAPI app · 11 endpoint · 10 route tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- saju-lab/app/main.py | 33 +++++ saju-lab/app/routers/compat.py | 107 ++++++++++++++++ saju-lab/app/routers/saju.py | 122 ++++++++++++++++++ saju-lab/tests/test_routes.py | 220 +++++++++++++++++++++++++++++++++ 4 files changed, 482 insertions(+) create mode 100644 saju-lab/app/main.py create mode 100644 saju-lab/app/routers/compat.py create mode 100644 saju-lab/app/routers/saju.py create mode 100644 saju-lab/tests/test_routes.py diff --git a/saju-lab/app/main.py b/saju-lab/app/main.py new file mode 100644 index 0000000..55150c6 --- /dev/null +++ b/saju-lab/app/main.py @@ -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) diff --git a/saju-lab/app/routers/compat.py b/saju-lab/app/routers/compat.py new file mode 100644 index 0000000..06c0e3a --- /dev/null +++ b/saju-lab/app/routers/compat.py @@ -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} diff --git a/saju-lab/app/routers/saju.py b/saju-lab/app/routers/saju.py new file mode 100644 index 0000000..15150e3 --- /dev/null +++ b/saju-lab/app/routers/saju.py @@ -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} diff --git a/saju-lab/tests/test_routes.py b/saju-lab/tests/test_routes.py new file mode 100644 index 0000000..5ca968e --- /dev/null +++ b/saju-lab/tests/test_routes.py @@ -0,0 +1,220 @@ +import pytest +from unittest.mock import patch, AsyncMock +from fastapi.testclient import TestClient + +from app.main import app +from app import db as db_module + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + db_file = tmp_path / "test_saju.db" + monkeypatch.setattr(db_module, "DB_PATH", str(db_file)) + db_module.init_db() + yield + try: + if db_file.exists(): + db_file.unlink() + except PermissionError: + pass + + +def _interpret_result(interp_json=None): + if interp_json is None: + interp_json = {"items": [], "summary": "...", "advice": "...", "warning": None, "confidence": "medium"} + return { + "interpretation_json": interp_json, + "model": "claude-sonnet-4-6", + "tokens_in": 200, "tokens_out": 400, "cost_usd": 0.01, + "latency_ms": 1200, "reroll_count": 0, + } + + +def test_health(): + with TestClient(app) as c: + r = c.get("/health") + assert r.status_code == 200 + assert r.json() == {"ok": True} + + +def test_saju_interpret_endpoint(monkeypatch): + """saju interpret이 pipeline mock으로 동작.""" + async def fake_interpret(*args, **kwargs): + return _interpret_result() + + # interpret_saju를 mock + from app.routers import saju as saju_router + monkeypatch.setattr(saju_router.pipeline, "interpret_saju", fake_interpret) + + with TestClient(app) as c: + r = c.post("/api/saju/interpret", json={ + "year": 1990, "month": 5, "day": 15, "hour": 14, + "gender": "male", "calendar_type": "solar" + }) + assert r.status_code == 200, r.text + data = r.json() + assert "saju" in data + assert "analysis" in data + assert "daeun" in data + assert "reading_id" in data + assert data["reading_id"] > 0 + + +def test_saju_list_get_cycle(monkeypatch): + async def fake_interpret(*args, **kwargs): + return _interpret_result() + from app.routers import saju as saju_router + monkeypatch.setattr(saju_router.pipeline, "interpret_saju", fake_interpret) + + with TestClient(app) as c: + # save + rid = c.post("/api/saju/interpret", json={ + "year": 1990, "month": 5, "day": 15, "hour": 14, + "gender": "male", "calendar_type": "solar" + }).json()["reading_id"] + # list + assert c.get("/api/saju/readings").json()["total"] == 1 + # get + r = c.get(f"/api/saju/readings/{rid}") + assert r.status_code == 200 + assert r.json()["birth_year"] == 1990 + + +def test_saju_patch_and_delete(monkeypatch): + async def fake_interpret(*args, **kwargs): + return _interpret_result() + from app.routers import saju as saju_router + monkeypatch.setattr(saju_router.pipeline, "interpret_saju", fake_interpret) + + with TestClient(app) as c: + rid = c.post("/api/saju/interpret", json={ + "year": 1990, "month": 5, "day": 15, "hour": 14, + "gender": "male", "calendar_type": "solar" + }).json()["reading_id"] + + r = c.patch(f"/api/saju/readings/{rid}", json={"favorite": True, "memo": "메모"}) + assert r.status_code == 200 + row = c.get(f"/api/saju/readings/{rid}").json() + assert row["favorite"] == 1 + assert row["memo"] == "메모" + + r = c.delete(f"/api/saju/readings/{rid}") + assert r.status_code == 200 + assert c.get(f"/api/saju/readings/{rid}").status_code == 404 + + +def test_saju_get_404(): + with TestClient(app) as c: + assert c.get("/api/saju/readings/9999").status_code == 404 + + +def test_saju_current_fortune(monkeypatch): + async def fake_interpret(*args, **kwargs): + return _interpret_result() + from app.routers import saju as saju_router + monkeypatch.setattr(saju_router.pipeline, "interpret_saju", fake_interpret) + + with TestClient(app) as c: + rid = c.post("/api/saju/interpret", json={ + "year": 1990, "month": 5, "day": 15, "hour": 14, + "gender": "male", "calendar_type": "solar" + }).json()["reading_id"] + r = c.get(f"/api/saju/current-fortune?reading_id={rid}") + assert r.status_code == 200 + data = r.json() + assert "seun" in data + assert data["seun"]["stem"] + + +def test_compat_interpret(monkeypatch): + async def fake_compat(*args, **kwargs): + return { + "interpretation_json": {"summary": "...", "strengths": [ + {"title": "A", "explanation": "B", "evidence": "C"}, + {"title": "A2", "explanation": "B2", "evidence": "C2"}, + ], "challenges": [ + {"title": "D", "explanation": "E", "evidence": "F"}, + {"title": "D2", "explanation": "E2", "evidence": "F2"}, + ], "advice": "...", "warning": None, "confidence": "high"}, + "model": "claude-sonnet-4-6", + "tokens_in": 300, "tokens_out": 500, "cost_usd": 0.015, + "latency_ms": 1500, "reroll_count": 0, + } + from app.routers import compat as compat_router + monkeypatch.setattr(compat_router.pipeline, "interpret_compat", fake_compat) + + with TestClient(app) as c: + r = c.post("/api/saju/compat/interpret", json={ + "person_a": {"year": 1990, "month": 5, "day": 15, "hour": 14, "gender": "male", "calendar_type": "solar"}, + "person_b": {"year": 1992, "month": 8, "day": 8, "hour": 18, "gender": "female", "calendar_type": "solar"}, + }) + assert r.status_code == 200, r.text + data = r.json() + assert "score" in data + assert "saju_a" in data + assert "saju_b" in data + + +def test_compat_list_get_cycle(monkeypatch): + async def fake_compat(*args, **kwargs): + return { + "interpretation_json": {"summary": "...", "strengths": [ + {"title": "a", "explanation": "b", "evidence": "c"}, + {"title": "a", "explanation": "b", "evidence": "c"}, + ], "challenges": [ + {"title": "d", "explanation": "e", "evidence": "f"}, + {"title": "d", "explanation": "e", "evidence": "f"}, + ], "advice": "", "warning": None, "confidence": "medium"}, + "model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, + "latency_ms": 0, "reroll_count": 0, + } + from app.routers import compat as compat_router + monkeypatch.setattr(compat_router.pipeline, "interpret_compat", fake_compat) + + with TestClient(app) as c: + rid = c.post("/api/saju/compat/interpret", json={ + "person_a": {"year": 1990, "month": 5, "day": 15, "hour": 14, "gender": "male", "calendar_type": "solar"}, + "person_b": {"year": 1992, "month": 8, "day": 8, "hour": 18, "gender": "female", "calendar_type": "solar"}, + }).json()["reading_id"] + + assert c.get("/api/saju/compat/readings").json()["total"] == 1 + r = c.get(f"/api/saju/compat/readings/{rid}") + assert r.status_code == 200 + assert r.json()["score"] >= 0 + + +def test_compat_patch_and_delete(monkeypatch): + async def fake_compat(*args, **kwargs): + return { + "interpretation_json": {"summary": "...", "strengths": [ + {"title": "a", "explanation": "b", "evidence": "c"}, + {"title": "a", "explanation": "b", "evidence": "c"}, + ], "challenges": [ + {"title": "d", "explanation": "e", "evidence": "f"}, + {"title": "d", "explanation": "e", "evidence": "f"}, + ], "advice": "", "warning": None, "confidence": "medium"}, + "model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, + "latency_ms": 0, "reroll_count": 0, + } + from app.routers import compat as compat_router + monkeypatch.setattr(compat_router.pipeline, "interpret_compat", fake_compat) + + with TestClient(app) as c: + rid = c.post("/api/saju/compat/interpret", json={ + "person_a": {"year": 1990, "month": 5, "day": 15, "hour": 14, "gender": "male", "calendar_type": "solar"}, + "person_b": {"year": 1992, "month": 8, "day": 8, "hour": 18, "gender": "female", "calendar_type": "solar"}, + }).json()["reading_id"] + + r = c.patch(f"/api/saju/compat/readings/{rid}", json={"favorite": True, "memo": "좋은 궁합"}) + assert r.status_code == 200 + row = c.get(f"/api/saju/compat/readings/{rid}").json() + assert row["favorite"] == 1 + + r = c.delete(f"/api/saju/compat/readings/{rid}") + assert r.status_code == 200 + assert c.get(f"/api/saju/compat/readings/{rid}").status_code == 404 + + +def test_compat_get_404(): + with TestClient(app) as c: + assert c.get("/api/saju/compat/readings/9999").status_code == 404