diff --git a/docs/superpowers/specs/2026-05-25-saju-tarot-lab-migration-design.md b/docs/superpowers/specs/2026-05-25-saju-tarot-lab-migration-design.md new file mode 100644 index 0000000..2ecbd27 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-saju-tarot-lab-migration-design.md @@ -0,0 +1,670 @@ +# saju-lab 신설 + tarot-lab 분리 — 마이그레이션 설계 + +**작성일**: 2026-05-25 +**상태**: Spec (구현 plan 작성 전) + +--- + +## 1. 목표 + +1. **saju-lab 신설**: 별도 디렉토리에 있던 `saju-web` (Next.js + Supabase + OpenAI) 프로젝트를 web-backend 모노레포의 한 lab 서비스로 마이그레이션. Python FastAPI + Claude + SQLite 패턴으로 단순화. +2. **tarot-lab 분리**: 현재 `agent-office` 컨테이너 내부 모듈로 들어 있는 tarot 기능을 독립 컨테이너로 분리. agent-office가 가벼워지고 tarot은 자체 라이프사이클을 가짐. + +두 작업이 같은 패턴(독립 lab 컨테이너 신설)을 공유하므로 하나의 spec에 담아 순차 구현. + +--- + +## 2. 배경 + +### 2-1. saju-web 현황 +- 위치: `C:\Users\jaeoh\Desktop\workspace\saju-web` +- 스택: Next.js 16, TypeScript, Supabase(OAuth+DB), OpenAI gpt-4o, PortOne 결제, Kakao 공유 +- 기능 4종: 사주분석(10토큰), 궁합(15토큰), 토정비결(5토큰), 오늘의 운세 +- 핵심 자산: `lib/saju-calculator.ts`, `lib/ai-interpretation.ts`, `lib/daeun-calculator.ts`, `lib/solar-terms.ts` (계산 엔진 ~1500줄) +- 현재 사용 중이 아님. 자산 보존 + 패턴 일치화를 위한 마이그레이션 + +### 2-2. tarot-lab 현황 +- 위치: `agent-office/app/tarot/` (모듈), `agent-office/app/routers/tarot.py` +- DB: `agent_office.db`의 `tarot_readings` 테이블 +- API: `/api/agent-office/tarot/*` 6개 endpoint (interpret, save, list, get, patch, delete) +- 21개 단위 테스트 존재 +- 문제: agent-office가 점점 비대해짐 (텔레그램·로또·주식·청약·유튜브·타로 모두 한 컨테이너에). tarot은 독립 도메인이라 분리가 자연스러움 + +### 2-3. 다른 lab 패턴 (참조 기준) +`insta-lab`, `music-lab`, `realestate-lab`은 모두 동일 패턴: +``` +/ +├── Dockerfile (python:3.12-slim) +├── requirements.txt +├── pytest.ini +├── tests/ +└── app/ + ├── main.py (FastAPI) + ├── config.py + ├── db.py (SQLite) + └── <도메인 모듈들> +``` +- 인증 없음 (개인 NAS 서비스) +- nginx가 `/api//`로 라우팅 +- docker-compose의 한 항목으로 등록 +- Gitea Webhook → deployer가 rsync + docker compose up -d --build + +--- + +## 3. 핵심 결정 사항 + +| 항목 | 결정 | +|------|------| +| 백엔드 언어 | Python FastAPI (saju 계산 엔진은 TypeScript → Python 포팅) | +| AI 모델 | Claude Sonnet 4.6 (`claude-sonnet-4-6`) + prompt-caching beta. tarot과 일관 | +| DB | SQLite 로컬 (saju-lab은 `saju.db`, tarot-lab은 `tarot.db`) | +| 인증 | 없음 (다른 lab 패턴 일치). saju-web의 Supabase/PortOne/Kakao 제거 | +| saju-lab v1 기능 | 사주 분석 + 궁합 + 사주 결과 내 세운(歲運) (오늘의 운세는 세운으로 통합). 토정비결은 v2 | +| tarot DB 마이그레이션 | 1회성 복사 스크립트 (agent_office.db → tarot.db), cutover 후 agent-office tarot 모듈 완전 제거 | +| saju-lab UI | 시안 기반 신규 (시안 추후 제공, Phase 2 마지막 단계) | +| API prefix | `/api/saju/*`, `/api/tarot/*` (완전 이전) — `/api/agent-office/tarot/*`는 제거 | +| 포트 (내부) | tarot-lab 18250, saju-lab 18300 | +| 진행 순서 | Phase 1 tarot 분리 → Phase 2 saju 신설 | + +--- + +## 4. 디렉토리 구조 + +``` +web-backend/ +├── tarot-lab/ # [신설] +│ ├── Dockerfile +│ ├── requirements.txt +│ ├── pytest.ini +│ ├── tests/ +│ │ ├── test_db.py # agent-office/tests/test_tarot_db.py 이관 +│ │ ├── test_schema.py +│ │ ├── test_pipeline.py +│ │ └── test_routes.py # 6 endpoint (interpret + readings CRUD 5) +│ └── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI app + /api/tarot/* 라우터 6개 +│ ├── config.py # TAROT_MODEL, TAROT_COST_*, ANTHROPIC_API_KEY, TAROT_TIMEOUT_SEC +│ ├── db.py # tarot.db: 5 CRUD + _tarot_row_to_dict +│ ├── models.py # Pydantic 모델 5개 (TarotCardDraw, TarotInterpretRequest, TarotInterpretResponse, TarotSaveRequest, TarotPatchRequest) +│ ├── pipeline.py # Claude 호출 + reroll 1회 +│ ├── prompt.py # SYSTEM_PROMPT + build_user_message +│ └── schema.py # validate_interpretation +│ +├── saju-lab/ # [신설] +│ ├── Dockerfile +│ ├── requirements.txt # fastapi, httpx, anthropic, pydantic, sxtwl(절기/음력) +│ ├── pytest.ini +│ ├── tests/ +│ │ ├── fixtures/ +│ │ │ └── reference_saju.json # Node.js 원본에서 추출한 입력→출력 쌍 +│ │ ├── test_core.py # 천간/지지/십성/십이운성/calculate_saju +│ │ ├── test_solar_terms.py # 24절기 +│ │ ├── test_lunar.py # 음력 변환 +│ │ ├── test_analysis.py # 오행/신강신약/용신/세운 +│ │ ├── test_daeun.py +│ │ ├── test_shinsal.py # 신살/공망/지장간 +│ │ ├── test_compatibility.py # 궁합 점수 +│ │ ├── test_pipeline.py # Claude mock + reroll +│ │ ├── test_compat_pipeline.py +│ │ ├── test_schema.py +│ │ ├── test_db.py +│ │ └── test_routes.py +│ └── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI app +│ ├── config.py # SAJU_MODEL, SAJU_COST_*, ANTHROPIC_API_KEY, SAJU_TIMEOUT_SEC +│ ├── db.py # saju.db: saju_records, compat_records 테이블 + CRUD +│ ├── models.py # SajuRequest, CompatRequest, etc. +│ ├── calculator/ +│ │ ├── __init__.py +│ │ ├── constants.py # HEAVENLY_STEMS, EARTHLY_BRANCHES, FIVE_ELEMENTS, HIDDEN_STEMS, TEN_GODS, TWELVE_FORTUNES +│ │ ├── core.py # get_year_ganzi, get_month_ganzi, get_day_ganzi, get_hour_ganzi, get_ten_god, get_twelve_fortune, calculate_saju +│ │ ├── solar_terms.py # get_solar_term_date, get_current_solar_term, get_solar_term_month_branch, get_days_to_next_solar_term — sxtwl 사용 +│ │ ├── lunar.py # solar_to_lunar, lunar_to_solar +│ │ ├── shinsal.py # get_hidden_stems, get_all_hidden_stems, analyze_branch_interactions, calculate_shinsal, calculate_gongmang +│ │ ├── analysis.py # calculate_detailed_element_balance, calculate_element_score, analyze_day_master_strength, estimate_yongshin, calculate_seun, perform_full_analysis +│ │ ├── daeun.py # calculate_daeun, get_current_daeun, get_daeun_description +│ │ └── compatibility.py # calculate_compatibility (오행 상생/상극 + 지지 합/충 점수화) +│ ├── interpret/ +│ │ ├── __init__.py +│ │ ├── pipeline.py # Claude 호출 + reroll (tarot 패턴) +│ │ ├── compat_pipeline.py +│ │ ├── prompt.py # 사주 12항목 SYSTEM_PROMPT (Claude용 재작성, evidence-based) +│ │ ├── compat_prompt.py # 궁합 SYSTEM_PROMPT +│ │ └── schema.py # validate_saju_interpretation, validate_compat_interpretation +│ └── routers/ +│ ├── __init__.py +│ ├── saju.py # POST /api/saju/interpret, /readings CRUD, /current-fortune +│ └── compat.py # POST /api/saju/compat/interpret, /readings CRUD +│ +├── agent-office/ # [수정] +│ ├── app/ +│ │ ├── tarot/ # [제거] +│ │ ├── routers/tarot.py # [제거] +│ │ ├── models.py # Tarot* 5개 제거 +│ │ ├── db.py # tarot_readings 관련 CRUD 5개 + _tarot_row_to_dict + CREATE TABLE 제거 +│ │ └── main.py # include_router(tarot_router.router) 줄 제거 +│ ├── tests/ # test_tarot_*.py 4개 제거 +│ └── scripts/ +│ └── migrate_tarot_to_lab.py # [신설] 1회성 마이그레이션 +│ +├── docker-compose.yml # [수정] tarot-lab, saju-lab 추가 +├── nginx/default.conf # [수정] /api/tarot/ → tarot-lab, /api/saju/ → saju-lab, /api/agent-office/tarot/ 제거 +├── scripts/ +│ ├── deploy-nas.sh # [수정] CONTAINERS 배열에 saju-lab, tarot-lab 추가 +│ └── deploy.sh # [수정] 5위치 (CLAUDE.md memory의 "배포 스크립트 동기화" 항목 참조) +└── docs/superpowers/specs/ + └── 2026-05-25-saju-tarot-lab-migration-design.md # 본 문서 +``` + +**프론트엔드 (`web-ui/`)** — Phase 1·2 양쪽 변경: +``` +web-ui/ +├── src/ +│ ├── api.js # [Phase 1 수정] tarot helpers 6개 URL prefix 변경 + [Phase 2 추가] saju/compat helpers +│ ├── routes.jsx # [Phase 2 수정] /saju, /saju/result, /saju/compatibility, /saju/compatibility/result 라우트 +│ ├── components/Icons.jsx # [Phase 2 수정] IconSaju 추가 +│ └── pages/ +│ ├── tarot/ # [Phase 1] URL prefix만 변경, 그 외 변경 없음 +│ └── saju/ # [Phase 2 신설, 시안 받은 후] +│ ├── Saju.jsx +│ ├── SajuForm.jsx +│ ├── SajuResult.jsx +│ ├── Compatibility.jsx +│ ├── CompatibilityForm.jsx +│ ├── CompatibilityResult.jsx +│ ├── data/ +│ │ ├── constants.js # 천간/지지/오행 상수 (UI 표시용) +│ │ └── interpretations.js +│ ├── hooks/ +│ │ ├── useSajuForm.js +│ │ └── useSajuInterpretation.js +│ └── components/ +│ ├── SajuBoard.jsx # 4기둥 시각화 +│ ├── ElementChart.jsx# 오행 차트 +│ ├── DaeunTimeline.jsx +│ └── InterpretationPanel.jsx +``` + +--- + +## 5. Phase 1: tarot-lab 분리 + +### 5-1. 신규 tarot-lab 컨테이너 생성 + +**파일 단순 복사 + 모듈 평탄화:** +- `agent-office/app/tarot/__init__.py` → `tarot-lab/app/__init__.py` (간단화) +- `agent-office/app/tarot/prompt.py` → `tarot-lab/app/prompt.py` +- `agent-office/app/tarot/pipeline.py` → `tarot-lab/app/pipeline.py` (import 경로 수정: `..config` → `.config`, `..models` → `.models`) +- `agent-office/app/tarot/schema.py` → `tarot-lab/app/schema.py` + +**추출 파일:** +- `tarot-lab/app/config.py`: agent-office의 config.py에서 TAROT_* 환경변수만 추출 + ```python + import os + ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") + TAROT_MODEL = os.getenv("TAROT_MODEL", "claude-sonnet-4-6") + TAROT_COST_INPUT_PER_M = float(os.getenv("TAROT_COST_INPUT_PER_M", "3.0")) + TAROT_COST_OUTPUT_PER_M = float(os.getenv("TAROT_COST_OUTPUT_PER_M", "15.0")) + TAROT_TIMEOUT_SEC = int(os.getenv("TAROT_TIMEOUT_SEC", "180")) + TAROT_DATA_PATH = os.getenv("TAROT_DATA_PATH", "/app/data") + TAROT_DB_PATH = os.path.join(TAROT_DATA_PATH, "tarot.db") + ``` +- `tarot-lab/app/models.py`: agent-office models.py에서 Tarot* 5개만 추출 +- `tarot-lab/app/db.py`: + - tarot_readings CREATE TABLE + WAL 활성화 + - 5 CRUD (save/get/list/update/delete) + `_tarot_row_to_dict` + - DB 경로는 `TAROT_DB_PATH` (volume mount된 `/app/data/tarot.db`) +- `tarot-lab/app/main.py`: + ```python + from fastapi import FastAPI, HTTPException + from .models import (...) + from . import pipeline, db as db_module + + app = FastAPI(title="tarot-lab") + + @app.on_event("startup") + def _init_db(): + db_module.init_db() + + # /api/tarot/* 5 endpoints (routers/tarot.py 코드 그대로) + ``` + +**Dockerfile (insta-lab 패턴):** +```dockerfile +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app/ ./app/ +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +**requirements.txt:** +``` +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +httpx==0.27.2 +pydantic==2.9.2 +``` + +### 5-2. 테스트 이관 +- `agent-office/tests/test_tarot_*.py` 4개 파일 → `tarot-lab/tests/test_*.py` +- import 경로 수정 (`from app.tarot.pipeline` → `from app.pipeline`) +- pytest.ini 추가 (`testpaths = tests`, `pythonpath = .`) +- 모두 통과 확인 (21 tests) + +### 5-3. DB 마이그레이션 스크립트 + +`agent-office/scripts/migrate_tarot_to_lab.py`: +```python +"""1회성 — agent_office.db의 tarot_readings를 tarot.db로 복사. +멱등성: 이미 존재하는 id는 SKIP. +실행: docker exec agent-office python /app/scripts/migrate_tarot_to_lab.py +""" +import sqlite3 +import os + +SRC = os.getenv("AGENT_OFFICE_DB", "/app/data/agent_office.db") +DST = os.getenv("TAROT_DB", "/app/data/tarot.db") + +def migrate(): + src = sqlite3.connect(SRC) + dst = sqlite3.connect(DST) + dst.execute(""" + CREATE TABLE IF NOT EXISTS tarot_readings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ... (agent-office 스키마 그대로) + ) + """) + rows = src.execute("SELECT * FROM tarot_readings").fetchall() + cols = [c[0] for c in src.execute("SELECT * FROM tarot_readings LIMIT 1").description] + placeholders = ",".join("?" * len(cols)) + cols_str = ",".join(cols) + moved = 0 + for r in rows: + cur = dst.execute(f"SELECT 1 FROM tarot_readings WHERE id = ?", (r[0],)) + if cur.fetchone() is None: + dst.execute(f"INSERT INTO tarot_readings ({cols_str}) VALUES ({placeholders})", r) + moved += 1 + dst.commit() + print(f"migrated {moved} / {len(rows)} rows") + +if __name__ == "__main__": + migrate() +``` + +**볼륨 공유 전략**: tarot-lab의 `/app/data`를 agent-office의 `/app/data`와 같은 NAS 호스트 디렉토리에 마운트. tarot.db는 신규 파일이라 별도 마운트 가능. + +### 5-4. docker-compose / nginx / deploy 갱신 + +**docker-compose.yml**에 추가: +```yaml +tarot-lab: + build: ./tarot-lab + container_name: tarot-lab + restart: unless-stopped + ports: + - "18250:8000" + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - TAROT_MODEL=${TAROT_MODEL:-claude-sonnet-4-6} + - TAROT_DATA_PATH=/app/data + volumes: + - ${RUNTIME_PATH:-.}/data:/app/data +``` + +**nginx/default.conf**에 추가, 기존 `/api/agent-office/tarot/`은 제거: +```nginx +location /api/tarot/ { + proxy_pass http://tarot-lab:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + proxy_connect_timeout 60s; +} +``` + +**deploy 스크립트 5위치** (memory의 "배포 스크립트 동기화" 참조): +- `scripts/deploy-nas.sh`의 CONTAINERS 배열 +- `scripts/deploy.sh`의 SERVICES, DIRS 배열 +- 컨테이너 목록 하드코딩된 모든 위치에 `tarot-lab` 추가 (Phase 1) / `saju-lab` 추가 (Phase 2) + +### 5-5. agent-office cutover + +마이그레이션 + 데이터 검증 후: +- `agent-office/app/tarot/` 디렉토리 통째로 제거 +- `agent-office/app/routers/tarot.py` 제거 +- `agent-office/app/main.py`에서 tarot router import + include_router 줄 제거 +- `agent-office/app/models.py`에서 `TarotCardDraw`, `TarotInterpretRequest`, `TarotInterpretResponse`, `TarotSaveRequest`, `TarotPatchRequest` 제거 +- `agent-office/app/db.py`에서 `save_tarot_reading`, `get_tarot_reading`, `list_tarot_readings`, `update_tarot_reading`, `delete_tarot_reading`, `_tarot_row_to_dict` 제거 +- `agent-office/app/db.py`의 CREATE TABLE에서 `tarot_readings` 줄 제거 (또는 idempotent 유지: 기존 DB 호환 위해 CREATE IF NOT EXISTS는 유지하되 코드 경로 제거) +- `agent-office/tests/test_tarot_*.py` 4개 제거 +- agent-office pytest 통과 확인 + +### 5-6. web-ui api.js URL 변경 + +`web-ui/src/api.js`의 tarot helpers 6개: +- `tarotInterpret`: `/api/agent-office/tarot/interpret` → `/api/tarot/interpret` +- `tarotSaveReading`: `/api/agent-office/tarot/readings` → `/api/tarot/readings` +- `tarotListReadings`: 동일 변환 +- `tarotGetReading`: 동일 변환 +- `tarotPatchReading`: 동일 변환 +- `tarotDeleteReading`: 동일 변환 + +Phase 1 검증: `npm run dev` → http://127.0.0.1:3007/tarot → 3장 리딩 1회 e2e 동작 확인. + +--- + +## 6. Phase 2: saju-lab 신설 + +### 6-1. 계산 엔진 포팅 (TypeScript → Python) + +**핵심 위험**: 계산 엔진은 ~1500줄 TypeScript로 매년 검증된 코드. Python으로 옮기면서 미세한 버그가 들어가면 모든 사주 해석이 잘못됨. + +**대응 전략 — Reference Output 비교 테스트**: +1. saju-web의 `lib/saju-calculator.ts` 코드를 Node.js로 직접 실행 (`node -e "..."`) +2. 알려진 입력 30~50쌍에 대해 `calculateSaju(year, month, day, hour, gender)` + `performFullAnalysis(saju, currentYear)` + `calculateDaeun(...)` 호출 결과를 JSON 파일로 저장 +3. `tests/fixtures/reference_saju.json` 형식: + ```json + [ + { + "input": {"year": 1990, "month": 5, "day": 15, "hour": 14, "gender": "male"}, + "expected": { + "saju": {...}, + "analysis": {...}, + "daeun": [...] + } + }, + ... (50개) + ] + ``` +4. Python 포팅 후 pytest로 매 입력 → expected와 1:1 비교 (`assert deep_equal(actual, expected)`) + +**포팅 순서** (의존성 그래프): +1. `calculator/constants.py` — 모든 상수 (천간 10·지지 12·오행 5·십성·십이운성·지장간·신살) +2. `calculator/solar_terms.py` — `sxtwl` Python 라이브러리 사용 (24절기 + 음력) +3. `calculator/lunar.py` — `sxtwl` 음력↔양력 변환 +4. `calculator/core.py` — `get_year_ganzi`, `get_month_ganzi` (절기 기반), `get_day_ganzi`, `get_hour_ganzi`, `get_ten_god`, `get_twelve_fortune`, `calculate_saju` +5. `calculator/shinsal.py` — 지장간(`get_hidden_stems`, `get_all_hidden_stems`), 지지 상호작용(`analyze_branch_interactions`), 신살(`calculate_shinsal`), 공망(`calculate_gongmang`) +6. `calculator/analysis.py` — 오행 점수(`calculate_detailed_element_balance`, `calculate_element_score`), 신강신약(`analyze_day_master_strength`), 용신(`estimate_yongshin`), 세운(`calculate_seun`), 종합(`perform_full_analysis`) +7. `calculator/daeun.py` — `calculate_daeun`, `get_current_daeun`, `get_daeun_description` +8. `calculator/compatibility.py` — 두 사주의 오행 매칭 + 지지 합/충 점수화 → 0~100 점수 + +각 단계마다 reference test 통과를 게이트로. + +### 6-2. Claude 프롬프트 (tarot 패턴 재활용) + +**`interpret/prompt.py`** — 사주 12항목 해석: +- 시스템 프롬프트: "당신은 한국 전통 사주명리학 전문가다. 다음 사주 + 분석 결과를 보고, JSON 스키마로 12항목 해석을 작성하라. 각 항목은 evidence 필드를 포함해 어떤 사주 요소에서 결론을 도출했는지 명시하라." +- 12항목: 타고난 기질 / 오행 밸런스 / 지지 상호작용 / 신살 영향 / 재물운 / 직업 적성 / 애정운 / 건강운 / 현재 대운 / 올해 세운 / 인생 황금기 / 종합 조언 +- JSON 응답 스키마: + ```json + { + "items": [ + { "key": "기질", "title": "...", "content": "...", "evidence": {"saju_element": "...", "reasoning": "..."} }, + ... + ], + "summary": "...", + "advice": "...", + "warning": "...", + "confidence": "high|medium|low" + } + ``` +- `cache_control: ephemeral`을 system 블록에 적용 + +**`interpret/compat_prompt.py`** — 궁합 해석: +- 두 사주 + 궁합 점수 + 오행 상생/상극 분석 → JSON 응답 +- evidence: 어떤 지지 합/충에서 점수가 나왔는지 명시 + +**`interpret/schema.py`** — validate 함수: +- `validate_saju_interpretation(parsed)`: items 12개 존재 / 각 evidence 채워졌는지 / confidence 값 검증 +- `validate_compat_interpretation(parsed)`: 마찬가지 + +**`interpret/pipeline.py`** — Claude 호출 (tarot pipeline.py 거의 그대로 복사 + 사주용 prompt/schema 사용): +- max_tokens 2400 (12항목 + 종합이라 더 길음) +- reroll 1회 +- latency_ms / tokens 로깅 + +### 6-3. DB 스키마 + +`saju-lab/app/db.py`: +```python +SAJU_DB_SCHEMA = """ +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 JSON NOT NULL, + analysis_data JSON NOT NULL, + daeun_data JSON NOT NULL, + interpretation_json JSON, + 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 DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS compat_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_a JSON NOT NULL, + person_b JSON NOT NULL, + score INTEGER NOT NULL, + breakdown JSON NOT NULL, + interpretation_json JSON, + model TEXT, + tokens_in INTEGER DEFAULT 0, + tokens_out INTEGER DEFAULT 0, + cost_usd REAL DEFAULT 0, + favorite INTEGER DEFAULT 0, + memo TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); +""" +``` + +CRUD 함수: `save_saju_record`, `get_saju_record`, `list_saju_records`, `update_saju_record`, `delete_saju_record` + compat 5개. + +### 6-4. API 엔드포인트 + +**`routers/saju.py`**: +| 메서드 | 경로 | 설명 | +|--------|------|------| +| POST | `/api/saju/interpret` | 입력 → 계산 + AI 해석 + DB 저장. 응답에 saju/analysis/daeun/interpretation/reading_id 포함 | +| GET | `/api/saju/readings` | 페이지네이션 목록 (page, size, favorite) | +| GET | `/api/saju/readings/{id}` | 상세 조회 | +| PATCH | `/api/saju/readings/{id}` | favorite, memo 수정 | +| DELETE | `/api/saju/readings/{id}` | 삭제 | +| GET | `/api/saju/current-fortune?reading_id={id}` | 저장된 사주 기반 오늘의 세운 (실시간 계산, AI 호출 없음) | + +**`routers/compat.py`**: +| 메서드 | 경로 | 설명 | +|--------|------|------| +| POST | `/api/saju/compat/interpret` | 두 사람 입력 → 두 사주 계산 + 궁합 점수 + AI 해석 + DB 저장 | +| GET | `/api/saju/compat/readings` | 목록 | +| GET | `/api/saju/compat/readings/{id}` | 상세 | +| PATCH | `/api/saju/compat/readings/{id}` | favorite, memo | +| DELETE | `/api/saju/compat/readings/{id}` | 삭제 | + +### 6-5. docker-compose / nginx 등록 + +**docker-compose.yml**에 saju-lab 항목 추가 (tarot-lab과 동일 패턴, 포트 18300). + +**nginx/default.conf**에 추가: +```nginx +location /api/saju/ { + proxy_pass http://saju-lab:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + proxy_connect_timeout 60s; +} +``` + +deploy 스크립트 5위치에 `saju-lab` 추가. + +### 6-6. web-ui /saju 페이지 + +**시안 추후 제공** (사용자 확인). 시안 받은 후 tarot 페이지 패턴 따라 구현: +- 입력 폼: 생년월일 + 시간 + 성별 + 양력/음력 (양력 default) +- 결과 페이지: 사주판 시각화 + 오행 차트 + 대운 타임라인 + AI 12항목 아코디언 +- 궁합: 두 사람 입력 폼 + 결과 카드 +- 인사이트 패널 (tarot의 InterpretationPanel.jsx 패턴 차용) + +`api.js`에 helpers 추가: +- `sajuInterpret`, `sajuListReadings`, `sajuGetReading`, `sajuPatchReading`, `sajuDeleteReading`, `sajuCurrentFortune` +- `compatInterpret`, `compatListReadings`, `compatGetReading`, `compatPatchReading`, `compatDeleteReading` + +`routes.jsx`에 라우트 추가: +- `/saju` (입력), `/saju/result` (사주 결과), `/saju/compatibility` (입력), `/saju/compatibility/result` (궁합 결과) + +`components/Icons.jsx`에 `IconSaju` 추가. + +--- + +## 7. 데이터 흐름 + +### tarot-lab (Phase 1) +``` +[web-ui /tarot/reading] + ↓ POST /api/tarot/interpret { cards, question, category, spread_type } +[nginx /api/tarot/ → tarot-lab:8000] + ↓ pipeline.interpret() → Claude API + ↓ validate + reroll +[tarot-lab] + ↓ POST /api/tarot/readings { ... save body } + ↓ db.save_tarot_reading() → tarot.db INSERT + ← { id, created_at } +``` + +### saju-lab (Phase 2) +``` +[web-ui /saju/result] + ↓ POST /api/saju/interpret { year, month, day, hour, gender, calendarType } +[nginx /api/saju/ → saju-lab:8000] + ↓ calculator.calculate_saju() → SajuData + ↓ calculator.perform_full_analysis() → SajuAnalysis + ↓ calculator.calculate_daeun() → DaeunPillar[] + ↓ interpret.pipeline.interpret() → Claude API + ↓ validate + reroll + ↓ db.save_saju_record() → saju.db INSERT + ← { saju, analysis, daeun, interpretation, reading_id, cost_usd, latency_ms } +[web-ui] +``` + +### saju-lab 궁합 +``` +[web-ui /saju/compatibility/result] + ↓ POST /api/saju/compat/interpret { person_a: {...}, person_b: {...} } +[saju-lab] + ↓ calculate_saju(person_a) + calculate_saju(person_b) + ↓ compatibility.calculate_compatibility(saju_a, saju_b) → { score, breakdown } + ↓ interpret.compat_pipeline.interpret() → Claude API + ↓ db.save_compat_record() + ← { saju_a, saju_b, score, breakdown, interpretation, reading_id } +``` + +--- + +## 8. 에러 처리 + +| 시나리오 | 처리 | +|---------|------| +| Claude API HTTP error | `TarotError` / `SajuError` raise → FastAPI 500 | +| Claude JSON 파싱 실패 | `_extract_json` codeblock 스트립 + 첫 `{` / 마지막 `}` 추출. 실패 시 reroll | +| validate 실패 (필수 필드 누락) | reroll 1회. 그래도 실패 시 `_Error("검증 실패")` raise → 500 | +| 계산 엔진 입력 오류 (잘못된 날짜 등) | Pydantic validation → 422 | +| DB 락 | sqlite WAL 모드. 짧은 retry 없이 raise (드물게 발생) | +| 마이그레이션 스크립트 중복 실행 | `INSERT OR IGNORE` 패턴 / 멱등 | + +--- + +## 9. 테스트 전략 + +### tarot-lab +- 기존 21 tests 이관 + import 경로 수정 후 100% 통과 + +### saju-lab — 계산 엔진 +- **Reference output 비교가 핵심**. 30~50개 입력 → JSON 저장 → Python 결과와 deep_equal 비교 +- 각 모듈 단위 테스트 (constants, solar_terms, lunar, core, shinsal, analysis, daeun, compatibility) +- 회귀 방지: 추가 입력 케이스 발견 시 fixtures에 추가 + +### saju-lab — Claude 파이프라인 +- httpx mock (respx 또는 monkeypatch) 사용 (tarot 패턴 그대로) +- validate / reroll / JSON 파싱 폴백 / cost 계산 검증 + +### saju-lab — 라우터 +- TestClient 기반 e2e (FastAPI 표준) +- DB tmp_path fixture + +### 통합 검증 (Phase 1, Phase 2 끝) +- `npm run dev` + http://127.0.0.1:3007/tarot에서 리딩 1회 (Phase 1) +- 같은 곳에서 /saju에서 사주 + 궁합 1회씩 (Phase 2, 시안 적용 후) + +--- + +## 10. 환경변수 정리 + +**tarot-lab 신규 환경변수** (docker-compose env): +- `ANTHROPIC_API_KEY` (필수) +- `TAROT_MODEL` (기본 `claude-sonnet-4-6`) +- `TAROT_COST_INPUT_PER_M` (기본 3.0) +- `TAROT_COST_OUTPUT_PER_M` (기본 15.0) +- `TAROT_TIMEOUT_SEC` (기본 180) +- `TAROT_DATA_PATH` (기본 `/app/data`) + +**saju-lab 신규 환경변수**: +- `ANTHROPIC_API_KEY` (필수) +- `SAJU_MODEL` (기본 `claude-sonnet-4-6`) +- `SAJU_COST_INPUT_PER_M`, `SAJU_COST_OUTPUT_PER_M` +- `SAJU_TIMEOUT_SEC` +- `SAJU_DATA_PATH` + +--- + +## 11. 마이그레이션 위험 + 완화 + +| 위험 | 영향 | 완화 | +|------|------|------| +| TS→Python 포팅 미세 차이 (예: 절기 일자 1일 차이) | 모든 사주 결과 변형 | Reference output 비교 테스트 50건 + sxtwl로 절기 동일 알고리즘 사용 | +| tarot.db 마이그레이션 중 데이터 손실 | 사용자 리딩 이력 손실 | 멱등 스크립트 + 검증 후 cutover. agent-office의 원본 데이터는 cutover 후에도 30일 유지 (테이블만 DROP 안 함) | +| 두 컨테이너 추가로 NAS 메모리 압박 | 다른 서비스 OOM | python:3.12-slim 기반 ~150MB. 18GB RAM 여유 충분 | +| API prefix 변경 missed 위치 (web-ui에서 일부 호출만 변경) | 일부 페이지 404 | grep 검색 (`/api/agent-office/tarot`) 후 일괄 변경 | +| nginx restart 누락 | 라우팅 안 됨 | docker compose up -d --build → nginx 컨테이너 재시작 자동 (deployer 패턴) | +| saju-web 코드 사라짐 (참조 못 하게 됨) | 검증 어려움 | saju-web 디렉토리는 그대로 유지 (포팅 끝나도 archive로 보존) | + +--- + +## 12. 향후 (v2, 본 spec 밖) + +- 토정비결 (12개월 운세) — saju-lab v2에서 추가 +- 정밀 음력 + 윤달 처리 검증 +- 자동 마이그레이션 스크립트의 ON DELETE CASCADE 검토 (이력 정합성) +- agent-office의 tarot 관련 텔레그램 명령이 있다면 그것도 saju-lab에 추가할지 검토 +- saju-lab UI 디자인 시안 확정 후 별도 짧은 plan으로 진행 + +--- + +## 13. 참고 자료 + +- saju-web/PROJECT_OVERVIEW.md — 마이그레이션 원본 명세 +- web-backend/CLAUDE.md — lab 서비스 패턴 참조 +- agent-office/app/tarot/, agent-office/app/routers/tarot.py — Phase 1 이관 원본 +- web-backend/insta-lab/, music-lab/, realestate-lab/ — Dockerfile + 디렉토리 구조 참조 패턴 +- sxtwl (Python 만세력 라이브러리) — solarlunar 대체 +- docs/superpowers/specs/2026-05-23-tarot-lab-design.md — 본 작업의 직전 spec (tarot-lab 원본 설계)