feat: add batched recipe import queue
This commit is contained in:
+25
@@ -9,6 +9,8 @@ import importRoutes from './routes/import.routes.js';
|
||||
import recipeRoutes from './routes/recipe.routes.js';
|
||||
import { createSourceExtractor } from './services/extractors/index.js';
|
||||
import { ImageStorageService } from './services/image-storage.service.js';
|
||||
import { ImportJobProcessorService } from './services/import-job-processor.service.js';
|
||||
import { RecipeCreatorService } from './services/recipe-creator.service.js';
|
||||
import { RecipeParserService } from './services/recipe-parser.service.js';
|
||||
import { AppError } from './utils/errors.js';
|
||||
|
||||
@@ -56,11 +58,13 @@ export async function buildApp({ config = loadConfig(), dependencies = {} } = {}
|
||||
if (
|
||||
dependencies.allowedEmailRepository
|
||||
&& dependencies.groupRepository
|
||||
&& dependencies.importJobRepository
|
||||
&& dependencies.recipeRepository
|
||||
&& dependencies.userRepository
|
||||
) {
|
||||
app.decorate('allowedEmailRepository', dependencies.allowedEmailRepository);
|
||||
app.decorate('groupRepository', dependencies.groupRepository);
|
||||
app.decorate('importJobRepository', dependencies.importJobRepository);
|
||||
app.decorate('recipeRepository', dependencies.recipeRepository);
|
||||
app.decorate('userRepository', dependencies.userRepository);
|
||||
} else {
|
||||
@@ -84,6 +88,27 @@ export async function buildApp({ config = loadConfig(), dependencies = {} } = {}
|
||||
'imageStorage',
|
||||
dependencies.imageStorage ?? new ImageStorageService({ root: config.imageRoot }),
|
||||
);
|
||||
app.decorate(
|
||||
'recipeCreator',
|
||||
dependencies.recipeCreator ?? new RecipeCreatorService({
|
||||
recipeRepository: app.recipeRepository,
|
||||
imageStorage: app.imageStorage,
|
||||
model: config.minimax.model,
|
||||
}),
|
||||
);
|
||||
app.decorate(
|
||||
'importJobProcessor',
|
||||
dependencies.importJobProcessor ?? new ImportJobProcessorService({
|
||||
importJobRepository: app.importJobRepository,
|
||||
sourceExtractor: app.sourceExtractor,
|
||||
recipeParser: app.recipeParser,
|
||||
recipeCreator: app.recipeCreator,
|
||||
logger: app.log,
|
||||
}),
|
||||
);
|
||||
app.addHook('onClose', async () => {
|
||||
await app.importJobProcessor.close();
|
||||
});
|
||||
|
||||
await app.register(accessRoutes);
|
||||
await app.register(importRoutes);
|
||||
|
||||
@@ -38,6 +38,7 @@ export function mongoCollectionNames(nodeEnv) {
|
||||
return Object.freeze({
|
||||
allowedEmails: 'allowed_google_emails',
|
||||
groups: `groups${suffix}`,
|
||||
importJobs: `import_jobs${suffix}`,
|
||||
recipes: `recipes${suffix}`,
|
||||
users: `users${suffix}`,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MongoClient } from 'mongodb';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { AllowedEmailRepository } from '../repositories/allowed-email.repository.js';
|
||||
import { GroupRepository } from '../repositories/group.repository.js';
|
||||
import { ImportJobRepository } from '../repositories/import-job.repository.js';
|
||||
import { RecipeRepository } from '../repositories/recipe.repository.js';
|
||||
import { UserRepository } from '../repositories/user.repository.js';
|
||||
|
||||
@@ -51,12 +52,14 @@ async function mongoPlugin(fastify, { config }) {
|
||||
const collections = config.mongoCollections;
|
||||
const allowedEmailRepository = new AllowedEmailRepository(db, collections.allowedEmails);
|
||||
const groupRepository = new GroupRepository(db, collections.groups);
|
||||
const importJobRepository = new ImportJobRepository(db, collections.importJobs);
|
||||
const recipeRepository = new RecipeRepository(db, collections.recipes);
|
||||
const userRepository = new UserRepository(db, collections.users);
|
||||
|
||||
await Promise.all([
|
||||
allowedEmailRepository.ensureIndexes(),
|
||||
groupRepository.ensureIndexes(),
|
||||
importJobRepository.ensureIndexes(),
|
||||
recipeRepository.ensureIndexes(),
|
||||
userRepository.ensureIndexes(),
|
||||
]);
|
||||
@@ -69,6 +72,7 @@ async function mongoPlugin(fastify, { config }) {
|
||||
fastify.decorate('db', db);
|
||||
fastify.decorate('allowedEmailRepository', allowedEmailRepository);
|
||||
fastify.decorate('groupRepository', groupRepository);
|
||||
fastify.decorate('importJobRepository', importJobRepository);
|
||||
fastify.decorate('recipeRepository', recipeRepository);
|
||||
fastify.decorate('userRepository', userRepository);
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const VISIBLE_STATUSES = ['pending', 'processing', 'failed'];
|
||||
|
||||
export class ImportJobRepository {
|
||||
constructor(db, collectionName = 'import_jobs') {
|
||||
this.collection = db.collection(collectionName);
|
||||
}
|
||||
|
||||
async ensureIndexes() {
|
||||
await this.collection.createIndex(
|
||||
{ ownerGoogleSub: 1, platform: 1, sourceId: 1 },
|
||||
{ unique: true },
|
||||
);
|
||||
await this.collection.createIndex({ ownerGoogleSub: 1, status: 1, createdAt: 1 });
|
||||
}
|
||||
|
||||
async enqueue({ ownerGoogleSub, url, platform, sourceId }) {
|
||||
const now = new Date();
|
||||
const document = {
|
||||
_id: randomUUID(),
|
||||
ownerGoogleSub,
|
||||
url,
|
||||
platform,
|
||||
sourceId,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
error: null,
|
||||
recipeId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
lockedAt: null,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.collection.insertOne(document);
|
||||
return { created: true, job: document };
|
||||
} catch (error) {
|
||||
if (error?.code !== 11000) throw error;
|
||||
const job = await this.collection.findOne({ ownerGoogleSub, platform, sourceId });
|
||||
return { created: false, job };
|
||||
}
|
||||
}
|
||||
|
||||
async listVisible(ownerGoogleSub) {
|
||||
return this.collection
|
||||
.find({ ownerGoogleSub, status: { $in: VISIBLE_STATUSES } })
|
||||
.sort({ createdAt: 1 })
|
||||
.toArray();
|
||||
}
|
||||
|
||||
async recoverStale(ownerGoogleSub, staleBefore) {
|
||||
await this.collection.updateMany(
|
||||
{
|
||||
ownerGoogleSub,
|
||||
status: 'processing',
|
||||
lockedAt: { $lt: staleBefore },
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
status: 'pending',
|
||||
updatedAt: new Date(),
|
||||
startedAt: null,
|
||||
lockedAt: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async claimNext(ownerGoogleSub) {
|
||||
const now = new Date();
|
||||
return this.collection.findOneAndUpdate(
|
||||
{ ownerGoogleSub, status: 'pending' },
|
||||
{
|
||||
$set: {
|
||||
status: 'processing',
|
||||
error: null,
|
||||
startedAt: now,
|
||||
lockedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
$inc: { attempts: 1 },
|
||||
},
|
||||
{
|
||||
sort: { createdAt: 1 },
|
||||
returnDocument: 'after',
|
||||
includeResultMetadata: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async markCompleted(jobId, recipeId) {
|
||||
const now = new Date();
|
||||
await this.collection.updateOne(
|
||||
{ _id: jobId },
|
||||
{
|
||||
$set: {
|
||||
status: 'completed',
|
||||
recipeId,
|
||||
error: null,
|
||||
completedAt: now,
|
||||
lockedAt: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async markFailed(jobId, error) {
|
||||
await this.collection.updateOne(
|
||||
{ _id: jobId },
|
||||
{
|
||||
$set: {
|
||||
status: 'failed',
|
||||
error,
|
||||
lockedAt: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async retry(ownerGoogleSub, jobId) {
|
||||
return this.collection.findOneAndUpdate(
|
||||
{ _id: jobId, ownerGoogleSub, status: 'failed' },
|
||||
{
|
||||
$set: {
|
||||
status: 'pending',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
lockedAt: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
{ returnDocument: 'after', includeResultMetadata: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,38 @@
|
||||
import { importPreviewRequestSchema, sourceSchema } from '../schemas/recipe.schema.js';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
enqueueImportJobsSchema,
|
||||
importPreviewRequestSchema,
|
||||
sourceSchema,
|
||||
} from '../schemas/recipe.schema.js';
|
||||
import { NotFoundError } from '../utils/errors.js';
|
||||
import {
|
||||
canonicalSourceUrl,
|
||||
detectPlatform,
|
||||
extractInstagramShortcode,
|
||||
extractYouTubeId,
|
||||
} from '../utils/url.js';
|
||||
|
||||
function toImportIdentity(value) {
|
||||
const url = canonicalSourceUrl(value);
|
||||
const platform = detectPlatform(url);
|
||||
const sourceId = platform === 'youtube'
|
||||
? extractYouTubeId(url)
|
||||
: extractInstagramShortcode(url);
|
||||
return { url, platform, sourceId };
|
||||
}
|
||||
|
||||
function toPublicJob(job) {
|
||||
return {
|
||||
id: String(job._id),
|
||||
url: job.url,
|
||||
platform: job.platform,
|
||||
status: job.status,
|
||||
attempts: job.attempts,
|
||||
error: job.error,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function importRoutes(fastify) {
|
||||
fastify.post('/api/import/preview', { preHandler: fastify.authenticate }, async (request) => {
|
||||
@@ -12,5 +46,57 @@ export default async function importRoutes(fastify) {
|
||||
imagePreviewUrl: source.thumbnailUrl,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
fastify.post('/api/import/jobs', { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
const { urls } = enqueueImportJobsSchema.parse(request.body);
|
||||
const identities = [...new Map(urls.map((url) => {
|
||||
const identity = toImportIdentity(url);
|
||||
return [`${identity.platform}:${identity.sourceId}`, identity];
|
||||
})).values()];
|
||||
|
||||
let addedCount = 0;
|
||||
let duplicateCount = urls.length - identities.length;
|
||||
for (const identity of identities) {
|
||||
const recipe = await fastify.recipeRepository.findBySource(
|
||||
request.user.sub,
|
||||
identity.platform,
|
||||
identity.sourceId,
|
||||
);
|
||||
if (recipe) {
|
||||
duplicateCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await fastify.importJobRepository.enqueue({
|
||||
ownerGoogleSub: request.user.sub,
|
||||
...identity,
|
||||
});
|
||||
if (result.created) addedCount += 1;
|
||||
else duplicateCount += 1;
|
||||
}
|
||||
|
||||
const jobs = await fastify.importJobRepository.listVisible(request.user.sub);
|
||||
return reply.code(201).send({
|
||||
addedCount,
|
||||
duplicateCount,
|
||||
jobs: jobs.map(toPublicJob),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get('/api/import/jobs', { preHandler: fastify.authenticate }, async (request) => {
|
||||
const jobs = await fastify.importJobRepository.listVisible(request.user.sub);
|
||||
return { jobs: jobs.map(toPublicJob) };
|
||||
});
|
||||
|
||||
fastify.post('/api/import/jobs/process', { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
fastify.importJobProcessor.start(request.user.sub);
|
||||
return reply.code(202).send();
|
||||
});
|
||||
|
||||
fastify.post('/api/import/jobs/:id/retry', { preHandler: fastify.authenticate }, async (request) => {
|
||||
const { id } = z.object({ id: z.string().trim().min(1) }).parse(request.params);
|
||||
const job = await fastify.importJobRepository.retry(request.user.sub, id);
|
||||
if (!job) throw new NotFoundError('재시도할 가져오기 작업을 찾을 수 없습니다.');
|
||||
return { job: toPublicJob(job) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { recipePatchSchema, saveRecipeRequestSchema } from '../schemas/recipe.schema.js';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '../utils/errors.js';
|
||||
import {
|
||||
detectPlatform,
|
||||
extractInstagramShortcode,
|
||||
extractYouTubeId,
|
||||
} from '../utils/url.js';
|
||||
|
||||
function toStoredSource(source) {
|
||||
const { sourceUrl, ...rest } = source;
|
||||
return { ...rest, url: sourceUrl };
|
||||
}
|
||||
|
||||
function isDuplicateKeyError(error) {
|
||||
return error?.code === 11000;
|
||||
}
|
||||
|
||||
function verifiedSource(source) {
|
||||
const platform = detectPlatform(source.sourceUrl);
|
||||
const sourceId = platform === 'youtube'
|
||||
? extractYouTubeId(source.sourceUrl)
|
||||
: platform === 'instagram'
|
||||
? extractInstagramShortcode(source.sourceUrl)
|
||||
: null;
|
||||
if (platform !== source.platform || sourceId !== source.sourceId) {
|
||||
throw new ValidationError('미리보기 원본 정보가 URL과 일치하지 않습니다.');
|
||||
}
|
||||
return source;
|
||||
}
|
||||
import { NotFoundError } from '../utils/errors.js';
|
||||
|
||||
async function visibleOwnerGoogleSubs(fastify, googleSub) {
|
||||
const groupMembers = await fastify.groupRepository.listMemberGoogleSubs(googleSub);
|
||||
@@ -52,42 +24,11 @@ export default async function recipeRoutes(fastify) {
|
||||
|
||||
fastify.post('/api/recipes', { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
const input = saveRecipeRequestSchema.parse(request.body);
|
||||
verifiedSource(input.source);
|
||||
const ownerGoogleSub = request.user.sub;
|
||||
const duplicate = await fastify.recipeRepository.findBySource(
|
||||
ownerGoogleSub,
|
||||
input.source.platform,
|
||||
input.source.sourceId,
|
||||
);
|
||||
if (duplicate) throw new ConflictError();
|
||||
|
||||
const recipeId = randomUUID();
|
||||
let imagePath = null;
|
||||
try {
|
||||
const imageUrl = input.imagePreviewUrl ?? input.source.thumbnailUrl;
|
||||
if (imageUrl) imagePath = await fastify.imageStorage.saveFromUrl(recipeId, imageUrl);
|
||||
|
||||
const now = new Date();
|
||||
const document = {
|
||||
_id: recipeId,
|
||||
ownerGoogleSub,
|
||||
...input.recipe,
|
||||
source: toStoredSource(input.source),
|
||||
imagePath,
|
||||
ai: {
|
||||
provider: 'minimax',
|
||||
model: fastify.config.minimax.model,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const recipe = await fastify.recipeRepository.create(document);
|
||||
return reply.code(201).send({ recipe });
|
||||
} catch (error) {
|
||||
if (imagePath) await fastify.imageStorage.removeRecipe(recipeId).catch(() => {});
|
||||
if (isDuplicateKeyError(error)) throw new ConflictError();
|
||||
throw error;
|
||||
}
|
||||
const recipe = await fastify.recipeCreator.create({
|
||||
ownerGoogleSub: request.user.sub,
|
||||
...input,
|
||||
});
|
||||
return reply.code(201).send({ recipe });
|
||||
});
|
||||
|
||||
fastify.patch('/api/recipes/:id', { preHandler: fastify.authenticate }, async (request) => {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const importPreviewRequestSchema = z.object({
|
||||
url: z.url(),
|
||||
});
|
||||
|
||||
export const enqueueImportJobsSchema = z.object({
|
||||
urls: z.array(z.url())
|
||||
.min(1, 'URL을 하나 이상 입력해 주세요.')
|
||||
.max(20, 'URL은 한 번에 최대 20개까지 등록할 수 있습니다.'),
|
||||
});
|
||||
|
||||
export const saveRecipeRequestSchema = z.object({
|
||||
recipe: recipeDraftSchema,
|
||||
source: sourceSchema,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { sourceSchema } from '../schemas/recipe.schema.js';
|
||||
import { AppError } from '../utils/errors.js';
|
||||
|
||||
const MAX_JOBS_PER_RUN = 20;
|
||||
const MAX_BATCH_ITEMS = 3;
|
||||
const MAX_BATCH_CHARACTERS = 100_000;
|
||||
|
||||
function publicError(error) {
|
||||
if (!(error instanceof AppError)) {
|
||||
return {
|
||||
code: 'IMPORT_PROCESSING_FAILED',
|
||||
message: '가져오기 작업을 처리하지 못했습니다.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
export function createImportBatches(items) {
|
||||
const batches = [];
|
||||
let current = [];
|
||||
let currentCharacters = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const characters = item.source.rawText.length;
|
||||
if (
|
||||
current.length > 0
|
||||
&& (current.length >= MAX_BATCH_ITEMS
|
||||
|| currentCharacters + characters > MAX_BATCH_CHARACTERS)
|
||||
) {
|
||||
batches.push(current);
|
||||
current = [];
|
||||
currentCharacters = 0;
|
||||
}
|
||||
current.push(item);
|
||||
currentCharacters += characters;
|
||||
}
|
||||
|
||||
if (current.length > 0) batches.push(current);
|
||||
return batches;
|
||||
}
|
||||
|
||||
export class ImportJobProcessorService {
|
||||
constructor({
|
||||
importJobRepository,
|
||||
sourceExtractor,
|
||||
recipeParser,
|
||||
recipeCreator,
|
||||
logger = console,
|
||||
}) {
|
||||
this.importJobRepository = importJobRepository;
|
||||
this.sourceExtractor = sourceExtractor;
|
||||
this.recipeParser = recipeParser;
|
||||
this.recipeCreator = recipeCreator;
|
||||
this.logger = logger;
|
||||
this.activeRuns = new Map();
|
||||
}
|
||||
|
||||
start(ownerGoogleSub) {
|
||||
if (this.activeRuns.has(ownerGoogleSub)) return false;
|
||||
|
||||
const run = Promise.resolve()
|
||||
.then(() => this.process(ownerGoogleSub))
|
||||
.catch((error) => {
|
||||
this.logger.error({ error, ownerGoogleSub }, '가져오기 대기열 처리 실패');
|
||||
})
|
||||
.finally(() => {
|
||||
this.activeRuns.delete(ownerGoogleSub);
|
||||
});
|
||||
this.activeRuns.set(ownerGoogleSub, run);
|
||||
return true;
|
||||
}
|
||||
|
||||
async waitForIdle(ownerGoogleSub) {
|
||||
await this.activeRuns.get(ownerGoogleSub);
|
||||
}
|
||||
|
||||
async close() {
|
||||
await Promise.allSettled(this.activeRuns.values());
|
||||
}
|
||||
|
||||
async process(ownerGoogleSub) {
|
||||
// 새 처리 요청이 시작되면 이전 서버 실행에서 중단된 작업도 다시 가져온다.
|
||||
await this.importJobRepository.recoverStale(
|
||||
ownerGoogleSub,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const jobs = [];
|
||||
for (let index = 0; index < MAX_JOBS_PER_RUN; index += 1) {
|
||||
const job = await this.importJobRepository.claimNext(ownerGoogleSub);
|
||||
if (!job) break;
|
||||
jobs.push(job);
|
||||
}
|
||||
if (jobs.length === 0) break;
|
||||
|
||||
const extracted = [];
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const source = sourceSchema.parse(await this.sourceExtractor.extract(job.url));
|
||||
if (source.rawText.length > MAX_BATCH_CHARACTERS) {
|
||||
throw new AppError('원문이 100,000자를 초과해 분석할 수 없습니다.', {
|
||||
statusCode: 422,
|
||||
code: 'SOURCE_TEXT_TOO_LONG',
|
||||
});
|
||||
}
|
||||
extracted.push({ jobId: String(job._id), job, source });
|
||||
} catch (error) {
|
||||
this.logger.warn?.({ error, jobId: job._id }, '가져오기 원문 추출 실패');
|
||||
await this.importJobRepository.markFailed(job._id, publicError(error));
|
||||
}
|
||||
}
|
||||
|
||||
for (const batch of createImportBatches(extracted)) {
|
||||
let batchResults;
|
||||
try {
|
||||
batchResults = await this.recipeParser.parseMany(
|
||||
batch.map(({ jobId, source }) => ({ jobId, source })),
|
||||
);
|
||||
} catch {
|
||||
batchResults = batch.map(({ jobId }) => ({
|
||||
jobId,
|
||||
error: new Error('AI 배치 처리에 실패했습니다.'),
|
||||
}));
|
||||
}
|
||||
const resultByJobId = new Map(batchResults.map((result) => [result.jobId, result]));
|
||||
|
||||
for (const item of batch) {
|
||||
try {
|
||||
const batchResult = resultByJobId.get(item.jobId);
|
||||
const recipe = batchResult?.recipe ?? await this.recipeParser.parse(item.source);
|
||||
const saved = await this.recipeCreator.create({
|
||||
ownerGoogleSub,
|
||||
recipe,
|
||||
source: item.source,
|
||||
imagePreviewUrl: item.source.thumbnailUrl,
|
||||
});
|
||||
await this.importJobRepository.markCompleted(item.job._id, saved._id);
|
||||
} catch (error) {
|
||||
if (error?.code === 'DUPLICATE_RECIPE') {
|
||||
await this.importJobRepository.markCompleted(item.job._id, null);
|
||||
} else {
|
||||
this.logger.warn?.({ error, jobId: item.job._id }, '가져오기 레시피 저장 실패');
|
||||
await this.importJobRepository.markFailed(item.job._id, publicError(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ConflictError, ValidationError } from '../utils/errors.js';
|
||||
import {
|
||||
detectPlatform,
|
||||
extractInstagramShortcode,
|
||||
extractYouTubeId,
|
||||
} from '../utils/url.js';
|
||||
|
||||
function toStoredSource(source) {
|
||||
const { sourceUrl, ...rest } = source;
|
||||
return { ...rest, url: sourceUrl };
|
||||
}
|
||||
|
||||
function isDuplicateKeyError(error) {
|
||||
return error?.code === 11000;
|
||||
}
|
||||
|
||||
function verifiedSource(source) {
|
||||
const platform = detectPlatform(source.sourceUrl);
|
||||
const sourceId = platform === 'youtube'
|
||||
? extractYouTubeId(source.sourceUrl)
|
||||
: platform === 'instagram'
|
||||
? extractInstagramShortcode(source.sourceUrl)
|
||||
: null;
|
||||
if (platform !== source.platform || sourceId !== source.sourceId) {
|
||||
throw new ValidationError('미리보기 원본 정보가 URL과 일치하지 않습니다.');
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
export class RecipeCreatorService {
|
||||
constructor({ recipeRepository, imageStorage, model }) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.imageStorage = imageStorage;
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
async create({ ownerGoogleSub, recipe, source, imagePreviewUrl }) {
|
||||
verifiedSource(source);
|
||||
const duplicate = await this.recipeRepository.findBySource(
|
||||
ownerGoogleSub,
|
||||
source.platform,
|
||||
source.sourceId,
|
||||
);
|
||||
if (duplicate) throw new ConflictError();
|
||||
|
||||
const recipeId = randomUUID();
|
||||
let imagePath = null;
|
||||
try {
|
||||
const imageUrl = imagePreviewUrl ?? source.thumbnailUrl;
|
||||
if (imageUrl) imagePath = await this.imageStorage.saveFromUrl(recipeId, imageUrl);
|
||||
|
||||
const now = new Date();
|
||||
const document = {
|
||||
_id: recipeId,
|
||||
ownerGoogleSub,
|
||||
...recipe,
|
||||
source: toStoredSource(source),
|
||||
imagePath,
|
||||
ai: {
|
||||
provider: 'minimax',
|
||||
model: this.model,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
return await this.recipeRepository.create(document);
|
||||
} catch (error) {
|
||||
if (imagePath) await this.imageStorage.removeRecipe(recipeId).catch(() => {});
|
||||
if (isDuplicateKeyError(error)) throw new ConflictError();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import { AppError } from '../utils/errors.js';
|
||||
|
||||
const STANDARD_TAGS = STANDARD_RECIPE_TAGS.join(', ');
|
||||
|
||||
const SYSTEM_PROMPT = `당신은 원문에서 레시피를 구조화하는 데이터 변환기입니다.
|
||||
반드시 JSON 객체만 출력하세요.
|
||||
const RECIPE_RULES = `당신은 원문에서 레시피를 구조화하는 데이터 변환기입니다.
|
||||
|
||||
원칙:
|
||||
- 원문에 없는 재료, 수량, 조리 순서를 추측하거나 추가하지 않습니다.
|
||||
@@ -21,10 +20,9 @@ const SYSTEM_PROMPT = `당신은 원문에서 레시피를 구조화하는 데
|
||||
- 원문에 재료 그룹 이름이 없으면 "재료"를 사용합니다. ingredientGroups[].name은 null이 될 수 없습니다.
|
||||
- 이 작업은 정보 추출이므로 깊은 분석은 필요하지 않습니다.
|
||||
- tags는 다음 표준 태그에서만 최대 3개를 선택합니다: ${STANDARD_TAGS}
|
||||
- 재료명, 요리 고유명사, 식사 시간대는 tags에 넣지 않습니다.
|
||||
- 재료명, 요리 고유명사, 식사 시간대는 tags에 넣지 않습니다.`;
|
||||
|
||||
출력 형식:
|
||||
{
|
||||
const RECIPE_OUTPUT_EXAMPLE = `{
|
||||
"title": "string",
|
||||
"summary": "string|null",
|
||||
"servings": "string|null",
|
||||
@@ -34,6 +32,24 @@ const SYSTEM_PROMPT = `당신은 원문에서 레시피를 구조화하는 데
|
||||
"tags": ["string"]
|
||||
}`;
|
||||
|
||||
const SINGLE_SYSTEM_PROMPT = `${RECIPE_RULES}
|
||||
|
||||
반드시 다음 형식의 JSON 객체 하나만 출력하세요.
|
||||
${RECIPE_OUTPUT_EXAMPLE}`;
|
||||
|
||||
const BATCH_SYSTEM_PROMPT = `${RECIPE_RULES}
|
||||
|
||||
입력에는 서로 독립적인 여러 원문과 jobId가 들어 있습니다. 원문끼리 정보를 섞지 말고 입력된 모든 jobId에 대해 결과를 하나씩 만드세요.
|
||||
반드시 다음 형식의 JSON 객체 하나만 출력하세요.
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"jobId": "입력과 동일한 string",
|
||||
"recipe": ${RECIPE_OUTPUT_EXAMPLE}
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
export function normalizeModelJson(content) {
|
||||
if (typeof content !== 'string' || !content.trim()) {
|
||||
throw new Error('AI 응답이 비어 있습니다.');
|
||||
@@ -74,43 +90,59 @@ function buildUserPrompt(source) {
|
||||
return lines.filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
function buildBatchUserPrompt(entries) {
|
||||
return JSON.stringify({
|
||||
sources: entries.map(({ jobId, source }) => ({
|
||||
jobId,
|
||||
platform: source.platform,
|
||||
title: source.title,
|
||||
rawText: source.rawText,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export class RecipeParserService {
|
||||
constructor({ apiKey, baseUrl, model, client } = {}) {
|
||||
this.model = model;
|
||||
this.client = client ?? (apiKey ? new OpenAI({ apiKey, baseURL: baseUrl }) : null);
|
||||
}
|
||||
|
||||
async parse(source) {
|
||||
assertConfigured() {
|
||||
if (!this.client) {
|
||||
throw new AppError('MiniMax API가 설정되지 않았습니다.', {
|
||||
statusCode: 503,
|
||||
code: 'MINIMAX_NOT_CONFIGURED',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createCompletion(systemPrompt, userPrompt, maxCompletionTokens) {
|
||||
const response = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
reasoning_split: true,
|
||||
max_completion_tokens: maxCompletionTokens,
|
||||
temperature: 0.2,
|
||||
stream: false,
|
||||
});
|
||||
return response.choices?.[0]?.message?.content ?? '';
|
||||
}
|
||||
|
||||
async parse(source) {
|
||||
this.assertConfigured();
|
||||
|
||||
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 userPrompt = attempt === 0
|
||||
? buildUserPrompt(source)
|
||||
: `${buildUserPrompt(source)}\n\n이전 출력은 유효한 JSON 스키마가 아니었습니다. 수정해서 JSON 객체만 다시 출력하세요.\n이전 출력:\n${previousOutput}`;
|
||||
previousOutput = await this.createCompletion(SINGLE_SYSTEM_PROMPT, userPrompt, 8192);
|
||||
const parsed = JSON.parse(normalizeModelJson(previousOutput));
|
||||
return recipeDraftSchema.parse(normalizeRecipePayload(parsed));
|
||||
} catch (error) {
|
||||
@@ -124,4 +156,53 @@ export class RecipeParserService {
|
||||
cause: lastError,
|
||||
});
|
||||
}
|
||||
|
||||
async parseMany(entries) {
|
||||
this.assertConfigured();
|
||||
if (entries.length === 0) return [];
|
||||
|
||||
let lastError;
|
||||
let previousOutput = null;
|
||||
const basePrompt = buildBatchUserPrompt(entries);
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const userPrompt = attempt === 0
|
||||
? basePrompt
|
||||
: `${basePrompt}\n\n이전 출력은 유효한 배치 JSON이 아니었습니다. 입력된 모든 jobId를 포함해 수정하세요.\n이전 출력:\n${previousOutput}`;
|
||||
const outputLimit = Math.min(32768, Math.max(8192, entries.length * 8192));
|
||||
previousOutput = await this.createCompletion(
|
||||
BATCH_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
outputLimit,
|
||||
);
|
||||
const parsed = JSON.parse(normalizeModelJson(previousOutput));
|
||||
if (!Array.isArray(parsed.results)) throw new Error('배치 results가 없습니다.');
|
||||
|
||||
const byJobId = new Map(parsed.results.map((result) => [result?.jobId, result]));
|
||||
return entries.map(({ jobId }) => {
|
||||
const result = byJobId.get(jobId);
|
||||
if (!result?.recipe) {
|
||||
return { jobId, error: new Error('AI 배치 응답에 recipe가 없습니다.') };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
jobId,
|
||||
recipe: recipeDraftSchema.parse(normalizeRecipePayload(result.recipe)),
|
||||
};
|
||||
} catch (error) {
|
||||
return { jobId, error };
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppError('AI 배치 응답을 처리하지 못했습니다.', {
|
||||
statusCode: 502,
|
||||
code: 'AI_BATCH_RESPONSE_INVALID',
|
||||
cause: lastError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user