- 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>
272 lines
7.8 KiB
TypeScript
272 lines
7.8 KiB
TypeScript
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,
|
||
},
|
||
];
|
||
}
|
||
}
|