feat(phase2): 타로 interpret API — Gemini strict JSON + 인증·일일제한·reroll

lib/tarot/prompt.ts에 TarotInterpretation 스키마·시스템 프롬프트·JSON
파싱/검증 유틸을 추가하고, app/api/tarot/interpret/route.ts에서 사주
analyze와 동일한 Gemini 모델 폴백(getGenerativeModel systemInstruction +
generationConfig) 패턴을 재사용해 인증(401)→일일제한(429)→입력검증(400)
→API키(503)→호출 순서로 처리한다. GEMINI_API_KEY 미설정 시 예시 데이터
대신 503을 반환해 실데이터 오염을 막고, 스키마 검증 실패 시 사유를
주입해 1회 reroll한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 20:53:53 +09:00
parent 84b36267bf
commit 3acc1dbbe6
2 changed files with 196 additions and 0 deletions

View File

@@ -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<string, unknown>;
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 }
);
}

82
lib/tarot/prompt.ts Normal file
View File

@@ -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<string, unknown>;
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<string, unknown>[]) {
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<string, unknown> | 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;
}