diff --git a/src/api.js b/src/api.js index 2a13ac3..61dbd0f 100644 --- a/src/api.js +++ b/src/api.js @@ -55,6 +55,22 @@ export async function apiPut(path, body) { return res.json(); } +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(); +} + export function getLatest() { return apiGet("/api/lotto/latest"); } @@ -723,3 +739,33 @@ export async function triggerEvolverEvaluate() { if (!r.ok) throw new Error(`evaluate-now ${r.status}`); return r.json(); } + +// --- 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}`); +} diff --git a/src/pages/tarot/hooks/useTarotReading.js b/src/pages/tarot/hooks/useTarotReading.js new file mode 100644 index 0000000..f8aad2c --- /dev/null +++ b/src/pages/tarot/hooks/useTarotReading.js @@ -0,0 +1,100 @@ +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 }; +} diff --git a/src/pages/tarot/hooks/useTarotReading.test.js b/src/pages/tarot/hooks/useTarotReading.test.js new file mode 100644 index 0000000..880af53 --- /dev/null +++ b/src/pages/tarot/hooks/useTarotReading.test.js @@ -0,0 +1,92 @@ +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(); + }); +});