diff --git a/docs/superpowers/plans/2026-05-23-tarot-lab.md b/docs/superpowers/plans/2026-05-23-tarot-lab.md new file mode 100644 index 0000000..3572b0d --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-tarot-lab.md @@ -0,0 +1,3547 @@ +# Tarot Lab v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 라이더-웨이트 타로 리딩 4 페이지(랜딩 / 오늘의 카드 / 3장 스프레드 / 히스토리)를 Claude Sonnet 4.6 evidence·interactions 기반 해석과 함께 web-ui + agent-office 확장으로 추가한다. + +**Architecture:** +- 프론트(`web-ui`): 정적 카드 78장 메타데이터 + 4 페이지 + 3-step 인터랙션 컴포넌트 +- 백엔드(`agent-office` 확장): `tarot_readings` 테이블 + `/api/agent-office/tarot/*` 5 endpoint + Claude Sonnet 호출 with evidence·interactions·confidence 검증 +- 신규 컨테이너·nginx·docker-compose 변경 0건. agent-office 라우터 등록만 추가. + +**Tech Stack:** FastAPI 0.115 + httpx + sqlite3 / React 18 + Vite + React Router v6 / Claude Sonnet 4.6 / Vitest + pytest + +**Spec:** `docs/superpowers/specs/2026-05-23-tarot-lab-design.md` + +--- + +## File Structure + +### 백엔드 — agent-office 확장 +| 파일 | 역할 | +|---|---| +| `agent-office/app/db.py` | `tarot_readings` 테이블 추가 + CRUD 함수 (save_tarot_reading, list_tarot_readings, get_tarot_reading, update_tarot_reading, delete_tarot_reading) | +| `agent-office/app/tarot/__init__.py` | 모듈 표식 | +| `agent-office/app/tarot/prompt.py` | SYSTEM_PROMPT 상수 + build_user_message(question, category, spread, cards_reference, context_meta) | +| `agent-office/app/tarot/schema.py` | `validate_interpretation(parsed: dict, spread_type: str) -> tuple[bool, str]` — evidence·interactions·confidence 필드 검증 | +| `agent-office/app/tarot/pipeline.py` | `interpret(req: InterpretRequest) -> InterpretResponse` — Claude 호출 + 파싱 폴백 + reroll 1회 + 비용 계산 | +| `agent-office/app/routers/tarot.py` | APIRouter `/api/agent-office/tarot` — 5 endpoint (interpret, save, list, patch, delete) | +| `agent-office/app/models.py` | 신규 Pydantic 모델 추가 (`TarotCard`, `InterpretRequest`, `InterpretResponse`, `SaveReadingRequest`, `PatchReadingRequest`) | +| `agent-office/app/main.py` | `include_router(tarot_router.router)` 1줄 추가 | +| `agent-office/app/config.py` | `TAROT_MODEL`, `TAROT_COST_INPUT_PER_M`, `TAROT_COST_OUTPUT_PER_M` 환경변수 | +| `agent-office/tests/test_tarot_schema.py` | schema 검증 단위 테스트 | +| `agent-office/tests/test_tarot_pipeline.py` | 파싱 폴백·reroll·비용 계산 테스트 (httpx mock) | +| `agent-office/tests/test_tarot_db.py` | CRUD 단위 테스트 | +| `agent-office/tests/test_tarot_routes.py` | endpoint 통합 테스트 (FastAPI TestClient) | + +### 프론트 — web-ui 신규 페이지 +| 파일 | 역할 | +|---|---| +| `web-ui/src/pages/tarot/data/cards.js` | `TAROT_DECK` (78장 메타) + `SPREADS` + `CATEGORIES` 상수 export | +| `web-ui/src/pages/tarot/data/cards.test.js` | 78장 검증 (총수, slug 중복, 필수 필드) | +| `web-ui/src/pages/tarot/hooks/useTarotShuffle.js` | Fisher–Yates 셔플 + 16장 슬라이스 hook | +| `web-ui/src/pages/tarot/hooks/useTarotShuffle.test.js` | 분포·중복 없음 | +| `web-ui/src/pages/tarot/hooks/useTarotReading.js` | 카드 선택 상태 + reference 블록 빌더 + AI 호출 + 자동 저장 hook | +| `web-ui/src/pages/tarot/hooks/useTarotReading.test.js` | step 전환, reference 블록 빌드, AI 호출 시나리오 | +| `web-ui/src/pages/tarot/components/TarotCard.jsx` | 단일 카드 (앞·뒷면 토글, props: cardId / reversed / size / clickable / onClick) | +| `web-ui/src/pages/tarot/components/CardGrid.jsx` | 셔플 16장 그리드 | +| `web-ui/src/pages/tarot/components/SpreadSlots.jsx` | 위치별 슬롯 | +| `web-ui/src/pages/tarot/components/InterpretationPanel.jsx` | 우측 패널 (카드 의미 + AI 텍스트 + evidence 접기) | +| `web-ui/src/pages/tarot/Tarot.jsx` | 랜딩 페이지 | +| `web-ui/src/pages/tarot/TodayCard.jsx` | 오늘의 카드 페이지 | +| `web-ui/src/pages/tarot/Reading.jsx` | 3장 스프레드 메인 (3-step) | +| `web-ui/src/pages/tarot/History.jsx` | 마이페이지 (이력) | +| `web-ui/src/pages/tarot/Tarot.css` | 다크 보라+금 톤 디자인 토큰 | +| `web-ui/src/api.js` | `apiPatch` 추가 + 8 tarot helper 함수 | +| `web-ui/src/components/Icons.jsx` | `IconTarot` 추가 | +| `web-ui/src/routes.jsx` | `/tarot`, `/tarot/today`, `/tarot/reading`, `/tarot/history` 라우트 + navLinks 추가 | +| `web-ui/public/videos/tarot_hero.mp4` | source/videos/tarot_main_background.mp4 복사본 | +| `web-ui/public/images/tarot_background.png` | source/images/tarot_page/tarot_background.png 복사본 | +| `web-ui/public/images/tarot/card_back.svg` | 신규 SVG (보라+금 모노그램) | + +--- + +## 실행 환경 + +- **백엔드 작업 경로**: `C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office/` +- **프론트 작업 경로**: `C:/Users/jaeoh/Desktop/workspace/web-ui/` +- **백엔드 테스트**: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office && pytest tests/test_tarot_*.py -v` +- **프론트 테스트**: `cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm test -- src/pages/tarot/` +- **백엔드 커밋**: web-backend 저장소에서 +- **프론트 커밋**: web-ui 저장소에서 (별도 Git) +- **로컬 백엔드 실행**: `cd web-backend && docker compose up -d agent-office` (NAS만 실행이 원칙이지만 로컬 테스트는 docker로) +- **로컬 프론트**: `cd web-ui && npm run dev` → http://localhost:3007 + +--- + +## Task 1: tarot_readings 테이블 추가 + CRUD + +**Files:** +- Modify: `agent-office/app/db.py` +- Create: `agent-office/tests/test_tarot_db.py` + +- [ ] **Step 1: 실패 테스트 작성** + +`agent-office/tests/test_tarot_db.py` 생성: + +```python +import json +import os +import tempfile + +import pytest + +from app import db as db_module + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + db_file = tmp_path / "test_tarot.db" + monkeypatch.setattr(db_module, "DB_PATH", str(db_file)) + db_module.init_db() + yield + if db_file.exists(): + db_file.unlink() + + +def test_save_and_get_tarot_reading(): + rid = db_module.save_tarot_reading({ + "spread_type": "three_card", + "category": "연애", + "question": "Q", + "cards": [{"position": "과거", "card_id": "the-fool", "reversed": False}], + "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "medium"}, + "model": "claude-sonnet-4-6", + "tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005, + "confidence": "medium", + }) + assert rid > 0 + row = db_module.get_tarot_reading(rid) + assert row["id"] == rid + assert row["category"] == "연애" + assert row["interpretation_json"]["summary"] == "S" + assert row["favorite"] == 0 + + +def test_list_tarot_readings_filters_and_pagination(): + for cat in ["연애", "연애", "재물"]: + db_module.save_tarot_reading({ + "spread_type": "three_card", "category": cat, "question": "Q", + "cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "low"}, + "model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low", + }) + res = db_module.list_tarot_readings(page=1, size=10, category="연애") + assert res["total"] == 2 + assert all(r["category"] == "연애" for r in res["items"]) + + +def test_update_tarot_reading_favorite_and_note(): + rid = db_module.save_tarot_reading({ + "spread_type": "one_card", "category": None, "question": None, + "cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"}, + "model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high", + }) + db_module.update_tarot_reading(rid, favorite=True, note="기억하고 싶음") + row = db_module.get_tarot_reading(rid) + assert row["favorite"] == 1 + assert row["note"] == "기억하고 싶음" + + +def test_delete_tarot_reading(): + rid = db_module.save_tarot_reading({ + "spread_type": "one_card", "category": None, "question": None, + "cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"}, + "model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high", + }) + db_module.delete_tarot_reading(rid) + assert db_module.get_tarot_reading(rid) is None +``` + +- [ ] **Step 2: 테스트 실패 확인** + +`cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office && pytest tests/test_tarot_db.py -v` + +Expected: FAIL with `AttributeError: module 'app.db' has no attribute 'save_tarot_reading'` + +- [ ] **Step 3: 테이블 + CRUD 구현** + +`agent-office/app/db.py`의 `init_db()` 함수 안, 마지막 `INSERT OR IGNORE` 시드 직전에 다음 블록 추가: + +```python + conn.execute(""" + CREATE TABLE IF NOT EXISTS tarot_readings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + spread_type TEXT NOT NULL, + category TEXT, + question TEXT, + cards TEXT NOT NULL, + interpretation_json TEXT, + summary TEXT, + model TEXT, + tokens_in INTEGER, + tokens_out INTEGER, + cost_usd REAL, + confidence TEXT, + favorite INTEGER NOT NULL DEFAULT 0, + note TEXT + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_tarot_created + ON tarot_readings(created_at DESC) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_tarot_favorite + ON tarot_readings(favorite, created_at DESC) + """) +``` + +같은 파일 끝에 CRUD 함수 추가: + +```python +# --- tarot_readings CRUD --- + +def save_tarot_reading(data: Dict[str, Any]) -> int: + interp = data.get("interpretation_json") or {} + summary = interp.get("summary", "") if isinstance(interp, dict) else "" + with _conn() as conn: + cur = conn.execute( + """INSERT INTO tarot_readings + (spread_type, category, question, cards, interpretation_json, + summary, model, tokens_in, tokens_out, cost_usd, confidence) + VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + ( + data["spread_type"], + data.get("category"), + data.get("question"), + json.dumps(data.get("cards") or [], ensure_ascii=False), + json.dumps(interp, ensure_ascii=False) if interp else None, + summary, + data.get("model"), + data.get("tokens_in"), + data.get("tokens_out"), + data.get("cost_usd"), + data.get("confidence"), + ), + ) + return int(cur.lastrowid) + + +def get_tarot_reading(reading_id: int) -> Optional[Dict[str, Any]]: + with _conn() as conn: + r = conn.execute("SELECT * FROM tarot_readings WHERE id=?", (reading_id,)).fetchone() + return _tarot_row_to_dict(r) if r else None + + +def list_tarot_readings( + page: int = 1, size: int = 20, + favorite: Optional[bool] = None, + spread_type: Optional[str] = None, + category: Optional[str] = None, +) -> Dict[str, Any]: + wheres, params = [], [] + if favorite is not None: + wheres.append("favorite=?") + params.append(1 if favorite else 0) + if spread_type: + wheres.append("spread_type=?") + params.append(spread_type) + if category: + wheres.append("category=?") + params.append(category) + where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else "" + offset = (page - 1) * size + with _conn() as conn: + total = conn.execute( + f"SELECT COUNT(*) c FROM tarot_readings {where_sql}", params + ).fetchone()["c"] + rows = conn.execute( + f"SELECT * FROM tarot_readings {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?", + params + [size, offset], + ).fetchall() + return { + "items": [_tarot_row_to_dict(r) for r in rows], + "page": page, "size": size, "total": int(total), + } + + +def update_tarot_reading(reading_id: int, **kwargs) -> None: + sets, vals = [], [] + if "favorite" in kwargs and kwargs["favorite"] is not None: + sets.append("favorite=?") + vals.append(1 if kwargs["favorite"] else 0) + if "note" in kwargs and kwargs["note"] is not None: + sets.append("note=?") + vals.append(kwargs["note"]) + if not sets: + return + vals.append(reading_id) + with _conn() as conn: + conn.execute(f"UPDATE tarot_readings SET {','.join(sets)} WHERE id=?", vals) + + +def delete_tarot_reading(reading_id: int) -> None: + with _conn() as conn: + conn.execute("DELETE FROM tarot_readings WHERE id=?", (reading_id,)) + + +def _tarot_row_to_dict(r) -> Dict[str, Any]: + try: + interp = json.loads(r["interpretation_json"]) if r["interpretation_json"] else None + except (ValueError, TypeError): + interp = None + try: + cards = json.loads(r["cards"]) if r["cards"] else [] + except (ValueError, TypeError): + cards = [] + return { + "id": r["id"], + "created_at": r["created_at"], + "spread_type": r["spread_type"], + "category": r["category"], + "question": r["question"], + "cards": cards, + "interpretation_json": interp, + "summary": r["summary"], + "model": r["model"], + "tokens_in": r["tokens_in"], + "tokens_out": r["tokens_out"], + "cost_usd": r["cost_usd"], + "confidence": r["confidence"], + "favorite": int(r["favorite"]), + "note": r["note"], + } +``` + +- [ ] **Step 4: 테스트 통과 확인** + +`pytest tests/test_tarot_db.py -v` +Expected: 4 passed + +- [ ] **Step 5: 커밋** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend +git add agent-office/app/db.py agent-office/tests/test_tarot_db.py +git commit -m "feat(agent-office): tarot_readings 테이블 + CRUD (T1)" +``` + +--- + +## Task 2: Pydantic 모델 + 설정값 추가 + +**Files:** +- Modify: `agent-office/app/models.py` +- Modify: `agent-office/app/config.py` + +- [ ] **Step 1: 설정값 추가** + +`agent-office/app/config.py` 끝에 추가: + +```python +# Tarot Lab +TAROT_MODEL = os.getenv("TAROT_MODEL", "claude-sonnet-4-6") +TAROT_COST_INPUT_PER_M = float(os.getenv("TAROT_COST_INPUT_PER_M", "3.0")) +TAROT_COST_OUTPUT_PER_M = float(os.getenv("TAROT_COST_OUTPUT_PER_M", "15.0")) +TAROT_TIMEOUT_SEC = int(os.getenv("TAROT_TIMEOUT_SEC", "60")) +``` + +- [ ] **Step 2: Pydantic 모델 추가** + +`agent-office/app/models.py` 끝에 추가: + +```python +from typing import List, Optional, Literal +from pydantic import BaseModel, Field + + +class TarotCardDraw(BaseModel): + position: str + card_id: str + reversed: bool = False + + +class TarotInterpretRequest(BaseModel): + spread_type: Literal["one_card", "three_card"] + category: Optional[str] = None + question: Optional[str] = None + cards: List[TarotCardDraw] + cards_reference: str = Field(..., min_length=1) + context_meta: dict = Field(default_factory=dict) + + +class TarotInterpretResponse(BaseModel): + interpretation_json: dict + model: str + tokens_in: int + tokens_out: int + cost_usd: float + latency_ms: int + reroll_count: int = 0 + + +class TarotSaveRequest(BaseModel): + spread_type: Literal["one_card", "three_card"] + category: Optional[str] = None + question: Optional[str] = None + cards: List[TarotCardDraw] + interpretation_json: dict + model: str + tokens_in: int + tokens_out: int + cost_usd: float + confidence: Optional[str] = None + + +class TarotPatchRequest(BaseModel): + favorite: Optional[bool] = None + note: Optional[str] = None +``` + +- [ ] **Step 3: import 검증 — 실행 가능한지만 확인** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office +python -c "from app.models import TarotInterpretRequest, TarotInterpretResponse, TarotSaveRequest, TarotPatchRequest; from app.config import TAROT_MODEL, TAROT_COST_INPUT_PER_M; print('ok')" +``` +Expected: `ok` + +- [ ] **Step 4: 커밋** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend +git add agent-office/app/models.py agent-office/app/config.py +git commit -m "feat(agent-office): Tarot Pydantic 모델 + config 추가 (T2)" +``` + +--- + +## Task 3: Tarot 프롬프트 (SYSTEM_PROMPT + builder) + +**Files:** +- Create: `agent-office/app/tarot/__init__.py` +- Create: `agent-office/app/tarot/prompt.py` + +- [ ] **Step 1: 모듈 표식** + +`agent-office/app/tarot/__init__.py`: + +```python +"""Tarot Lab — Claude Sonnet 기반 evidence·interactions 해석 파이프라인.""" +``` + +- [ ] **Step 2: 프롬프트 작성** + +`agent-office/app/tarot/prompt.py`: + +```python +"""Tarot 프롬프트 — SYSTEM + build_user_message.""" + +SYSTEM_PROMPT = """당신은 라이더-웨이트(RWS) 타로 덱의 전통 상징체계에 정통한 타로 리더입니다. +사용자의 질문, 카테고리, 뽑힌 카드 각각의 정·역방향과 위치를 받아 근거 기반으로 해석합니다. + +# 해석 원칙 +1. 데이터 우선: "참고 카드 정보" 블록의 키워드·기본의미·상징만을 1차 근거로 사용. + 외부 변형 의미·다른 덱 해석은 사용하지 않음. +2. 위치 의미 결합: 카드의 의미와 위치(과거/현재/미래 또는 오늘)를 명시적으로 결합해서 해석. evidence에 근거 기록. +3. 카드 간 상호작용 분석 (3장 스프레드): + - 시너지: 같은 슈트, 같은 원소, 메이저 비율, 정·역 흐름 + - 충돌·전환: 슈트 충돌(컵-소드, 완드-펜타클), 정→역 전환, 메이저↔마이너 전환 +4. 자기 성찰 톤: 운명론 단정 금지. "…할 가능성이 있어 보입니다" 같은 표현. +5. 카테고리 컨텍스트: 동일 카드라도 카테고리에 따라 강조점이 달라야 함. +6. 질문 직접 응답: 사용자 질문을 evidence·advice에서 인용·반영. + +# 응답 형식 (strict JSON only — 코드블록 없이 raw JSON) +{ + "summary": "전체 흐름 한 단락 (3~4문장)", + "cards": [ + { + "position": "<위치 라벨>", + "card": "", + "reversed": , + "interpretation": "3~4문장", + "evidence": { + "card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징", + "position_logic": "왜 이 위치에 이렇게 적용되는지 (1~2문장)", + "category_lens": "카테고리 관점에서 부각되는 면 (1문장)" + }, + "advice": "1문장" + } + ], + "interactions": [ + { "type": "synergy"|"conflict"|"transition", + "between": ["", ""], + "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 + + + + + + + + + + + + + + + + + A + + + + + + + + + ARCANA TAROT + +``` + +- [ ] **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 ( + + ); + } + + const styleClass = reversed ? 'tarot-card--reversed' : ''; + const onImgError = (e) => { e.currentTarget.style.display = 'none'; }; + + return ( + + ); +} +``` + +- [ ] **Step 2: CardGrid.jsx** + +```jsx +import React from 'react'; +import TarotCard from './TarotCard'; + +export default function CardGrid({ slice, onPick, disabledIds = [] }) { + return ( +
+ {slice.map((card) => { + const disabled = disabledIds.includes(card.slug); + return ( + !disabled && onPick(card)} + /> + ); + })} +
+ ); +} +``` + +- [ ] **Step 3: SpreadSlots.jsx** + +```jsx +import React from 'react'; +import TarotCard from './TarotCard'; + +export default function SpreadSlots({ spread, picks, onCardClick }) { + return ( +
+ {spread.positions.map((pos) => { + const pick = picks[pos.idx]; + return ( +
+
{pos.label}
+ {pick ? ( + onCardClick(pos.idx)} + /> + ) : ( +
_
+ )} +
+ ); + })} +
+ ); +} +``` + +- [ ] **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 확신 {text}; +} + +export default function InterpretationPanel({ interpretation, selectedCard, focusCardId }) { + const [showEvidence, setShowEvidence] = useState(true); + + if (!interpretation) { + return ( + + ); + } + + const cardDetail = focusCardId + ? (interpretation.cards || []).find((c) => c.card === focusCardId) + : null; + + return ( + + ); +} +``` + +- [ ] **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 ( +
+