diff --git a/app/courses/[courseId]/quiz/[quizId]/lecturer.tsx b/app/courses/[courseId]/quiz/[quizId]/lecturer.tsx index e319936a..c03737e4 100644 --- a/app/courses/[courseId]/quiz/[quizId]/lecturer.tsx +++ b/app/courses/[courseId]/quiz/[quizId]/lecturer.tsx @@ -3,6 +3,7 @@ import { lecturerEditQuizQuery } from "@/__generated__/lecturerEditQuizQuery.gra import { ErrorContext, ES2022Error } from "@/components/ErrorContext"; import { PageError } from "@/components/PageError"; import { QuizModal } from "@/components/QuizModal"; +import { GenerateQuizModal } from "@/components/GenerateQuizModal"; import { AddQuestionButton } from "@/components/quiz/AddQuestionButton"; import QuestionPreview from "@/components/quiz/QuestionPreview"; import QuizHeader from "@/components/quiz/QuizHeader"; @@ -72,6 +73,7 @@ export default function LecturerQuiz() { }, [courseId, loadQuery, queryReference]); const [isEditSetModalOpen, setEditSetModalOpen] = useState(false); + const [isGenerateSetModalOpen, setGenerateSetModalOpen] = useState(false); const content = contentsByIds[0]; const quiz = content.quiz; @@ -89,6 +91,7 @@ export default function LecturerQuiz() { setEditSetModalOpen(true)} + openGenerateQuizModal={() => setGenerateSetModalOpen(true)} content={content} /> @@ -117,6 +120,13 @@ export default function LecturerQuiz() { _existingQuiz={quiz} chapterId={content.metadata.chapterId} /> + + setGenerateSetModalOpen(false)} + isOpen={isGenerateSetModalOpen} + courseId={courseId} + quizId={quizId} + /> ); diff --git a/components/Form.tsx b/components/Form.tsx index 946c33fc..aed1347e 100644 --- a/components/Form.tsx +++ b/components/Form.tsx @@ -10,14 +10,16 @@ export function FormSection({ title, subtitle, children, + showDivider = true, }: { title: string; subtitle?: string; children?: ReactNode; + showDivider?: boolean; }) { return ( <> - + {showDivider && }
{title}
@@ -45,7 +47,7 @@ export function FormActions({ export function Form({ children }: { children: ReactNode }) { return ( -
+
{children}
); diff --git a/components/GenerateQuizModal.tsx b/components/GenerateQuizModal.tsx new file mode 100644 index 00000000..388a7b76 --- /dev/null +++ b/components/GenerateQuizModal.tsx @@ -0,0 +1,260 @@ +"use client"; +import { GenerateQuizModalMediaQuery } from "@/__generated__/GenerateQuizModalMediaQuery.graphql"; +import { + AiGenQuestionContext, + GenerateQuizModalMutation, +} from "@/__generated__/GenerateQuizModalMutation.graphql"; +import { FormDivider } from "@/components/Form"; +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogTitle, + Tab, + Tabs, + Typography, +} from "@mui/material"; +import { useMemo, useState } from "react"; +import { graphql, useLazyLoadQuery, useMutation } from "react-relay"; +import { + CapabilitiesTabPanel, + EducationalObjective, +} from "./quiz/CapabilitiesTabPanel"; +import { LectureMaterialsTabPanel } from "./quiz/LectureMaterialsTabPanel"; +import { QuestionsTabPanel } from "./quiz/QuestionsTabPanel"; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +export type CapabilityInput = { + objectives: EducationalObjective[]; + keywords: string[]; + relationship: string; +}; + +const defaultCapability = { + objectives: [], + keywords: [""], + relationship: "", +}; + +const defaultQuestionAmount = { + multipleChoiceAmount: 0, + clozeAmount: 0, + associationAmount: 0, +}; + +export function GenerateQuizModal({ + onClose: _onClose, + courseId, + isOpen, + quizId, +}: { + onClose: () => void; + isOpen: boolean; + courseId: string; + quizId: string; +}) { + const [tabIndex, setTabIndex] = useState(0); + + const [capabilities, setCapabilities] = + useState(defaultCapability); + + const [materialIds, setMaterialIds] = useState([]); + + const [questionAmount, setQuestionAmount] = useState<{ + multipleChoiceAmount: number; + clozeAmount: number; + associationAmount: number; + }>(defaultQuestionAmount); + + const [error, setError] = useState(null); + + const valid = useMemo(() => { + const validCapabilities = + capabilities.objectives.length !== 0 && + capabilities.relationship !== "" && + capabilities.keywords.every((kw) => kw.trim() !== ""); + + const validMaterials = + materialIds.length !== 0 && materialIds.every((m) => m !== ""); + const validQuestionAmount = Object.values(questionAmount).some( + (amount) => amount !== 0 + ); + return ( + (tabIndex === 0 && validCapabilities) || + (tabIndex === 1 && validMaterials) || + (tabIndex === 2 && validQuestionAmount) + ); + }, [capabilities, materialIds, questionAmount, tabIndex]); + + const data = useLazyLoadQuery( + graphql` + query GenerateQuizModalMediaQuery { + mediaRecords { + id + name + type + courseIds + } + } + `, + { courseId } + ); + + const mediaRecords = data.mediaRecords.filter((item) => { + return item.courseIds.includes(courseId); + }); + + const [generate] = useMutation(graphql` + mutation GenerateQuizModalMutation( + $context: AiGenQuestionContext! + $assessmentId: UUID! + ) { + mutateQuiz(assessmentId: $assessmentId) { + aiGenerateQuestionAsync(context: $context) { + quiz { + assessmentId + } + } + } + } + `); + + function handleSubmit() { + const context: AiGenQuestionContext = { + description: + "Use the following keywords as context to generate the questions:\n" + + capabilities.keywords.join(", "), + maxAnswersPerQuestion: 5, + maxExactQuestions: 0, + minExactQuestions: 0, + maxFreeTextQuestions: 0, + minFreeTextQuestions: 0, + maxMultipleChoiceQuestions: questionAmount.multipleChoiceAmount, + minMultipleChoiceQuestions: 0, + maxNumericQuestions: 0, + minNumericQuestions: 0, + mediaRecordIds: materialIds, + }; + generate({ + variables: { context, assessmentId: quizId }, + onError: setError, + + onCompleted() { + alert( + "Generation of questions was started successfully!" + + "\n Please come back later to review the generated questions!" + ); + closeModal(); + }, + }); + } + + function closeModal() { + setCapabilities(defaultCapability); + setMaterialIds([]); + setQuestionAmount(defaultQuestionAmount); + setTabIndex(0); + setError(null); + _onClose(); + } + + function handleNext() { + if (!valid) return; + if (tabIndex != 2) { + setTabIndex(tabIndex + 1); + } else { + handleSubmit(); + } + } + + return ( + + Generate Quiz + + {error?.source.errors.map((err: any, i: number) => ( + setError(null)}> + {err.message} + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/components/quiz/CapabilitiesTabPanel.tsx b/components/quiz/CapabilitiesTabPanel.tsx new file mode 100644 index 00000000..0f04f702 --- /dev/null +++ b/components/quiz/CapabilitiesTabPanel.tsx @@ -0,0 +1,166 @@ +import { + Button, + Checkbox, + FormControl, + IconButton, + InputLabel, + ListItemText, + MenuItem, + Select, + TextField, +} from "@mui/material"; +import { Form, FormSection } from "../Form"; +import { Add, Delete } from "@mui/icons-material"; +import { useCallback, useEffect, useState } from "react"; +import { SkillType } from "@/__generated__/QuizModalEditMutation.graphql"; +import { CapabilityInput } from "../GenerateQuizModal"; + +export type EducationalObjective = Exclude; + +const skillTypeLabel: Record = { + ANALYZE: "Analyze", + APPLY: "Apply", + REMEMBER: "Remember", + UNDERSTAND: "Understand", + "%future added value": "Unknown", +}; + +export function CapabilitiesTabPanel({ + onChange, + capabilities, +}: { + onChange: (capabilities: CapabilityInput) => void; + capabilities: CapabilityInput; +}) { + const [skillTypes, setSkillTypes] = useState( + capabilities.objectives + ); + const [relationship, setRelationship] = useState( + capabilities.relationship + ); + const [keywords, setKeywords] = useState(capabilities.keywords); + + const updateKeywordAt = useCallback( + (index: number, keywordText: string) => { + setKeywords((oldValue) => + oldValue.map((item, i) => (index === i ? keywordText : item)) + ); + }, + [setKeywords] + ); + + const deleteQuestionAnswerAt = useCallback( + (index: number) => { + setKeywords((oldValue) => oldValue.filter((_, i) => i !== index)); + }, + [setKeywords] + ); + + const addEmptyKeyword = useCallback( + () => setKeywords((oldValue) => [...oldValue, ""]), + [setKeywords] + ); + + useEffect(() => { + onChange({ + objectives: skillTypes, + keywords: keywords, + relationship: relationship, + }); + }, [skillTypes, keywords, relationship, onChange]); + + return ( +
+ + + + Objectives * + + + + + + + +
+ +
+ {keywords.map((keyword, i) => ( +
+ updateKeywordAt(i, e.target.value)} + /> + {i !== 0 && ( + deleteQuestionAnswerAt(i)} + > + + + )} +
+ ))} +
+ + + + Relationship * + + + +
+ ); +} diff --git a/components/quiz/LectureMaterialsTabPanel.tsx b/components/quiz/LectureMaterialsTabPanel.tsx new file mode 100644 index 00000000..8da14ecd --- /dev/null +++ b/components/quiz/LectureMaterialsTabPanel.tsx @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Form, FormSection } from "../Form"; +import { + Alert, + AlertTitle, + Button, + IconButton, + MenuItem, + Select, +} from "@mui/material"; +import { Add, Delete } from "@mui/icons-material"; +import { GenerateQuizModalMediaQuery$data } from "@/__generated__/GenerateQuizModalMediaQuery.graphql"; + +export function LectureMaterialsTabPanel({ + mediaRecords, + materialIds, + onChange, +}: { + mediaRecords: GenerateQuizModalMediaQuery$data["mediaRecords"]; + materialIds: string[]; + onChange: (materialIds: string[]) => void; +}) { + const [selectedMediaIds, setSelectedMediaIds] = + useState(materialIds); + + const noMediaToPick = useMemo(() => { + const test = mediaRecords.filter((item) => { + return !!item.id && !!item.name; + }); + return test.length === 0; + }, [mediaRecords]); + + const addMaterial = useCallback( + () => setSelectedMediaIds((oldValue) => [...oldValue, ""]), + [setSelectedMediaIds] + ); + + const updateMaterialAt = useCallback( + (index: number, materialID: string) => { + setSelectedMediaIds((oldValue) => + oldValue.map((item, i) => (index === i ? materialID : item)) + ); + }, + [setSelectedMediaIds] + ); + + const deleteMaterialAt = useCallback( + (index: number) => { + setSelectedMediaIds((oldValue) => oldValue.filter((_, i) => i !== index)); + }, + [setSelectedMediaIds] + ); + + useEffect(() => { + onChange(selectedMediaIds); + }); + + return ( +
+ +
+ +
+ {!noMediaToPick ? ( + selectedMediaIds.map((media, i) => ( +
+ + {i !== 0 && ( + deleteMaterialAt(i)}> + + + )} +
+ )) + ) : ( + + No Media Available + Please upload Media like Lecture Recordings or Slides in this Course + before generating questions based on these + + )} +
+
+ ); +} diff --git a/components/quiz/QuestionPreview.tsx b/components/quiz/QuestionPreview.tsx index 8a82b8c3..7367211c 100644 --- a/components/quiz/QuestionPreview.tsx +++ b/components/quiz/QuestionPreview.tsx @@ -4,8 +4,8 @@ import { BloomLevel, QuestionPreviewFragment$key, } from "@/__generated__/QuestionPreviewFragment.graphql"; -import { Edit } from "@mui/icons-material"; -import { Button } from "@mui/material"; +import { AutoAwesome, Edit } from "@mui/icons-material"; +import { Button, Tooltip } from "@mui/material"; import { useParams } from "next/navigation"; import { useCallback, @@ -31,6 +31,7 @@ import { MultipleChoiceQuestionPreview } from "./MultipleChoiceQuestionPreview"; const QuestionFragment = graphql` fragment QuestionPreviewFragment on Question { __typename + aiGenerated hint number itemId @@ -113,7 +114,7 @@ const QuestionPreview = ({ }: Props) => { const { quizId } = useParams(); const data = useFragment(QuestionFragment, question); - + console.log(data); // Destructuring necessary due to `readonly` types from relay const item = useMemo( () => ({ @@ -164,7 +165,16 @@ const QuestionPreview = ({ return ( <>
-
+
+ {data.aiGenerated && ( + + + + )}
{data.text ? ( void; +}) { + const [input, setInput] = useState(questionAmounts); + + useEffect(() => { + onChange(input); + }); + + return ( +
+ + + + setInput({ ...input, multipleChoiceAmount: Number(value) }) + } + type="number" + label="Amount to Generate" + required + sx={{ width: "225px" }} + /> + + + + + + + setInput({ ...input, clozeAmount: Number(value) }) + } + type="number" + label="Amount to Generate" + required + sx={{ width: "225px" }} + /> + + + + + + + setInput({ ...input, associationAmount: Number(value) }) + } + type="number" + label="Amount to Generate" + required + sx={{ width: "225px" }} + /> + + +
+ ); +} diff --git a/components/quiz/QuizHeader.tsx b/components/quiz/QuizHeader.tsx index 47f9bdd5..4fce5921 100644 --- a/components/quiz/QuizHeader.tsx +++ b/components/quiz/QuizHeader.tsx @@ -1,7 +1,7 @@ import { QuizHeaderDeleteQuizMutation } from "@/__generated__/QuizHeaderDeleteQuizMutation.graphql"; import { QuizHeaderFragment$key } from "@/__generated__/QuizHeaderFragment.graphql"; import { updaterSetDelete } from "@/src/relay-helpers/common"; -import { Delete, Edit } from "@mui/icons-material"; +import { AutoAwesome, Delete, Edit } from "@mui/icons-material"; import { Button, CircularProgress } from "@mui/material"; import { useParams, useRouter } from "next/navigation"; import { useCallback } from "react"; @@ -32,9 +32,14 @@ const metadataFragment = graphql` interface Props { content: QuizHeaderFragment$key; openEditQuizModal: () => void; + openGenerateQuizModal: () => void; } -const QuizHeader = ({ content, openEditQuizModal }: Props) => { +const QuizHeader = ({ + content, + openEditQuizModal, + openGenerateQuizModal, +}: Props) => { const { courseId, quizId } = useParams(); const router = useRouter(); @@ -63,6 +68,13 @@ const QuizHeader = ({ content, openEditQuizModal }: Props) => { title={metadata.name} action={
+