18 task — agent-office 6 (DB·모델·프롬프트·스키마·파이프라인·라우터) + web-ui 11 (자산·카드·hooks·컴포넌트·CSS·페이지·라우팅) + 통합 검증 1. TDD per task — 실패 테스트 → 구현 → 통과 → 커밋 워크플로우. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3548 lines
119 KiB
Markdown
3548 lines
119 KiB
Markdown
# Tarot Lab v1 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 라이더-웨이트 타로 리딩 4 페이지(랜딩 / 오늘의 카드 / 3장 스프레드 / 히스토리)를 Claude Sonnet 4.6 evidence·interactions 기반 해석과 함께 web-ui + agent-office 확장으로 추가한다.
|
||
|
||
**Architecture:**
|
||
- 프론트(`web-ui`): 정적 카드 78장 메타데이터 + 4 페이지 + 3-step 인터랙션 컴포넌트
|
||
- 백엔드(`agent-office` 확장): `tarot_readings` 테이블 + `/api/agent-office/tarot/*` 5 endpoint + Claude Sonnet 호출 with evidence·interactions·confidence 검증
|
||
- 신규 컨테이너·nginx·docker-compose 변경 0건. agent-office 라우터 등록만 추가.
|
||
|
||
**Tech Stack:** FastAPI 0.115 + httpx + sqlite3 / React 18 + Vite + React Router v6 / Claude Sonnet 4.6 / Vitest + pytest
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-23-tarot-lab-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### 백엔드 — agent-office 확장
|
||
| 파일 | 역할 |
|
||
|---|---|
|
||
| `agent-office/app/db.py` | `tarot_readings` 테이블 추가 + CRUD 함수 (save_tarot_reading, list_tarot_readings, get_tarot_reading, update_tarot_reading, delete_tarot_reading) |
|
||
| `agent-office/app/tarot/__init__.py` | 모듈 표식 |
|
||
| `agent-office/app/tarot/prompt.py` | SYSTEM_PROMPT 상수 + build_user_message(question, category, spread, cards_reference, context_meta) |
|
||
| `agent-office/app/tarot/schema.py` | `validate_interpretation(parsed: dict, spread_type: str) -> tuple[bool, str]` — evidence·interactions·confidence 필드 검증 |
|
||
| `agent-office/app/tarot/pipeline.py` | `interpret(req: InterpretRequest) -> InterpretResponse` — Claude 호출 + 파싱 폴백 + reroll 1회 + 비용 계산 |
|
||
| `agent-office/app/routers/tarot.py` | APIRouter `/api/agent-office/tarot` — 5 endpoint (interpret, save, list, patch, delete) |
|
||
| `agent-office/app/models.py` | 신규 Pydantic 모델 추가 (`TarotCard`, `InterpretRequest`, `InterpretResponse`, `SaveReadingRequest`, `PatchReadingRequest`) |
|
||
| `agent-office/app/main.py` | `include_router(tarot_router.router)` 1줄 추가 |
|
||
| `agent-office/app/config.py` | `TAROT_MODEL`, `TAROT_COST_INPUT_PER_M`, `TAROT_COST_OUTPUT_PER_M` 환경변수 |
|
||
| `agent-office/tests/test_tarot_schema.py` | schema 검증 단위 테스트 |
|
||
| `agent-office/tests/test_tarot_pipeline.py` | 파싱 폴백·reroll·비용 계산 테스트 (httpx mock) |
|
||
| `agent-office/tests/test_tarot_db.py` | CRUD 단위 테스트 |
|
||
| `agent-office/tests/test_tarot_routes.py` | endpoint 통합 테스트 (FastAPI TestClient) |
|
||
|
||
### 프론트 — web-ui 신규 페이지
|
||
| 파일 | 역할 |
|
||
|---|---|
|
||
| `web-ui/src/pages/tarot/data/cards.js` | `TAROT_DECK` (78장 메타) + `SPREADS` + `CATEGORIES` 상수 export |
|
||
| `web-ui/src/pages/tarot/data/cards.test.js` | 78장 검증 (총수, slug 중복, 필수 필드) |
|
||
| `web-ui/src/pages/tarot/hooks/useTarotShuffle.js` | Fisher–Yates 셔플 + 16장 슬라이스 hook |
|
||
| `web-ui/src/pages/tarot/hooks/useTarotShuffle.test.js` | 분포·중복 없음 |
|
||
| `web-ui/src/pages/tarot/hooks/useTarotReading.js` | 카드 선택 상태 + reference 블록 빌더 + AI 호출 + 자동 저장 hook |
|
||
| `web-ui/src/pages/tarot/hooks/useTarotReading.test.js` | step 전환, reference 블록 빌드, AI 호출 시나리오 |
|
||
| `web-ui/src/pages/tarot/components/TarotCard.jsx` | 단일 카드 (앞·뒷면 토글, props: cardId / reversed / size / clickable / onClick) |
|
||
| `web-ui/src/pages/tarot/components/CardGrid.jsx` | 셔플 16장 그리드 |
|
||
| `web-ui/src/pages/tarot/components/SpreadSlots.jsx` | 위치별 슬롯 |
|
||
| `web-ui/src/pages/tarot/components/InterpretationPanel.jsx` | 우측 패널 (카드 의미 + AI 텍스트 + evidence 접기) |
|
||
| `web-ui/src/pages/tarot/Tarot.jsx` | 랜딩 페이지 |
|
||
| `web-ui/src/pages/tarot/TodayCard.jsx` | 오늘의 카드 페이지 |
|
||
| `web-ui/src/pages/tarot/Reading.jsx` | 3장 스프레드 메인 (3-step) |
|
||
| `web-ui/src/pages/tarot/History.jsx` | 마이페이지 (이력) |
|
||
| `web-ui/src/pages/tarot/Tarot.css` | 다크 보라+금 톤 디자인 토큰 |
|
||
| `web-ui/src/api.js` | `apiPatch` 추가 + 8 tarot helper 함수 |
|
||
| `web-ui/src/components/Icons.jsx` | `IconTarot` 추가 |
|
||
| `web-ui/src/routes.jsx` | `/tarot`, `/tarot/today`, `/tarot/reading`, `/tarot/history` 라우트 + navLinks 추가 |
|
||
| `web-ui/public/videos/tarot_hero.mp4` | source/videos/tarot_main_background.mp4 복사본 |
|
||
| `web-ui/public/images/tarot_background.png` | source/images/tarot_page/tarot_background.png 복사본 |
|
||
| `web-ui/public/images/tarot/card_back.svg` | 신규 SVG (보라+금 모노그램) |
|
||
|
||
---
|
||
|
||
## 실행 환경
|
||
|
||
- **백엔드 작업 경로**: `C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office/`
|
||
- **프론트 작업 경로**: `C:/Users/jaeoh/Desktop/workspace/web-ui/`
|
||
- **백엔드 테스트**: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office && pytest tests/test_tarot_*.py -v`
|
||
- **프론트 테스트**: `cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm test -- src/pages/tarot/`
|
||
- **백엔드 커밋**: web-backend 저장소에서
|
||
- **프론트 커밋**: web-ui 저장소에서 (별도 Git)
|
||
- **로컬 백엔드 실행**: `cd web-backend && docker compose up -d agent-office` (NAS만 실행이 원칙이지만 로컬 테스트는 docker로)
|
||
- **로컬 프론트**: `cd web-ui && npm run dev` → http://localhost:3007
|
||
|
||
---
|
||
|
||
## Task 1: tarot_readings 테이블 추가 + CRUD
|
||
|
||
**Files:**
|
||
- Modify: `agent-office/app/db.py`
|
||
- Create: `agent-office/tests/test_tarot_db.py`
|
||
|
||
- [ ] **Step 1: 실패 테스트 작성**
|
||
|
||
`agent-office/tests/test_tarot_db.py` 생성:
|
||
|
||
```python
|
||
import json
|
||
import os
|
||
import tempfile
|
||
|
||
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
|
||
if db_file.exists():
|
||
db_file.unlink()
|
||
|
||
|
||
def test_save_and_get_tarot_reading():
|
||
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_tarot_readings_filters_and_pagination():
|
||
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_tarot_reading_favorite_and_note():
|
||
rid = db_module.save_tarot_reading({
|
||
"spread_type": "one_card", "category": None, "question": None,
|
||
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"},
|
||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high",
|
||
})
|
||
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_tarot_reading():
|
||
rid = db_module.save_tarot_reading({
|
||
"spread_type": "one_card", "category": None, "question": None,
|
||
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"},
|
||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high",
|
||
})
|
||
db_module.delete_tarot_reading(rid)
|
||
assert db_module.get_tarot_reading(rid) is None
|
||
```
|
||
|
||
- [ ] **Step 2: 테스트 실패 확인**
|
||
|
||
`cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office && pytest tests/test_tarot_db.py -v`
|
||
|
||
Expected: FAIL with `AttributeError: module 'app.db' has no attribute 'save_tarot_reading'`
|
||
|
||
- [ ] **Step 3: 테이블 + CRUD 구현**
|
||
|
||
`agent-office/app/db.py`의 `init_db()` 함수 안, 마지막 `INSERT OR IGNORE` 시드 직전에 다음 블록 추가:
|
||
|
||
```python
|
||
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)
|
||
""")
|
||
```
|
||
|
||
같은 파일 끝에 CRUD 함수 추가:
|
||
|
||
```python
|
||
# --- tarot_readings CRUD ---
|
||
|
||
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"],
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 테스트 통과 확인**
|
||
|
||
`pytest tests/test_tarot_db.py -v`
|
||
Expected: 4 passed
|
||
|
||
- [ ] **Step 5: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||
git add agent-office/app/db.py agent-office/tests/test_tarot_db.py
|
||
git commit -m "feat(agent-office): tarot_readings 테이블 + CRUD (T1)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Pydantic 모델 + 설정값 추가
|
||
|
||
**Files:**
|
||
- Modify: `agent-office/app/models.py`
|
||
- Modify: `agent-office/app/config.py`
|
||
|
||
- [ ] **Step 1: 설정값 추가**
|
||
|
||
`agent-office/app/config.py` 끝에 추가:
|
||
|
||
```python
|
||
# Tarot Lab
|
||
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", "60"))
|
||
```
|
||
|
||
- [ ] **Step 2: Pydantic 모델 추가**
|
||
|
||
`agent-office/app/models.py` 끝에 추가:
|
||
|
||
```python
|
||
from typing import List, Optional, Literal
|
||
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: import 검증 — 실행 가능한지만 확인**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office
|
||
python -c "from app.models import TarotInterpretRequest, TarotInterpretResponse, TarotSaveRequest, TarotPatchRequest; from app.config import TAROT_MODEL, TAROT_COST_INPUT_PER_M; print('ok')"
|
||
```
|
||
Expected: `ok`
|
||
|
||
- [ ] **Step 4: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||
git add agent-office/app/models.py agent-office/app/config.py
|
||
git commit -m "feat(agent-office): Tarot Pydantic 모델 + config 추가 (T2)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Tarot 프롬프트 (SYSTEM_PROMPT + builder)
|
||
|
||
**Files:**
|
||
- Create: `agent-office/app/tarot/__init__.py`
|
||
- Create: `agent-office/app/tarot/prompt.py`
|
||
|
||
- [ ] **Step 1: 모듈 표식**
|
||
|
||
`agent-office/app/tarot/__init__.py`:
|
||
|
||
```python
|
||
"""Tarot Lab — Claude Sonnet 기반 evidence·interactions 해석 파이프라인."""
|
||
```
|
||
|
||
- [ ] **Step 2: 프롬프트 작성**
|
||
|
||
`agent-office/app/tarot/prompt.py`:
|
||
|
||
```python
|
||
"""Tarot 프롬프트 — SYSTEM + build_user_message."""
|
||
|
||
SYSTEM_PROMPT = """당신은 라이더-웨이트(RWS) 타로 덱의 전통 상징체계에 정통한 타로 리더입니다.
|
||
사용자의 질문, 카테고리, 뽑힌 카드 각각의 정·역방향과 위치를 받아 근거 기반으로 해석합니다.
|
||
|
||
# 해석 원칙
|
||
1. 데이터 우선: "참고 카드 정보" 블록의 키워드·기본의미·상징만을 1차 근거로 사용.
|
||
외부 변형 의미·다른 덱 해석은 사용하지 않음.
|
||
2. 위치 의미 결합: 카드의 의미와 위치(과거/현재/미래 또는 오늘)를 명시적으로 결합해서 해석. evidence에 근거 기록.
|
||
3. 카드 간 상호작용 분석 (3장 스프레드):
|
||
- 시너지: 같은 슈트, 같은 원소, 메이저 비율, 정·역 흐름
|
||
- 충돌·전환: 슈트 충돌(컵-소드, 완드-펜타클), 정→역 전환, 메이저↔마이너 전환
|
||
4. 자기 성찰 톤: 운명론 단정 금지. "…할 가능성이 있어 보입니다" 같은 표현.
|
||
5. 카테고리 컨텍스트: 동일 카드라도 카테고리에 따라 강조점이 달라야 함.
|
||
6. 질문 직접 응답: 사용자 질문을 evidence·advice에서 인용·반영.
|
||
|
||
# 응답 형식 (strict JSON only — 코드블록 없이 raw JSON)
|
||
{
|
||
"summary": "전체 흐름 한 단락 (3~4문장)",
|
||
"cards": [
|
||
{
|
||
"position": "<위치 라벨>",
|
||
"card": "<card_id>",
|
||
"reversed": <bool>,
|
||
"interpretation": "3~4문장",
|
||
"evidence": {
|
||
"card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징",
|
||
"position_logic": "왜 이 위치에 이렇게 적용되는지 (1~2문장)",
|
||
"category_lens": "카테고리 관점에서 부각되는 면 (1문장)"
|
||
},
|
||
"advice": "1문장"
|
||
}
|
||
],
|
||
"interactions": [
|
||
{ "type": "synergy"|"conflict"|"transition",
|
||
"between": ["<card_id>", "<card_id>"],
|
||
"explanation": "1~2문장" }
|
||
],
|
||
"advice": "2문장. interactions를 1개 이상 참조할 것.",
|
||
"warning": "역방향·충돌 경계 (없으면 null)",
|
||
"confidence": "high"|"medium"|"low"
|
||
}
|
||
|
||
# confidence 판정 기준
|
||
- high: 3장 모두 한 방향 서사 또는 명확한 전환
|
||
- medium: 2장 일관, 1장 별도 신호
|
||
- low: 카드 간 의미 충돌이 커서 명확한 흐름 잡기 어려움
|
||
|
||
# 금지사항
|
||
- 참고 카드 정보에 없는 상징 도입 금지
|
||
- 역방향 카드를 정방향처럼 다루지 말 것
|
||
- "신비롭게 들리는" 문구로 채우지 말 것 — evidence에 인용·근거 명시
|
||
- JSON 외 텍스트 금지
|
||
"""
|
||
|
||
|
||
SPREAD_NAMES = {
|
||
"one_card": "오늘의 카드",
|
||
"three_card": "3장 스프레드 (과거·현재·미래)",
|
||
}
|
||
|
||
|
||
def build_user_message(
|
||
question: str,
|
||
category: str,
|
||
spread_type: str,
|
||
cards_reference: str,
|
||
context_meta: dict,
|
||
spread_count: int,
|
||
) -> str:
|
||
q = question or "(질문 없음)"
|
||
cat = category or "일반"
|
||
spread_name = SPREAD_NAMES.get(spread_type, spread_type)
|
||
|
||
meta_lines = []
|
||
if context_meta:
|
||
if "major_minor_ratio" in context_meta:
|
||
meta_lines.append(f"- 메이저:마이너 비율: {context_meta['major_minor_ratio']}")
|
||
if "element_distribution" in context_meta:
|
||
ed = context_meta["element_distribution"]
|
||
meta_lines.append(
|
||
f"- 원소 분포: 공기 {ed.get('air',0)}, 물 {ed.get('water',0)}, 불 {ed.get('fire',0)}, 흙 {ed.get('earth',0)}"
|
||
)
|
||
if "orientation_flow" in context_meta:
|
||
meta_lines.append(f"- 정역 흐름: {context_meta['orientation_flow']}")
|
||
meta_block = "\n".join(meta_lines) if meta_lines else "(추가 컨텍스트 없음)"
|
||
|
||
return f"""# 질문
|
||
{q}
|
||
|
||
# 카테고리
|
||
{cat}
|
||
|
||
# 스프레드
|
||
{spread_name} ({spread_count}장)
|
||
|
||
# 뽑힌 카드와 참고 카드 정보
|
||
{cards_reference}
|
||
|
||
## 추가 컨텍스트
|
||
{meta_block}
|
||
|
||
# 작업
|
||
위 정보만을 근거로 사용해, 시스템 지침의 JSON 형식으로 응답하세요.
|
||
- 각 카드의 evidence.card_meaning_used에는 위 "참고 카드 정보"에서 발췌한 키워드·의미를 그대로 인용.
|
||
- interactions는 3장 간 슈트·원소·정역방향 패턴을 분석해 최소 1개 이상 도출 (1장 스프레드면 빈 배열 허용).
|
||
- confidence는 카드 흐름의 일관성에 따라 정직하게 판정.
|
||
"""
|
||
```
|
||
|
||
- [ ] **Step 3: import 검증**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office
|
||
python -c "from app.tarot.prompt import SYSTEM_PROMPT, build_user_message; m = build_user_message('Q','연애','three_card','CARDS',{'major_minor_ratio':'2:1'},3); assert 'Q' in m and '연애' in m and 'CARDS' in m and '2:1' in m; print('ok')"
|
||
```
|
||
Expected: `ok`
|
||
|
||
- [ ] **Step 4: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||
git add agent-office/app/tarot/__init__.py agent-office/app/tarot/prompt.py
|
||
git commit -m "feat(agent-office): Tarot SYSTEM_PROMPT + user message builder (T3)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: 응답 스키마 검증
|
||
|
||
**Files:**
|
||
- Create: `agent-office/app/tarot/schema.py`
|
||
- Create: `agent-office/tests/test_tarot_schema.py`
|
||
|
||
- [ ] **Step 1: 실패 테스트**
|
||
|
||
`agent-office/tests/test_tarot_schema.py`:
|
||
|
||
```python
|
||
import pytest
|
||
|
||
from app.tarot.schema import validate_interpretation
|
||
|
||
|
||
def _valid_three():
|
||
return {
|
||
"summary": "S",
|
||
"cards": [
|
||
{"position": "과거", "card": "the-fool", "reversed": False,
|
||
"interpretation": "...", "advice": "a",
|
||
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||
{"position": "현재", "card": "the-lovers", "reversed": True,
|
||
"interpretation": "...", "advice": "a",
|
||
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||
{"position": "미래", "card": "ten-of-cups", "reversed": False,
|
||
"interpretation": "...", "advice": "a",
|
||
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||
],
|
||
"interactions": [{"type": "synergy", "between": ["the-fool", "ten-of-cups"], "explanation": "..."}],
|
||
"advice": "A",
|
||
"warning": None,
|
||
"confidence": "medium",
|
||
}
|
||
|
||
|
||
def test_valid_three_card_passes():
|
||
ok, msg = validate_interpretation(_valid_three(), "three_card")
|
||
assert ok, msg
|
||
|
||
|
||
def test_missing_evidence_fails():
|
||
bad = _valid_three()
|
||
del bad["cards"][0]["evidence"]
|
||
ok, msg = validate_interpretation(bad, "three_card")
|
||
assert not ok
|
||
assert "evidence" in msg
|
||
|
||
|
||
def test_empty_card_meaning_used_fails():
|
||
bad = _valid_three()
|
||
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||
ok, msg = validate_interpretation(bad, "three_card")
|
||
assert not ok
|
||
assert "card_meaning_used" in msg
|
||
|
||
|
||
def test_three_card_requires_interactions():
|
||
bad = _valid_three()
|
||
bad["interactions"] = []
|
||
ok, msg = validate_interpretation(bad, "three_card")
|
||
assert not ok
|
||
assert "interactions" in msg
|
||
|
||
|
||
def test_one_card_accepts_empty_interactions():
|
||
one = {
|
||
"summary": "S",
|
||
"cards": [{"position": "오늘", "card": "the-fool", "reversed": False,
|
||
"interpretation": "...", "advice": "a",
|
||
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}}],
|
||
"interactions": [],
|
||
"advice": "A",
|
||
"warning": None,
|
||
"confidence": "high",
|
||
}
|
||
ok, msg = validate_interpretation(one, "one_card")
|
||
assert ok, msg
|
||
|
||
|
||
def test_invalid_confidence_fails():
|
||
bad = _valid_three()
|
||
bad["confidence"] = "very high"
|
||
ok, msg = validate_interpretation(bad, "three_card")
|
||
assert not ok
|
||
```
|
||
|
||
- [ ] **Step 2: 실패 확인**
|
||
|
||
`pytest tests/test_tarot_schema.py -v`
|
||
Expected: ImportError — `app.tarot.schema` 모듈 없음
|
||
|
||
- [ ] **Step 3: 구현**
|
||
|
||
`agent-office/app/tarot/schema.py`:
|
||
|
||
```python
|
||
"""Tarot 응답 스키마 검증 — 누락·빈 필드 reroll 트리거."""
|
||
|
||
VALID_CONFIDENCE = {"high", "medium", "low"}
|
||
|
||
|
||
def validate_interpretation(parsed: dict, spread_type: str) -> tuple[bool, str]:
|
||
if not isinstance(parsed, dict):
|
||
return False, "응답이 dict가 아님"
|
||
for k in ("summary", "cards", "interactions", "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')}"
|
||
cards = parsed.get("cards")
|
||
if not isinstance(cards, list) or not cards:
|
||
return False, "cards가 빈 배열"
|
||
for i, c in enumerate(cards):
|
||
if not isinstance(c, dict):
|
||
return False, f"cards[{i}] dict 아님"
|
||
for k in ("position", "card", "reversed", "interpretation", "advice", "evidence"):
|
||
if k not in c:
|
||
return False, f"cards[{i}].{k} 누락"
|
||
ev = c["evidence"]
|
||
if not isinstance(ev, dict):
|
||
return False, f"cards[{i}].evidence dict 아님"
|
||
for k in ("card_meaning_used", "position_logic", "category_lens"):
|
||
if k not in ev:
|
||
return False, f"cards[{i}].evidence.{k} 누락"
|
||
if not isinstance(ev[k], str) or not ev[k].strip():
|
||
return False, f"cards[{i}].evidence.{k} 빈 문자열"
|
||
interactions = parsed.get("interactions")
|
||
if not isinstance(interactions, list):
|
||
return False, "interactions가 list 아님"
|
||
if spread_type == "three_card" and len(interactions) == 0:
|
||
return False, "three_card는 interactions 1개 이상 필요"
|
||
return True, ""
|
||
```
|
||
|
||
- [ ] **Step 4: 통과 확인**
|
||
|
||
`pytest tests/test_tarot_schema.py -v`
|
||
Expected: 6 passed
|
||
|
||
- [ ] **Step 5: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||
git add agent-office/app/tarot/schema.py agent-office/tests/test_tarot_schema.py
|
||
git commit -m "feat(agent-office): Tarot 응답 스키마 검증 (T4)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Claude 호출 파이프라인 (interpret)
|
||
|
||
**Files:**
|
||
- Create: `agent-office/app/tarot/pipeline.py`
|
||
- Create: `agent-office/tests/test_tarot_pipeline.py`
|
||
|
||
- [ ] **Step 1: 실패 테스트**
|
||
|
||
`agent-office/tests/test_tarot_pipeline.py`:
|
||
|
||
```python
|
||
import json
|
||
import pytest
|
||
import respx
|
||
from httpx import Response
|
||
|
||
from app.tarot import pipeline as p
|
||
from app.models import TarotInterpretRequest
|
||
|
||
|
||
def _valid_response_text():
|
||
return json.dumps({
|
||
"summary": "S",
|
||
"cards": [
|
||
{"position": "과거", "card": "the-fool", "reversed": False,
|
||
"interpretation": "i", "advice": "a",
|
||
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||
{"position": "현재", "card": "the-lovers", "reversed": True,
|
||
"interpretation": "i", "advice": "a",
|
||
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||
{"position": "미래", "card": "ten-of-cups", "reversed": False,
|
||
"interpretation": "i", "advice": "a",
|
||
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||
],
|
||
"interactions": [{"type": "synergy", "between": ["the-fool", "ten-of-cups"], "explanation": "."}],
|
||
"advice": "A", "warning": None, "confidence": "medium",
|
||
})
|
||
|
||
|
||
def _claude_resp(text, in_tok=100, out_tok=200):
|
||
return {
|
||
"content": [{"type": "text", "text": text}],
|
||
"usage": {"input_tokens": in_tok, "output_tokens": out_tok},
|
||
}
|
||
|
||
|
||
def _req():
|
||
return TarotInterpretRequest(
|
||
spread_type="three_card",
|
||
category="연애",
|
||
question="Q",
|
||
cards=[
|
||
{"position": "과거", "card_id": "the-fool", "reversed": False},
|
||
{"position": "현재", "card_id": "the-lovers", "reversed": True},
|
||
{"position": "미래", "card_id": "ten-of-cups", "reversed": False},
|
||
],
|
||
cards_reference="REFERENCE",
|
||
context_meta={"major_minor_ratio": "2:1"},
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_interpret_happy_path(monkeypatch):
|
||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(_valid_response_text())))
|
||
out = await p.interpret(_req())
|
||
assert out["interpretation_json"]["confidence"] == "medium"
|
||
assert out["tokens_in"] == 100
|
||
assert out["tokens_out"] == 200
|
||
assert out["reroll_count"] == 0
|
||
assert out["cost_usd"] > 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_interpret_codeblock_strip(monkeypatch):
|
||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||
wrapped = "```json\n" + _valid_response_text() + "\n```"
|
||
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(wrapped)))
|
||
out = await p.interpret(_req())
|
||
assert out["interpretation_json"]["summary"] == "S"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_interpret_reroll_on_validation_fail(monkeypatch):
|
||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||
bad = json.loads(_valid_response_text())
|
||
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||
bad_text = json.dumps(bad)
|
||
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||
route = mock.post("/v1/messages")
|
||
route.side_effect = [
|
||
Response(200, json=_claude_resp(bad_text)),
|
||
Response(200, json=_claude_resp(_valid_response_text())),
|
||
]
|
||
out = await p.interpret(_req())
|
||
assert out["reroll_count"] == 1
|
||
assert out["interpretation_json"]["cards"][0]["evidence"]["card_meaning_used"] == "k"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_interpret_raises_when_both_attempts_fail(monkeypatch):
|
||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||
bad = json.loads(_valid_response_text())
|
||
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||
bad_text = json.dumps(bad)
|
||
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(bad_text)))
|
||
with pytest.raises(p.TarotError):
|
||
await p.interpret(_req())
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_interpret_raises_when_api_key_missing(monkeypatch):
|
||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "")
|
||
with pytest.raises(p.TarotError):
|
||
await p.interpret(_req())
|
||
|
||
|
||
def test_calc_cost():
|
||
assert p.calc_cost(1_000_000, 0) == pytest.approx(3.0)
|
||
assert p.calc_cost(0, 1_000_000) == pytest.approx(15.0)
|
||
assert p.calc_cost(500_000, 500_000) == pytest.approx(9.0)
|
||
```
|
||
|
||
- [ ] **Step 2: 실패 확인**
|
||
|
||
`pytest tests/test_tarot_pipeline.py -v`
|
||
Expected: ImportError — `app.tarot.pipeline` 모듈 없음
|
||
|
||
- [ ] **Step 3: 구현**
|
||
|
||
`agent-office/app/tarot/pipeline.py`:
|
||
|
||
```python
|
||
"""Tarot 파이프라인 — Claude Sonnet 호출 + 파싱 폴백 + reroll 1회."""
|
||
import json
|
||
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,
|
||
)
|
||
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": 2048,
|
||
"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"
|
||
)
|
||
parsed = _extract_json(raw_text)
|
||
usage = resp.get("usage", {}) or {}
|
||
meta = {
|
||
"tokens_in": int(usage.get("input_tokens", 0) or 0),
|
||
"tokens_out": int(usage.get("output_tokens", 0) or 0),
|
||
"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}"
|
||
total_in += 0
|
||
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 4: 비동기 테스트 의존성 확인 + 통과**
|
||
|
||
`pytest-asyncio` 미설치면 추가:
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office
|
||
python -c "import pytest_asyncio" 2>&1 || pip install pytest-asyncio
|
||
```
|
||
|
||
`pytest.ini` 또는 `pyproject.toml`에 `asyncio_mode = "auto"` 있는지 확인. 없으면 `agent-office/pytest.ini` 생성:
|
||
|
||
```ini
|
||
[pytest]
|
||
asyncio_mode = auto
|
||
```
|
||
|
||
테스트 실행:
|
||
`pytest tests/test_tarot_pipeline.py -v`
|
||
Expected: 6 passed
|
||
|
||
- [ ] **Step 5: requirements.txt 갱신**
|
||
|
||
`agent-office/requirements.txt`에 다음이 없으면 추가:
|
||
```
|
||
pytest-asyncio>=0.23
|
||
```
|
||
|
||
- [ ] **Step 6: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||
git add agent-office/app/tarot/pipeline.py agent-office/tests/test_tarot_pipeline.py agent-office/pytest.ini agent-office/requirements.txt
|
||
git commit -m "feat(agent-office): Tarot Claude 파이프라인 + reroll 1회 (T5)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Tarot 라우터 (5 endpoint)
|
||
|
||
**Files:**
|
||
- Create: `agent-office/app/routers/tarot.py`
|
||
- Modify: `agent-office/app/main.py`
|
||
- Create: `agent-office/tests/test_tarot_routes.py`
|
||
|
||
- [ ] **Step 1: 실패 테스트**
|
||
|
||
`agent-office/tests/test_tarot_routes.py`:
|
||
|
||
```python
|
||
import json
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app import db as db_module
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def fresh_db(monkeypatch, tmp_path):
|
||
db_file = tmp_path / "test_routes.db"
|
||
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||
db_module.init_db()
|
||
from app.main import app
|
||
yield app
|
||
|
||
|
||
def test_interpret_calls_pipeline(monkeypatch, fresh_db):
|
||
async def fake_interpret(req):
|
||
return {
|
||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "high"},
|
||
"model": "claude-sonnet-4-6", "tokens_in": 100, "tokens_out": 200,
|
||
"cost_usd": 0.005, "latency_ms": 1234, "reroll_count": 0,
|
||
}
|
||
from app.tarot import pipeline
|
||
monkeypatch.setattr(pipeline, "interpret", fake_interpret)
|
||
client = TestClient(fresh_db)
|
||
r = client.post("/api/agent-office/tarot/interpret", json={
|
||
"spread_type": "one_card",
|
||
"category": "일반",
|
||
"question": "Q",
|
||
"cards": [{"position": "오늘", "card_id": "the-fool", "reversed": False}],
|
||
"cards_reference": "REF",
|
||
"context_meta": {},
|
||
})
|
||
assert r.status_code == 200, r.text
|
||
assert r.json()["interpretation_json"]["confidence"] == "high"
|
||
|
||
|
||
def test_save_and_list(fresh_db):
|
||
client = TestClient(fresh_db)
|
||
save = client.post("/api/agent-office/tarot/readings", json={
|
||
"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": 1, "tokens_out": 2, "cost_usd": 0.01,
|
||
"confidence": "medium",
|
||
})
|
||
assert save.status_code == 200, save.text
|
||
rid = save.json()["id"]
|
||
lst = client.get("/api/agent-office/tarot/readings?page=1&size=10")
|
||
assert lst.json()["total"] == 1
|
||
assert lst.json()["items"][0]["id"] == rid
|
||
|
||
|
||
def test_patch_favorite(fresh_db):
|
||
client = TestClient(fresh_db)
|
||
save = client.post("/api/agent-office/tarot/readings", json={
|
||
"spread_type": "one_card", "cards": [],
|
||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "low"},
|
||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||
})
|
||
rid = save.json()["id"]
|
||
p = client.patch(f"/api/agent-office/tarot/readings/{rid}", json={"favorite": True})
|
||
assert p.status_code == 200
|
||
g = client.get(f"/api/agent-office/tarot/readings/{rid}")
|
||
assert g.json()["favorite"] == 1
|
||
|
||
|
||
def test_delete(fresh_db):
|
||
client = TestClient(fresh_db)
|
||
save = client.post("/api/agent-office/tarot/readings", json={
|
||
"spread_type": "one_card", "cards": [],
|
||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "low"},
|
||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||
})
|
||
rid = save.json()["id"]
|
||
d = client.delete(f"/api/agent-office/tarot/readings/{rid}")
|
||
assert d.status_code == 200
|
||
g = client.get(f"/api/agent-office/tarot/readings/{rid}")
|
||
assert g.status_code == 404
|
||
|
||
|
||
def test_get_missing_reading_404(fresh_db):
|
||
client = TestClient(fresh_db)
|
||
r = client.get("/api/agent-office/tarot/readings/99999")
|
||
assert r.status_code == 404
|
||
```
|
||
|
||
- [ ] **Step 2: 실패 확인**
|
||
|
||
`pytest tests/test_tarot_routes.py -v`
|
||
Expected: 404 (router 미등록) 또는 ImportError
|
||
|
||
- [ ] **Step 3: 라우터 구현**
|
||
|
||
`agent-office/app/routers/tarot.py`:
|
||
|
||
```python
|
||
"""Tarot Lab 엔드포인트 — interpret + readings CRUD."""
|
||
from fastapi import APIRouter, HTTPException
|
||
|
||
from ..models import (
|
||
TarotInterpretRequest,
|
||
TarotInterpretResponse,
|
||
TarotSaveRequest,
|
||
TarotPatchRequest,
|
||
)
|
||
from ..tarot import pipeline
|
||
from .. import db as db_module
|
||
|
||
|
||
router = APIRouter(prefix="/api/agent-office/tarot")
|
||
|
||
|
||
@router.post("/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
|
||
|
||
|
||
@router.post("/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"]}
|
||
|
||
|
||
@router.get("/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,
|
||
)
|
||
|
||
|
||
@router.get("/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
|
||
|
||
|
||
@router.patch("/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}
|
||
|
||
|
||
@router.delete("/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 4: main.py에 라우터 등록**
|
||
|
||
`agent-office/app/main.py` 파일 상단 import 블록:
|
||
|
||
```python
|
||
from .routers import notify as notify_router
|
||
```
|
||
|
||
뒤에 추가:
|
||
|
||
```python
|
||
from .routers import tarot as tarot_router
|
||
```
|
||
|
||
`app.include_router(notify_router.router)` 다음 줄에 추가:
|
||
|
||
```python
|
||
app.include_router(tarot_router.router)
|
||
```
|
||
|
||
- [ ] **Step 5: 통과 확인**
|
||
|
||
`pytest tests/test_tarot_routes.py -v`
|
||
Expected: 5 passed
|
||
|
||
- [ ] **Step 6: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||
git add agent-office/app/routers/tarot.py agent-office/app/main.py agent-office/tests/test_tarot_routes.py
|
||
git commit -m "feat(agent-office): /api/agent-office/tarot 5 endpoint (T6)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: 미디어 자산 복사
|
||
|
||
**Files:**
|
||
- Create: `web-ui/public/videos/tarot_hero.mp4`
|
||
- Create: `web-ui/public/images/tarot_background.png`
|
||
- Create: `web-ui/public/images/tarot/card_back.svg`
|
||
|
||
- [ ] **Step 1: 영상·이미지 복사**
|
||
|
||
```powershell
|
||
# PowerShell에서 실행
|
||
$src = "C:\Users\jaeoh\Desktop\workspace\source"
|
||
$dst = "C:\Users\jaeoh\Desktop\workspace\web-ui\public"
|
||
New-Item -ItemType Directory -Force -Path "$dst\videos" | Out-Null
|
||
New-Item -ItemType Directory -Force -Path "$dst\images\tarot\cards" | Out-Null
|
||
Copy-Item "$src\videos\tarot_main_background.mp4" "$dst\videos\tarot_hero.mp4"
|
||
Copy-Item "$src\images\tarot_page\tarot_background.png" "$dst\images\tarot_background.png"
|
||
```
|
||
|
||
영상 크기 확인:
|
||
```powershell
|
||
$f = Get-Item "$dst\videos\tarot_hero.mp4"
|
||
"$([math]::Round($f.Length/1MB, 1)) MB"
|
||
```
|
||
|
||
> 5MB 초과 시 plan 진행 중 사용자에게 확인 요청 (압축 권장).
|
||
|
||
- [ ] **Step 2: 카드 뒷면 SVG**
|
||
|
||
`web-ui/public/images/tarot/card_back.svg`:
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
|
||
<defs>
|
||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="#1a0d2e"/>
|
||
<stop offset="100%" stop-color="#0a0420"/>
|
||
</linearGradient>
|
||
<linearGradient id="goldFrame" x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0%" stop-color="#d4af37"/>
|
||
<stop offset="100%" stop-color="#8b6914"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<rect width="200" height="300" rx="14" fill="url(#bg)"/>
|
||
<rect x="8" y="8" width="184" height="284" rx="10" fill="none"
|
||
stroke="url(#goldFrame)" stroke-width="2"/>
|
||
<g transform="translate(100 150)" fill="#d4af37" font-family="serif" text-anchor="middle">
|
||
<circle r="38" fill="none" stroke="#d4af37" stroke-width="1.5"/>
|
||
<text font-size="48" dy="14" font-style="italic">A</text>
|
||
<g opacity=".5">
|
||
<circle cx="-60" cy="-90" r="1.5"/>
|
||
<circle cx="55" cy="-100" r="1"/>
|
||
<circle cx="-50" cy="80" r="1.2"/>
|
||
<circle cx="65" cy="90" r="1"/>
|
||
<circle cx="0" cy="-110" r="1.6"/>
|
||
</g>
|
||
</g>
|
||
<text x="100" y="280" fill="#d4af37" font-family="serif" font-size="10"
|
||
text-anchor="middle" letter-spacing="2">ARCANA TAROT</text>
|
||
</svg>
|
||
```
|
||
|
||
- [ ] **Step 3: 자산 존재 확인**
|
||
|
||
```powershell
|
||
Test-Path "$dst\videos\tarot_hero.mp4"
|
||
Test-Path "$dst\images\tarot_background.png"
|
||
Test-Path "$dst\images\tarot\card_back.svg"
|
||
```
|
||
Expected: True 3개
|
||
|
||
- [ ] **Step 4: 커밋 (web-ui 저장소)**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add public/videos/tarot_hero.mp4 public/images/tarot_background.png public/images/tarot/card_back.svg
|
||
git commit -m "feat(tarot): 히어로 영상 + 배경 + 카드 뒷면 SVG (T7)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: 카드 메타데이터 (cards.js 메이저 22장 + 마이너 56장)
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/tarot/data/cards.js`
|
||
- Create: `web-ui/src/pages/tarot/data/cards.test.js`
|
||
|
||
- [ ] **Step 1: 실패 테스트**
|
||
|
||
`web-ui/src/pages/tarot/data/cards.test.js`:
|
||
|
||
```javascript
|
||
import { describe, it, expect } from 'vitest';
|
||
import { TAROT_DECK, SPREADS, CATEGORIES } from './cards';
|
||
|
||
describe('TAROT_DECK', () => {
|
||
it('총 78장', () => {
|
||
expect(TAROT_DECK).toHaveLength(78);
|
||
});
|
||
|
||
it('메이저 22장 + 마이너 56장', () => {
|
||
const majors = TAROT_DECK.filter((c) => c.arcana === 'major');
|
||
const minors = TAROT_DECK.filter((c) => c.arcana === 'minor');
|
||
expect(majors).toHaveLength(22);
|
||
expect(minors).toHaveLength(56);
|
||
});
|
||
|
||
it('slug 중복 없음', () => {
|
||
const slugs = TAROT_DECK.map((c) => c.slug);
|
||
expect(new Set(slugs).size).toBe(slugs.length);
|
||
});
|
||
|
||
it('모든 카드에 필수 필드 존재', () => {
|
||
for (const c of TAROT_DECK) {
|
||
expect(c.id).toBeTypeOf('number');
|
||
expect(c.slug).toBeTruthy();
|
||
expect(c.name).toBeTruthy();
|
||
expect(c.arcana).toMatch(/^(major|minor)$/);
|
||
expect(c.keywords).toBeInstanceOf(Array);
|
||
expect(c.keywords.length).toBeGreaterThan(0);
|
||
expect(c.reversedKeywords).toBeInstanceOf(Array);
|
||
expect(c.reversedKeywords.length).toBeGreaterThan(0);
|
||
expect(c.meaningUpright).toBeTruthy();
|
||
expect(c.meaningReversed).toBeTruthy();
|
||
}
|
||
});
|
||
|
||
it('마이너 카드는 suit, rank 필드를 가진다', () => {
|
||
const minors = TAROT_DECK.filter((c) => c.arcana === 'minor');
|
||
for (const c of minors) {
|
||
expect(c.suit).toMatch(/^(wands|cups|swords|pentacles)$/);
|
||
expect(c.rank).toBeTypeOf('number');
|
||
expect(c.rank).toBeGreaterThanOrEqual(1);
|
||
expect(c.rank).toBeLessThanOrEqual(14);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('SPREADS', () => {
|
||
it('one_card / three_card 정의 존재', () => {
|
||
expect(SPREADS.one_card.positions).toHaveLength(1);
|
||
expect(SPREADS.three_card.positions).toHaveLength(3);
|
||
expect(SPREADS.three_card.positions.map((p) => p.label)).toEqual(['과거', '현재', '미래']);
|
||
});
|
||
});
|
||
|
||
describe('CATEGORIES', () => {
|
||
it('6개 카테고리', () => {
|
||
expect(CATEGORIES).toHaveLength(6);
|
||
expect(CATEGORIES).toContain('연애');
|
||
expect(CATEGORIES).toContain('일·커리어');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 실패 확인**
|
||
|
||
`cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm test -- src/pages/tarot/data/cards.test.js`
|
||
Expected: 모듈 없음 에러
|
||
|
||
- [ ] **Step 3: cards.js 구현 — 메이저 22장 (정·역 키워드 + 의미 텍스트 완성)**
|
||
|
||
`web-ui/src/pages/tarot/data/cards.js`:
|
||
|
||
```javascript
|
||
const cardImage = (slug) => `/images/tarot/cards/${slug}.png`;
|
||
|
||
const MAJOR_ARCANA = [
|
||
{ id: 0, slug: 'the-fool', name: '바보', nameEn: 'The Fool', element: 'air',
|
||
keywords: ['새로운 시작','도약','순수','자유','무한한 가능성'],
|
||
reversedKeywords: ['무모함','경솔함','위험','방향 상실','준비 부족'],
|
||
meaningUpright: '미지의 세계로 내딛는 첫걸음. 계산보다 직관과 신뢰로 시작하는 시기. 위험은 있으나 그 자체가 성장의 통로.',
|
||
meaningReversed: '준비 없이 뛰어들어 위험을 자초하거나, 두려움으로 첫걸음을 미루는 상태.' },
|
||
{ id: 1, slug: 'the-magician', name: '마법사', nameEn: 'The Magician', element: 'air',
|
||
keywords: ['의지','창조','집중','실행력','자기 효능감'],
|
||
reversedKeywords: ['조작','자기 기만','산만함','잠재력 미발현'],
|
||
meaningUpright: '내가 가진 자원과 의지를 명확히 모아 현실로 옮길 수 있는 시기. 시작과 추진력이 일치한다.',
|
||
meaningReversed: '의도가 흐려지거나 능력을 잘못 사용해 자기 기만에 빠질 위험.' },
|
||
{ id: 2, slug: 'the-high-priestess', name: '여사제', nameEn: 'The High Priestess', element: 'water',
|
||
keywords: ['직관','내면의 지혜','비밀','잠재의식','신비'],
|
||
reversedKeywords: ['직관 무시','정보 단절','억압','표면적 판단'],
|
||
meaningUpright: '드러나지 않은 진실을 들여다볼 시기. 외부 답이 아닌 내면의 신호에 귀 기울일 때 길이 보인다.',
|
||
meaningReversed: '직관을 무시하거나 비밀이 노출되어 균형이 깨지는 상태.' },
|
||
{ id: 3, slug: 'the-empress', name: '여황제', nameEn: 'The Empress', element: 'earth',
|
||
keywords: ['풍요','창조성','어머니','자연','감각적 충만'],
|
||
reversedKeywords: ['창조 정체','과보호','의존','정서적 소진'],
|
||
meaningUpright: '풍요와 창조가 무르익는 시기. 보살핌·예술·자연과의 연결에서 에너지가 자라남.',
|
||
meaningReversed: '돌봄이 과해 자신을 잃거나, 창조 흐름이 정체된 상태.' },
|
||
{ id: 4, slug: 'the-emperor', name: '황제', nameEn: 'The Emperor', element: 'fire',
|
||
keywords: ['권위','구조','책임','통제','아버지'],
|
||
reversedKeywords: ['독선','경직','통제 욕구','권위 남용'],
|
||
meaningUpright: '질서와 책임을 세워 안정을 만드는 시기. 명확한 경계와 원칙이 힘이 된다.',
|
||
meaningReversed: '경직된 통제가 관계를 막거나, 권위 남용으로 신뢰가 깨질 위험.' },
|
||
{ id: 5, slug: 'the-hierophant', name: '교황', nameEn: 'The Hierophant', element: 'earth',
|
||
keywords: ['전통','가르침','믿음','제도','조언자'],
|
||
reversedKeywords: ['관습 거부','독학','권위 도전','형식주의'],
|
||
meaningUpright: '전통과 멘토의 지혜를 빌릴 때. 검증된 길과 가르침이 도움을 준다.',
|
||
meaningReversed: '관습이 답이 되지 않거나, 자기만의 길을 새로 찾고 싶은 시기.' },
|
||
{ id: 6, slug: 'the-lovers', name: '연인', nameEn: 'The Lovers', element: 'air',
|
||
keywords: ['사랑','선택','조화','가치관 일치','결합'],
|
||
reversedKeywords: ['관계 갈등','선택의 어려움','가치관 충돌','미성숙한 결정'],
|
||
meaningUpright: '깊은 결합과 가치관의 일치. 중요한 선택을 마음으로부터 내릴 때.',
|
||
meaningReversed: '두 길 사이에서 머뭇거리거나, 이미 내린 선택의 의구심이 커지는 시기.' },
|
||
{ id: 7, slug: 'the-chariot', name: '전차', nameEn: 'The Chariot', element: 'water',
|
||
keywords: ['의지','전진','승리','자기 통제','목표 추진'],
|
||
reversedKeywords: ['방향 상실','자기 통제 부족','과욕','지연'],
|
||
meaningUpright: '명확한 목표와 강한 의지로 전진하는 시기. 상반된 힘들을 조율해 추진력으로 바꾼다.',
|
||
meaningReversed: '방향이 흔들리거나 통제력을 잃어 진전이 멈춘 상태.' },
|
||
{ id: 8, slug: 'strength', name: '힘', nameEn: 'Strength', element: 'fire',
|
||
keywords: ['내면의 힘','용기','부드러운 통제','인내','자제'],
|
||
reversedKeywords: ['자신감 부족','감정 과잉','자제력 상실'],
|
||
meaningUpright: '강제가 아닌 부드러움으로 어려움을 다루는 시기. 진짜 힘은 자기 통제와 인내에서 나온다.',
|
||
meaningReversed: '감정에 휘말려 자제력을 잃거나, 자신감이 흔들리는 상태.' },
|
||
{ id: 9, slug: 'the-hermit', name: '은둔자', nameEn: 'The Hermit', element: 'earth',
|
||
keywords: ['성찰','고독','내면의 빛','지혜 추구','은둔'],
|
||
reversedKeywords: ['고립','회피','외로움','자기 폐쇄'],
|
||
meaningUpright: '바깥 소음에서 물러나 자기 안의 빛으로 길을 찾는 시기.',
|
||
meaningReversed: '회피·고립이 길어져 균형이 깨진 상태.' },
|
||
{ id: 10, slug: 'wheel-of-fortune', name: '운명의 수레바퀴', nameEn: 'Wheel of Fortune', element: 'fire',
|
||
keywords: ['전환점','순환','운명','기회','변화'],
|
||
reversedKeywords: ['악순환','정체','불운','통제력 상실'],
|
||
meaningUpright: '큰 흐름이 바뀌는 전환점. 받아들이고 흐름에 올라타면 새로운 국면이 열린다.',
|
||
meaningReversed: '순환의 하강기. 흐름을 거스르기보다 자세를 낮추고 견뎌야 할 시기.' },
|
||
{ id: 11, slug: 'justice', name: '정의', nameEn: 'Justice', element: 'air',
|
||
keywords: ['정의','균형','진실','책임','명료성'],
|
||
reversedKeywords: ['불공정','책임 회피','판단 왜곡'],
|
||
meaningUpright: '원인과 결과가 명확히 드러나는 시기. 진실에 기초한 결정이 길을 연다.',
|
||
meaningReversed: '책임을 외면하거나 한쪽 시각에 치우쳐 균형이 깨진 상태.' },
|
||
{ id: 12, slug: 'the-hanged-man', name: '매달린 사람', nameEn: 'The Hanged Man', element: 'water',
|
||
keywords: ['시야 전환','내려놓음','희생','수용','새로운 관점'],
|
||
reversedKeywords: ['고집','정체','희생 거부','시야의 닫힘'],
|
||
meaningUpright: '잠시 멈춰 시야를 뒤집어 보는 시기. 강제로 풀려 하지 말고 다른 각도를 받아들이자.',
|
||
meaningReversed: '내려놓아야 할 것을 붙들고 있어 정체가 길어지는 상태.' },
|
||
{ id: 13, slug: 'death', name: '죽음', nameEn: 'Death', element: 'water',
|
||
keywords: ['종결','변형','놓아주기','재탄생','전환'],
|
||
reversedKeywords: ['변화 저항','놓지 못함','정체','두려움'],
|
||
meaningUpright: '한 챕터가 닫히고 새로운 챕터가 열리는 결정적 전환. 끝맺음이 새 시작의 조건이다.',
|
||
meaningReversed: '끝나야 할 것을 붙들어 변화가 늦어지는 상태.' },
|
||
{ id: 14, slug: 'temperance', name: '절제', nameEn: 'Temperance', element: 'fire',
|
||
keywords: ['조화','중용','연금술적 결합','인내','치유'],
|
||
reversedKeywords: ['불균형','과잉','조급함','조화 상실'],
|
||
meaningUpright: '서로 다른 것들을 천천히 섞어 균형 잡힌 상태로 만드는 시기. 조급함보다 끈기.',
|
||
meaningReversed: '극단으로 치우치거나 조급함이 흐름을 깨는 상태.' },
|
||
{ id: 15, slug: 'the-devil', name: '악마', nameEn: 'The Devil', element: 'earth',
|
||
keywords: ['속박','집착','중독','물질주의','그림자'],
|
||
reversedKeywords: ['해방','구속에서 벗어남','자각','단절'],
|
||
meaningUpright: '스스로 묶어둔 사슬을 직시할 시기. 욕망·중독·집착이 시야를 가린다.',
|
||
meaningReversed: '구속이 풀리며 의식적인 해방이 가능한 상태.' },
|
||
{ id: 16, slug: 'the-tower', name: '탑', nameEn: 'The Tower', element: 'fire',
|
||
keywords: ['붕괴','갑작스러운 변화','각성','진실 노출'],
|
||
reversedKeywords: ['붕괴 회피','두려움','지연된 충격'],
|
||
meaningUpright: '거짓 기반 위의 구조가 갑자기 무너지는 시기. 충격은 크나 진실의 자리를 만든다.',
|
||
meaningReversed: '붕괴를 미루거나 외면해 더 큰 충격을 키울 수 있는 상태.' },
|
||
{ id: 17, slug: 'the-star', name: '별', nameEn: 'The Star', element: 'air',
|
||
keywords: ['희망','영감','치유','평온','신뢰'],
|
||
reversedKeywords: ['희망 상실','자기 의심','단절감'],
|
||
meaningUpright: '폭풍 뒤의 평온. 영감이 회복되고 길게 볼 힘이 돌아오는 시기.',
|
||
meaningReversed: '의심과 무력감으로 빛이 잘 보이지 않는 상태.' },
|
||
{ id: 18, slug: 'the-moon', name: '달', nameEn: 'The Moon', element: 'water',
|
||
keywords: ['직관','무의식','환영','불안','꿈'],
|
||
reversedKeywords: ['혼란 해소','진실 드러남','직관 회복'],
|
||
meaningUpright: '명확하지 않은 신호와 감정의 파도. 직관을 따르되 환상은 분별해야 하는 시기.',
|
||
meaningReversed: '안개가 걷히며 가려졌던 진실이 드러나는 상태.' },
|
||
{ id: 19, slug: 'the-sun', name: '태양', nameEn: 'The Sun', element: 'fire',
|
||
keywords: ['기쁨','성공','명료성','활력','진실'],
|
||
reversedKeywords: ['과신','단편적 기쁨','피상적 성공'],
|
||
meaningUpright: '명료하고 따뜻한 시기. 노력의 결실이 분명히 드러난다.',
|
||
meaningReversed: '겉만 환한 기쁨이거나, 자만으로 본질을 놓칠 위험.' },
|
||
{ id: 20, slug: 'judgement', name: '심판', nameEn: 'Judgement', element: 'fire',
|
||
keywords: ['각성','부름','재평가','부활','결단'],
|
||
reversedKeywords: ['자기 비판','부름 무시','과거에 묶임'],
|
||
meaningUpright: '오랜 흐름을 정산하고 새 부름에 응답하는 시기. 결단의 순간.',
|
||
meaningReversed: '과거의 비판이나 미련에 묶여 새 길로 나서지 못하는 상태.' },
|
||
{ id: 21, slug: 'the-world', name: '세계', nameEn: 'The World', element: 'earth',
|
||
keywords: ['완성','통합','성취','순환의 닫힘','전체성'],
|
||
reversedKeywords: ['미완성','마무리 지연','반복'],
|
||
meaningUpright: '한 사이클의 완성과 통합. 다음 시작을 위한 단단한 기반이 마련된다.',
|
||
meaningReversed: '마무리가 늦어지거나 반복으로 인해 다음 단계로 나아가지 못하는 상태.' },
|
||
];
|
||
|
||
const SUITS = [
|
||
{ suit: 'wands', element: 'fire', kr: '완드' },
|
||
{ suit: 'cups', element: 'water', kr: '컵' },
|
||
{ suit: 'swords', element: 'air', kr: '소드' },
|
||
{ suit: 'pentacles', element: 'earth', kr: '펜타클' },
|
||
];
|
||
|
||
const RANK_NAMES = ['에이스', '2', '3', '4', '5', '6', '7', '8', '9', '10', '시종', '기사', '여왕', '왕'];
|
||
const RANK_EN = ['Ace', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Page', 'Knight', 'Queen', 'King'];
|
||
const SUIT_NAMES_EN = { wands: 'Wands', cups: 'Cups', swords: 'Swords', pentacles: 'Pentacles' };
|
||
|
||
const SUIT_KEYWORDS = {
|
||
wands: { up: ['열정','창조','행동','의지'], down: ['고갈','지연','분열'], theme: '의지와 창조의 불꽃' },
|
||
cups: { up: ['감정','관계','직관','사랑'], down: ['감정 정체','상실','오해'], theme: '감정과 관계의 흐름' },
|
||
swords: { up: ['사고','갈등','명료성','진실'], down: ['혼란','과도한 사고','오해'], theme: '사고와 결단의 칼날' },
|
||
pentacles: { up: ['물질','일','안정','성취'], down: ['결핍','정체','집착'], theme: '물질과 일의 토대' },
|
||
};
|
||
|
||
function buildMinor() {
|
||
const out = [];
|
||
let id = 22;
|
||
for (const { suit, element, kr } of SUITS) {
|
||
for (let rank = 1; rank <= 14; rank++) {
|
||
const krName = `${kr} ${RANK_NAMES[rank - 1]}`;
|
||
const enName = `${RANK_EN[rank - 1]} of ${SUIT_NAMES_EN[suit]}`;
|
||
const kw = SUIT_KEYWORDS[suit];
|
||
out.push({
|
||
id: id++,
|
||
slug: `${RANK_EN[rank - 1].toLowerCase()}-of-${suit}`,
|
||
name: krName,
|
||
nameEn: enName,
|
||
arcana: 'minor',
|
||
suit,
|
||
rank,
|
||
element,
|
||
keywords: [...kw.up, `${kr} ${rank}의 단계`],
|
||
reversedKeywords: [...kw.down, `${kr} ${rank} 정체`],
|
||
meaningUpright: `${kw.theme} — ${krName} 단계. ${kw.up.join(', ')} 의 흐름이 작동하는 시점.`,
|
||
meaningReversed: `${kr} 흐름의 정체 또는 왜곡. ${kw.down.join(', ')} 양상이 드러남.`,
|
||
});
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export const TAROT_DECK = [
|
||
...MAJOR_ARCANA.map((c) => ({
|
||
...c,
|
||
arcana: 'major',
|
||
image: cardImage(c.slug),
|
||
})),
|
||
...buildMinor().map((c) => ({ ...c, image: cardImage(c.slug) })),
|
||
];
|
||
|
||
export const SPREADS = {
|
||
one_card: {
|
||
id: 'one_card',
|
||
name: '오늘의 카드',
|
||
positions: [{ idx: 0, label: '오늘' }],
|
||
},
|
||
three_card: {
|
||
id: 'three_card',
|
||
name: '3장 스프레드',
|
||
positions: [
|
||
{ idx: 0, label: '과거' },
|
||
{ idx: 1, label: '현재' },
|
||
{ idx: 2, label: '미래' },
|
||
],
|
||
},
|
||
};
|
||
|
||
export const CATEGORIES = ['연애', '일·커리어', '관계', '재물', '건강', '일반'];
|
||
|
||
export function findCard(slug) {
|
||
return TAROT_DECK.find((c) => c.slug === slug) || null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 통과 확인**
|
||
|
||
`cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm test -- src/pages/tarot/data/cards.test.js --run`
|
||
Expected: 7 passed
|
||
|
||
- [ ] **Step 5: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/pages/tarot/data/cards.js src/pages/tarot/data/cards.test.js
|
||
git commit -m "feat(tarot): 카드 78장 메타데이터 (메이저 22 + 마이너 56) (T8)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: useTarotShuffle hook
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/tarot/hooks/useTarotShuffle.js`
|
||
- Create: `web-ui/src/pages/tarot/hooks/useTarotShuffle.test.js`
|
||
|
||
- [ ] **Step 1: 실패 테스트**
|
||
|
||
`web-ui/src/pages/tarot/hooks/useTarotShuffle.test.js`:
|
||
|
||
```javascript
|
||
import { describe, it, expect } from 'vitest';
|
||
import { renderHook, act } from '@testing-library/react';
|
||
import { useTarotShuffle, fisherYates } from './useTarotShuffle';
|
||
|
||
describe('fisherYates', () => {
|
||
it('원본 길이를 유지하고 모든 원소를 포함한다', () => {
|
||
const src = [1, 2, 3, 4, 5, 6, 7, 8];
|
||
const out = fisherYates(src);
|
||
expect(out).toHaveLength(8);
|
||
expect([...out].sort()).toEqual(src);
|
||
});
|
||
|
||
it('새 배열을 반환한다 (mutation 없음)', () => {
|
||
const src = [1, 2, 3];
|
||
const out = fisherYates(src);
|
||
expect(src).toEqual([1, 2, 3]);
|
||
expect(out).not.toBe(src);
|
||
});
|
||
});
|
||
|
||
describe('useTarotShuffle', () => {
|
||
it('16장 슬라이스를 반환한다', () => {
|
||
const deck = Array.from({ length: 78 }, (_, i) => ({ id: i, slug: `c${i}` }));
|
||
const { result } = renderHook(() => useTarotShuffle(deck, 16));
|
||
expect(result.current.slice).toHaveLength(16);
|
||
});
|
||
|
||
it('reshuffle 시 새 슬라이스를 만든다 (대부분 다름)', () => {
|
||
const deck = Array.from({ length: 78 }, (_, i) => ({ id: i, slug: `c${i}` }));
|
||
const { result } = renderHook(() => useTarotShuffle(deck, 16));
|
||
const first = result.current.slice.map((c) => c.id);
|
||
act(() => result.current.reshuffle());
|
||
const second = result.current.slice.map((c) => c.id);
|
||
// 무작위지만 16장 모두 같을 확률은 사실상 0
|
||
const overlap = first.filter((id) => second.includes(id)).length;
|
||
expect(overlap).toBeLessThan(16);
|
||
});
|
||
|
||
it('reversed 플래그를 카드에 부여한다', () => {
|
||
const deck = Array.from({ length: 78 }, (_, i) => ({ id: i, slug: `c${i}` }));
|
||
const { result } = renderHook(() => useTarotShuffle(deck, 16));
|
||
for (const card of result.current.slice) {
|
||
expect(card).toHaveProperty('reversed');
|
||
expect(typeof card.reversed).toBe('boolean');
|
||
}
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 실패 확인**
|
||
|
||
`npm test -- src/pages/tarot/hooks/useTarotShuffle.test.js --run`
|
||
Expected: 모듈 없음 에러
|
||
|
||
- [ ] **Step 3: 구현**
|
||
|
||
`web-ui/src/pages/tarot/hooks/useTarotShuffle.js`:
|
||
|
||
```javascript
|
||
import { useCallback, useState } from 'react';
|
||
|
||
export function fisherYates(input) {
|
||
const a = [...input];
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[a[i], a[j]] = [a[j], a[i]];
|
||
}
|
||
return a;
|
||
}
|
||
|
||
function buildShuffle(deck, size) {
|
||
return fisherYates(deck).slice(0, size).map((c) => ({
|
||
...c,
|
||
reversed: Math.random() < 0.5,
|
||
}));
|
||
}
|
||
|
||
export function useTarotShuffle(deck, size = 16) {
|
||
const [slice, setSlice] = useState(() => buildShuffle(deck, size));
|
||
const reshuffle = useCallback(() => {
|
||
setSlice(buildShuffle(deck, size));
|
||
}, [deck, size]);
|
||
return { slice, reshuffle };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 통과 확인**
|
||
|
||
`npm test -- src/pages/tarot/hooks/useTarotShuffle.test.js --run`
|
||
Expected: 4 passed
|
||
|
||
- [ ] **Step 5: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/pages/tarot/hooks/useTarotShuffle.js src/pages/tarot/hooks/useTarotShuffle.test.js
|
||
git commit -m "feat(tarot): useTarotShuffle hook (Fisher-Yates + reversed 플래그) (T9)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: useTarotReading hook (reference 빌더 + API 호출)
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/tarot/hooks/useTarotReading.js`
|
||
- Create: `web-ui/src/pages/tarot/hooks/useTarotReading.test.js`
|
||
- Modify: `web-ui/src/api.js`
|
||
|
||
- [ ] **Step 1: api.js에 apiPatch + tarot helper 추가**
|
||
|
||
`web-ui/src/api.js` `apiPut` 함수 다음에 추가:
|
||
|
||
```javascript
|
||
export async function apiPatch(path, body) {
|
||
const res = await fetch(toApiUrl(path), {
|
||
method: "PATCH",
|
||
headers: {
|
||
"Accept": "application/json",
|
||
...(body ? { "Content-Type": "application/json" } : {}),
|
||
},
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => "");
|
||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
```
|
||
|
||
파일 끝에 tarot helpers 추가:
|
||
|
||
```javascript
|
||
// --- Tarot Lab ---
|
||
|
||
export function tarotInterpret(body) {
|
||
return apiPost('/api/agent-office/tarot/interpret', body);
|
||
}
|
||
|
||
export function tarotSaveReading(body) {
|
||
return apiPost('/api/agent-office/tarot/readings', body);
|
||
}
|
||
|
||
export function tarotListReadings({ page = 1, size = 20, favorite, spread_type, category } = {}) {
|
||
const qs = new URLSearchParams({ page: String(page), size: String(size) });
|
||
if (favorite !== undefined) qs.set('favorite', favorite ? 'true' : 'false');
|
||
if (spread_type) qs.set('spread_type', spread_type);
|
||
if (category) qs.set('category', category);
|
||
return apiGet(`/api/agent-office/tarot/readings?${qs.toString()}`);
|
||
}
|
||
|
||
export function tarotGetReading(id) {
|
||
return apiGet(`/api/agent-office/tarot/readings/${id}`);
|
||
}
|
||
|
||
export function tarotPatchReading(id, body) {
|
||
return apiPatch(`/api/agent-office/tarot/readings/${id}`, body);
|
||
}
|
||
|
||
export function tarotDeleteReading(id) {
|
||
return apiDelete(`/api/agent-office/tarot/readings/${id}`);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 실패 테스트**
|
||
|
||
`web-ui/src/pages/tarot/hooks/useTarotReading.test.js`:
|
||
|
||
```javascript
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||
import { buildReferenceBlock, buildContextMeta, useTarotReading } from './useTarotReading';
|
||
import * as api from '../../../api';
|
||
|
||
const FOOL = { id: 0, slug: 'the-fool', name: '바보', nameEn: 'The Fool',
|
||
arcana: 'major', element: 'air',
|
||
keywords: ['새로운 시작','도약'], reversedKeywords: ['무모함'],
|
||
meaningUpright: 'M-up', meaningReversed: 'M-rev' };
|
||
const LOVERS = { id: 6, slug: 'the-lovers', name: '연인', nameEn: 'The Lovers',
|
||
arcana: 'major', element: 'air',
|
||
keywords: ['사랑'], reversedKeywords: ['갈등'],
|
||
meaningUpright: 'L-up', meaningReversed: 'L-rev' };
|
||
const TEN_OF_CUPS = { id: 31, slug: 'ten-of-cups', name: '컵 10', nameEn: 'Ten of Cups',
|
||
arcana: 'minor', suit: 'cups', rank: 10, element: 'water',
|
||
keywords: ['정서적 충만'], reversedKeywords: ['갈등'],
|
||
meaningUpright: 'T-up', meaningReversed: 'T-rev' };
|
||
|
||
describe('buildReferenceBlock', () => {
|
||
it('각 카드를 번호·위치·정역과 함께 포함한다', () => {
|
||
const block = buildReferenceBlock([
|
||
{ card: FOOL, position: '과거', reversed: false },
|
||
{ card: LOVERS, position: '현재', reversed: true },
|
||
]);
|
||
expect(block).toContain('1. 위치: 과거');
|
||
expect(block).toContain('The Fool (정방향)');
|
||
expect(block).toContain('2. 위치: 현재');
|
||
expect(block).toContain('The Lovers (역방향)');
|
||
expect(block).toContain('M-up');
|
||
expect(block).toContain('L-rev');
|
||
});
|
||
});
|
||
|
||
describe('buildContextMeta', () => {
|
||
it('메이저:마이너 비율과 원소 분포·정역 흐름을 계산한다', () => {
|
||
const meta = buildContextMeta([
|
||
{ card: FOOL, position: '과거', reversed: false },
|
||
{ card: LOVERS, position: '현재', reversed: true },
|
||
{ card: TEN_OF_CUPS, position: '미래', reversed: false },
|
||
]);
|
||
expect(meta.major_minor_ratio).toBe('2:1');
|
||
expect(meta.element_distribution.air).toBe(2);
|
||
expect(meta.element_distribution.water).toBe(1);
|
||
expect(meta.orientation_flow).toBe('upright→reversed→upright');
|
||
});
|
||
});
|
||
|
||
describe('useTarotReading', () => {
|
||
beforeEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('runInterpretAndSave 시 interpret → save 순서로 호출한다', async () => {
|
||
vi.spyOn(api, 'tarotInterpret').mockResolvedValue({
|
||
interpretation_json: { summary: 'S', cards: [], interactions: [], advice: 'A', warning: null, confidence: 'high' },
|
||
model: 'm', tokens_in: 1, tokens_out: 2, cost_usd: 0.01, latency_ms: 10, reroll_count: 0,
|
||
});
|
||
vi.spyOn(api, 'tarotSaveReading').mockResolvedValue({ id: 42, created_at: 't' });
|
||
|
||
const { result } = renderHook(() => useTarotReading());
|
||
await act(async () => {
|
||
await result.current.runInterpretAndSave({
|
||
spread_type: 'one_card',
|
||
category: '일반',
|
||
question: 'Q',
|
||
picks: [{ card: FOOL, position: '오늘', reversed: false }],
|
||
});
|
||
});
|
||
expect(api.tarotInterpret).toHaveBeenCalledTimes(1);
|
||
expect(api.tarotSaveReading).toHaveBeenCalledTimes(1);
|
||
expect(result.current.readingId).toBe(42);
|
||
expect(result.current.interpretation.confidence).toBe('high');
|
||
});
|
||
|
||
it('interpret 실패 시 readingId는 null', async () => {
|
||
vi.spyOn(api, 'tarotInterpret').mockRejectedValue(new Error('boom'));
|
||
const saveSpy = vi.spyOn(api, 'tarotSaveReading');
|
||
|
||
const { result } = renderHook(() => useTarotReading());
|
||
await act(async () => {
|
||
try {
|
||
await result.current.runInterpretAndSave({
|
||
spread_type: 'one_card', category: null, question: null,
|
||
picks: [{ card: FOOL, position: '오늘', reversed: false }],
|
||
});
|
||
} catch { /* expected */ }
|
||
});
|
||
expect(saveSpy).not.toHaveBeenCalled();
|
||
expect(result.current.readingId).toBe(null);
|
||
expect(result.current.error).toBeTruthy();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 실패 확인**
|
||
|
||
`npm test -- src/pages/tarot/hooks/useTarotReading.test.js --run`
|
||
Expected: 모듈 없음 에러
|
||
|
||
- [ ] **Step 4: 구현**
|
||
|
||
`web-ui/src/pages/tarot/hooks/useTarotReading.js`:
|
||
|
||
```javascript
|
||
import { useCallback, useState } from 'react';
|
||
import { tarotInterpret, tarotSaveReading } from '../../../api';
|
||
|
||
const ELEMENTS = ['air', 'water', 'fire', 'earth'];
|
||
|
||
export function buildReferenceBlock(picks) {
|
||
return picks
|
||
.map((p, i) => {
|
||
const c = p.card;
|
||
const orient = p.reversed ? '역방향' : '정방향';
|
||
const arcana = c.arcana === 'major'
|
||
? `Major (${c.id})`
|
||
: `Minor (${c.suit}, ${c.rank})`;
|
||
const kw = p.reversed ? c.reversedKeywords : c.keywords;
|
||
const meaning = p.reversed ? c.meaningReversed : c.meaningUpright;
|
||
return [
|
||
`## ${i + 1}. 위치: ${p.position} | 카드: ${c.nameEn} (${orient})`,
|
||
`- 아르카나: ${arcana}`,
|
||
`- 원소: ${c.element || '없음'}`,
|
||
`- ${orient} 키워드: ${(kw || []).join(', ')}`,
|
||
`- ${orient} 의미: ${meaning}`,
|
||
].join('\n');
|
||
})
|
||
.join('\n\n');
|
||
}
|
||
|
||
export function buildContextMeta(picks) {
|
||
const majors = picks.filter((p) => p.card.arcana === 'major').length;
|
||
const minors = picks.length - majors;
|
||
const elementDist = ELEMENTS.reduce((acc, e) => ({ ...acc, [e]: 0 }), {});
|
||
for (const p of picks) {
|
||
const e = p.card.element;
|
||
if (e && elementDist[e] !== undefined) elementDist[e] += 1;
|
||
}
|
||
const flow = picks.map((p) => (p.reversed ? 'reversed' : 'upright')).join('→');
|
||
return {
|
||
major_minor_ratio: `${majors}:${minors}`,
|
||
element_distribution: elementDist,
|
||
orientation_flow: flow,
|
||
};
|
||
}
|
||
|
||
export function useTarotReading() {
|
||
const [status, setStatus] = useState('idle');
|
||
const [interpretation, setInterpretation] = useState(null);
|
||
const [readingId, setReadingId] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
|
||
const runInterpretAndSave = useCallback(async ({ spread_type, category, question, picks }) => {
|
||
setStatus('interpreting');
|
||
setError(null);
|
||
setInterpretation(null);
|
||
setReadingId(null);
|
||
|
||
const cards = picks.map((p) => ({
|
||
position: p.position,
|
||
card_id: p.card.slug,
|
||
reversed: !!p.reversed,
|
||
}));
|
||
const reference = buildReferenceBlock(picks);
|
||
const meta = buildContextMeta(picks);
|
||
|
||
let interp;
|
||
try {
|
||
interp = await tarotInterpret({
|
||
spread_type, category, question, cards,
|
||
cards_reference: reference,
|
||
context_meta: meta,
|
||
});
|
||
} catch (e) {
|
||
setStatus('failed');
|
||
setError(e.message || String(e));
|
||
throw e;
|
||
}
|
||
|
||
setInterpretation(interp.interpretation_json);
|
||
setStatus('saving');
|
||
|
||
try {
|
||
const saved = await tarotSaveReading({
|
||
spread_type, category, question, cards,
|
||
interpretation_json: interp.interpretation_json,
|
||
model: interp.model,
|
||
tokens_in: interp.tokens_in,
|
||
tokens_out: interp.tokens_out,
|
||
cost_usd: interp.cost_usd,
|
||
confidence: interp.interpretation_json.confidence,
|
||
});
|
||
setReadingId(saved.id);
|
||
setStatus('done');
|
||
} catch (e) {
|
||
setStatus('save_failed');
|
||
setError(e.message || String(e));
|
||
}
|
||
|
||
return interp.interpretation_json;
|
||
}, []);
|
||
|
||
return { status, interpretation, readingId, error, runInterpretAndSave };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 통과 확인**
|
||
|
||
`npm test -- src/pages/tarot/hooks/useTarotReading.test.js --run`
|
||
Expected: 4 passed (buildReferenceBlock 1 + buildContextMeta 1 + useTarotReading 2)
|
||
|
||
- [ ] **Step 6: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/api.js src/pages/tarot/hooks/useTarotReading.js src/pages/tarot/hooks/useTarotReading.test.js
|
||
git commit -m "feat(tarot): useTarotReading hook + api helper 6종 (T10)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: TarotCard / CardGrid / SpreadSlots / InterpretationPanel 컴포넌트
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/tarot/components/TarotCard.jsx`
|
||
- Create: `web-ui/src/pages/tarot/components/CardGrid.jsx`
|
||
- Create: `web-ui/src/pages/tarot/components/SpreadSlots.jsx`
|
||
- Create: `web-ui/src/pages/tarot/components/InterpretationPanel.jsx`
|
||
|
||
- [ ] **Step 1: TarotCard.jsx**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
|
||
export default function TarotCard({
|
||
card, reversed = false, size = 'md', faceDown = false,
|
||
clickable = false, onClick, label,
|
||
}) {
|
||
const sizeClass = size === 'sm' ? 'tarot-card--sm' : size === 'lg' ? 'tarot-card--lg' : 'tarot-card--md';
|
||
|
||
const handleClick = (e) => {
|
||
if (!clickable) return;
|
||
onClick?.(card, e);
|
||
};
|
||
|
||
if (faceDown || !card) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`tarot-card tarot-card--back ${sizeClass} ${clickable ? 'is-clickable' : ''}`}
|
||
onClick={handleClick}
|
||
aria-label={label || '카드 뒷면'}
|
||
disabled={!clickable}
|
||
>
|
||
<img src="/images/tarot/card_back.svg" alt="" draggable={false} />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const styleClass = reversed ? 'tarot-card--reversed' : '';
|
||
const onImgError = (e) => { e.currentTarget.style.display = 'none'; };
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`tarot-card tarot-card--face ${sizeClass} ${styleClass} ${clickable ? 'is-clickable' : ''}`}
|
||
onClick={handleClick}
|
||
aria-label={`${card.name}${reversed ? ' 역방향' : ''}`}
|
||
disabled={!clickable}
|
||
>
|
||
<div className="tarot-card__inner">
|
||
<img src={card.image} alt="" onError={onImgError} draggable={false} />
|
||
<div className="tarot-card__fallback">
|
||
<div className="tarot-card__symbol">{card.arcana === 'major' ? '✦' : '◆'}</div>
|
||
<div className="tarot-card__name">{card.name}</div>
|
||
<div className="tarot-card__name-en">{card.nameEn}</div>
|
||
</div>
|
||
</div>
|
||
{label && <div className="tarot-card__label">{label}</div>}
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: CardGrid.jsx**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import TarotCard from './TarotCard';
|
||
|
||
export default function CardGrid({ slice, onPick, disabledIds = [] }) {
|
||
return (
|
||
<div className="tarot-grid">
|
||
{slice.map((card) => {
|
||
const disabled = disabledIds.includes(card.slug);
|
||
return (
|
||
<TarotCard
|
||
key={card.slug}
|
||
card={card}
|
||
faceDown
|
||
clickable={!disabled}
|
||
onClick={() => !disabled && onPick(card)}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: SpreadSlots.jsx**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import TarotCard from './TarotCard';
|
||
|
||
export default function SpreadSlots({ spread, picks, onCardClick }) {
|
||
return (
|
||
<div className="tarot-slots">
|
||
{spread.positions.map((pos) => {
|
||
const pick = picks[pos.idx];
|
||
return (
|
||
<div key={pos.idx} className="tarot-slots__cell">
|
||
<div className="tarot-slots__label">{pos.label}</div>
|
||
{pick ? (
|
||
<TarotCard
|
||
card={pick.card}
|
||
reversed={pick.reversed}
|
||
clickable
|
||
onClick={() => onCardClick(pos.idx)}
|
||
/>
|
||
) : (
|
||
<div className="tarot-slots__empty">_</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: InterpretationPanel.jsx**
|
||
|
||
```jsx
|
||
import React, { useState } from 'react';
|
||
|
||
function ConfidenceBadge({ level }) {
|
||
if (!level) return null;
|
||
const cls = level === 'high' ? 'is-high' : level === 'low' ? 'is-low' : 'is-medium';
|
||
const text = level === 'high' ? '높음' : level === 'low' ? '낮음' : '보통';
|
||
return <span className={`tarot-confidence ${cls}`}>확신 {text}</span>;
|
||
}
|
||
|
||
export default function InterpretationPanel({ interpretation, selectedCard, focusCardId }) {
|
||
const [showEvidence, setShowEvidence] = useState(true);
|
||
|
||
if (!interpretation) {
|
||
return (
|
||
<aside className="tarot-panel tarot-panel--empty">
|
||
<p>카드를 모두 뽑은 후 AI 해석을 시작하세요.</p>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
const cardDetail = focusCardId
|
||
? (interpretation.cards || []).find((c) => c.card === focusCardId)
|
||
: null;
|
||
|
||
return (
|
||
<aside className="tarot-panel">
|
||
{selectedCard && (
|
||
<header className="tarot-panel__head">
|
||
<h3 className="tarot-panel__title">{selectedCard.name}</h3>
|
||
<p className="tarot-panel__sub">{selectedCard.nameEn}</p>
|
||
<div className="tarot-panel__chips">
|
||
{(selectedCard.keywords || []).slice(0, 4).map((k) => (
|
||
<span key={k} className="tarot-chip">{k}</span>
|
||
))}
|
||
</div>
|
||
</header>
|
||
)}
|
||
|
||
{cardDetail && (
|
||
<section className="tarot-panel__section">
|
||
<h4>이 위치의 해석</h4>
|
||
<p>{cardDetail.interpretation}</p>
|
||
<p className="tarot-panel__advice">💡 {cardDetail.advice}</p>
|
||
<button
|
||
type="button"
|
||
className="tarot-panel__toggle"
|
||
onClick={() => setShowEvidence((v) => !v)}
|
||
>
|
||
{showEvidence ? '근거 접기' : '근거 펼치기'}
|
||
</button>
|
||
{showEvidence && cardDetail.evidence && (
|
||
<dl className="tarot-evidence">
|
||
<dt>카드 의미</dt>
|
||
<dd>{cardDetail.evidence.card_meaning_used}</dd>
|
||
<dt>위치 결합</dt>
|
||
<dd>{cardDetail.evidence.position_logic}</dd>
|
||
<dt>카테고리 관점</dt>
|
||
<dd>{cardDetail.evidence.category_lens}</dd>
|
||
</dl>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
<section className="tarot-panel__section">
|
||
<h4>종합 해석 <ConfidenceBadge level={interpretation.confidence} /></h4>
|
||
<p>{interpretation.summary}</p>
|
||
<p className="tarot-panel__advice">💡 {interpretation.advice}</p>
|
||
{interpretation.warning && (
|
||
<p className="tarot-panel__warning">⚠️ {interpretation.warning}</p>
|
||
)}
|
||
</section>
|
||
|
||
{(interpretation.interactions || []).length > 0 && (
|
||
<section className="tarot-panel__section">
|
||
<h4>카드 상호작용</h4>
|
||
<ul className="tarot-interactions">
|
||
{interpretation.interactions.map((it, i) => (
|
||
<li key={i}>
|
||
<span className={`tarot-interaction-type tarot-interaction-type--${it.type}`}>
|
||
{it.type === 'synergy' ? '시너지' : it.type === 'conflict' ? '충돌' : '전환'}
|
||
</span>
|
||
{' '}
|
||
<strong>{(it.between || []).join(' ↔ ')}</strong>
|
||
<p>{it.explanation}</p>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
)}
|
||
</aside>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 빌드 검증**
|
||
|
||
`cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run build`
|
||
Expected: 성공 (TarotCard 등이 아직 import되지 않아도 syntax 검증)
|
||
|
||
- [ ] **Step 6: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/pages/tarot/components/
|
||
git commit -m "feat(tarot): TarotCard·CardGrid·SpreadSlots·InterpretationPanel 컴포넌트 (T11)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Tarot.css (디자인 토큰)
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/tarot/Tarot.css`
|
||
|
||
- [ ] **Step 1: CSS 작성**
|
||
|
||
`web-ui/src/pages/tarot/Tarot.css`:
|
||
|
||
```css
|
||
/* Tarot Lab — 다크 보라+금 톤 */
|
||
.tarot {
|
||
--tarot-bg-1: #0a0420;
|
||
--tarot-bg-2: #1a0d2e;
|
||
--tarot-bg-3: #2a1648;
|
||
--tarot-gold: #d4af37;
|
||
--tarot-gold-dim: rgba(212, 175, 55, .35);
|
||
--tarot-text: #e9e2ff;
|
||
--tarot-text-dim: rgba(233, 226, 255, .7);
|
||
--tarot-card-w: 100px;
|
||
--tarot-card-h: 150px;
|
||
|
||
min-height: 100vh;
|
||
background: linear-gradient(180deg, var(--tarot-bg-1), var(--tarot-bg-2) 50%, var(--tarot-bg-3));
|
||
color: var(--tarot-text);
|
||
font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||
padding: 0;
|
||
}
|
||
|
||
.tarot--landing {
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.tarot__hero-video {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
z-index: 0;
|
||
}
|
||
|
||
.tarot__hero-poster {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
z-index: 0;
|
||
}
|
||
|
||
.tarot__hero-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(180deg, rgba(15,4,40,.5), rgba(15,4,40,.85));
|
||
z-index: 1;
|
||
}
|
||
|
||
.tarot__hero-content {
|
||
position: relative;
|
||
z-index: 2;
|
||
padding: 60px 40px 80px;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.tarot__brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-family: 'Cormorant Garamond', 'Noto Serif KR', serif;
|
||
font-size: 18px;
|
||
letter-spacing: 4px;
|
||
color: var(--tarot-gold);
|
||
margin-bottom: 40px;
|
||
}
|
||
|
||
.tarot__nav {
|
||
display: flex;
|
||
gap: 24px;
|
||
font-size: 14px;
|
||
color: var(--tarot-text-dim);
|
||
}
|
||
.tarot__nav a {
|
||
color: inherit;
|
||
text-decoration: none;
|
||
}
|
||
.tarot__nav a:hover {
|
||
color: var(--tarot-gold);
|
||
}
|
||
|
||
.tarot__h1 {
|
||
font-family: 'Cormorant Garamond', 'Noto Serif KR', serif;
|
||
font-size: clamp(36px, 5vw, 64px);
|
||
line-height: 1.1;
|
||
margin: 60px 0 20px;
|
||
color: var(--tarot-text);
|
||
}
|
||
|
||
.tarot__sub {
|
||
font-size: 16px;
|
||
color: var(--tarot-text-dim);
|
||
max-width: 520px;
|
||
margin-bottom: 36px;
|
||
}
|
||
|
||
.tarot__cta-row {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-bottom: 60px;
|
||
}
|
||
|
||
.tarot__cta {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 14px 28px;
|
||
border: 1px solid var(--tarot-gold);
|
||
border-radius: 6px;
|
||
background: rgba(212, 175, 55, .08);
|
||
color: var(--tarot-gold);
|
||
text-decoration: none;
|
||
font-size: 15px;
|
||
letter-spacing: 1px;
|
||
transition: background .2s;
|
||
}
|
||
.tarot__cta:hover {
|
||
background: rgba(212, 175, 55, .18);
|
||
}
|
||
.tarot__cta--secondary {
|
||
border-color: rgba(233, 226, 255, .25);
|
||
color: var(--tarot-text);
|
||
}
|
||
|
||
.tarot__tier-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px;
|
||
margin-top: 40px;
|
||
}
|
||
|
||
.tarot__tier {
|
||
padding: 24px;
|
||
border: 1px solid rgba(255, 255, 255, .08);
|
||
border-radius: 10px;
|
||
background: rgba(255, 255, 255, .03);
|
||
transition: transform .2s, border-color .2s;
|
||
}
|
||
.tarot__tier:hover {
|
||
transform: translateY(-4px);
|
||
border-color: var(--tarot-gold-dim);
|
||
}
|
||
.tarot__tier h3 {
|
||
font-family: 'Cormorant Garamond', 'Noto Serif KR', serif;
|
||
font-size: 20px;
|
||
color: var(--tarot-gold);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
/* ===== Reading page (3-step) ===== */
|
||
.tarot-reading {
|
||
max-width: 1280px;
|
||
margin: 0 auto;
|
||
padding: 40px 24px;
|
||
display: grid;
|
||
grid-template-columns: 320px 1fr 380px;
|
||
gap: 24px;
|
||
}
|
||
|
||
.tarot-reading__col {
|
||
background: rgba(255, 255, 255, .04);
|
||
border: 1px solid rgba(255, 255, 255, .06);
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
}
|
||
|
||
.tarot-reading__step-label {
|
||
font-size: 12px;
|
||
letter-spacing: 2px;
|
||
color: var(--tarot-gold);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.tarot-reading__textarea {
|
||
width: 100%;
|
||
min-height: 96px;
|
||
background: rgba(0, 0, 0, .25);
|
||
border: 1px solid rgba(255, 255, 255, .1);
|
||
color: var(--tarot-text);
|
||
padding: 10px;
|
||
border-radius: 6px;
|
||
font-family: inherit;
|
||
resize: vertical;
|
||
}
|
||
|
||
.tarot-reading__chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.tarot-chip {
|
||
padding: 6px 12px;
|
||
font-size: 13px;
|
||
border: 1px solid rgba(255, 255, 255, .15);
|
||
border-radius: 16px;
|
||
background: transparent;
|
||
color: var(--tarot-text-dim);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
.tarot-chip.is-active,
|
||
.tarot-chip:hover {
|
||
border-color: var(--tarot-gold);
|
||
color: var(--tarot-gold);
|
||
}
|
||
|
||
.tarot-reading__radio-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.tarot-reading__primary {
|
||
width: 100%;
|
||
padding: 12px;
|
||
margin-top: 12px;
|
||
background: var(--tarot-gold);
|
||
color: #1a0d2e;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
}
|
||
.tarot-reading__primary:disabled {
|
||
opacity: .4;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* ===== Card primitive ===== */
|
||
.tarot-card {
|
||
position: relative;
|
||
width: var(--tarot-card-w);
|
||
height: var(--tarot-card-h);
|
||
border: none;
|
||
padding: 0;
|
||
background: transparent;
|
||
cursor: default;
|
||
transition: transform .2s;
|
||
}
|
||
.tarot-card.is-clickable {
|
||
cursor: pointer;
|
||
}
|
||
.tarot-card.is-clickable:hover {
|
||
transform: translateY(-6px);
|
||
}
|
||
|
||
.tarot-card img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--tarot-gold-dim);
|
||
}
|
||
|
||
.tarot-card--reversed .tarot-card__inner {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.tarot-card__inner {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.tarot-card__fallback {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
background: linear-gradient(180deg, #1a0d2e, #0a0420);
|
||
border-radius: 8px;
|
||
border: 1px solid var(--tarot-gold-dim);
|
||
color: var(--tarot-text);
|
||
font-family: 'Cormorant Garamond', serif;
|
||
z-index: -1; /* 이미지가 있으면 가려짐, 없거나 onError로 사라지면 노출 */
|
||
padding: 8px;
|
||
}
|
||
.tarot-card__symbol {
|
||
font-size: 28px;
|
||
color: var(--tarot-gold);
|
||
margin-bottom: 8px;
|
||
}
|
||
.tarot-card__name {
|
||
font-size: 13px;
|
||
color: var(--tarot-text);
|
||
}
|
||
.tarot-card__name-en {
|
||
font-size: 10px;
|
||
color: var(--tarot-text-dim);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.tarot-card__label {
|
||
position: absolute;
|
||
bottom: -22px;
|
||
left: 0;
|
||
right: 0;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: var(--tarot-text-dim);
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.tarot-card--sm { --tarot-card-w: 60px; --tarot-card-h: 90px; }
|
||
.tarot-card--md { --tarot-card-w: 100px; --tarot-card-h: 150px; }
|
||
.tarot-card--lg { --tarot-card-w: 140px; --tarot-card-h: 210px; }
|
||
|
||
/* ===== Grid + Slots ===== */
|
||
.tarot-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, var(--tarot-card-w));
|
||
gap: 12px;
|
||
justify-content: center;
|
||
}
|
||
|
||
.tarot-slots {
|
||
display: flex;
|
||
gap: 16px;
|
||
justify-content: center;
|
||
margin-top: 40px;
|
||
}
|
||
.tarot-slots__cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.tarot-slots__label {
|
||
font-size: 12px;
|
||
letter-spacing: 2px;
|
||
color: var(--tarot-gold);
|
||
text-transform: uppercase;
|
||
}
|
||
.tarot-slots__empty {
|
||
width: var(--tarot-card-w);
|
||
height: var(--tarot-card-h);
|
||
border: 1px dashed rgba(212, 175, 55, .3);
|
||
border-radius: 8px;
|
||
display: grid;
|
||
place-items: center;
|
||
color: rgba(212, 175, 55, .3);
|
||
font-size: 24px;
|
||
}
|
||
|
||
/* ===== Panel ===== */
|
||
.tarot-panel {
|
||
background: rgba(255, 255, 255, .04);
|
||
border: 1px solid rgba(255, 255, 255, .06);
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
}
|
||
.tarot-panel--empty {
|
||
color: var(--tarot-text-dim);
|
||
text-align: center;
|
||
}
|
||
.tarot-panel__head h3 {
|
||
font-family: 'Cormorant Garamond', serif;
|
||
font-size: 24px;
|
||
color: var(--tarot-gold);
|
||
}
|
||
.tarot-panel__sub {
|
||
font-size: 13px;
|
||
color: var(--tarot-text-dim);
|
||
margin-bottom: 12px;
|
||
}
|
||
.tarot-panel__chips {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 16px;
|
||
}
|
||
.tarot-panel__section {
|
||
margin-top: 18px;
|
||
padding-top: 18px;
|
||
border-top: 1px solid rgba(255, 255, 255, .06);
|
||
}
|
||
.tarot-panel__section h4 {
|
||
font-size: 14px;
|
||
letter-spacing: 1px;
|
||
color: var(--tarot-gold);
|
||
margin-bottom: 8px;
|
||
}
|
||
.tarot-panel__advice {
|
||
background: rgba(212, 175, 55, .08);
|
||
padding: 10px;
|
||
border-left: 2px solid var(--tarot-gold);
|
||
margin-top: 10px;
|
||
font-size: 13px;
|
||
color: var(--tarot-text);
|
||
}
|
||
.tarot-panel__warning {
|
||
background: rgba(244, 63, 94, .1);
|
||
padding: 10px;
|
||
border-left: 2px solid #f43f5e;
|
||
margin-top: 10px;
|
||
font-size: 13px;
|
||
}
|
||
.tarot-panel__toggle {
|
||
margin-top: 10px;
|
||
padding: 6px 10px;
|
||
background: transparent;
|
||
border: 1px solid rgba(255, 255, 255, .15);
|
||
color: var(--tarot-text-dim);
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
.tarot-evidence dt {
|
||
font-size: 11px;
|
||
letter-spacing: 1px;
|
||
color: var(--tarot-gold);
|
||
margin-top: 8px;
|
||
}
|
||
.tarot-evidence dd {
|
||
font-size: 13px;
|
||
color: var(--tarot-text-dim);
|
||
margin: 2px 0;
|
||
}
|
||
|
||
.tarot-confidence {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
font-size: 11px;
|
||
letter-spacing: 1px;
|
||
border-radius: 12px;
|
||
background: rgba(255, 255, 255, .08);
|
||
margin-left: 8px;
|
||
}
|
||
.tarot-confidence.is-high { background: rgba(52, 211, 153, .2); color: #34d399; }
|
||
.tarot-confidence.is-medium { background: rgba(212, 175, 55, .2); color: var(--tarot-gold); }
|
||
.tarot-confidence.is-low { background: rgba(244, 63, 94, .15); color: #f43f5e; }
|
||
|
||
.tarot-interactions {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
}
|
||
.tarot-interactions li {
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid rgba(255, 255, 255, .04);
|
||
}
|
||
.tarot-interaction-type {
|
||
padding: 2px 8px;
|
||
font-size: 11px;
|
||
border-radius: 4px;
|
||
background: rgba(255, 255, 255, .08);
|
||
}
|
||
.tarot-interaction-type--synergy { color: #34d399; background: rgba(52, 211, 153, .15); }
|
||
.tarot-interaction-type--conflict { color: #f43f5e; background: rgba(244, 63, 94, .15); }
|
||
.tarot-interaction-type--transition { color: var(--tarot-gold); background: rgba(212, 175, 55, .15); }
|
||
|
||
/* ===== History ===== */
|
||
.tarot-history {
|
||
max-width: 960px;
|
||
margin: 0 auto;
|
||
padding: 40px 24px;
|
||
}
|
||
.tarot-history__item {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
gap: 12px;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
background: rgba(255, 255, 255, .04);
|
||
border: 1px solid rgba(255, 255, 255, .06);
|
||
border-radius: 10px;
|
||
}
|
||
.tarot-history__star {
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--tarot-text-dim);
|
||
cursor: pointer;
|
||
font-size: 20px;
|
||
}
|
||
.tarot-history__star.is-fav {
|
||
color: var(--tarot-gold);
|
||
}
|
||
|
||
/* Responsive */
|
||
@media (max-width: 1100px) {
|
||
.tarot-reading {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.tarot__hero-video { display: none; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 빌드 검증**
|
||
|
||
`cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run build`
|
||
Expected: 성공
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/pages/tarot/Tarot.css
|
||
git commit -m "feat(tarot): Tarot.css 디자인 토큰 + 4 페이지 스타일 (T12)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Tarot.jsx 랜딩 페이지
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/tarot/Tarot.jsx`
|
||
|
||
- [ ] **Step 1: 구현**
|
||
|
||
```jsx
|
||
import React, { useRef, useEffect } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import './Tarot.css';
|
||
|
||
export default function Tarot() {
|
||
const videoRef = useRef(null);
|
||
useEffect(() => {
|
||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
const saveData = navigator.connection?.saveData;
|
||
if ((reduced || saveData) && videoRef.current) {
|
||
videoRef.current.pause();
|
||
videoRef.current.style.display = 'none';
|
||
}
|
||
}, []);
|
||
|
||
return (
|
||
<div className="tarot tarot--landing">
|
||
<video
|
||
ref={videoRef}
|
||
className="tarot__hero-video"
|
||
src="/videos/tarot_hero.mp4"
|
||
autoPlay loop muted playsInline preload="metadata"
|
||
poster="/images/tarot_background.png"
|
||
/>
|
||
<img className="tarot__hero-poster" src="/images/tarot_background.png" alt="" aria-hidden />
|
||
<div className="tarot__hero-overlay" />
|
||
|
||
<div className="tarot__hero-content">
|
||
<header className="tarot__brand">
|
||
<span>✦</span>
|
||
<span>ARCANA TAROT</span>
|
||
</header>
|
||
|
||
<nav className="tarot__nav" aria-label="Tarot navigation">
|
||
<Link to="/tarot/today">오늘의 카드</Link>
|
||
<Link to="/tarot/reading">타로 리딩</Link>
|
||
<Link to="/tarot/history">히스토리</Link>
|
||
</nav>
|
||
|
||
<h1 className="tarot__h1">당신의 오늘을<br />비추는 타로</h1>
|
||
<p className="tarot__sub">
|
||
카드를 펼쳐, 당신만의 인사이트를 받아보세요.
|
||
라이더-웨이트 덱과 Claude Sonnet 4.6 해석이 함께합니다.
|
||
</p>
|
||
|
||
<div className="tarot__cta-row">
|
||
<Link to="/tarot/reading" className="tarot__cta">지금 시작하기 →</Link>
|
||
<Link to="/tarot/today" className="tarot__cta tarot__cta--secondary">오늘의 카드</Link>
|
||
</div>
|
||
|
||
<div className="tarot__tier-row">
|
||
<article className="tarot__tier">
|
||
<h3>🌙 오늘의 운세</h3>
|
||
<p>매일 1장의 카드로 오늘의 흐름을 확인하세요.</p>
|
||
</article>
|
||
<article className="tarot__tier">
|
||
<h3>🃏 3장 스프레드</h3>
|
||
<p>과거·현재·미래의 흐름을 한눈에 읽어봅니다.</p>
|
||
</article>
|
||
<article className="tarot__tier">
|
||
<h3>✨ AI 해석</h3>
|
||
<p>Claude Sonnet 4.6이 카드 의미·위치·상호작용을 근거로 풀어줍니다.</p>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 빌드 검증**
|
||
|
||
`npm run build`
|
||
Expected: 성공
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/pages/tarot/Tarot.jsx
|
||
git commit -m "feat(tarot): 랜딩 페이지 Tarot.jsx (T13)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: TodayCard.jsx 오늘의 카드 페이지
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/tarot/TodayCard.jsx`
|
||
|
||
- [ ] **Step 1: 구현**
|
||
|
||
```jsx
|
||
import React, { useMemo, useState } from 'react';
|
||
import './Tarot.css';
|
||
import { TAROT_DECK, CATEGORIES, SPREADS } from './data/cards';
|
||
import { useTarotReading } from './hooks/useTarotReading';
|
||
import TarotCard from './components/TarotCard';
|
||
import InterpretationPanel from './components/InterpretationPanel';
|
||
|
||
export default function TodayCard() {
|
||
const [category, setCategory] = useState('일반');
|
||
const [question, setQuestion] = useState('');
|
||
const [pick, setPick] = useState(null);
|
||
const { status, interpretation, runInterpretAndSave, error } = useTarotReading();
|
||
|
||
const drawCard = () => {
|
||
const idx = Math.floor(Math.random() * TAROT_DECK.length);
|
||
const reversed = Math.random() < 0.5;
|
||
const card = TAROT_DECK[idx];
|
||
setPick({ card, position: '오늘', reversed });
|
||
};
|
||
|
||
const handleStart = async () => {
|
||
drawCard();
|
||
};
|
||
|
||
const handleInterpret = async () => {
|
||
if (!pick) return;
|
||
try {
|
||
await runInterpretAndSave({
|
||
spread_type: 'one_card',
|
||
category,
|
||
question: question.trim() || null,
|
||
picks: [pick],
|
||
});
|
||
} catch (e) {
|
||
// error는 hook의 state로 전달됨
|
||
}
|
||
};
|
||
|
||
const selectedCardMeaning = pick?.card || null;
|
||
const focusCardId = pick?.card?.slug;
|
||
|
||
const busy = status === 'interpreting' || status === 'saving';
|
||
|
||
return (
|
||
<div className="tarot tarot-reading">
|
||
<aside className="tarot-reading__col">
|
||
<div className="tarot-reading__step-label">오늘의 카드</div>
|
||
<label className="tarot-reading__step-label">질문 (선택)</label>
|
||
<textarea
|
||
className="tarot-reading__textarea"
|
||
value={question}
|
||
onChange={(e) => setQuestion(e.target.value)}
|
||
placeholder="오늘 무엇이 궁금한가요?"
|
||
/>
|
||
<div className="tarot-reading__step-label" style={{ marginTop: 16 }}>카테고리</div>
|
||
<div className="tarot-reading__chips">
|
||
{CATEGORIES.map((c) => (
|
||
<button
|
||
key={c}
|
||
className={`tarot-chip ${category === c ? 'is-active' : ''}`}
|
||
onClick={() => setCategory(c)}
|
||
>{c}</button>
|
||
))}
|
||
</div>
|
||
{!pick && (
|
||
<button className="tarot-reading__primary" onClick={handleStart}>
|
||
카드 뽑기
|
||
</button>
|
||
)}
|
||
{pick && !interpretation && (
|
||
<button className="tarot-reading__primary" onClick={handleInterpret} disabled={busy}>
|
||
{busy ? '해석 중…' : 'AI 해석 시작'}
|
||
</button>
|
||
)}
|
||
{pick && interpretation && (
|
||
<button className="tarot-reading__primary" onClick={() => { setPick(null); }}>
|
||
다시 뽑기
|
||
</button>
|
||
)}
|
||
{error && <p style={{ color: '#f43f5e', marginTop: 12, fontSize: 13 }}>오류: {error}</p>}
|
||
</aside>
|
||
|
||
<div className="tarot-reading__col" style={{ display: 'grid', placeItems: 'center', minHeight: 320 }}>
|
||
{pick ? (
|
||
<TarotCard card={pick.card} reversed={pick.reversed} size="lg" label={pick.position} />
|
||
) : (
|
||
<p style={{ color: 'var(--tarot-text-dim)' }}>좌측에서 "카드 뽑기"를 눌러보세요.</p>
|
||
)}
|
||
</div>
|
||
|
||
<InterpretationPanel
|
||
interpretation={interpretation}
|
||
selectedCard={selectedCardMeaning}
|
||
focusCardId={focusCardId}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 빌드 검증**
|
||
|
||
`npm run build`
|
||
Expected: 성공
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/pages/tarot/TodayCard.jsx
|
||
git commit -m "feat(tarot): TodayCard.jsx — 원카드 페이지 (T14)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Reading.jsx 3장 스프레드 페이지
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/tarot/Reading.jsx`
|
||
|
||
- [ ] **Step 1: 구현**
|
||
|
||
```jsx
|
||
import React, { useMemo, useState } from 'react';
|
||
import './Tarot.css';
|
||
import { TAROT_DECK, CATEGORIES, SPREADS } from './data/cards';
|
||
import { useTarotShuffle } from './hooks/useTarotShuffle';
|
||
import { useTarotReading } from './hooks/useTarotReading';
|
||
import TarotCard from './components/TarotCard';
|
||
import CardGrid from './components/CardGrid';
|
||
import SpreadSlots from './components/SpreadSlots';
|
||
import InterpretationPanel from './components/InterpretationPanel';
|
||
|
||
export default function Reading() {
|
||
const [category, setCategory] = useState('일반');
|
||
const [question, setQuestion] = useState('');
|
||
const [spreadId, setSpreadId] = useState('three_card');
|
||
const [step, setStep] = useState(1); // 1: 입력, 2: 뽑기, 3: 해석
|
||
const [picks, setPicks] = useState([]);
|
||
const [focusIdx, setFocusIdx] = useState(null);
|
||
|
||
const spread = SPREADS[spreadId];
|
||
const { slice, reshuffle } = useTarotShuffle(TAROT_DECK, 16);
|
||
const { status, interpretation, runInterpretAndSave, error } = useTarotReading();
|
||
|
||
const startShuffle = () => {
|
||
reshuffle();
|
||
setPicks([]);
|
||
setFocusIdx(null);
|
||
setStep(2);
|
||
};
|
||
|
||
const handlePick = (card) => {
|
||
if (picks.length >= spread.positions.length) return;
|
||
const idx = picks.length;
|
||
const pos = spread.positions[idx];
|
||
const next = [...picks, { card, position: pos.label, reversed: card.reversed }];
|
||
setPicks(next);
|
||
if (next.length === spread.positions.length) {
|
||
setFocusIdx(0);
|
||
}
|
||
};
|
||
|
||
const handleInterpret = async () => {
|
||
try {
|
||
await runInterpretAndSave({
|
||
spread_type: spreadId, category,
|
||
question: question.trim() || null, picks,
|
||
});
|
||
setStep(3);
|
||
} catch { /* error는 hook state */ }
|
||
};
|
||
|
||
const disabledIds = picks.map((p) => p.card.slug);
|
||
const focusCard = focusIdx !== null && picks[focusIdx] ? picks[focusIdx].card : null;
|
||
const focusCardId = focusCard?.slug;
|
||
const allPicked = picks.length === spread.positions.length;
|
||
const busy = status === 'interpreting' || status === 'saving';
|
||
|
||
return (
|
||
<div className="tarot tarot-reading">
|
||
<aside className="tarot-reading__col">
|
||
<div className="tarot-reading__step-label">1. 질문</div>
|
||
<textarea
|
||
className="tarot-reading__textarea"
|
||
value={question}
|
||
onChange={(e) => setQuestion(e.target.value)}
|
||
placeholder="궁금한 것을 적어주세요"
|
||
/>
|
||
|
||
<div className="tarot-reading__step-label" style={{ marginTop: 16 }}>2. 카테고리</div>
|
||
<div className="tarot-reading__chips">
|
||
{CATEGORIES.map((c) => (
|
||
<button
|
||
key={c}
|
||
className={`tarot-chip ${category === c ? 'is-active' : ''}`}
|
||
onClick={() => setCategory(c)}
|
||
>{c}</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="tarot-reading__step-label" style={{ marginTop: 16 }}>3. 스프레드</div>
|
||
<div className="tarot-reading__radio-row">
|
||
<label>
|
||
<input type="radio" checked={spreadId === 'three_card'}
|
||
onChange={() => setSpreadId('three_card')} /> 3장 (과거/현재/미래)
|
||
</label>
|
||
<label>
|
||
<input type="radio" checked={spreadId === 'one_card'}
|
||
onChange={() => setSpreadId('one_card')} /> 1장 (오늘의 카드)
|
||
</label>
|
||
</div>
|
||
|
||
<button className="tarot-reading__primary" onClick={startShuffle}>
|
||
⊃ 카드 셔플하기
|
||
</button>
|
||
|
||
{step >= 2 && allPicked && step < 3 && (
|
||
<button className="tarot-reading__primary" onClick={handleInterpret} disabled={busy}>
|
||
{busy ? '해석 중…' : 'AI 해석 시작'}
|
||
</button>
|
||
)}
|
||
|
||
{step === 3 && (
|
||
<button className="tarot-reading__primary" onClick={() => { setStep(1); setPicks([]); setFocusIdx(null); }}>
|
||
새 리딩
|
||
</button>
|
||
)}
|
||
|
||
{error && <p style={{ color: '#f43f5e', marginTop: 12, fontSize: 13 }}>{error}</p>}
|
||
</aside>
|
||
|
||
<div className="tarot-reading__col">
|
||
{step < 2 && <p style={{ color: 'var(--tarot-text-dim)' }}>좌측에서 질문·카테고리·스프레드를 선택하고 셔플하세요.</p>}
|
||
{step === 2 && (
|
||
<>
|
||
{!allPicked && (
|
||
<>
|
||
<p style={{ color: 'var(--tarot-text-dim)', marginBottom: 16 }}>
|
||
카드 {picks.length + 1}/{spread.positions.length} — {spread.positions[picks.length].label}
|
||
</p>
|
||
<CardGrid slice={slice} onPick={handlePick} disabledIds={disabledIds} />
|
||
</>
|
||
)}
|
||
<SpreadSlots
|
||
spread={spread} picks={picks}
|
||
onCardClick={(idx) => setFocusIdx(idx)}
|
||
/>
|
||
</>
|
||
)}
|
||
{step === 3 && (
|
||
<SpreadSlots
|
||
spread={spread} picks={picks}
|
||
onCardClick={(idx) => setFocusIdx(idx)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<InterpretationPanel
|
||
interpretation={interpretation}
|
||
selectedCard={focusCard}
|
||
focusCardId={focusCardId}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 빌드 검증**
|
||
|
||
`npm run build`
|
||
Expected: 성공
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/pages/tarot/Reading.jsx
|
||
git commit -m "feat(tarot): Reading.jsx — 3장 스프레드 메인 (T15)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: History.jsx 마이페이지
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/tarot/History.jsx`
|
||
|
||
- [ ] **Step 1: 구현**
|
||
|
||
```jsx
|
||
import React, { useCallback, useEffect, useState } from 'react';
|
||
import './Tarot.css';
|
||
import { tarotListReadings, tarotPatchReading, tarotDeleteReading } from '../../api';
|
||
import { findCard, SPREADS } from './data/cards';
|
||
|
||
function pickLine(r) {
|
||
const labels = (r.cards || []).map((c) => {
|
||
const card = findCard(c.card_id);
|
||
const name = card ? card.name : c.card_id;
|
||
return `${c.position} · ${name}${c.reversed ? '(역)' : ''}`;
|
||
});
|
||
return labels.join(' / ');
|
||
}
|
||
|
||
export default function History() {
|
||
const [items, setItems] = useState([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [favoriteOnly, setFavoriteOnly] = useState(false);
|
||
const [spreadFilter, setSpreadFilter] = useState('');
|
||
const [categoryFilter, setCategoryFilter] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [openId, setOpenId] = useState(null);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await tarotListReadings({
|
||
page, size: 20,
|
||
favorite: favoriteOnly || undefined,
|
||
spread_type: spreadFilter || undefined,
|
||
category: categoryFilter || undefined,
|
||
});
|
||
setItems(res.items);
|
||
setTotal(res.total);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [page, favoriteOnly, spreadFilter, categoryFilter]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const toggleFav = async (id, cur) => {
|
||
await tarotPatchReading(id, { favorite: !cur });
|
||
load();
|
||
};
|
||
|
||
const remove = async (id) => {
|
||
if (!window.confirm('삭제할까요?')) return;
|
||
await tarotDeleteReading(id);
|
||
load();
|
||
};
|
||
|
||
return (
|
||
<div className="tarot tarot-history">
|
||
<h2 style={{ fontFamily: 'Cormorant Garamond, serif', fontSize: 32, marginBottom: 16 }}>
|
||
리딩 히스토리
|
||
</h2>
|
||
|
||
<div className="tarot-reading__chips" style={{ marginBottom: 16 }}>
|
||
<button className={`tarot-chip ${favoriteOnly ? 'is-active' : ''}`}
|
||
onClick={() => setFavoriteOnly((v) => !v)}>
|
||
⭐ 즐겨찾기만
|
||
</button>
|
||
<button className={`tarot-chip ${spreadFilter === 'three_card' ? 'is-active' : ''}`}
|
||
onClick={() => setSpreadFilter((v) => v === 'three_card' ? '' : 'three_card')}>
|
||
3장
|
||
</button>
|
||
<button className={`tarot-chip ${spreadFilter === 'one_card' ? 'is-active' : ''}`}
|
||
onClick={() => setSpreadFilter((v) => v === 'one_card' ? '' : 'one_card')}>
|
||
1장
|
||
</button>
|
||
</div>
|
||
|
||
{loading && <p style={{ color: 'var(--tarot-text-dim)' }}>불러오는 중…</p>}
|
||
{!loading && items.length === 0 && <p style={{ color: 'var(--tarot-text-dim)' }}>리딩 기록이 없습니다.</p>}
|
||
|
||
{items.map((r) => (
|
||
<div key={r.id} className="tarot-history__item">
|
||
<div>
|
||
<div style={{ fontSize: 12, color: 'var(--tarot-text-dim)' }}>
|
||
{r.created_at} · {SPREADS[r.spread_type]?.name || r.spread_type}
|
||
{r.category ? ` · ${r.category}` : ''}
|
||
</div>
|
||
<div style={{ marginTop: 6 }}>{r.question || '(질문 없음)'}</div>
|
||
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--tarot-gold)' }}>
|
||
{pickLine(r)}
|
||
</div>
|
||
<p style={{ marginTop: 8, fontSize: 13, color: 'var(--tarot-text-dim)' }}>
|
||
{r.summary}
|
||
</p>
|
||
{openId === r.id && r.interpretation_json && (
|
||
<div style={{ marginTop: 12, padding: 12, background: 'rgba(0,0,0,.2)', borderRadius: 6 }}>
|
||
<p style={{ fontSize: 13 }}>{r.interpretation_json.advice}</p>
|
||
{r.interpretation_json.warning && (
|
||
<p style={{ fontSize: 13, color: '#f43f5e' }}>⚠️ {r.interpretation_json.warning}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={() => setOpenId(openId === r.id ? null : r.id)}
|
||
style={{ marginTop: 8, fontSize: 12, background: 'transparent', border: '1px solid rgba(255,255,255,.15)', color: 'var(--tarot-text-dim)', padding: '4px 8px', borderRadius: 4, cursor: 'pointer' }}
|
||
>
|
||
{openId === r.id ? '접기' : '자세히'}
|
||
</button>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
<button
|
||
className={`tarot-history__star ${r.favorite ? 'is-fav' : ''}`}
|
||
onClick={() => toggleFav(r.id, r.favorite)}
|
||
aria-label="즐겨찾기 토글"
|
||
>★</button>
|
||
<button
|
||
onClick={() => remove(r.id)}
|
||
style={{ background: 'transparent', border: 'none', color: 'var(--tarot-text-dim)', cursor: 'pointer', fontSize: 12 }}
|
||
aria-label="삭제"
|
||
>삭제</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{total > 20 && (
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'center' }}>
|
||
<button className="tarot-chip" disabled={page === 1} onClick={() => setPage((p) => p - 1)}>이전</button>
|
||
<span style={{ color: 'var(--tarot-text-dim)', alignSelf: 'center' }}>{page} / {Math.ceil(total / 20)}</span>
|
||
<button className="tarot-chip" disabled={page * 20 >= total} onClick={() => setPage((p) => p + 1)}>다음</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 빌드 검증**
|
||
|
||
`npm run build`
|
||
Expected: 성공
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/pages/tarot/History.jsx
|
||
git commit -m "feat(tarot): History.jsx — 마이페이지 (T16)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: 라우팅 + navLinks 등록
|
||
|
||
**Files:**
|
||
- Modify: `web-ui/src/routes.jsx`
|
||
- Modify: `web-ui/src/components/Icons.jsx`
|
||
|
||
- [ ] **Step 1: Icons.jsx에 IconTarot 추가**
|
||
|
||
`web-ui/src/components/Icons.jsx`를 Read해서 다른 아이콘 패턴 확인 후 다음 컴포넌트를 추가:
|
||
|
||
```jsx
|
||
export const IconTarot = () => (
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||
<rect x="5" y="3" width="14" height="18" rx="2" />
|
||
<path d="M12 7v10M9 12h6" />
|
||
<circle cx="12" cy="12" r="3" />
|
||
</svg>
|
||
);
|
||
```
|
||
|
||
(기존 아이콘들의 export 패턴에 맞춰 위치)
|
||
|
||
- [ ] **Step 2: routes.jsx에 페이지 + 라우트 + navLink 추가**
|
||
|
||
`web-ui/src/routes.jsx`:
|
||
|
||
상단 `lazy` import 블록에 추가:
|
||
|
||
```jsx
|
||
const Tarot = lazy(() => import('./pages/tarot/Tarot'));
|
||
const TarotTodayCard = lazy(() => import('./pages/tarot/TodayCard'));
|
||
const TarotReading = lazy(() => import('./pages/tarot/Reading'));
|
||
const TarotHistory = lazy(() => import('./pages/tarot/History'));
|
||
```
|
||
|
||
상단 import에 IconTarot 추가:
|
||
|
||
```jsx
|
||
import {
|
||
IconHome,
|
||
// ...기존
|
||
IconTarot,
|
||
} from './components/Icons';
|
||
```
|
||
|
||
`navLinks` 배열 끝(`lab` 다음)에 추가:
|
||
|
||
```jsx
|
||
{
|
||
id: 'tarot',
|
||
label: 'Tarot',
|
||
path: '/tarot',
|
||
subtitle: 'ARCANA',
|
||
description: '라이더-웨이트 카드로 오늘과 내일을 비추는 리딩 랩',
|
||
icon: <IconTarot />,
|
||
accent: '#a78bfa',
|
||
},
|
||
```
|
||
|
||
`appRoutes` 배열에 추가:
|
||
|
||
```jsx
|
||
{ path: 'tarot', element: <Tarot /> },
|
||
{ path: 'tarot/today', element: <TarotTodayCard /> },
|
||
{ path: 'tarot/reading', element: <TarotReading /> },
|
||
{ path: 'tarot/history', element: <TarotHistory /> },
|
||
```
|
||
|
||
- [ ] **Step 3: 빌드·라우트 동작 검증**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run build
|
||
```
|
||
Expected: 성공
|
||
|
||
dev 서버 띄워 라우트 동작 직접 확인:
|
||
```bash
|
||
npm run dev
|
||
```
|
||
브라우저로 다음 5개 URL 모두 접근 (오류 없이 페이지 렌더):
|
||
- http://localhost:3007/tarot
|
||
- http://localhost:3007/tarot/today
|
||
- http://localhost:3007/tarot/reading
|
||
- http://localhost:3007/tarot/history
|
||
- 메인 네비에 "Tarot" 링크 표시
|
||
|
||
- [ ] **Step 4: 커밋**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/routes.jsx src/components/Icons.jsx
|
||
git commit -m "feat(tarot): 라우팅 4 페이지 + navLinks 추가 (T17)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: 통합 동작 검증 (스모크 테스트)
|
||
|
||
**Files:** 없음 (수동 QA)
|
||
|
||
- [ ] **Step 1: 백엔드 로컬 실행 + API 호출 확인**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||
docker compose up -d agent-office
|
||
docker compose logs -f agent-office | head -50
|
||
```
|
||
|
||
agent-office가 healthy 되면 (`/health` 200) 다음 호출:
|
||
|
||
```bash
|
||
# 빈 리스트 확인
|
||
curl -s http://localhost:18900/api/agent-office/tarot/readings | head -c 200
|
||
|
||
# interpret은 ANTHROPIC_API_KEY 필요 → .env 설정되어 있다면 실제 호출,
|
||
# 없으면 500 + "ANTHROPIC_API_KEY missing" 확인
|
||
curl -X POST http://localhost:18900/api/agent-office/tarot/interpret \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"spread_type":"one_card","category":"일반","question":"오늘은?","cards":[{"position":"오늘","card_id":"the-fool","reversed":false}],"cards_reference":"## 1. 위치: 오늘 | 카드: The Fool (정방향)\n- 키워드: 새로운 시작","context_meta":{}}' \
|
||
| head -c 500
|
||
```
|
||
|
||
Expected:
|
||
- `/api/agent-office/tarot/readings` → `{"items":[],"page":1,"size":20,"total":0}`
|
||
- `/api/agent-office/tarot/interpret` → 200 + interpretation_json 또는 500 + "ANTHROPIC_API_KEY missing"
|
||
|
||
- [ ] **Step 2: 프론트 dev 서버 + E2E 흐름 확인**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
npm run dev
|
||
```
|
||
|
||
브라우저로:
|
||
1. http://localhost:3007/tarot — 랜딩 페이지 영상 재생 확인 + 3-tier 카드 + CTA 버튼
|
||
2. "지금 시작하기" → /tarot/reading 이동
|
||
3. 질문 입력 + 카테고리 선택 + "카드 셔플하기" → 16장 그리드 표시
|
||
4. 3장 클릭 → 슬롯에 채워짐
|
||
5. "AI 해석 시작" → ANTHROPIC_API_KEY 있을 시 interpretation 표시 / 없으면 좌측에 오류 메시지
|
||
6. /tarot/history → 빈 상태 또는 방금 저장된 리딩 표시
|
||
7. /tarot/today → 카드 뽑기 + AI 해석 흐름 확인
|
||
|
||
- [ ] **Step 3: 백엔드 전체 테스트 한 번 더**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office
|
||
pytest tests/test_tarot_*.py -v
|
||
```
|
||
Expected: 모든 tarot 테스트 통과 (T1 4 + T4 6 + T5 6 + T6 5 = 21)
|
||
|
||
- [ ] **Step 4: 프론트 전체 테스트**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
npm test -- src/pages/tarot/ --run
|
||
```
|
||
Expected: 모든 tarot 테스트 통과
|
||
|
||
- [ ] **Step 5: 정리 커밋 (필요 시)**
|
||
|
||
스모크에서 발견된 사소한 버그는 즉시 fix + 별도 커밋:
|
||
|
||
```bash
|
||
git commit -m "fix(tarot): <발견된 이슈>"
|
||
```
|
||
|
||
- [ ] **Step 6: 완료 알림**
|
||
|
||
작업 완료. 사용자에게:
|
||
- 백엔드 변경: `git push` → Gitea Webhook → NAS agent-office 재빌드
|
||
- 프론트 변경: `cd web-ui && npm run release:nas` 실행 필요
|
||
- `ANTHROPIC_API_KEY`는 이미 운영 `.env`에 있음 — 추가 설정 없음
|
||
- 카드 78장 이미지 자산은 추후 `web-ui/public/images/tarot/cards/<slug>.png` 추가로 자동 매핑
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
### 1. Spec 커버리지 체크
|
||
| Spec 섹션 | Task | 확인 |
|
||
|---|---|---|
|
||
| §2 아키텍처 (agent-office 확장) | T1·T6 | ✓ |
|
||
| §3 프론트 데이터 모델 (cards.js, SPREADS, CATEGORIES) | T8 | ✓ |
|
||
| §4 백엔드 데이터 모델 (tarot_readings) | T1 | ✓ |
|
||
| §5.1 POST /interpret | T5·T6 | ✓ |
|
||
| §5.2 POST /readings | T6 | ✓ |
|
||
| §5.3 GET /readings | T6 | ✓ |
|
||
| §5.4 PATCH /readings/{id} | T6 | ✓ |
|
||
| §5.5 DELETE /readings/{id} | T6 | ✓ |
|
||
| §6 AI 프롬프트 (SYSTEM + USER_TEMPLATE + evidence·interactions·confidence) | T3·T4·T5 | ✓ |
|
||
| §6 응답 검증 (reroll 1회) | T5 | ✓ |
|
||
| §7.1 Routes (/tarot, /today, /reading, /history) | T17 | ✓ |
|
||
| §7.2 랜딩 (히어로 영상 + 3-tier) | T13 | ✓ |
|
||
| §7.3 3장 스프레드 3-step | T15 | ✓ |
|
||
| §7.4 오늘의 카드 | T14 | ✓ |
|
||
| §7.5 히스토리 | T16 | ✓ |
|
||
| §7.6 공용 컴포넌트 (5개) + hooks (2개) | T9·T10·T11 | ✓ |
|
||
| §7.7 디자인 토큰 + CSS | T12 | ✓ |
|
||
| §7.8 navLinks 추가 | T17 | ✓ |
|
||
| §8 미디어 자산 (영상·이미지·card_back.svg) | T7 | ✓ |
|
||
| §9 테스트 전략 (프론트 Vitest + 백엔드 pytest) | T1·T4·T5·T6·T8·T9·T10 | ✓ |
|
||
| §10 배포 (docker-compose·nginx 변경 0건) | — (T18 검증) | ✓ |
|
||
|
||
### 2. Placeholder 스캔
|
||
- "TBD"/"TODO"/"implement later" — 코드에 없음
|
||
- 모든 함수·메서드 시그니처가 다른 task에서 사용되기 전에 정의됨
|
||
- 모든 코드 스텝에 실제 코드 포함
|
||
|
||
### 3. Type Consistency
|
||
- `save_tarot_reading(data: dict) -> int` — T1 정의, T6에서 사용 일관
|
||
- `list_tarot_readings(page, size, favorite, spread_type, category)` — T1 정의, T6에서 사용 일관
|
||
- `update_tarot_reading(reading_id, **kwargs)` — T1 정의, T6에서 `**req.model_dump(exclude_none=True)` 호출 일관 (favorite, note만 처리)
|
||
- `delete_tarot_reading(reading_id)` — T1 정의, T6에서 사용 일관
|
||
- `TarotInterpretRequest` 필드 (spread_type, cards, cards_reference, context_meta) — T2 정의, T5·T6 사용 일관
|
||
- `interpret(req) -> dict` 반환 키 (interpretation_json, model, tokens_in/out, cost_usd, latency_ms, reroll_count) — T5 정의, T6 응답 모델 `TarotInterpretResponse` (T2)와 일관
|
||
- `buildReferenceBlock`, `buildContextMeta`, `useTarotReading.runInterpretAndSave` — T10 정의, T14·T15에서 사용 일관
|
||
- `SPREADS.three_card.positions[i].label` — T8 정의 ('과거','현재','미래'), T15 사용 일관
|
||
- `TAROT_DECK[i].slug` — 카드 식별자로 T8 정의, T5 prompt evidence와 T10 buildReferenceBlock에서 일관 사용
|
||
- `interpretation_json.cards[i].card` (card_id 참조) — schema (T4) + prompt (T3) + frontend InterpretationPanel.focusCardId (T11) 일관 (card.slug = card_id)
|
||
|
||
### 4. 작업 순서 의존성
|
||
- T1 (DB) → T6 (라우터)
|
||
- T2 (모델) → T5 (파이프라인) → T6 (라우터)
|
||
- T3 (프롬프트) → T5
|
||
- T4 (스키마) → T5
|
||
- T7 (미디어) → T13 (랜딩에서 영상 참조)
|
||
- T8 (카드 데이터) → T9·T10·T14·T15·T16
|
||
- T9·T10 (hooks) → T11 (컴포넌트는 hooks를 직접 사용 안 하지만 props로 받음) → T14·T15
|
||
- T11·T12 (컴포넌트·CSS) → T13·T14·T15·T16
|
||
- T17 (라우팅) → T18 (스모크)
|
||
|
||
T1·T2·T3·T4·T7·T8 은 서로 독립적이라 병렬 가능.
|
||
|
||
---
|
||
|
||
**총 18 task. 백엔드 6개 + 프론트 11개 + 통합 검증 1개.**
|