Files
course-craft-service/apps/api/src/courses/lessons.service.ts
root f39680d714 feat: AI-powered quiz generation with caching
- Add Quiz model to store generated questions in DB
- Integrate OpenRouter API to generate quiz from lesson content
- Cache quiz results to avoid regenerating on every request
- Extract text from TipTap JSON and send to AI
- Fallback to default questions if AI fails or no API key

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 10:58:15 +00:00

272 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { Lesson } from '@coursecraft/database';
import { CoursesService } from './courses.service';
import { ChaptersService } from './chapters.service';
import { CreateLessonDto } from './dto/create-lesson.dto';
import { UpdateLessonDto } from './dto/update-lesson.dto';
@Injectable()
export class LessonsService {
constructor(
private prisma: PrismaService,
private coursesService: CoursesService,
private chaptersService: ChaptersService
) {}
async create(chapterId: string, userId: string, dto: CreateLessonDto): Promise<Lesson> {
const chapter = await this.chaptersService.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
// Get max order
const maxOrder = await this.prisma.lesson.aggregate({
where: { chapterId },
_max: { order: true },
});
const order = (maxOrder._max.order ?? -1) + 1;
return this.prisma.lesson.create({
data: {
chapterId,
title: dto.title,
content: dto.content as any,
order,
durationMinutes: dto.durationMinutes,
},
});
}
async findById(id: string): Promise<Lesson | null> {
return this.prisma.lesson.findUnique({
where: { id },
include: {
chapter: {
select: {
id: true,
title: true,
courseId: true,
},
},
},
});
}
async update(lessonId: string, userId: string, dto: UpdateLessonDto): Promise<Lesson> {
const lesson = await this.prisma.lesson.findUnique({
where: { id: lessonId },
include: {
chapter: {
select: { courseId: true },
},
},
});
if (!lesson) {
throw new NotFoundException('Lesson not found');
}
const isOwner = await this.coursesService.checkOwnership(lesson.chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
return this.prisma.lesson.update({
where: { id: lessonId },
data: {
title: dto.title,
content: dto.content as any,
durationMinutes: dto.durationMinutes,
videoUrl: dto.videoUrl,
},
});
}
async delete(lessonId: string, userId: string): Promise<void> {
const lesson = await this.prisma.lesson.findUnique({
where: { id: lessonId },
include: {
chapter: {
select: { courseId: true },
},
},
});
if (!lesson) {
throw new NotFoundException('Lesson not found');
}
const isOwner = await this.coursesService.checkOwnership(lesson.chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
await this.prisma.lesson.delete({
where: { id: lessonId },
});
}
async reorder(chapterId: string, userId: string, lessonIds: string[]): Promise<Lesson[]> {
const chapter = await this.chaptersService.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
await Promise.all(
lessonIds.map((id, index) =>
this.prisma.lesson.update({
where: { id },
data: { order: index },
})
)
);
return this.prisma.lesson.findMany({
where: { chapterId },
orderBy: { order: 'asc' },
});
}
async generateQuiz(lessonId: string): Promise<any> {
const lesson = await this.prisma.lesson.findUnique({
where: { id: lessonId },
include: { quiz: true },
});
if (!lesson) throw new NotFoundException('Lesson not found');
// Return cached quiz if exists
if (lesson.quiz) {
return { questions: lesson.quiz.questions };
}
// Generate quiz using AI
const questions = await this.generateQuizWithAI(lesson.content, lesson.title);
// Save to database
await this.prisma.quiz.create({
data: { lessonId, questions: questions as any },
});
return { questions };
}
private async generateQuizWithAI(content: any, lessonTitle: string): Promise<any[]> {
try {
// Extract text from TipTap JSON content
const textContent = this.extractTextFromContent(content);
if (!textContent || textContent.length < 50) {
// Not enough content, return simple quiz
return this.getDefaultQuiz();
}
// Call OpenRouter to generate quiz
const apiKey = process.env.OPENROUTER_API_KEY;
const baseUrl = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
const model = process.env.AI_MODEL_FREE || 'openai/gpt-4o-mini';
if (!apiKey) {
return this.getDefaultQuiz();
}
const prompt = `На основе следующего содержания урока "${lessonTitle}" сгенерируй 3-5 вопросов с вариантами ответов для теста.
Содержание урока:
${textContent.slice(0, 3000)}
Верни JSON массив вопросов в формате:
[
{
"id": "1",
"question": "Вопрос?",
"options": ["Вариант А", "Вариант Б", "Вариант В", "Вариант Г"],
"correctAnswer": 0
}
]
Только JSON, без markdown.`;
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
}),
});
if (!response.ok) {
return this.getDefaultQuiz();
}
const data: any = await response.json();
const aiResponse = data.choices?.[0]?.message?.content || '';
// Parse JSON from response
const jsonMatch = aiResponse.match(/\[[\s\S]*\]/);
if (jsonMatch) {
const questions = JSON.parse(jsonMatch[0]);
return questions;
}
return this.getDefaultQuiz();
} catch {
return this.getDefaultQuiz();
}
}
private extractTextFromContent(content: any): string {
if (!content || typeof content !== 'object') return '';
let text = '';
const traverse = (node: any) => {
if (node.type === 'text' && node.text) {
text += node.text + ' ';
}
if (node.content && Array.isArray(node.content)) {
node.content.forEach(traverse);
}
};
traverse(content);
return text.trim();
}
private getDefaultQuiz(): any[] {
return [
{
id: '1',
question: 'Вы изучили материал урока?',
options: ['Да, всё понятно', 'Частично', 'Нужно повторить', 'Нет'],
correctAnswer: 0,
},
{
id: '2',
question: 'Какие основные темы были рассмотрены?',
options: ['Теория и практика', 'Только теория', 'Только примеры', 'Не помню'],
correctAnswer: 0,
},
{
id: '3',
question: 'Готовы ли вы применить полученные знания?',
options: ['Да, готов', 'Нужна практика', 'Нужно повторить', 'Нет'],
correctAnswer: 0,
},
];
}
}