# 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 (