From 9423f455862065737ba81737525b88f096b8f7dd Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Tue, 1 Jul 2025 19:55:13 +0600 Subject: [PATCH] wip: questions parser --- src/components/AIQuiz/AIQuiz.tsx | 12 +- src/components/AIQuiz/AIQuizGenerator.tsx | 2 + src/components/AIQuiz/AIQuizLayout.tsx | 1 + src/components/AIQuiz/GenerateAIQuiz.tsx | 110 +++++++++++ src/pages/ai/quiz/search.astro | 15 ++ src/queries/ai-quiz.ts | 231 ++++++++++++++++++++++ src/queries/ai-roadmap.ts | 12 +- 7 files changed, 369 insertions(+), 14 deletions(-) create mode 100644 src/components/AIQuiz/GenerateAIQuiz.tsx create mode 100644 src/pages/ai/quiz/search.astro create mode 100644 src/queries/ai-quiz.ts diff --git a/src/components/AIQuiz/AIQuiz.tsx b/src/components/AIQuiz/AIQuiz.tsx index c137cc8ff..b19f133ac 100644 --- a/src/components/AIQuiz/AIQuiz.tsx +++ b/src/components/AIQuiz/AIQuiz.tsx @@ -1,12 +1,10 @@ -import { AITutorLayout } from '../AITutor/AITutorLayout'; +import { AIQuizLayout } from './AIQuizLayout'; +import { GenerateAIQuiz } from './GenerateAIQuiz'; export function AIQuiz() { return ( - -

AI Quiz

-
+ + + ); } diff --git a/src/components/AIQuiz/AIQuizGenerator.tsx b/src/components/AIQuiz/AIQuizGenerator.tsx index 01c230f40..bd613bd0c 100644 --- a/src/components/AIQuiz/AIQuizGenerator.tsx +++ b/src/components/AIQuiz/AIQuizGenerator.tsx @@ -93,6 +93,8 @@ export function AIQuizGenerator() { clearQuestionAnswerChatMessages(); sessionId = storeQuestionAnswerChatMessages(questionAnswerChatMessages); } + + window.location.href = `/ai/quiz/search?term=${title}&format=${selectedFormat}&id=${sessionId}`; }; useEffect(() => { diff --git a/src/components/AIQuiz/AIQuizLayout.tsx b/src/components/AIQuiz/AIQuizLayout.tsx index 01c1e346c..3914a5893 100644 --- a/src/components/AIQuiz/AIQuizLayout.tsx +++ b/src/components/AIQuiz/AIQuizLayout.tsx @@ -8,6 +8,7 @@ export function AIQuizLayout(props: AIQuizLayoutProps) { const { children } = props; return ( diff --git a/src/components/AIQuiz/GenerateAIQuiz.tsx b/src/components/AIQuiz/GenerateAIQuiz.tsx new file mode 100644 index 000000000..1df604026 --- /dev/null +++ b/src/components/AIQuiz/GenerateAIQuiz.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from 'react'; +import { getUrlParams } from '../../lib/browser'; +import { isLoggedIn } from '../../lib/jwt'; +import { queryClient } from '../../stores/query-client'; +import { LoadingChip } from '../LoadingChip'; +import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat'; +import { getQuestionAnswerChatMessages } from '../../lib/ai-questions'; +import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap'; +import { generateAIQuiz, type QuizQuestion } from '../../queries/ai-quiz'; + +type GenerateAIQuizProps = { + onQuizSlugChange?: (quizSlug: string) => void; +}; + +export function GenerateAIQuiz(props: GenerateAIQuizProps) { + const { onQuizSlugChange } = props; + + const [isLoading, setIsLoading] = useState(true); + const [isStreaming, setIsStreaming] = useState(false); + const [error, setError] = useState(''); + + const [questions, setQuestions] = useState([]); + + useEffect(() => { + const params = getUrlParams(); + const paramsTerm = params?.term; + const paramsFormat = params?.format; + const paramsSrc = params?.src || 'search'; + if (!paramsTerm) { + return; + } + + let questionAndAnswers: QuestionAnswerChatMessage[] = []; + const sessionId = params?.id; + if (sessionId) { + questionAndAnswers = getQuestionAnswerChatMessages(sessionId); + } + + handleGenerateDocument({ + term: paramsTerm, + format: paramsFormat, + src: paramsSrc, + questionAndAnswers, + }); + }, []); + + const handleGenerateDocument = async (options: { + term: string; + format: string; + isForce?: boolean; + prompt?: string; + src?: string; + questionAndAnswers?: QuestionAnswerChatMessage[]; + }) => { + const { term, format, isForce, prompt, src, questionAndAnswers } = options; + + if (!isLoggedIn()) { + window.location.href = '/ai'; + return; + } + + await generateAIQuiz({ + term, + format, + isForce, + prompt, + questionAndAnswers, + onDetailsChange: (details) => { + // const { quizId, quizSlug, title, userId } = details; + // const aiRoadmapData = { + // _id: quizId, + // userId, + // title, + // term, + // data: content, + // questionAndAnswers, + // viewCount: 0, + // svgHtml: svgRef.current || '', + // lastVisitedAt: new Date(), + // createdAt: new Date(), + // updatedAt: new Date(), + // }; + // queryClient.setQueryData( + // aiRoadmapOptions(roadmapSlug).queryKey, + // aiRoadmapData, + // ); + // onQuizSlugChange?.(roadmapSlug); + // window.history.replaceState(null, '', `/ai-roadmaps/${roadmapSlug}`); + }, + onLoadingChange: setIsLoading, + onError: setError, + onStreamingChange: setIsStreaming, + onQuestionsChange: setQuestions, + }); + }; + + if (error) { + return
{error}
; + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + return
GenerateAIQuiz
; +} diff --git a/src/pages/ai/quiz/search.astro b/src/pages/ai/quiz/search.astro new file mode 100644 index 000000000..6cab838d6 --- /dev/null +++ b/src/pages/ai/quiz/search.astro @@ -0,0 +1,15 @@ +--- +import { AIQuiz } from '../../../components/AIQuiz/AIQuiz'; +import SkeletonLayout from '../../../layouts/SkeletonLayout.astro'; +--- + + + + diff --git a/src/queries/ai-quiz.ts b/src/queries/ai-quiz.ts new file mode 100644 index 000000000..71c97bee0 --- /dev/null +++ b/src/queries/ai-quiz.ts @@ -0,0 +1,231 @@ +import { nanoid } from 'nanoid'; +import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat'; +import { readChatStream } from '../lib/chat'; +import { queryClient } from '../stores/query-client'; +import { getAiCourseLimitOptions } from './ai-course'; + +type QuizDetails = { + quizId: string; + quizSlug: string; + userId: string; + title: string; +}; + +type GenerateAIQuizOptions = { + term: string; + format: string; + isForce?: boolean; + prompt?: string; + questionAndAnswers?: QuestionAnswerChatMessage[]; + + quizSlug?: string; + + onQuestionsChange?: (questions: QuizQuestion[]) => void; + onDetailsChange?: (details: QuizDetails) => void; + onLoadingChange?: (isLoading: boolean) => void; + onStreamingChange?: (isStreaming: boolean) => void; + onError?: (error: string) => void; + onFinish?: () => void; +}; + +export async function generateAIQuiz(options: GenerateAIQuizOptions) { + const { + term, + format, + quizSlug, + onLoadingChange, + onError, + isForce = false, + prompt, + onDetailsChange, + onFinish, + questionAndAnswers, + onStreamingChange, + onQuestionsChange, + } = options; + + onLoadingChange?.(true); + onStreamingChange?.(false); + try { + let response = null; + + if (quizSlug && isForce) { + response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-regenerate-ai-quiz/${quizSlug}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + prompt, + }), + }, + ); + } else { + response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-quiz`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + keyword: term, + format, + isForce, + customPrompt: prompt, + questionAndAnswers, + }), + credentials: 'include', + }, + ); + } + + if (!response.ok) { + const data = await response.json(); + console.error( + 'Error generating quiz:', + data?.message || 'Something went wrong', + ); + onLoadingChange?.(false); + onError?.(data?.message || 'Something went wrong'); + return; + } + + const stream = response.body; + if (!stream) { + console.error('Failed to get stream from response'); + onError?.('Something went wrong'); + onLoadingChange?.(false); + return; + } + + onLoadingChange?.(false); + onStreamingChange?.(true); + await readChatStream(stream, { + onMessage: async (message) => { + const questions = generateAiQuizQuestions(message); + console.log(questions); + onQuestionsChange?.(questions); + }, + onMessageEnd: async (result) => { + console.log('FINAL RESULT:', result); + queryClient.invalidateQueries(getAiCourseLimitOptions()); + onStreamingChange?.(false); + }, + onDetails: async (details) => { + if (!details?.quizId || !details?.quizSlug) { + throw new Error('Invalid details'); + } + + onDetailsChange?.(details); + }, + }); + onFinish?.(); + } catch (error: any) { + onError?.(error?.message || 'Something went wrong'); + console.error('Error in quiz generation:', error); + onLoadingChange?.(false); + onStreamingChange?.(false); + } +} + +export type QuizQuestion = { + id: string; + title: string; + type: 'mcq' | 'open-ended'; + options: { + id: string; + title: string; + isCorrect: boolean; + }[]; + answerExplanation?: string; +}; + +export function generateAiQuizQuestions(questionData: string): QuizQuestion[] { + const questions: QuizQuestion[] = []; + const lines = questionData.split('\n').map((line) => line.trim()); + + let currentQuestion: QuizQuestion | null = null; + let context: 'question' | 'explanation' | 'option' | null = null; + + const addCurrentQuestion = () => { + if (!currentQuestion) { + return; + } + + questions.push(currentQuestion); + currentQuestion = null; + }; + + for (const line of lines) { + if (!line) { + continue; + } + + if (line.startsWith('###')) { + addCurrentQuestion(); + + currentQuestion = { + id: nanoid(), + title: line.slice(3).trim(), + type: 'open-ended', + options: [], + }; + context = 'question'; + } else if (line.startsWith('##')) { + if (!currentQuestion) { + continue; + } + + currentQuestion.answerExplanation = line.slice(2).trim(); + context = 'explanation'; + } else if (line.startsWith('#')) { + addCurrentQuestion(); + + const title = line.slice(1).trim(); + currentQuestion = { + id: nanoid(), + title, + type: 'mcq', + options: [], + }; + context = 'question'; + } else if (line.startsWith('-')) { + if (!currentQuestion) { + continue; + } + + const rawOption = line.slice(1).trim(); + const isCorrect = rawOption.startsWith('*'); + const title = rawOption.slice(isCorrect ? 1 : 0).trim(); + currentQuestion.options.push({ + id: nanoid(), + title, + isCorrect, + }); + context = 'option'; + } else { + if (!currentQuestion) { + continue; + } + + if (context === 'question') { + currentQuestion.title += `\n${line}`; + } else if (context === 'explanation') { + currentQuestion.answerExplanation = + (currentQuestion?.answerExplanation || '') + `\n${line}`; + } else if (context === 'option') { + const lastOption = currentQuestion.options.at(-1); + if (lastOption) { + lastOption.title += `\n${line}`; + } + } + } + } + + addCurrentQuestion(); + return questions; +} diff --git a/src/queries/ai-roadmap.ts b/src/queries/ai-roadmap.ts index 200d1e759..b39957fad 100644 --- a/src/queries/ai-roadmap.ts +++ b/src/queries/ai-roadmap.ts @@ -2,6 +2,11 @@ import { queryOptions } from '@tanstack/react-query'; import { httpGet } from '../lib/query-http'; import { generateAICourseRoadmapStructure } from '../lib/ai'; import { generateAIRoadmapFromText, renderFlowJSON } from '@roadmapsh/editor'; +import { queryClient } from '../stores/query-client'; +import { getAiCourseLimitOptions } from '../queries/ai-course'; +import { readChatStream } from '../lib/chat'; +import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat'; +import { isLoggedIn } from '../lib/jwt'; export interface AIRoadmapDocument { _id: string; @@ -47,13 +52,6 @@ export function aiRoadmapOptions(roadmapSlug?: string) { }); } -import { queryClient } from '../stores/query-client'; -import { getAiCourseLimitOptions } from '../queries/ai-course'; -import { readChatStream } from '../lib/chat'; -import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat'; -import type { AIGuideDocument } from './ai-guide'; -import { isLoggedIn } from '../lib/jwt'; - type RoadmapDetails = { roadmapId: string; roadmapSlug: string;