project init
This commit is contained in:
26
apps/ai-service/package.json
Normal file
26
apps/ai-service/package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@coursecraft/ai-service",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist .turbo",
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src/**/*.ts --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coursecraft/database": "workspace:*",
|
||||
"@coursecraft/shared": "workspace:*",
|
||||
"bullmq": "^5.1.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"ioredis": "^5.3.0",
|
||||
"openai": "^4.28.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
108
apps/ai-service/src/index.ts
Normal file
108
apps/ai-service/src/index.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// Load .env from project root
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||
import { Worker, Job } from 'bullmq';
|
||||
import Redis from 'ioredis';
|
||||
import { CourseGenerationPipeline } from './pipeline/course-generation.pipeline';
|
||||
import { OpenRouterProvider } from './providers/openrouter.provider';
|
||||
|
||||
// Logger helper
|
||||
const log = {
|
||||
info: (msg: string) => console.log(`\x1b[36m[AI Service]\x1b[0m ${msg}`),
|
||||
success: (msg: string) => console.log(`\x1b[32m[AI Service] ✓\x1b[0m ${msg}`),
|
||||
error: (msg: string, error?: unknown) => console.error(`\x1b[31m[AI Service] ✗\x1b[0m ${msg}`, error || ''),
|
||||
job: (action: string, job: Job) => {
|
||||
console.log(`\x1b[33m[AI Service]\x1b[0m ${action}: ${job.name} (ID: ${job.id})`);
|
||||
console.log(`\x1b[90m Data: ${JSON.stringify(job.data)}\x1b[0m`);
|
||||
},
|
||||
};
|
||||
|
||||
// Check environment variables
|
||||
log.info('Checking environment variables...');
|
||||
log.info(`REDIS_URL: ${process.env.REDIS_URL || 'redis://localhost:6395 (default)'}`);
|
||||
log.info(`OPENROUTER_API_KEY: ${process.env.OPENROUTER_API_KEY ? 'SET' : 'NOT SET'}`);
|
||||
log.info(`OPENROUTER_BASE_URL: ${process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1 (default)'}`);
|
||||
|
||||
const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6395', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
redisConnection.on('connect', () => log.success('Connected to Redis'));
|
||||
redisConnection.on('error', (err) => log.error('Redis connection error', err));
|
||||
|
||||
let openRouterProvider: OpenRouterProvider;
|
||||
let pipeline: CourseGenerationPipeline;
|
||||
|
||||
try {
|
||||
openRouterProvider = new OpenRouterProvider();
|
||||
pipeline = new CourseGenerationPipeline(openRouterProvider);
|
||||
} catch (error) {
|
||||
log.error('Failed to initialize AI providers', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Worker for course generation
|
||||
const worker = new Worker(
|
||||
'course-generation',
|
||||
async (job) => {
|
||||
log.job('Processing job', job);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (job.name === 'generate-course') {
|
||||
const { generationId, prompt, aiModel } = job.data;
|
||||
result = await pipeline.generateCourse(generationId, prompt, aiModel);
|
||||
} else if (job.name === 'continue-generation') {
|
||||
const { generationId, stage } = job.data;
|
||||
result = await pipeline.continueGeneration(generationId, stage);
|
||||
} else {
|
||||
log.error(`Unknown job name: ${job.name}`);
|
||||
throw new Error(`Unknown job name: ${job.name}`);
|
||||
}
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
log.success(`Job ${job.name} completed in ${duration}s`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
log.error(`Job ${job.name} failed after ${duration}s`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: redisConnection,
|
||||
concurrency: 5,
|
||||
}
|
||||
);
|
||||
|
||||
worker.on('completed', (job) => {
|
||||
log.success(`Job ${job.id} completed successfully`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
log.error(`Job ${job?.id} failed: ${error.message}`);
|
||||
if (error.stack) {
|
||||
console.error(`\x1b[90m${error.stack}\x1b[0m`);
|
||||
}
|
||||
});
|
||||
|
||||
worker.on('error', (error) => {
|
||||
log.error('Worker error', error);
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log('🤖 ═══════════════════════════════════════════════════════');
|
||||
console.log('🤖 AI Service started and listening for jobs...');
|
||||
console.log('🤖 ═══════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
log.info('Shutting down AI Service...');
|
||||
await worker.close();
|
||||
await redisConnection.quit();
|
||||
process.exit(0);
|
||||
});
|
||||
289
apps/ai-service/src/pipeline/course-generation.pipeline.ts
Normal file
289
apps/ai-service/src/pipeline/course-generation.pipeline.ts
Normal file
@ -0,0 +1,289 @@
|
||||
import { 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 unknown as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
|
||||
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 unknown as Record<string, unknown>,
|
||||
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 unknown as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
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: null, // 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 && { errorMessage: additionalData.errorMessage as string }),
|
||||
...(status === GenerationStatus.COMPLETED && { completedAt: new Date() }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
393
apps/ai-service/src/providers/openrouter.provider.ts
Normal file
393
apps/ai-service/src/providers/openrouter.provider.ts
Normal file
@ -0,0 +1,393 @@
|
||||
import OpenAI from 'openai';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Logger helper
|
||||
const log = {
|
||||
info: (msg: string, data?: unknown) => {
|
||||
console.log(`\x1b[36m[AI Provider]\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : '');
|
||||
},
|
||||
success: (msg: string, data?: unknown) => {
|
||||
console.log(`\x1b[32m[AI Provider] ✓\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : '');
|
||||
},
|
||||
error: (msg: string, error?: unknown) => {
|
||||
console.error(`\x1b[31m[AI Provider] ✗\x1b[0m ${msg}`, error);
|
||||
},
|
||||
debug: (msg: string, data?: unknown) => {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log(`\x1b[90m[AI Provider DEBUG]\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : '');
|
||||
}
|
||||
},
|
||||
request: (method: string, model: string) => {
|
||||
console.log(`\x1b[33m[AI Provider] →\x1b[0m Calling ${method} with model: ${model}`);
|
||||
},
|
||||
response: (method: string, tokens?: { prompt?: number; completion?: number }) => {
|
||||
const tokenInfo = tokens ? ` (tokens: ${tokens.prompt || '?'}/${tokens.completion || '?'})` : '';
|
||||
console.log(`\x1b[33m[AI Provider] ←\x1b[0m ${method} completed${tokenInfo}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Course outline schema for structured output
|
||||
const CourseOutlineSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
chapters: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
lessons: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
estimatedMinutes: z.number(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
estimatedTotalHours: z.number(),
|
||||
difficulty: z.enum(['beginner', 'intermediate', 'advanced']),
|
||||
tags: z.array(z.string()),
|
||||
});
|
||||
|
||||
const ClarifyingQuestionItemSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
question: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
type: z.enum(['single_choice', 'multiple_choice', 'text']).optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
required: z.boolean().optional(),
|
||||
}).refine((q) => typeof (q.question ?? q.text) === 'string', { message: 'question or text required' });
|
||||
|
||||
const ClarifyingQuestionsSchema = z.object({
|
||||
questions: z.array(ClarifyingQuestionItemSchema).transform((items) =>
|
||||
items.map((q, i) => ({
|
||||
id: q.id ?? `q_${i}`,
|
||||
question: (q.question ?? q.text ?? '').trim() || `Вопрос ${i + 1}`,
|
||||
type: (q.type ?? 'text') as 'single_choice' | 'multiple_choice' | 'text',
|
||||
options: q.options,
|
||||
required: q.required ?? true,
|
||||
}))
|
||||
),
|
||||
});
|
||||
|
||||
const LessonContentSchema = z.object({
|
||||
content: z.object({
|
||||
type: z.literal('doc'),
|
||||
content: z.array(z.any()),
|
||||
}),
|
||||
});
|
||||
|
||||
export type CourseOutline = z.infer<typeof CourseOutlineSchema>;
|
||||
export type ClarifyingQuestions = z.infer<typeof ClarifyingQuestionsSchema>;
|
||||
export type LessonContent = z.infer<typeof LessonContentSchema>;
|
||||
|
||||
export class OpenRouterProvider {
|
||||
private client: OpenAI;
|
||||
private maxRetries = 3;
|
||||
private retryDelay = 2000;
|
||||
|
||||
constructor() {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
const baseURL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
|
||||
|
||||
log.info('Initializing OpenRouter provider...');
|
||||
log.info(`Base URL: ${baseURL}`);
|
||||
log.info(`API Key: ${apiKey ? apiKey.substring(0, 15) + '...' : 'NOT SET'}`);
|
||||
|
||||
if (!apiKey) {
|
||||
log.error('OPENROUTER_API_KEY environment variable is required');
|
||||
throw new Error('OPENROUTER_API_KEY environment variable is required');
|
||||
}
|
||||
|
||||
this.client = new OpenAI({
|
||||
baseURL,
|
||||
apiKey,
|
||||
timeout: 120000, // 2 minute timeout
|
||||
maxRetries: 3,
|
||||
defaultHeaders: {
|
||||
'HTTP-Referer': process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
|
||||
'X-Title': 'CourseCraft',
|
||||
},
|
||||
});
|
||||
|
||||
log.success('OpenRouter provider initialized');
|
||||
}
|
||||
|
||||
private async withRetry<T>(fn: () => Promise<T>, operation: string): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
log.error(`${operation} attempt ${attempt}/${this.maxRetries} failed: ${error.message}`);
|
||||
|
||||
if (attempt < this.maxRetries) {
|
||||
const delay = this.retryDelay * attempt;
|
||||
log.info(`Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async generateClarifyingQuestions(
|
||||
prompt: string,
|
||||
model: string
|
||||
): Promise<ClarifyingQuestions> {
|
||||
log.request('generateClarifyingQuestions', model);
|
||||
log.info(`User prompt: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
||||
|
||||
const systemPrompt = `Ты - эксперт по созданию образовательных курсов.
|
||||
Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы,
|
||||
чтобы лучше понять его потребности и создать максимально релевантный курс.
|
||||
|
||||
Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса:
|
||||
- Короткий (2-4 главы, введение в тему)
|
||||
- Средний (4-7 глав, хорошее покрытие)
|
||||
- Длинный / полный (6-12 глав, глубокое погружение)
|
||||
|
||||
Остальные вопросы: целевая аудитория, глубина материала, специфические темы.
|
||||
|
||||
Ответь в формате JSON.`;
|
||||
|
||||
return this.withRetry(async () => {
|
||||
const response = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: `Запрос пользователя: "${prompt}"` },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
log.response('generateClarifyingQuestions', {
|
||||
prompt: response.usage?.prompt_tokens,
|
||||
completion: response.usage?.completion_tokens,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
log.debug('Raw AI response:', content);
|
||||
|
||||
if (!content) {
|
||||
log.error('Empty response from AI');
|
||||
throw new Error('Empty response from AI');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
const validated = ClarifyingQuestionsSchema.parse(parsed);
|
||||
|
||||
log.success(`Generated ${validated.questions.length} clarifying questions`);
|
||||
log.info('Questions:', validated.questions.map(q => q.question));
|
||||
|
||||
return validated;
|
||||
}, 'generateClarifyingQuestions');
|
||||
}
|
||||
|
||||
async generateCourseOutline(
|
||||
prompt: string,
|
||||
answers: Record<string, string | string[]>,
|
||||
model: string
|
||||
): Promise<CourseOutline> {
|
||||
log.request('generateCourseOutline', model);
|
||||
log.info(`Prompt: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
||||
log.info('User answers:', answers);
|
||||
|
||||
const systemPrompt = `Ты - эксперт по созданию образовательных курсов.
|
||||
Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы.
|
||||
|
||||
ОБЪЁМ КУРСА (соблюдай строго по ответам пользователя):
|
||||
- Если пользователь выбрал короткий курс / введение: 2–4 главы, в каждой 2–4 урока. estimatedTotalHours: 2–8.
|
||||
- Если средний курс: 4–7 глав, в каждой 3–5 уроков. estimatedTotalHours: 8–20.
|
||||
- Если длинный / полный курс: 6–12 глав, в каждой 4–8 уроков. estimatedTotalHours: 15–40.
|
||||
- Если объём не указан — предложи средний (4–6 глав по 3–5 уроков).
|
||||
|
||||
Укажи примерное время на каждый урок (estimatedMinutes: 10–45). estimatedTotalHours = сумма уроков.
|
||||
Определи сложность (beginner|intermediate|advanced). Добавь релевантные теги.
|
||||
|
||||
Ответь в формате JSON со структурой:
|
||||
{
|
||||
"title": "Название курса",
|
||||
"description": "Подробное описание курса",
|
||||
"chapters": [
|
||||
{
|
||||
"title": "Название главы",
|
||||
"description": "Описание главы",
|
||||
"lessons": [
|
||||
{ "title": "Название урока", "estimatedMinutes": 25 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"estimatedTotalHours": 20,
|
||||
"difficulty": "beginner|intermediate|advanced",
|
||||
"tags": ["тег1", "тег2"]
|
||||
}`;
|
||||
|
||||
const userMessage = `Запрос: "${prompt}"
|
||||
|
||||
Ответы пользователя на уточняющие вопросы:
|
||||
${Object.entries(answers)
|
||||
.map(([key, value]) => `- ${key}: ${Array.isArray(value) ? value.join(', ') : value}`)
|
||||
.join('\n')}`;
|
||||
|
||||
return this.withRetry(async () => {
|
||||
const response = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
log.response('generateCourseOutline', {
|
||||
prompt: response.usage?.prompt_tokens,
|
||||
completion: response.usage?.completion_tokens,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
log.debug('Raw AI response:', content);
|
||||
|
||||
if (!content) {
|
||||
log.error('Empty response from AI');
|
||||
throw new Error('Empty response from AI');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
const validated = CourseOutlineSchema.parse(parsed);
|
||||
|
||||
const totalLessons = validated.chapters.reduce((acc, ch) => acc + ch.lessons.length, 0);
|
||||
log.success(`Generated course outline: "${validated.title}"`);
|
||||
log.info(`Structure: ${validated.chapters.length} chapters, ${totalLessons} lessons`);
|
||||
log.info(`Difficulty: ${validated.difficulty}, Est. hours: ${validated.estimatedTotalHours}`);
|
||||
|
||||
return validated;
|
||||
}, 'generateCourseOutline');
|
||||
}
|
||||
|
||||
async generateLessonContent(
|
||||
courseTitle: string,
|
||||
chapterTitle: string,
|
||||
lessonTitle: string,
|
||||
context: {
|
||||
difficulty: string;
|
||||
targetAudience?: string;
|
||||
},
|
||||
model: string
|
||||
): Promise<LessonContent> {
|
||||
log.request('generateLessonContent', model);
|
||||
log.info(`Generating content for: "${lessonTitle}" (Chapter: "${chapterTitle}")`);
|
||||
|
||||
const systemPrompt = `Ты - эксперт по созданию образовательного контента.
|
||||
Пиши содержание урока СРАЗУ в форматированном виде — в формате TipTap JSON. Каждый блок контента должен быть правильным узлом TipTap (heading, paragraph, bulletList, orderedList, blockquote, codeBlock, image).
|
||||
|
||||
ФОРМАТИРОВАНИЕ (используй обязательно):
|
||||
- Заголовки: { "type": "heading", "attrs": { "level": 1|2|3 }, "content": [{ "type": "text", "text": "..." }] }
|
||||
- Параграфы: { "type": "paragraph", "content": [{ "type": "text", "text": "..." }] } — для выделения используй "marks": [{ "type": "bold" }] или [{ "type": "italic" }]
|
||||
- Списки: bulletList > listItem > paragraph; orderedList > listItem > paragraph
|
||||
- Цитаты: { "type": "blockquote", "content": [{ "type": "paragraph", "content": [...] }] }
|
||||
- Код: { "type": "codeBlock", "attrs": { "language": "javascript"|"python"|"text" }, "content": [{ "type": "text", "text": "код" }] }
|
||||
- Mermaid-диаграммы: { "type": "codeBlock", "attrs": { "language": "mermaid" }, "content": [{ "type": "text", "text": "graph LR\\n A --> B" }] } — вставляй где уместно (схемы, процессы, связи)
|
||||
- Картинки не генерируй (src нельзя выдумывать); если нужна иллюстрация — опиши в тексте или предложи место для изображения
|
||||
|
||||
ОБЪЁМ: зависит от темы. Короткий урок: 300–600 слов (3–5 блоков). Средний: 600–1200 слов. Длинный: 1200–2500 слов. Структура: заголовок, введение, 2–4 секции с подзаголовками, примеры/код/списки, резюме.
|
||||
|
||||
Уровень: ${context.difficulty}. ${context.targetAudience ? `Аудитория: ${context.targetAudience}` : ''}
|
||||
|
||||
Ответь только валидным JSON:
|
||||
{
|
||||
"content": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{ "type": "heading", "attrs": { "level": 1 }, "content": [{ "type": "text", "text": "Заголовок" }] },
|
||||
{ "type": "paragraph", "content": [{ "type": "text", "text": "Текст..." }] }
|
||||
]
|
||||
}
|
||||
}`;
|
||||
|
||||
return this.withRetry(async () => {
|
||||
const response = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: `Курс: "${courseTitle}"
|
||||
Глава: "${chapterTitle}"
|
||||
Урок: "${lessonTitle}"
|
||||
|
||||
Создай содержание урока сразу в TipTap JSON: заголовки (heading), параграфы (paragraph), списки (bulletList/orderedList), цитаты (blockquote), блоки кода (codeBlock) и при необходимости Mermaid-диаграммы (codeBlock с "language": "mermaid"). Объём — по теме урока (короткий/средний/длинный).`,
|
||||
},
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.7,
|
||||
max_tokens: 8000,
|
||||
});
|
||||
|
||||
log.response('generateLessonContent', {
|
||||
prompt: response.usage?.prompt_tokens,
|
||||
completion: response.usage?.completion_tokens,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
log.debug('Raw AI response:', content?.substring(0, 200) + '...');
|
||||
|
||||
if (!content) {
|
||||
log.error('Empty response from AI');
|
||||
throw new Error('Empty response from AI');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
const validated = LessonContentSchema.parse(parsed);
|
||||
|
||||
log.success(`Generated content for lesson: "${lessonTitle}"`);
|
||||
|
||||
return validated;
|
||||
}, `generateLessonContent: ${lessonTitle}`);
|
||||
}
|
||||
|
||||
async rewriteText(
|
||||
text: string,
|
||||
instruction: string,
|
||||
model: string
|
||||
): Promise<string> {
|
||||
const response = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'Ты - редактор образовательного контента. Переработай текст согласно инструкции пользователя.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Исходный текст:
|
||||
"${text}"
|
||||
|
||||
Инструкция: ${instruction}
|
||||
|
||||
Верни только переработанный текст без дополнительных пояснений.`,
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return response.choices[0].message.content || text;
|
||||
}
|
||||
|
||||
async chat(
|
||||
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
|
||||
model: string
|
||||
): Promise<string> {
|
||||
const response = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return response.choices[0].message.content || '';
|
||||
}
|
||||
}
|
||||
9
apps/ai-service/tsconfig.json
Normal file
9
apps/ai-service/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user