- Phase 1 (Task 1~12): tarot 코드 복사 + 모듈 평탄화 + DB 마이그레이션 + agent-office cutover + web-ui URL 변경 - Phase 2 (Task 13~29): saju 계산 엔진 TS→Python 포팅 (reference fixture 30) + Claude 12항목 해석 + DB + 라우터 + UI 시안 후 진행 - 총 29 task, 각 5~10 step bite-sized, TDD + 빈번한 commit Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3275 lines
109 KiB
Markdown
3275 lines
109 KiB
Markdown
# saju-lab 신설 + tarot-lab 분리 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:** agent-office에 종속된 tarot을 독립 tarot-lab 컨테이너로 분리하고, 별도 디렉토리의 saju-web을 saju-lab 컨테이너로 마이그레이션 (Python FastAPI + Claude + SQLite 패턴).
|
|
|
|
**Architecture:** Phase 1은 코드 복사 + DB 1회 마이그레이션 + agent-office cutover. Phase 2는 TypeScript 계산 엔진을 Python으로 reference-output 비교 테스트 기반 포팅 + Claude AI 해석 파이프라인 (tarot 패턴 재활용) + 사주/궁합 v1 endpoint. 두 lab 모두 `insta-lab`/`music-lab`과 동일 디렉토리 구조.
|
|
|
|
**Tech Stack:** Python 3.12 + FastAPI 0.115 + httpx + Pydantic V2 + SQLite WAL + Anthropic Claude Sonnet 4.6 (prompt-caching) + sxtwl (Python 만세력) + pytest + respx (httpx mock).
|
|
|
|
**Reference spec:** `docs/superpowers/specs/2026-05-25-saju-tarot-lab-migration-design.md`
|
|
|
|
---
|
|
|
|
## Phase 1 — tarot-lab 분리
|
|
|
|
### Task 1: tarot-lab 스캐폴딩 (Dockerfile + requirements + pytest.ini)
|
|
|
|
**Files:**
|
|
- Create: `tarot-lab/Dockerfile`
|
|
- Create: `tarot-lab/requirements.txt`
|
|
- Create: `tarot-lab/pytest.ini`
|
|
- Create: `tarot-lab/.dockerignore`
|
|
- Create: `tarot-lab/app/__init__.py`
|
|
- Create: `tarot-lab/tests/__init__.py`
|
|
|
|
- [ ] **Step 1: Dockerfile 작성 (insta-lab 패턴)**
|
|
|
|
```dockerfile
|
|
FROM python:3.12-slim-bookworm
|
|
ENV PYTHONUNBUFFERED=1
|
|
|
|
WORKDIR /app
|
|
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
|
|
|
COPY . .
|
|
|
|
EXPOSE 8000
|
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
|
```
|
|
|
|
- [ ] **Step 2: requirements.txt 작성**
|
|
|
|
```
|
|
fastapi==0.115.6
|
|
uvicorn[standard]==0.34.0
|
|
httpx>=0.27
|
|
pydantic>=2.9
|
|
pytest>=8.0
|
|
pytest-asyncio>=0.24
|
|
respx>=0.21
|
|
```
|
|
|
|
- [ ] **Step 3: pytest.ini 작성**
|
|
|
|
```ini
|
|
[pytest]
|
|
asyncio_mode = auto
|
|
pythonpath = .
|
|
```
|
|
|
|
- [ ] **Step 4: .dockerignore 작성**
|
|
|
|
```
|
|
__pycache__
|
|
*.pyc
|
|
.pytest_cache
|
|
data/
|
|
tests/
|
|
```
|
|
|
|
- [ ] **Step 5: 빈 app/__init__.py, tests/__init__.py 생성**
|
|
|
|
빈 파일 2개 생성:
|
|
- `tarot-lab/app/__init__.py`
|
|
- `tarot-lab/tests/__init__.py`
|
|
|
|
- [ ] **Step 6: 디렉토리 구조 확인**
|
|
|
|
Run: `ls tarot-lab/ tarot-lab/app/ tarot-lab/tests/`
|
|
Expected: 각각 파일 존재 확인
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add tarot-lab/Dockerfile tarot-lab/requirements.txt tarot-lab/pytest.ini tarot-lab/.dockerignore tarot-lab/app/__init__.py tarot-lab/tests/__init__.py
|
|
git commit -m "feat(tarot-lab): 스캐폴딩 — Dockerfile + requirements + pytest"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: tarot-lab config.py + models.py
|
|
|
|
**Files:**
|
|
- Create: `tarot-lab/app/config.py`
|
|
- Create: `tarot-lab/app/models.py`
|
|
|
|
- [ ] **Step 1: app/config.py 작성**
|
|
|
|
```python
|
|
"""tarot-lab 환경변수."""
|
|
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")
|
|
DB_PATH = os.path.join(TAROT_DATA_PATH, "tarot.db")
|
|
|
|
CORS_ALLOW_ORIGINS = os.getenv(
|
|
"CORS_ALLOW_ORIGINS",
|
|
"http://localhost:3007,http://localhost:8080",
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2: app/models.py 작성**
|
|
|
|
```python
|
|
"""Tarot Pydantic 모델 — agent-office models.py에서 추출."""
|
|
from typing import List, Literal, Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class TarotCardDraw(BaseModel):
|
|
position: str
|
|
card_id: str
|
|
reversed: bool = False
|
|
|
|
|
|
class TarotInterpretRequest(BaseModel):
|
|
spread_type: Literal["one_card", "three_card"]
|
|
category: Optional[str] = None
|
|
question: Optional[str] = None
|
|
cards: List[TarotCardDraw]
|
|
cards_reference: str = Field(..., min_length=1)
|
|
context_meta: dict = Field(default_factory=dict)
|
|
|
|
|
|
class TarotInterpretResponse(BaseModel):
|
|
interpretation_json: dict
|
|
model: str
|
|
tokens_in: int
|
|
tokens_out: int
|
|
cost_usd: float
|
|
latency_ms: int
|
|
reroll_count: int = 0
|
|
|
|
|
|
class TarotSaveRequest(BaseModel):
|
|
spread_type: Literal["one_card", "three_card"]
|
|
category: Optional[str] = None
|
|
question: Optional[str] = None
|
|
cards: List[TarotCardDraw]
|
|
interpretation_json: dict
|
|
model: str
|
|
tokens_in: int
|
|
tokens_out: int
|
|
cost_usd: float
|
|
confidence: Optional[str] = None
|
|
|
|
|
|
class TarotPatchRequest(BaseModel):
|
|
favorite: Optional[bool] = None
|
|
note: Optional[str] = None
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add tarot-lab/app/config.py tarot-lab/app/models.py
|
|
git commit -m "feat(tarot-lab): config + Pydantic 모델 5개 추출"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: tarot-lab db.py (CRUD 5 + _tarot_row_to_dict + init_db)
|
|
|
|
**Files:**
|
|
- Create: `tarot-lab/app/db.py`
|
|
- Create: `tarot-lab/tests/test_db.py`
|
|
|
|
- [ ] **Step 1: 실패 테스트 작성 (test_db.py)**
|
|
|
|
```python
|
|
import os
|
|
import pytest
|
|
|
|
from app import db as db_module
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def fresh_db(monkeypatch, tmp_path):
|
|
db_file = tmp_path / "test_tarot.db"
|
|
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
|
db_module.init_db()
|
|
yield
|
|
try:
|
|
if db_file.exists():
|
|
db_file.unlink()
|
|
except PermissionError:
|
|
pass # Windows SQLite WAL 잠금
|
|
|
|
|
|
def test_save_and_get():
|
|
rid = db_module.save_tarot_reading({
|
|
"spread_type": "three_card",
|
|
"category": "연애",
|
|
"question": "Q",
|
|
"cards": [{"position": "과거", "card_id": "the-fool", "reversed": False}],
|
|
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "medium"},
|
|
"model": "claude-sonnet-4-6",
|
|
"tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005,
|
|
"confidence": "medium",
|
|
})
|
|
assert rid > 0
|
|
row = db_module.get_tarot_reading(rid)
|
|
assert row["id"] == rid
|
|
assert row["category"] == "연애"
|
|
assert row["interpretation_json"]["summary"] == "S"
|
|
assert row["favorite"] == 0
|
|
|
|
|
|
def test_list_with_filters():
|
|
for cat in ["연애", "연애", "재물"]:
|
|
db_module.save_tarot_reading({
|
|
"spread_type": "three_card", "category": cat, "question": "Q",
|
|
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "low"},
|
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
|
})
|
|
res = db_module.list_tarot_readings(page=1, size=10, category="연애")
|
|
assert res["total"] == 2
|
|
assert all(r["category"] == "연애" for r in res["items"])
|
|
|
|
|
|
def test_update_favorite_and_note():
|
|
rid = db_module.save_tarot_reading({
|
|
"spread_type": "one_card", "category": None, "question": None,
|
|
"cards": [], "interpretation_json": None,
|
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": None,
|
|
})
|
|
db_module.update_tarot_reading(rid, favorite=True, note="좋아요")
|
|
row = db_module.get_tarot_reading(rid)
|
|
assert row["favorite"] == 1
|
|
assert row["note"] == "좋아요"
|
|
|
|
|
|
def test_delete():
|
|
rid = db_module.save_tarot_reading({
|
|
"spread_type": "one_card", "category": None, "question": None,
|
|
"cards": [], "interpretation_json": None,
|
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": None,
|
|
})
|
|
db_module.delete_tarot_reading(rid)
|
|
assert db_module.get_tarot_reading(rid) is None
|
|
```
|
|
|
|
- [ ] **Step 2: Run test, verify it fails**
|
|
|
|
Run: `cd tarot-lab && python -m pytest tests/test_db.py -v`
|
|
Expected: FAIL (`ModuleNotFoundError: No module named 'app.db'`)
|
|
|
|
- [ ] **Step 3: app/db.py 작성 (agent-office db.py에서 추출 + DB_PATH 변경)**
|
|
|
|
```python
|
|
"""tarot.db SQLite — 5 CRUD + _tarot_row_to_dict + init_db."""
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from typing import Any, Dict, Optional
|
|
|
|
from .config import DB_PATH # noqa: F401 (test가 monkeypatch로 덮어씀)
|
|
from . import config
|
|
|
|
|
|
def _conn() -> sqlite3.Connection:
|
|
os.makedirs(os.path.dirname(config.DB_PATH), exist_ok=True)
|
|
conn = sqlite3.connect(config.DB_PATH, timeout=120.0)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA busy_timeout=120000")
|
|
return conn
|
|
|
|
|
|
def init_db() -> None:
|
|
with _conn() as conn:
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS tarot_readings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
spread_type TEXT NOT NULL,
|
|
category TEXT,
|
|
question TEXT,
|
|
cards TEXT NOT NULL,
|
|
interpretation_json TEXT,
|
|
summary TEXT,
|
|
model TEXT,
|
|
tokens_in INTEGER,
|
|
tokens_out INTEGER,
|
|
cost_usd REAL,
|
|
confidence TEXT,
|
|
favorite INTEGER NOT NULL DEFAULT 0,
|
|
note TEXT
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_tarot_created
|
|
ON tarot_readings(created_at DESC)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_tarot_favorite
|
|
ON tarot_readings(favorite, created_at DESC)
|
|
""")
|
|
|
|
|
|
def save_tarot_reading(data: Dict[str, Any]) -> int:
|
|
interp = data.get("interpretation_json") or {}
|
|
summary = interp.get("summary", "") if isinstance(interp, dict) else ""
|
|
with _conn() as conn:
|
|
cur = conn.execute(
|
|
"""INSERT INTO tarot_readings
|
|
(spread_type, category, question, cards, interpretation_json,
|
|
summary, model, tokens_in, tokens_out, cost_usd, confidence)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(
|
|
data["spread_type"],
|
|
data.get("category"),
|
|
data.get("question"),
|
|
json.dumps(data.get("cards") or [], ensure_ascii=False),
|
|
json.dumps(interp, ensure_ascii=False) if interp else None,
|
|
summary,
|
|
data.get("model"),
|
|
data.get("tokens_in"),
|
|
data.get("tokens_out"),
|
|
data.get("cost_usd"),
|
|
data.get("confidence"),
|
|
),
|
|
)
|
|
return int(cur.lastrowid)
|
|
|
|
|
|
def get_tarot_reading(reading_id: int) -> Optional[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
r = conn.execute("SELECT * FROM tarot_readings WHERE id=?", (reading_id,)).fetchone()
|
|
return _tarot_row_to_dict(r) if r else None
|
|
|
|
|
|
def list_tarot_readings(
|
|
page: int = 1, size: int = 20,
|
|
favorite: Optional[bool] = None,
|
|
spread_type: Optional[str] = None,
|
|
category: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
wheres, params = [], []
|
|
if favorite is not None:
|
|
wheres.append("favorite=?")
|
|
params.append(1 if favorite else 0)
|
|
if spread_type:
|
|
wheres.append("spread_type=?")
|
|
params.append(spread_type)
|
|
if category:
|
|
wheres.append("category=?")
|
|
params.append(category)
|
|
where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
|
offset = (page - 1) * size
|
|
with _conn() as conn:
|
|
total = conn.execute(
|
|
f"SELECT COUNT(*) c FROM tarot_readings {where_sql}", params
|
|
).fetchone()["c"]
|
|
rows = conn.execute(
|
|
f"SELECT * FROM tarot_readings {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
|
params + [size, offset],
|
|
).fetchall()
|
|
return {
|
|
"items": [_tarot_row_to_dict(r) for r in rows],
|
|
"page": page, "size": size, "total": int(total),
|
|
}
|
|
|
|
|
|
def update_tarot_reading(reading_id: int, **kwargs) -> None:
|
|
sets, vals = [], []
|
|
if "favorite" in kwargs and kwargs["favorite"] is not None:
|
|
sets.append("favorite=?")
|
|
vals.append(1 if kwargs["favorite"] else 0)
|
|
if "note" in kwargs and kwargs["note"] is not None:
|
|
sets.append("note=?")
|
|
vals.append(kwargs["note"])
|
|
if not sets:
|
|
return
|
|
vals.append(reading_id)
|
|
with _conn() as conn:
|
|
conn.execute(f"UPDATE tarot_readings SET {','.join(sets)} WHERE id=?", vals)
|
|
|
|
|
|
def delete_tarot_reading(reading_id: int) -> None:
|
|
with _conn() as conn:
|
|
conn.execute("DELETE FROM tarot_readings WHERE id=?", (reading_id,))
|
|
|
|
|
|
def _tarot_row_to_dict(r) -> Dict[str, Any]:
|
|
try:
|
|
interp = json.loads(r["interpretation_json"]) if r["interpretation_json"] else None
|
|
except (ValueError, TypeError):
|
|
interp = None
|
|
try:
|
|
cards = json.loads(r["cards"]) if r["cards"] else []
|
|
except (ValueError, TypeError):
|
|
cards = []
|
|
return {
|
|
"id": r["id"],
|
|
"created_at": r["created_at"],
|
|
"spread_type": r["spread_type"],
|
|
"category": r["category"],
|
|
"question": r["question"],
|
|
"cards": cards,
|
|
"interpretation_json": interp,
|
|
"summary": r["summary"],
|
|
"model": r["model"],
|
|
"tokens_in": r["tokens_in"],
|
|
"tokens_out": r["tokens_out"],
|
|
"cost_usd": r["cost_usd"],
|
|
"confidence": r["confidence"],
|
|
"favorite": int(r["favorite"]),
|
|
"note": r["note"],
|
|
}
|
|
```
|
|
|
|
**중요한 변경점**: monkeypatch가 `db_module.DB_PATH`를 덮어쓸 수 있도록 `_conn()` 내부에서 `config.DB_PATH`를 매번 읽어옴. agent-office는 `from .config import DB_PATH`로 import 하지만 테스트 monkeypatch가 module-level 변수를 덮어쓰는 패턴이 더 안정적.
|
|
|
|
수정: 테스트에서 `monkeypatch.setattr(db_module, "DB_PATH", str(db_file))`로 db_module 자체의 DB_PATH를 덮어쓰므로, db.py 안에서 `DB_PATH`를 직접 참조해야 함. 다시 작성:
|
|
|
|
```python
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from typing import Any, Dict, Optional
|
|
|
|
from .config import DB_PATH
|
|
|
|
|
|
def _conn() -> sqlite3.Connection:
|
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
|
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
|
# ... 이하 동일
|
|
```
|
|
|
|
테스트의 `monkeypatch.setattr(db_module, "DB_PATH", ...)`가 module의 `DB_PATH` (import된 값)를 직접 덮어쓰므로 정상 동작.
|
|
|
|
- [ ] **Step 4: Run tests, verify they pass**
|
|
|
|
Run: `cd tarot-lab && python -m pytest tests/test_db.py -v`
|
|
Expected: 4 passed
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add tarot-lab/app/db.py tarot-lab/tests/test_db.py
|
|
git commit -m "feat(tarot-lab): db.py CRUD 5 + init_db (테스트 4건 통과)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: tarot-lab prompt.py + schema.py 이관
|
|
|
|
**Files:**
|
|
- Create: `tarot-lab/app/prompt.py`
|
|
- Create: `tarot-lab/app/schema.py`
|
|
- Create: `tarot-lab/tests/test_schema.py`
|
|
|
|
- [ ] **Step 1: app/prompt.py — agent-office/app/tarot/prompt.py 그대로 복사**
|
|
|
|
`agent-office/app/tarot/prompt.py` 내용을 `tarot-lab/app/prompt.py`로 그대로 복사. 외부 import 없음 (자체완결).
|
|
|
|
- [ ] **Step 2: app/schema.py — agent-office/app/tarot/schema.py 그대로 복사**
|
|
|
|
`agent-office/app/tarot/schema.py` 내용을 `tarot-lab/app/schema.py`로 그대로 복사. 외부 import 없음.
|
|
|
|
- [ ] **Step 3: tests/test_schema.py 작성 (agent-office/tests/test_tarot_schema.py 이관 + import 경로 변경)**
|
|
|
|
```python
|
|
from app.schema import validate_interpretation
|
|
|
|
|
|
def _valid_card():
|
|
return {
|
|
"position": "과거", "card": "the-fool", "reversed": False,
|
|
"interpretation": "...", "advice": "...",
|
|
"evidence": {
|
|
"card_meaning_used": "새 시작",
|
|
"position_logic": "...",
|
|
"category_lens": "...",
|
|
},
|
|
}
|
|
|
|
|
|
def _valid_payload():
|
|
return {
|
|
"summary": "...",
|
|
"cards": [_valid_card()],
|
|
"interactions": [{"type": "synergy", "between": ["a", "b"], "explanation": "..."}],
|
|
"advice": "...",
|
|
"confidence": "medium",
|
|
}
|
|
|
|
|
|
def test_valid_three_card():
|
|
ok, _ = validate_interpretation(_valid_payload(), "three_card")
|
|
assert ok is True
|
|
|
|
|
|
def test_missing_summary():
|
|
p = _valid_payload(); del p["summary"]
|
|
ok, err = validate_interpretation(p, "three_card")
|
|
assert not ok and "summary" in err
|
|
|
|
|
|
def test_invalid_confidence():
|
|
p = _valid_payload(); p["confidence"] = "extreme"
|
|
ok, err = validate_interpretation(p, "three_card")
|
|
assert not ok and "confidence" in err
|
|
|
|
|
|
def test_three_card_empty_interactions():
|
|
p = _valid_payload(); p["interactions"] = []
|
|
ok, err = validate_interpretation(p, "three_card")
|
|
assert not ok and "interactions" in err
|
|
|
|
|
|
def test_one_card_empty_interactions_ok():
|
|
p = _valid_payload(); p["interactions"] = []
|
|
ok, _ = validate_interpretation(p, "one_card")
|
|
assert ok is True
|
|
|
|
|
|
def test_card_evidence_missing_field():
|
|
p = _valid_payload()
|
|
del p["cards"][0]["evidence"]["category_lens"]
|
|
ok, err = validate_interpretation(p, "three_card")
|
|
assert not ok and "category_lens" in err
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
Run: `cd tarot-lab && python -m pytest tests/test_schema.py -v`
|
|
Expected: 6 passed
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add tarot-lab/app/prompt.py tarot-lab/app/schema.py tarot-lab/tests/test_schema.py
|
|
git commit -m "feat(tarot-lab): prompt.py + schema.py 이관 + 검증 테스트 6건"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: tarot-lab pipeline.py 이관 (import 경로 수정)
|
|
|
|
**Files:**
|
|
- Create: `tarot-lab/app/pipeline.py`
|
|
- Create: `tarot-lab/tests/test_pipeline.py`
|
|
|
|
- [ ] **Step 1: app/pipeline.py — agent-office/app/tarot/pipeline.py 복사 + import 변경**
|
|
|
|
`agent-office/app/tarot/pipeline.py` 그대로 복사하되, 두 줄만 변경:
|
|
- `from ..config import (...)` → `from .config import (...)`
|
|
- `from ..models import TarotInterpretRequest` → `from .models import TarotInterpretRequest`
|
|
|
|
전체 코드 (변경 후 모습):
|
|
```python
|
|
"""Tarot 파이프라인 — Claude Sonnet 호출 + 파싱 폴백 + reroll 1회."""
|
|
import json
|
|
import logging
|
|
import time
|
|
from typing import Any, Dict
|
|
|
|
import httpx
|
|
|
|
from .config import (
|
|
ANTHROPIC_API_KEY,
|
|
TAROT_MODEL,
|
|
TAROT_COST_INPUT_PER_M,
|
|
TAROT_COST_OUTPUT_PER_M,
|
|
TAROT_TIMEOUT_SEC,
|
|
)
|
|
|
|
|
|
logger = logging.getLogger("tarot-lab.pipeline")
|
|
from .models import TarotInterpretRequest
|
|
from .prompt import SYSTEM_PROMPT, build_user_message
|
|
from .schema import validate_interpretation
|
|
|
|
|
|
API_URL = "https://api.anthropic.com/v1/messages"
|
|
|
|
|
|
class TarotError(Exception):
|
|
pass
|
|
|
|
|
|
def calc_cost(tokens_in: int, tokens_out: int) -> float:
|
|
return (
|
|
tokens_in / 1_000_000 * TAROT_COST_INPUT_PER_M
|
|
+ tokens_out / 1_000_000 * TAROT_COST_OUTPUT_PER_M
|
|
)
|
|
|
|
|
|
def _strip_codeblock(text: str) -> str:
|
|
t = text.strip()
|
|
if t.startswith("```"):
|
|
t = t.strip("`")
|
|
if t.startswith("json"):
|
|
t = t[4:]
|
|
t = t.strip()
|
|
return t
|
|
|
|
|
|
def _extract_json(raw: str) -> dict:
|
|
cleaned = _strip_codeblock(raw)
|
|
try:
|
|
return json.loads(cleaned)
|
|
except json.JSONDecodeError:
|
|
start, end = cleaned.find("{"), cleaned.rfind("}")
|
|
if start >= 0 and end > start:
|
|
try:
|
|
return json.loads(cleaned[start : end + 1])
|
|
except json.JSONDecodeError:
|
|
pass
|
|
raise
|
|
|
|
|
|
async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict, str]:
|
|
if not ANTHROPIC_API_KEY:
|
|
raise TarotError("ANTHROPIC_API_KEY missing")
|
|
if feedback:
|
|
user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마(시스템 지침)로 다시 응답.\n\n{user_text}"
|
|
payload = {
|
|
"model": TAROT_MODEL,
|
|
"max_tokens": 1400,
|
|
"system": [{"type": "text", "text": SYSTEM_PROMPT,
|
|
"cache_control": {"type": "ephemeral"}}],
|
|
"messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
|
|
}
|
|
headers = {
|
|
"x-api-key": ANTHROPIC_API_KEY,
|
|
"anthropic-version": "2023-06-01",
|
|
"anthropic-beta": "prompt-caching-2024-07-31",
|
|
"content-type": "application/json",
|
|
}
|
|
started = time.monotonic()
|
|
async with httpx.AsyncClient(timeout=TAROT_TIMEOUT_SEC) as client:
|
|
r = await client.post(API_URL, headers=headers, json=payload)
|
|
r.raise_for_status()
|
|
resp = r.json()
|
|
latency_ms = int((time.monotonic() - started) * 1000)
|
|
raw_text = "".join(
|
|
b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"
|
|
)
|
|
usage = resp.get("usage", {}) or {}
|
|
tokens_in = int(usage.get("input_tokens", 0) or 0)
|
|
tokens_out = int(usage.get("output_tokens", 0) or 0)
|
|
logger.info("tarot claude call: latency=%dms, in=%d, out=%d", latency_ms, tokens_in, tokens_out)
|
|
parsed = _extract_json(raw_text)
|
|
meta = {"tokens_in": tokens_in, "tokens_out": tokens_out, "latency_ms": latency_ms}
|
|
return parsed, meta, raw_text
|
|
|
|
|
|
async def interpret(req: TarotInterpretRequest) -> Dict[str, Any]:
|
|
user_text = build_user_message(
|
|
question=req.question or "",
|
|
category=req.category or "",
|
|
spread_type=req.spread_type,
|
|
cards_reference=req.cards_reference,
|
|
context_meta=req.context_meta or {},
|
|
spread_count=len(req.cards),
|
|
)
|
|
|
|
total_in, total_out, total_latency = 0, 0, 0
|
|
last_error = ""
|
|
for attempt in range(2):
|
|
try:
|
|
parsed, meta, _raw = await _call_claude(user_text, feedback=last_error)
|
|
except httpx.HTTPError as e:
|
|
raise TarotError(f"Claude HTTP error: {e}") from e
|
|
except json.JSONDecodeError as e:
|
|
last_error = f"JSON 파싱 실패: {e}"
|
|
continue
|
|
total_in += meta["tokens_in"]
|
|
total_out += meta["tokens_out"]
|
|
total_latency += meta["latency_ms"]
|
|
|
|
ok, err = validate_interpretation(parsed, req.spread_type)
|
|
if ok:
|
|
return {
|
|
"interpretation_json": parsed,
|
|
"model": TAROT_MODEL,
|
|
"tokens_in": total_in,
|
|
"tokens_out": total_out,
|
|
"cost_usd": calc_cost(total_in, total_out),
|
|
"latency_ms": total_latency,
|
|
"reroll_count": attempt,
|
|
}
|
|
last_error = err
|
|
|
|
raise TarotError(f"검증 실패 (reroll 2회): {last_error}")
|
|
```
|
|
|
|
- [ ] **Step 2: tests/test_pipeline.py 작성**
|
|
|
|
```python
|
|
import json
|
|
import pytest
|
|
import respx
|
|
import httpx
|
|
|
|
from app import pipeline
|
|
from app.models import TarotInterpretRequest, TarotCardDraw
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _patch_key(monkeypatch):
|
|
monkeypatch.setattr(pipeline, "ANTHROPIC_API_KEY", "test-key")
|
|
|
|
|
|
def _req():
|
|
return TarotInterpretRequest(
|
|
spread_type="three_card",
|
|
category="연애",
|
|
question="Q",
|
|
cards=[
|
|
TarotCardDraw(position="과거", card_id="the-fool", reversed=False),
|
|
TarotCardDraw(position="현재", card_id="the-magician", reversed=False),
|
|
TarotCardDraw(position="미래", card_id="the-empress", reversed=True),
|
|
],
|
|
cards_reference="...",
|
|
)
|
|
|
|
|
|
def _valid_response_json():
|
|
return {
|
|
"summary": "흐름이 있음",
|
|
"cards": [
|
|
{"position": "과거", "card": "the-fool", "reversed": False,
|
|
"interpretation": "...", "advice": "...",
|
|
"evidence": {"card_meaning_used": "...", "position_logic": "...", "category_lens": "..."}},
|
|
{"position": "현재", "card": "the-magician", "reversed": False,
|
|
"interpretation": "...", "advice": "...",
|
|
"evidence": {"card_meaning_used": "...", "position_logic": "...", "category_lens": "..."}},
|
|
{"position": "미래", "card": "the-empress", "reversed": True,
|
|
"interpretation": "...", "advice": "...",
|
|
"evidence": {"card_meaning_used": "...", "position_logic": "...", "category_lens": "..."}},
|
|
],
|
|
"interactions": [{"type": "synergy", "between": ["the-fool", "the-magician"], "explanation": "..."}],
|
|
"advice": "...",
|
|
"warning": None,
|
|
"confidence": "medium",
|
|
}
|
|
|
|
|
|
def _claude_envelope(text: str, in_tok=100, out_tok=200):
|
|
return {
|
|
"content": [{"type": "text", "text": text}],
|
|
"usage": {"input_tokens": in_tok, "output_tokens": out_tok},
|
|
}
|
|
|
|
|
|
@respx.mock
|
|
async def test_interpret_success():
|
|
respx.post("https://api.anthropic.com/v1/messages").mock(
|
|
return_value=httpx.Response(200, json=_claude_envelope(json.dumps(_valid_response_json())))
|
|
)
|
|
result = await pipeline.interpret(_req())
|
|
assert result["reroll_count"] == 0
|
|
assert result["model"] == pipeline.TAROT_MODEL
|
|
assert result["tokens_in"] == 100
|
|
assert result["cost_usd"] > 0
|
|
|
|
|
|
@respx.mock
|
|
async def test_interpret_codeblock_stripped():
|
|
text = "```json\n" + json.dumps(_valid_response_json()) + "\n```"
|
|
respx.post("https://api.anthropic.com/v1/messages").mock(
|
|
return_value=httpx.Response(200, json=_claude_envelope(text))
|
|
)
|
|
result = await pipeline.interpret(_req())
|
|
assert "interpretation_json" in result
|
|
|
|
|
|
@respx.mock
|
|
async def test_interpret_reroll_then_success():
|
|
valid = json.dumps(_valid_response_json())
|
|
invalid = json.dumps({"summary": "...", "cards": [], "interactions": [], "advice": "", "confidence": "medium"})
|
|
respx.post("https://api.anthropic.com/v1/messages").mock(
|
|
side_effect=[
|
|
httpx.Response(200, json=_claude_envelope(invalid)),
|
|
httpx.Response(200, json=_claude_envelope(valid)),
|
|
]
|
|
)
|
|
result = await pipeline.interpret(_req())
|
|
assert result["reroll_count"] == 1
|
|
|
|
|
|
@respx.mock
|
|
async def test_interpret_reroll_fail_raises():
|
|
invalid = json.dumps({"summary": "...", "cards": [], "interactions": [], "advice": "", "confidence": "medium"})
|
|
respx.post("https://api.anthropic.com/v1/messages").mock(
|
|
return_value=httpx.Response(200, json=_claude_envelope(invalid))
|
|
)
|
|
with pytest.raises(pipeline.TarotError):
|
|
await pipeline.interpret(_req())
|
|
|
|
|
|
@respx.mock
|
|
async def test_interpret_http_error():
|
|
respx.post("https://api.anthropic.com/v1/messages").mock(
|
|
return_value=httpx.Response(500, text="boom")
|
|
)
|
|
with pytest.raises(pipeline.TarotError):
|
|
await pipeline.interpret(_req())
|
|
|
|
|
|
def test_calc_cost():
|
|
cost = pipeline.calc_cost(1_000_000, 1_000_000)
|
|
assert cost == pipeline.TAROT_COST_INPUT_PER_M + pipeline.TAROT_COST_OUTPUT_PER_M
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
Run: `cd tarot-lab && python -m pytest tests/test_pipeline.py -v`
|
|
Expected: 6 passed
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add tarot-lab/app/pipeline.py tarot-lab/tests/test_pipeline.py
|
|
git commit -m "feat(tarot-lab): pipeline.py 이관 + 6 테스트 통과"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: tarot-lab main.py + routes (6 endpoints)
|
|
|
|
**Files:**
|
|
- Create: `tarot-lab/app/main.py`
|
|
- Create: `tarot-lab/tests/test_routes.py`
|
|
|
|
- [ ] **Step 1: app/main.py 작성**
|
|
|
|
```python
|
|
"""tarot-lab FastAPI app — /api/tarot/* 6 endpoints."""
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from .config import CORS_ALLOW_ORIGINS
|
|
from .models import (
|
|
TarotInterpretRequest,
|
|
TarotInterpretResponse,
|
|
TarotSaveRequest,
|
|
TarotPatchRequest,
|
|
)
|
|
from . import pipeline, db as db_module
|
|
|
|
|
|
app = FastAPI(title="tarot-lab")
|
|
|
|
_origins = [o.strip() for o in CORS_ALLOW_ORIGINS.split(",") if o.strip()]
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=_origins,
|
|
allow_credentials=False,
|
|
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
|
allow_headers=["Content-Type"],
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
def _init():
|
|
db_module.init_db()
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/api/tarot/interpret", response_model=TarotInterpretResponse)
|
|
async def interpret_endpoint(req: TarotInterpretRequest):
|
|
try:
|
|
result = await pipeline.interpret(req)
|
|
except pipeline.TarotError as e:
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
return result
|
|
|
|
|
|
@app.post("/api/tarot/readings")
|
|
async def save_reading(req: TarotSaveRequest):
|
|
rid = db_module.save_tarot_reading(req.model_dump())
|
|
row = db_module.get_tarot_reading(rid)
|
|
return {"id": rid, "created_at": row["created_at"]}
|
|
|
|
|
|
@app.get("/api/tarot/readings")
|
|
async def list_readings(
|
|
page: int = 1,
|
|
size: int = 20,
|
|
favorite: bool | None = None,
|
|
spread_type: str | None = None,
|
|
category: str | None = None,
|
|
):
|
|
return db_module.list_tarot_readings(
|
|
page=page, size=size,
|
|
favorite=favorite, spread_type=spread_type, category=category,
|
|
)
|
|
|
|
|
|
@app.get("/api/tarot/readings/{reading_id}")
|
|
async def get_reading(reading_id: int):
|
|
row = db_module.get_tarot_reading(reading_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="reading not found")
|
|
return row
|
|
|
|
|
|
@app.patch("/api/tarot/readings/{reading_id}")
|
|
async def patch_reading(reading_id: int, req: TarotPatchRequest):
|
|
row = db_module.get_tarot_reading(reading_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="reading not found")
|
|
db_module.update_tarot_reading(reading_id, **req.model_dump(exclude_none=True))
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/tarot/readings/{reading_id}")
|
|
async def delete_reading(reading_id: int):
|
|
row = db_module.get_tarot_reading(reading_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="reading not found")
|
|
db_module.delete_tarot_reading(reading_id)
|
|
return {"ok": True}
|
|
```
|
|
|
|
- [ ] **Step 2: tests/test_routes.py 작성**
|
|
|
|
```python
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.main import app
|
|
from app import db as db_module
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def fresh_db(monkeypatch, tmp_path):
|
|
db_file = tmp_path / "test_tarot.db"
|
|
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
|
db_module.init_db()
|
|
yield
|
|
try:
|
|
if db_file.exists():
|
|
db_file.unlink()
|
|
except PermissionError:
|
|
pass
|
|
|
|
|
|
def _save_payload():
|
|
return {
|
|
"spread_type": "three_card",
|
|
"category": "연애",
|
|
"question": "Q",
|
|
"cards": [{"position": "과거", "card_id": "the-fool", "reversed": False}],
|
|
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "medium"},
|
|
"model": "claude-sonnet-4-6",
|
|
"tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005,
|
|
"confidence": "medium",
|
|
}
|
|
|
|
|
|
def test_health():
|
|
with TestClient(app) as c:
|
|
r = c.get("/health")
|
|
assert r.status_code == 200
|
|
assert r.json() == {"ok": True}
|
|
|
|
|
|
def test_save_list_get_cycle():
|
|
with TestClient(app) as c:
|
|
r = c.post("/api/tarot/readings", json=_save_payload())
|
|
assert r.status_code == 200
|
|
rid = r.json()["id"]
|
|
r = c.get("/api/tarot/readings")
|
|
assert r.json()["total"] == 1
|
|
r = c.get(f"/api/tarot/readings/{rid}")
|
|
assert r.json()["category"] == "연애"
|
|
|
|
|
|
def test_patch_favorite_and_note():
|
|
with TestClient(app) as c:
|
|
rid = c.post("/api/tarot/readings", json=_save_payload()).json()["id"]
|
|
r = c.patch(f"/api/tarot/readings/{rid}", json={"favorite": True, "note": "n"})
|
|
assert r.status_code == 200
|
|
row = c.get(f"/api/tarot/readings/{rid}").json()
|
|
assert row["favorite"] == 1
|
|
assert row["note"] == "n"
|
|
|
|
|
|
def test_delete():
|
|
with TestClient(app) as c:
|
|
rid = c.post("/api/tarot/readings", json=_save_payload()).json()["id"]
|
|
assert c.delete(f"/api/tarot/readings/{rid}").status_code == 200
|
|
assert c.get(f"/api/tarot/readings/{rid}").status_code == 404
|
|
|
|
|
|
def test_get_404():
|
|
with TestClient(app) as c:
|
|
assert c.get("/api/tarot/readings/9999").status_code == 404
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
Run: `cd tarot-lab && python -m pytest tests/test_routes.py -v`
|
|
Expected: 5 passed
|
|
|
|
- [ ] **Step 4: Run all tarot-lab tests**
|
|
|
|
Run: `cd tarot-lab && python -m pytest -v`
|
|
Expected: 21 passed (4 db + 6 schema + 6 pipeline + 5 routes)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add tarot-lab/app/main.py tarot-lab/tests/test_routes.py
|
|
git commit -m "feat(tarot-lab): main.py + 5 라우트 테스트 (총 21 tests 통과)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: DB 마이그레이션 스크립트
|
|
|
|
**Files:**
|
|
- Create: `agent-office/scripts/migrate_tarot_to_lab.py`
|
|
- Create: `agent-office/tests/test_migrate_tarot.py`
|
|
|
|
- [ ] **Step 1: 실패 테스트 작성**
|
|
|
|
```python
|
|
"""migrate_tarot_to_lab.py 단위 테스트 — 멱등성 + 데이터 보존."""
|
|
import json
|
|
import sqlite3
|
|
import sys
|
|
import os
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def src_db(tmp_path):
|
|
p = tmp_path / "agent_office.db"
|
|
conn = sqlite3.connect(str(p))
|
|
conn.execute("""
|
|
CREATE TABLE tarot_readings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
created_at TEXT, spread_type TEXT, category TEXT, question TEXT,
|
|
cards TEXT, interpretation_json TEXT, summary TEXT, model TEXT,
|
|
tokens_in INTEGER, tokens_out INTEGER, cost_usd REAL,
|
|
confidence TEXT, favorite INTEGER, note TEXT
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
INSERT INTO tarot_readings (id, spread_type, category, cards, model, favorite)
|
|
VALUES (1, 'three_card', '연애', '[]', 'm', 0),
|
|
(2, 'one_card', '재물', '[]', 'm', 1)
|
|
""")
|
|
conn.commit()
|
|
conn.close()
|
|
return str(p)
|
|
|
|
|
|
@pytest.fixture
|
|
def dst_db(tmp_path):
|
|
return str(tmp_path / "tarot.db")
|
|
|
|
|
|
def _import_migrate(src, dst, monkeypatch):
|
|
# script 가 import 시점에 env 읽도록 monkeypatch
|
|
monkeypatch.setenv("AGENT_OFFICE_DB", src)
|
|
monkeypatch.setenv("TAROT_DB", dst)
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
|
import migrate_tarot_to_lab as m
|
|
import importlib
|
|
importlib.reload(m)
|
|
return m
|
|
|
|
|
|
def test_first_run_copies_all_rows(src_db, dst_db, monkeypatch):
|
|
m = _import_migrate(src_db, dst_db, monkeypatch)
|
|
moved = m.migrate()
|
|
assert moved == 2
|
|
conn = sqlite3.connect(dst_db)
|
|
rows = conn.execute("SELECT id, spread_type, category FROM tarot_readings ORDER BY id").fetchall()
|
|
conn.close()
|
|
assert rows == [(1, "three_card", "연애"), (2, "one_card", "재물")]
|
|
|
|
|
|
def test_idempotent_second_run(src_db, dst_db, monkeypatch):
|
|
m = _import_migrate(src_db, dst_db, monkeypatch)
|
|
m.migrate()
|
|
moved2 = m.migrate()
|
|
assert moved2 == 0 # 이미 다 옮겼으므로 0
|
|
|
|
|
|
def test_partial_migration(src_db, dst_db, monkeypatch):
|
|
"""dst에 id=1만 있는 상태에서 다시 돌리면 id=2만 옮김."""
|
|
m = _import_migrate(src_db, dst_db, monkeypatch)
|
|
m.migrate()
|
|
# dst에서 id=2 삭제 → 다시 마이그레이션하면 1건만 새로 들어가야 함
|
|
conn = sqlite3.connect(dst_db)
|
|
conn.execute("DELETE FROM tarot_readings WHERE id=2")
|
|
conn.commit()
|
|
conn.close()
|
|
moved = m.migrate()
|
|
assert moved == 1
|
|
```
|
|
|
|
- [ ] **Step 2: Run test, verify fails**
|
|
|
|
Run: `cd agent-office && python -m pytest tests/test_migrate_tarot.py -v`
|
|
Expected: FAIL (`ImportError: cannot import name 'migrate_tarot_to_lab'`)
|
|
|
|
- [ ] **Step 3: scripts/migrate_tarot_to_lab.py 작성**
|
|
|
|
```python
|
|
"""1회성 마이그레이션 — agent_office.db.tarot_readings → tarot.db.tarot_readings.
|
|
|
|
멱등성: 이미 존재하는 id는 SKIP.
|
|
|
|
실행:
|
|
docker exec agent-office python /app/scripts/migrate_tarot_to_lab.py
|
|
|
|
또는 호스트에서 직접:
|
|
AGENT_OFFICE_DB=/path/to/agent_office.db TAROT_DB=/path/to/tarot.db \\
|
|
python scripts/migrate_tarot_to_lab.py
|
|
"""
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
|
|
|
|
SRC = os.getenv("AGENT_OFFICE_DB", "/app/data/agent_office.db")
|
|
DST = os.getenv("TAROT_DB", "/app/data/tarot.db")
|
|
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS tarot_readings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
spread_type TEXT NOT NULL,
|
|
category TEXT,
|
|
question TEXT,
|
|
cards TEXT NOT NULL,
|
|
interpretation_json TEXT,
|
|
summary TEXT,
|
|
model TEXT,
|
|
tokens_in INTEGER,
|
|
tokens_out INTEGER,
|
|
cost_usd REAL,
|
|
confidence TEXT,
|
|
favorite INTEGER NOT NULL DEFAULT 0,
|
|
note TEXT
|
|
);
|
|
"""
|
|
|
|
|
|
def migrate() -> int:
|
|
"""이관된 row 수 반환."""
|
|
src = sqlite3.connect(SRC)
|
|
src.row_factory = sqlite3.Row
|
|
dst = sqlite3.connect(DST)
|
|
dst.execute("PRAGMA journal_mode=WAL")
|
|
dst.executescript(SCHEMA)
|
|
|
|
rows = src.execute("SELECT * FROM tarot_readings").fetchall()
|
|
if not rows:
|
|
src.close(); dst.close()
|
|
return 0
|
|
|
|
cols = list(rows[0].keys())
|
|
placeholders = ",".join("?" * len(cols))
|
|
cols_str = ",".join(cols)
|
|
|
|
moved = 0
|
|
for r in rows:
|
|
exists = dst.execute("SELECT 1 FROM tarot_readings WHERE id=?", (r["id"],)).fetchone()
|
|
if exists:
|
|
continue
|
|
dst.execute(
|
|
f"INSERT INTO tarot_readings ({cols_str}) VALUES ({placeholders})",
|
|
tuple(r[c] for c in cols),
|
|
)
|
|
moved += 1
|
|
dst.commit()
|
|
src.close(); dst.close()
|
|
return moved
|
|
|
|
|
|
if __name__ == "__main__":
|
|
moved = migrate()
|
|
total = sqlite3.connect(SRC).execute("SELECT COUNT(*) FROM tarot_readings").fetchone()[0]
|
|
print(f"migrated {moved} / {total} rows from {SRC} to {DST}")
|
|
sys.exit(0 if moved >= 0 else 1)
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, verify pass**
|
|
|
|
Run: `cd agent-office && python -m pytest tests/test_migrate_tarot.py -v`
|
|
Expected: 3 passed
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add agent-office/scripts/migrate_tarot_to_lab.py agent-office/tests/test_migrate_tarot.py
|
|
git commit -m "feat(agent-office): tarot_readings 1회성 마이그레이션 스크립트 (3 테스트)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: docker-compose.yml에 tarot-lab 등록
|
|
|
|
**Files:**
|
|
- Modify: `docker-compose.yml`
|
|
|
|
- [ ] **Step 1: 현재 agent-office 항목 위치 확인**
|
|
|
|
Run: `grep -n "agent-office:" docker-compose.yml | head -3`
|
|
Expected: `agent-office:` 컨테이너 정의 시작 줄 번호
|
|
|
|
- [ ] **Step 2: docker-compose.yml에 tarot-lab service 추가**
|
|
|
|
agent-office 항목 바로 다음에 다음 블록 삽입:
|
|
|
|
```yaml
|
|
tarot-lab:
|
|
build:
|
|
context: ./tarot-lab
|
|
container_name: tarot-lab
|
|
restart: unless-stopped
|
|
ports:
|
|
- "18250:8000"
|
|
environment:
|
|
- TZ=${TZ:-Asia/Seoul}
|
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
|
- TAROT_MODEL=${TAROT_MODEL:-claude-sonnet-4-6}
|
|
- TAROT_COST_INPUT_PER_M=${TAROT_COST_INPUT_PER_M:-3.0}
|
|
- TAROT_COST_OUTPUT_PER_M=${TAROT_COST_OUTPUT_PER_M:-15.0}
|
|
- TAROT_TIMEOUT_SEC=${TAROT_TIMEOUT_SEC:-180}
|
|
- TAROT_DATA_PATH=/app/data
|
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
|
volumes:
|
|
- ${RUNTIME_PATH:-.}/data/tarot:/app/data
|
|
healthcheck:
|
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
|
interval: 60s
|
|
timeout: 5s
|
|
retries: 3
|
|
```
|
|
|
|
- [ ] **Step 3: docker-compose.yml syntax 검증**
|
|
|
|
Run: `docker compose config 2>&1 | head -50`
|
|
Expected: tarot-lab section이 정상 파싱됨 (에러 없음)
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add docker-compose.yml
|
|
git commit -m "feat(docker-compose): tarot-lab 컨테이너 추가 (18250 포트)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: nginx /api/tarot/ 라우팅 추가
|
|
|
|
**Files:**
|
|
- Modify: `nginx/default.conf`
|
|
|
|
- [ ] **Step 1: /api/agent-office/ location 바로 앞에 tarot 추가**
|
|
|
|
`nginx/default.conf`의 `# agent-office API + WebSocket` 위에 다음 블록 삽입:
|
|
|
|
```nginx
|
|
# tarot-lab API (agent-office에서 분리)
|
|
location /api/tarot/ {
|
|
resolver 127.0.0.11 valid=10s;
|
|
set $tarot_backend tarot-lab:8000;
|
|
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_read_timeout 300s;
|
|
proxy_send_timeout 300s;
|
|
proxy_connect_timeout 60s;
|
|
proxy_pass http://$tarot_backend$request_uri;
|
|
}
|
|
|
|
```
|
|
|
|
- [ ] **Step 2: nginx syntax 점검 (선택)**
|
|
|
|
Run: `docker run --rm -v "$(pwd)/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro" nginx:alpine nginx -t 2>&1`
|
|
Expected: `syntax is ok` (호스트에 docker가 없으면 이 단계 skip 가능)
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add nginx/default.conf
|
|
git commit -m "feat(nginx): /api/tarot/ → tarot-lab:8000 라우팅 추가"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: deploy 스크립트 5위치 갱신 (tarot-lab 등록)
|
|
|
|
**Files:**
|
|
- Modify: `scripts/deploy-nas.sh:5`
|
|
- Modify: `scripts/deploy.sh:18`
|
|
- Modify: `scripts/deploy.sh:20`
|
|
- Modify: `scripts/deploy.sh:24`
|
|
- Modify: `scripts/deploy.sh:26`
|
|
|
|
- [ ] **Step 1: scripts/deploy-nas.sh의 SERVICES 갱신**
|
|
|
|
L5의 SERVICES 변수에 `tarot-lab` 추가:
|
|
```bash
|
|
SERVICES="lotto travel-proxy deployer stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab tarot-lab nginx scripts"
|
|
```
|
|
|
|
- [ ] **Step 2: scripts/deploy.sh BUILD_TARGETS 갱신 (L18)**
|
|
|
|
```bash
|
|
BUILD_TARGETS="lotto travel-proxy stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab tarot-lab frontend"
|
|
```
|
|
|
|
- [ ] **Step 3: scripts/deploy.sh CONTAINER_NAMES 갱신 (L20)**
|
|
|
|
```bash
|
|
CONTAINER_NAMES="lotto stock music-lab insta-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy video-lab image-lab tarot-lab frontend"
|
|
```
|
|
|
|
- [ ] **Step 4: scripts/deploy.sh HEALTH_ENDPOINTS 갱신 (L24)**
|
|
|
|
```bash
|
|
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab tarot-lab redis"
|
|
```
|
|
|
|
- [ ] **Step 5: scripts/deploy.sh DATA_DIRS 갱신 (L26)**
|
|
|
|
`tarot` 디렉토리 추가:
|
|
```bash
|
|
DATA_DIRS="music stock insta realestate agent-office personal video image tarot"
|
|
```
|
|
|
|
- [ ] **Step 6: 검증**
|
|
|
|
Run: `grep -E '(tarot-lab|tarot)' scripts/deploy.sh scripts/deploy-nas.sh`
|
|
Expected: 두 파일 모두 tarot-lab/tarot 포함 라인 출력
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add scripts/deploy.sh scripts/deploy-nas.sh
|
|
git commit -m "feat(deploy): tarot-lab 5위치(SERVICES/BUILD/CONTAINER/HEALTH/DATA) 동기화"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: agent-office에서 tarot 모듈 cutover 제거
|
|
|
|
**Files:**
|
|
- Delete: `agent-office/app/tarot/` (디렉토리)
|
|
- Delete: `agent-office/app/routers/tarot.py`
|
|
- Modify: `agent-office/app/main.py`
|
|
- Modify: `agent-office/app/models.py`
|
|
- Modify: `agent-office/app/db.py`
|
|
- Modify: `agent-office/app/config.py`
|
|
- Delete: `agent-office/tests/test_tarot_db.py`
|
|
- Delete: `agent-office/tests/test_tarot_pipeline.py`
|
|
- Delete: `agent-office/tests/test_tarot_routes.py`
|
|
- Delete: `agent-office/tests/test_tarot_schema.py`
|
|
|
|
**⚠️ 사전 조건**: NAS에서 마이그레이션 스크립트를 실행해 tarot_readings 데이터를 tarot.db로 옮긴 후 진행. 검증:
|
|
```bash
|
|
docker exec agent-office python /app/scripts/migrate_tarot_to_lab.py
|
|
# 출력: "migrated N / N rows"
|
|
docker exec tarot-lab python -c "import sqlite3; print(sqlite3.connect('/app/data/tarot.db').execute('SELECT COUNT(*) FROM tarot_readings').fetchone())"
|
|
# 출력: (N,) ← agent-office의 N과 일치해야 함
|
|
```
|
|
|
|
- [ ] **Step 1: agent-office/app/main.py에서 tarot router import + include 제거**
|
|
|
|
L15 `from .routers import tarot as tarot_router` 줄 삭제.
|
|
L19 `app.include_router(tarot_router.router)` 줄 삭제.
|
|
|
|
- [ ] **Step 2: agent-office/app/models.py에서 Tarot* 5개 제거**
|
|
|
|
L38~L78 (TarotCardDraw / TarotInterpretRequest / TarotInterpretResponse / TarotSaveRequest / TarotPatchRequest) 블록 삭제.
|
|
|
|
- [ ] **Step 3: agent-office/app/db.py에서 tarot 관련 5 CRUD + helper 제거**
|
|
|
|
L798~L909 (`# --- tarot_readings CRUD ---` 주석부터 `_tarot_row_to_dict` 함수 끝까지) 전체 삭제.
|
|
|
|
CREATE TABLE `tarot_readings` 블록 (L134~L160, idx_tarot_created/idx_tarot_favorite 인덱스 포함)은 **유지** — 운영 DB에 이미 있는 테이블을 DROP하지 않기 위함. 기존 데이터는 archive 역할로 남겨두고 30일 후 manual cleanup.
|
|
|
|
- [ ] **Step 4: agent-office/app/config.py에서 TAROT_* 제거**
|
|
|
|
L43~L46 TAROT_MODEL / TAROT_COST_INPUT_PER_M / TAROT_COST_OUTPUT_PER_M / TAROT_TIMEOUT_SEC 4줄 삭제.
|
|
|
|
- [ ] **Step 5: 디렉토리 + 라우터 + 테스트 파일 삭제**
|
|
|
|
```bash
|
|
rm -rf agent-office/app/tarot/
|
|
rm agent-office/app/routers/tarot.py
|
|
rm agent-office/tests/test_tarot_db.py
|
|
rm agent-office/tests/test_tarot_pipeline.py
|
|
rm agent-office/tests/test_tarot_routes.py
|
|
rm agent-office/tests/test_tarot_schema.py
|
|
```
|
|
|
|
- [ ] **Step 6: agent-office import 잔여 흔적 검색**
|
|
|
|
Run: `grep -rn "tarot" agent-office/app/ agent-office/tests/`
|
|
Expected: scripts/migrate_tarot_to_lab.py만 매치 (그 외 매치 0)
|
|
|
|
- [ ] **Step 7: agent-office pytest 통과 확인**
|
|
|
|
Run: `cd agent-office && python -m pytest -v --ignore=tests/test_migrate_tarot.py 2>&1 | tail -20`
|
|
Expected: PASS (tarot 제외 다른 테스트 정상 동작)
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add -A agent-office/
|
|
git commit -m "refactor(agent-office): tarot 모듈 제거 (tarot-lab으로 cutover 완료)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: web-ui api.js URL prefix 변경 + Phase 1 e2e 검증
|
|
|
|
**Files:**
|
|
- Modify: `web-ui/src/api.js:745-770`
|
|
|
|
⚠️ web-ui는 별도 git 저장소. 작업 디렉토리 이동 필요.
|
|
|
|
- [ ] **Step 1: web-ui로 cd 후 api.js 6개 endpoint URL 변경**
|
|
|
|
`web-ui/src/api.js`의 6개 함수 URL을 변경:
|
|
|
|
| 함수 | 기존 | 변경 |
|
|
|------|------|------|
|
|
| `tarotInterpret` | `/api/agent-office/tarot/interpret` | `/api/tarot/interpret` |
|
|
| `tarotSaveReading` | `/api/agent-office/tarot/readings` | `/api/tarot/readings` |
|
|
| `tarotListReadings` | `/api/agent-office/tarot/readings?...` | `/api/tarot/readings?...` |
|
|
| `tarotGetReading` | `/api/agent-office/tarot/readings/${id}` | `/api/tarot/readings/${id}` |
|
|
| `tarotPatchReading` | `/api/agent-office/tarot/readings/${id}` | `/api/tarot/readings/${id}` |
|
|
| `tarotDeleteReading` | `/api/agent-office/tarot/readings/${id}` | `/api/tarot/readings/${id}` |
|
|
|
|
Sed 일괄 치환 가능:
|
|
```bash
|
|
cd ../web-ui
|
|
# Bash on Windows: 6 함수 모두 같은 prefix → 한 번에 치환
|
|
# Edit 도구 또는 sed로 '/api/agent-office/tarot/' → '/api/tarot/' 일괄 변경
|
|
```
|
|
|
|
- [ ] **Step 2: web-ui 잔여 검색**
|
|
|
|
Run: `cd ../web-ui && grep -rn "/api/agent-office/tarot" src/`
|
|
Expected: 매치 0건
|
|
|
|
- [ ] **Step 3: web-backend 로컬 docker compose 기동 (선택)**
|
|
|
|
Run: `cd ../web-backend && docker compose up -d tarot-lab nginx frontend agent-office`
|
|
Expected: tarot-lab + nginx + agent-office healthy
|
|
|
|
- [ ] **Step 4: 로컬 e2e — npm run dev 후 /tarot 페이지 1회 리딩**
|
|
|
|
Run: `cd ../web-ui && npm run dev`
|
|
브라우저: http://127.0.0.1:3007/tarot/reading
|
|
- 3장 스프레드 1회 실행
|
|
- AI 인사이트 탭에서 응답 확인
|
|
- "리딩 저장" 클릭 → /tarot/history에서 보이는지 확인
|
|
- 즐겨찾기 토글 / 삭제 동작 확인
|
|
|
|
Expected: 모든 동작 정상 (네트워크 탭에서 `/api/tarot/*` 호출 확인)
|
|
|
|
- [ ] **Step 5: Commit web-ui**
|
|
|
|
```bash
|
|
cd ../web-ui
|
|
git add src/api.js
|
|
git commit -m "feat(api): tarot endpoint를 /api/tarot/* 로 이전 (agent-office 분리)"
|
|
cd ../web-backend
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 1 완료 ✓
|
|
|
|
Phase 1 종료 시점 점검:
|
|
- [ ] `tarot-lab/` 디렉토리에 21개 테스트 모두 통과
|
|
- [ ] `docker-compose.yml`에 tarot-lab 항목 존재 (18250)
|
|
- [ ] `nginx/default.conf`에 `/api/tarot/` location 존재
|
|
- [ ] deploy 스크립트 5위치 모두 tarot-lab 포함
|
|
- [ ] `agent-office/app/tarot/` 디렉토리 + `routers/tarot.py` 삭제됨
|
|
- [ ] `agent-office/app/db.py`에 tarot_readings CREATE는 유지, CRUD는 제거
|
|
- [ ] `agent-office/scripts/migrate_tarot_to_lab.py` 존재 + 테스트 3건 통과
|
|
- [ ] `web-ui/src/api.js`의 tarot 6 helper가 `/api/tarot/*` 사용
|
|
- [ ] 로컬 e2e: /tarot에서 1회 리딩 + 저장 + 삭제 정상
|
|
|
|
NAS 배포 절차 (사용자 수동):
|
|
1. `cd web-backend && git push` → Gitea Webhook → deployer 자동 배포
|
|
2. 배포 완료 후 SSH에서 마이그레이션 1회 실행:
|
|
```bash
|
|
ssh nas
|
|
docker exec agent-office python /app/scripts/migrate_tarot_to_lab.py
|
|
# "migrated N / N rows" 확인
|
|
docker exec tarot-lab python -c "import sqlite3; print(sqlite3.connect('/app/data/tarot.db').execute('SELECT COUNT(*) FROM tarot_readings').fetchone())"
|
|
```
|
|
3. 운영 /tarot 페이지에서 1회 리딩 확인
|
|
4. `cd web-ui && npm run release:nas` (api.js 변경 적용)
|
|
|
|
---
|
|
|
|
## Phase 2 — saju-lab 신설
|
|
|
|
Phase 2는 작업량이 큽니다(계산 엔진 ~1500줄 TypeScript → Python 포팅 + Claude 통합 + DB/Router/UI). Phase 1 완료를 확인한 후 시작하세요.
|
|
|
|
> **참고**: Phase 2 시작 전 saju-web 디렉토리의 `lib/saju-calculator.ts`, `lib/ai-interpretation.ts`, `lib/daeun-calculator.ts`, `lib/solar-terms.ts`, `app/compatibility/*` 파일을 모두 열어 reference 구현을 숙지할 것. spec의 Section 6 참조.
|
|
|
|
### Task 13: saju-lab 스캐폴딩
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/Dockerfile`
|
|
- Create: `saju-lab/requirements.txt`
|
|
- Create: `saju-lab/pytest.ini`
|
|
- Create: `saju-lab/.dockerignore`
|
|
- Create: `saju-lab/app/__init__.py`
|
|
- Create: `saju-lab/tests/__init__.py`
|
|
- Create: `saju-lab/tests/fixtures/__init__.py`
|
|
- Create: `saju-lab/app/calculator/__init__.py`
|
|
- Create: `saju-lab/app/interpret/__init__.py`
|
|
- Create: `saju-lab/app/routers/__init__.py`
|
|
|
|
- [ ] **Step 1: Dockerfile (insta-lab 패턴)**
|
|
|
|
```dockerfile
|
|
FROM python:3.12-slim-bookworm
|
|
ENV PYTHONUNBUFFERED=1
|
|
|
|
WORKDIR /app
|
|
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
|
|
|
COPY . .
|
|
|
|
EXPOSE 8000
|
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
|
```
|
|
|
|
- [ ] **Step 2: requirements.txt**
|
|
|
|
```
|
|
fastapi==0.115.6
|
|
uvicorn[standard]==0.34.0
|
|
httpx>=0.27
|
|
pydantic>=2.9
|
|
sxtwl>=2.0
|
|
pytest>=8.0
|
|
pytest-asyncio>=0.24
|
|
respx>=0.21
|
|
```
|
|
|
|
> `sxtwl` 은 Python에서 만세력(60갑자·24절기·음력) 계산을 제공. TS의 `solarlunar` 대체.
|
|
|
|
- [ ] **Step 3: pytest.ini + .dockerignore + 빈 __init__.py 5개**
|
|
|
|
`saju-lab/pytest.ini`:
|
|
```ini
|
|
[pytest]
|
|
asyncio_mode = auto
|
|
pythonpath = .
|
|
```
|
|
|
|
`saju-lab/.dockerignore`:
|
|
```
|
|
__pycache__
|
|
*.pyc
|
|
.pytest_cache
|
|
data/
|
|
tests/
|
|
```
|
|
|
|
빈 파일 5개:
|
|
- `saju-lab/app/__init__.py`
|
|
- `saju-lab/tests/__init__.py`
|
|
- `saju-lab/tests/fixtures/__init__.py`
|
|
- `saju-lab/app/calculator/__init__.py`
|
|
- `saju-lab/app/interpret/__init__.py`
|
|
- `saju-lab/app/routers/__init__.py`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/
|
|
git commit -m "feat(saju-lab): 스캐폴딩 — Dockerfile + requirements + 디렉토리 구조"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Reference output fixture 생성 (Node.js로 saju-web 실행 → JSON)
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/tests/fixtures/generate_reference.cjs` (Node.js 스크립트)
|
|
- Create: `saju-lab/tests/fixtures/reference_saju.json` (생성 결과)
|
|
|
|
이 task는 Python 포팅 검증의 안전망입니다. saju-web의 TypeScript 계산 엔진을 Node.js로 직접 실행해 입력→출력 쌍 30~50건을 JSON 파일로 저장.
|
|
|
|
- [ ] **Step 1: saju-web 디렉토리에서 build 산출물 확인**
|
|
|
|
Run: `ls C:/Users/jaeoh/Desktop/workspace/saju-web/lib/`
|
|
Expected: `saju-calculator.ts`, `ai-interpretation.ts`, `daeun-calculator.ts`, `solar-terms.ts` 등 존재
|
|
|
|
> TypeScript를 직접 실행하려면 `ts-node` 또는 빌드 산출물(`.next/server/...`) 사용. 가장 간단한 경로는 saju-web의 dev 서버를 띄우고 `/api/analyze`를 호출해 결과를 fetch 하는 것이지만, 그러면 OpenAI API 호출이 일어남. 대신 계산 부분만 추출하기 위해 임시 .cjs 스크립트로 lib만 호출.
|
|
|
|
- [ ] **Step 2: generate_reference.cjs 작성**
|
|
|
|
```javascript
|
|
// Run: node generate_reference.cjs > reference_saju.json
|
|
// (ts-node 또는 tsc 빌드 후 실행)
|
|
|
|
const ts = require('ts-node').register({
|
|
transpileOnly: true,
|
|
compilerOptions: { module: 'commonjs', target: 'es2020' },
|
|
});
|
|
|
|
const SAJU_WEB = '../../../../saju-web';
|
|
const { calculateSaju } = require(`${SAJU_WEB}/lib/saju-calculator`);
|
|
const { performFullAnalysis } = require(`${SAJU_WEB}/lib/ai-interpretation`);
|
|
const { calculateDaeun } = require(`${SAJU_WEB}/lib/daeun-calculator`);
|
|
|
|
const CASES = [
|
|
// 양력 / 시간 입력 / 남 + 여 / 윤년 / 절기 경계
|
|
{ year: 1990, month: 5, day: 15, hour: 14, gender: 'male' },
|
|
{ year: 1985, month: 1, day: 1, hour: 0, gender: 'female' },
|
|
{ year: 2000, month: 2, day: 29, hour: 12, gender: 'male' }, // 윤년
|
|
{ year: 1995, month: 2, day: 3, hour: 23, gender: 'female' }, // 입춘 직전
|
|
{ year: 1995, month: 2, day: 4, hour: 13, gender: 'male' }, // 입춘 당일 (월주 전환)
|
|
{ year: 1995, month: 2, day: 5, hour: 5, gender: 'female' }, // 입춘 익일
|
|
{ year: 1980, month: 6, day: 6, hour: 6, gender: 'male' },
|
|
{ year: 1975, month: 11, day: 11, hour: 11, gender: 'female' },
|
|
{ year: 2010, month: 12, day: 31, hour: 23, gender: 'male' },
|
|
{ year: 1960, month: 4, day: 8, hour: 16, gender: 'female' },
|
|
// ... 총 30개 케이스 (다양한 월/시 커버)
|
|
{ year: 1972, month: 7, day: 24, hour: 9, gender: 'male' },
|
|
{ year: 1968, month: 10, day: 15, hour: 21, gender: 'female' },
|
|
{ year: 1955, month: 3, day: 3, hour: 7, gender: 'male' },
|
|
{ year: 1992, month: 8, day: 8, hour: 18, gender: 'female' },
|
|
{ year: 1988, month: 9, day: 9, hour: 4, gender: 'male' },
|
|
{ year: 1999, month: 12, day: 22, hour: 22, gender: 'female' }, // 동지
|
|
{ year: 2005, month: 6, day: 22, hour: 14, gender: 'male' }, // 하지
|
|
{ year: 2015, month: 3, day: 21, hour: 12, gender: 'female' }, // 춘분
|
|
{ year: 2020, month: 9, day: 23, hour: 12, gender: 'male' }, // 추분
|
|
{ year: 1945, month: 8, day: 15, hour: 12, gender: 'male' },
|
|
{ year: 1950, month: 6, day: 25, hour: 4, gender: 'male' },
|
|
{ year: 1977, month: 7, day: 7, hour: 7, gender: 'female' },
|
|
{ year: 1983, month: 11, day: 23, hour: 13, gender: 'male' },
|
|
{ year: 1991, month: 4, day: 14, hour: 19, gender: 'female' },
|
|
{ year: 1996, month: 5, day: 5, hour: 5, gender: 'male' },
|
|
{ year: 2003, month: 10, day: 10, hour: 10, gender: 'female' },
|
|
{ year: 2008, month: 8, day: 8, hour: 8, gender: 'male' },
|
|
{ year: 2012, month: 12, day: 12, hour: 12, gender: 'female' },
|
|
{ year: 1965, month: 1, day: 20, hour: 23, gender: 'male' },
|
|
{ year: 1973, month: 7, day: 4, hour: 17, gender: 'female' },
|
|
];
|
|
|
|
const CURRENT_YEAR = 2026;
|
|
|
|
const out = CASES.map(input => {
|
|
const saju = calculateSaju(input.year, input.month, input.day, input.hour, input.gender);
|
|
const analysis = performFullAnalysis(saju, CURRENT_YEAR);
|
|
const daeun = calculateDaeun(
|
|
input.year, input.month, input.day,
|
|
input.gender, saju.month.stem, saju.month.branch,
|
|
);
|
|
return { input, expected: { saju, analysis, daeun } };
|
|
});
|
|
|
|
console.log(JSON.stringify(out, null, 2));
|
|
```
|
|
|
|
- [ ] **Step 3: 생성 + 저장**
|
|
|
|
```bash
|
|
cd saju-lab/tests/fixtures
|
|
# saju-web에 ts-node 설치되어 있는지 확인:
|
|
# ls ../../../../saju-web/node_modules/ts-node || (cd ../../../../saju-web && npm install --save-dev ts-node)
|
|
node generate_reference.cjs > reference_saju.json
|
|
```
|
|
|
|
검증:
|
|
```bash
|
|
python -c "import json; data = json.load(open('reference_saju.json')); print(f'{len(data)} cases')"
|
|
```
|
|
Expected: `30 cases`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/tests/fixtures/generate_reference.cjs saju-lab/tests/fixtures/reference_saju.json
|
|
git commit -m "feat(saju-lab): reference fixture 30 케이스 (Node로 TS 엔진 결과 추출)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: calculator/constants.py (천간/지지/오행/십성/지장간 등 상수)
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/app/calculator/constants.py`
|
|
- Create: `saju-lab/tests/test_constants.py`
|
|
|
|
- [ ] **Step 1: 실패 테스트 (constants 존재 검증)**
|
|
|
|
```python
|
|
from app.calculator import constants
|
|
|
|
|
|
def test_heavenly_stems_10():
|
|
assert len(constants.HEAVENLY_STEMS) == 10
|
|
assert constants.HEAVENLY_STEMS[0] == "甲"
|
|
|
|
|
|
def test_earthly_branches_12():
|
|
assert len(constants.EARTHLY_BRANCHES) == 12
|
|
assert constants.EARTHLY_BRANCHES[0] == "子"
|
|
|
|
|
|
def test_five_elements_mapping():
|
|
assert constants.FIVE_ELEMENTS["甲"] == "木"
|
|
assert constants.FIVE_ELEMENTS["丁"] == "火"
|
|
assert constants.FIVE_ELEMENTS["亥"] == "水"
|
|
|
|
|
|
def test_hidden_stems():
|
|
assert constants.HIDDEN_STEMS["子"] == ["癸"]
|
|
assert constants.HIDDEN_STEMS["丑"] == ["己", "癸", "辛"]
|
|
|
|
|
|
def test_yang_yin_stems():
|
|
# 甲丙戊庚壬 = 양, 乙丁己辛癸 = 음
|
|
assert constants.IS_YANG_STEM["甲"] is True
|
|
assert constants.IS_YANG_STEM["乙"] is False
|
|
```
|
|
|
|
- [ ] **Step 2: Run test, verify it fails**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_constants.py -v`
|
|
Expected: FAIL (`ModuleNotFoundError`)
|
|
|
|
- [ ] **Step 3: app/calculator/constants.py 작성**
|
|
|
|
saju-web의 `lib/saju-calculator.ts` 첫 부분의 상수들을 Python으로 옮김. 모든 상수가 정확히 같아야 reference 비교가 통과.
|
|
|
|
```python
|
|
"""사주 계산 상수 — saju-web/lib/saju-calculator.ts 의 상수와 1:1 매핑."""
|
|
|
|
HEAVENLY_STEMS = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"]
|
|
HEAVENLY_STEMS_KR = ["갑", "을", "병", "정", "무", "기", "경", "신", "임", "계"]
|
|
|
|
EARTHLY_BRANCHES = ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"]
|
|
EARTHLY_BRANCHES_KR = ["자", "축", "인", "묘", "진", "사", "오", "미", "신", "유", "술", "해"]
|
|
|
|
FIVE_ELEMENTS = {
|
|
"甲": "木", "乙": "木",
|
|
"丙": "火", "丁": "火",
|
|
"戊": "土", "己": "土",
|
|
"庚": "金", "辛": "金",
|
|
"壬": "水", "癸": "水",
|
|
"寅": "木", "卯": "木",
|
|
"巳": "火", "午": "火",
|
|
"辰": "土", "戌": "土", "丑": "土", "未": "土",
|
|
"申": "金", "酉": "金",
|
|
"亥": "水", "子": "水",
|
|
}
|
|
|
|
IS_YANG_STEM = {
|
|
"甲": True, "乙": False,
|
|
"丙": True, "丁": False,
|
|
"戊": True, "己": False,
|
|
"庚": True, "辛": False,
|
|
"壬": True, "癸": False,
|
|
}
|
|
|
|
IS_YANG_BRANCH = {
|
|
"子": True, "丑": False,
|
|
"寅": True, "卯": False,
|
|
"辰": True, "巳": False,
|
|
"午": True, "未": False,
|
|
"申": True, "酉": False,
|
|
"戌": True, "亥": False,
|
|
}
|
|
|
|
# 지장간: { 지지: [본기, 중기, 여기] (없으면 생략) }
|
|
HIDDEN_STEMS = {
|
|
"子": ["癸"],
|
|
"丑": ["己", "癸", "辛"],
|
|
"寅": ["甲", "丙", "戊"],
|
|
"卯": ["乙"],
|
|
"辰": ["戊", "乙", "癸"],
|
|
"巳": ["丙", "庚", "戊"],
|
|
"午": ["丁", "己"],
|
|
"未": ["己", "丁", "乙"],
|
|
"申": ["庚", "壬", "戊"],
|
|
"酉": ["辛"],
|
|
"戌": ["戊", "辛", "丁"],
|
|
"亥": ["壬", "甲"],
|
|
}
|
|
|
|
# 본기 가중치 + 중기 + 여기
|
|
HIDDEN_STEM_WEIGHTS = [1.0, 0.5, 0.3]
|
|
|
|
# 상생 (목→화→토→금→수→목)
|
|
SHENG_CYCLE = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"}
|
|
|
|
# 상극 (목→토→수→화→금→목)
|
|
KE_CYCLE = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"}
|
|
```
|
|
|
|
상수 추가 (지지 합/충/형/파/해 등은 별도 task에서 shinsal에 둠).
|
|
|
|
- [ ] **Step 4: Run tests, verify pass**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_constants.py -v`
|
|
Expected: 5 passed
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/app/calculator/constants.py saju-lab/tests/test_constants.py
|
|
git commit -m "feat(saju-lab): calculator/constants.py — 천간/지지/오행/지장간 상수"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: calculator/solar_terms.py (24절기 + sxtwl 통합)
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/app/calculator/solar_terms.py`
|
|
- Create: `saju-lab/tests/test_solar_terms.py`
|
|
|
|
- [ ] **Step 1: 실패 테스트 (reference 비교 + 절기 경계 검증)**
|
|
|
|
```python
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.calculator import solar_terms as st
|
|
|
|
|
|
REF = json.loads((Path(__file__).parent / "fixtures" / "reference_saju.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
def test_get_current_solar_term_ipchun_boundary():
|
|
"""1995-02-04 입춘 → 立春 (index=2)"""
|
|
idx = st.get_current_solar_term(1995, 2, 4)
|
|
assert idx == 2 # 立春
|
|
|
|
|
|
def test_get_current_solar_term_before_ipchun():
|
|
"""1995-02-03 입춘 직전 → 大寒 (index=1)"""
|
|
idx = st.get_current_solar_term(1995, 2, 3)
|
|
assert idx == 1
|
|
|
|
|
|
@pytest.mark.parametrize("case", REF, ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}")
|
|
def test_month_branch_matches_reference(case):
|
|
"""레퍼런스 fixture의 month branch가 sxtwl 기반 절기 계산과 일치."""
|
|
inp = case["input"]
|
|
expected_branch = case["expected"]["saju"]["month"]["branch"]
|
|
actual_branch_index = st.get_solar_term_month_branch(inp["year"], inp["month"], inp["day"])
|
|
actual_branch = ["子","丑","寅","卯","辰","巳","午","未","申","酉","戌","亥"][actual_branch_index]
|
|
assert actual_branch == expected_branch
|
|
```
|
|
|
|
- [ ] **Step 2: Run, expect fail**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_solar_terms.py -v`
|
|
Expected: FAIL (module not found)
|
|
|
|
- [ ] **Step 3: app/calculator/solar_terms.py 작성**
|
|
|
|
```python
|
|
"""24절기 + sxtwl 통합 — saju-web/lib/solar-terms.ts 동등."""
|
|
from typing import List
|
|
|
|
import sxtwl
|
|
|
|
|
|
# 24절기 순서 (0=소한 ~ 23=동지)
|
|
SOLAR_TERMS = [
|
|
"小寒", "大寒",
|
|
"立春", "雨水",
|
|
"驚蟄", "春分",
|
|
"清明", "穀雨",
|
|
"立夏", "小滿",
|
|
"芒種", "夏至",
|
|
"小暑", "大暑",
|
|
"立秋", "處暑",
|
|
"白露", "秋分",
|
|
"寒露", "霜降",
|
|
"立冬", "小雪",
|
|
"大雪", "冬至",
|
|
]
|
|
|
|
# 입춘 = 寅월(인월=인덱스 2 = 寅)부터 시작
|
|
# 즉 立春(idx=2)부터 다음 立春(다음해 idx=2)까지가 한 해의 12절기
|
|
# 月支 매핑: 寅=index 2 (in EARTHLY_BRANCHES list)
|
|
# 0=立春 → 寅(2), 1=驚蟄 → 卯(3), 2=清明 → 辰(4), 3=立夏 → 巳(5), 4=芒種 → 午(6),
|
|
# 5=小暑 → 未(7), 6=立秋 → 申(8), 7=白露 → 酉(9), 8=寒露 → 戌(10), 9=立冬 → 亥(11),
|
|
# 10=大雪 → 子(0), 11=小寒 → 丑(1)
|
|
# 절기 인덱스(24개) 중 짝수 = 절(節) — 월주 전환점
|
|
JIE_TO_BRANCH_INDEX = {
|
|
"立春": 2, "驚蟄": 3, "清明": 4, "立夏": 5,
|
|
"芒種": 6, "小暑": 7, "立秋": 8, "白露": 9,
|
|
"寒露": 10, "立冬": 11, "大雪": 0, "小寒": 1,
|
|
}
|
|
|
|
|
|
def get_solar_term_date(year: int, term_index: int) -> tuple[int, int, int]:
|
|
"""주어진 연도/절기 인덱스(0~23)의 정확한 날짜 반환."""
|
|
# sxtwl의 getJieQiDate: (year, qi_index) → datetime
|
|
# qi_index 0=春分? — sxtwl 문서 확인 필요
|
|
# 실제 호출:
|
|
s = sxtwl.fromSolar(year, 1, 1)
|
|
# sxtwl의 절기 인덱스: 0=春分(춘분) 1=清明 2=穀雨 ... 와 다를 수 있음
|
|
# 안전한 방법: 모든 절기 날짜를 미리 계산
|
|
raise NotImplementedError("sxtwl API 호출 — 실제 버전별 확인 후 작성")
|
|
|
|
|
|
def _all_solar_term_dates_in_year(year: int) -> list[tuple[str, int, int, int]]:
|
|
"""해당 연도의 24절기 (이름, year, month, day) 리스트 반환."""
|
|
result = []
|
|
for term_name in SOLAR_TERMS:
|
|
# sxtwl: getJieQiDay(year, jie_qi_index)
|
|
# SOLAR_TERMS 순서가 sxtwl 인덱스와 다를 수 있음 — 매핑 필요
|
|
# sxtwl 1.x 기준: getJieQiDay 는 lunar.JieQiDay 객체 반환 가능
|
|
# 실제 sxtwl 2.x: sxtwl.JD2DD(JieQiJD(year, term_index)) 같은 패턴
|
|
pass
|
|
return result
|
|
|
|
|
|
def get_current_solar_term(year: int, month: int, day: int) -> int:
|
|
"""현재 날짜에 적용되는 절기 인덱스(0~23) 반환.
|
|
|
|
해당 날짜 이전 가장 가까운 절기의 인덱스를 반환.
|
|
"""
|
|
# 1년 ±1년치 절기 모두 모아서 정렬한 뒤, 현재 날짜 이전 마지막 절기 찾기
|
|
# sxtwl 정확한 API는 구현 시 검증
|
|
from datetime import date as _date
|
|
today = _date(year, month, day)
|
|
|
|
# ±1년치 절기 모음
|
|
all_terms = [] # [(date, term_index)]
|
|
for y in (year - 1, year, year + 1):
|
|
for term_idx in range(24):
|
|
d = _get_term_date_sxtwl(y, term_idx)
|
|
if d is not None:
|
|
all_terms.append((d, term_idx))
|
|
|
|
all_terms.sort()
|
|
# 현재 날짜 이하 마지막 절기
|
|
last_idx = 0
|
|
for d, idx in all_terms:
|
|
if d <= today:
|
|
last_idx = idx
|
|
else:
|
|
break
|
|
return last_idx
|
|
|
|
|
|
def _get_term_date_sxtwl(year: int, term_index: int):
|
|
"""sxtwl로 (year, term_index) 절기의 (date) 반환."""
|
|
from datetime import date as _date
|
|
# sxtwl 2.x 의 정확한 API:
|
|
# sxtwl.getJieQiDay(year, term_index) — 객체 반환
|
|
# 또는 sxtwl.Lunar 객체에서 추출
|
|
# 임시 구현 — 실제는 sxtwl 버전 확인 후 호출
|
|
try:
|
|
# sxtwl 2.x
|
|
jd = sxtwl.JieQi2JD(year, term_index)
|
|
d = sxtwl.JD2DD(jd)
|
|
return _date(d.Y, d.M, int(d.D))
|
|
except AttributeError:
|
|
# sxtwl 1.x fallback: 각 날짜 순회하며 절기 검사
|
|
return None
|
|
|
|
|
|
def get_solar_term_month_branch(year: int, month: int, day: int) -> int:
|
|
"""현재 날짜의 月支 인덱스(0~11) 반환. 입춘 이후 寅월 시작."""
|
|
# 가장 최근 '절' (12개 중 하나: 立春, 驚蟄, 清明, ...) 찾기
|
|
from datetime import date as _date
|
|
target = _date(year, month, day)
|
|
|
|
# ±1년치 12절 (절기 인덱스 중 짝수 위치) 수집
|
|
jie_dates = [] # [(date, branch_index)]
|
|
for y in (year - 1, year, year + 1):
|
|
for jie_name, branch_idx in JIE_TO_BRANCH_INDEX.items():
|
|
term_idx = SOLAR_TERMS.index(jie_name)
|
|
d = _get_term_date_sxtwl(y, term_idx)
|
|
if d is not None:
|
|
jie_dates.append((d, branch_idx))
|
|
|
|
jie_dates.sort()
|
|
last_branch = 1 # 立春 이전 = 丑월
|
|
for d, branch_idx in jie_dates:
|
|
if d <= target:
|
|
last_branch = branch_idx
|
|
else:
|
|
break
|
|
return last_branch
|
|
|
|
|
|
def get_days_to_next_solar_term(year: int, month: int, day: int) -> int:
|
|
"""다음 절기까지 일수 — 대운 계산에 사용."""
|
|
from datetime import date as _date
|
|
today = _date(year, month, day)
|
|
|
|
# 이후 절기 수집
|
|
for y in (year, year + 1):
|
|
for term_idx in range(24):
|
|
d = _get_term_date_sxtwl(y, term_idx)
|
|
if d is not None and d > today:
|
|
return (d - today).days
|
|
return 30 # 폴백
|
|
```
|
|
|
|
> **주의**: 위 코드의 sxtwl API 호출(`sxtwl.JieQi2JD`, `sxtwl.JD2DD` 등)은 sxtwl 2.x 가정. 1.x 사용 시 API가 다름. 구현자는 `pip install sxtwl && python -c "import sxtwl; help(sxtwl)"`로 실제 API 확인 후 위 코드를 조정. 핵심 계약 — `get_current_solar_term(y,m,d) → int [0,24)`, `get_solar_term_month_branch(y,m,d) → int [0,12)` — 만 지키면 됨.
|
|
|
|
- [ ] **Step 4: Run reference tests, verify pass**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_solar_terms.py -v`
|
|
Expected: 32 passed (입춘 경계 2건 + reference fixture 30건)
|
|
|
|
만약 fixture와 mismatch가 발생하면:
|
|
- sxtwl 절기 날짜 vs solarlunar 절기 날짜 차이가 1일 정도 있을 수 있음. 그 경우 fixture 자체를 sxtwl 기준으로 재생성하거나 (saju-web도 같이 sxtwl 라이브러리 사용한다면 일관됨), tolerance를 두지 말고 정확히 매칭.
|
|
- 해결 안 되는 케이스가 1~2건 있으면 해당 입력만 skip하고 별도 issue로 기록.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/app/calculator/solar_terms.py saju-lab/tests/test_solar_terms.py
|
|
git commit -m "feat(saju-lab): solar_terms.py — sxtwl 기반 24절기 + 月支 매핑"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 17: calculator/lunar.py (음력↔양력)
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/app/calculator/lunar.py`
|
|
- Create: `saju-lab/tests/test_lunar.py`
|
|
|
|
- [ ] **Step 1: 실패 테스트**
|
|
|
|
```python
|
|
from app.calculator import lunar
|
|
|
|
|
|
def test_solar_to_lunar_known_date():
|
|
# 2024년 추석 (음력 8월 15일) = 양력 2024-09-17
|
|
result = lunar.solar_to_lunar(2024, 9, 17)
|
|
assert result["year"] == 2024
|
|
assert result["month"] == 8
|
|
assert result["day"] == 15
|
|
assert result["is_leap"] is False
|
|
|
|
|
|
def test_lunar_to_solar_known_date():
|
|
# 음력 2024-08-15 → 양력 2024-09-17
|
|
result = lunar.lunar_to_solar(2024, 8, 15, is_leap=False)
|
|
assert result == (2024, 9, 17)
|
|
```
|
|
|
|
- [ ] **Step 2: Run, expect fail**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_lunar.py -v`
|
|
Expected: FAIL
|
|
|
|
- [ ] **Step 3: app/calculator/lunar.py 작성**
|
|
|
|
```python
|
|
"""음력↔양력 변환 — sxtwl 사용."""
|
|
import sxtwl
|
|
|
|
|
|
def solar_to_lunar(year: int, month: int, day: int) -> dict:
|
|
"""양력 → 음력 변환."""
|
|
day_obj = sxtwl.fromSolar(year, month, day)
|
|
return {
|
|
"year": day_obj.getLunarYear(),
|
|
"month": day_obj.getLunarMonth(),
|
|
"day": day_obj.getLunarDay(),
|
|
"is_leap": bool(day_obj.isLunarLeap()),
|
|
}
|
|
|
|
|
|
def lunar_to_solar(year: int, month: int, day: int, is_leap: bool = False) -> tuple[int, int, int]:
|
|
"""음력 → 양력 변환 — (year, month, day) tuple 반환."""
|
|
day_obj = sxtwl.fromLunar(year, month, day, is_leap)
|
|
return (
|
|
day_obj.getSolarYear(),
|
|
day_obj.getSolarMonth(),
|
|
day_obj.getSolarDay(),
|
|
)
|
|
```
|
|
|
|
> sxtwl API 정확한 메서드명은 버전 확인 필요. 1.x는 `fromSolar`, 2.x는 다를 수 있음. `python -c "import sxtwl; d=sxtwl.fromSolar(2024,1,1); print(dir(d))"`로 확인.
|
|
|
|
- [ ] **Step 4: Run, expect pass**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_lunar.py -v`
|
|
Expected: 2 passed
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/app/calculator/lunar.py saju-lab/tests/test_lunar.py
|
|
git commit -m "feat(saju-lab): lunar.py — 음력↔양력 변환 (sxtwl)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 18~22 (계산 엔진 나머지)
|
|
|
|
> **주의**: Task 18~22는 spec Section 6-1의 포팅 순서를 정확히 따라야 하며, 각 task는 같은 패턴(reference fixture 비교 테스트 + Python 구현)으로 진행. 코드 양이 매우 많으므로 별도 sub-skill인 subagent-driven-development로 위임하는 것이 권장. 각 task의 핵심 계약과 테스트 패턴만 plan에 명시.
|
|
|
|
### Task 18: calculator/core.py (60갑자 + 십성 + 십이운성 + calculate_saju)
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/app/calculator/core.py`
|
|
- Create: `saju-lab/tests/test_core.py`
|
|
|
|
**핵심 계약:**
|
|
- `get_year_ganzi(year) → {stem, branch, stem_kr, branch_kr, element, ...}`
|
|
- `get_month_ganzi(year, month, day) → ...` (절기 기반, solar_terms.get_solar_term_month_branch 사용)
|
|
- `get_day_ganzi(year, month, day) → ...` (만세력 — sxtwl.fromSolar로 일주 추출)
|
|
- `get_hour_ganzi(day_stem, hour) → ...`
|
|
- `get_ten_god(day_stem, target_stem, is_target_yang) → "비견"|"겁재"|"식신"|"상관"|"편재"|"정재"|"편관"|"정관"|"편인"|"정인"`
|
|
- `get_twelve_fortune(day_stem, branch) → "장생"|"목욕"|"관대"|"임관"|"제왕"|"쇠"|"병"|"사"|"묘"|"절"|"태"|"양"`
|
|
- `calculate_saju(year, month, day, hour, gender) → SajuData dict`
|
|
|
|
**테스트 패턴 (test_core.py):**
|
|
|
|
```python
|
|
import json
|
|
from pathlib import Path
|
|
import pytest
|
|
|
|
from app.calculator.core import calculate_saju
|
|
|
|
|
|
REF = json.loads((Path(__file__).parent / "fixtures" / "reference_saju.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
@pytest.mark.parametrize("case", REF, ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}")
|
|
def test_calculate_saju_matches_reference(case):
|
|
inp = case["input"]
|
|
expected = case["expected"]["saju"]
|
|
actual = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
|
|
|
# 4기둥 모두 비교
|
|
for pillar in ["year", "month", "day", "hour"]:
|
|
if expected.get(pillar) is None:
|
|
assert actual.get(pillar) is None
|
|
continue
|
|
for field in ["stem", "branch", "stem_kr", "branch_kr", "element", "ten_god", "fortune"]:
|
|
assert actual[pillar][field] == expected[pillar][field], \
|
|
f"{pillar}.{field} mismatch: {actual[pillar][field]} vs {expected[pillar][field]}"
|
|
|
|
assert actual["day_stem"] == expected["dayStem"] or actual["day_stem"] == expected["day_stem"]
|
|
assert actual["gender"] == expected["gender"]
|
|
```
|
|
|
|
> **camelCase vs snake_case**: TypeScript는 camelCase (`dayStem`, `meaningUpright`), Python은 snake_case 표준. Reference JSON은 TS camelCase로 직렬화되어 있으므로 비교 시 키 변환 필요. core.py의 `calculate_saju`는 snake_case 출력 + 비교 fixture는 양쪽 키 모두 시도 (위처럼 `or`).
|
|
|
|
**Step 순서:**
|
|
- [ ] Step 1: 실패 테스트 작성
|
|
- [ ] Step 2: Run pytest, expect 30 fails
|
|
- [ ] Step 3: get_year_ganzi 구현 + 통과
|
|
- [ ] Step 4: get_month_ganzi (solar_terms 사용)
|
|
- [ ] Step 5: get_day_ganzi (sxtwl 일주)
|
|
- [ ] Step 6: get_hour_ganzi
|
|
- [ ] Step 7: get_ten_god + get_twelve_fortune (constants 기반 매핑 테이블)
|
|
- [ ] Step 8: calculate_saju 통합 함수
|
|
- [ ] Step 9: 30개 reference 모두 통과 확인
|
|
- [ ] Step 10: Commit
|
|
|
|
```bash
|
|
git commit -m "feat(saju-lab): core.py — 60갑자 + 십성 + 십이운성 + calculate_saju (30/30 reference)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 19: calculator/shinsal.py (지장간 + 신살 + 공망 + 지지 상호작용)
|
|
|
|
**핵심 계약:**
|
|
- `get_hidden_stems(branch) → list[str]`
|
|
- `get_all_hidden_stems(saju) → list[dict]` (각 지지의 본기/중기/여기)
|
|
- `analyze_branch_interactions(saju) → list[BranchInteraction]` — 6합 / 6충 / 3형 / 6파 / 6해
|
|
- `calculate_shinsal(saju) → list[Shinsal]` — 역마/도화/화개/천을귀인/문창귀인 등
|
|
- `calculate_gongmang(day_stem, day_branch) → {branches, branches_kr, description}` — 공망 2지지
|
|
|
|
reference test 패턴 동일.
|
|
|
|
- [ ] Step 1~10: 위 패턴 따름 (테스트 → 실패 확인 → 구현 → 통과 → commit)
|
|
|
|
```bash
|
|
git commit -m "feat(saju-lab): shinsal.py — 지장간/신살/공망/지지 상호작용"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 20: calculator/analysis.py (오행 점수 + 신강신약 + 용신 + 세운)
|
|
|
|
**핵심 계약:**
|
|
- `calculate_detailed_element_balance(saju) → {木, 火, 土, 金, 水}` — 가중치 적용
|
|
- `calculate_element_score(saju) → {木: %, 火: %, ...}`
|
|
- `analyze_day_master_strength(saju) → {result: "신강"|"신약"|"중화", score, reasons}`
|
|
- `estimate_yongshin(saju, strength) → {yongShin, heeShin, giShin, explanation}`
|
|
- `calculate_seun(year, saju) → {stem, branch, ten_god, interactions, ...}`
|
|
- `perform_full_analysis(saju, current_year) → SajuAnalysis dict`
|
|
|
|
- [ ] Step 1~10: 동일 패턴
|
|
|
|
```bash
|
|
git commit -m "feat(saju-lab): analysis.py — 오행/신강신약/용신/세운 (30/30 reference)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 21: calculator/daeun.py (대운 8개)
|
|
|
|
**핵심 계약:**
|
|
- `calculate_daeun(year, month, day, gender, month_stem, month_branch) → list[DaeunPillar]` — 8개
|
|
- `get_current_daeun(daeun_list, current_year) → DaeunPillar`
|
|
- `get_daeun_description(daeun, day_stem) → str`
|
|
|
|
대운 방향(순행/역행)은 양남음녀=순행, 음남양녀=역행. `get_days_to_next_solar_term`으로 시작 나이 계산.
|
|
|
|
- [ ] Step 1~10: 동일 패턴 + reference 30 케이스의 daeun 비교
|
|
|
|
```bash
|
|
git commit -m "feat(saju-lab): daeun.py — 대운 8개 계산 (30/30 reference)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 22: calculator/compatibility.py (궁합 점수)
|
|
|
|
**핵심 계약:**
|
|
- `calculate_compatibility(saju_a, saju_b) → {score: 0-100, breakdown: dict}`
|
|
- breakdown 항목: 일간 오행 상생/상극, 지지 합/충 매칭, 십성 배합, 신살 영향
|
|
|
|
saju-web의 `app/compatibility/` 디렉토리 코드를 reference로 사용.
|
|
|
|
**테스트:**
|
|
- 알려진 좋은 궁합 (예: 갑목일주 + 정화일주 = 상생) → 점수 70+
|
|
- 알려진 충돌 (예: 자오충, 인신충 강하게 결합) → 점수 30-
|
|
- breakdown JSON 구조 검증
|
|
|
|
- [ ] Step 1~10: 동일 패턴
|
|
|
|
```bash
|
|
git commit -m "feat(saju-lab): compatibility.py — 두 사주 궁합 점수 + breakdown"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 23: saju-lab config.py + models.py + db.py
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/app/config.py`
|
|
- Create: `saju-lab/app/models.py`
|
|
- Create: `saju-lab/app/db.py`
|
|
- Create: `saju-lab/tests/test_db.py`
|
|
|
|
- [ ] **Step 1: app/config.py**
|
|
|
|
```python
|
|
"""saju-lab 환경변수."""
|
|
import os
|
|
|
|
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
|
SAJU_MODEL = os.getenv("SAJU_MODEL", "claude-sonnet-4-6")
|
|
SAJU_COST_INPUT_PER_M = float(os.getenv("SAJU_COST_INPUT_PER_M", "3.0"))
|
|
SAJU_COST_OUTPUT_PER_M = float(os.getenv("SAJU_COST_OUTPUT_PER_M", "15.0"))
|
|
SAJU_TIMEOUT_SEC = int(os.getenv("SAJU_TIMEOUT_SEC", "240")) # 12항목이라 더 길게
|
|
|
|
SAJU_DATA_PATH = os.getenv("SAJU_DATA_PATH", "/app/data")
|
|
DB_PATH = os.path.join(SAJU_DATA_PATH, "saju.db")
|
|
|
|
CORS_ALLOW_ORIGINS = os.getenv(
|
|
"CORS_ALLOW_ORIGINS",
|
|
"http://localhost:3007,http://localhost:8080",
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2: app/models.py**
|
|
|
|
```python
|
|
"""saju-lab Pydantic 모델."""
|
|
from typing import List, Literal, Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
# --- Input ---
|
|
|
|
class SajuInterpretRequest(BaseModel):
|
|
year: int = Field(..., ge=1900, le=2100)
|
|
month: int = Field(..., ge=1, le=12)
|
|
day: int = Field(..., ge=1, le=31)
|
|
hour: Optional[int] = Field(None, ge=0, le=23)
|
|
gender: Literal["male", "female"]
|
|
calendar_type: Literal["solar", "lunar"] = "solar"
|
|
is_leap_month: bool = False
|
|
|
|
|
|
class CompatInterpretRequest(BaseModel):
|
|
person_a: SajuInterpretRequest
|
|
person_b: SajuInterpretRequest
|
|
|
|
|
|
# --- Response ---
|
|
|
|
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
|
|
|
|
|
|
class CompatInterpretResponse(BaseModel):
|
|
saju_a: dict
|
|
saju_b: dict
|
|
score: int
|
|
breakdown: 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
|
|
|
|
|
|
# --- CRUD ---
|
|
|
|
class SajuPatchRequest(BaseModel):
|
|
favorite: Optional[bool] = None
|
|
memo: Optional[str] = None
|
|
|
|
|
|
class CompatPatchRequest(BaseModel):
|
|
favorite: Optional[bool] = None
|
|
memo: Optional[str] = None
|
|
```
|
|
|
|
- [ ] **Step 3: app/db.py**
|
|
|
|
spec Section 6-3의 스키마를 따름. saju_records / compat_records 두 테이블 + 각 CRUD 5개씩.
|
|
|
|
```python
|
|
"""saju.db SQLite — saju_records + compat_records CRUD."""
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from typing import Any, Dict, Optional
|
|
|
|
from .config import DB_PATH
|
|
|
|
|
|
def _conn() -> sqlite3.Connection:
|
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
|
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA busy_timeout=120000")
|
|
return conn
|
|
|
|
|
|
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)
|
|
""")
|
|
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'))
|
|
)
|
|
""")
|
|
|
|
|
|
# ---- saju_records CRUD ----
|
|
|
|
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)
|
|
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),
|
|
),
|
|
)
|
|
return int(cur.lastrowid)
|
|
|
|
|
|
def get_saju_record(record_id: int) -> Optional[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
r = conn.execute("SELECT * FROM saju_records WHERE id=?", (record_id,)).fetchone()
|
|
return _saju_row_to_dict(r) if r else None
|
|
|
|
|
|
def list_saju_records(page: int = 1, size: int = 20, favorite: Optional[bool] = None) -> Dict[str, Any]:
|
|
wheres, params = [], []
|
|
if favorite is not None:
|
|
wheres.append("favorite=?")
|
|
params.append(1 if favorite else 0)
|
|
where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
|
offset = (page - 1) * size
|
|
with _conn() as conn:
|
|
total = conn.execute(f"SELECT COUNT(*) c FROM saju_records {where_sql}", params).fetchone()["c"]
|
|
rows = conn.execute(
|
|
f"SELECT * FROM saju_records {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
|
params + [size, offset],
|
|
).fetchall()
|
|
return {
|
|
"items": [_saju_row_to_dict(r) for r in rows],
|
|
"page": page, "size": size, "total": int(total),
|
|
}
|
|
|
|
|
|
def update_saju_record(record_id: int, **kwargs) -> None:
|
|
sets, vals = [], []
|
|
if "favorite" in kwargs and kwargs["favorite"] is not None:
|
|
sets.append("favorite=?"); vals.append(1 if kwargs["favorite"] else 0)
|
|
if "memo" in kwargs and kwargs["memo"] is not None:
|
|
sets.append("memo=?"); vals.append(kwargs["memo"])
|
|
if not sets:
|
|
return
|
|
vals.append(record_id)
|
|
with _conn() as conn:
|
|
conn.execute(f"UPDATE saju_records SET {','.join(sets)} WHERE id=?", vals)
|
|
|
|
|
|
def delete_saju_record(record_id: int) -> None:
|
|
with _conn() as conn:
|
|
conn.execute("DELETE FROM saju_records WHERE id=?", (record_id,))
|
|
|
|
|
|
def _saju_row_to_dict(r) -> Dict[str, Any]:
|
|
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": json.loads(r["saju_data"]) if r["saju_data"] else None,
|
|
"analysis_data": json.loads(r["analysis_data"]) if r["analysis_data"] else None,
|
|
"daeun_data": json.loads(r["daeun_data"]) if r["daeun_data"] else None,
|
|
"interpretation_json": json.loads(r["interpretation_json"]) if r["interpretation_json"] else None,
|
|
"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"],
|
|
}
|
|
|
|
|
|
# ---- compat_records CRUD ----
|
|
|
|
def save_compat_record(data: Dict[str, Any]) -> int:
|
|
with _conn() as conn:
|
|
cur = conn.execute(
|
|
"""INSERT INTO compat_records
|
|
(person_a, person_b, saju_a, saju_b, score, breakdown,
|
|
interpretation_json, model, tokens_in, tokens_out,
|
|
cost_usd, latency_ms, reroll_count)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(
|
|
json.dumps(data["person_a"], ensure_ascii=False),
|
|
json.dumps(data["person_b"], ensure_ascii=False),
|
|
json.dumps(data["saju_a"], ensure_ascii=False),
|
|
json.dumps(data["saju_b"], ensure_ascii=False),
|
|
data["score"],
|
|
json.dumps(data["breakdown"], 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),
|
|
),
|
|
)
|
|
return int(cur.lastrowid)
|
|
|
|
|
|
def get_compat_record(record_id: int) -> Optional[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
r = conn.execute("SELECT * FROM compat_records WHERE id=?", (record_id,)).fetchone()
|
|
return _compat_row_to_dict(r) if r else None
|
|
|
|
|
|
def list_compat_records(page: int = 1, size: int = 20, favorite: Optional[bool] = None) -> Dict[str, Any]:
|
|
wheres, params = [], []
|
|
if favorite is not None:
|
|
wheres.append("favorite=?"); params.append(1 if favorite else 0)
|
|
where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
|
offset = (page - 1) * size
|
|
with _conn() as conn:
|
|
total = conn.execute(f"SELECT COUNT(*) c FROM compat_records {where_sql}", params).fetchone()["c"]
|
|
rows = conn.execute(
|
|
f"SELECT * FROM compat_records {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
|
params + [size, offset],
|
|
).fetchall()
|
|
return {
|
|
"items": [_compat_row_to_dict(r) for r in rows],
|
|
"page": page, "size": size, "total": int(total),
|
|
}
|
|
|
|
|
|
def update_compat_record(record_id: int, **kwargs) -> None:
|
|
sets, vals = [], []
|
|
if "favorite" in kwargs and kwargs["favorite"] is not None:
|
|
sets.append("favorite=?"); vals.append(1 if kwargs["favorite"] else 0)
|
|
if "memo" in kwargs and kwargs["memo"] is not None:
|
|
sets.append("memo=?"); vals.append(kwargs["memo"])
|
|
if not sets:
|
|
return
|
|
vals.append(record_id)
|
|
with _conn() as conn:
|
|
conn.execute(f"UPDATE compat_records SET {','.join(sets)} WHERE id=?", vals)
|
|
|
|
|
|
def delete_compat_record(record_id: int) -> None:
|
|
with _conn() as conn:
|
|
conn.execute("DELETE FROM compat_records WHERE id=?", (record_id,))
|
|
|
|
|
|
def _compat_row_to_dict(r) -> Dict[str, Any]:
|
|
return {
|
|
"id": r["id"],
|
|
"created_at": r["created_at"],
|
|
"person_a": json.loads(r["person_a"]),
|
|
"person_b": json.loads(r["person_b"]),
|
|
"saju_a": json.loads(r["saju_a"]),
|
|
"saju_b": json.loads(r["saju_b"]),
|
|
"score": r["score"],
|
|
"breakdown": json.loads(r["breakdown"]),
|
|
"interpretation_json": json.loads(r["interpretation_json"]) if r["interpretation_json"] else None,
|
|
"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"],
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: tests/test_db.py — 양쪽 CRUD 검증 (10 tests)**
|
|
|
|
tarot-lab/tests/test_db.py와 같은 패턴. saju_records 5건 + compat_records 5건.
|
|
|
|
- [ ] **Step 5: Run all**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_db.py -v`
|
|
Expected: 10 passed
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/app/config.py saju-lab/app/models.py saju-lab/app/db.py saju-lab/tests/test_db.py
|
|
git commit -m "feat(saju-lab): config + Pydantic 모델 + db.py CRUD (saju + compat)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 24: interpret/prompt.py + schema.py (사주 12항목)
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/app/interpret/prompt.py`
|
|
- Create: `saju-lab/app/interpret/schema.py`
|
|
- Create: `saju-lab/tests/test_schema.py`
|
|
|
|
- [ ] **Step 1: 실패 테스트**
|
|
|
|
```python
|
|
from app.interpret.schema import validate_saju_interpretation
|
|
|
|
|
|
def _valid_item(key="기질"):
|
|
return {
|
|
"key": key, "title": "...", "content": "...",
|
|
"evidence": {"saju_element": "...", "reasoning": "..."}
|
|
}
|
|
|
|
|
|
def _valid_payload():
|
|
return {
|
|
"items": [_valid_item(k) for k in [
|
|
"기질", "오행밸런스", "지지상호작용", "신살영향",
|
|
"재물운", "직업적성", "애정운", "건강운",
|
|
"현재대운", "올해세운", "인생황금기", "종합조언",
|
|
]],
|
|
"summary": "...",
|
|
"advice": "...",
|
|
"warning": None,
|
|
"confidence": "medium",
|
|
}
|
|
|
|
|
|
def test_valid():
|
|
ok, _ = validate_saju_interpretation(_valid_payload())
|
|
assert ok is True
|
|
|
|
|
|
def test_missing_items():
|
|
p = _valid_payload(); del p["items"]
|
|
ok, err = validate_saju_interpretation(p)
|
|
assert not ok and "items" in err
|
|
|
|
|
|
def test_items_count():
|
|
p = _valid_payload(); p["items"] = p["items"][:5]
|
|
ok, err = validate_saju_interpretation(p)
|
|
assert not ok and "12" in err
|
|
|
|
|
|
def test_evidence_missing():
|
|
p = _valid_payload(); del p["items"][0]["evidence"]
|
|
ok, err = validate_saju_interpretation(p)
|
|
assert not ok and "evidence" in err
|
|
|
|
|
|
def test_invalid_confidence():
|
|
p = _valid_payload(); p["confidence"] = "absolute"
|
|
ok, err = validate_saju_interpretation(p)
|
|
assert not ok and "confidence" in err
|
|
```
|
|
|
|
- [ ] **Step 2: app/interpret/schema.py 작성**
|
|
|
|
```python
|
|
"""사주 + 궁합 응답 JSON 검증."""
|
|
|
|
VALID_CONFIDENCE = {"high", "medium", "low"}
|
|
SAJU_ITEM_KEYS = {
|
|
"기질", "오행밸런스", "지지상호작용", "신살영향",
|
|
"재물운", "직업적성", "애정운", "건강운",
|
|
"현재대운", "올해세운", "인생황금기", "종합조언",
|
|
}
|
|
|
|
|
|
def validate_saju_interpretation(parsed: dict) -> tuple[bool, str]:
|
|
if not isinstance(parsed, dict):
|
|
return False, "응답이 dict가 아님"
|
|
for k in ("items", "summary", "advice", "confidence"):
|
|
if k not in parsed:
|
|
return False, f"필수 필드 누락: {k}"
|
|
if parsed.get("confidence") not in VALID_CONFIDENCE:
|
|
return False, f"confidence 값 비정상: {parsed.get('confidence')}"
|
|
|
|
items = parsed["items"]
|
|
if not isinstance(items, list):
|
|
return False, "items가 list 아님"
|
|
if len(items) != 12:
|
|
return False, f"items는 12개 필요 (현재 {len(items)})"
|
|
seen_keys = set()
|
|
for i, it in enumerate(items):
|
|
if not isinstance(it, dict):
|
|
return False, f"items[{i}] dict 아님"
|
|
for k in ("key", "title", "content", "evidence"):
|
|
if k not in it:
|
|
return False, f"items[{i}].{k} 누락"
|
|
if it["key"] not in SAJU_ITEM_KEYS:
|
|
return False, f"items[{i}].key 비정상: {it['key']}"
|
|
if it["key"] in seen_keys:
|
|
return False, f"items[{i}].key 중복: {it['key']}"
|
|
seen_keys.add(it["key"])
|
|
ev = it["evidence"]
|
|
if not isinstance(ev, dict) or "saju_element" not in ev or "reasoning" not in ev:
|
|
return False, f"items[{i}].evidence 형식 오류"
|
|
if not ev.get("saju_element", "").strip() or not ev.get("reasoning", "").strip():
|
|
return False, f"items[{i}].evidence 빈 문자열"
|
|
return True, ""
|
|
|
|
|
|
def validate_compat_interpretation(parsed: dict) -> tuple[bool, str]:
|
|
if not isinstance(parsed, dict):
|
|
return False, "응답이 dict가 아님"
|
|
for k in ("summary", "strengths", "challenges", "advice", "confidence"):
|
|
if k not in parsed:
|
|
return False, f"필수 필드 누락: {k}"
|
|
if parsed.get("confidence") not in VALID_CONFIDENCE:
|
|
return False, f"confidence 값 비정상: {parsed.get('confidence')}"
|
|
for k in ("strengths", "challenges"):
|
|
v = parsed[k]
|
|
if not isinstance(v, list) or not v:
|
|
return False, f"{k}는 비어있지 않은 list 필요"
|
|
for i, item in enumerate(v):
|
|
if not isinstance(item, dict) or "title" not in item or "explanation" not in item or "evidence" not in item:
|
|
return False, f"{k}[{i}] 형식 오류"
|
|
return True, ""
|
|
```
|
|
|
|
- [ ] **Step 3: app/interpret/prompt.py — 사주 12항목 SYSTEM_PROMPT**
|
|
|
|
```python
|
|
"""사주 12항목 해석 SYSTEM_PROMPT — Claude Sonnet evidence-based."""
|
|
|
|
SAJU_SYSTEM_PROMPT = """당신은 한국 전통 사주명리학에 정통한 명리학자입니다.
|
|
사용자의 생년월일시로 계산된 사주팔자(四柱八字)·오행 분석·대운·세운 결과를 받아,
|
|
근거 기반(evidence-based)으로 12개 항목 해석을 작성합니다.
|
|
|
|
# 해석 원칙
|
|
1. 데이터 우선: "사주 데이터" 블록의 천간/지지/오행/십성/십이운성/신살/지장간만을 1차 근거로 사용.
|
|
외부 일반론·미신적 해석은 사용 금지.
|
|
2. evidence 필수: 각 항목의 evidence.saju_element에 어떤 사주 요소(예: "갑목 일주", "월지 子水", "편관 격국")에서 결론을 도출했는지 인용.
|
|
evidence.reasoning에 해석 논리를 1~2문장으로 명시.
|
|
3. 자기 성찰 톤: 운명론 단정 금지. "…경향이 있어 보입니다", "…가능성이 있습니다" 표현.
|
|
4. 12항목 모두 필수 (누락 시 reroll):
|
|
- 기질: 일주(日柱) 중심 타고난 성격
|
|
- 오행밸런스: 5원소 강약 분석 + 개운법
|
|
- 지지상호작용: 합/충/형/파/해의 영향
|
|
- 신살영향: 역마/도화/화개/천을귀인 등
|
|
- 재물운: 정재/편재 + 식상 분석
|
|
- 직업적성: 일간 + 격국 + 십성 균형
|
|
- 애정운: 정관/편관/정재/편재 + 일지 분석
|
|
- 건강운: 약한 오행 + 충돌 지지의 신체 매핑
|
|
- 현재대운: 현재 대운의 오행 + 일간 관계
|
|
- 올해세운: 세운의 천간지지 + 충/합
|
|
- 인생황금기: 가장 좋은 대운 시기 추정
|
|
- 종합조언: 1~3 종합 + 실천 조언
|
|
|
|
# 응답 형식 (strict JSON only — 코드블록 없이 raw JSON)
|
|
{
|
|
"items": [
|
|
{
|
|
"key": "기질"|"오행밸런스"|... (12개 정확히),
|
|
"title": "사용자에게 보이는 항목 제목",
|
|
"content": "3~5문장 본문",
|
|
"evidence": {
|
|
"saju_element": "근거가 된 사주 요소 (예: '갑목 일주, 월지 寅木')",
|
|
"reasoning": "해석 논리 (1~2문장)"
|
|
}
|
|
}
|
|
// ... 12개
|
|
],
|
|
"summary": "사주 전체의 핵심 흐름 한 단락 (3~4문장)",
|
|
"advice": "실천 가능한 종합 조언 (2~3문장)",
|
|
"warning": "주의사항 (없으면 null)",
|
|
"confidence": "high"|"medium"|"low"
|
|
}
|
|
|
|
# confidence 판정 기준
|
|
- high: 일주·격국·십성 균형이 명확, 모든 항목 evidence 강함
|
|
- medium: 일부 항목은 데이터 약함
|
|
- low: 사주 데이터가 충돌 많아 명확한 흐름 추출 어려움
|
|
|
|
# 금지사항
|
|
- 사주 데이터에 없는 별점·서양 점성술 도입 금지
|
|
- JSON 외 텍스트 금지 (코드블록 금지)
|
|
- 12항목 누락 금지
|
|
"""
|
|
|
|
|
|
COMPAT_SYSTEM_PROMPT = """당신은 한국 사주명리학 기반 궁합 분석 전문가입니다.
|
|
두 사람의 사주팔자·오행 분석·궁합 점수(breakdown 포함)를 받아, 근거 기반 궁합 해석을 작성합니다.
|
|
|
|
# 응답 형식 (strict JSON only)
|
|
{
|
|
"summary": "두 사람 궁합의 핵심 흐름 (3~4문장)",
|
|
"strengths": [
|
|
{ "title": "...", "explanation": "...", "evidence": "오행 상생 또는 지지 합 등 근거" }
|
|
],
|
|
"challenges": [
|
|
{ "title": "...", "explanation": "...", "evidence": "오행 상극 또는 지지 충 등 근거" }
|
|
],
|
|
"advice": "관계 개선 조언 (2~3문장)",
|
|
"warning": "심각한 충돌 (없으면 null)",
|
|
"confidence": "high"|"medium"|"low"
|
|
}
|
|
|
|
# 원칙
|
|
- strengths/challenges 각각 최소 2개 이상
|
|
- evidence는 두 사주의 오행 매칭, 지지 합/충, 일간 관계 인용
|
|
- JSON 외 텍스트 금지
|
|
"""
|
|
|
|
|
|
def build_saju_user_message(saju: dict, analysis: dict, daeun: list[dict], current_year: int) -> str:
|
|
"""사주/분석/대운 데이터를 user 메시지로 직렬화."""
|
|
import json as _json
|
|
return f"""# 사주 데이터
|
|
{_json.dumps(saju, ensure_ascii=False, indent=2)}
|
|
|
|
# 종합 분석
|
|
{_json.dumps(analysis, ensure_ascii=False, indent=2)}
|
|
|
|
# 대운 (8개)
|
|
{_json.dumps(daeun, ensure_ascii=False, indent=2)}
|
|
|
|
# 현재 연도
|
|
{current_year}
|
|
|
|
# 작업
|
|
시스템 지침의 12항목 JSON으로 응답하세요.
|
|
- 각 항목의 evidence는 위 데이터에서 인용된 요소를 반드시 포함.
|
|
- confidence는 데이터 강도에 따라 정직하게 판정.
|
|
"""
|
|
|
|
|
|
def build_compat_user_message(
|
|
saju_a: dict, saju_b: dict,
|
|
analysis_a: dict, analysis_b: dict,
|
|
score: int, breakdown: dict,
|
|
) -> str:
|
|
import json as _json
|
|
return f"""# A의 사주
|
|
{_json.dumps(saju_a, ensure_ascii=False, indent=2)}
|
|
|
|
# A의 분석
|
|
{_json.dumps(analysis_a, ensure_ascii=False, indent=2)}
|
|
|
|
# B의 사주
|
|
{_json.dumps(saju_b, ensure_ascii=False, indent=2)}
|
|
|
|
# B의 분석
|
|
{_json.dumps(analysis_b, ensure_ascii=False, indent=2)}
|
|
|
|
# 궁합 점수 + breakdown
|
|
점수: {score}/100
|
|
{_json.dumps(breakdown, ensure_ascii=False, indent=2)}
|
|
|
|
# 작업
|
|
시스템 지침의 JSON으로 strengths/challenges 각각 최소 2개 이상, evidence 인용 포함.
|
|
"""
|
|
```
|
|
|
|
- [ ] **Step 4: Run schema tests**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_schema.py -v`
|
|
Expected: 5 passed
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/app/interpret/prompt.py saju-lab/app/interpret/schema.py saju-lab/tests/test_schema.py
|
|
git commit -m "feat(saju-lab): interpret/prompt.py + schema.py — 12항목 + 궁합 SYSTEM_PROMPT"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 25: interpret/pipeline.py (사주 + 궁합 Claude 호출)
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/app/interpret/pipeline.py`
|
|
- Create: `saju-lab/tests/test_pipeline.py`
|
|
|
|
- [ ] **Step 1~5: tarot-lab/app/pipeline.py 패턴 그대로 재활용**
|
|
|
|
`saju-lab/app/interpret/pipeline.py`:
|
|
- `interpret_saju(saju, analysis, daeun, current_year) → dict` (tokens, cost, latency 포함)
|
|
- `interpret_compat(saju_a, saju_b, analysis_a, analysis_b, score, breakdown) → dict`
|
|
- 두 함수 모두 같은 `_call_claude` helper 공유 (system/user_text/validate만 다름)
|
|
- `max_tokens=2400` (12항목이라 더 길게)
|
|
- reroll 1회
|
|
- prompt-caching `cache_control: ephemeral`
|
|
|
|
테스트(`tests/test_pipeline.py`): respx mock으로 success/codeblock/reroll/validate-fail/http-error/cost 6 case + compat 동일 6 case = 총 12 case.
|
|
|
|
- [ ] **Step 6: Run + Commit**
|
|
|
|
```bash
|
|
git add saju-lab/app/interpret/pipeline.py saju-lab/tests/test_pipeline.py
|
|
git commit -m "feat(saju-lab): interpret/pipeline.py — Claude 호출 + reroll (12 tests)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 26: routers/saju.py + routers/compat.py + app/main.py
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/app/routers/saju.py`
|
|
- Create: `saju-lab/app/routers/compat.py`
|
|
- Create: `saju-lab/app/main.py`
|
|
- Create: `saju-lab/tests/test_routes.py`
|
|
|
|
**API 엔드포인트:**
|
|
|
|
`routers/saju.py` 6개:
|
|
- `POST /api/saju/interpret` — 입력 → calculate_saju + perform_full_analysis + calculate_daeun + Claude interpret → DB save → 응답
|
|
- `GET /api/saju/readings` — list
|
|
- `GET /api/saju/readings/{id}` — get
|
|
- `PATCH /api/saju/readings/{id}` — favorite/memo
|
|
- `DELETE /api/saju/readings/{id}`
|
|
- `GET /api/saju/current-fortune?reading_id={id}` — 저장된 사주의 오늘 세운만 실시간 (AI 없음)
|
|
|
|
`routers/compat.py` 5개:
|
|
- `POST /api/saju/compat/interpret` — 두 사람 입력 → 두 사주 계산 + compatibility 점수 + Claude → DB save
|
|
- `GET /api/saju/compat/readings`
|
|
- `GET /api/saju/compat/readings/{id}`
|
|
- `PATCH /api/saju/compat/readings/{id}`
|
|
- `DELETE /api/saju/compat/readings/{id}`
|
|
|
|
`main.py`는 tarot-lab과 동일 구조 + 두 router include.
|
|
|
|
테스트(`tests/test_routes.py`): TestClient + Claude pipeline mock으로 11 endpoint 모두 검증 (interpret 호출 시 pipeline.interpret_saju/compat을 monkeypatch).
|
|
|
|
- [ ] **Step 1~10**: 6 endpoint(saju) + 5(compat) 각각 작성/테스트
|
|
|
|
- [ ] **Step 11: Run all saju-lab tests**
|
|
|
|
Run: `cd saju-lab && python -m pytest -v`
|
|
Expected: 70+ tests passing (constants 5 + solar_terms 32 + lunar 2 + core 30 + shinsal 10+ + analysis 30 + daeun 30 + compatibility 10+ + db 10 + schema 5 + pipeline 12 + routes 11)
|
|
|
|
- [ ] **Step 12: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/app/main.py saju-lab/app/routers/ saju-lab/tests/test_routes.py
|
|
git commit -m "feat(saju-lab): main.py + routers (saju 6 + compat 5) + 11 route tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 27: docker-compose.yml + nginx + deploy scripts (saju-lab 등록)
|
|
|
|
**Files:**
|
|
- Modify: `docker-compose.yml`
|
|
- Modify: `nginx/default.conf`
|
|
- Modify: `scripts/deploy.sh` (5 위치 추가)
|
|
- Modify: `scripts/deploy-nas.sh` (SERVICES 추가)
|
|
|
|
- [ ] **Step 1: docker-compose.yml에 saju-lab 추가 (tarot-lab 다음)**
|
|
|
|
```yaml
|
|
saju-lab:
|
|
build:
|
|
context: ./saju-lab
|
|
container_name: saju-lab
|
|
restart: unless-stopped
|
|
ports:
|
|
- "18300:8000"
|
|
environment:
|
|
- TZ=${TZ:-Asia/Seoul}
|
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
|
- SAJU_MODEL=${SAJU_MODEL:-claude-sonnet-4-6}
|
|
- SAJU_COST_INPUT_PER_M=${SAJU_COST_INPUT_PER_M:-3.0}
|
|
- SAJU_COST_OUTPUT_PER_M=${SAJU_COST_OUTPUT_PER_M:-15.0}
|
|
- SAJU_TIMEOUT_SEC=${SAJU_TIMEOUT_SEC:-240}
|
|
- SAJU_DATA_PATH=/app/data
|
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
|
volumes:
|
|
- ${RUNTIME_PATH:-.}/data/saju:/app/data
|
|
healthcheck:
|
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
|
interval: 60s
|
|
timeout: 5s
|
|
retries: 3
|
|
```
|
|
|
|
- [ ] **Step 2: nginx/default.conf — tarot 다음에 saju 추가**
|
|
|
|
```nginx
|
|
# saju-lab API
|
|
location /api/saju/ {
|
|
resolver 127.0.0.11 valid=10s;
|
|
set $saju_backend saju-lab:8000;
|
|
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_read_timeout 300s;
|
|
proxy_send_timeout 300s;
|
|
proxy_connect_timeout 60s;
|
|
proxy_pass http://$saju_backend$request_uri;
|
|
}
|
|
|
|
```
|
|
|
|
- [ ] **Step 3: scripts/deploy-nas.sh SERVICES에 saju-lab 추가**
|
|
|
|
```bash
|
|
SERVICES="lotto travel-proxy deployer stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab tarot-lab saju-lab nginx scripts"
|
|
```
|
|
|
|
- [ ] **Step 4: scripts/deploy.sh 4개 변수에 saju-lab + saju 디렉토리**
|
|
|
|
```bash
|
|
BUILD_TARGETS="lotto travel-proxy stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab tarot-lab saju-lab frontend"
|
|
CONTAINER_NAMES="lotto stock music-lab insta-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy video-lab image-lab tarot-lab saju-lab frontend"
|
|
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab tarot-lab saju-lab redis"
|
|
DATA_DIRS="music stock insta realestate agent-office personal video image tarot saju"
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add docker-compose.yml nginx/default.conf scripts/deploy.sh scripts/deploy-nas.sh
|
|
git commit -m "feat(deploy): saju-lab 컨테이너 + nginx + 5위치 동기화"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 28: web-ui api.js + routes.jsx + Icons (saju helpers)
|
|
|
|
**Files (web-ui repo):**
|
|
- Modify: `web-ui/src/api.js`
|
|
- Modify: `web-ui/src/routes.jsx`
|
|
- Modify: `web-ui/src/components/Icons.jsx`
|
|
|
|
- [ ] **Step 1: api.js에 saju + compat helpers 추가 (tarot helper 다음에)**
|
|
|
|
```javascript
|
|
// ====== Saju ======
|
|
|
|
export function sajuInterpret(body) {
|
|
return apiPost('/api/saju/interpret', body);
|
|
}
|
|
|
|
export function sajuListReadings({ page = 1, size = 20, favorite } = {}) {
|
|
const qs = new URLSearchParams();
|
|
qs.set('page', page); qs.set('size', size);
|
|
if (favorite !== undefined) qs.set('favorite', favorite);
|
|
return apiGet(`/api/saju/readings?${qs.toString()}`);
|
|
}
|
|
|
|
export function sajuGetReading(id) {
|
|
return apiGet(`/api/saju/readings/${id}`);
|
|
}
|
|
|
|
export function sajuPatchReading(id, body) {
|
|
return apiPatch(`/api/saju/readings/${id}`, body);
|
|
}
|
|
|
|
export function sajuDeleteReading(id) {
|
|
return apiDelete(`/api/saju/readings/${id}`);
|
|
}
|
|
|
|
export function sajuCurrentFortune(readingId) {
|
|
return apiGet(`/api/saju/current-fortune?reading_id=${readingId}`);
|
|
}
|
|
|
|
// ====== Compatibility ======
|
|
|
|
export function compatInterpret(body) {
|
|
return apiPost('/api/saju/compat/interpret', body);
|
|
}
|
|
|
|
export function compatListReadings({ page = 1, size = 20, favorite } = {}) {
|
|
const qs = new URLSearchParams();
|
|
qs.set('page', page); qs.set('size', size);
|
|
if (favorite !== undefined) qs.set('favorite', favorite);
|
|
return apiGet(`/api/saju/compat/readings?${qs.toString()}`);
|
|
}
|
|
|
|
export function compatGetReading(id) {
|
|
return apiGet(`/api/saju/compat/readings/${id}`);
|
|
}
|
|
|
|
export function compatPatchReading(id, body) {
|
|
return apiPatch(`/api/saju/compat/readings/${id}`, body);
|
|
}
|
|
|
|
export function compatDeleteReading(id) {
|
|
return apiDelete(`/api/saju/compat/readings/${id}`);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: routes.jsx에 saju 라우트 4개 + navLinks 추가**
|
|
|
|
기존 tarot 라우트 패턴 따라:
|
|
- `/saju` → SajuLanding
|
|
- `/saju/reading` → Saju (입력 + 결과)
|
|
- `/saju/compatibility` → Compatibility
|
|
- `/saju/history` → SajuHistory
|
|
|
|
> **참고**: 실제 React 컴포넌트 파일(Saju.jsx, Compatibility.jsx 등)은 시안 받은 후 작성. 현재 task는 라우트 등록 + 빈 placeholder 컴포넌트만 추가.
|
|
|
|
- [ ] **Step 3: Icons.jsx에 IconSaju 추가**
|
|
|
|
(임시 SVG — 8괘 또는 음양 심볼)
|
|
|
|
- [ ] **Step 4: Commit (web-ui)**
|
|
|
|
```bash
|
|
cd ../web-ui
|
|
git add src/api.js src/routes.jsx src/components/Icons.jsx
|
|
git commit -m "feat(saju): api helpers (saju + compat) + 라우트 + 아이콘"
|
|
cd ../web-backend
|
|
```
|
|
|
|
---
|
|
|
|
### Task 29: web-ui /saju UI 페이지 (시안 받은 후)
|
|
|
|
**Files (web-ui repo):**
|
|
- Create: `web-ui/src/pages/saju/Saju.jsx`
|
|
- Create: `web-ui/src/pages/saju/SajuForm.jsx`
|
|
- Create: `web-ui/src/pages/saju/SajuResult.jsx`
|
|
- Create: `web-ui/src/pages/saju/Compatibility.jsx`
|
|
- Create: `web-ui/src/pages/saju/CompatibilityResult.jsx`
|
|
- Create: `web-ui/src/pages/saju/SajuHistory.jsx`
|
|
- Create: `web-ui/src/pages/saju/Saju.css`
|
|
- Create: `web-ui/src/pages/saju/data/constants.js`
|
|
- Create: `web-ui/src/pages/saju/hooks/useSajuForm.js`
|
|
- Create: `web-ui/src/pages/saju/hooks/useSajuInterpretation.js`
|
|
- Create: `web-ui/src/pages/saju/components/SajuBoard.jsx`
|
|
- Create: `web-ui/src/pages/saju/components/ElementChart.jsx`
|
|
- Create: `web-ui/src/pages/saju/components/DaeunTimeline.jsx`
|
|
- Create: `web-ui/src/pages/saju/components/InterpretationPanel.jsx`
|
|
|
|
**⚠️ 이 Task는 사용자로부터 UI 시안 이미지를 받은 후 진행**합니다. 시안 없이 진행하면 재작업 가능성이 높음.
|
|
|
|
시안 받으면 tarot 페이지(`web-ui/src/pages/tarot/`) 구조를 참조하여 동일 패턴으로 구성:
|
|
- 입력 폼 → useSajuForm hook
|
|
- API 호출 → useSajuInterpretation hook (sajuInterpret 호출, AI 응답 도착 시 자동 탭 전환)
|
|
- 결과 페이지: 4기둥 시각화 + 오행 차트 + 대운 타임라인 + 12항목 아코디언
|
|
- 디자인은 saju-web 디자인 시스템(딥 네이비 + 골드)을 web-ui에 맞춰 조정
|
|
|
|
상세 컴포넌트 명세는 시안 + spec Section 6-6 + saju-web 디렉토리 참고.
|
|
|
|
- [ ] **Step 1~N**: 시안 도착 후 별도 mini-plan으로 진행
|
|
|
|
```bash
|
|
git commit -m "feat(saju): web-ui /saju 페이지 — 입력/결과/궁합/히스토리"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2 완료 ✓
|
|
|
|
Phase 2 종료 시점 점검:
|
|
- [ ] `saju-lab/`에 모든 테스트 통과 (목표: 130+ tests)
|
|
- [ ] Reference fixture 30 케이스 모두 saju 계산 일치
|
|
- [ ] `docker-compose.yml`에 saju-lab 항목 존재 (18300)
|
|
- [ ] `nginx/default.conf`에 `/api/saju/` location 존재
|
|
- [ ] deploy 스크립트 5위치 모두 saju-lab 포함
|
|
- [ ] `web-ui/src/api.js`에 saju + compat helpers
|
|
- [ ] `web-ui/src/routes.jsx`에 /saju 라우트 (UI는 시안 후)
|
|
|
|
NAS 배포 절차:
|
|
1. `cd web-backend && git push` → 자동 배포
|
|
2. 배포 완료 후 SSH:
|
|
```bash
|
|
docker logs -f saju-lab # 시작 확인
|
|
curl http://localhost:18300/health
|
|
```
|
|
3. 시안 받으면 web-ui Phase 2 UI 진행
|
|
|
|
---
|
|
|
|
## 전체 검증 체크리스트
|
|
|
|
### Phase 1 검증
|
|
- [ ] tarot-lab pytest 21 passed
|
|
- [ ] agent-office migrate_tarot pytest 3 passed
|
|
- [ ] agent-office에서 tarot import 0건 (grep 검색)
|
|
- [ ] 로컬 e2e: /tarot 리딩 1회 성공
|
|
- [ ] git log: 12 commit (Task 1~12)
|
|
|
|
### Phase 2 검증
|
|
- [ ] saju-lab pytest 130+ passed
|
|
- [ ] reference fixture 30 케이스 일치
|
|
- [ ] 로컬 e2e: /api/saju/interpret + /api/saju/compat/interpret 각 1회 성공 (curl 또는 Swagger)
|
|
- [ ] git log: Phase 1 + Phase 2 총 28+ commit
|
|
|
|
### NAS 배포 후
|
|
- [ ] 두 컨테이너 모두 healthy
|
|
- [ ] 데이터 마이그레이션 후 tarot.db 행 수 = agent_office.db 의 tarot_readings 행 수
|
|
- [ ] /api/tarot/* 5분 SLA (504 없음)
|
|
- [ ] /api/saju/* 5분 SLA
|
|
|
|
---
|
|
|
|
## 위험 + 대응
|
|
|
|
| 위험 | 대응 |
|
|
|------|------|
|
|
| sxtwl API가 saju-web의 solarlunar와 절기 일자 1일 차이 | reference fixture 재생성 (Node→Python 모두 sxtwl 호환되도록) 또는 saju-web에 sxtwl 동등 라이브러리 적용 |
|
|
| 계산 엔진 포팅 reference 일부 mismatch | 해당 input 별도 issue 기록 + 30개 중 28~29건 통과 시 우선 통과로 처리 (다음 cycle에서 보강) |
|
|
| docker-compose의 RUNTIME_PATH가 호스트마다 다름 | `${RUNTIME_PATH:-.}/data/...` 패턴 (이미 다른 lab에서 사용 중) |
|
|
| Phase 2 UI 시안 지연 | Phase 2 백엔드만 먼저 배포 → /api/saju/* 사용 가능 (Swagger UI 또는 curl). UI는 시안 받은 후 |
|
|
| agent-office cutover 시점에 운영 tarot 요청 끊김 | nginx /api/tarot/ → tarot-lab 라우팅이 cutover 전에 추가되어 있어 끊김 없음 |
|
|
|
|
---
|
|
|
|
## 참고 자료
|
|
|
|
- spec: `docs/superpowers/specs/2026-05-25-saju-tarot-lab-migration-design.md`
|
|
- tarot 원본 구현: `agent-office/app/tarot/`, `agent-office/app/routers/tarot.py`
|
|
- saju-web: `C:/Users/jaeoh/Desktop/workspace/saju-web/`
|
|
- 다른 lab 패턴: `web-backend/insta-lab/`, `music-lab/`, `realestate-lab/`
|
|
- sxtwl 라이브러리: https://github.com/yuangu/sxtwl_cpp (Python binding)
|
|
- 배포 스크립트 동기화 memory: `~/.claude/.../memory/feedback_deploy_script_sync.md`
|