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>
This commit is contained in:
@ -140,34 +140,132 @@ export class LessonsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async generateQuiz(lessonId: string): Promise<any> {
|
async generateQuiz(lessonId: string): Promise<any> {
|
||||||
const lesson = await this.prisma.lesson.findUnique({ where: { id: lessonId } });
|
const lesson = await this.prisma.lesson.findUnique({
|
||||||
|
where: { id: lessonId },
|
||||||
|
include: { quiz: true },
|
||||||
|
});
|
||||||
if (!lesson) throw new NotFoundException('Lesson not found');
|
if (!lesson) throw new NotFoundException('Lesson not found');
|
||||||
|
|
||||||
// Mock quiz - in production, parse lesson.content and call AI to generate questions
|
// Return cached quiz if exists
|
||||||
return {
|
if (lesson.quiz) {
|
||||||
questions: [
|
return { questions: lesson.quiz.questions };
|
||||||
{
|
}
|
||||||
id: '1',
|
|
||||||
type: 'multiple_choice',
|
// Generate quiz using AI
|
||||||
question: 'Какой из следующих вариантов верен?',
|
const questions = await this.generateQuizWithAI(lesson.content, lesson.title);
|
||||||
options: ['Вариант A', 'Вариант B', 'Вариант C', 'Вариант D'],
|
|
||||||
correctAnswer: 0,
|
// 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({
|
||||||
id: '2',
|
model,
|
||||||
type: 'multiple_choice',
|
messages: [{ role: 'user', content: prompt }],
|
||||||
question: 'Выберите правильный ответ:',
|
temperature: 0.7,
|
||||||
options: ['Ответ 1', 'Ответ 2', 'Ответ 3'],
|
}),
|
||||||
correctAnswer: 1,
|
});
|
||||||
},
|
|
||||||
{
|
if (!response.ok) {
|
||||||
id: '3',
|
return this.getDefaultQuiz();
|
||||||
type: 'multiple_choice',
|
}
|
||||||
question: 'Что из перечисленного правильно?',
|
|
||||||
options: ['Первое', 'Второе', 'Третье', 'Четвёртое'],
|
const data: any = await response.json();
|
||||||
correctAnswer: 2,
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -241,6 +241,7 @@ model Lesson {
|
|||||||
// Relations
|
// Relations
|
||||||
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
|
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
|
||||||
homework Homework[]
|
homework Homework[]
|
||||||
|
quiz Quiz?
|
||||||
|
|
||||||
// Vector embedding for semantic search
|
// Vector embedding for semantic search
|
||||||
embedding Unsupported("vector(1536)")?
|
embedding Unsupported("vector(1536)")?
|
||||||
@ -249,6 +250,18 @@ model Lesson {
|
|||||||
@@map("lessons")
|
@@map("lessons")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Quiz {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
lessonId String @unique @map("lesson_id")
|
||||||
|
questions Json // Array of {id, question, options, correctAnswer}
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("quizzes")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// AI Course Generation
|
// AI Course Generation
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user