import OpenAI from 'openai'; import { STANDARD_RECIPE_TAGS } from '../constants/recipe-tags.js'; import { recipeDraftSchema } from '../schemas/recipe.schema.js'; import { AppError } from '../utils/errors.js'; const STANDARD_TAGS = STANDARD_RECIPE_TAGS.join(', '); const SYSTEM_PROMPT = `당신은 원문에서 레시피를 구조화하는 데이터 변환기입니다. 반드시 JSON 객체만 출력하세요. 원칙: - 원문에 없는 재료, 수량, 조리 순서를 추측하거나 추가하지 않습니다. - 수량이 명확하지 않으면 null 또는 원문 표현을 유지합니다. - g, ml, 큰술, 작은술, 장, 개 등의 원문 단위를 유지합니다. - 광고, 비즈니스 문의, SNS 링크, 해시태그 등 레시피와 무관한 내용을 제거합니다. - YouTube timestamp는 원문 transcript에서 확인되는 경우에만 초 단위 숫자로 기록합니다. - Instagram 단계의 timestampSec은 null입니다. - 원문에 재료 그룹 이름이 없으면 "재료"를 사용합니다. ingredientGroups[].name은 null이 될 수 없습니다. - 이 작업은 정보 추출이므로 깊은 분석은 필요하지 않습니다. - tags는 다음 표준 태그에서만 최대 3개를 선택합니다: ${STANDARD_TAGS} - 재료명, 요리 고유명사, 식사 시간대는 tags에 넣지 않습니다. 출력 형식: { "title": "string", "summary": "string|null", "servings": "string|null", "ingredientGroups": [{"name":"string","items":[{"name":"string","amount":"string|null"}]}], "steps": [{"order":1,"text":"string","timestampSec":null}], "tips": ["string"], "tags": ["string"] }`; export function normalizeModelJson(content) { if (typeof content !== 'string' || !content.trim()) { throw new Error('AI 응답이 비어 있습니다.'); } const withoutThinking = content.replace(/[\s\S]*?<\/think>/gi, '').trim(); const withoutFence = withoutThinking .replace(/^```(?:json)?\s*/i, '') .replace(/\s*```$/i, '') .trim(); const start = withoutFence.indexOf('{'); const end = withoutFence.lastIndexOf('}'); if (start < 0 || end <= start) throw new Error('JSON 객체를 찾을 수 없습니다.'); return withoutFence.slice(start, end + 1); } export function normalizeRecipePayload(value) { if (!value || typeof value !== 'object' || !Array.isArray(value.ingredientGroups)) { return value; } return { ...value, ingredientGroups: value.ingredientGroups.map((group) => { if (!group || typeof group !== 'object') return group; const name = typeof group.name === 'string' ? group.name.trim() : ''; return { ...group, name: name || '재료' }; }), }; } function buildUserPrompt(source) { const lines = [ `SOURCE_PLATFORM: ${source.platform}`, source.title ? `TITLE:\n${source.title}` : null, `SOURCE_TEXT:\n${source.rawText}`, ]; return lines.filter(Boolean).join('\n\n'); } export class RecipeParserService { constructor({ apiKey, baseUrl, model, client } = {}) { this.model = model; this.client = client ?? (apiKey ? new OpenAI({ apiKey, baseURL: baseUrl }) : null); } async parse(source) { if (!this.client) { throw new AppError('MiniMax API가 설정되지 않았습니다.', { statusCode: 503, code: 'MINIMAX_NOT_CONFIGURED', }); } let lastError; let previousOutput = null; for (let attempt = 0; attempt < 2; attempt += 1) { try { const response = await this.client.chat.completions.create({ model: this.model, messages: [ { role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: attempt === 0 ? buildUserPrompt(source) : `${buildUserPrompt(source)}\n\n이전 출력은 유효한 JSON 스키마가 아니었습니다. 수정해서 JSON 객체만 다시 출력하세요.\n이전 출력:\n${previousOutput}`, }, ], reasoning_split: true, max_completion_tokens: 8192, temperature: 0.2, stream: false, }); previousOutput = response.choices?.[0]?.message?.content ?? ''; const parsed = JSON.parse(normalizeModelJson(previousOutput)); return recipeDraftSchema.parse(normalizeRecipePayload(parsed)); } catch (error) { lastError = error; } } throw new AppError('AI 응답을 처리하지 못했습니다.', { statusCode: 502, code: 'AI_RESPONSE_INVALID', cause: lastError, }); } }