feat: ai course lesson questions

feat/ai-course-questions
Arik Chakma 3 weeks ago
parent 2931461e2e
commit c4bb8ad6bb
  1. 13
      src/components/GenerateCourse/AICourseLesson.tsx
  2. 409
      src/components/GenerateCourse/TestMyKnowledgeAction.tsx
  3. 59
      src/lib/ai.ts

@ -27,6 +27,7 @@ import { queryClient } from '../../stores/query-client';
import { AICourseFollowUp } from './AICourseFollowUp';
import './AICourseFollowUp.css';
import { RegenerateLesson } from './RegenerateLesson';
import { TestMyKnowledgeAction } from './TestMyKnowledgeAction';
type AICourseLessonProps = {
courseSlug: string;
@ -203,7 +204,9 @@ export function AICourseLesson(props: AICourseLessonProps) {
isLoading;
const cantGoBack =
(activeModuleIndex === 0 && activeLessonIndex === 0) || isGenerating || isLoading;
(activeModuleIndex === 0 && activeLessonIndex === 0) ||
isGenerating ||
isLoading;
return (
<div className="mx-auto max-w-4xl">
@ -319,6 +322,14 @@ export function AICourseLesson(props: AICourseLessonProps) {
</div>
)}
{!isLoading && !isGenerating && (
<TestMyKnowledgeAction
courseSlug={courseSlug}
activeModuleIndex={activeModuleIndex}
activeLessonIndex={activeLessonIndex}
/>
)}
<div className="mt-8 flex items-center justify-between">
<button
onClick={onGoToPrevLesson}

@ -0,0 +1,409 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
CircleCheckIcon,
CircleIcon,
CircleXIcon,
FlaskConicalIcon,
Loader2Icon,
RotateCcwIcon,
} from 'lucide-react';
import { cn } from '../../lib/classname';
import {
generateAiCourseLessonQuestions,
readStream,
type Question,
} from '../../lib/ai';
import { useCallback, useMemo, useState } from 'react';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
type TestMyKnowledgeActionProps = {
courseSlug: string;
activeModuleIndex: number;
activeLessonIndex: number;
};
export function TestMyKnowledgeAction(props: TestMyKnowledgeActionProps) {
const { courseSlug, activeModuleIndex, activeLessonIndex } = props;
const [questions, setQuestions] = useState<Question[]>([]);
const [isKnowledgeTestOpen, setIsKnowledgeTestOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState('');
const abortController = useMemo(
() => new AbortController(),
[activeModuleIndex, activeLessonIndex],
);
const generateAiLessonQuestions = async () => {
setIsLoading(true);
setError('');
if (!isLoggedIn()) {
setIsLoading(false);
setError('Please login to generate course content');
return;
}
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course-lesson-question/${courseSlug}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: abortController.signal,
credentials: 'include',
body: JSON.stringify({
moduleIndex: activeModuleIndex,
lessonIndex: activeLessonIndex,
}),
},
);
if (!response.ok) {
const data = await response.json();
setError(data?.message || 'Something went wrong');
setIsLoading(false);
// Logout user if token is invalid
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
return;
}
if (!response.body) {
setIsLoading(false);
setError('No response body received');
return;
}
try {
const reader = response.body.getReader();
setIsLoading(false);
setIsGenerating(true);
await readStream(reader, {
onStream: async (result) => {
if (abortController.signal.aborted) {
return;
}
const questions = generateAiCourseLessonQuestions(result);
setQuestions(questions);
},
onStreamEnd: async (result) => {
if (abortController.signal.aborted) {
return;
}
const questions = generateAiCourseLessonQuestions(result);
setQuestions(questions);
setIsGenerating(false);
},
});
} catch (e) {
setError(e instanceof Error ? e.message : 'Something went wrong');
setIsLoading(false);
setIsGenerating(false);
}
};
return (
<div className="mt-12 flex flex-col gap-4">
<div className="flex items-center gap-2">
<button
className="flex flex-shrink-0 items-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900"
onClick={() => {
if (isGenerating || isLoading || isKnowledgeTestOpen) {
return;
}
setIsKnowledgeTestOpen(true);
generateAiLessonQuestions();
}}
>
<FlaskConicalIcon className="size-5 shrink-0" />
<span>Test My Knowledge</span>
</button>
</div>
{isKnowledgeTestOpen && (
<ListQuestions
isLoading={isLoading}
isGenerating={isGenerating}
questions={questions}
/>
)}
</div>
);
}
type ListQuestionsProps = {
isLoading: boolean;
isGenerating: boolean;
questions: Question[];
};
export function ListQuestions(props: ListQuestionsProps) {
const { isLoading, isGenerating, questions } = props;
const [selectedAnswers, setSelectedAnswers] = useState<
Record<string, string[]>
>({});
const [submitted, setSubmitted] = useState(false);
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
const activeQuestion = questions[activeQuestionIndex];
const handleOptionSelectChange = useCallback(
(questionId: string, optionId: string) => {
setSelectedAnswers((prev) => {
const newSelectedAnswers = { ...prev };
const selectedOptionIds = newSelectedAnswers[questionId] ?? [];
newSelectedAnswers[questionId] = selectedOptionIds.includes(optionId)
? selectedOptionIds.filter((id) => id !== optionId)
: [...selectedOptionIds, optionId];
return newSelectedAnswers;
});
},
[],
);
const handleNext = useCallback(() => {
const isLastQuestion = activeQuestionIndex === questions.length - 1;
if (isLastQuestion) {
setSubmitted(true);
setActiveQuestionIndex(0);
return;
}
setActiveQuestionIndex(activeQuestionIndex + 1);
}, [activeQuestionIndex, questions, submitted]);
const handlePrevious = useCallback(() => {
setActiveQuestionIndex((prev) => Math.max(prev - 1, 0));
}, [questions]);
const handleTryAgain = useCallback(() => {
setSelectedAnswers({});
setSubmitted(false);
setActiveQuestionIndex(0);
}, []);
const correctAnswerCount = useMemo(() => {
if (!submitted) {
return 0;
}
return questions.filter((question) => {
const selectedOptionIds = selectedAnswers[question.id];
const correctAnswerIds = question.options
.filter((option) => option.isCorrect)
.map((option) => option.id);
return (
correctAnswerIds.length === selectedOptionIds?.length &&
correctAnswerIds.every((correctAnswerId) =>
selectedOptionIds?.includes(correctAnswerId),
)
);
}).length;
}, [questions, selectedAnswers, submitted]);
if (isLoading || !questions.length) {
return (
<div className="flex h-[306px] w-full items-center justify-center rounded-lg border p-5 text-black">
<Loader2Icon className="size-8 animate-spin stroke-[2.5]" />
</div>
);
}
return (
<QuizItem
totalQuestions={questions.length}
correctAnswerCount={correctAnswerCount}
isLoading={isGenerating}
question={activeQuestion}
onOptionSelectChange={handleOptionSelectChange}
selectedOptionIds={selectedAnswers[activeQuestion.id]}
submitted={submitted}
onNext={handleNext}
onPrevious={handlePrevious}
onTryAgain={handleTryAgain}
/>
);
}
type QuizItemProps = {
totalQuestions: number;
correctAnswerCount: number;
question: Question;
onOptionSelectChange?: (id: string, optionId: string) => void;
selectedOptionIds?: string[];
submitted?: boolean;
isLoading: boolean;
onNext?: () => void;
onPrevious?: () => void;
onTryAgain?: () => void;
};
export function QuizItem(props: QuizItemProps) {
const {
totalQuestions,
correctAnswerCount,
isLoading,
question,
onOptionSelectChange,
selectedOptionIds,
submitted = false,
onNext,
onPrevious,
onTryAgain,
} = props;
const { id: questionId, title, options } = question;
const correctAnswerIds = options
.filter((option) => option.isCorrect)
.map((option) => option.id);
const isAllCorrectAnswer =
correctAnswerIds.length === selectedOptionIds?.length &&
correctAnswerIds.every((correctAnswerId) =>
selectedOptionIds?.includes(correctAnswerId),
);
const hasWrongAnswer = submitted && !isAllCorrectAnswer;
const hasCorrectAnswer = submitted && isAllCorrectAnswer;
return (
<div
className={cn('relative w-full rounded-lg border p-5 text-black', {
'border-red-400': hasWrongAnswer,
'border-green-500': hasCorrectAnswer,
})}
>
<h3 className="mx-2 text-balance text-lg font-medium">{title}</h3>
<div className="mt-4 flex flex-col gap-1">
{options.map((option, index) => {
let status: QuizOptionStatus = 'default';
if (submitted) {
if (option.isCorrect) {
status = 'correct';
} else if (selectedOptionIds?.includes(option.id)) {
status = 'wrong';
}
} else {
if (selectedOptionIds?.includes(option.id)) {
status = 'selected';
}
}
return (
<QuizOption
key={index}
title={option.title}
status={status}
onSelect={() => onOptionSelectChange?.(questionId, option.id)}
submitted={submitted}
/>
);
})}
</div>
<div className="mt-4 flex w-full items-center justify-between px-2">
<div className="text-gray-500">
{submitted ? (
<span>
You got {correctAnswerCount} out of {totalQuestions} correct
</span>
) : (
<span>Answer all questions to submit</span>
)}
</div>
<div className="flex gap-2">
<button
className="flex h-8 items-center justify-center gap-1 rounded-lg border border-gray-200 p-2 pr-4 text-sm text-black hover:bg-black hover:text-white focus:outline-none max-sm:pr-2"
onClick={onPrevious}
>
<ChevronLeftIcon className="size-5 shrink-0" />
<span className="max-sm:hidden">Previous</span>
</button>
<button
className="flex h-8 items-center justify-center gap-1 rounded-lg border border-gray-200 p-2 pl-4 text-sm text-black hover:bg-black hover:text-white focus:outline-none max-sm:pl-2"
onClick={onNext}
>
<span className="max-sm:hidden">Next</span>
<ChevronRightIcon className="size-5 shrink-0" />
</button>
</div>
</div>
{submitted && (
<button
className="absolute right-2 top-2 flex h-8 items-center justify-center gap-1 rounded-lg border border-gray-200 p-2 text-sm text-black hover:bg-black hover:text-white focus:outline-none"
onClick={onTryAgain}
>
<RotateCcwIcon className="size-5 shrink-0" />
<span className="max-sm:hidden">Try Again</span>
</button>
)}
{isLoading && (
<div className="absolute right-2 top-2 flex h-8 items-center justify-center gap-1 rounded-lg border border-gray-200 p-2 text-sm text-black hover:bg-black hover:text-white focus:outline-none">
<Loader2Icon className="size-5 animate-spin" />
</div>
)}
</div>
);
}
type QuizOptionStatus = 'default' | 'selected' | 'wrong' | 'correct';
type QuizOptionProps = {
title: string;
status?: QuizOptionStatus;
onSelect: () => void;
submitted?: boolean;
};
export function QuizOption(props: QuizOptionProps) {
const { title, status = 'default', onSelect, submitted = false } = props;
return (
<button
onClick={onSelect}
className={cn(
'flex items-start gap-2 rounded-xl p-2 text-base disabled:cursor-not-allowed',
status === 'selected' && 'bg-gray-600 text-white',
status === 'wrong' && submitted && 'bg-red-200 text-black',
status === 'correct' && submitted && 'bg-green-200 text-black',
status === 'default' && 'bg-white hover:bg-gray-100',
submitted && status !== 'correct' && 'opacity-40',
)}
disabled={submitted}
>
<span className="mt-[1px]">
{status === 'wrong' && submitted && <CircleXIcon className="size-5" />}
{status === 'correct' && submitted && (
<CircleCheckIcon className="size-5" />
)}
{(status === 'selected' || status === 'default') && (
<CircleIcon className="size-5" />
)}
</span>
<p className="text-left">{title}</p>
</button>
);
}

@ -1,3 +1,5 @@
import { nanoid } from 'nanoid';
export const IS_KEY_ONLY_ROADMAP_GENERATION = false;
type Lesson = string;
@ -52,6 +54,7 @@ export function generateAiCourseStructure(
return {
title,
modules,
done: [],
};
}
@ -207,3 +210,59 @@ export async function readStream(
onStreamEnd?.(result);
reader.releaseLock();
}
export type Question = {
id: string;
title: string;
options: {
id: string;
title: string;
isCorrect: boolean;
}[];
};
export function generateAiCourseLessonQuestions(
questionData: string,
): Question[] {
const questions: Question[] = [];
const lines = questionData.split('\n');
let currentQuestion: Question | null = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('#')) {
if (currentQuestion) {
questions.push(currentQuestion);
currentQuestion = null;
}
const title = line.replace('#', '').trim();
currentQuestion = {
id: nanoid(),
title,
options: [],
};
} else if (line.startsWith('-')) {
if (!currentQuestion) {
continue;
}
let title = line.replace('-', '').trim();
const isCorrect = title.startsWith('*');
title = title.replace('*', '').trim();
currentQuestion.options.push({
id: nanoid(),
title,
isCorrect,
});
}
}
if (currentQuestion) {
questions.push(currentQuestion);
}
return questions;
}

Loading…
Cancel
Save