mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-02 05:42:41 +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 { flushSync } from 'react-dom';
|
||||||
import { AIQuizResultStrip } from './AIQuizResultStrip';
|
import { AIQuizResultStrip } from './AIQuizResultStrip';
|
||||||
import { cn } from '../../lib/classname';
|
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 = {
|
export type QuestionState = {
|
||||||
isSubmitted: boolean;
|
isSubmitted: boolean;
|
||||||
@@ -49,21 +64,40 @@ export function AIQuizContent(props: AIQuizContentProps) {
|
|||||||
questionStates[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE;
|
questionStates[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE;
|
||||||
const isLastQuestion = activeQuestionIndex === questions.length - 1;
|
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']) => {
|
const handleSubmit = (status: QuestionState['status']) => {
|
||||||
setQuestionStates((prev) => {
|
const oldState =
|
||||||
const oldState = prev[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE;
|
questionStates[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE;
|
||||||
|
|
||||||
const newSelectedOptions = {
|
const newQuestionStates = {
|
||||||
...prev,
|
...questionStates,
|
||||||
[activeQuestionIndex]: {
|
[activeQuestionIndex]: {
|
||||||
...oldState,
|
...oldState,
|
||||||
isSubmitted: true,
|
isSubmitted: true,
|
||||||
status,
|
status,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return newSelectedOptions;
|
setQuestionStates(newQuestionStates);
|
||||||
});
|
return newQuestionStates;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetUserAnswer = (userAnswer: string) => {
|
const handleSetUserAnswer = (userAnswer: string) => {
|
||||||
@@ -120,6 +154,7 @@ export function AIQuizContent(props: AIQuizContentProps) {
|
|||||||
setActiveQuestionIndex(0);
|
setActiveQuestionIndex(0);
|
||||||
setQuestionStates({});
|
setQuestionStates({});
|
||||||
setQuizStatus('answering');
|
setQuizStatus('answering');
|
||||||
|
resetUserQuizResultFeedback();
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasNextQuestion = activeQuestionIndex < questions.length - 1;
|
const hasNextQuestion = activeQuestionIndex < questions.length - 1;
|
||||||
@@ -147,17 +182,59 @@ export function AIQuizContent(props: AIQuizContentProps) {
|
|||||||
|
|
||||||
const handleSkip = () => {
|
const handleSkip = () => {
|
||||||
const prevStatus = questionStates[activeQuestionIndex]?.status ?? 'pending';
|
const prevStatus = questionStates[activeQuestionIndex]?.status ?? 'pending';
|
||||||
handleSubmit(prevStatus === 'pending' ? 'skipped' : prevStatus);
|
const newQuestionStates = handleSubmit(
|
||||||
|
prevStatus === 'pending' ? 'skipped' : prevStatus,
|
||||||
|
);
|
||||||
|
|
||||||
if (hasNextQuestion) {
|
if (hasNextQuestion) {
|
||||||
handleNextQuestion();
|
handleNextQuestion();
|
||||||
} else {
|
} else {
|
||||||
handleComplete();
|
handleComplete(newQuestionStates);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = (
|
||||||
|
newQuestionStates?: Record<number, QuestionState>,
|
||||||
|
) => {
|
||||||
|
const states = newQuestionStates ?? questionStates;
|
||||||
setQuizStatus('submitted');
|
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 (
|
return (
|
||||||
@@ -203,6 +280,8 @@ export function AIQuizContent(props: AIQuizContentProps) {
|
|||||||
setActiveQuestionIndex(questionIndex);
|
setActiveQuestionIndex(questionIndex);
|
||||||
setQuizStatus('reviewing');
|
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 { cn } from '../../lib/classname';
|
||||||
import { getPercentage } from '../../lib/number';
|
import { getPercentage } from '../../lib/number';
|
||||||
import type { QuestionState } from './AIQuizContent';
|
import type {
|
||||||
|
AIQuizResultFeedbackResponse,
|
||||||
|
QuestionState,
|
||||||
|
} from './AIQuizContent';
|
||||||
import { QuizStateButton } from './AIQuizResultStrip';
|
import { QuizStateButton } from './AIQuizResultStrip';
|
||||||
import { CircularProgress } from './CircularProgress';
|
import { CircularProgress } from './CircularProgress';
|
||||||
|
import { markdownToHtml } from '../../lib/markdown';
|
||||||
|
import { markdownClassName } from './AIMCQQuestion';
|
||||||
|
|
||||||
type AIQuizResultsProps = {
|
type AIQuizResultsProps = {
|
||||||
questionStates: Record<number, QuestionState>;
|
questionStates: Record<number, QuestionState>;
|
||||||
@@ -11,11 +25,21 @@ type AIQuizResultsProps = {
|
|||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
onNewQuiz: () => void;
|
onNewQuiz: () => void;
|
||||||
onReview?: (questionIndex: number) => void;
|
onReview?: (questionIndex: number) => void;
|
||||||
|
|
||||||
|
isFeedbackLoading?: boolean;
|
||||||
|
feedback?: AIQuizResultFeedbackResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AIQuizResults(props: AIQuizResultsProps) {
|
export function AIQuizResults(props: AIQuizResultsProps) {
|
||||||
const { questionStates, totalQuestions, onRetry, onNewQuiz, onReview } =
|
const {
|
||||||
props;
|
questionStates,
|
||||||
|
totalQuestions,
|
||||||
|
onRetry,
|
||||||
|
onNewQuiz,
|
||||||
|
onReview,
|
||||||
|
isFeedbackLoading,
|
||||||
|
feedback,
|
||||||
|
} = props;
|
||||||
|
|
||||||
const states = Object.values(questionStates);
|
const states = Object.values(questionStates);
|
||||||
const correctCount = states.filter(
|
const correctCount = states.filter(
|
||||||
@@ -146,7 +170,6 @@ export function AIQuizResults(props: AIQuizResultsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -164,58 +187,80 @@ export function AIQuizResults(props: AIQuizResultsProps) {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Performance Insights */}
|
{feedback && (
|
||||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 md:p-6">
|
<>
|
||||||
<div className="space-y-4">
|
<div className="rounded-xl border border-gray-200 bg-gray-50">
|
||||||
<div>
|
{feedback.summary && (
|
||||||
<h4 className="mb-1 flex items-center text-sm font-semibold text-gray-900 md:text-base">
|
<div className="border-b border-gray-200 p-4 md:p-6">
|
||||||
Performance Insight
|
<h4 className="mb-2 flex items-center text-sm font-semibold text-gray-900 md:text-base">
|
||||||
</h4>
|
Summary of your quiz
|
||||||
<p className="text-sm leading-relaxed text-balance text-gray-600">
|
</h4>
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* Action Items */}
|
<div
|
||||||
<div className="mt-5 border-t border-gray-200 pt-5 -mx-6 px-6">
|
dangerouslySetInnerHTML={{
|
||||||
<h5 className="mb-3 text-sm font-medium text-gray-900">
|
__html: markdownToHtml(feedback.summary, false),
|
||||||
Here's what you can do next
|
}}
|
||||||
</h5>
|
className={cn(
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
markdownClassName,
|
||||||
<ActionLink
|
'prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:text-balance',
|
||||||
href="/ai"
|
)}
|
||||||
label="Learn a Topic"
|
/>
|
||||||
description="Create a course or guide"
|
</div>
|
||||||
variant="secondary"
|
)}
|
||||||
/>
|
|
||||||
<ActionLink
|
{feedback.guideTopics?.length && feedback.courseTopics?.length && (
|
||||||
href="/ai/chat"
|
<>
|
||||||
label="Chat with AI Tutor"
|
<div className="p-4 md:p-6">
|
||||||
description="Learn while you chat"
|
<div className="mb-4">
|
||||||
variant="secondary"
|
<h4 className="mb-1 flex items-center text-sm font-semibold text-gray-900 md:text-base">
|
||||||
/>
|
Suggested Resources
|
||||||
<ActionLink
|
</h4>
|
||||||
href="/ai/quiz"
|
|
||||||
label="Take another Quiz"
|
<p className="text-sm leading-relaxed text-balance text-gray-600">
|
||||||
description="Challenge yourself"
|
You can follow these courses or guides to improve your
|
||||||
variant="secondary"
|
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>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -377,3 +422,26 @@ export function ResultAction(props: ResultActionProps) {
|
|||||||
</button>
|
</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, {
|
await readChatStream(stream, {
|
||||||
onMessage: async (message) => {
|
onMessage: async (message) => {
|
||||||
const questions = generateAiQuizQuestions(message);
|
const questions = generateAiQuizQuestions(message);
|
||||||
console.log(questions);
|
|
||||||
onQuestionsChange?.(questions);
|
onQuestionsChange?.(questions);
|
||||||
},
|
},
|
||||||
onMessageEnd: async (result) => {
|
onMessageEnd: async (result) => {
|
||||||
@@ -158,6 +157,12 @@ export function generateAiQuizQuestions(questionData: string): QuizQuestion[] {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentQuestion.type === 'mcq') {
|
||||||
|
currentQuestion.options = currentQuestion.options.sort(
|
||||||
|
() => Math.random() - 0.5,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
questions.push(currentQuestion);
|
questions.push(currentQuestion);
|
||||||
currentQuestion = null;
|
currentQuestion = null;
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user