Files
web-page-backend/docs/superpowers/plans/2026-05-23-tarot-lab.md
gahusb 9dba1e74b0 docs(plan): tarot-lab v1 implementation plan
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>
2026-05-23 23:59:13 +09:00

3548 lines
119 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` | FisherYates 셔플 + 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개.**