Files
our_recipe_atlas/src/services/recipe-parser.service.js
T
2026-08-13 18:36:31 +09:00

125 lines
4.5 KiB
JavaScript

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(/<think>[\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,
});
}
}