Files
web-page-backend/docs/superpowers/plans/2026-05-23-tarot-lab.md
gahusb 9dba1e74b0 docs(plan): tarot-lab v1 implementation plan
18 task — agent-office 6 (DB·모델·프롬프트·스키마·파이프라인·라우터) + web-ui 11 (자산·카드·hooks·컴포넌트·CSS·페이지·라우팅) + 통합 검증 1.
TDD per task — 실패 테스트 → 구현 → 통과 → 커밋 워크플로우.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:59:13 +09:00

119 KiB
Raw Blame History

Tarot Lab v1 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 라이더-웨이트 타로 리딩 4 페이지(랜딩 / 오늘의 카드 / 3장 스프레드 / 히스토리)를 Claude Sonnet 4.6 evidence·interactions 기반 해석과 함께 web-ui + agent-office 확장으로 추가한다.

Architecture:

  • 프론트(web-ui): 정적 카드 78장 메타데이터 + 4 페이지 + 3-step 인터랙션 컴포넌트
  • 백엔드(agent-office 확장): tarot_readings 테이블 + /api/agent-office/tarot/* 5 endpoint + Claude Sonnet 호출 with evidence·interactions·confidence 검증
  • 신규 컨테이너·nginx·docker-compose 변경 0건. agent-office 라우터 등록만 추가.

Tech Stack: FastAPI 0.115 + httpx + sqlite3 / React 18 + Vite + React Router v6 / Claude Sonnet 4.6 / Vitest + pytest

Spec: docs/superpowers/specs/2026-05-23-tarot-lab-design.md


File Structure

백엔드 — agent-office 확장

파일 역할
agent-office/app/db.py tarot_readings 테이블 추가 + CRUD 함수 (save_tarot_reading, list_tarot_readings, get_tarot_reading, update_tarot_reading, delete_tarot_reading)
agent-office/app/tarot/__init__.py 모듈 표식
agent-office/app/tarot/prompt.py SYSTEM_PROMPT 상수 + build_user_message(question, category, spread, cards_reference, context_meta)
agent-office/app/tarot/schema.py validate_interpretation(parsed: dict, spread_type: str) -> tuple[bool, str] — evidence·interactions·confidence 필드 검증
agent-office/app/tarot/pipeline.py interpret(req: InterpretRequest) -> InterpretResponse — Claude 호출 + 파싱 폴백 + reroll 1회 + 비용 계산
agent-office/app/routers/tarot.py APIRouter /api/agent-office/tarot — 5 endpoint (interpret, save, list, patch, delete)
agent-office/app/models.py 신규 Pydantic 모델 추가 (TarotCard, InterpretRequest, InterpretResponse, SaveReadingRequest, PatchReadingRequest)
agent-office/app/main.py include_router(tarot_router.router) 1줄 추가
agent-office/app/config.py TAROT_MODEL, TAROT_COST_INPUT_PER_M, TAROT_COST_OUTPUT_PER_M 환경변수
agent-office/tests/test_tarot_schema.py schema 검증 단위 테스트
agent-office/tests/test_tarot_pipeline.py 파싱 폴백·reroll·비용 계산 테스트 (httpx mock)
agent-office/tests/test_tarot_db.py CRUD 단위 테스트
agent-office/tests/test_tarot_routes.py endpoint 통합 테스트 (FastAPI TestClient)

프론트 — web-ui 신규 페이지

파일 역할
web-ui/src/pages/tarot/data/cards.js TAROT_DECK (78장 메타) + SPREADS + CATEGORIES 상수 export
web-ui/src/pages/tarot/data/cards.test.js 78장 검증 (총수, slug 중복, 필수 필드)
web-ui/src/pages/tarot/hooks/useTarotShuffle.js FisherYates 셔플 + 16장 슬라이스 hook
web-ui/src/pages/tarot/hooks/useTarotShuffle.test.js 분포·중복 없음
web-ui/src/pages/tarot/hooks/useTarotReading.js 카드 선택 상태 + reference 블록 빌더 + AI 호출 + 자동 저장 hook
web-ui/src/pages/tarot/hooks/useTarotReading.test.js step 전환, reference 블록 빌드, AI 호출 시나리오
web-ui/src/pages/tarot/components/TarotCard.jsx 단일 카드 (앞·뒷면 토글, props: cardId / reversed / size / clickable / onClick)
web-ui/src/pages/tarot/components/CardGrid.jsx 셔플 16장 그리드
web-ui/src/pages/tarot/components/SpreadSlots.jsx 위치별 슬롯
web-ui/src/pages/tarot/components/InterpretationPanel.jsx 우측 패널 (카드 의미 + AI 텍스트 + evidence 접기)
web-ui/src/pages/tarot/Tarot.jsx 랜딩 페이지
web-ui/src/pages/tarot/TodayCard.jsx 오늘의 카드 페이지
web-ui/src/pages/tarot/Reading.jsx 3장 스프레드 메인 (3-step)
web-ui/src/pages/tarot/History.jsx 마이페이지 (이력)
web-ui/src/pages/tarot/Tarot.css 다크 보라+금 톤 디자인 토큰
web-ui/src/api.js apiPatch 추가 + 8 tarot helper 함수
web-ui/src/components/Icons.jsx IconTarot 추가
web-ui/src/routes.jsx /tarot, /tarot/today, /tarot/reading, /tarot/history 라우트 + navLinks 추가
web-ui/public/videos/tarot_hero.mp4 source/videos/tarot_main_background.mp4 복사본
web-ui/public/images/tarot_background.png source/images/tarot_page/tarot_background.png 복사본
web-ui/public/images/tarot/card_back.svg 신규 SVG (보라+금 모노그램)

실행 환경

  • 백엔드 작업 경로: C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office/
  • 프론트 작업 경로: C:/Users/jaeoh/Desktop/workspace/web-ui/
  • 백엔드 테스트: cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office && pytest tests/test_tarot_*.py -v
  • 프론트 테스트: cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm test -- src/pages/tarot/
  • 백엔드 커밋: web-backend 저장소에서
  • 프론트 커밋: web-ui 저장소에서 (별도 Git)
  • 로컬 백엔드 실행: cd web-backend && docker compose up -d agent-office (NAS만 실행이 원칙이지만 로컬 테스트는 docker로)
  • 로컬 프론트: cd web-ui && npm run devhttp://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 생성:

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.pyinit_db() 함수 안, 마지막 INSERT OR IGNORE 시드 직전에 다음 블록 추가:

        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 함수 추가:

# --- 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: 커밋
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 끝에 추가:

# 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 끝에 추가:

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 검증 — 실행 가능한지만 확인
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: 커밋
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:

"""Tarot Lab — Claude Sonnet 기반 evidence·interactions 해석 파이프라인."""
  • Step 2: 프롬프트 작성

agent-office/app/tarot/prompt.py:

"""Tarot 프롬프트 — SYSTEM + build_user_message."""

SYSTEM_PROMPT = """당신은 라이더-웨이트(RWS) 타로 덱의 전통 상징체계에 정통한 타로 리더입니다.
사용자의 질문, 카테고리, 뽑힌 카드 각각의 정·역방향과 위치를 받아 근거 기반으로 해석합니다.

# 해석 원칙
1. 데이터 우선: "참고 카드 정보" 블록의 키워드·기본의미·상징만을 1차 근거로 사용.
   외부 변형 의미·다른 덱 해석은 사용하지 않음.
2. 위치 의미 결합: 카드의 의미와 위치(과거/현재/미래 또는 오늘)를 명시적으로 결합해서 해석. evidence에 근거 기록.
3. 카드 간 상호작용 분석 (3장 스프레드):
   - 시너지: 같은 슈트, 같은 원소, 메이저 비율, 정·역 흐름
   - 충돌·전환: 슈트 충돌(컵-소드, 완드-펜타클), 정→역 전환, 메이저↔마이너 전환
4. 자기 성찰 톤: 운명론 단정 금지. "…할 가능성이 있어 보입니다" 같은 표현.
5. 카테고리 컨텍스트: 동일 카드라도 카테고리에 따라 강조점이 달라야 함.
6. 질문 직접 응답: 사용자 질문을 evidence·advice에서 인용·반영.

# 응답 형식 (strict JSON only — 코드블록 없이 raw JSON)
{
  "summary": "전체 흐름 한 단락 (3~4문장)",
  "cards": [
    {
      "position": "<위치 라벨>",
      "card": "<card_id>",
      "reversed": <bool>,
      "interpretation": "3~4문장",
      "evidence": {
        "card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징",
        "position_logic": "왜 이 위치에 이렇게 적용되는지 (1~2문장)",
        "category_lens": "카테고리 관점에서 부각되는 면 (1문장)"
      },
      "advice": "1문장"
    }
  ],
  "interactions": [
    { "type": "synergy"|"conflict"|"transition",
      "between": ["<card_id>", "<card_id>"],
      "explanation": "1~2문장" }
  ],
  "advice": "2문장. interactions를 1개 이상 참조할 것.",
  "warning": "역방향·충돌 경계 (없으면 null)",
  "confidence": "high"|"medium"|"low"
}

# confidence 판정 기준
- high: 3장 모두 한 방향 서사 또는 명확한 전환
- medium: 2장 일관, 1장 별도 신호
- low: 카드 간 의미 충돌이 커서 명확한 흐름 잡기 어려움

# 금지사항
- 참고 카드 정보에 없는 상징 도입 금지
- 역방향 카드를 정방향처럼 다루지 말 것
- "신비롭게 들리는" 문구로 채우지 말 것 — evidence에 인용·근거 명시
- JSON 외 텍스트 금지
"""


SPREAD_NAMES = {
    "one_card": "오늘의 카드",
    "three_card": "3장 스프레드 (과거·현재·미래)",
}


def build_user_message(
    question: str,
    category: str,
    spread_type: str,
    cards_reference: str,
    context_meta: dict,
    spread_count: int,
) -> str:
    q = question or "(질문 없음)"
    cat = category or "일반"
    spread_name = SPREAD_NAMES.get(spread_type, spread_type)

    meta_lines = []
    if context_meta:
        if "major_minor_ratio" in context_meta:
            meta_lines.append(f"- 메이저:마이너 비율: {context_meta['major_minor_ratio']}")
        if "element_distribution" in context_meta:
            ed = context_meta["element_distribution"]
            meta_lines.append(
                f"- 원소 분포: 공기 {ed.get('air',0)}, 물 {ed.get('water',0)}, 불 {ed.get('fire',0)}, 흙 {ed.get('earth',0)}"
            )
        if "orientation_flow" in context_meta:
            meta_lines.append(f"- 정역 흐름: {context_meta['orientation_flow']}")
    meta_block = "\n".join(meta_lines) if meta_lines else "(추가 컨텍스트 없음)"

    return f"""# 질문
{q}

# 카테고리
{cat}

# 스프레드
{spread_name} ({spread_count}장)

# 뽑힌 카드와 참고 카드 정보
{cards_reference}

## 추가 컨텍스트
{meta_block}

# 작업
위 정보만을 근거로 사용해, 시스템 지침의 JSON 형식으로 응답하세요.
- 각 카드의 evidence.card_meaning_used에는 위 "참고 카드 정보"에서 발췌한 키워드·의미를 그대로 인용.
- interactions는 3장 간 슈트·원소·정역방향 패턴을 분석해 최소 1개 이상 도출 (1장 스프레드면 빈 배열 허용).
- confidence는 카드 흐름의 일관성에 따라 정직하게 판정.
"""
  • Step 3: import 검증
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: 커밋
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:

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:

"""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: 커밋
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:

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:

"""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 미설치면 추가:

cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office
python -c "import pytest_asyncio" 2>&1 || pip install pytest-asyncio

pytest.ini 또는 pyproject.tomlasyncio_mode = "auto" 있는지 확인. 없으면 agent-office/pytest.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: 커밋
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:

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:

"""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 블록:

from .routers import notify as notify_router

뒤에 추가:

from .routers import tarot as tarot_router

app.include_router(notify_router.router) 다음 줄에 추가:

app.include_router(tarot_router.router)
  • Step 5: 통과 확인

pytest tests/test_tarot_routes.py -v Expected: 5 passed

  • Step 6: 커밋
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에서 실행
$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"

영상 크기 확인:

$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 version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0%" stop-color="#1a0d2e"/>
      <stop offset="100%" stop-color="#0a0420"/>
    </linearGradient>
    <linearGradient id="goldFrame" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#d4af37"/>
      <stop offset="100%" stop-color="#8b6914"/>
    </linearGradient>
  </defs>
  <rect width="200" height="300" rx="14" fill="url(#bg)"/>
  <rect x="8" y="8" width="184" height="284" rx="10" fill="none"
        stroke="url(#goldFrame)" stroke-width="2"/>
  <g transform="translate(100 150)" fill="#d4af37" font-family="serif" text-anchor="middle">
    <circle r="38" fill="none" stroke="#d4af37" stroke-width="1.5"/>
    <text font-size="48" dy="14" font-style="italic">A</text>
    <g opacity=".5">
      <circle cx="-60" cy="-90" r="1.5"/>
      <circle cx="55" cy="-100" r="1"/>
      <circle cx="-50" cy="80" r="1.2"/>
      <circle cx="65" cy="90" r="1"/>
      <circle cx="0" cy="-110" r="1.6"/>
    </g>
  </g>
  <text x="100" y="280" fill="#d4af37" font-family="serif" font-size="10"
        text-anchor="middle" letter-spacing="2">ARCANA TAROT</text>
</svg>
  • Step 3: 자산 존재 확인
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 저장소)
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:

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:

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: 커밋
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:

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:

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: 커밋
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 함수 다음에 추가:

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 추가:

// --- 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:

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:

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: 커밋
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

import React from 'react';

export default function TarotCard({
  card, reversed = false, size = 'md', faceDown = false,
  clickable = false, onClick, label,
}) {
  const sizeClass = size === 'sm' ? 'tarot-card--sm' : size === 'lg' ? 'tarot-card--lg' : 'tarot-card--md';

  const handleClick = (e) => {
    if (!clickable) return;
    onClick?.(card, e);
  };

  if (faceDown || !card) {
    return (
      <button
        type="button"
        className={`tarot-card tarot-card--back ${sizeClass} ${clickable ? 'is-clickable' : ''}`}
        onClick={handleClick}
        aria-label={label || '카드 뒷면'}
        disabled={!clickable}
      >
        <img src="/images/tarot/card_back.svg" alt="" draggable={false} />
      </button>
    );
  }

  const styleClass = reversed ? 'tarot-card--reversed' : '';
  const onImgError = (e) => { e.currentTarget.style.display = 'none'; };

  return (
    <button
      type="button"
      className={`tarot-card tarot-card--face ${sizeClass} ${styleClass} ${clickable ? 'is-clickable' : ''}`}
      onClick={handleClick}
      aria-label={`${card.name}${reversed ? ' 역방향' : ''}`}
      disabled={!clickable}
    >
      <div className="tarot-card__inner">
        <img src={card.image} alt="" onError={onImgError} draggable={false} />
        <div className="tarot-card__fallback">
          <div className="tarot-card__symbol">{card.arcana === 'major' ? '✦' : '◆'}</div>
          <div className="tarot-card__name">{card.name}</div>
          <div className="tarot-card__name-en">{card.nameEn}</div>
        </div>
      </div>
      {label && <div className="tarot-card__label">{label}</div>}
    </button>
  );
}
  • Step 2: CardGrid.jsx
import React from 'react';
import TarotCard from './TarotCard';

export default function CardGrid({ slice, onPick, disabledIds = [] }) {
  return (
    <div className="tarot-grid">
      {slice.map((card) => {
        const disabled = disabledIds.includes(card.slug);
        return (
          <TarotCard
            key={card.slug}
            card={card}
            faceDown
            clickable={!disabled}
            onClick={() => !disabled && onPick(card)}
          />
        );
      })}
    </div>
  );
}
  • Step 3: SpreadSlots.jsx
import React from 'react';
import TarotCard from './TarotCard';

export default function SpreadSlots({ spread, picks, onCardClick }) {
  return (
    <div className="tarot-slots">
      {spread.positions.map((pos) => {
        const pick = picks[pos.idx];
        return (
          <div key={pos.idx} className="tarot-slots__cell">
            <div className="tarot-slots__label">{pos.label}</div>
            {pick ? (
              <TarotCard
                card={pick.card}
                reversed={pick.reversed}
                clickable
                onClick={() => onCardClick(pos.idx)}
              />
            ) : (
              <div className="tarot-slots__empty">_</div>
            )}
          </div>
        );
      })}
    </div>
  );
}
  • Step 4: InterpretationPanel.jsx
import React, { useState } from 'react';

function ConfidenceBadge({ level }) {
  if (!level) return null;
  const cls = level === 'high' ? 'is-high' : level === 'low' ? 'is-low' : 'is-medium';
  const text = level === 'high' ? '높음' : level === 'low' ? '낮음' : '보통';
  return <span className={`tarot-confidence ${cls}`}>확신 {text}</span>;
}

export default function InterpretationPanel({ interpretation, selectedCard, focusCardId }) {
  const [showEvidence, setShowEvidence] = useState(true);

  if (!interpretation) {
    return (
      <aside className="tarot-panel tarot-panel--empty">
        <p>카드를 모두 뽑은  AI 해석을 시작하세요.</p>
      </aside>
    );
  }

  const cardDetail = focusCardId
    ? (interpretation.cards || []).find((c) => c.card === focusCardId)
    : null;

  return (
    <aside className="tarot-panel">
      {selectedCard && (
        <header className="tarot-panel__head">
          <h3 className="tarot-panel__title">{selectedCard.name}</h3>
          <p className="tarot-panel__sub">{selectedCard.nameEn}</p>
          <div className="tarot-panel__chips">
            {(selectedCard.keywords || []).slice(0, 4).map((k) => (
              <span key={k} className="tarot-chip">{k}</span>
            ))}
          </div>
        </header>
      )}

      {cardDetail && (
        <section className="tarot-panel__section">
          <h4> 위치의 해석</h4>
          <p>{cardDetail.interpretation}</p>
          <p className="tarot-panel__advice">💡 {cardDetail.advice}</p>
          <button
            type="button"
            className="tarot-panel__toggle"
            onClick={() => setShowEvidence((v) => !v)}
          >
            {showEvidence ? '근거 접기' : '근거 펼치기'}
          </button>
          {showEvidence && cardDetail.evidence && (
            <dl className="tarot-evidence">
              <dt>카드 의미</dt>
              <dd>{cardDetail.evidence.card_meaning_used}</dd>
              <dt>위치 결합</dt>
              <dd>{cardDetail.evidence.position_logic}</dd>
              <dt>카테고리 관점</dt>
              <dd>{cardDetail.evidence.category_lens}</dd>
            </dl>
          )}
        </section>
      )}

      <section className="tarot-panel__section">
        <h4>종합 해석 <ConfidenceBadge level={interpretation.confidence} /></h4>
        <p>{interpretation.summary}</p>
        <p className="tarot-panel__advice">💡 {interpretation.advice}</p>
        {interpretation.warning && (
          <p className="tarot-panel__warning">⚠️ {interpretation.warning}</p>
        )}
      </section>

      {(interpretation.interactions || []).length > 0 && (
        <section className="tarot-panel__section">
          <h4>카드 상호작용</h4>
          <ul className="tarot-interactions">
            {interpretation.interactions.map((it, i) => (
              <li key={i}>
                <span className={`tarot-interaction-type tarot-interaction-type--${it.type}`}>
                  {it.type === 'synergy' ? '시너지' : it.type === 'conflict' ? '충돌' : '전환'}
                </span>
                {' '}
                <strong>{(it.between || []).join(' ↔ ')}</strong>
                <p>{it.explanation}</p>
              </li>
            ))}
          </ul>
        </section>
      )}
    </aside>
  );
}
  • Step 5: 빌드 검증

cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run build Expected: 성공 (TarotCard 등이 아직 import되지 않아도 syntax 검증)

  • Step 6: 커밋
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:

/* 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: 커밋
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: 구현

import React, { useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import './Tarot.css';

export default function Tarot() {
  const videoRef = useRef(null);
  useEffect(() => {
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const saveData = navigator.connection?.saveData;
    if ((reduced || saveData) && videoRef.current) {
      videoRef.current.pause();
      videoRef.current.style.display = 'none';
    }
  }, []);

  return (
    <div className="tarot tarot--landing">
      <video
        ref={videoRef}
        className="tarot__hero-video"
        src="/videos/tarot_hero.mp4"
        autoPlay loop muted playsInline preload="metadata"
        poster="/images/tarot_background.png"
      />
      <img className="tarot__hero-poster" src="/images/tarot_background.png" alt="" aria-hidden />
      <div className="tarot__hero-overlay" />

      <div className="tarot__hero-content">
        <header className="tarot__brand">
          <span></span>
          <span>ARCANA TAROT</span>
        </header>

        <nav className="tarot__nav" aria-label="Tarot navigation">
          <Link to="/tarot/today">오늘의 카드</Link>
          <Link to="/tarot/reading">타로 리딩</Link>
          <Link to="/tarot/history">히스토리</Link>
        </nav>

        <h1 className="tarot__h1">당신의 오늘을<br />비추는 타로</h1>
        <p className="tarot__sub">
          카드를 펼쳐, 당신만의 인사이트를 받아보세요.
          라이더-웨이트 덱과 Claude Sonnet 4.6 해석이 함께합니다.
        </p>

        <div className="tarot__cta-row">
          <Link to="/tarot/reading" className="tarot__cta">지금 시작하기 </Link>
          <Link to="/tarot/today" className="tarot__cta tarot__cta--secondary">오늘의 카드</Link>
        </div>

        <div className="tarot__tier-row">
          <article className="tarot__tier">
            <h3>🌙 오늘의 운세</h3>
            <p>매일 1장의 카드로 오늘의 흐름을 확인하세요.</p>
          </article>
          <article className="tarot__tier">
            <h3>🃏 3 스프레드</h3>
            <p>과거·현재·미래의 흐름을 한눈에 읽어봅니다.</p>
          </article>
          <article className="tarot__tier">
            <h3> AI 해석</h3>
            <p>Claude Sonnet 4.6 카드 의미·위치·상호작용을 근거로 풀어줍니다.</p>
          </article>
        </div>
      </div>
    </div>
  );
}
  • Step 2: 빌드 검증

npm run build Expected: 성공

  • Step 3: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/pages/tarot/Tarot.jsx
git commit -m "feat(tarot): 랜딩 페이지 Tarot.jsx (T13)"

Task 14: TodayCard.jsx 오늘의 카드 페이지

Files:

  • Create: web-ui/src/pages/tarot/TodayCard.jsx

  • Step 1: 구현

import React, { useMemo, useState } from 'react';
import './Tarot.css';
import { TAROT_DECK, CATEGORIES, SPREADS } from './data/cards';
import { useTarotReading } from './hooks/useTarotReading';
import TarotCard from './components/TarotCard';
import InterpretationPanel from './components/InterpretationPanel';

export default function TodayCard() {
  const [category, setCategory] = useState('일반');
  const [question, setQuestion] = useState('');
  const [pick, setPick] = useState(null);
  const { status, interpretation, runInterpretAndSave, error } = useTarotReading();

  const drawCard = () => {
    const idx = Math.floor(Math.random() * TAROT_DECK.length);
    const reversed = Math.random() < 0.5;
    const card = TAROT_DECK[idx];
    setPick({ card, position: '오늘', reversed });
  };

  const handleStart = async () => {
    drawCard();
  };

  const handleInterpret = async () => {
    if (!pick) return;
    try {
      await runInterpretAndSave({
        spread_type: 'one_card',
        category,
        question: question.trim() || null,
        picks: [pick],
      });
    } catch (e) {
      // error는 hook의 state로 전달됨
    }
  };

  const selectedCardMeaning = pick?.card || null;
  const focusCardId = pick?.card?.slug;

  const busy = status === 'interpreting' || status === 'saving';

  return (
    <div className="tarot tarot-reading">
      <aside className="tarot-reading__col">
        <div className="tarot-reading__step-label">오늘의 카드</div>
        <label className="tarot-reading__step-label">질문 (선택)</label>
        <textarea
          className="tarot-reading__textarea"
          value={question}
          onChange={(e) => setQuestion(e.target.value)}
          placeholder="오늘 무엇이 궁금한가요?"
        />
        <div className="tarot-reading__step-label" style={{ marginTop: 16 }}>카테고리</div>
        <div className="tarot-reading__chips">
          {CATEGORIES.map((c) => (
            <button
              key={c}
              className={`tarot-chip ${category === c ? 'is-active' : ''}`}
              onClick={() => setCategory(c)}
            >{c}</button>
          ))}
        </div>
        {!pick && (
          <button className="tarot-reading__primary" onClick={handleStart}>
            카드 뽑기
          </button>
        )}
        {pick && !interpretation && (
          <button className="tarot-reading__primary" onClick={handleInterpret} disabled={busy}>
            {busy ? '해석 중…' : 'AI 해석 시작'}
          </button>
        )}
        {pick && interpretation && (
          <button className="tarot-reading__primary" onClick={() => { setPick(null); }}>
            다시 뽑기
          </button>
        )}
        {error && <p style={{ color: '#f43f5e', marginTop: 12, fontSize: 13 }}>오류: {error}</p>}
      </aside>

      <div className="tarot-reading__col" style={{ display: 'grid', placeItems: 'center', minHeight: 320 }}>
        {pick ? (
          <TarotCard card={pick.card} reversed={pick.reversed} size="lg" label={pick.position} />
        ) : (
          <p style={{ color: 'var(--tarot-text-dim)' }}>좌측에서 "카드 뽑기" 눌러보세요.</p>
        )}
      </div>

      <InterpretationPanel
        interpretation={interpretation}
        selectedCard={selectedCardMeaning}
        focusCardId={focusCardId}
      />
    </div>
  );
}
  • Step 2: 빌드 검증

npm run build Expected: 성공

  • Step 3: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/pages/tarot/TodayCard.jsx
git commit -m "feat(tarot): TodayCard.jsx — 원카드 페이지 (T14)"

Task 15: Reading.jsx 3장 스프레드 페이지

Files:

  • Create: web-ui/src/pages/tarot/Reading.jsx

  • Step 1: 구현

import React, { useMemo, useState } from 'react';
import './Tarot.css';
import { TAROT_DECK, CATEGORIES, SPREADS } from './data/cards';
import { useTarotShuffle } from './hooks/useTarotShuffle';
import { useTarotReading } from './hooks/useTarotReading';
import TarotCard from './components/TarotCard';
import CardGrid from './components/CardGrid';
import SpreadSlots from './components/SpreadSlots';
import InterpretationPanel from './components/InterpretationPanel';

export default function Reading() {
  const [category, setCategory] = useState('일반');
  const [question, setQuestion] = useState('');
  const [spreadId, setSpreadId] = useState('three_card');
  const [step, setStep] = useState(1);  // 1: 입력, 2: 뽑기, 3: 해석
  const [picks, setPicks] = useState([]);
  const [focusIdx, setFocusIdx] = useState(null);

  const spread = SPREADS[spreadId];
  const { slice, reshuffle } = useTarotShuffle(TAROT_DECK, 16);
  const { status, interpretation, runInterpretAndSave, error } = useTarotReading();

  const startShuffle = () => {
    reshuffle();
    setPicks([]);
    setFocusIdx(null);
    setStep(2);
  };

  const handlePick = (card) => {
    if (picks.length >= spread.positions.length) return;
    const idx = picks.length;
    const pos = spread.positions[idx];
    const next = [...picks, { card, position: pos.label, reversed: card.reversed }];
    setPicks(next);
    if (next.length === spread.positions.length) {
      setFocusIdx(0);
    }
  };

  const handleInterpret = async () => {
    try {
      await runInterpretAndSave({
        spread_type: spreadId, category,
        question: question.trim() || null, picks,
      });
      setStep(3);
    } catch { /* error는 hook state */ }
  };

  const disabledIds = picks.map((p) => p.card.slug);
  const focusCard = focusIdx !== null && picks[focusIdx] ? picks[focusIdx].card : null;
  const focusCardId = focusCard?.slug;
  const allPicked = picks.length === spread.positions.length;
  const busy = status === 'interpreting' || status === 'saving';

  return (
    <div className="tarot tarot-reading">
      <aside className="tarot-reading__col">
        <div className="tarot-reading__step-label">1. 질문</div>
        <textarea
          className="tarot-reading__textarea"
          value={question}
          onChange={(e) => setQuestion(e.target.value)}
          placeholder="궁금한 것을 적어주세요"
        />

        <div className="tarot-reading__step-label" style={{ marginTop: 16 }}>2. 카테고리</div>
        <div className="tarot-reading__chips">
          {CATEGORIES.map((c) => (
            <button
              key={c}
              className={`tarot-chip ${category === c ? 'is-active' : ''}`}
              onClick={() => setCategory(c)}
            >{c}</button>
          ))}
        </div>

        <div className="tarot-reading__step-label" style={{ marginTop: 16 }}>3. 스프레드</div>
        <div className="tarot-reading__radio-row">
          <label>
            <input type="radio" checked={spreadId === 'three_card'}
                   onChange={() => setSpreadId('three_card')} /> 3 (과거/현재/미래)
          </label>
          <label>
            <input type="radio" checked={spreadId === 'one_card'}
                   onChange={() => setSpreadId('one_card')} /> 1 (오늘의 카드)
          </label>
        </div>

        <button className="tarot-reading__primary" onClick={startShuffle}>
           카드 셔플하기
        </button>

        {step >= 2 && allPicked && step < 3 && (
          <button className="tarot-reading__primary" onClick={handleInterpret} disabled={busy}>
            {busy ? '해석 중…' : 'AI 해석 시작'}
          </button>
        )}

        {step === 3 && (
          <button className="tarot-reading__primary" onClick={() => { setStep(1); setPicks([]); setFocusIdx(null); }}>
             리딩
          </button>
        )}

        {error && <p style={{ color: '#f43f5e', marginTop: 12, fontSize: 13 }}>{error}</p>}
      </aside>

      <div className="tarot-reading__col">
        {step < 2 && <p style={{ color: 'var(--tarot-text-dim)' }}>좌측에서 질문·카테고리·스프레드를 선택하고 셔플하세요.</p>}
        {step === 2 && (
          <>
            {!allPicked && (
              <>
                <p style={{ color: 'var(--tarot-text-dim)', marginBottom: 16 }}>
                  카드 {picks.length + 1}/{spread.positions.length}  {spread.positions[picks.length].label}
                </p>
                <CardGrid slice={slice} onPick={handlePick} disabledIds={disabledIds} />
              </>
            )}
            <SpreadSlots
              spread={spread} picks={picks}
              onCardClick={(idx) => setFocusIdx(idx)}
            />
          </>
        )}
        {step === 3 && (
          <SpreadSlots
            spread={spread} picks={picks}
            onCardClick={(idx) => setFocusIdx(idx)}
          />
        )}
      </div>

      <InterpretationPanel
        interpretation={interpretation}
        selectedCard={focusCard}
        focusCardId={focusCardId}
      />
    </div>
  );
}
  • Step 2: 빌드 검증

npm run build Expected: 성공

  • Step 3: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/pages/tarot/Reading.jsx
git commit -m "feat(tarot): Reading.jsx — 3장 스프레드 메인 (T15)"

Task 16: History.jsx 마이페이지

Files:

  • Create: web-ui/src/pages/tarot/History.jsx

  • Step 1: 구현

import React, { useCallback, useEffect, useState } from 'react';
import './Tarot.css';
import { tarotListReadings, tarotPatchReading, tarotDeleteReading } from '../../api';
import { findCard, SPREADS } from './data/cards';

function pickLine(r) {
  const labels = (r.cards || []).map((c) => {
    const card = findCard(c.card_id);
    const name = card ? card.name : c.card_id;
    return `${c.position} · ${name}${c.reversed ? '(역)' : ''}`;
  });
  return labels.join(' / ');
}

export default function History() {
  const [items, setItems] = useState([]);
  const [total, setTotal] = useState(0);
  const [page, setPage] = useState(1);
  const [favoriteOnly, setFavoriteOnly] = useState(false);
  const [spreadFilter, setSpreadFilter] = useState('');
  const [categoryFilter, setCategoryFilter] = useState('');
  const [loading, setLoading] = useState(false);
  const [openId, setOpenId] = useState(null);

  const load = useCallback(async () => {
    setLoading(true);
    try {
      const res = await tarotListReadings({
        page, size: 20,
        favorite: favoriteOnly || undefined,
        spread_type: spreadFilter || undefined,
        category: categoryFilter || undefined,
      });
      setItems(res.items);
      setTotal(res.total);
    } finally {
      setLoading(false);
    }
  }, [page, favoriteOnly, spreadFilter, categoryFilter]);

  useEffect(() => { load(); }, [load]);

  const toggleFav = async (id, cur) => {
    await tarotPatchReading(id, { favorite: !cur });
    load();
  };

  const remove = async (id) => {
    if (!window.confirm('삭제할까요?')) return;
    await tarotDeleteReading(id);
    load();
  };

  return (
    <div className="tarot tarot-history">
      <h2 style={{ fontFamily: 'Cormorant Garamond, serif', fontSize: 32, marginBottom: 16 }}>
        리딩 히스토리
      </h2>

      <div className="tarot-reading__chips" style={{ marginBottom: 16 }}>
        <button className={`tarot-chip ${favoriteOnly ? 'is-active' : ''}`}
                onClick={() => setFavoriteOnly((v) => !v)}>
           즐겨찾기만
        </button>
        <button className={`tarot-chip ${spreadFilter === 'three_card' ? 'is-active' : ''}`}
                onClick={() => setSpreadFilter((v) => v === 'three_card' ? '' : 'three_card')}>
          3
        </button>
        <button className={`tarot-chip ${spreadFilter === 'one_card' ? 'is-active' : ''}`}
                onClick={() => setSpreadFilter((v) => v === 'one_card' ? '' : 'one_card')}>
          1
        </button>
      </div>

      {loading && <p style={{ color: 'var(--tarot-text-dim)' }}>불러오는 </p>}
      {!loading && items.length === 0 && <p style={{ color: 'var(--tarot-text-dim)' }}>리딩 기록이 없습니다.</p>}

      {items.map((r) => (
        <div key={r.id} className="tarot-history__item">
          <div>
            <div style={{ fontSize: 12, color: 'var(--tarot-text-dim)' }}>
              {r.created_at} · {SPREADS[r.spread_type]?.name || r.spread_type}
              {r.category ? ` · ${r.category}` : ''}
            </div>
            <div style={{ marginTop: 6 }}>{r.question || '(질문 없음)'}</div>
            <div style={{ marginTop: 6, fontSize: 13, color: 'var(--tarot-gold)' }}>
              {pickLine(r)}
            </div>
            <p style={{ marginTop: 8, fontSize: 13, color: 'var(--tarot-text-dim)' }}>
              {r.summary}
            </p>
            {openId === r.id && r.interpretation_json && (
              <div style={{ marginTop: 12, padding: 12, background: 'rgba(0,0,0,.2)', borderRadius: 6 }}>
                <p style={{ fontSize: 13 }}>{r.interpretation_json.advice}</p>
                {r.interpretation_json.warning && (
                  <p style={{ fontSize: 13, color: '#f43f5e' }}>⚠️ {r.interpretation_json.warning}</p>
                )}
              </div>
            )}
            <button
              onClick={() => setOpenId(openId === r.id ? null : r.id)}
              style={{ marginTop: 8, fontSize: 12, background: 'transparent', border: '1px solid rgba(255,255,255,.15)', color: 'var(--tarot-text-dim)', padding: '4px 8px', borderRadius: 4, cursor: 'pointer' }}
            >
              {openId === r.id ? '접기' : '자세히'}
            </button>
          </div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
            <button
              className={`tarot-history__star ${r.favorite ? 'is-fav' : ''}`}
              onClick={() => toggleFav(r.id, r.favorite)}
              aria-label="즐겨찾기 토글"
            ></button>
            <button
              onClick={() => remove(r.id)}
              style={{ background: 'transparent', border: 'none', color: 'var(--tarot-text-dim)', cursor: 'pointer', fontSize: 12 }}
              aria-label="삭제"
            >삭제</button>
          </div>
        </div>
      ))}

      {total > 20 && (
        <div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'center' }}>
          <button className="tarot-chip" disabled={page === 1} onClick={() => setPage((p) => p - 1)}>이전</button>
          <span style={{ color: 'var(--tarot-text-dim)', alignSelf: 'center' }}>{page} / {Math.ceil(total / 20)}</span>
          <button className="tarot-chip" disabled={page * 20 >= total} onClick={() => setPage((p) => p + 1)}>다음</button>
        </div>
      )}
    </div>
  );
}
  • Step 2: 빌드 검증

npm run build Expected: 성공

  • Step 3: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/pages/tarot/History.jsx
git commit -m "feat(tarot): History.jsx — 마이페이지 (T16)"

Files:

  • Modify: web-ui/src/routes.jsx

  • Modify: web-ui/src/components/Icons.jsx

  • Step 1: Icons.jsx에 IconTarot 추가

web-ui/src/components/Icons.jsx를 Read해서 다른 아이콘 패턴 확인 후 다음 컴포넌트를 추가:

export const IconTarot = () => (
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
       strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
    <rect x="5" y="3" width="14" height="18" rx="2" />
    <path d="M12 7v10M9 12h6" />
    <circle cx="12" cy="12" r="3" />
  </svg>
);

(기존 아이콘들의 export 패턴에 맞춰 위치)

  • Step 2: routes.jsx에 페이지 + 라우트 + navLink 추가

web-ui/src/routes.jsx:

상단 lazy import 블록에 추가:

const Tarot = lazy(() => import('./pages/tarot/Tarot'));
const TarotTodayCard = lazy(() => import('./pages/tarot/TodayCard'));
const TarotReading = lazy(() => import('./pages/tarot/Reading'));
const TarotHistory = lazy(() => import('./pages/tarot/History'));

상단 import에 IconTarot 추가:

import {
    IconHome,
    // ...기존
    IconTarot,
} from './components/Icons';

navLinks 배열 끝(lab 다음)에 추가:

    {
        id: 'tarot',
        label: 'Tarot',
        path: '/tarot',
        subtitle: 'ARCANA',
        description: '라이더-웨이트 카드로 오늘과 내일을 비추는 리딩 랩',
        icon: <IconTarot />,
        accent: '#a78bfa',
    },

appRoutes 배열에 추가:

    { path: 'tarot', element: <Tarot /> },
    { path: 'tarot/today', element: <TarotTodayCard /> },
    { path: 'tarot/reading', element: <TarotReading /> },
    { path: 'tarot/history', element: <TarotHistory /> },
  • Step 3: 빌드·라우트 동작 검증
cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run build

Expected: 성공

dev 서버 띄워 라우트 동작 직접 확인:

npm run dev

브라우저로 다음 5개 URL 모두 접근 (오류 없이 페이지 렌더):

cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/routes.jsx src/components/Icons.jsx
git commit -m "feat(tarot): 라우팅 4 페이지 + navLinks 추가 (T17)"

Task 18: 통합 동작 검증 (스모크 테스트)

Files: 없음 (수동 QA)

  • Step 1: 백엔드 로컬 실행 + API 호출 확인
cd C:/Users/jaeoh/Desktop/workspace/web-backend
docker compose up -d agent-office
docker compose logs -f agent-office | head -50

agent-office가 healthy 되면 (/health 200) 다음 호출:

# 빈 리스트 확인
curl -s http://localhost:18900/api/agent-office/tarot/readings | head -c 200

# interpret은 ANTHROPIC_API_KEY 필요 → .env 설정되어 있다면 실제 호출,
# 없으면 500 + "ANTHROPIC_API_KEY missing" 확인
curl -X POST http://localhost:18900/api/agent-office/tarot/interpret \
  -H "Content-Type: application/json" \
  -d '{"spread_type":"one_card","category":"일반","question":"오늘은?","cards":[{"position":"오늘","card_id":"the-fool","reversed":false}],"cards_reference":"## 1. 위치: 오늘 | 카드: The Fool (정방향)\n- 키워드: 새로운 시작","context_meta":{}}' \
  | head -c 500

Expected:

  • /api/agent-office/tarot/readings{"items":[],"page":1,"size":20,"total":0}

  • /api/agent-office/tarot/interpret → 200 + interpretation_json 또는 500 + "ANTHROPIC_API_KEY missing"

  • Step 2: 프론트 dev 서버 + E2E 흐름 확인

cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run dev

브라우저로:

  1. http://localhost:3007/tarot — 랜딩 페이지 영상 재생 확인 + 3-tier 카드 + CTA 버튼
  2. "지금 시작하기" → /tarot/reading 이동
  3. 질문 입력 + 카테고리 선택 + "카드 셔플하기" → 16장 그리드 표시
  4. 3장 클릭 → 슬롯에 채워짐
  5. "AI 해석 시작" → ANTHROPIC_API_KEY 있을 시 interpretation 표시 / 없으면 좌측에 오류 메시지
  6. /tarot/history → 빈 상태 또는 방금 저장된 리딩 표시
  7. /tarot/today → 카드 뽑기 + AI 해석 흐름 확인
  • Step 3: 백엔드 전체 테스트 한 번 더
cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office
pytest tests/test_tarot_*.py -v

Expected: 모든 tarot 테스트 통과 (T1 4 + T4 6 + T5 6 + T6 5 = 21)

  • Step 4: 프론트 전체 테스트
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm test -- src/pages/tarot/ --run

Expected: 모든 tarot 테스트 통과

  • Step 5: 정리 커밋 (필요 시)

스모크에서 발견된 사소한 버그는 즉시 fix + 별도 커밋:

git commit -m "fix(tarot): <발견된 이슈>"
  • Step 6: 완료 알림

작업 완료. 사용자에게:

  • 백엔드 변경: git push → Gitea Webhook → NAS agent-office 재빌드
  • 프론트 변경: cd web-ui && npm run release:nas 실행 필요
  • ANTHROPIC_API_KEY는 이미 운영 .env에 있음 — 추가 설정 없음
  • 카드 78장 이미지 자산은 추후 web-ui/public/images/tarot/cards/<slug>.png 추가로 자동 매핑

Self-Review

1. Spec 커버리지 체크

Spec 섹션 Task 확인
§2 아키텍처 (agent-office 확장) T1·T6
§3 프론트 데이터 모델 (cards.js, SPREADS, CATEGORIES) T8
§4 백엔드 데이터 모델 (tarot_readings) T1
§5.1 POST /interpret T5·T6
§5.2 POST /readings T6
§5.3 GET /readings T6
§5.4 PATCH /readings/{id} T6
§5.5 DELETE /readings/{id} T6
§6 AI 프롬프트 (SYSTEM + USER_TEMPLATE + evidence·interactions·confidence) T3·T4·T5
§6 응답 검증 (reroll 1회) T5
§7.1 Routes (/tarot, /today, /reading, /history) T17
§7.2 랜딩 (히어로 영상 + 3-tier) T13
§7.3 3장 스프레드 3-step T15
§7.4 오늘의 카드 T14
§7.5 히스토리 T16
§7.6 공용 컴포넌트 (5개) + hooks (2개) T9·T10·T11
§7.7 디자인 토큰 + CSS T12
§7.8 navLinks 추가 T17
§8 미디어 자산 (영상·이미지·card_back.svg) T7
§9 테스트 전략 (프론트 Vitest + 백엔드 pytest) T1·T4·T5·T6·T8·T9·T10
§10 배포 (docker-compose·nginx 변경 0건) — (T18 검증)

2. Placeholder 스캔

  • "TBD"/"TODO"/"implement later" — 코드에 없음
  • 모든 함수·메서드 시그니처가 다른 task에서 사용되기 전에 정의됨
  • 모든 코드 스텝에 실제 코드 포함

3. Type Consistency

  • save_tarot_reading(data: dict) -> int — T1 정의, T6에서 사용 일관
  • list_tarot_readings(page, size, favorite, spread_type, category) — T1 정의, T6에서 사용 일관
  • update_tarot_reading(reading_id, **kwargs) — T1 정의, T6에서 **req.model_dump(exclude_none=True) 호출 일관 (favorite, note만 처리)
  • delete_tarot_reading(reading_id) — T1 정의, T6에서 사용 일관
  • TarotInterpretRequest 필드 (spread_type, cards, cards_reference, context_meta) — T2 정의, T5·T6 사용 일관
  • interpret(req) -> dict 반환 키 (interpretation_json, model, tokens_in/out, cost_usd, latency_ms, reroll_count) — T5 정의, T6 응답 모델 TarotInterpretResponse (T2)와 일관
  • buildReferenceBlock, buildContextMeta, useTarotReading.runInterpretAndSave — T10 정의, T14·T15에서 사용 일관
  • SPREADS.three_card.positions[i].label — T8 정의 ('과거','현재','미래'), T15 사용 일관
  • TAROT_DECK[i].slug — 카드 식별자로 T8 정의, T5 prompt evidence와 T10 buildReferenceBlock에서 일관 사용
  • interpretation_json.cards[i].card (card_id 참조) — schema (T4) + prompt (T3) + frontend InterpretationPanel.focusCardId (T11) 일관 (card.slug = card_id)

4. 작업 순서 의존성

  • T1 (DB) → T6 (라우터)
  • T2 (모델) → T5 (파이프라인) → T6 (라우터)
  • T3 (프롬프트) → T5
  • T4 (스키마) → T5
  • T7 (미디어) → T13 (랜딩에서 영상 참조)
  • T8 (카드 데이터) → T9·T10·T14·T15·T16
  • T9·T10 (hooks) → T11 (컴포넌트는 hooks를 직접 사용 안 하지만 props로 받음) → T14·T15
  • T11·T12 (컴포넌트·CSS) → T13·T14·T15·T16
  • T17 (라우팅) → T18 (스모크)

T1·T2·T3·T4·T7·T8 은 서로 독립적이라 병렬 가능.


총 18 task. 백엔드 6개 + 프론트 11개 + 통합 검증 1개.