mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-01 05:21:43 +02:00
feat: implement quiz ai feedback (#8897)
* wip * wip * wip * wip * wip * Add AI summary at the end --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
@@ -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<AIQuizResultFeedbackResponse>(
|
||||
`/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<number, QuestionState>,
|
||||
) => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@@ -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<number, QuestionState>;
|
||||
@@ -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) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
@@ -164,58 +187,80 @@ export function AIQuizResults(props: AIQuizResultsProps) {
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Performance Insights */}
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 md:p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="mb-1 flex items-center text-sm font-semibold text-gray-900 md:text-base">
|
||||
Performance Insight
|
||||
</h4>
|
||||
<p className="text-sm leading-relaxed text-balance text-gray-600">
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
{feedback && (
|
||||
<>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50">
|
||||
{feedback.summary && (
|
||||
<div className="border-b border-gray-200 p-4 md:p-6">
|
||||
<h4 className="mb-2 flex items-center text-sm font-semibold text-gray-900 md:text-base">
|
||||
Summary of your quiz
|
||||
</h4>
|
||||
|
||||
{/* Action Items */}
|
||||
<div className="mt-5 border-t border-gray-200 pt-5 -mx-6 px-6">
|
||||
<h5 className="mb-3 text-sm font-medium text-gray-900">
|
||||
Here's what you can do next
|
||||
</h5>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ActionLink
|
||||
href="/ai"
|
||||
label="Learn a Topic"
|
||||
description="Create a course or guide"
|
||||
variant="secondary"
|
||||
/>
|
||||
<ActionLink
|
||||
href="/ai/chat"
|
||||
label="Chat with AI Tutor"
|
||||
description="Learn while you chat"
|
||||
variant="secondary"
|
||||
/>
|
||||
<ActionLink
|
||||
href="/ai/quiz"
|
||||
label="Take another Quiz"
|
||||
description="Challenge yourself"
|
||||
variant="secondary"
|
||||
/>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToHtml(feedback.summary, false),
|
||||
}}
|
||||
className={cn(
|
||||
markdownClassName,
|
||||
'prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:text-balance',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback.guideTopics?.length && feedback.courseTopics?.length && (
|
||||
<>
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-1 flex items-center text-sm font-semibold text-gray-900 md:text-base">
|
||||
Suggested Resources
|
||||
</h4>
|
||||
|
||||
<p className="text-sm leading-relaxed text-balance text-gray-600">
|
||||
You can follow these courses or guides to improve your
|
||||
understanding of the topic you missed in the quiz
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{feedback.courseTopics?.map((topic, index) => (
|
||||
<ResourceCard
|
||||
key={`course-${index}`}
|
||||
icon={<BookOpenIcon className="h-5 w-5" />}
|
||||
title={topic}
|
||||
type="course"
|
||||
href={`/ai/course?term=${encodeURIComponent(topic)}&format=course`}
|
||||
/>
|
||||
))}
|
||||
{feedback.guideTopics?.map((topic, index) => (
|
||||
<ResourceCard
|
||||
key={`guide-${index}`}
|
||||
icon={<FileTextIcon className="h-5 w-5" />}
|
||||
title={topic}
|
||||
type="guide"
|
||||
href={`/ai/guide?term=${encodeURIComponent(topic)}&format=guide`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isFeedbackLoading && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 md:p-6">
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center gap-3 text-gray-600">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600" />
|
||||
<span className="text-sm md:text-base">
|
||||
Generating personalized feedback...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -377,3 +422,26 @@ export function ResultAction(props: ResultActionProps) {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type ResourceCardProps = {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
type: 'guide' | 'course';
|
||||
href: string;
|
||||
};
|
||||
|
||||
function ResourceCard(props: ResourceCardProps) {
|
||||
const { icon, title, type, href } = props;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="block rounded-lg border border-gray-200 bg-white p-2.5 text-left hover:border-gray-400 hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-gray-500">{icon}</div>
|
||||
<div className="truncate text-sm text-gray-900">{title}</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
@@ -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;
|
||||
};
|
||||
|
Reference in New Issue
Block a user