mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-01 21:32:35 +02:00
feat: ai course lesson questions (#8396)
* feat: ai course lesson questions * Several fixes * Improve test my knowledge * Improve UI for questions --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import { queryClient } from '../../stores/query-client';
|
|||||||
import { AICourseFollowUp } from './AICourseFollowUp';
|
import { AICourseFollowUp } from './AICourseFollowUp';
|
||||||
import './AICourseFollowUp.css';
|
import './AICourseFollowUp.css';
|
||||||
import { RegenerateLesson } from './RegenerateLesson';
|
import { RegenerateLesson } from './RegenerateLesson';
|
||||||
|
import { TestMyKnowledgeAction } from './TestMyKnowledgeAction';
|
||||||
|
|
||||||
type AICourseLessonProps = {
|
type AICourseLessonProps = {
|
||||||
courseSlug: string;
|
courseSlug: string;
|
||||||
@@ -203,7 +204,9 @@ export function AICourseLesson(props: AICourseLessonProps) {
|
|||||||
isLoading;
|
isLoading;
|
||||||
|
|
||||||
const cantGoBack =
|
const cantGoBack =
|
||||||
(activeModuleIndex === 0 && activeLessonIndex === 0) || isGenerating || isLoading;
|
(activeModuleIndex === 0 && activeLessonIndex === 0) ||
|
||||||
|
isGenerating ||
|
||||||
|
isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
@@ -319,6 +322,14 @@ export function AICourseLesson(props: AICourseLessonProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isGenerating && !error && (
|
||||||
|
<TestMyKnowledgeAction
|
||||||
|
courseSlug={courseSlug}
|
||||||
|
activeModuleIndex={activeModuleIndex}
|
||||||
|
activeLessonIndex={activeLessonIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-8 flex items-center justify-between">
|
<div className="mt-8 flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={onGoToPrevLesson}
|
onClick={onGoToPrevLesson}
|
||||||
|
473
src/components/GenerateCourse/TestMyKnowledgeAction.tsx
Normal file
473
src/components/GenerateCourse/TestMyKnowledgeAction.tsx
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
CircleCheckIcon,
|
||||||
|
CircleIcon,
|
||||||
|
CircleXIcon,
|
||||||
|
FlaskConicalIcon,
|
||||||
|
FrownIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
} 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-10 flex flex-col gap-4">
|
||||||
|
{!isKnowledgeTestOpen && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'group flex flex-shrink-0 items-center gap-2 rounded-xl border border-gray-200 bg-white px-6 py-3 text-sm font-medium text-gray-700 transition-all hover:border-black hover:bg-gray-50 hover:text-gray-900',
|
||||||
|
{
|
||||||
|
'bg-gray-100 text-gray-900': isKnowledgeTestOpen,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (isGenerating || isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isKnowledgeTestOpen) {
|
||||||
|
setIsKnowledgeTestOpen(true);
|
||||||
|
generateAiLessonQuestions();
|
||||||
|
} else {
|
||||||
|
setIsKnowledgeTestOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FlaskConicalIcon className="size-5 shrink-0 transition-transform group-hover:scale-110" />
|
||||||
|
<span>Test My Knowledge</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-xl bg-red-50/80 p-6 text-red-500">
|
||||||
|
<FrownIcon className="size-12 shrink-0" />
|
||||||
|
<span className="text-center font-semibold">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && 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 = function (
|
||||||
|
questionId: string,
|
||||||
|
optionId: string,
|
||||||
|
) {
|
||||||
|
setSelectedAnswers((prev) => {
|
||||||
|
const newAnswers = { ...prev };
|
||||||
|
|
||||||
|
const canMultiSelect =
|
||||||
|
activeQuestion.options.filter((option) => option.isCorrect).length > 1;
|
||||||
|
const isAlreadySelected = selectedAnswers[questionId]?.includes(optionId);
|
||||||
|
|
||||||
|
if (isAlreadySelected) {
|
||||||
|
newAnswers[questionId] = newAnswers[questionId].filter(
|
||||||
|
(id) => id !== optionId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (canMultiSelect) {
|
||||||
|
newAnswers[questionId] = [
|
||||||
|
...(newAnswers[questionId] || []),
|
||||||
|
optionId,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
newAnswers[questionId] = [optionId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newAnswers;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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] text-gray-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QuizItem
|
||||||
|
counter={activeQuestionIndex + 1}
|
||||||
|
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;
|
||||||
|
counter: 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,
|
||||||
|
counter,
|
||||||
|
isLoading,
|
||||||
|
question,
|
||||||
|
onOptionSelectChange,
|
||||||
|
selectedOptionIds,
|
||||||
|
submitted = false,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
onTryAgain,
|
||||||
|
} = props;
|
||||||
|
const { id: questionId, title, options } = question;
|
||||||
|
|
||||||
|
const canMultiSelect =
|
||||||
|
options.filter((option) => option.isCorrect).length > 1;
|
||||||
|
|
||||||
|
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 overflow-hidden rounded-xl border text-black transition-all',
|
||||||
|
{
|
||||||
|
'border-red-400': hasWrongAnswer,
|
||||||
|
'border-green-500': hasCorrectAnswer,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 rounded-t-xl border-b bg-gradient-to-r from-gray-50 to-white p-5">
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
Question {counter} of {totalQuestions}
|
||||||
|
</span>
|
||||||
|
<div className="h-2 flex-1 rounded-full bg-gray-100">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-black transition-all duration-300"
|
||||||
|
style={{ width: `${(counter / totalQuestions) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{submitted && (
|
||||||
|
<span
|
||||||
|
className={cn('rounded-full px-3 py-1 text-xs font-medium', {
|
||||||
|
'bg-red-500 text-white': hasWrongAnswer,
|
||||||
|
'bg-green-500 text-white': hasCorrectAnswer,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{hasWrongAnswer ? 'Wrong' : 'Correct'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="mx-2 text-balance text-xl font-medium">
|
||||||
|
{title} {canMultiSelect ? '(Select Multiple)' : ''}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-0.5">
|
||||||
|
{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-6 flex w-full items-center justify-between px-2">
|
||||||
|
<div className="text-gray-600">
|
||||||
|
{submitted ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
You got {correctAnswerCount} out of {totalQuestions} correct.
|
||||||
|
<button
|
||||||
|
className="relative rounded-md bg-black px-3 py-1 text-xs font-medium uppercase tracking-wider text-white transition-colors hover:bg-black/80"
|
||||||
|
onClick={onTryAgain}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>Answer all questions to submit</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="group flex h-10 items-center justify-center gap-1 rounded-lg border border-gray-200 p-2 pr-4 text-sm text-black transition-all hover:border-black hover:bg-black hover:text-white focus:outline-none max-sm:pr-2"
|
||||||
|
onClick={onPrevious}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-5 shrink-0 transition-transform group-hover:-translate-x-0.5" />
|
||||||
|
<span className="max-sm:hidden">Previous</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="group flex h-10 items-center justify-center gap-1 rounded-lg border border-gray-200 p-2 pl-4 text-sm text-black transition-all hover:border-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 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 text-gray-400" />
|
||||||
|
</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(
|
||||||
|
'group flex items-start gap-3 rounded-xl p-4 text-base transition-all disabled:cursor-not-allowed',
|
||||||
|
status === 'selected' && 'bg-black text-white',
|
||||||
|
status === 'wrong' && submitted && 'bg-red-100 text-red-800',
|
||||||
|
status === 'correct' && submitted && 'bg-green-100 text-green-800',
|
||||||
|
status === 'default' && 'bg-white hover:bg-gray-50',
|
||||||
|
submitted && status !== 'correct' && 'opacity-40',
|
||||||
|
)}
|
||||||
|
disabled={submitted}
|
||||||
|
>
|
||||||
|
<span className="mt-[2px]">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
@@ -211,6 +211,62 @@ export async function readStream(
|
|||||||
reader.releaseLock();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export type SubTopic = {
|
export type SubTopic = {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'subtopic';
|
type: 'subtopic';
|
||||||
|
Reference in New Issue
Block a user