125 lines
4.5 KiB
JavaScript
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,
|
|
});
|
|
}
|
|
}
|