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:
33
saju-lab/app/main.py
Normal file
33
saju-lab/app/main.py
Normal 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)
|
||||
107
saju-lab/app/routers/compat.py
Normal file
107
saju-lab/app/routers/compat.py
Normal 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}
|
||||
122
saju-lab/app/routers/saju.py
Normal file
122
saju-lab/app/routers/saju.py
Normal 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}
|
||||
220
saju-lab/tests/test_routes.py
Normal file
220
saju-lab/tests/test_routes.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user