1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-09 16:53:33 +02:00
This commit is contained in:
Arik Chakma
2025-07-03 02:07:48 +06:00
parent 9f15ca1e53
commit 920d0512f6
5 changed files with 184 additions and 44 deletions

View File

@@ -168,10 +168,11 @@ export function QuestionTitle(props: QuestionTitleProps) {
type QuestionExplanationProps = { type QuestionExplanationProps = {
explanation: string; explanation: string;
title?: string;
}; };
export function QuestionExplanation(props: QuestionExplanationProps) { export function QuestionExplanation(props: QuestionExplanationProps) {
const { explanation } = props; const { explanation, title } = props;
const explanationHtml = markdownToHtml(explanation, false); const explanationHtml = markdownToHtml(explanation, false);
@@ -179,7 +180,7 @@ export function QuestionExplanation(props: QuestionExplanationProps) {
<div className="mt-4 rounded-xl bg-gray-100 p-4"> <div className="mt-4 rounded-xl bg-gray-100 p-4">
<p className="flex items-center gap-2 text-lg text-gray-600"> <p className="flex items-center gap-2 text-lg text-gray-600">
<InfoIcon className="size-4" /> <InfoIcon className="size-4" />
Explanation {title || 'Explanation'}
</p> </p>
<div <div
className={cn(markdownClassName, 'mt-0.5')} className={cn(markdownClassName, 'mt-0.5')}

View File

@@ -1,17 +1,13 @@
import type { QuizQuestion } from '../../queries/ai-quiz'; import { type QuizQuestion } from '../../queries/ai-quiz';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { InfoIcon, Loader2Icon } from 'lucide-react'; import { Loader2Icon } from 'lucide-react';
import { markdownToHtml } from '../../lib/markdown';
import { QuestionExplanation, QuestionTitle } from './AIMCQQuestion'; import { QuestionExplanation, QuestionTitle } from './AIMCQQuestion';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { httpPost } from '../../lib/query-http';
import { queryClient } from '../../stores/query-client';
import type { QuestionState } from './AIQuizContent'; import type { QuestionState } from './AIQuizContent';
import { useVerifyAnswer } from '../../hooks/use-verify-answer';
type VerifyQuizAnswerResponse = { export type VerifyQuizAnswerResponse = {
isCorrect?: boolean; status: 'correct' | 'incorrect' | 'can_be_improved';
correctAnswer?: string; feedback: string;
}; };
type AIOpenEndedQuestionProps = { type AIOpenEndedQuestionProps = {
@@ -46,41 +42,39 @@ export function AIOpenEndedQuestion(props: AIOpenEndedQuestionProps) {
} = questionState; } = questionState;
const { const {
mutate: verifyAnswer, verifyAnswer,
isPending: isVerifying, data: verificationData,
data: verifyAnswerData, status: verifyStatus,
} = useMutation( } = useVerifyAnswer({
{ quizSlug,
mutationFn: (answer: string) => { question: questionText,
return httpPost<VerifyQuizAnswerResponse>(
`/v1-verify-quiz-answer/${quizSlug}`,
{
question: question.title,
userAnswer, userAnswer,
}, onFinish: (data) => {
); if (!data || !data.status) {
}, console.error('No data or status', data);
onSuccess: (data) => { onSubmit('incorrect');
setCorrectAnswer(data.correctAnswer ?? ''); return;
onSubmit?.(data.isCorrect ? 'correct' : 'incorrect'); }
},
},
queryClient,
);
const handleSubmit = () => { setCorrectAnswer(data.feedback || '');
onSubmit(data.status);
},
});
const handleSubmit = async () => {
if (isSubmitted) { if (isSubmitted) {
onNext?.(); onNext?.();
return; return;
} }
verifyAnswer(userAnswer); await verifyAnswer();
}; };
const canSubmit = userAnswer.trim().length > 0; const canSubmit = userAnswer.trim().length > 0;
const isVerifying =
const markdownClassName = verifyStatus === 'loading' || verifyStatus === 'streaming';
'prose prose-lg prose-p:text-lg prose-p:font-normal prose-p:my-0 prose-pre:my-0 prose-p:prose-code:text-base! prose-p:prose-code:px-2 prose-p:prose-code:py-0.5 prose-p:prose-code:rounded-lg prose-p:prose-code:border prose-p:prose-code:border-black text-left text-black'; const feedback = verificationData?.feedback || correctAnswer;
const feedbackStatus = verificationData?.status || status;
return ( return (
<div> <div>
@@ -93,9 +87,14 @@ export function AIOpenEndedQuestion(props: AIOpenEndedQuestionProps) {
'focus:border-gray-400 focus:ring-0 focus:outline-none', 'focus:border-gray-400 focus:ring-0 focus:outline-none',
isSubmitted && 'bg-gray-50', isSubmitted && 'bg-gray-50',
isSubmitted && isSubmitted &&
status === 'correct' && feedbackStatus === 'correct' &&
'border-green-500 bg-green-50', 'border-green-500 bg-green-50',
isSubmitted && status === 'incorrect' && 'border-red-500 bg-red-50', isSubmitted &&
feedbackStatus === 'incorrect' &&
'border-red-500 bg-red-50',
isSubmitted &&
feedbackStatus === 'can_be_improved' &&
'border-yellow-500 bg-yellow-50',
)} )}
placeholder="Type your answer here..." placeholder="Type your answer here..."
value={userAnswer} value={userAnswer}
@@ -104,8 +103,15 @@ export function AIOpenEndedQuestion(props: AIOpenEndedQuestionProps) {
/> />
</div> </div>
{!isVerifying && correctAnswer && ( {feedback && (
<QuestionExplanation explanation={correctAnswer} /> <QuestionExplanation
title={
feedbackStatus === 'can_be_improved'
? 'Can be improved'
: 'Feedback'
}
explanation={feedback}
/>
)} )}
<button <button

View File

@@ -14,7 +14,7 @@ export type QuestionState = {
selectedOptions?: number[]; selectedOptions?: number[];
userAnswer?: string; userAnswer?: string;
correctAnswer?: string; correctAnswer?: string;
status: 'correct' | 'incorrect' | 'skipped' | 'pending'; status: 'correct' | 'incorrect' | 'skipped' | 'pending' | 'can_be_improved';
}; };
const DEFAULT_QUESTION_STATE: QuestionState = { const DEFAULT_QUESTION_STATE: QuestionState = {

View File

@@ -0,0 +1,133 @@
import { useCallback, useRef, useState } from 'react';
import { removeAuthToken } from '../lib/jwt';
import { readChatStream } from '../lib/chat';
import { flushSync } from 'react-dom';
import type { VerifyQuizAnswerResponse } from '../components/AIQuiz/AIOpenEndedQuestion';
type VerifyAnswerResponse = {
status?: VerifyQuizAnswerResponse['status'];
feedback?: string;
};
type UseVerifyAnswerOptions = {
quizSlug: string;
question: string;
userAnswer: string;
onError?: (error: Error) => void;
onFinish?: (data: VerifyAnswerResponse) => void;
};
export function useVerifyAnswer(options: UseVerifyAnswerOptions) {
const { quizSlug, question, userAnswer, onError, onFinish } = options;
const abortControllerRef = useRef<AbortController | null>(null);
const contentRef = useRef<VerifyAnswerResponse | null>(null);
const [data, setData] = useState<VerifyAnswerResponse | null>(null);
const [status, setStatus] = useState<
'idle' | 'streaming' | 'loading' | 'ready' | 'error'
>('idle');
const verifyAnswer = useCallback(async () => {
try {
setStatus('loading');
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-verify-quiz-answer/${quizSlug}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ question, userAnswer }),
signal: abortControllerRef.current?.signal,
credentials: 'include',
},
);
if (!response.ok) {
const data = await response.json();
setStatus('error');
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
throw new Error(data?.message || 'Something went wrong');
}
const stream = response.body;
if (!stream) {
setStatus('error');
throw new Error('Something went wrong');
}
await readChatStream(stream, {
onMessage: async (content) => {
flushSync(() => {
setStatus('streaming');
contentRef.current = parseVerifyAIQuizAnswerResponse(content);
setData(contentRef.current);
});
},
onMessageEnd: async () => {
flushSync(() => {
setStatus('ready');
});
},
});
setStatus('idle');
abortControllerRef.current = null;
if (!contentRef.current) {
setStatus('error');
throw new Error('Something went wrong');
}
onFinish?.(contentRef.current);
} catch (error) {
if (abortControllerRef.current?.signal.aborted) {
// we don't want to show error if the user stops the chat
// so we just return
return;
}
onError?.(error as Error);
setStatus('error');
}
}, [quizSlug, question, userAnswer, onError]);
const stop = useCallback(() => {
if (!abortControllerRef.current) {
return;
}
abortControllerRef.current.abort();
abortControllerRef.current = null;
}, []);
return {
data,
status,
stop,
verifyAnswer,
};
}
export function parseVerifyAIQuizAnswerResponse(
response: string,
): VerifyQuizAnswerResponse {
const statusRegex = /<status>(.*?)<\/status>/;
const status = response.match(statusRegex)?.[1]?.trim();
const responseWithoutStatus = response.replace(statusRegex, '').trim();
return {
status: status as VerifyQuizAnswerResponse['status'],
feedback: responseWithoutStatus,
};
}

View File

@@ -5,6 +5,7 @@ import { queryClient } from '../stores/query-client';
import { getAiCourseLimitOptions } from './ai-course'; import { getAiCourseLimitOptions } from './ai-course';
import { queryOptions } from '@tanstack/react-query'; import { queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http'; import { httpGet } from '../lib/query-http';
import type { VerifyQuizAnswerResponse } from '../components/AIQuiz/AIOpenEndedQuestion';
type QuizDetails = { type QuizDetails = {
quizId: string; quizId: string;
@@ -113,7 +114,6 @@ export async function generateAIQuiz(options: GenerateAIQuizOptions) {
onQuestionsChange?.(questions); onQuestionsChange?.(questions);
}, },
onMessageEnd: async (result) => { onMessageEnd: async (result) => {
console.log('FINAL RESULT:', result);
queryClient.invalidateQueries(getAiCourseLimitOptions()); queryClient.invalidateQueries(getAiCourseLimitOptions());
onStreamingChange?.(false); onStreamingChange?.(false);
}, },