feat(tarot): useTarotReading hook + api helper 6종 (T10)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
46
src/api.js
46
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}`);
|
||||
}
|
||||
|
||||
100
src/pages/tarot/hooks/useTarotReading.js
Normal file
100
src/pages/tarot/hooks/useTarotReading.js
Normal file
@@ -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 };
|
||||
}
|
||||
92
src/pages/tarot/hooks/useTarotReading.test.js
Normal file
92
src/pages/tarot/hooks/useTarotReading.test.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user