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>
119 KiB
Tarot Lab v1 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 라이더-웨이트 타로 리딩 4 페이지(랜딩 / 오늘의 카드 / 3장 스프레드 / 히스토리)를 Claude Sonnet 4.6 evidence·interactions 기반 해석과 함께 web-ui + agent-office 확장으로 추가한다.
Architecture:
- 프론트(
web-ui): 정적 카드 78장 메타데이터 + 4 페이지 + 3-step 인터랙션 컴포넌트 - 백엔드(
agent-office확장):tarot_readings테이블 +/api/agent-office/tarot/*5 endpoint + Claude Sonnet 호출 with evidence·interactions·confidence 검증 - 신규 컨테이너·nginx·docker-compose 변경 0건. agent-office 라우터 등록만 추가.
Tech Stack: FastAPI 0.115 + httpx + sqlite3 / React 18 + Vite + React Router v6 / Claude Sonnet 4.6 / Vitest + pytest
Spec: docs/superpowers/specs/2026-05-23-tarot-lab-design.md
File Structure
백엔드 — agent-office 확장
| 파일 | 역할 |
|---|---|
agent-office/app/db.py |
tarot_readings 테이블 추가 + CRUD 함수 (save_tarot_reading, list_tarot_readings, get_tarot_reading, update_tarot_reading, delete_tarot_reading) |
agent-office/app/tarot/__init__.py |
모듈 표식 |
agent-office/app/tarot/prompt.py |
SYSTEM_PROMPT 상수 + build_user_message(question, category, spread, cards_reference, context_meta) |
agent-office/app/tarot/schema.py |
validate_interpretation(parsed: dict, spread_type: str) -> tuple[bool, str] — evidence·interactions·confidence 필드 검증 |
agent-office/app/tarot/pipeline.py |
interpret(req: InterpretRequest) -> InterpretResponse — Claude 호출 + 파싱 폴백 + reroll 1회 + 비용 계산 |
agent-office/app/routers/tarot.py |
APIRouter /api/agent-office/tarot — 5 endpoint (interpret, save, list, patch, delete) |
agent-office/app/models.py |
신규 Pydantic 모델 추가 (TarotCard, InterpretRequest, InterpretResponse, SaveReadingRequest, PatchReadingRequest) |
agent-office/app/main.py |
include_router(tarot_router.router) 1줄 추가 |
agent-office/app/config.py |
TAROT_MODEL, TAROT_COST_INPUT_PER_M, TAROT_COST_OUTPUT_PER_M 환경변수 |
agent-office/tests/test_tarot_schema.py |
schema 검증 단위 테스트 |
agent-office/tests/test_tarot_pipeline.py |
파싱 폴백·reroll·비용 계산 테스트 (httpx mock) |
agent-office/tests/test_tarot_db.py |
CRUD 단위 테스트 |
agent-office/tests/test_tarot_routes.py |
endpoint 통합 테스트 (FastAPI TestClient) |
프론트 — web-ui 신규 페이지
| 파일 | 역할 |
|---|---|
web-ui/src/pages/tarot/data/cards.js |
TAROT_DECK (78장 메타) + SPREADS + CATEGORIES 상수 export |
web-ui/src/pages/tarot/data/cards.test.js |
78장 검증 (총수, slug 중복, 필수 필드) |
web-ui/src/pages/tarot/hooks/useTarotShuffle.js |
Fisher–Yates 셔플 + 16장 슬라이스 hook |
web-ui/src/pages/tarot/hooks/useTarotShuffle.test.js |
분포·중복 없음 |
web-ui/src/pages/tarot/hooks/useTarotReading.js |
카드 선택 상태 + reference 블록 빌더 + AI 호출 + 자동 저장 hook |
web-ui/src/pages/tarot/hooks/useTarotReading.test.js |
step 전환, reference 블록 빌드, AI 호출 시나리오 |
web-ui/src/pages/tarot/components/TarotCard.jsx |
단일 카드 (앞·뒷면 토글, props: cardId / reversed / size / clickable / onClick) |
web-ui/src/pages/tarot/components/CardGrid.jsx |
셔플 16장 그리드 |
web-ui/src/pages/tarot/components/SpreadSlots.jsx |
위치별 슬롯 |
web-ui/src/pages/tarot/components/InterpretationPanel.jsx |
우측 패널 (카드 의미 + AI 텍스트 + evidence 접기) |
web-ui/src/pages/tarot/Tarot.jsx |
랜딩 페이지 |
web-ui/src/pages/tarot/TodayCard.jsx |
오늘의 카드 페이지 |
web-ui/src/pages/tarot/Reading.jsx |
3장 스프레드 메인 (3-step) |
web-ui/src/pages/tarot/History.jsx |
마이페이지 (이력) |
web-ui/src/pages/tarot/Tarot.css |
다크 보라+금 톤 디자인 토큰 |
web-ui/src/api.js |
apiPatch 추가 + 8 tarot helper 함수 |
web-ui/src/components/Icons.jsx |
IconTarot 추가 |
web-ui/src/routes.jsx |
/tarot, /tarot/today, /tarot/reading, /tarot/history 라우트 + navLinks 추가 |
web-ui/public/videos/tarot_hero.mp4 |
source/videos/tarot_main_background.mp4 복사본 |
web-ui/public/images/tarot_background.png |
source/images/tarot_page/tarot_background.png 복사본 |
web-ui/public/images/tarot/card_back.svg |
신규 SVG (보라+금 모노그램) |
실행 환경
- 백엔드 작업 경로:
C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office/ - 프론트 작업 경로:
C:/Users/jaeoh/Desktop/workspace/web-ui/ - 백엔드 테스트:
cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office && pytest tests/test_tarot_*.py -v - 프론트 테스트:
cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm test -- src/pages/tarot/ - 백엔드 커밋: web-backend 저장소에서
- 프론트 커밋: web-ui 저장소에서 (별도 Git)
- 로컬 백엔드 실행:
cd web-backend && docker compose up -d agent-office(NAS만 실행이 원칙이지만 로컬 테스트는 docker로) - 로컬 프론트:
cd web-ui && npm run dev→ http://localhost:3007
Task 1: tarot_readings 테이블 추가 + CRUD
Files:
-
Modify:
agent-office/app/db.py -
Create:
agent-office/tests/test_tarot_db.py -
Step 1: 실패 테스트 작성
agent-office/tests/test_tarot_db.py 생성:
import json
import os
import tempfile
import pytest
from app import db as db_module
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
db_file = tmp_path / "test_tarot.db"
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
db_module.init_db()
yield
if db_file.exists():
db_file.unlink()
def test_save_and_get_tarot_reading():
rid = db_module.save_tarot_reading({
"spread_type": "three_card",
"category": "연애",
"question": "Q",
"cards": [{"position": "과거", "card_id": "the-fool", "reversed": False}],
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "medium"},
"model": "claude-sonnet-4-6",
"tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005,
"confidence": "medium",
})
assert rid > 0
row = db_module.get_tarot_reading(rid)
assert row["id"] == rid
assert row["category"] == "연애"
assert row["interpretation_json"]["summary"] == "S"
assert row["favorite"] == 0
def test_list_tarot_readings_filters_and_pagination():
for cat in ["연애", "연애", "재물"]:
db_module.save_tarot_reading({
"spread_type": "three_card", "category": cat, "question": "Q",
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "low"},
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
})
res = db_module.list_tarot_readings(page=1, size=10, category="연애")
assert res["total"] == 2
assert all(r["category"] == "연애" for r in res["items"])
def test_update_tarot_reading_favorite_and_note():
rid = db_module.save_tarot_reading({
"spread_type": "one_card", "category": None, "question": None,
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"},
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high",
})
db_module.update_tarot_reading(rid, favorite=True, note="기억하고 싶음")
row = db_module.get_tarot_reading(rid)
assert row["favorite"] == 1
assert row["note"] == "기억하고 싶음"
def test_delete_tarot_reading():
rid = db_module.save_tarot_reading({
"spread_type": "one_card", "category": None, "question": None,
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"},
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high",
})
db_module.delete_tarot_reading(rid)
assert db_module.get_tarot_reading(rid) is None
- Step 2: 테스트 실패 확인
cd C:/Users/jaeoh/Desktop/workspace/web-backend/agent-office && pytest tests/test_tarot_db.py -v
Expected: FAIL with AttributeError: module 'app.db' has no attribute 'save_tarot_reading'
- Step 3: 테이블 + CRUD 구현
agent-office/app/db.py의 init_db() 함수 안, 마지막 INSERT OR IGNORE 시드 직전에 다음 블록 추가:
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.toml에 asyncio_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)"
Task 17: 라우팅 + navLinks 등록
Files:
-
Modify:
web-ui/src/routes.jsx -
Modify:
web-ui/src/components/Icons.jsx -
Step 1: Icons.jsx에 IconTarot 추가
web-ui/src/components/Icons.jsx를 Read해서 다른 아이콘 패턴 확인 후 다음 컴포넌트를 추가:
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 모두 접근 (오류 없이 페이지 렌더):
-
메인 네비에 "Tarot" 링크 표시
-
Step 4: 커밋
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
브라우저로:
- http://localhost:3007/tarot — 랜딩 페이지 영상 재생 확인 + 3-tier 카드 + CTA 버튼
- "지금 시작하기" → /tarot/reading 이동
- 질문 입력 + 카테고리 선택 + "카드 셔플하기" → 16장 그리드 표시
- 3장 클릭 → 슬롯에 채워짐
- "AI 해석 시작" → ANTHROPIC_API_KEY 있을 시 interpretation 표시 / 없으면 좌측에 오류 메시지
- /tarot/history → 빈 상태 또는 방금 저장된 리딩 표시
- /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개.