Files
jaengseung-made/app/api/tarot/interpret/route.ts
gahusb 3acc1dbbe6 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>
2026-07-02 20:53:53 +09:00

115 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
);
}