1
0
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:
Arik Chakma
2025-07-16 18:02:02 +06:00
committed by GitHub
parent 20506756d6
commit b4b581b1f4
3 changed files with 221 additions and 69 deletions

View File

@@ -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}
/>
)}

View File

@@ -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>
);
}

View File

@@ -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;
};