Compare commits
7 Commits
af836df1ac
...
03e1dc1dbb
| Author | SHA1 | Date | |
|---|---|---|---|
| 03e1dc1dbb | |||
| f57c790437 | |||
| 030367da6c | |||
| 429e3448e5 | |||
| 579e7387be | |||
| 8ef0ba81f2 | |||
| afb4175bd5 |
2913
docs/superpowers/plans/2026-05-26-saju-ui-v1.md
Normal file
2913
docs/superpowers/plans/2026-05-26-saju-ui-v1.md
Normal file
File diff suppressed because it is too large
Load Diff
387
docs/superpowers/specs/2026-05-26-saju-ui-design.md
Normal file
387
docs/superpowers/specs/2026-05-26-saju-ui-design.md
Normal 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)
|
||||||
114
saju-lab/app/calculator/fortune_scores.py
Normal file
114
saju-lab/app/calculator/fortune_scores.py
Normal 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,
|
||||||
|
}
|
||||||
84
saju-lab/app/calculator/lucky.py
Normal file
84
saju-lab/app/calculator/lucky.py
Normal 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,
|
||||||
|
}
|
||||||
91
saju-lab/app/calculator/monthly_flow.py
Normal file
91
saju-lab/app/calculator/monthly_flow.py
Normal 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
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
66
saju-lab/tests/test_db_migration.py
Normal file
66
saju-lab/tests/test_db_migration.py
Normal 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
|
||||||
56
saju-lab/tests/test_fortune_scores.py
Normal file
56
saju-lab/tests/test_fortune_scores.py
Normal 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())
|
||||||
63
saju-lab/tests/test_lucky.py
Normal file
63
saju-lab/tests/test_lucky.py
Normal 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
|
||||||
38
saju-lab/tests/test_monthly_flow.py
Normal file
38
saju-lab/tests/test_monthly_flow.py
Normal 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
|
||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user