# saju-lab UI v1 — 호령 사주 페이지 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 시안 기반 호령 사주 페이지 v1 (메인 + 사주풀이 + 오늘운세) 구축. 백엔드는 fortune_scores + lucky + monthly_flow 산출 추가. **Architecture:** saju-lab 백엔드에 3 신규 calculator 모듈 + 응답 확장 + DB schema migration. web-ui에 호령 캐릭터 자산 + saju-page scope CSS + 3 페이지 컴포넌트 + 공통 컴포넌트. 데이터 흐름은 메인에서 1회 입력 → reading_id URL query로 다른 페이지 공유. **Tech Stack:** Python 3.12 + FastAPI + SQLite (saju-lab 확장) / React 18 + Vite + React Router v6 + Pretendard + Noto Serif KR (web-ui). PIL for 캐릭터 PNG 추출. **Reference spec:** `docs/superpowers/specs/2026-05-26-saju-ui-design.md` --- ## Phase A — 백엔드 확장 (saju-lab) ### Task 1: calculator/fortune_scores.py — 4 카테고리 점수 **Files:** - Create: `saju-lab/app/calculator/fortune_scores.py` - Create: `saju-lab/tests/test_fortune_scores.py` - [ ] **Step 1: 실패 테스트 작성** `saju-lab/tests/test_fortune_scores.py`: ```python 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(): """매우 약한 입력 — score는 0 미만이 될 수 없음.""" 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(): """매우 강한 입력 — score는 100 초과 될 수 없음.""" 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(): """다른 사주는 다른 점수 — degenerate to all-same-60 방지.""" 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()) ``` - [ ] **Step 2: Run test, verify FAIL** Run: `cd saju-lab && python -m pytest tests/test_fortune_scores.py -v` Expected: FAIL with `ModuleNotFoundError: app.calculator.fortune_scores` - [ ] **Step 3: 구현 작성** `saju-lab/app/calculator/fortune_scores.py`: ```python """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, } ``` - [ ] **Step 4: Run tests, verify PASS** Run: `cd saju-lab && python -m pytest tests/test_fortune_scores.py -v` Expected: 6 passed - [ ] **Step 5: Commit** ```bash git add saju-lab/app/calculator/fortune_scores.py saju-lab/tests/test_fortune_scores.py git commit -m "feat(saju-lab): fortune_scores.py — 4 카테고리 점수 + overall (6 tests)" ``` --- ### Task 2: calculator/lucky.py — 럭키 데이터 **Files:** - Create: `saju-lab/app/calculator/lucky.py` - Create: `saju-lab/tests/test_lucky.py` - [ ] **Step 1: 실패 테스트 작성** `saju-lab/tests/test_lucky.py`: ```python 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 ``` - [ ] **Step 2: Run, expect FAIL** Run: `cd saju-lab && python -m pytest tests/test_lucky.py -v` Expected: FAIL — ModuleNotFoundError - [ ] **Step 3: 구현** `saju-lab/app/calculator/lucky.py`: ```python """오늘의 럭키 컬러/숫자/방향 + 행운/위험 알림.""" from datetime import date as _date from typing import Dict, List import sxtwl from .constants import HEAVENLY_STEMS, EARTHLY_BRANCHES, FIVE_ELEMENTS 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] from .constants import KE_CYCLE if KE_CYCLE.get(day_elem) == today_elem: good_signs.append("재물 기회가 다가옵니다") if KE_CYCLE.get(today_elem) == day_elem: warnings.append("강한 압박이 있을 수 있어요") # 오늘 지지가 일지와 6충이면 경고 LIU_CHONG = { frozenset(["子", "午"]), frozenset(["丑", "未"]), frozenset(["寅", "申"]), frozenset(["卯", "酉"]), frozenset(["辰", "戌"]), frozenset(["巳", "亥"]), } day_branch = saju["day"]["branch"] if frozenset([day_branch, today_branch]) in LIU_CHONG: warnings.append("대인 갈등에 주의하세요") # 오늘 지지가 일지와 6합이면 행운 LIU_HE = { frozenset(["子", "丑"]), frozenset(["寅", "亥"]), frozenset(["卯", "戌"]), frozenset(["辰", "酉"]), frozenset(["巳", "申"]), frozenset(["午", "未"]), } 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, } ``` - [ ] **Step 4: Run tests, PASS** Run: `cd saju-lab && python -m pytest tests/test_lucky.py -v` Expected: 6 passed - [ ] **Step 5: Commit** ```bash git add saju-lab/app/calculator/lucky.py saju-lab/tests/test_lucky.py git commit -m "feat(saju-lab): lucky.py — 럭키 컬러/숫자/방향 + 행운/위험 알림 (6 tests)" ``` --- ### Task 3: calculator/monthly_flow.py — 12개월 운세 흐름 **Files:** - Create: `saju-lab/app/calculator/monthly_flow.py` - Create: `saju-lab/tests/test_monthly_flow.py` - [ ] **Step 1: 테스트** `saju-lab/tests/test_monthly_flow.py`: ```python 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 ``` - [ ] **Step 2: Run, FAIL** Run: `cd saju-lab && python -m pytest tests/test_monthly_flow.py -v` Expected: FAIL - [ ] **Step 3: 구현** `saju-lab/app/calculator/monthly_flow.py`: ```python """12개월 운세 흐름 — 월운(月運) + 일간 관계.""" from typing import List from .constants import ( HEAVENLY_STEMS, EARTHLY_BRANCHES, FIVE_ELEMENTS, SHENG_CYCLE, KE_CYCLE, ) # 寅월부터 시작하는 월지 순서 (1월=寅, 2월=卯, ..., 12월=丑) MONTH_BRANCHES = ["寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥", "子", "丑"] # 6충/6합 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개월 운세 흐름. 각 월: 월간/월지 + 일간과의 관계로 score 산출. """ 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 ``` - [ ] **Step 4: Run, PASS** Run: `cd saju-lab && python -m pytest tests/test_monthly_flow.py -v` Expected: 4 passed - [ ] **Step 5: Commit** ```bash git add saju-lab/app/calculator/monthly_flow.py saju-lab/tests/test_monthly_flow.py git commit -m "feat(saju-lab): monthly_flow.py — 12개월 운세 흐름 (4 tests)" ``` --- ### Task 4: db.py — saju_records 3 컬럼 추가 (ALTER TABLE) **Files:** - Modify: `saju-lab/app/db.py` - Create: `saju-lab/tests/test_db_migration.py` - [ ] **Step 1: 마이그레이션 테스트** `saju-lab/tests/test_db_migration.py`: ```python 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(): """init_db 후 3 신규 컬럼이 존재.""" 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(): """init_db를 두 번 호출해도 에러 없음.""" 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(): """기존 호출 패턴 (새 필드 없이) 도 동작 (NULL 저장).""" 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 ``` - [ ] **Step 2: Run, expect FAIL** Run: `cd saju-lab && python -m pytest tests/test_db_migration.py -v` Expected: FAIL — column doesn't exist - [ ] **Step 3: db.py 수정** `saju-lab/app/db.py`의 `init_db()` 함수 끝(L70 직전)에 ALTER TABLE 추가: ```python def init_db() -> None: with _conn() as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS saju_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, birth_year INTEGER NOT NULL, birth_month INTEGER NOT NULL, birth_day INTEGER NOT NULL, birth_hour INTEGER, gender TEXT NOT NULL, calendar_type TEXT DEFAULT 'solar', saju_data TEXT NOT NULL, analysis_data TEXT NOT NULL, daeun_data TEXT NOT NULL, interpretation_json TEXT, model TEXT, tokens_in INTEGER DEFAULT 0, tokens_out INTEGER DEFAULT 0, cost_usd REAL DEFAULT 0, latency_ms INTEGER DEFAULT 0, reroll_count INTEGER DEFAULT 0, favorite INTEGER DEFAULT 0, memo TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ) """) conn.execute(""" CREATE INDEX IF NOT EXISTS idx_saju_created 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 # already exists conn.execute(""" CREATE TABLE IF NOT EXISTS compat_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, person_a TEXT NOT NULL, person_b TEXT NOT NULL, saju_a TEXT NOT NULL, saju_b TEXT NOT NULL, score INTEGER NOT NULL, breakdown TEXT NOT NULL, interpretation_json TEXT, model TEXT, tokens_in INTEGER DEFAULT 0, tokens_out INTEGER DEFAULT 0, cost_usd REAL DEFAULT 0, latency_ms INTEGER DEFAULT 0, reroll_count INTEGER DEFAULT 0, favorite INTEGER DEFAULT 0, memo TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ) """) ``` 그리고 `save_saju_record`를 신규 컬럼 저장하도록 수정: ```python def save_saju_record(data: Dict[str, Any]) -> int: with _conn() as conn: cur = conn.execute( """INSERT INTO saju_records (birth_year, birth_month, birth_day, birth_hour, gender, calendar_type, saju_data, analysis_data, daeun_data, interpretation_json, model, tokens_in, tokens_out, cost_usd, latency_ms, reroll_count, fortune_scores_json, lucky_json, monthly_flow_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( data["birth_year"], data["birth_month"], data["birth_day"], data.get("birth_hour"), data["gender"], data.get("calendar_type", "solar"), json.dumps(data["saju_data"], ensure_ascii=False), json.dumps(data["analysis_data"], ensure_ascii=False), json.dumps(data["daeun_data"], ensure_ascii=False), json.dumps(data.get("interpretation_json"), ensure_ascii=False) if data.get("interpretation_json") else None, data.get("model"), data.get("tokens_in", 0), data.get("tokens_out", 0), data.get("cost_usd", 0.0), data.get("latency_ms", 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) ``` 그리고 `_saju_row_to_dict`에 3 컬럼 응답 추가: ```python 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 { "id": r["id"], "created_at": r["created_at"], "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"], "saju_data": _safe_json(r["saju_data"]), "analysis_data": _safe_json(r["analysis_data"]), "daeun_data": _safe_json(r["daeun_data"]), "interpretation_json": _safe_json(r["interpretation_json"]), "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"], "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, } ``` - [ ] **Step 4: Run all db tests, PASS** Run: `cd saju-lab && python -m pytest tests/test_db.py tests/test_db_migration.py -v` Expected: 10 (기존 test_db.py) + 4 (test_db_migration.py) = 14 passed 만약 기존 `test_db.py`가 실패하면 (row.keys() 검사 등 행동 변화로 인해), 기존 테스트 확인 후 호환 유지. - [ ] **Step 5: Commit** ```bash git add saju-lab/app/db.py saju-lab/tests/test_db_migration.py git commit -m "feat(saju-lab): db.py — saju_records 3 컬럼 추가 (fortune_scores/lucky/monthly_flow) + 4 마이그레이션 테스트" ``` --- ### Task 5: routers/saju.py + models.py — 응답 확장 **Files:** - Modify: `saju-lab/app/models.py` - Modify: `saju-lab/app/routers/saju.py` - Modify: `saju-lab/tests/test_routes.py` - [ ] **Step 1: models.py에 3 필드 추가** `saju-lab/app/models.py`의 `SajuInterpretResponse` 클래스 수정: ```python class SajuInterpretResponse(BaseModel): saju: dict analysis: dict daeun: List[dict] interpretation_json: dict reading_id: int model: str tokens_in: int tokens_out: int cost_usd: float latency_ms: int reroll_count: int = 0 fortune_scores: dict lucky: dict monthly_flow: List[dict] ``` - [ ] **Step 2: routers/saju.py 수정** `saju-lab/app/routers/saju.py`의 `interpret_saju_endpoint` 함수에 계산 + 저장 + 응답 추가. 기존 코드를 다음으로 교체: ```python @router.post("/interpret", response_model=SajuInterpretResponse) async def interpret_saju_endpoint(req: SajuInterpretRequest): """사주 입력 → 계산 + AI 해석 + DB 저장.""" from datetime import date # 음력 입력 시 양력 변환 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"]) # 신규 — 4 카테고리 + 럭키 + 월운 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: 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 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"], "fortune_scores_json": fortune_scores, "lucky_json": lucky, "monthly_flow_json": monthly_flow, }) 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"], "fortune_scores": fortune_scores, "lucky": lucky, "monthly_flow": monthly_flow, } ``` - [ ] **Step 3: test_routes.py 수정 — 응답에 새 필드 검증 추가** 기존 `test_saju_interpret_endpoint` 테스트의 assert 부분 강화: ```python def test_saju_interpret_endpoint(monkeypatch): """saju interpret이 pipeline mock으로 동작 + 신규 필드 검증.""" 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: 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 # 신규 필드 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 ``` - [ ] **Step 4: Run all route tests** Run: `cd saju-lab && python -m pytest tests/test_routes.py -v` Expected: 10 passed (기존 10개, 그중 1개 강화) - [ ] **Step 5: Commit** ```bash git add saju-lab/app/models.py saju-lab/app/routers/saju.py saju-lab/tests/test_routes.py git commit -m "feat(saju-lab): /interpret 응답에 fortune_scores + lucky + monthly_flow 포함" ``` --- ## Phase B — 캐릭터 자산 추출 ### Task 6: 호령 6 PNG 추출 (Python PIL) **Files:** - Create: `scripts/extract_horyung.py` (web-backend 또는 web-ui 어디든) - Create: `web-ui/public/images/saju/horyung/horyung-front.png` - Create: `web-ui/public/images/saju/horyung/horyung-bust.png` - Create: `web-ui/public/images/saju/horyung/horyung-greeting.png` - Create: `web-ui/public/images/saju/horyung/horyung-thinking.png` - Create: `web-ui/public/images/saju/horyung/horyung-pointing.png` - Create: `web-ui/public/images/saju/horyung/horyung-happy.png` - [ ] **Step 1: 소스 이미지 크기 확인** Run: ```bash python -c "from PIL import Image; im = Image.open('C:/Users/jaeoh/Desktop/workspace/source/characters/horyung.png'); print(im.size)" python -c "from PIL import Image; im = Image.open('C:/Users/jaeoh/Desktop/workspace/source/images/saju_page/saju_color_sheet.png'); print(im.size)" ``` 기록한 두 이미지 크기를 다음 step에 사용. - [ ] **Step 2: 추출 스크립트 작성** `C:/Users/jaeoh/Desktop/workspace/web-ui/scripts/extract_horyung.py`: ```python """호령 캐릭터 PNG 추출 — horyung.png(3 view) + saju_color_sheet.png(8 emotion). Usage: cd web-ui python scripts/extract_horyung.py """ import os from PIL import Image SOURCE_ROOT = "../source" HORYUNG_PATH = f"{SOURCE_ROOT}/characters/horyung.png" COLORSHEET_PATH = f"{SOURCE_ROOT}/images/saju_page/saju_color_sheet.png" OUT_DIR = "public/images/saju/horyung" os.makedirs(OUT_DIR, exist_ok=True) def crop_save(src_path, box, out_name): """src에서 box=(x1,y1,x2,y2) 영역 crop → out_name으로 저장.""" im = Image.open(src_path).convert("RGBA") cropped = im.crop(box) cropped.save(f"{OUT_DIR}/{out_name}") print(f"saved {out_name} ({cropped.size})") # horyung.png — 3 view 레이아웃 (실제 크기에 따라 좌표 조정 필요). # Step 1에서 측정한 실제 크기로 다음 비율 계산: # 시안 horyung.png는 대략 1200x1700 (세로형). 3 view는 세로로 분할: # - bust shot: 상단 1/3 # - back view: 중단 1/3 # - front view: 하단 1/3 (메인 hero용) # 다음은 1200x1700 기준 예시 좌표 — 실제 크기에 맞춰 조정: crop_save(HORYUNG_PATH, (300, 30, 900, 570), "horyung-bust.png") # bust shot crop_save(HORYUNG_PATH, (250, 1130, 950, 1690), "horyung-front.png") # front view (메인 hero) # saju_color_sheet.png — 우측 하단 캐릭터 감정 스티커 8개 (가로 4 x 세로 2) # 시안 컬러시트는 대략 1600x1100. 8 emotion sticker는 우측 하단 영역 (대략 x=1100~1600, y=850~1100) # 각 sticker는 약 125x125 EMOTION_BASE_X = 1100 EMOTION_BASE_Y = 850 EMOTION_W = 125 EMOTION_H = 125 # 4 emotion만 추출 (8 중 가장 유용한 4개) crop_save(COLORSHEET_PATH, (EMOTION_BASE_X, EMOTION_BASE_Y, EMOTION_BASE_X + EMOTION_W, EMOTION_BASE_Y + EMOTION_H), "horyung-greeting.png") crop_save(COLORSHEET_PATH, (EMOTION_BASE_X + EMOTION_W, EMOTION_BASE_Y, EMOTION_BASE_X + EMOTION_W*2, EMOTION_BASE_Y + EMOTION_H), "horyung-thinking.png") crop_save(COLORSHEET_PATH, (EMOTION_BASE_X + EMOTION_W*2, EMOTION_BASE_Y, EMOTION_BASE_X + EMOTION_W*3, EMOTION_BASE_Y + EMOTION_H), "horyung-pointing.png") crop_save(COLORSHEET_PATH, (EMOTION_BASE_X + EMOTION_W*3, EMOTION_BASE_Y, EMOTION_BASE_X + EMOTION_W*4, EMOTION_BASE_Y + EMOTION_H), "horyung-happy.png") print("Done.") ``` - [ ] **Step 3: 실행 + 결과 확인** ```bash cd C:/Users/jaeoh/Desktop/workspace/web-ui pip install Pillow # 이미 있으면 skip python scripts/extract_horyung.py ls public/images/saju/horyung/ ``` Expected: 6 PNG 파일 생성 (`horyung-front.png`, `horyung-bust.png`, `horyung-greeting.png`, `horyung-thinking.png`, `horyung-pointing.png`, `horyung-happy.png`) - [ ] **Step 4: 시각 검증** 각 PNG를 image viewer로 열어 확인. 좌표가 부정확하면 step 2의 box 좌표를 조정 후 step 3 재실행. 품질 기준: - horyung-front.png: 호령 전신이 중앙에 위치, 여백 적절 - horyung-bust.png: 호령 얼굴 + 어깨까지 보임 - horyung-greeting/thinking/pointing/happy.png: 각 표정 잘 보이고 잘림 없음 좌표 조정 필요 시 step 2 코드 수정 후 다시 실행. - [ ] **Step 5: Commit (web-ui repo)** ```bash cd C:/Users/jaeoh/Desktop/workspace/web-ui git add scripts/extract_horyung.py public/images/saju/horyung/ git commit -m "feat(saju): 호령 캐릭터 PNG 6개 추출 (horyung.png + saju_color_sheet.png)" ``` > ⚠️ Step 4에서 시각 검증 후 좌표 조정이 여러 번 필요할 수 있음. 모든 PNG가 시안 품질에 가까워야 다음 task로 진행. --- ## Phase C — 프론트엔드 구축 (web-ui) > Phase C부터 모든 작업은 `C:/Users/jaeoh/Desktop/workspace/web-ui`에서. 별도 git 저장소이므로 commit도 web-ui repo에 들어감. ### Task 7: Saju.css + 폰트 + 글로벌 토큰 **Files:** - Create: `web-ui/src/pages/saju/Saju.css` - Modify: `web-ui/index.html` (Noto Serif KR Google Fonts 추가) - [ ] **Step 1: index.html에 Noto Serif KR 추가** `web-ui/index.html`의 `
` 안에 추가: ```html ``` 기존 Pretendard 폰트는 유지. - [ ] **Step 2: Saju.css 작성** `web-ui/src/pages/saju/Saju.css`: ```css /* saju-page scope — 다른 페이지에 영향 없음 */ .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; padding: 0; margin: 0; } .saju-page * { box-sizing: border-box; } .saju-page .saju-h1, .saju-page .saju-h2, .saju-page .saju-h3 { font-family: 'Noto Serif KR', 'Pretendard', serif; font-weight: 700; letter-spacing: -0.02em; color: var(--saju-ink); margin: 0; } .saju-page .saju-h1 { font-size: clamp(2.5rem, 4vw, 3.5rem); line-height: 1.2; } .saju-page .saju-h2 { font-size: clamp(1.8rem, 3vw, 2.5rem); line-height: 1.3; } .saju-page .saju-h3 { font-size: clamp(1.2rem, 2vw, 1.5rem); } /* 호령 마스코트 */ .horyung-mascot { display: block; object-fit: contain; } .horyung-mascot--sm { width: 80px; height: auto; } .horyung-mascot--md { width: 180px; height: auto; } .horyung-mascot--lg { width: 320px; height: auto; } /* 상단 네비게이션 */ .saju-nav { display: flex; align-items: center; justify-content: space-between; padding: 1rem 2rem; background: var(--saju-ink); color: var(--saju-cream); } .saju-nav__logo { font-family: 'Noto Serif KR', serif; font-size: 1.25rem; font-weight: 700; } .saju-nav__links { display: flex; gap: 1.5rem; list-style: none; padding: 0; margin: 0; } .saju-nav__links a { color: var(--saju-cream); text-decoration: none; font-size: 0.95rem; opacity: 0.85; } .saju-nav__links a:hover { opacity: 1; } .saju-nav__cta { background: var(--saju-gold); color: var(--saju-ink); border: none; padding: 0.5rem 1.25rem; border-radius: 999px; font-weight: 600; cursor: pointer; font-family: 'Pretendard', sans-serif; } /* Hero (메인) */ .saju-hero { display: grid; grid-template-columns: 1fr 1.4fr; gap: 3rem; padding: 3rem 2rem; max-width: 1400px; margin: 0 auto; } .saju-hero__left { display: flex; flex-direction: column; align-items: center; gap: 1.5rem; } .saju-quote-box { background: var(--saju-paper); padding: 1rem 1.25rem; border-radius: 12px; border: 1px solid var(--saju-gold-deep); color: var(--saju-ink); font-size: 0.9rem; line-height: 1.5; max-width: 280px; } .saju-hero__right { display: flex; flex-direction: column; gap: 1.5rem; justify-content: center; } .saju-sub { color: var(--saju-ink); opacity: 0.7; margin: 0; line-height: 1.6; } /* ActionCard */ .saju-action-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-top: 1rem; } .saju-action-card { background: var(--saju-saju-bg); color: var(--saju-cream); padding: 1.5rem 1rem; border-radius: 16px; text-decoration: none; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; transition: transform 0.2s; font-family: 'Pretendard', sans-serif; } .saju-action-card:hover { transform: translateY(-4px); } .saju-action-card--today { background: var(--saju-today-bg); } .saju-action-card--gunghab { background: var(--saju-gunghab-bg); } .saju-action-card--saju { background: var(--saju-saju-bg); } .saju-action-card[aria-disabled="true"] { opacity: 0.6; cursor: not-allowed; } .saju-action-card__icon { font-size: 2rem; } .saju-action-card__title { font-size: 1.1rem; font-weight: 700; } .saju-action-card__desc { font-size: 0.85rem; opacity: 0.85; text-align: center; } /* Bottom (메인 — 통계 + 입력) */ .saju-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; padding: 3rem 2rem; max-width: 1400px; margin: 0 auto; background: var(--saju-ink); color: var(--saju-cream); border-radius: 24px 24px 0 0; } .saju-form { display: flex; flex-direction: column; gap: 1rem; } .saju-form input, .saju-form select { padding: 0.75rem; border-radius: 8px; border: 1px solid var(--saju-gold-deep); background: var(--saju-ink-deep); color: var(--saju-cream); font-family: inherit; font-size: 1rem; } .saju-form button { background: var(--saju-gold); color: var(--saju-ink); border: none; padding: 0.875rem; border-radius: 999px; font-weight: 700; cursor: pointer; font-family: inherit; font-size: 1rem; } .saju-form button:disabled { opacity: 0.6; cursor: not-allowed; } .saju-form__error { background: rgba(217, 162, 166, 0.2); color: var(--saju-rose); padding: 0.75rem; border-radius: 8px; font-size: 0.9rem; } /* Score / Fortune ring */ .saju-fortune-ring { display: flex; align-items: center; justify-content: center; position: relative; } .saju-fortune-ring svg { width: 200px; height: 200px; } .saju-fortune-ring__score { position: absolute; font-family: 'Noto Serif KR', serif; font-size: 2.5rem; font-weight: 700; color: var(--saju-ink); } .saju-fortune-ring__total { font-size: 0.9rem; color: var(--saju-ink); opacity: 0.6; } .saju-score-card { background: var(--saju-cream); border-radius: 16px; padding: 1.25rem; display: flex; flex-direction: column; gap: 0.5rem; border: 1px solid var(--saju-paper); } .saju-score-card__head { display: flex; align-items: center; gap: 0.5rem; } .saju-score-card__icon { font-size: 1.5rem; } .saju-score-card__title { font-weight: 700; font-size: 0.95rem; } .saju-score-card__value { font-family: 'Noto Serif KR', serif; font-size: 2rem; font-weight: 700; color: var(--saju-ink); } .saju-score-card__bar { height: 6px; background: var(--saju-paper); border-radius: 3px; overflow: hidden; } .saju-score-card__bar > div { height: 100%; background: var(--saju-gold); transition: width 0.5s; } /* Lucky box */ .saju-lucky-box { background: var(--saju-paper); border-radius: 16px; padding: 1.5rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; } .saju-lucky-box__item { text-align: center; } .saju-lucky-box__label { font-size: 0.8rem; color: var(--saju-ink); opacity: 0.7; margin-bottom: 0.25rem; } .saju-lucky-box__value { font-family: 'Noto Serif KR', serif; font-size: 1.5rem; font-weight: 700; color: var(--saju-ink); } /* SajuPillars (4기둥) */ .saju-pillars { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; } .saju-pillar { background: var(--saju-paper); border-radius: 12px; padding: 1rem; text-align: center; } .saju-pillar__label { font-size: 0.8rem; color: var(--saju-ink); opacity: 0.6; margin-bottom: 0.5rem; } .saju-pillar__stem, .saju-pillar__branch { font-family: 'Noto Serif KR', serif; font-size: 1.75rem; font-weight: 700; display: block; } .saju-pillar__stem-kr, .saju-pillar__branch-kr { font-size: 0.85rem; opacity: 0.7; } .saju-pillar__ten-god, .saju-pillar__fortune { font-size: 0.75rem; margin-top: 0.25rem; opacity: 0.7; } /* Element bar chart (오행) */ .saju-element-bars { display: flex; flex-direction: column; gap: 0.5rem; padding: 1.5rem; background: var(--saju-cream); border-radius: 16px; } .saju-element-bar { display: grid; grid-template-columns: 30px 1fr 50px; align-items: center; gap: 0.75rem; } .saju-element-bar__label { font-size: 0.9rem; font-weight: 700; } .saju-element-bar__track { height: 12px; background: var(--saju-paper); border-radius: 6px; overflow: hidden; } .saju-element-bar__fill { height: 100%; border-radius: 6px; transition: width 0.5s; } .saju-element-bar__fill--木 { background: #4B7065; } .saju-element-bar__fill--火 { background: #C56F5C; } .saju-element-bar__fill--土 { background: #D4A574; } .saju-element-bar__fill--金 { background: #B8B5A8; } .saju-element-bar__fill--水 { background: #4A5878; } .saju-element-bar__value { text-align: right; font-size: 0.85rem; opacity: 0.7; } /* Monthly flow */ .saju-monthly-flow { display: grid; grid-template-columns: repeat(12, 1fr); gap: 0.25rem; padding: 1rem; background: var(--saju-cream); border-radius: 16px; } .saju-monthly-flow__cell { display: flex; flex-direction: column; align-items: center; padding: 0.5rem 0.25rem; border-radius: 8px; background: var(--saju-paper); } .saju-monthly-flow__month { font-size: 0.7rem; opacity: 0.7; } .saju-monthly-flow__score { font-family: 'Noto Serif KR', serif; font-weight: 700; font-size: 1rem; } .saju-monthly-flow__label { font-size: 0.7rem; opacity: 0.8; margin-top: 0.25rem; } /* Horyung quote */ .saju-horyung-quote { background: var(--saju-ink); color: var(--saju-cream); padding: 1.5rem; border-radius: 16px; display: flex; gap: 1rem; align-items: flex-start; } .saju-horyung-quote__text { font-size: 0.95rem; line-height: 1.6; } /* Interpret accordion */ .saju-interpret-accordion { display: flex; flex-direction: column; gap: 0.5rem; } .saju-interpret-item { background: var(--saju-cream); border-radius: 12px; border: 1px solid var(--saju-paper); overflow: hidden; } .saju-interpret-item__header { padding: 1rem; background: var(--saju-paper); cursor: pointer; display: flex; justify-content: space-between; align-items: center; font-weight: 700; user-select: none; } .saju-interpret-item__body { padding: 1rem; font-size: 0.95rem; line-height: 1.6; } .saju-interpret-item__evidence { background: var(--saju-paper); padding: 0.75rem; border-radius: 8px; margin-top: 0.75rem; font-size: 0.85rem; opacity: 0.85; } /* Stub (Compatibility v2) */ .saju-stub { max-width: 480px; margin: 5rem auto; text-align: center; padding: 2rem; background: var(--saju-paper); border-radius: 24px; } .saju-stub a { display: inline-block; margin-top: 1.5rem; background: var(--saju-gold); color: var(--saju-ink); padding: 0.75rem 1.5rem; border-radius: 999px; text-decoration: none; font-weight: 700; } /* 반응형 */ @media (max-width: 1280px) { .saju-hero { grid-template-columns: 1fr; text-align: center; } .saju-hero__left { order: 2; } .saju-hero__right { order: 1; } .saju-bottom { grid-template-columns: 1fr; } } @media (max-width: 768px) { .saju-nav { padding: 0.75rem 1rem; flex-wrap: wrap; gap: 0.5rem; } .saju-nav__links { display: none; } .saju-action-cards { grid-template-columns: 1fr; } .saju-pillars { grid-template-columns: repeat(2, 1fr); } .saju-monthly-flow { grid-template-columns: repeat(4, 1fr); } .horyung-mascot--lg { width: 220px; } } ``` - [ ] **Step 3: 빌드 검증** ```bash cd C:/Users/jaeoh/Desktop/workspace/web-ui npm run build 2>&1 | tail -20 ``` Expected: 빌드 성공 (CSS 파일 정상 처리) - [ ] **Step 4: Commit (web-ui)** ```bash cd C:/Users/jaeoh/Desktop/workspace/web-ui git add src/pages/saju/Saju.css index.html git commit -m "feat(saju): Saju.css 컬러 토큰 + 폰트 + 격리 + Noto Serif KR Google Fonts" ``` --- ### Task 8: HoryungMascot + SajuNav 공통 컴포넌트 **Files:** - Create: `web-ui/src/pages/saju/components/HoryungMascot.jsx` - Create: `web-ui/src/pages/saju/components/SajuNav.jsx` - [ ] **Step 1: HoryungMascot 작성** `web-ui/src/pages/saju/components/HoryungMascot.jsx`: ```jsx import React from 'react'; const POSE_TO_FILE = { front: '/images/saju/horyung/horyung-front.png', bust: '/images/saju/horyung/horyung-bust.png', greeting: '/images/saju/horyung/horyung-greeting.png', thinking: '/images/saju/horyung/horyung-thinking.png', pointing: '/images/saju/horyung/horyung-pointing.png', happy: '/images/saju/horyung/horyung-happy.png', }; export default function HoryungMascot({ pose = 'front', size = 'lg', className = '' }) { const src = POSE_TO_FILE[pose] || POSE_TO_FILE.front; return (
전통 사주명리학 + AI 인사이트로
당신의 오늘을 풀어드립니다
오랜 지혜와 정성으로 다듬어진 사주명리학을 호령이 풀어드립니다.
당신의 사주 8자에 담긴 운명을 만나보세요.
사주 8자를 입력하시면 오늘의 종합점수, 4가지 카테고리 분석, 럭키 정보를 한 번에
확인하실 수 있습니다.
{it.content}
{it.evidence && (먼저 메인 페이지에서 사주를 입력해주세요.
메인으로 가기호령이 사주를 풀어보는 중...
{error || '다시 입력해주세요.'}
메인으로 가기{data.birth_year}년 {data.birth_month}월 {data.birth_day}일 {data.birth_hour !== null ? ` ${data.birth_hour}시` : ' (시간 미상)'} ·{' '} {data.gender === 'male' ? '남' : '여'} ·{' '} {data.calendar_type === 'lunar' ? '음력' : '양력'}
{interp?.summary && (
{analysis.day_master_strength.result} · 점수 {analysis.day_master_strength.score}
{(analysis.day_master_strength.reasons || []).join(' · ')}
오늘의 운세를 보려면 먼저 사주를 입력해주세요.
사주 입력하러 가기오늘의 운세를 풀어보는 중...
{error || '다시 입력해주세요.'}
메인으로 가기오늘 하루 어떤 흐름이 호령을 따라올지 확인해보세요.
두 사람의 사주를 함께 풀어보는 기능을 준비 중입니다.
조금만 기다려 주세요.