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:
2026-05-24 00:36:08 +09:00
parent 1a7dfe73e4
commit d91be529eb
3 changed files with 238 additions and 0 deletions

View File

@@ -55,6 +55,22 @@ export async function apiPut(path, body) {
return res.json(); 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() { export function getLatest() {
return apiGet("/api/lotto/latest"); return apiGet("/api/lotto/latest");
} }
@@ -723,3 +739,33 @@ export async function triggerEvolverEvaluate() {
if (!r.ok) throw new Error(`evaluate-now ${r.status}`); if (!r.ok) throw new Error(`evaluate-now ${r.status}`);
return r.json(); 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}`);
}

View 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 };
}

View 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();
});
});