297 lines
9.6 KiB
TypeScript
297 lines
9.6 KiB
TypeScript
import { prisma, Prisma, GenerationStatus, CourseStatus } from '@coursecraft/database';
|
|
import { generateUniqueSlug } from '@coursecraft/shared';
|
|
import { OpenRouterProvider, CourseOutline } from '../providers/openrouter.provider';
|
|
|
|
// Logger helper
|
|
const log = {
|
|
step: (step: string, msg: string) => {
|
|
console.log(`\x1b[35m[Pipeline]\x1b[0m \x1b[1m${step}\x1b[0m - ${msg}`);
|
|
},
|
|
info: (msg: string, data?: unknown) => {
|
|
console.log(`\x1b[35m[Pipeline]\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : '');
|
|
},
|
|
success: (msg: string) => {
|
|
console.log(`\x1b[32m[Pipeline] ✓\x1b[0m ${msg}`);
|
|
},
|
|
error: (msg: string, error?: unknown) => {
|
|
console.error(`\x1b[31m[Pipeline] ✗\x1b[0m ${msg}`, error);
|
|
},
|
|
progress: (generationId: string, progress: number, step: string) => {
|
|
console.log(`\x1b[34m[Pipeline]\x1b[0m [${generationId.substring(0, 8)}...] ${progress}% - ${step}`);
|
|
},
|
|
};
|
|
|
|
export class CourseGenerationPipeline {
|
|
constructor(private aiProvider: OpenRouterProvider) {
|
|
log.info('CourseGenerationPipeline initialized');
|
|
}
|
|
|
|
async generateCourse(
|
|
generationId: string,
|
|
prompt: string,
|
|
aiModel: string
|
|
): Promise<void> {
|
|
log.step('START', `Beginning course generation`);
|
|
log.info(`Generation ID: ${generationId}`);
|
|
log.info(`AI Model: ${aiModel}`);
|
|
log.info(`Prompt: "${prompt}"`);
|
|
|
|
try {
|
|
// Step 1: Update status to analyzing
|
|
log.step('STEP 1', 'Analyzing request...');
|
|
await this.updateProgress(generationId, GenerationStatus.ANALYZING, 10, 'Analyzing your request...');
|
|
|
|
// Step 2: Generate clarifying questions
|
|
log.step('STEP 2', 'Generating clarifying questions...');
|
|
await this.updateProgress(generationId, GenerationStatus.ASKING_QUESTIONS, 15, 'Generating questions...');
|
|
|
|
const questionsResult = await this.aiProvider.generateClarifyingQuestions(prompt, aiModel);
|
|
log.success(`Generated ${questionsResult.questions.length} clarifying questions`);
|
|
|
|
// Step 3: Wait for user answers
|
|
log.step('STEP 3', 'Waiting for user answers...');
|
|
await prisma.courseGeneration.update({
|
|
where: { id: generationId },
|
|
data: {
|
|
status: GenerationStatus.WAITING_FOR_ANSWERS,
|
|
progress: 20,
|
|
currentStep: 'Waiting for your answers...',
|
|
questions: questionsResult.questions as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
log.success('Generation paused - waiting for user answers');
|
|
// Job will be continued when user answers via continue-generation
|
|
} catch (error) {
|
|
log.error('Course generation failed', error);
|
|
await this.updateProgress(
|
|
generationId,
|
|
GenerationStatus.FAILED,
|
|
0,
|
|
'Generation failed',
|
|
{ errorMessage: error instanceof Error ? error.message : 'Unknown error' }
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async continueGeneration(generationId: string, stage: string): Promise<void> {
|
|
log.step('CONTINUE', `Continuing generation at stage: ${stage}`);
|
|
log.info(`Generation ID: ${generationId}`);
|
|
|
|
const generation = await prisma.courseGeneration.findUnique({
|
|
where: { id: generationId },
|
|
});
|
|
|
|
if (!generation) {
|
|
log.error('Generation not found');
|
|
throw new Error('Generation not found');
|
|
}
|
|
|
|
log.info(`Current status: ${generation.status}`);
|
|
log.info(`AI Model: ${generation.aiModel}`);
|
|
|
|
try {
|
|
if (stage === 'after-questions') {
|
|
await this.continueAfterQuestions(generation);
|
|
}
|
|
} catch (error) {
|
|
log.error('Continue generation failed', error);
|
|
await this.updateProgress(
|
|
generationId,
|
|
GenerationStatus.FAILED,
|
|
0,
|
|
'Generation failed',
|
|
{ errorMessage: error instanceof Error ? error.message : 'Unknown error' }
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async continueAfterQuestions(generation: {
|
|
id: string;
|
|
userId: string;
|
|
initialPrompt: string;
|
|
aiModel: string;
|
|
answers: unknown;
|
|
}): Promise<void> {
|
|
const { id: generationId, userId, initialPrompt, aiModel, answers } = generation;
|
|
|
|
log.info('User answers received:', answers);
|
|
|
|
// Step 4: Research (simulated - in production would use web search)
|
|
log.step('STEP 4', 'Researching the topic...');
|
|
await this.updateProgress(generationId, GenerationStatus.RESEARCHING, 30, 'Researching the topic...');
|
|
await this.delay(2000); // Simulate research time
|
|
log.success('Research completed');
|
|
|
|
// Step 5: Generate outline
|
|
log.step('STEP 5', 'Generating course outline...');
|
|
await this.updateProgress(generationId, GenerationStatus.GENERATING_OUTLINE, 50, 'Creating course structure...');
|
|
|
|
const outline = await this.aiProvider.generateCourseOutline(
|
|
initialPrompt,
|
|
(answers as Record<string, string | string[]>) || {},
|
|
aiModel
|
|
);
|
|
|
|
log.success(`Course outline generated: "${outline.title}"`);
|
|
log.info(`Chapters: ${outline.chapters.length}`);
|
|
|
|
await prisma.courseGeneration.update({
|
|
where: { id: generationId },
|
|
data: {
|
|
generatedOutline: outline as Prisma.InputJsonValue,
|
|
progress: 55,
|
|
},
|
|
});
|
|
|
|
// Step 6: Generate content
|
|
log.step('STEP 6', 'Generating course content...');
|
|
await this.updateProgress(generationId, GenerationStatus.GENERATING_CONTENT, 60, 'Writing course content...');
|
|
|
|
// Create course structure first
|
|
log.info('Creating course structure in database...');
|
|
const course = await this.createCourseFromOutline(userId, outline);
|
|
log.success(`Course created with ID: ${course.id}`);
|
|
|
|
// Link course to generation
|
|
await prisma.courseGeneration.update({
|
|
where: { id: generationId },
|
|
data: { courseId: course.id },
|
|
});
|
|
|
|
// Generate content for each lesson
|
|
const totalLessons = outline.chapters.reduce((acc, ch) => acc + ch.lessons.length, 0);
|
|
let processedLessons = 0;
|
|
|
|
log.info(`Total lessons to generate: ${totalLessons}`);
|
|
|
|
for (const chapter of course.chapters) {
|
|
log.info(`Processing chapter: "${chapter.title}" (${chapter.lessons.length} lessons)`);
|
|
|
|
for (const lesson of chapter.lessons) {
|
|
const progress = 60 + Math.floor((processedLessons / totalLessons) * 35);
|
|
log.progress(generationId, progress, `Writing: ${lesson.title}`);
|
|
|
|
await this.updateProgress(
|
|
generationId,
|
|
GenerationStatus.GENERATING_CONTENT,
|
|
progress,
|
|
`Writing: ${lesson.title}...`
|
|
);
|
|
|
|
// Generate lesson content
|
|
const lessonContent = await this.aiProvider.generateLessonContent(
|
|
course.title,
|
|
chapter.title,
|
|
lesson.title,
|
|
{
|
|
difficulty: outline.difficulty,
|
|
},
|
|
aiModel
|
|
);
|
|
|
|
// Update lesson with generated content
|
|
await prisma.lesson.update({
|
|
where: { id: lesson.id },
|
|
data: {
|
|
content: lessonContent.content as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
processedLessons++;
|
|
log.success(`Lesson ${processedLessons}/${totalLessons} completed: "${lesson.title}"`);
|
|
}
|
|
}
|
|
|
|
// Increment user's course count
|
|
log.info('Updating user subscription...');
|
|
await prisma.subscription.update({
|
|
where: { userId },
|
|
data: {
|
|
coursesCreatedThisMonth: {
|
|
increment: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Step 7: Complete
|
|
log.step('COMPLETE', 'Course generation finished!');
|
|
await this.updateProgress(generationId, GenerationStatus.COMPLETED, 100, 'Course completed!');
|
|
log.success(`Course "${course.title}" is ready!`);
|
|
}
|
|
|
|
private async createCourseFromOutline(userId: string, outline: CourseOutline) {
|
|
const slug = generateUniqueSlug(outline.title);
|
|
|
|
return prisma.course.create({
|
|
data: {
|
|
authorId: userId,
|
|
title: outline.title,
|
|
description: outline.description,
|
|
slug,
|
|
status: CourseStatus.DRAFT,
|
|
difficulty: outline.difficulty,
|
|
estimatedHours: outline.estimatedTotalHours,
|
|
tags: outline.tags,
|
|
groups: {
|
|
create: {
|
|
name: 'Основная группа',
|
|
description: 'Обсуждение курса и вопросы преподавателю',
|
|
isDefault: true,
|
|
},
|
|
},
|
|
chapters: {
|
|
create: outline.chapters.map((chapter, chapterIndex) => ({
|
|
title: chapter.title,
|
|
description: chapter.description,
|
|
order: chapterIndex,
|
|
lessons: {
|
|
create: chapter.lessons.map((lesson, lessonIndex) => ({
|
|
title: lesson.title,
|
|
order: lessonIndex,
|
|
durationMinutes: lesson.estimatedMinutes,
|
|
content: Prisma.JsonNull, // Will be filled later
|
|
})),
|
|
},
|
|
})),
|
|
},
|
|
},
|
|
include: {
|
|
chapters: {
|
|
include: {
|
|
lessons: {
|
|
orderBy: { order: 'asc' },
|
|
},
|
|
},
|
|
orderBy: { order: 'asc' },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
private async updateProgress(
|
|
generationId: string,
|
|
status: GenerationStatus,
|
|
progress: number,
|
|
currentStep: string,
|
|
additionalData?: Record<string, unknown>
|
|
): Promise<void> {
|
|
await prisma.courseGeneration.update({
|
|
where: { id: generationId },
|
|
data: {
|
|
status,
|
|
progress,
|
|
currentStep,
|
|
...(additionalData?.errorMessage != null ? { errorMessage: additionalData.errorMessage as string } : {}),
|
|
...(status === GenerationStatus.COMPLETED ? { completedAt: new Date() } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
private delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
}
|