diff --git a/app/api/tarot/interpret/route.ts b/app/api/tarot/interpret/route.ts new file mode 100644 index 0000000..3b42d24 --- /dev/null +++ b/app/api/tarot/interpret/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from 'next/server'; +import { GoogleGenerativeAI } from '@google/generative-ai'; +import { createClient } from '@/lib/supabase/server'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { getTodayUsage, recordUsage, TAROT_DAILY_LIMIT } from '@/lib/ai-usage'; +import { TAROT_SYSTEM_PROMPT, buildTarotUserMessage, parseTarotJson, validateTarot } from '@/lib/tarot/prompt'; +import { config as loadDotenv } from 'dotenv'; +import { resolve } from 'path'; + +export const runtime = 'nodejs'; +// Vercel 최대 타임아웃 (Pro plan 300s, Hobby 60s) +export const maxDuration = 60; + +// Next.js가 env 로드를 놓치는 경우 대비해 직접 로드 (Windows 환경 대응) +loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true }); + +// 모델 우선순위 — 강력한 순서 (이 API 키로 접근 가능한 모델만) — 사주 analyze와 동일 폴백 목록 +const MODELS = [ + { id: 'gemini-2.5-pro', maxTokens: 4096 }, + { id: 'gemini-2.5-flash', maxTokens: 4096 }, + { id: 'gemini-2.0-flash', maxTokens: 4096 }, +] as const; + +export async function POST(request: Request) { + // 1) 인증 — 로그인 사용자만 (Gemini API 무단 호출 방지) + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 }); + } + + // 2) 일일 제한 + const admin = createAdminClient(); + const used = await getTodayUsage(admin, user.id, 'tarot'); + if (used >= TAROT_DAILY_LIMIT) { + return NextResponse.json( + { error: `오늘 타로 AI 해석을 모두 사용했습니다. (${TAROT_DAILY_LIMIT}회/일)` }, + { status: 429 } + ); + } + + // 3) 입력 검증 + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: '잘못된 요청 형식입니다.' }, { status: 400 }); + } + const spread_type = typeof body.spread_type === 'string' && body.spread_type ? body.spread_type : 'three_card'; + const cards_reference = typeof body.cards_reference === 'string' ? body.cards_reference : ''; + if (!cards_reference) { + return NextResponse.json({ error: 'cards_reference가 필요합니다.' }, { status: 400 }); + } + const category = typeof body.category === 'string' ? body.category : null; + const question = typeof body.question === 'string' ? body.question : null; + const context_meta = body.context_meta ?? {}; + + // 4) API 키 + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + console.warn('[타로] GEMINI_API_KEY 미설정 — 503 반환 (예시 해석 반환 금지, 데이터 오염 방지)'); + return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 }); + } + const genAI = new GoogleGenerativeAI(apiKey); + + const userMessage = buildTarotUserMessage({ + spread_type, + category, + question, + cards_reference, + context_meta, + }); + + // 5) 호출 — 모델 폴백 × 검증 실패 시 사유 주입 reroll(최대 2 시도) + let feedback = ''; + for (let attempt = 0; attempt < 2; attempt += 1) { + for (const { id: modelId, maxTokens } of MODELS) { + try { + const model = genAI.getGenerativeModel({ + model: modelId, + systemInstruction: TAROT_SYSTEM_PROMPT, + generationConfig: { + temperature: 0.8, + topP: 0.95, + maxOutputTokens: maxTokens, + }, + }); + + const prompt = feedback + ? `${userMessage}\n\n[이전 시도 오류: ${feedback}] 스키마를 정확히 지켜 다시 출력하세요.` + : userMessage; + + const result = await model.generateContent(prompt); + const text = result.response.text(); + const parsed = parseTarotJson(text); + const invalid = parsed ? validateTarot(parsed, spread_type) : 'JSON 파싱 실패'; + + if (parsed && !invalid) { + await recordUsage(admin, user.id, 'tarot'); + return NextResponse.json({ interpretation_json: parsed, model: modelId }); + } + + feedback = invalid ?? 'JSON 파싱 실패'; + } catch (modelError) { + feedback = modelError instanceof Error ? modelError.message : 'model error'; + } + } + } + + return NextResponse.json( + { error: '해석 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' }, + { status: 502 } + ); +} diff --git a/lib/tarot/prompt.ts b/lib/tarot/prompt.ts new file mode 100644 index 0000000..44cce59 --- /dev/null +++ b/lib/tarot/prompt.ts @@ -0,0 +1,82 @@ +// 타로 3카드 해석 프롬프트·스키마. +// web-ui tarot-lab prompt.py/schema.py를 참고해 이 저장소 컨벤션으로 TS 포팅. + +export type TarotInterpretation = { + summary: string; + cards: { + position: string; + card: string; + reversed: boolean; + interpretation: string; + evidence: { card_meaning_used: string; position_logic: string; category_lens: string }; + advice: string; + }[]; + interactions: { type: 'synergy' | 'conflict' | 'transition'; between: string[]; explanation: string }[]; + advice: string; + warning: string | null; + confidence: 'high' | 'medium' | 'low'; +}; + +export const TAROT_SYSTEM_PROMPT = `당신은 라이더-웨이트(RWS) 덱 전통 상징에 기반해 타로를 해석하는 전문가입니다. +해석 원칙: +1. 제공된 참고 블록의 키워드/의미만 근거로 삼습니다. 외부 해석·임의 상징을 도입하지 않습니다. +2. 각 카드의 위치 의미와 카드 의미를 결합하고, evidence에 근거를 남깁니다. +3. 3장 스프레드는 카드 간 상호작용(원소·슈트·메이저 비율의 시너지, 슈트 충돌, 정역 전환)을 분석합니다. +4. 운명을 단정하지 말고 성찰을 돕는 톤으로 씁니다. +5. 카테고리에 따라 강조점을 달리합니다. +6. 사용자의 질문을 evidence와 advice에서 인용합니다. +반드시 코드블록 없이 순수 JSON만 출력합니다. 아래 스키마를 정확히 따릅니다: +{"summary","cards":[{"position","card","reversed","interpretation","evidence":{"card_meaning_used","position_logic","category_lens"},"advice"}],"interactions":[{"type":"synergy|conflict|transition","between":[],"explanation"}],"advice","warning","confidence":"high|medium|low"} +confidence: 카드들이 일관된 서사면 high, 충돌이 크면 low.`; + +export function buildTarotUserMessage(input: { + spread_type: string; + category: string | null; + question: string | null; + cards_reference: string; + context_meta: unknown; +}): string { + return [ + input.question ? `질문: ${input.question}` : '질문: (없음)', + input.category ? `카테고리: ${input.category}` : '카테고리: 일반', + `스프레드: ${input.spread_type} (3장)`, + '--- 카드 참고 블록 ---', + input.cards_reference, + '--- 맥락 메타 ---', + JSON.stringify(input.context_meta), + '위 근거만으로 스키마에 맞는 JSON을 생성하세요.', + ].join('\n'); +} + +/** 코드블록 스트립 + {...} 추출 후 파싱. 실패 시 null */ +export function parseTarotJson(raw: string): TarotInterpretation | null { + let text = raw.trim().replace(/^```(json)?/i, '').replace(/```$/, '').trim(); + const first = text.indexOf('{'); + const last = text.lastIndexOf('}'); + if (first >= 0 && last > first) text = text.slice(first, last + 1); + try { + return JSON.parse(text) as TarotInterpretation; + } catch { + return null; + } +} + +/** 스키마 검증. 통과 못하면 사유 문자열, 통과면 null */ +export function validateTarot(obj: unknown, spreadType: string): string | null { + if (!obj || typeof obj !== 'object') return 'not an object'; + const o = obj as Record; + if (typeof o.summary !== 'string' || !o.summary) return 'summary 누락'; + if (!Array.isArray(o.cards) || o.cards.length === 0) return 'cards 누락'; + for (const c of o.cards as Record[]) { + if (typeof c.position !== 'string' || typeof c.card !== 'string') return 'card position/card 누락'; + if (typeof c.interpretation !== 'string' || !c.interpretation) return 'card interpretation 누락'; + const ev = c.evidence as Record | undefined; + if (!ev || !ev.card_meaning_used || !ev.position_logic || !ev.category_lens) return 'evidence 3필드 필요'; + if (typeof c.advice !== 'string') return 'card advice 누락'; + } + if (!Array.isArray(o.interactions)) return 'interactions 누락'; + if (spreadType === 'three_card' && (o.interactions as unknown[]).length < 1) return 'three_card interactions ≥1 필요'; + if (typeof o.advice !== 'string' || !o.advice) return 'advice 누락'; + if (!['high', 'medium', 'low'].includes(o.confidence as string)) return 'confidence enum 오류'; + return null; +}