diff --git a/src/ai/ai.service.ts b/src/ai/ai.service.ts index f929740..112f128 100644 --- a/src/ai/ai.service.ts +++ b/src/ai/ai.service.ts @@ -2321,4 +2321,127 @@ Rules: throw new NotFoundException('Deck not found'); } } + + /** + * Generate quiz questions from raw conversation text (for auto-generated quizzes) + * Used by QuizGenerationService to generate quizzes from recent learning history + */ + async generateQuizQuestions( + conversationText: string, + ): Promise< + Array<{ + question: string; + options: string[]; + correctAnswer: string; + explanation: string; + }> + > { + try { + if (!conversationText || conversationText.trim().length === 0) { + throw new Error('Conversation text is required'); + } + + let result; + let attempts = 0; + const maxAttempts = 2; + + while (attempts < maxAttempts) { + try { + result = await Promise.race([ + this.genAI.models.generateContent({ + model: 'gemini-2.5-flash', + contents: conversationText, + config: { + temperature: 0.1, + maxOutputTokens: 5000, + systemInstruction: this.systemInstructionForQuiz, + responseMimeType: 'application/json', + responseSchema: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + question: { + type: Type.STRING, + description: 'The quiz question text', + }, + options: { + type: Type.ARRAY, + items: { + type: Type.STRING, + }, + description: 'Array of 4 possible answers', + }, + correctAnswer: { + type: Type.STRING, + description: + 'The correct answer (must be one of the options)', + }, + explanation: { + type: Type.STRING, + description: 'Brief explanation of the correct answer', + }, + }, + required: [ + 'question', + 'options', + 'correctAnswer', + 'explanation', + ], + }, + }, + }, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Quiz generation timeout')), 30000), + ), + ]); + + if ( + result && + result.candidates && + result.candidates[0]?.content?.parts[0]?.text + ) { + const text = result.candidates[0].content.parts[0].text; + const questions = JSON.parse(text); + + if (!Array.isArray(questions)) { + throw new Error('Response is not an array'); + } + + // Validate questions + const validatedQuestions = questions.filter((q) => { + return ( + q.question && + Array.isArray(q.options) && + q.options.length === 4 && + q.correctAnswer && + q.options.includes(q.correctAnswer) && + q.explanation + ); + }); + + if (validatedQuestions.length === 0) { + throw new Error('No valid questions generated'); + } + + return validatedQuestions; + } + + throw new Error('No response from AI model'); + } catch (error) { + attempts++; + if (attempts >= maxAttempts) { + throw error; + } + // Retry once on error + } + } + + throw new Error('Failed to generate quiz after max attempts'); + } catch (error) { + console.error('Error in generateQuizQuestions:', error); + throw error; + } + } } diff --git a/src/quizzes/quiz-generation.service.ts b/src/quizzes/quiz-generation.service.ts new file mode 100644 index 0000000..2ed34e2 --- /dev/null +++ b/src/quizzes/quiz-generation.service.ts @@ -0,0 +1,311 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import db from '../../drizzle'; +import { eq, desc, and, gte } from 'drizzle-orm'; +import { chat, message, publicQuiz, user } from '../../lib/db/schema'; +import { AiService } from '../ai/ai.service'; +import { NotificationsService } from '../common/services/notifications.service'; +import { ActivityService } from '../activity/activity.service'; + +interface QuizQuestion { + question: string; + options: string[]; + correctAnswer: string; + explanation: string; +} + +@Injectable() +export class QuizGenerationService { + private readonly logger = new Logger(QuizGenerationService.name); + + constructor( + private readonly aiService: AiService, + private readonly notificationsService: NotificationsService, + private readonly activityService: ActivityService, + ) {} + + /** + * Generate a quiz based on the user's recent learning history + * Fetches recent chat messages, passes to AI for generation, + * and creates a quiz notification + */ + async generateQuizFromRecentLearning( + userId: string, + daysBack: number = 3, + ): Promise<{ quizId: string; title: string; notificationSent: boolean }> { + try { + // 1. Get user info + const [userRecord] = await db + .select() + .from(user) + .where(eq(user.id, userId)); + + if (!userRecord) { + throw new NotFoundException(`User ${userId} not found`); + } + + // 2. Fetch recent chat sessions (within daysBack) + const dateThreshold = new Date(); + dateThreshold.setDate(dateThreshold.getDate() - daysBack); + + const recentChats = await db + .select({ + chatId: chat.id, + chatTitle: chat.title, + createdAt: chat.createdAt, + }) + .from(chat) + .where(and(eq(chat.userId, userId), gte(chat.createdAt, dateThreshold))) + .orderBy(desc(chat.createdAt)) + .limit(5); // Get up to 5 recent chats + + if (recentChats.length === 0) { + throw new NotFoundException( + `No recent learning activity found for user ${userId} in the last ${daysBack} days`, + ); + } + + // 3. Fetch messages from the most recent chat + const mostRecentChat = recentChats[0]; + const chatMessages = await db + .select() + .from(message) + .where(eq(message.chatId, mostRecentChat.chatId)) + .orderBy(message.createdAt) + .limit(50); + + if (chatMessages.length === 0) { + throw new NotFoundException( + `No messages found in chat ${mostRecentChat.chatId}`, + ); + } + + // 4. Build conversation context for AI + const conversationText = chatMessages + .map( + (m) => + `${m.role === 'user' ? 'User' : 'Assistant'}: ${ + typeof m.content === 'string' + ? m.content + : JSON.stringify(m.content) + }`, + ) + .join('\n'); + + // 5. Call AI service to generate quiz + const generatedQuestions = await this.aiService.generateQuizQuestions( + conversationText, + ); + + if (!generatedQuestions || generatedQuestions.length === 0) { + throw new Error('AI service did not generate any questions'); + } + + // 6. Determine quiz title from chat or topic + const quizTitle = this.generateQuizTitle( + mostRecentChat.chatTitle, + generatedQuestions, + ); + + // 7. Create quiz in database + const [createdQuiz] = await db + .insert(publicQuiz) + .values({ + title: quizTitle, + description: `Auto-generated quiz from your ${mostRecentChat.chatTitle} discussion`, + questions: generatedQuestions as unknown as Record, + createdBy: userId, + sourceChatId: mostRecentChat.chatId, + }) + .returning(); + + this.logger.log( + `Generated quiz ${createdQuiz.id} for user ${userId} from chat ${mostRecentChat.chatId}`, + ); + + // 8. Send notification to user (with compelling copy) + let notificationSent = false; + if (userRecord.expoPushToken) { + try { + await this.notificationsService.createNotification( + { + title: '✨ New Quiz Ready for You!', + content: `Test yourself on ${quizTitle.replace('Quiz: ', '')} — see how much you've learned! 🚀`, + userId: userId, + }, + true, // sendPush = true + ); + notificationSent = true; + this.logger.log( + `Notification sent to user ${userId} for quiz ${createdQuiz.id}`, + ); + } catch (notificationError) { + this.logger.warn( + `Failed to send notification: ${(notificationError as Error)?.message}`, + ); + // Don't fail the whole operation if notification fails + notificationSent = false; + } + } + + return { + quizId: createdQuiz.id, + title: createdQuiz.title, + notificationSent, + }; + } catch (error) { + this.logger.error( + `Failed to generate quiz for user ${userId}`, + (error as Error)?.stack, + ); + throw error; + } + } + + /** + * Generate a human-readable quiz title from chat title and content + */ + private generateQuizTitle( + chatTitle: string, + questions: QuizQuestion[], + ): string { + // Extract a topic from the first question or use chat title + if (questions.length > 0 && questions[0].question) { + const firstQuestion = questions[0].question; + // Extract topic from question (first 30 chars) + const topic = firstQuestion.substring(0, 40).trim(); + return `Quiz: ${topic}...`; + } + + return `Quiz: ${chatTitle}`; + } + + /** + * Get a user's generated quizzes (from their learning history) + */ + async getUserGeneratedQuizzes(userId: string, limit: number = 10) { + try { + const quizzes = await db + .select({ + id: publicQuiz.id, + title: publicQuiz.title, + description: publicQuiz.description, + createdAt: publicQuiz.createdAt, + viewCount: publicQuiz.viewCount, + attemptCount: publicQuiz.attemptCount, + sourceChatId: publicQuiz.sourceChatId, + }) + .from(publicQuiz) + .where(eq(publicQuiz.createdBy, userId)) + .orderBy(desc(publicQuiz.createdAt)) + .limit(limit); + + return quizzes; + } catch (error) { + this.logger.error( + `Failed to fetch quizzes for user ${userId}`, + (error as Error)?.stack, + ); + throw error; + } + } + + /** + * Schedule automatic quiz generation for a user + * Called by cron job (monthly) - generates quiz if user has recent activity + */ + async scheduleQuizGeneration(userId: string): Promise { + try { + // Check when user last got a quiz (monthly rate limit) + const recentGeneratedQuizzes = await db + .select() + .from(publicQuiz) + .where(eq(publicQuiz.createdBy, userId)) + .orderBy(desc(publicQuiz.createdAt)) + .limit(1); + + const lastQuizTime = recentGeneratedQuizzes[0]?.createdAt; + const now = new Date(); + const daysSinceLastQuiz = lastQuizTime + ? (now.getTime() - lastQuizTime.getTime()) / (1000 * 60 * 60 * 24) + : 31; + + // Only generate if at least 30 days (1 month) have passed since last quiz + if (daysSinceLastQuiz < 30) { + this.logger.log( + `Skipping monthly quiz generation for user ${userId} - recently generated (${Math.floor(daysSinceLastQuiz)} days ago)`, + ); + return; + } + + // Generate quiz (look back 7 days for recent activity) + const result = await this.generateQuizFromRecentLearning(userId, 7); + this.logger.log( + `Monthly quiz generation completed for user ${userId}: ${result.quizId}`, + ); + } catch (error) { + this.logger.warn( + `Monthly quiz generation failed for user ${userId}: ${(error as Error)?.message}`, + ); + // Don't throw - this is a background task + } + } + + /** + * Run monthly quiz generation for all active users + * Called by cron job once per month + */ + async runMonthlyQuizGeneration(): Promise<{ + total: number; + successful: number; + failed: number; + }> { + try { + this.logger.log('Starting monthly quiz generation for all users...'); + + // Get all users who have been active in last 7 days + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const activeUsers = await db + .select({ id: user.id }) + .from(user) + .where(gte(user.lastLoggedIn, sevenDaysAgo)); + + let successful = 0; + let failed = 0; + + this.logger.log( + `Found ${activeUsers.length} active users for monthly quiz generation`, + ); + + // Generate quizzes for each user + for (const u of activeUsers) { + try { + await this.scheduleQuizGeneration(u.id); + successful++; + } catch (error) { + this.logger.warn( + `Failed to generate quiz for user ${u.id}: ${(error as Error)?.message}`, + ); + failed++; + } + } + + this.logger.log( + `Monthly quiz generation complete: ${successful} successful, ${failed} failed`, + ); + + return { + total: activeUsers.length, + successful, + failed, + }; + } catch (error) { + this.logger.error( + `Monthly quiz generation job failed`, + (error as Error)?.stack, + ); + return { total: 0, successful: 0, failed: 1 }; + } + } +} diff --git a/src/quizzes/quiz-scheduler.service.ts b/src/quizzes/quiz-scheduler.service.ts new file mode 100644 index 0000000..e7dfd31 --- /dev/null +++ b/src/quizzes/quiz-scheduler.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { QuizGenerationService } from './quiz-generation.service'; + +/** + * Handles scheduled quiz generation tasks + * Runs monthly cron job to generate quizzes for active users + */ +@Injectable() +export class QuizSchedulerService { + private readonly logger = new Logger(QuizSchedulerService.name); + + constructor(private readonly quizGenerationService: QuizGenerationService) {} + + /** + * Monthly quiz generation cron job + * Runs on the 1st of every month at 2:00 AM UTC + * Generates quizzes for all active users who haven't received one in 30 days + */ + @Cron('0 2 1 * *') // 2:00 AM on the 1st of every month + async handleMonthlyQuizGeneration() { + this.logger.log('⏰ Starting monthly quiz generation cron job...'); + + try { + const result = + await this.quizGenerationService.runMonthlyQuizGeneration(); + + this.logger.log( + `✅ Monthly quiz generation complete: ${result.successful}/${result.total} users generated quiz successfully. ${result.failed} failed.`, + ); + } catch (error) { + this.logger.error( + `❌ Monthly quiz generation cron job failed: ${(error as Error)?.message}`, + (error as Error)?.stack, + ); + } + } + + /** + * Alternative: Weekly quiz generation (for testing/custom frequency) + * Disabled by default - uncomment to use + * Runs every Sunday at 3:00 AM UTC + */ + // @Cron('0 3 0 * * 0') + // async handleWeeklyQuizGeneration() { + // this.logger.log('⏰ Starting weekly quiz generation cron job...'); + // try { + // const result = await this.quizGenerationService.runMonthlyQuizGeneration(); + // this.logger.log(`✅ Weekly quiz generation complete: ${result.successful}/${result.total} successful`); + // } catch (error) { + // this.logger.error(`❌ Weekly quiz generation failed: ${(error as Error)?.message}`); + // } + // } +} diff --git a/src/quizzes/quizzes.controller.ts b/src/quizzes/quizzes.controller.ts index c02dba3..ff9af53 100644 --- a/src/quizzes/quizzes.controller.ts +++ b/src/quizzes/quizzes.controller.ts @@ -15,12 +15,16 @@ import { getDatabaseUserId, } from '../common/helpers/authorization.helper'; import { QuizzesService } from './quizzes.service'; +import { QuizGenerationService } from './quiz-generation.service'; import { PublishQuizDto } from './dto/publish-quiz.dto'; import { SubmitPublicQuizDto } from './dto/submit-public-quiz.dto'; @Controller('quizzes') export class QuizzesController { - constructor(private readonly quizzesService: QuizzesService) {} + constructor( + private readonly quizzesService: QuizzesService, + private readonly quizGenerationService: QuizGenerationService, + ) {} @Post('public') @UseGuards(JwtAuthGuard) @@ -59,4 +63,53 @@ export class QuizzesController { await verifyUserAuthorization(req.user, dto.userId, 'submitting attempt'); return this.quizzesService.submitAttempt(id, dto.userId, dto); } + + /** + * Generate a quiz from user's recent learning history + * Creates a notification and returns quiz ID + * POST /quizzes/generate + */ + @Post('generate') + @UseGuards(JwtAuthGuard) + async generateQuizFromLearning( + @Request() req, + @Query('daysBack') daysBack?: string, + ) { + const userId = await getDatabaseUserId(req.user); + const days = daysBack ? parseInt(daysBack, 10) : 3; + return this.quizGenerationService.generateQuizFromRecentLearning( + userId, + days, + ); + } + + /** + * Get user's generated quizzes + * GET /quizzes/generated + */ + @Get('generated') + @UseGuards(JwtAuthGuard) + async getUserGeneratedQuizzes( + @Request() req, + @Query('limit') limit?: string, + ) { + const userId = await getDatabaseUserId(req.user); + const limitNum = limit ? parseInt(limit, 10) : 10; + return this.quizGenerationService.getUserGeneratedQuizzes( + userId, + limitNum, + ); + } + + /** + * Share quiz (increment view count for shareable links) + * Public endpoint - no auth required + * GET /quizzes/public/:id/share + */ + @Get('public/:id/share') + async shareQuiz(@Param('id') id: string) { + // This endpoint increments view count for quiz sharing analytics + // Same as findOne but used for tracking shared links + return this.quizzesService.findOne(id); + } } diff --git a/src/quizzes/quizzes.module.ts b/src/quizzes/quizzes.module.ts index caf74ac..a51e182 100644 --- a/src/quizzes/quizzes.module.ts +++ b/src/quizzes/quizzes.module.ts @@ -1,11 +1,22 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; import { QuizzesController } from './quizzes.controller'; import { QuizzesService } from './quizzes.service'; +import { QuizGenerationService } from './quiz-generation.service'; +import { QuizSchedulerService } from './quiz-scheduler.service'; import { ActivityModule } from '../activity/activity.module'; +import { AiModule } from '../ai/ai.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [ActivityModule], + imports: [ + ScheduleModule.forRoot(), + ActivityModule, + forwardRef(() => AiModule), + forwardRef(() => NotificationsModule), + ], controllers: [QuizzesController], - providers: [QuizzesService], + providers: [QuizzesService, QuizGenerationService, QuizSchedulerService], + exports: [QuizGenerationService], }) export class QuizzesModule {}