feat: add batched recipe import queue

This commit is contained in:
2026-08-28 11:09:36 +09:00
parent 4491c91455
commit f65e660184
19 changed files with 1080 additions and 262 deletions
+25
View File
@@ -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);
+1
View File
@@ -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}`,
});
+4
View File
@@ -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);
+140
View File
@@ -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 },
);
}
}
+88 -2
View File
@@ -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) };
});
}
+6 -65
View File
@@ -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) => {
+6
View File
@@ -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));
}
}
}
}
}
}
}
+74
View File
@@ -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;
}
}
}
+105 -24
View File
@@ -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,
});
}
}