From f39680d714cc8d5213b63709f7ece403571be2a8 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 6 Feb 2026 10:58:15 +0000 Subject: [PATCH] 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 --- apps/api/src/courses/lessons.service.ts | 148 ++++++++++++++++++++---- packages/database/prisma/schema.prisma | 13 +++ 2 files changed, 136 insertions(+), 25 deletions(-) diff --git a/apps/api/src/courses/lessons.service.ts b/apps/api/src/courses/lessons.service.ts index 9c71a78..ca2881a 100644 --- a/apps/api/src/courses/lessons.service.ts +++ b/apps/api/src/courses/lessons.service.ts @@ -140,34 +140,132 @@ export class LessonsService { } async generateQuiz(lessonId: string): Promise { - 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'); - // Mock quiz - in production, parse lesson.content and call AI to generate questions - return { - questions: [ - { - id: '1', - type: 'multiple_choice', - question: 'Какой из следующих вариантов верен?', - options: ['Вариант A', 'Вариант B', 'Вариант C', 'Вариант D'], - correctAnswer: 0, + // 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 { + 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}`, }, - { - id: '2', - type: 'multiple_choice', - question: 'Выберите правильный ответ:', - options: ['Ответ 1', 'Ответ 2', 'Ответ 3'], - correctAnswer: 1, - }, - { - id: '3', - type: 'multiple_choice', - question: 'Что из перечисленного правильно?', - options: ['Первое', 'Второе', 'Третье', 'Четвёртое'], - correctAnswer: 2, - }, - ], + 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, + }, + ]; } } diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 5aa5bda..6312ff2 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -241,6 +241,7 @@ model Lesson { // Relations chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade) homework Homework[] + quiz Quiz? // Vector embedding for semantic search embedding Unsupported("vector(1536)")? @@ -249,6 +250,18 @@ model Lesson { @@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 // ============================================