From b4b581b1f4bdc8c367c246dee7155481b9838340 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Wed, 16 Jul 2025 18:02:02 +0600 Subject: [PATCH] feat: implement quiz ai feedback (#8897) * wip * wip * wip * wip * wip * Add AI summary at the end --------- Co-authored-by: Kamran Ahmed --- src/components/AIQuiz/AIQuizContent.tsx | 109 +++++++++++++-- src/components/AIQuiz/AIQuizResults.tsx | 174 ++++++++++++++++-------- src/queries/ai-quiz.ts | 7 +- 3 files changed, 221 insertions(+), 69 deletions(-) diff --git a/src/components/AIQuiz/AIQuizContent.tsx b/src/components/AIQuiz/AIQuizContent.tsx index 5c4dd9f76..2f09b243c 100644 --- a/src/components/AIQuiz/AIQuizContent.tsx +++ b/src/components/AIQuiz/AIQuizContent.tsx @@ -8,6 +8,21 @@ import { AIQuizResults } from './AIQuizResults'; import { flushSync } from 'react-dom'; import { AIQuizResultStrip } from './AIQuizResultStrip'; import { cn } from '../../lib/classname'; +import { httpPost } from '../../lib/query-http'; +import { useMutation } from '@tanstack/react-query'; +import { queryClient } from '../../stores/query-client'; + +type AIQuizResultFeedbackBody = { + questionsWithAnswers: string; +}; + +type AIQuizResultFeedbackQuery = {}; + +export type AIQuizResultFeedbackResponse = { + summary?: string; + guideTopics?: string[]; + courseTopics?: string[]; +}; export type QuestionState = { isSubmitted: boolean; @@ -49,21 +64,40 @@ export function AIQuizContent(props: AIQuizContentProps) { questionStates[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE; const isLastQuestion = activeQuestionIndex === questions.length - 1; + const { + mutate: userQuizResultFeedback, + isPending: isUserQuizResultFeedbackPending, + data: userQuizResultFeedbackData, + status: userQuizResultFeedbackStatus, + reset: resetUserQuizResultFeedback, + } = useMutation( + { + mutationKey: ['user-quiz-result-feedback', quizSlug], + mutationFn: (body: AIQuizResultFeedbackBody) => { + return httpPost( + `/v1-ai-quiz-result-feedback/${quizSlug}`, + body, + ); + }, + }, + queryClient, + ); + const handleSubmit = (status: QuestionState['status']) => { - setQuestionStates((prev) => { - const oldState = prev[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE; + const oldState = + questionStates[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE; - const newSelectedOptions = { - ...prev, - [activeQuestionIndex]: { - ...oldState, - isSubmitted: true, - status, - }, - }; + const newQuestionStates = { + ...questionStates, + [activeQuestionIndex]: { + ...oldState, + isSubmitted: true, + status, + }, + }; - return newSelectedOptions; - }); + setQuestionStates(newQuestionStates); + return newQuestionStates; }; const handleSetUserAnswer = (userAnswer: string) => { @@ -120,6 +154,7 @@ export function AIQuizContent(props: AIQuizContentProps) { setActiveQuestionIndex(0); setQuestionStates({}); setQuizStatus('answering'); + resetUserQuizResultFeedback(); }; const hasNextQuestion = activeQuestionIndex < questions.length - 1; @@ -147,17 +182,59 @@ export function AIQuizContent(props: AIQuizContentProps) { const handleSkip = () => { const prevStatus = questionStates[activeQuestionIndex]?.status ?? 'pending'; - handleSubmit(prevStatus === 'pending' ? 'skipped' : prevStatus); + const newQuestionStates = handleSubmit( + prevStatus === 'pending' ? 'skipped' : prevStatus, + ); if (hasNextQuestion) { handleNextQuestion(); } else { - handleComplete(); + handleComplete(newQuestionStates); } }; - const handleComplete = () => { + const handleComplete = ( + newQuestionStates?: Record, + ) => { + const states = newQuestionStates ?? questionStates; setQuizStatus('submitted'); + + const questionsWithAnswers = questions + .map((question, index) => { + const questionState = states[index]; + + let questionWithAnswer = `## Question ${index + 1} (${question.type === 'mcq' ? 'MCQ' : 'Open Ended'}): ${question.title}`; + if (question.type === 'mcq') { + questionWithAnswer += `\n### Options:`; + question?.options?.forEach((option, optionIndex) => { + questionWithAnswer += `\n${optionIndex + 1}. ${option.title} (${option.isCorrect ? 'Correct' : 'Incorrect'})`; + }); + + if (questionState?.selectedOptions?.length) { + questionWithAnswer += `\n### User Selected Answer:`; + questionState?.selectedOptions?.forEach((optionIndex) => { + questionWithAnswer += `\n${optionIndex + 1}. ${question.options[optionIndex].title}`; + }); + } + } else { + if (questionState?.userAnswer) { + questionWithAnswer += `\n### User Answer: ${questionState?.userAnswer}`; + } + + if (questionState?.correctAnswer) { + questionWithAnswer += `\n### AI Feedback: ${questionState?.correctAnswer}`; + } + } + + questionWithAnswer += `\n### Final Status: ${questionState?.status}`; + + return questionWithAnswer; + }) + .join('\n\n'); + + if (userQuizResultFeedbackStatus === 'idle') { + userQuizResultFeedback({ questionsWithAnswers }); + } }; return ( @@ -203,6 +280,8 @@ export function AIQuizContent(props: AIQuizContentProps) { setActiveQuestionIndex(questionIndex); setQuizStatus('reviewing'); }} + isFeedbackLoading={isUserQuizResultFeedbackPending} + feedback={userQuizResultFeedbackData} /> )} diff --git a/src/components/AIQuiz/AIQuizResults.tsx b/src/components/AIQuiz/AIQuizResults.tsx index efd309f5c..c3afb400c 100644 --- a/src/components/AIQuiz/AIQuizResults.tsx +++ b/src/components/AIQuiz/AIQuizResults.tsx @@ -1,9 +1,23 @@ -import { RotateCcw, BarChart3, Zap, Check, X, Minus } from 'lucide-react'; +import { + RotateCcw, + BarChart3, + Zap, + Check, + X, + Minus, + BookOpenIcon, + FileTextIcon, +} from 'lucide-react'; import { cn } from '../../lib/classname'; import { getPercentage } from '../../lib/number'; -import type { QuestionState } from './AIQuizContent'; +import type { + AIQuizResultFeedbackResponse, + QuestionState, +} from './AIQuizContent'; import { QuizStateButton } from './AIQuizResultStrip'; import { CircularProgress } from './CircularProgress'; +import { markdownToHtml } from '../../lib/markdown'; +import { markdownClassName } from './AIMCQQuestion'; type AIQuizResultsProps = { questionStates: Record; @@ -11,11 +25,21 @@ type AIQuizResultsProps = { onRetry: () => void; onNewQuiz: () => void; onReview?: (questionIndex: number) => void; + + isFeedbackLoading?: boolean; + feedback?: AIQuizResultFeedbackResponse; }; export function AIQuizResults(props: AIQuizResultsProps) { - const { questionStates, totalQuestions, onRetry, onNewQuiz, onReview } = - props; + const { + questionStates, + totalQuestions, + onRetry, + onNewQuiz, + onReview, + isFeedbackLoading, + feedback, + } = props; const states = Object.values(questionStates); const correctCount = states.filter( @@ -146,7 +170,6 @@ export function AIQuizResults(props: AIQuizResultsProps) { )} - {/* Action Buttons */}
- {/* Performance Insights */} -
-
-
-

- Performance Insight -

-

- {accuracy >= 90 && - "Outstanding work! You've mastered this topic. Consider challenging yourself with more advanced questions."} - {accuracy >= 75 && - accuracy < 90 && - 'Great job! You have a solid understanding. A few more practice sessions could get you to mastery.'} - {accuracy >= 60 && - accuracy < 75 && - "Good progress! You're on the right track. Focus on reviewing the questions you missed."} - {accuracy >= 40 && - accuracy < 60 && - 'Keep practicing! Consider reviewing the fundamentals before attempting another quiz.'} - {accuracy < 40 && - "Don't give up! Learning takes time. Review the material thoroughly and try again when you're ready."} -

-
+ {feedback && ( + <> +
+ {feedback.summary && ( +
+

+ Summary of your quiz +

- {/* Action Items */} -
-
- Here's what you can do next -
-
- - - +
+
+ )} + + {feedback.guideTopics?.length && feedback.courseTopics?.length && ( + <> +
+
+

+ Suggested Resources +

+ +

+ You can follow these courses or guides to improve your + understanding of the topic you missed in the quiz +

+
+ +
+ {feedback.courseTopics?.map((topic, index) => ( + } + title={topic} + type="course" + href={`/ai/course?term=${encodeURIComponent(topic)}&format=course`} + /> + ))} + {feedback.guideTopics?.map((topic, index) => ( + } + title={topic} + type="guide" + href={`/ai/guide?term=${encodeURIComponent(topic)}&format=guide`} + /> + ))} +
+
+ + )} +
+ + )} + + {isFeedbackLoading && ( +
+
+
+
+ + Generating personalized feedback... +
-
+ )}
); } @@ -377,3 +422,26 @@ export function ResultAction(props: ResultActionProps) { ); } + +type ResourceCardProps = { + icon: React.ReactNode; + title: string; + type: 'guide' | 'course'; + href: string; +}; + +function ResourceCard(props: ResourceCardProps) { + const { icon, title, type, href } = props; + + return ( + +
+
{icon}
+
{title}
+
+
+ ); +} diff --git a/src/queries/ai-quiz.ts b/src/queries/ai-quiz.ts index bee684f56..e70e78437 100644 --- a/src/queries/ai-quiz.ts +++ b/src/queries/ai-quiz.ts @@ -110,7 +110,6 @@ export async function generateAIQuiz(options: GenerateAIQuizOptions) { await readChatStream(stream, { onMessage: async (message) => { const questions = generateAiQuizQuestions(message); - console.log(questions); onQuestionsChange?.(questions); }, onMessageEnd: async (result) => { @@ -158,6 +157,12 @@ export function generateAiQuizQuestions(questionData: string): QuizQuestion[] { return; } + if (currentQuestion.type === 'mcq') { + currentQuestion.options = currentQuestion.options.sort( + () => Math.random() - 0.5, + ); + } + questions.push(currentQuestion); currentQuestion = null; };