7 Commits

Author SHA1 Message Date
03e1dc1dbb feat(saju-lab): /interpret 응답에 fortune_scores + lucky + monthly_flow 포함 2026-05-26 08:08:14 +09:00
f57c790437 feat(saju-lab): db.py — saju_records 3 컬럼 추가 (fortune_scores/lucky/monthly_flow) + 4 마이그레이션 테스트 2026-05-26 08:05:41 +09:00
030367da6c feat(saju-lab): monthly_flow.py — 12개월 운세 흐름 (4 tests)
월간(月干)과 월지(月支)의 일간 관계를 이용한 12개월 운세 점수 계산:
- 월간 상생(生) 관계: +5~10점
- 월간 상극(剋) 관계: -8점
- 월지 육합(六合) 관계: +10점
- 월지 육충(六衝) 관계: -12점
- 월지 상생/상극: ±4점

점수 범위 0~100, 5단계 레이블 (정체/도전/변동/안정/성장)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:02:35 +09:00
429e3448e5 feat(saju-lab): lucky.py — 럭키 컬러/숫자/방향 + 행운/위험 알림 (6 tests) 2026-05-26 08:00:37 +09:00
579e7387be feat(saju-lab): fortune_scores.py — 4 카테고리 점수 + overall (6 tests) 2026-05-26 07:58:02 +09:00
8ef0ba81f2 docs(plan): saju-lab UI v1 — 호령 사주 페이지 구현 plan
- Phase A 백엔드 확장 (Task 1-5): fortune_scores + lucky + monthly_flow + DB 마이그레이션 + 응답 확장
- Phase B 캐릭터 자산 (Task 6): horyung.png + saju_color_sheet.png에서 6 PNG 추출 (PIL)
- Phase C 프론트 구축 (Task 7-16): CSS 격리 + 컴포넌트 11개 + 3 페이지 + e2e 검증
- TDD + 빈번한 commit + 시안 1:1 매칭 목표

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:54:13 +09:00
afb4175bd5 docs(spec): saju-lab UI v1 — 호령 사주 페이지 설계
- 시안 4종(메인/오늘운세/궁합/사주풀이) + 호령 캐릭터 시트 + 컬러시트 기반
- v1 범위: 메인 + 사주풀이 + 오늘운세 (궁합은 v2 placeholder)
- 백엔드 확장: fortune_scores + lucky + monthly_flow 산출
- 입력 흐름: reading_id URL 공유 + useSajuReading 캐시
- 데스크탑 우선 + 태블릿 반응형
- CSS .saju-page scope로 격리 + Pretendard + Noto Serif KR 폰트

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 02:51:34 +09:00
13 changed files with 3873 additions and 10 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,387 @@
# saju-lab UI v1 — 호령 사주 페이지 설계
**작성일**: 2026-05-26
**상태**: Spec (구현 plan 작성 전)
**전제**: saju-lab 백엔드 완성 (474 tests, SHA 8123f75) + web-ui Task 28 (api helpers + placeholder pages)
---
## 1. 목표
사용자 시안 4종(`source/images/saju_page/horyung_saju_main.png`, `_today.png`, `_gunghab.png`, `_saju.png`) + 캐릭터 시트(`source/characters/horyung.png`) + 컬러시트(`saju_color_sheet.png`) 기반으로 web-ui `/saju/*` 페이지를 호령 마스코트와 함께 구축한다.
v1 범위: **메인 / 오늘의 운세 / 사주풀이** 3개 페이지. 궁합은 v2 placeholder.
---
## 2. 결정된 핵심 사항
| 항목 | 결정 |
|------|------|
| 캐릭터 자산 | horyung.png + saju_color_sheet.png에서 PNG 6개 추출 |
| 백엔드 확장 | saju-lab에 fortune_scores + lucky + monthly_flow 산출 추가 |
| 입력 흐름 | 메인에서 사주 1회 입력 → reading_id를 다른 페이지 URL query로 공유 |
| v1 페이지 | 메인 + 사주풀이 + 오늘운세 (궁합은 v2) |
| 반응형 | 데스크탑(1280+) 우선 + 태블릿 그라데이션 |
| 컬러 | 시안 추출 — 크림 베이스 + 다크 네이비 + 골드 + 살구 + 청록 |
| 폰트 | Pretendard (본문) + Noto Serif KR (큰 제목, Google Fonts) |
| CSS 격리 | `.saju-page` scope (다른 페이지에 새지 않음) |
---
## 3. 백엔드 확장 (saju-lab)
### 3-1. 신설 모듈
**`saju-lab/app/calculator/fortune_scores.py`** — 4 카테고리 점수:
```
calculate_fortune_scores(saju, analysis, current_year) → {
wealth: 0-100 (재물운)
romance: 0-100 (연애운)
social: 0-100 (인간관계)
career: 0-100 (직장운)
overall: 0-100 (가중평균: wealth*0.3 + career*0.3 + romance*0.2 + social*0.2)
}
```
알고리즘 (각 base 60에서 가산/감산, clamp 0-100):
- **wealth**: +정재 강도 / +편재 강도 / +식상→재 통로 / -비겁 강도 / +세운재성
- **romance**: +일지 합 / +정관·정재 균형 / -일지 충 / +세운 도화살
- **social**: +인성 / +비겁 적정 / +식상 / +격국 균형 / +천을귀인
- **career**: +정관 강도 / +편관 제어 / +일간 신강 / +세운 관성
**`saju-lab/app/calculator/lucky.py`** — 럭키 데이터:
```
calculate_lucky(saju, analysis, target_date) → {
color: [str, str] # 용신 오행 컬러 1~2개 (예: ["청록", "녹색"])
number: int 1-9 # (일진 천간 idx + 시진 천간 idx) % 9 + 1
direction: str # 용신 오행 방향 (동/남/중앙/서/북)
good_signs: [str] # 세운 천간이 일간 재성 → "재물 기회" 등
warnings: [str] # 세운 지지가 일지 충 → "대인 갈등 주의"
}
```
오행→컬러/방향 매핑은 정적 dict. 럭키 숫자는 일진+시진(시간 미상 시 일진만)으로 산출.
**`saju-lab/app/calculator/monthly_flow.py`** — 12개월 운세 흐름:
```
calculate_monthly_flow(saju, year) → [
{month: 1, stem: "壬", branch: "寅", score: 65, label: "변동"},
{month: 2, stem: "癸", branch: "卯", score: 70, label: "성장"},
... 12 entries
]
```
각 월: 해당 월의 60갑자(寅월부터 12월 사이클) → 일간 관계(상생/상극/충/합) → score 0-100 + label(`변동`/`성장`/`안정`/`도전`/`정체` 등).
### 3-2. `routers/saju.py` 응답 확장
`SajuInterpretResponse`에 3 필드 추가:
```python
fortune_scores: dict # {wealth, romance, social, career, overall}
lucky: dict # {color, number, direction, good_signs, warnings}
monthly_flow: list[dict] # 12 entries
```
`interpret_saju_endpoint`에서 계산 + DB 저장 + 응답 포함.
### 3-3. `db.py` 스키마 마이그레이션
`saju_records` 테이블에 ALTER TABLE로 3 컬럼 추가 (idempotent):
- `fortune_scores_json TEXT`
- `lucky_json TEXT`
- `monthly_flow_json TEXT`
`init_db()`에 try/except OperationalError 패턴 (이미 존재하면 skip).
`_saju_row_to_dict`에서 3 컬럼 JSON 파싱하여 응답에 포함.
### 3-4. 테스트
- `test_fortune_scores.py` — 5-8 case (정재 강함 → wealth 80+, 일지 충 → romance 50-, clamp 검증)
- `test_lucky.py` — 5 case (오행→컬러/방향 매핑, 럭키 숫자 1-9 범위)
- `test_monthly_flow.py` — 3 case (12 entries 정확, 일간 충 월 score 낮음)
기존 30 reference fixture 비교는 영향 없음 (응답에 새 필드만 추가).
---
## 4. 프론트엔드 구조 (web-ui)
### 4-1. 디렉토리
```
web-ui/
├── public/images/saju/
│ ├── horyung/
│ │ ├── horyung-front.png # 시안 main hero용 (정면, 큰 사이즈)
│ │ ├── horyung-bust.png # 작은 카드용 (가슴샷)
│ │ ├── horyung-greeting.png # 인사 표정 (메인 좌상단)
│ │ ├── horyung-thinking.png # 생각하는 표정 (사주풀이)
│ │ ├── horyung-pointing.png # 가르치는 표정 (오늘운세)
│ │ └── horyung-happy.png # 기쁜 표정 (점수 높을 때)
│ ├── frame-cloud.png # 시안의 한국화 산 배경 (hero용)
│ ├── pattern-cloud.svg # 한국 전통 구름 패턴
│ └── icons/
│ ├── icon-today.svg
│ ├── icon-heart.svg
│ └── icon-book.svg
└── src/pages/saju/
├── Saju.css # 모든 saju 페이지 공통 스타일 (격리)
├── data/
│ └── constants.js # 4 카테고리 메타, 컬러 토큰
├── hooks/
│ ├── useSajuForm.js
│ └── useSajuReading.js # reading_id → fetched data + 캐시
├── components/
│ ├── HoryungMascot.jsx
│ ├── SajuNav.jsx # 시안 상단 네비게이션 (호령사주 로고 + nav)
│ ├── SajuInputForm.jsx
│ ├── ActionCard.jsx # 3 카드 (오늘운세/궁합/사주풀이)
│ ├── ScoreCard.jsx # 카테고리 점수 카드
│ ├── FortuneRing.jsx # 종합점 ring SVG
│ ├── LuckyBox.jsx # 럭키 컬러/숫자/방향
│ ├── ElementBarChart.jsx # 오행 5색 가로 바
│ ├── SajuPillars.jsx # 4기둥 8자 표시
│ ├── MonthlyFlow.jsx # 12개월 운세 흐름 차트
│ ├── InterpretAccordion.jsx # AI 12항목 아코디언
│ └── HoryungQuote.jsx # 호령 말풍선
├── Saju.jsx # 메인 페이지
├── SajuResult.jsx # 사주풀이 결과
├── Today.jsx # 오늘의 운세
└── Compatibility.jsx # v2 placeholder
```
### 4-2. 라우팅 (변경 없음, Task 28에서 등록됨)
| 경로 | 컴포넌트 | reading_id 필요 |
|------|---------|----------------|
| `/saju` | Saju.jsx (메인) | 아니오 |
| `/saju/result?rid=N` | SajuResult.jsx | 예 |
| `/saju/today?rid=N` | Today.jsx | 예 |
| `/saju/compatibility` | Compatibility.jsx (placeholder) | — |
기존 `/saju/result` 등은 Task 28에서 placeholder로 등록 — 본 task에서 실제 컴포넌트로 교체.
### 4-3. 데이터 흐름
```
[사용자] → /saju (메인)
↓ 사주 입력
↓ sajuInterpret(form)
↓ POST /api/saju/interpret
[saju-lab] 계산 + Claude AI + fortune_scores + lucky + monthly_flow
↓ 응답: { reading_id, ... 풍부한 데이터 }
[프론트] navigate(`/saju/result?rid=${reading_id}`)
[사주풀이 페이지] /saju/result?rid=N
↓ useSajuReading(N) → sajuGetReading(N)
↓ GET /api/saju/readings/N
↓ saju_data + analysis_data + daeun_data + interpretation_json + fortune_scores + lucky + monthly_flow
↓ 렌더
[오늘운세] /saju/today?rid=N — 사용자가 메인 또는 사주풀이에서 클릭
↓ useSajuReading(N) + sajuCurrentFortune(N)
↓ 렌더: ring + 4 score + lucky + 오늘 세운
```
### 4-4. 호령 마스코트
`HoryungMascot.jsx``pose` prop으로 6개 PNG 중 선택.
```jsx
<HoryungMascot pose="greeting" size="lg" /> // 메인 좌상단
<HoryungMascot pose="thinking" size="md" /> // 사주풀이
<HoryungMascot pose="pointing" size="md" /> // 오늘운세
<HoryungMascot pose="happy" size="sm" /> // 점수 높을 때 (옵션)
```
`onError` 핸들러로 PNG 누락 시 silent (디자인 깨짐 방지).
### 4-5. CSS 격리 + 컬러 시스템
`Saju.css`:
```css
.saju-page {
/* 베이스 */
--saju-cream: #FAF6EE;
--saju-paper: #F2EAD8;
--saju-ink: #2E2D45; /* 다크 네이비 (헤더, 본문) */
--saju-ink-deep: #1F1D38;
/* 액센트 */
--saju-gold: #D4A574;
--saju-gold-deep: #B5874E;
--saju-apricot: #C58F76;
--saju-rose: #D9A2A6;
--saju-jade: #4B7065;
--saju-violet: #6A5285;
/* 카테고리 (3 ActionCard) */
--saju-today-bg: #4B7065; /* 청록 (오늘운세) */
--saju-gunghab-bg: #A8736E; /* 살구 (궁합) */
--saju-saju-bg: #4F4A78; /* 보라 (사주풀이) */
/* 점수 카테고리 (4 ScoreCard) */
--saju-wealth: #D4A574; /* 골드 (재물) */
--saju-romance: #D9A2A6; /* 로즈 (연애) */
--saju-social: #4B7065; /* 청록 (인간관계) */
--saju-career: #6A5285; /* 보라 (직장) */
min-height: 100vh;
background: var(--saju-cream);
color: var(--saju-ink);
font-family: 'Pretendard', sans-serif;
}
.saju-page .saju-h1,
.saju-page .saju-h2 {
font-family: 'Noto Serif KR', serif;
font-weight: 700;
letter-spacing: -0.02em;
}
```
모든 saju 컴포넌트의 클래스는 `saju-` prefix로 시작 (다른 페이지와 격리).
### 4-6. 반응형
- 기준: `1280px+` 데스크탑 (시안 그대로)
- `768~1280px` 태블릿: hero 컬럼 → 세로 스택, action card 3 → 2x2 grid
- `~768px` 모바일: 호령 작게 (size="sm"), action card 1열, 입력 폼 세로
`@media` 쿼리로 `Saju.css` 안에서 처리.
### 4-7. 폰트
`index.html`에 Google Fonts preconnect + Noto Serif KR 추가:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@500;700&display=swap" rel="stylesheet">
```
큰 제목(h1/h2)만 Noto Serif KR, 본문은 기존 Pretendard.
---
## 5. 컴포넌트별 세부
### 5-1. Saju.jsx (메인)
레이아웃 (시안 horyung_saju_main.png):
- 상단: SajuNav (호령사주 로고 + 4 nav + "사주풀이 시작하기" 버튼)
- Hero: 좌측 호령(front + greeting 박스) / 우측 큰 h1 + 3 ActionCard
- Bottom: 좌측 통계 미리보기 / 우측 SajuInputForm
폴백: reading_id가 query에 있으면 (`/saju?rid=N`) 통계 영역에 미리보기 점수 + 마지막 분석 결과로.
### 5-2. SajuResult.jsx (사주풀이)
레이아웃 (시안 horyung_saju_saju.png):
- 상단: SajuNav + "사주풀이" 큰 타이틀 + 기본 정보 (이름, 생년월일) + 호령(thinking)
- 중단 좌: 사주 4기둥 표 (SajuPillars) + 오행 바 차트 (ElementBarChart)
- 중단 우: 호령의 비전 박스 (HoryungQuote — interpretation의 summary 발췌)
- 하단: 성격강점 / 직업운 / 재물운 / 연애운 4 카드 (12항목 중 추출) + 12개월 운세 흐름 (MonthlyFlow)
- 우하단: 이번 달 핵심 결정 포인트 (interpretation_json.advice)
데이터: `useSajuReading(rid)` → saju + analysis + daeun + interpretation_json + monthly_flow
### 5-3. Today.jsx (오늘의 운세)
레이아웃 (시안 horyung_saju_today.png):
- 상단: SajuNav + "오늘의 운세" 큰 타이틀 + 호령(pointing) + 풍경 배경
- 중단: FortuneRing(overall) + 4 ScoreCard(wealth/romance/social/career) + LuckyBox
- 하단: 행운 알림 / 위험 알림 (lucky.good_signs, lucky.warnings)
- 최하단: 다음 페이지 (사주풀이 / 궁합보기) 버튼
데이터: `useSajuReading(rid)` → fortune_scores + lucky + `sajuCurrentFortune(rid)` → 오늘 세운
### 5-4. Compatibility.jsx (v2 placeholder)
```jsx
export default function Compatibility() {
return (
<div className="saju-page saju-page--compat-stub">
<SajuNav />
<div className="saju-stub">
<HoryungMascot pose="thinking" />
<h2>궁합보기는 만나요!</h2>
<p> 사람의 사주를 함께 풀어보는 기능을 준비 중입니다.</p>
<Link to="/saju">메인으로 돌아가기</Link>
</div>
</div>
);
}
```
백엔드 `/api/saju/compat/*`는 이미 동작하지만 UI는 v2에서 정식 구현.
---
## 6. 에러 처리
| 시나리오 | 처리 |
|---------|------|
| 메인 입력 폼 — 잘못된 날짜 | Pydantic 422 → 폼에서 "올바른 날짜를 입력해주세요" |
| Claude API 504/500 | "잠시 후 다시 시도해주세요" + 사용자 입력 보존 |
| reading_id 무효(404) | "사주 결과를 찾을 수 없습니다" + 메인으로 돌아가기 버튼 |
| 호령 PNG 누락 | onError로 silent hide (디자인은 살짝 빈 자리, 동작은 정상) |
| fortune_scores 산출 실패 (예외) | 기본값 60/60/60/60으로 fallback + 콘솔 warn |
---
## 7. 테스트 전략
### 백엔드
- fortune_scores: 5-8 unit test (각 카테고리 high/low 케이스 + clamp)
- lucky: 5 unit test (오행→컬러 매핑, 숫자 1-9 범위, 방향)
- monthly_flow: 3 unit test (12 entries, 점수 범위, 충/합 영향)
- 기존 30 reference fixture 비교: 영향 없음 (응답 추가 필드만)
### 프론트
- 컴포넌트 단위 테스트는 v1 범위 밖 (수동 e2e 검증)
- 로컬 e2e: `npm run dev` + 입력 → 사주풀이/오늘운세 1회 정상 동작
- 호령 6 PNG 모두 존재 확인 (수동)
- 반응형 — Chrome DevTools 1280/1024/768 3가지 확인
---
## 8. 위험 + 완화
| 위험 | 완화 |
|------|------|
| 호령 PNG crop 좌표가 부정확 | plan 단계에서 PIL로 trial-and-error + 사용자 검수. onError로 silent fallback |
| fortune_scores 점수 산식이 명리학적 부정확 | v1은 plausible default + base 60으로 보수적. 실사용 피드백으로 튜닝 |
| 시안 색상과 미세 차이 | 시안 PNG에서 color picker로 hex 추출 후 CSS variable로 명시 |
| Noto Serif KR Google Fonts 로드 지연 | display=swap로 폰트 fallback (Pretendard) → 깜빡임 최소화 |
| reading_id 만료(DB row 삭제) | 404 graceful fallback + 새 입력 유도 |
| Claude 응답 시간 초과 | nginx timeout 300s + 폼에서 progress 표시 |
---
## 9. 향후 (v2, 본 spec 밖)
- 궁합보기 페이지 정식 구현 (시안 horyung_saju_gunghab.png 기반)
- 상담안내 페이지 (nav에 있는 메뉴)
- 즐겨찾기/히스토리 페이지 (sajuListReadings 활용)
- 사주풀이 PDF 내보내기
- 호령 캐릭터 lottie 애니메이션 (정적 PNG → 동적)
---
## 10. 참고
- 시안: `source/images/saju_page/horyung_saju_{main,today,gunghab,saju}.png`
- 캐릭터: `source/characters/horyung.png`
- 컬러시트: `source/images/saju_page/saju_color_sheet.png`
- 백엔드: web-backend/saju-lab/ (SHA 8123f75)
- 직전 spec: `docs/superpowers/specs/2026-05-25-saju-tarot-lab-migration-design.md` (saju-lab 백엔드 설계)
- web-ui Task 28 commit: e634cde (api.js + routes + IconSaju + placeholder pages)

View File

@@ -0,0 +1,114 @@
"""4 카테고리 점수 — 재물/연애/인간관계/직장 + 종합점."""
from typing import Dict
from .constants import (
FIVE_ELEMENTS, IS_YANG_STEM, SHENG_CYCLE, KE_CYCLE,
)
def _ten_god_counts(saju: dict) -> Dict[str, int]:
"""4기둥의 ten_god 카운트."""
counts: Dict[str, int] = {}
for p in ("year", "month", "day", "hour"):
pillar = saju.get(p)
if not pillar:
continue
tg = pillar.get("ten_god", "")
counts[tg] = counts.get(tg, 0) + 1
return counts
def _branch_has_chong(saju: dict) -> bool:
"""일지가 다른 기둥과 6충 관계인지."""
LIU_CHONG = {
frozenset(["", ""]), frozenset(["", ""]),
frozenset(["", ""]), frozenset(["", ""]),
frozenset(["", ""]), frozenset(["", ""]),
}
day_branch = saju["day"]["branch"]
for p in ("year", "month", "hour"):
pillar = saju.get(p)
if not pillar:
continue
if frozenset([day_branch, pillar["branch"]]) in LIU_CHONG:
return True
return False
def _branch_has_he(saju: dict) -> bool:
"""일지가 다른 기둥과 6합 관계인지."""
LIU_HE = {
frozenset(["", ""]), frozenset(["", ""]),
frozenset(["", ""]), frozenset(["", ""]),
frozenset(["", ""]), frozenset(["", ""]),
}
day_branch = saju["day"]["branch"]
for p in ("year", "month", "hour"):
pillar = saju.get(p)
if not pillar:
continue
if frozenset([day_branch, pillar["branch"]]) in LIU_HE:
return True
return False
def _clamp(v: int) -> int:
return max(0, min(100, v))
def calculate_fortune_scores(saju: dict, analysis: dict, current_year: int) -> Dict[str, int]:
"""4 카테고리 + overall (가중평균)."""
counts = _ten_god_counts(saju)
has_chong = _branch_has_chong(saju)
has_he = _branch_has_he(saju)
strength = analysis.get("day_master_strength", {}).get("result", "중화")
# 재물운: 정재/편재 강도
wealth = 60
wealth += counts.get("정재", 0) * 8
wealth += counts.get("편재", 0) * 6
wealth += counts.get("식신", 0) * 4
wealth -= counts.get("비견", 0) * 5
wealth -= counts.get("겁재", 0) * 5
wealth = _clamp(wealth)
# 연애운: 일지 합/충 + 정관/정재
romance = 60
if has_he:
romance += 15
if has_chong:
romance -= 15
romance += counts.get("정관", 0) * 5
romance += counts.get("정재", 0) * 5
romance = _clamp(romance)
# 인간관계: 인성 + 비겁 적정 + 식상
social = 60
social += counts.get("정인", 0) * 5
social += counts.get("편인", 0) * 4
social += min(counts.get("비견", 0), 2) * 5
social += counts.get("식신", 0) * 3
social += counts.get("상관", 0) * 3
social = _clamp(social)
# 직장운: 정관 + 편관(제어) + 일간 강도
career = 60
career += counts.get("정관", 0) * 10
career += counts.get("편관", 0) * 5
if strength == "신강":
career += 8
elif strength == "신약":
career -= 5
career = _clamp(career)
overall = round(wealth * 0.3 + career * 0.3 + romance * 0.2 + social * 0.2)
overall = _clamp(overall)
return {
"wealth": wealth,
"romance": romance,
"social": social,
"career": career,
"overall": overall,
}

View File

@@ -0,0 +1,84 @@
"""오늘의 럭키 컬러/숫자/방향 + 행운/위험 알림."""
from datetime import date as _date
from typing import Dict, List
import sxtwl
from .constants import HEAVENLY_STEMS, EARTHLY_BRANCHES, FIVE_ELEMENTS, KE_CYCLE
LUCKY_COLOR_BY_ELEMENT: Dict[str, List[str]] = {
"": ["청록", "녹색"],
"": ["빨강", "주황"],
"": ["황색", "베이지"],
"": ["흰색", "은색"],
"": ["파랑", "검정"],
}
LUCKY_DIRECTION_BY_ELEMENT: Dict[str, str] = {
"": "동쪽",
"": "남쪽",
"": "중앙",
"": "서쪽",
"": "북쪽",
}
def _day_stem_branch_idx(target: _date) -> tuple[int, int]:
"""양력 → sxtwl 일주 천간/지지 인덱스."""
day_obj = sxtwl.fromSolar(target.year, target.month, target.day)
try:
gz = day_obj.getDayGZ()
return gz.tg, gz.dz
except AttributeError:
return day_obj.getDayTG(), day_obj.getDayDZ()
def calculate_lucky(saju: dict, analysis: dict, target_date: _date) -> dict:
"""오늘의 럭키 정보."""
yongshin = analysis["yong_shin"]["yong_shin"]
color = LUCKY_COLOR_BY_ELEMENT.get(yongshin, ["흰색"])
direction = LUCKY_DIRECTION_BY_ELEMENT.get(yongshin, "중앙")
# 럭키 숫자: 오늘 일진 천간+지지 인덱스 합 % 9 + 1 → 1~9
day_stem_idx, day_branch_idx = _day_stem_branch_idx(target_date)
number = (day_stem_idx + day_branch_idx) % 9 + 1
# 행운/위험
good_signs: List[str] = []
warnings: List[str] = []
today_stem = HEAVENLY_STEMS[day_stem_idx]
today_branch = EARTHLY_BRANCHES[day_branch_idx]
day_master = saju["day_stem"]
day_elem = FIVE_ELEMENTS[day_master]
today_elem = FIVE_ELEMENTS[today_stem]
if KE_CYCLE.get(day_elem) == today_elem:
good_signs.append("재물 기회가 다가옵니다")
if KE_CYCLE.get(today_elem) == day_elem:
warnings.append("강한 압박이 있을 수 있어요")
LIU_CHONG = {
frozenset(["", ""]), frozenset(["", ""]),
frozenset(["", ""]), frozenset(["", ""]),
frozenset(["", ""]), frozenset(["", ""]),
}
LIU_HE = {
frozenset(["", ""]), frozenset(["", ""]),
frozenset(["", ""]), frozenset(["", ""]),
frozenset(["", ""]), frozenset(["", ""]),
}
day_branch = saju["day"]["branch"]
if frozenset([day_branch, today_branch]) in LIU_CHONG:
warnings.append("대인 갈등에 주의하세요")
if frozenset([day_branch, today_branch]) in LIU_HE:
good_signs.append("좋은 인연을 만날 수 있어요")
return {
"color": color,
"number": number,
"direction": direction,
"good_signs": good_signs,
"warnings": warnings,
}

View File

@@ -0,0 +1,91 @@
"""12개월 운세 흐름 — 월운(月運) + 일간 관계."""
from typing import List
from .constants import (
HEAVENLY_STEMS, EARTHLY_BRANCHES,
FIVE_ELEMENTS, SHENG_CYCLE, KE_CYCLE,
)
# 寅월부터 시작 (1월=寅, 2월=卯, ..., 12월=丑)
MONTH_BRANCHES = ["", "", "", "", "", "", "", "", "", "", "", ""]
LIU_CHONG = {
frozenset(["", ""]), frozenset(["", ""]),
frozenset(["", ""]), frozenset(["", ""]),
frozenset(["", ""]), frozenset(["", ""]),
}
LIU_HE = {
frozenset(["", ""]), frozenset(["", ""]),
frozenset(["", ""]), frozenset(["", ""]),
frozenset(["", ""]), frozenset(["", ""]),
}
def _month_stem_for_year(year_stem: str, branch_idx: int) -> str:
"""월간(月干) — saju-web의 공식: (yearStemIdx * 2 + branchIdx) % 10."""
year_stem_idx = HEAVENLY_STEMS.index(year_stem)
stem_idx = (year_stem_idx * 2 + branch_idx) % 10
return HEAVENLY_STEMS[stem_idx]
def _label_for(score: int) -> str:
if score >= 80:
return "성장"
if score >= 65:
return "안정"
if score >= 50:
return "변동"
if score >= 35:
return "도전"
return "정체"
def calculate_monthly_flow(saju: dict, year: int) -> List[dict]:
"""12개월 운세 흐름."""
day_stem = saju["day_stem"]
day_element = FIVE_ELEMENTS[day_stem]
day_branch = saju["day"]["branch"]
year_stem = saju["year"]["stem"]
flow: List[dict] = []
for i, branch in enumerate(MONTH_BRANCHES):
branch_idx = EARTHLY_BRANCHES.index(branch)
stem = _month_stem_for_year(year_stem, branch_idx)
stem_element = FIVE_ELEMENTS[stem]
branch_element = FIVE_ELEMENTS[branch]
score = 60
if SHENG_CYCLE.get(day_element) == stem_element:
score += 5
elif SHENG_CYCLE.get(stem_element) == day_element:
score += 10
elif KE_CYCLE.get(day_element) == stem_element:
score += 8
elif KE_CYCLE.get(stem_element) == day_element:
score -= 8
elif stem_element == day_element:
score += 3
if frozenset([day_branch, branch]) in LIU_HE:
score += 10
elif frozenset([day_branch, branch]) in LIU_CHONG:
score -= 12
if SHENG_CYCLE.get(branch_element) == day_element:
score += 4
elif KE_CYCLE.get(branch_element) == day_element:
score -= 4
score = max(0, min(100, score))
flow.append({
"month": i + 1,
"stem": stem,
"branch": branch,
"score": score,
"label": _label_for(score),
})
return flow

View File

@@ -46,6 +46,17 @@ def init_db() -> None:
CREATE INDEX IF NOT EXISTS idx_saju_created CREATE INDEX IF NOT EXISTS idx_saju_created
ON saju_records(created_at DESC) ON saju_records(created_at DESC)
""") """)
# 신규 컬럼 ALTER (idempotent — 이미 있으면 OperationalError로 skip)
for col in (
"fortune_scores_json TEXT",
"lucky_json TEXT",
"monthly_flow_json TEXT",
):
try:
conn.execute(f"ALTER TABLE saju_records ADD COLUMN {col}")
except sqlite3.OperationalError:
pass
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS compat_records ( CREATE TABLE IF NOT EXISTS compat_records (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -76,8 +87,9 @@ def save_saju_record(data: Dict[str, Any]) -> int:
"""INSERT INTO saju_records """INSERT INTO saju_records
(birth_year, birth_month, birth_day, birth_hour, gender, calendar_type, (birth_year, birth_month, birth_day, birth_hour, gender, calendar_type,
saju_data, analysis_data, daeun_data, interpretation_json, saju_data, analysis_data, daeun_data, interpretation_json,
model, tokens_in, tokens_out, cost_usd, latency_ms, reroll_count) model, tokens_in, tokens_out, cost_usd, latency_ms, reroll_count,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", fortune_scores_json, lucky_json, monthly_flow_json)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
( (
data["birth_year"], data["birth_month"], data["birth_day"], data["birth_year"], data["birth_month"], data["birth_day"],
data.get("birth_hour"), data["gender"], data.get("calendar_type", "solar"), data.get("birth_hour"), data["gender"], data.get("calendar_type", "solar"),
@@ -89,6 +101,9 @@ def save_saju_record(data: Dict[str, Any]) -> int:
data.get("tokens_in", 0), data.get("tokens_out", 0), data.get("tokens_in", 0), data.get("tokens_out", 0),
data.get("cost_usd", 0.0), data.get("latency_ms", 0), data.get("cost_usd", 0.0), data.get("latency_ms", 0),
data.get("reroll_count", 0), data.get("reroll_count", 0),
json.dumps(data["fortune_scores_json"], ensure_ascii=False) if data.get("fortune_scores_json") else None,
json.dumps(data["lucky_json"], ensure_ascii=False) if data.get("lucky_json") else None,
json.dumps(data["monthly_flow_json"], ensure_ascii=False) if data.get("monthly_flow_json") else None,
), ),
) )
return int(cur.lastrowid) return int(cur.lastrowid)
@@ -138,18 +153,29 @@ def delete_saju_record(record_id: int) -> None:
def _saju_row_to_dict(r) -> Dict[str, Any]: def _saju_row_to_dict(r) -> Dict[str, Any]:
def _safe_json(val):
if val is None:
return None
try:
return json.loads(val)
except (ValueError, TypeError):
return None
return { return {
"id": r["id"], "id": r["id"],
"created_at": r["created_at"], "created_at": r["created_at"],
"birth_year": r["birth_year"], "birth_month": r["birth_month"], "birth_day": r["birth_day"], "birth_year": r["birth_year"], "birth_month": r["birth_month"], "birth_day": r["birth_day"],
"birth_hour": r["birth_hour"], "gender": r["gender"], "calendar_type": r["calendar_type"], "birth_hour": r["birth_hour"], "gender": r["gender"], "calendar_type": r["calendar_type"],
"saju_data": json.loads(r["saju_data"]) if r["saju_data"] else None, "saju_data": _safe_json(r["saju_data"]),
"analysis_data": json.loads(r["analysis_data"]) if r["analysis_data"] else None, "analysis_data": _safe_json(r["analysis_data"]),
"daeun_data": json.loads(r["daeun_data"]) if r["daeun_data"] else None, "daeun_data": _safe_json(r["daeun_data"]),
"interpretation_json": json.loads(r["interpretation_json"]) if r["interpretation_json"] else None, "interpretation_json": _safe_json(r["interpretation_json"]),
"model": r["model"], "tokens_in": r["tokens_in"], "tokens_out": r["tokens_out"], "model": r["model"], "tokens_in": r["tokens_in"], "tokens_out": r["tokens_out"],
"cost_usd": r["cost_usd"], "latency_ms": r["latency_ms"], "reroll_count": r["reroll_count"], "cost_usd": r["cost_usd"], "latency_ms": r["latency_ms"], "reroll_count": r["reroll_count"],
"favorite": int(r["favorite"]), "memo": r["memo"], "favorite": int(r["favorite"]), "memo": r["memo"],
"fortune_scores": _safe_json(r["fortune_scores_json"]) if "fortune_scores_json" in r.keys() else None,
"lucky": _safe_json(r["lucky_json"]) if "lucky_json" in r.keys() else None,
"monthly_flow": _safe_json(r["monthly_flow_json"]) if "monthly_flow_json" in r.keys() else None,
} }

View File

@@ -34,6 +34,9 @@ class SajuInterpretResponse(BaseModel):
cost_usd: float cost_usd: float
latency_ms: int latency_ms: int
reroll_count: int = 0 reroll_count: int = 0
fortune_scores: dict
lucky: dict
monthly_flow: List[dict]
class CompatInterpretResponse(BaseModel): class CompatInterpretResponse(BaseModel):

View File

@@ -22,7 +22,8 @@ router = APIRouter(prefix="/api/saju")
@router.post("/interpret", response_model=SajuInterpretResponse) @router.post("/interpret", response_model=SajuInterpretResponse)
async def interpret_saju_endpoint(req: SajuInterpretRequest): async def interpret_saju_endpoint(req: SajuInterpretRequest):
"""사주 입력 → 계산 + AI 해석 + DB 저장.""" """사주 입력 → 계산 + AI 해석 + DB 저장."""
# 음력 입력 시 양력 변환 from datetime import date
if req.calendar_type == "lunar": if req.calendar_type == "lunar":
sy, sm, sd = lunar_to_solar(req.year, req.month, req.day, req.is_leap_month) sy, sm, sd = lunar_to_solar(req.year, req.month, req.day, req.is_leap_month)
else: else:
@@ -32,6 +33,13 @@ async def interpret_saju_endpoint(req: SajuInterpretRequest):
saju = calculate_saju(sy, sm, sd, req.hour, req.gender) saju = calculate_saju(sy, sm, sd, req.hour, req.gender)
analysis = perform_full_analysis(saju, 2026) analysis = perform_full_analysis(saju, 2026)
daeun = calculate_daeun(sy, sm, sd, req.gender, saju["month"]["stem"], saju["month"]["branch"]) daeun = calculate_daeun(sy, sm, sd, req.gender, saju["month"]["stem"], saju["month"]["branch"])
# 신규
from ..calculator.fortune_scores import calculate_fortune_scores
from ..calculator.lucky import calculate_lucky
from ..calculator.monthly_flow import calculate_monthly_flow
fortune_scores = calculate_fortune_scores(saju, analysis, 2026)
lucky = calculate_lucky(saju, analysis, date.today())
monthly_flow = calculate_monthly_flow(saju, 2026)
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f"계산 실패: {e}") raise HTTPException(status_code=400, detail=f"계산 실패: {e}")
@@ -40,7 +48,6 @@ async def interpret_saju_endpoint(req: SajuInterpretRequest):
except pipeline.SajuError as e: except pipeline.SajuError as e:
raise HTTPException(status_code=500, detail=str(e)) from e raise HTTPException(status_code=500, detail=str(e)) from e
# DB 저장
rid = db_module.save_saju_record({ rid = db_module.save_saju_record({
"birth_year": req.year, "birth_month": req.month, "birth_day": req.day, "birth_year": req.year, "birth_month": req.month, "birth_day": req.day,
"birth_hour": req.hour, "gender": req.gender, "birth_hour": req.hour, "gender": req.gender,
@@ -55,6 +62,9 @@ async def interpret_saju_endpoint(req: SajuInterpretRequest):
"cost_usd": interp_result["cost_usd"], "cost_usd": interp_result["cost_usd"],
"latency_ms": interp_result["latency_ms"], "latency_ms": interp_result["latency_ms"],
"reroll_count": interp_result["reroll_count"], "reroll_count": interp_result["reroll_count"],
"fortune_scores_json": fortune_scores,
"lucky_json": lucky,
"monthly_flow_json": monthly_flow,
}) })
return { return {
@@ -69,6 +79,9 @@ async def interpret_saju_endpoint(req: SajuInterpretRequest):
"cost_usd": interp_result["cost_usd"], "cost_usd": interp_result["cost_usd"],
"latency_ms": interp_result["latency_ms"], "latency_ms": interp_result["latency_ms"],
"reroll_count": interp_result["reroll_count"], "reroll_count": interp_result["reroll_count"],
"fortune_scores": fortune_scores,
"lucky": lucky,
"monthly_flow": monthly_flow,
} }

View File

@@ -0,0 +1,66 @@
import sqlite3
import pytest
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))
yield
try:
if db_file.exists():
db_file.unlink()
except PermissionError:
pass
def test_new_columns_exist():
db_module.init_db()
conn = sqlite3.connect(db_module.DB_PATH)
cols = [row[1] for row in conn.execute("PRAGMA table_info(saju_records)").fetchall()]
conn.close()
assert "fortune_scores_json" in cols
assert "lucky_json" in cols
assert "monthly_flow_json" in cols
def test_idempotent_init():
db_module.init_db()
db_module.init_db() # 두 번째 호출 — ALTER TABLE이 OperationalError 캐치
def test_save_and_get_with_new_fields():
db_module.init_db()
rid = db_module.save_saju_record({
"birth_year": 1990, "birth_month": 5, "birth_day": 15, "birth_hour": 14,
"gender": "male", "calendar_type": "solar",
"saju_data": {"day_stem": ""},
"analysis_data": {"element_balance": {"": 3.0}},
"daeun_data": [{"age": 10}],
"interpretation_json": {"items": []},
"model": "claude-sonnet-4-6",
"tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005,
"fortune_scores_json": {"wealth": 80, "romance": 60, "social": 70, "career": 75, "overall": 73},
"lucky_json": {"color": ["청록"], "number": 5, "direction": "동쪽", "good_signs": ["S1"], "warnings": []},
"monthly_flow_json": [{"month": 1, "stem": "", "branch": "", "score": 65, "label": "변동"}],
})
row = db_module.get_saju_record(rid)
assert row["fortune_scores"]["wealth"] == 80
assert row["lucky"]["number"] == 5
assert row["monthly_flow"][0]["month"] == 1
def test_save_without_new_fields_backwards_compat():
db_module.init_db()
rid = db_module.save_saju_record({
"birth_year": 1990, "birth_month": 5, "birth_day": 15, "birth_hour": None,
"gender": "male",
"saju_data": {}, "analysis_data": {}, "daeun_data": [],
"model": "x",
})
row = db_module.get_saju_record(rid)
assert row["fortune_scores"] is None
assert row["lucky"] is None
assert row["monthly_flow"] is None

View File

@@ -0,0 +1,56 @@
import json
from pathlib import Path
import pytest
from app.calculator.core import calculate_saju
from app.calculator.analysis import perform_full_analysis
from app.calculator.fortune_scores import calculate_fortune_scores
def _saju_for(year, month, day, hour, gender):
saju = calculate_saju(year, month, day, hour, gender)
analysis = perform_full_analysis(saju, 2026)
return saju, analysis
def test_scores_in_valid_range():
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
result = calculate_fortune_scores(saju, analysis, 2026)
for key in ("wealth", "romance", "social", "career", "overall"):
assert key in result, f"missing key: {key}"
assert 0 <= result[key] <= 100, f"{key}={result[key]} out of range"
def test_overall_weighted_average():
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
r = calculate_fortune_scores(saju, analysis, 2026)
expected = round(
r["wealth"] * 0.3 + r["career"] * 0.3 + r["romance"] * 0.2 + r["social"] * 0.2
)
assert abs(r["overall"] - expected) <= 1, f"overall mismatch: {r['overall']} vs {expected}"
def test_clamping_lower_bound():
saju, analysis = _saju_for(2000, 2, 29, 12, "male")
r = calculate_fortune_scores(saju, analysis, 2026)
assert all(v >= 0 for v in r.values())
def test_clamping_upper_bound():
saju, analysis = _saju_for(1985, 1, 1, 0, "female")
r = calculate_fortune_scores(saju, analysis, 2026)
assert all(v <= 100 for v in r.values())
def test_different_inputs_different_scores():
s1, a1 = _saju_for(1990, 5, 15, 14, "male")
s2, a2 = _saju_for(1985, 1, 1, 0, "female")
r1 = calculate_fortune_scores(s1, a1, 2026)
r2 = calculate_fortune_scores(s2, a2, 2026)
assert r1 != r2, "scores should differ for different sajus"
def test_handles_missing_hour():
saju, analysis = _saju_for(1990, 5, 15, None, "male")
r = calculate_fortune_scores(saju, analysis, 2026)
assert all(0 <= v <= 100 for v in r.values())

View File

@@ -0,0 +1,63 @@
from datetime import date
import pytest
from app.calculator.core import calculate_saju
from app.calculator.analysis import perform_full_analysis
from app.calculator.lucky import calculate_lucky
def _saju_for(year, month, day, hour, gender):
saju = calculate_saju(year, month, day, hour, gender)
analysis = perform_full_analysis(saju, 2026)
return saju, analysis
def test_lucky_keys():
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
r = calculate_lucky(saju, analysis, date(2026, 5, 26))
for k in ("color", "number", "direction", "good_signs", "warnings"):
assert k in r, f"missing {k}"
def test_lucky_number_range():
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
r = calculate_lucky(saju, analysis, date(2026, 5, 26))
assert 1 <= r["number"] <= 9
def test_lucky_color_from_yongshin():
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
r = calculate_lucky(saju, analysis, date(2026, 5, 26))
assert isinstance(r["color"], list)
assert len(r["color"]) >= 1
yongshin = analysis["yong_shin"]["yong_shin"]
valid_colors_by_element = {
"": {"청록", "녹색"},
"": {"빨강", "주황"},
"": {"황색", "베이지"},
"": {"흰색", "은색"},
"": {"파랑", "검정"},
}
expected_set = valid_colors_by_element[yongshin]
assert all(c in expected_set for c in r["color"])
def test_lucky_direction_from_yongshin():
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
r = calculate_lucky(saju, analysis, date(2026, 5, 26))
valid_dirs = {"동쪽", "남쪽", "중앙", "서쪽", "북쪽"}
assert r["direction"] in valid_dirs
def test_good_signs_and_warnings_are_lists():
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
r = calculate_lucky(saju, analysis, date(2026, 5, 26))
assert isinstance(r["good_signs"], list)
assert isinstance(r["warnings"], list)
def test_handles_missing_hour():
saju, analysis = _saju_for(1990, 5, 15, None, "male")
r = calculate_lucky(saju, analysis, date(2026, 5, 26))
assert 1 <= r["number"] <= 9

View File

@@ -0,0 +1,38 @@
import pytest
from app.calculator.core import calculate_saju
from app.calculator.monthly_flow import calculate_monthly_flow
def test_returns_12_entries():
saju = calculate_saju(1990, 5, 15, 14, "male")
flow = calculate_monthly_flow(saju, 2026)
assert len(flow) == 12
def test_entries_have_required_keys():
saju = calculate_saju(1990, 5, 15, 14, "male")
flow = calculate_monthly_flow(saju, 2026)
for i, entry in enumerate(flow):
assert entry["month"] == i + 1
for k in ("stem", "branch", "score", "label"):
assert k in entry, f"month {i+1} missing {k}"
assert 0 <= entry["score"] <= 100
def test_labels_are_valid():
saju = calculate_saju(1990, 5, 15, 14, "male")
flow = calculate_monthly_flow(saju, 2026)
valid_labels = {"변동", "성장", "안정", "도전", "정체"}
for entry in flow:
assert entry["label"] in valid_labels
def test_different_sajus_different_flows():
s1 = calculate_saju(1990, 5, 15, 14, "male")
s2 = calculate_saju(1985, 1, 1, 0, "female")
f1 = calculate_monthly_flow(s1, 2026)
f2 = calculate_monthly_flow(s2, 2026)
scores_1 = [e["score"] for e in f1]
scores_2 = [e["score"] for e in f2]
assert scores_1 != scores_2

View File

@@ -38,11 +38,10 @@ def test_health():
def test_saju_interpret_endpoint(monkeypatch): def test_saju_interpret_endpoint(monkeypatch):
"""saju interpret이 pipeline mock으로 동작.""" """saju interpret이 pipeline mock으로 동작 + 신규 필드 검증."""
async def fake_interpret(*args, **kwargs): async def fake_interpret(*args, **kwargs):
return _interpret_result() return _interpret_result()
# interpret_saju를 mock
from app.routers import saju as saju_router from app.routers import saju as saju_router
monkeypatch.setattr(saju_router.pipeline, "interpret_saju", fake_interpret) monkeypatch.setattr(saju_router.pipeline, "interpret_saju", fake_interpret)
@@ -58,6 +57,16 @@ def test_saju_interpret_endpoint(monkeypatch):
assert "daeun" in data assert "daeun" in data
assert "reading_id" in data assert "reading_id" in data
assert data["reading_id"] > 0 assert data["reading_id"] > 0
# 신규 필드
assert "fortune_scores" in data
for k in ("wealth", "romance", "social", "career", "overall"):
assert k in data["fortune_scores"]
assert 0 <= data["fortune_scores"][k] <= 100
assert "lucky" in data
for k in ("color", "number", "direction", "good_signs", "warnings"):
assert k in data["lucky"]
assert "monthly_flow" in data
assert len(data["monthly_flow"]) == 12
def test_saju_list_get_cycle(monkeypatch): def test_saju_list_get_cycle(monkeypatch):