diff --git a/app/controllers/course/assessment/question/multiple_responses_controller.rb b/app/controllers/course/assessment/question/multiple_responses_controller.rb index 9e9933ea0f0..4f0f689f611 100644 --- a/app/controllers/course/assessment/question/multiple_responses_controller.rb +++ b/app/controllers/course/assessment/question/multiple_responses_controller.rb @@ -14,7 +14,12 @@ def new def create if @multiple_response_question.save - render json: { redirectUrl: course_assessment_path(current_course, @assessment) } + render json: { + redirectUrl: course_assessment_path(current_course, @assessment), + redirectEditUrl: edit_course_assessment_question_multiple_response_path( + current_course, @assessment, @multiple_response_question + ) + } else render json: { errors: @multiple_response_question.errors }, status: :bad_request end @@ -32,7 +37,12 @@ def update update_skill_ids_if_params_present(multiple_response_question_params[:question_assessment]) if update_multiple_response_question - render json: { redirectUrl: course_assessment_path(current_course, @assessment) } + render json: { + redirectUrl: course_assessment_path(current_course, @assessment), + redirectEditUrl: edit_course_assessment_question_multiple_response_path( + current_course, @assessment, @multiple_response_question + ) + } else render json: { errors: @multiple_response_question.errors }, status: :bad_request end @@ -49,6 +59,30 @@ def destroy end end + def generate + generation_params = parse_generation_params + + unless validate_generation_params(generation_params) + render json: { success: false, message: 'Invalid parameters' }, status: :bad_request + return + end + + generation_service = Course::Assessment::Question::MrqGenerationService.new(@assessment, generation_params) + generated_questions = generation_service.generate_questions + questions = generated_questions['questions'] || [] + + if questions.empty? + render json: { success: false, message: 'No questions were generated' }, status: :bad_request + return + end + + render json: format_generation_response(questions), status: :ok + rescue StandardError => e + Rails.logger.error "MCQ/MRQ Generation Error: #{e.message}" + render json: { success: false, message: 'An error occurred while generating questions' }, + status: :internal_server_error + end + private def respond_to_switch_mcq_mrq_type @@ -85,4 +119,62 @@ def multiple_response_question_params def load_question_assessment @question_assessment = load_question_assessment_for(@multiple_response_question) end + + def parse_generation_params + { + custom_prompt: params[:custom_prompt] || '', + number_of_questions: (params[:number_of_questions] || 1).to_i, + question_type: params[:question_type], + source_question_data: parse_source_question_data + } + end + + def parse_source_question_data + return {} unless params[:source_question_data].present? + + JSON.parse(params[:source_question_data]) + rescue JSON::ParserError + {} + end + + def validate_generation_params(params) + params[:custom_prompt].present? && + params[:number_of_questions] >= 1 && params[:number_of_questions] <= 10 && + %w[mrq mcq].include?(params[:question_type]) + end + + def format_generation_response(questions) + { + success: true, + data: { + title: questions.first['title'], + description: questions.first['description'], + options: format_options(questions.first['options']), + allQuestions: questions.map { |question| format_question(question) }, + numberOfQuestions: questions.length + } + } + end + + def format_options(options) + options.map.with_index do |option, index| + { + id: index + 1, + option: option['option'], + correct: option['correct'], + weight: index + 1, + explanation: option['explanation'] || '', + ignoreRandomization: false, + toBeDeleted: false + } + end + end + + def format_question(question) + { + title: question['title'], + description: question['description'], + options: format_options(question['options']) + } + end end diff --git a/app/services/course/assessment/question/mrq_generation_service.rb b/app/services/course/assessment/question/mrq_generation_service.rb new file mode 100644 index 00000000000..3a68f42ed87 --- /dev/null +++ b/app/services/course/assessment/question/mrq_generation_service.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true +class Course::Assessment::Question::MrqGenerationService + @output_schema = JSON.parse( + File.read('app/services/course/assessment/question/prompts/mcq_mrq_generation_output_format.json') + ) + @output_parser = Langchain::OutputParsers::StructuredOutputParser.from_json_schema( + @output_schema + ) + @mrq_system_prompt = Langchain::Prompt.load_from_path( + file_path: 'app/services/course/assessment/question/prompts/mrq_generation_system_prompt.json' + ) + @mrq_user_prompt = Langchain::Prompt.load_from_path( + file_path: 'app/services/course/assessment/question/prompts/mrq_generation_user_prompt.json' + ) + @mcq_system_prompt = Langchain::Prompt.load_from_path( + file_path: 'app/services/course/assessment/question/prompts/mcq_generation_system_prompt.json' + ) + @mcq_user_prompt = Langchain::Prompt.load_from_path( + file_path: 'app/services/course/assessment/question/prompts/mcq_generation_user_prompt.json' + ) + @llm = LANGCHAIN_OPENAI + + class << self + attr_reader :output_schema, :output_parser, + :mrq_system_prompt, :mrq_user_prompt, :mcq_system_prompt, :mcq_user_prompt + attr_accessor :llm + end + + # Initializes the MRQ generation service with assessment and parameters. + # @param [Course::Assessment] assessment The assessment to generate questions for. + # @param [Hash] params Parameters for question generation. + # @option params [String] :custom_prompt Custom instructions for the LLM. + # @option params [Integer] :number_of_questions Number of questions to generate. + # @option params [Hash] :source_question_data Data from an existing question to base new questions on. + # @option params [String] :question_type Type of question to generate ('mrq' or 'mcq'). + def initialize(assessment, params) + @assessment = assessment + @params = params + @custom_prompt = params[:custom_prompt].to_s + @number_of_questions = (params[:number_of_questions] || 1).to_i + @source_question_data = params[:source_question_data] + @question_type = params[:question_type] || 'mrq' + end + + # Calls the LLM service to generate MRQ or MCQ questions. + # @return [Hash] The LLM's generation response containing multiple questions. + def generate_questions + messages = build_messages + response = self.class.llm.chat( + messages: messages, + response_format: { + type: 'json_schema', + json_schema: { + name: 'mcq_mrq_generation_output', + strict: true, + schema: self.class.output_schema + } + } + ).completion + parse_llm_response(response) + end + + private + + # Builds the messages array from system and user prompt for the LLM chat + # @return [Array] Array of messages formatted for the LLM chat + def build_messages + system_prompt, user_prompt = select_prompts + formatted_system_prompt = system_prompt.format + formatted_user_prompt = user_prompt.format( + custom_prompt: @custom_prompt, + number_of_questions: @number_of_questions, + source_question_title: @source_question_data&.dig('title') || '', + source_question_description: @source_question_data&.dig('description') || '', + source_question_options: format_source_options(@source_question_data&.dig('options') || []) + ) + [ + { role: 'system', content: formatted_system_prompt }, + { role: 'user', content: formatted_user_prompt } + ] + end + + # Selects the appropriate prompts based on the question type + # @return [Array] Array containing system and user prompts + def select_prompts + if @question_type == 'mcq' + [self.class.mcq_system_prompt, self.class.mcq_user_prompt] + else + [self.class.mrq_system_prompt, self.class.mrq_user_prompt] + end + end + + # Formats source question options for inclusion in the LLM prompt + # @param [Array] options The source question options + # @return [String] Formatted string representation of options + def format_source_options(options) + return 'None' if options.empty? + + options.map.with_index do |option, index| + "- Option #{index + 1}: #{option['option']} (Correct: #{option['correct']})" + end.join("\n") + end + + # Parses LLM response with retry logic for handling parsing failures + # @param [String] response The raw LLM response to parse + # @return [Hash] The parsed response as a structured hash + def parse_llm_response(response) + fix_parser = Langchain::OutputParsers::OutputFixingParser.from_llm( + llm: self.class.llm, + parser: self.class.output_parser + ) + fix_parser.parse(response) + end +end diff --git a/app/services/course/assessment/question/prompts/mcq_generation_system_prompt.json b/app/services/course/assessment/question/prompts/mcq_generation_system_prompt.json new file mode 100644 index 00000000000..d34d7f5430f --- /dev/null +++ b/app/services/course/assessment/question/prompts/mcq_generation_system_prompt.json @@ -0,0 +1,5 @@ +{ + "_type": "prompt", + "input_variables": ["format_instructions"], + "template": "You are an expert educational content creator specializing in multiple choice questions (MCQ).\n\nYour task is to generate high-quality multiple choice questions based on the provided instructions and context.\n\nKey requirements for MCQ generation:\n1. Each question must have exactly ONE correct answer.\n2. Ensure all options are plausible and well-written.\n3. Try to create 4 options per question. If that is not feasible, ensure at least 2 options are provided.\n4. Questions should be clear, concise, and educational.\n5. Options should be mutually exclusive and cover different aspects.\n6. Avoid obvious or trivially incorrect distractors.\n7. Use an appropriate difficulty level for the target audience.\n8. Make sure distractors (incorrect options) are plausible but clearly wrong.\n9. **Do not include any language in the question or options that indicates which answer is correct or incorrect.** Avoid phrases like \"correct answer,\" or \"this is incorrect.\"\n10. **If a specific number of questions is provided in the instructions, you must generate exactly that number of questions.**\n11. **All generated questions must be unique and non-repetitive. Do not create questions that are reworded versions or variants of each other.**\n\nInstructions for source question use:\n- If a source question is provided, you **must use it as the base** and **refine or improve** upon it using the provided custom instructions. Do **not** create an unrelated or entirely new question.\n- If the source question is **not provided** or is empty, you may generate a **new, original** question that aligns with the custom instructions.\n\n{format_instructions}" +} diff --git a/app/services/course/assessment/question/prompts/mcq_generation_user_prompt.json b/app/services/course/assessment/question/prompts/mcq_generation_user_prompt.json new file mode 100644 index 00000000000..ec2bc6e5dd5 --- /dev/null +++ b/app/services/course/assessment/question/prompts/mcq_generation_user_prompt.json @@ -0,0 +1,11 @@ +{ + "_type": "prompt", + "input_variables": [ + "custom_prompt", + "number_of_questions", + "source_question_title", + "source_question_description", + "source_question_options" + ], + "template": "Please generate EXACTLY {number_of_questions} multiple choice question(s) based on the following instructions:\n\nCustom Instructions: {custom_prompt}\n\nSource Question Context:\nTitle: {source_question_title}\nDescription: {source_question_description}\nOptions:\n{source_question_options}\n\nCRITICAL REQUIREMENTS:\n- You MUST generate EXACTLY {number_of_questions} questions - no more, no less\n- Do not stop generating until you have created exactly {number_of_questions} questions\n\nGeneration Rules:\n- If the source question fields (title, description, or options) are provided and meaningful, you **must build upon and improve** the existing question. Do **not** create an unrelated question.\n- If the source question is empty or missing, then you may create a **new, original** question based solely on the custom instructions.\n\nAll questions must:\n- Have clear, educational content\n- Include at least 2 options per question (ideally 4 if possible)\n- Have exactly ONE correct answer per question\n- Be appropriate for educational assessment\n- Follow the custom instructions strictly\n- Provide well-written, plausible, and mutually exclusive options\n\nEach question should be well-structured and educationally valuable.\n\nREMEMBER: You must generate EXACTLY {number_of_questions} questions." +} diff --git a/app/services/course/assessment/question/prompts/mcq_mrq_generation_output_format.json b/app/services/course/assessment/question/prompts/mcq_mrq_generation_output_format.json new file mode 100644 index 00000000000..bb2acdd01f8 --- /dev/null +++ b/app/services/course/assessment/question/prompts/mcq_mrq_generation_output_format.json @@ -0,0 +1,50 @@ +{ + "_type": "json_schema", + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The title of the question" + }, + "description": { + "type": "string", + "description": "The description of the question" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "option": { + "type": "string", + "description": "The text of the option" + }, + "correct": { + "type": "boolean", + "description": "Whether this option is correct" + }, + "explanation": { + "type": "string", + "description": "Highly detailed explanation for why this option is correct or incorrect" + } + }, + "required": ["option", "correct", "explanation"], + "additionalProperties": false + }, + "description": "Array of at least 2 options for the question" + } + }, + "required": ["title", "description", "options"], + "additionalProperties": false + }, + "description": "Array of generated multiple response questions" + } + }, + "required": ["questions"], + "additionalProperties": false +} diff --git a/app/services/course/assessment/question/prompts/mrq_generation_system_prompt.json b/app/services/course/assessment/question/prompts/mrq_generation_system_prompt.json new file mode 100644 index 00000000000..fdc3ed0bedd --- /dev/null +++ b/app/services/course/assessment/question/prompts/mrq_generation_system_prompt.json @@ -0,0 +1,5 @@ +{ + "_type": "prompt", + "input_variables": ["format_instructions"], + "template": "You are an expert educational content creator specializing in multiple response questions (MRQ).\n\nYour task is to generate high-quality multiple response questions based on the provided instructions and context.\n\nKey requirements for MRQ generation:\n1. Each question may have one or more correct answers. It is acceptable for some questions to have only one correct answer, or for options like \"None of the above\" to be correct.\n2. Ensure all options are plausible and well-written.\n3. Try to create 4 options per question. If that is not feasible, ensure at least 2 options are provided.\n4. Questions should be clear, concise, and educational.\n5. Options should be mutually exclusive when possible.\n6. Avoid obvious or trivially incorrect distractors.\n7. **Do not include any language in the question or options that indicates whether an answer is correct or incorrect.** Avoid phrases like \"the correct answer is,\" or \"this is incorrect.\"\n8. **If a specific number of questions is provided in the instructions, you must generate exactly that number of questions.**\n9. **All generated questions must be unique and non-repetitive. Do not create questions that are reworded versions or variants of each other.**\n\nInstruction for source question use:\n- If a source question is provided, you **must use it as the base** and **refine or improve** upon it using the provided custom instructions. Do not generate an unrelated or entirely new question.\n- If the source question is **not provided** or is empty, you may generate a new, original question that aligns with the custom instructions.\n\n{format_instructions}" +} diff --git a/app/services/course/assessment/question/prompts/mrq_generation_user_prompt.json b/app/services/course/assessment/question/prompts/mrq_generation_user_prompt.json new file mode 100644 index 00000000000..cae8c07bf1b --- /dev/null +++ b/app/services/course/assessment/question/prompts/mrq_generation_user_prompt.json @@ -0,0 +1,11 @@ +{ + "_type": "prompt", + "input_variables": [ + "custom_prompt", + "number_of_questions", + "source_question_title", + "source_question_description", + "source_question_options" + ], + "template": "Please generate EXACTLY {number_of_questions} multiple response question(s) based on the following instructions:\n\nCustom Instructions:\n{custom_prompt}\n\nSource Question Context:\nTitle: {source_question_title}\nDescription: {source_question_description}\nOptions:\n{source_question_options}\n\nCRITICAL REQUIREMENTS:\n- You MUST generate EXACTLY {number_of_questions} questions - no more, no less\n- Do not stop generating until you have created exactly {number_of_questions} questions\n\nGeneration Rules:\n- If the source question fields (title, description, or options) are provided and meaningful, you **must build upon and improve** the existing question. Do **not** create an unrelated question.\n- If the source question is empty or missing, then you may create a **new, original** question based solely on the custom instructions.\n\nAll questions must:\n- Have clear, educational content\n- Include at least 2 options per question (ideally 4 if possible)\n- Be appropriate for educational assessment\n- Follow the custom instructions strictly\n- Provide well-written, plausible, and mutually exclusive options\n\nEach question should be well-structured and educationally valuable.\n\nREMEMBER: You must generate EXACTLY {number_of_questions} questions." +} diff --git a/app/views/course/assessment/assessments/show.json.jbuilder b/app/views/course/assessment/assessments/show.json.jbuilder index e038311d050..333a504c576 100644 --- a/app/views/course/assessment/assessments/show.json.jbuilder +++ b/app/views/course/assessment/assessments/show.json.jbuilder @@ -173,6 +173,28 @@ if can_observe ] end - json.generateQuestionUrl generate_course_assessment_question_programming_index_path(current_course, assessment) + json.generateQuestionUrls do + json.child! do + json.type 'MultipleChoice' + json.url generate_course_assessment_question_multiple_responses_path( + current_course, assessment, multiple_choice: true + ) + end + + json.child! do + json.type 'MultipleResponse' + json.url generate_course_assessment_question_multiple_responses_path( + current_course, assessment + ) + end + + json.child! do + json.type 'Programming' + json.url generate_course_assessment_question_programming_index_path( + current_course, assessment + ) + end + end + end end diff --git a/app/views/course/question_assessments/_question_assessment.json.jbuilder b/app/views/course/question_assessments/_question_assessment.json.jbuilder index 602748e21b0..24fd36095de 100644 --- a/app/views/course/question_assessments/_question_assessment.json.jbuilder +++ b/app/views/course/question_assessments/_question_assessment.json.jbuilder @@ -15,6 +15,7 @@ json.type question_assessment.question.question_type_readable json.description format_ckeditor_rich_text(question.description) unless question.description.blank? is_programming_question = question.actable_type == Course::Assessment::Question::Programming.name +is_multiple_response_question = question.actable_type == Course::Assessment::Question::MultipleResponse.name is_course_koditsu_enabled = current_course.component_enabled?(Course::KoditsuPlatformComponent) if is_course_koditsu_enabled && is_programming_question @@ -29,6 +30,10 @@ if can?(:manage, assessment) json.generateFromUrl "#{generate_course_assessment_question_programming_index_path( current_course, assessment )}?source_question_id=#{question.specific.id}" + elsif is_multiple_response_question + json.generateFromUrl "#{generate_course_assessment_question_multiple_responses_path( + current_course, assessment + )}?source_question_id=#{question.specific.id}" end json.duplicationUrls question_duplication_dropdown_data do |tab_hash| diff --git a/client/app/api/course/Assessment/Question/McqMrq.ts b/client/app/api/course/Assessment/Question/McqMrq.ts index 00f3638f3c6..b658120caeb 100644 --- a/client/app/api/course/Assessment/Question/McqMrq.ts +++ b/client/app/api/course/Assessment/Question/McqMrq.ts @@ -2,8 +2,9 @@ import { McqMrqFormData, McqMrqPostData, } from 'types/course/assessment/question/multiple-responses'; +import { McqMrqGenerateResponse } from 'types/course/assessment/question-generation'; -import { APIResponse, JustRedirect } from 'api/types'; +import { APIResponse, RedirectWithEditUrl } from 'api/types'; import BaseAPI from '../Base'; @@ -26,11 +27,15 @@ export default class McqMrqAPI extends BaseAPI { return this.client.get(`${this.#urlPrefix}/${id}/edit`); } - create(data: McqMrqPostData): APIResponse { + create(data: McqMrqPostData): APIResponse { return this.client.post(`${this.#urlPrefix}`, data); } - update(id: number, data: McqMrqPostData): APIResponse { + update(id: number, data: McqMrqPostData): APIResponse { return this.client.patch(`${this.#urlPrefix}/${id}`, data); } + + generate(data: FormData): APIResponse { + return this.client.post(`${this.#urlPrefix}/generate`, data); + } } diff --git a/client/app/api/types.ts b/client/app/api/types.ts index 5105111fc47..9871eb2d158 100644 --- a/client/app/api/types.ts +++ b/client/app/api/types.ts @@ -5,3 +5,8 @@ export type APIResponse = Promise>; export interface JustRedirect { redirectUrl: string; } + +export interface RedirectWithEditUrl { + redirectUrl: string; + redirectEditUrl?: string; +} diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateTabs.tsx b/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateTabs.tsx index 0d28d1b6658..1d98feab982 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateTabs.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateTabs.tsx @@ -112,53 +112,57 @@ const GenerateTabs: FC = (props) => { className="min-h-17 p-2" id={metadata.id} label={ - + {metadata.isGenerating && ( )} - {metadata.title ?? 'Untitled Question'} - { - e.stopPropagation(); - duplicateConversation(conversations[metadata.id]); - }} - onMouseDown={(e) => { - e.stopPropagation(); - }} - size="small" - > - - - { - e.stopPropagation(); - if (metadata.hasData) { - setConversationToDeleteId(metadata.id); - } else { - deleteConversation(conversations[metadata.id]); + + {metadata.title ?? 'Untitled Question'} + +
+ { + e.stopPropagation(); + duplicateConversation(conversations[metadata.id]); + }} + onMouseDown={(e) => { + e.stopPropagation(); + }} + size="small" + > + + + { - e.stopPropagation(); - }} - size="small" - > - - + onClick={(e) => { + e.stopPropagation(); + if (metadata.hasData) { + setConversationToDeleteId(metadata.id); + } else { + deleteConversation(conversations[metadata.id]); + } + }} + onMouseDown={(e) => { + e.stopPropagation(); + }} + size="small" + > + + +
} value={metadata.id} diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqConversation.tsx b/client/app/bundles/course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqConversation.tsx new file mode 100644 index 00000000000..72b864db7c4 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqConversation.tsx @@ -0,0 +1,405 @@ +import { FC, useEffect, useState } from 'react'; +import { Controller, UseFormReturn } from 'react-hook-form'; +import { defineMessages } from 'react-intl'; +import Clear from '@mui/icons-material/Clear'; +import DoneAll from '@mui/icons-material/DoneAll'; +import { + Box, + Button, + IconButton, + InputAdornment, + Paper, + TextareaAutosize, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from '@mui/material'; + +import Accordion from 'lib/components/core/layouts/Accordion'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import FormTextField from 'lib/components/form/fields/TextField'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { McqMrqGenerateFormData, SnapshotState } from '../types'; + +const translations = defineMessages({ + numberOfQuestionsField: { + id: 'course.assessment.generation.mrq.numberOfQuestionsField', + defaultMessage: 'Number of Questions', + }, + promptPlaceholder: { + id: 'course.assessment.generation.promptPlaceholder', + defaultMessage: 'Type something here...', + }, + generateQuestion: { + id: 'course.assessment.generation.generateQuestion', + defaultMessage: 'Generate', + }, + showInactive: { + id: 'course.assessment.generation.showInactive', + defaultMessage: 'Show inactive items', + }, + numberOfQuestionsRange: { + id: 'course.assessment.generation.mrq.numberOfQuestionsRange', + defaultMessage: 'Please enter a number from {min} to {max}', + }, + enhanceMode: { + id: 'course.assessment.generation.enhanceMode', + defaultMessage: 'Enhance', + }, + createMode: { + id: 'course.assessment.generation.createMode', + defaultMessage: 'Create New', + }, + enhanceModeTooltip: { + id: 'course.assessment.generation.enhanceModeTooltip', + defaultMessage: 'Build upon your current question', + }, + createModeTooltip: { + id: 'course.assessment.generation.createModeTooltip', + defaultMessage: 'Generate fresh questions from scratch', + }, +}); + +const MAX_PROMPT_LENGTH = 10_000; +const NUM_OF_QN_MIN = 1; +const NUM_OF_QN_MAX = 10; + +const ConversationSnapshot: FC<{ + snapshot: SnapshotState; + className: string; + onClickSnapshot: (snapshot: SnapshotState) => void; +}> = (props) => { + const { snapshot, className, onClickSnapshot } = props; + + return ( +
onClickSnapshot(snapshot)} + > + + {snapshot.state === 'generating' && ( + + )} + {snapshot.state === 'success' && ( + + )} + {snapshot?.generateFormData?.customPrompt} + +
+ ); +}; + +interface Props { + onGenerate: (data: McqMrqGenerateFormData) => Promise; + onSaveActiveData: () => void; + questionFormDataEqual: () => boolean; + generateForm: UseFormReturn; + activeSnapshotId: string; + snapshots: { [id: string]: SnapshotState }; + latestSnapshotId: string; + onClickSnapshot: (snapshot: SnapshotState) => void; +} + +const GenerateMcqMrqConversation: FC = (props) => { + const { t } = useTranslation(); + const { + onGenerate, + onSaveActiveData, + questionFormDataEqual, + generateForm, + activeSnapshotId, + snapshots, + latestSnapshotId, + onClickSnapshot, + } = props; + + // Store the mode before generation starts to preserve it during generation + const [modeBeforeGeneration, setModeBeforeGeneration] = useState( + generateForm.getValues('generationMode'), + ); + + const customPrompt = generateForm.watch('customPrompt'); + const isEnhanceMode = generateForm.watch('generationMode') === 'enhance'; + const isGenerating = Object.values(snapshots || {}).some( + (snapshot) => snapshot.state === 'generating', + ); + + // Update the stored mode when not generating + useEffect(() => { + if (!isGenerating) { + setModeBeforeGeneration(generateForm.getValues('generationMode')); + } + }, [generateForm.watch('generationMode'), isGenerating]); + + // Set default generation mode based on snapshot state + useEffect(() => { + const currentSnapshot = snapshots?.[activeSnapshotId]; + const isSentinel = currentSnapshot?.state === 'sentinel'; + const defaultMode = isSentinel ? 'create' : 'enhance'; + const currentMode = generateForm.getValues('generationMode'); + + // Only update if the current mode doesn't match the expected default + if (currentMode !== defaultMode) { + generateForm.setValue('generationMode', defaultMode); + } + }, [activeSnapshotId, snapshots, generateForm]); + + // Set numberOfQuestions to 1 when enhance mode is selected + useEffect(() => { + const currentMode = generateForm.watch('generationMode'); + if (currentMode === 'enhance') { + generateForm.setValue('numberOfQuestions', 1); + } + }, [generateForm.watch('generationMode'), generateForm]); + + let traversalId: string | undefined = latestSnapshotId; + const mainlineSnapshots: SnapshotState[] = []; + while (traversalId !== undefined && snapshots?.[traversalId]) { + mainlineSnapshots.push(snapshots[traversalId]); + traversalId = snapshots[traversalId].parentId; + } + const mainlineSnapshotsToRender = mainlineSnapshots + .filter((snapshot) => snapshot.state !== 'sentinel') + .reverse(); + mainlineSnapshotsToRender.push( + ...Object.values(snapshots || {}).filter( + (snapshot) => snapshot.state === 'generating', + ), + ); + + const inactiveSnapshotsToRender = Object.values(snapshots || {}).filter( + (snapshot) => + snapshot.state !== 'sentinel' && + !mainlineSnapshotsToRender.some( + (snapshot2) => snapshot.id === snapshot2.id, + ), + ); + + const handleGenerate = async (): Promise => { + if (!questionFormDataEqual()) { + onSaveActiveData(); + } + await onGenerate(generateForm.getValues()); + }; + + return ( + + + {mainlineSnapshotsToRender.map((snapshot) => { + const active = + snapshot.state === 'success' && snapshot.id === activeSnapshotId; + return ( + + ); + })} + {inactiveSnapshotsToRender.length > 0 && ( + + {inactiveSnapshotsToRender.map((snapshot) => { + const active = + snapshot.state === 'success' && + snapshot.id === activeSnapshotId; + return ( + + ); + })} + + )} + + +
+ ( + { + // Prevent onChange when disabled to preserve the selected value + if (isGenerating) { + return; + } + if (newValue !== null) { + field.onChange(newValue); + } + }} + size="small" + value={isGenerating ? modeBeforeGeneration : field.value} + > + + + {t(translations.createMode)} + + + + + {t(translations.enhanceMode)} + + + + )} + /> +
+ +
+ ( + + )} + /> + MAX_PROMPT_LENGTH ? 'error' : 'textSecondary' + } + variant="caption" + > + {customPrompt.length} / {MAX_PROMPT_LENGTH} + +
+
+
+ {((): JSX.Element => { + return ( +
+ ( + { + if (['-', '.', 'e', 'E'].includes(e.key)) { + e.preventDefault(); + } + }, + }, + endAdornment: !isEnhanceMode && + !isGenerating && + field.value !== undefined && + field.value !== null && ( + + field.onChange('')} + size="small" + tabIndex={-1} + > + + + + ), + }} + label={t(translations.numberOfQuestionsField)} + type="number" + variant="filled" + /> + )} + /> +
+ ); + })()} + + +
+ + {((): JSX.Element | null => { + const value = generateForm.watch('numberOfQuestions'); + const isOutOfRange = + value && (value < NUM_OF_QN_MIN || value > NUM_OF_QN_MAX); + return ( +
+ + {t(translations.numberOfQuestionsRange, { + min: NUM_OF_QN_MIN, + max: NUM_OF_QN_MAX, + })} + +
+ ); + })()} +
+
+ ); +}; + +export default GenerateMcqMrqConversation; diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqExportDialog.tsx b/client/app/bundles/course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqExportDialog.tsx new file mode 100644 index 00000000000..1e0fb0530ec --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqExportDialog.tsx @@ -0,0 +1,358 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Done, ExpandLess, ExpandMore, Launch } from '@mui/icons-material'; +import { + Button, + Collapse, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Radio, + Typography, +} from '@mui/material'; +import { red } from '@mui/material/colors'; + +import { generationActions as actions } from 'course/assessment/reducers/generation'; +import Checkbox from 'lib/components/core/buttons/Checkbox'; +import Link from 'lib/components/core/Link'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; +import formTranslations from 'lib/translations/form'; + +import { + create, + updateMcqMrq, +} from '../../../question/multiple-responses/operations'; +import { getAssessmentGenerateQuestionsData } from '../selectors'; +import { ConversationState, McqMrqPrototypeFormData } from '../types'; +import { buildMcqMrqQuestionDataFromPrototype } from '../utils'; + +interface Props { + open: boolean; + onClose: () => void; +} + +const translations = defineMessages({ + exportDialogHeader: { + id: 'course.assessment.generation.mrq.exportDialogHeader', + defaultMessage: 'Export Questions ({exportCount} selected)', + }, + exportAction: { + id: 'course.assessment.generation.mrq.exportAction', + defaultMessage: 'Export', + }, + exportError: { + id: 'course.assessment.generation.exportError', + defaultMessage: 'An error occurred in exporting this question: {error}', + }, + requireNonEmptyOptionError: { + id: 'course.assessment.generation.requireNonEmptyOptionError', + defaultMessage: 'Question must have at least one non-empty option', + }, + untitledQuestion: { + id: 'course.assessment.generation.untitledQuestion', + defaultMessage: 'Untitled Question', + }, + showOptions: { + id: 'course.assessment.question.multipleResponses.showOptions', + defaultMessage: 'Show Options', + }, + hideOptions: { + id: 'course.assessment.question.multipleResponses.hideOptions', + defaultMessage: 'Hide Options', + }, + noOptions: { + id: 'course.assessment.question.multipleResponses.noOptions', + defaultMessage: 'No options', + }, +}); + +const GenerateMcqMrqExportDialog: FC = (props) => { + const { open, onClose } = props; + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const generatePageData = useAppSelector(getAssessmentGenerateQuestionsData); + + // State to track which questions have expanded options + const [expandedQuestions, setExpandedQuestions] = useState>( + new Set(), + ); + + const toggleExpanded = (conversationId: string): void => { + const newExpanded = new Set(expandedQuestions); + if (newExpanded.has(conversationId)) { + newExpanded.delete(conversationId); + } else { + newExpanded.add(conversationId); + } + setExpandedQuestions(newExpanded); + }; + + const setToExport = ( + conversation: ConversationState, + toExport: boolean, + ): void => { + dispatch( + actions.setConversationToExport({ + conversationId: conversation.id, + toExport, + }), + ); + }; + + const handleExportError = async ( + conversation: ConversationState, + exportErrorMessage?: string, + ): Promise => { + dispatch( + actions.exportConversationError({ + conversationId: conversation.id, + exportErrorMessage, + }), + ); + }; + + const handleExport = async (): Promise => { + // Only export conversations that are marked for export + const conversationsToExport = Object.values( + generatePageData.conversations, + ).filter((conversation) => conversation.toExport); + + conversationsToExport.forEach((conversation) => { + dispatch( + actions.exportConversation({ + conversationId: conversation.id, + }), + ); + + // Build the question data from the conversation + const isCreate = conversation.questionId === undefined; + const questionData = buildMcqMrqQuestionDataFromPrototype( + conversation.activeSnapshotEditedData as McqMrqPrototypeFormData, + isCreate, + ); + + // Validate that we have at least one non-empty option + const validOptions = + questionData.options?.filter( + (option) => option.option && option.option.trim().length > 0, + ) || []; + + if (validOptions.length === 0) { + handleExportError( + conversation, + t(translations.requireNonEmptyOptionError), + ); + return; + } + + // Create or update the question + const operation = + conversation.questionId === undefined + ? create(questionData) + : updateMcqMrq(conversation.questionId, questionData); + + operation + .then((response) => { + dispatch( + actions.exportMcqMrqConversationSuccess({ + conversationId: conversation.id, + data: response.redirectEditUrl + ? { redirectEditUrl: response.redirectEditUrl } + : undefined, + }), + ); + }) + .catch((error) => { + handleExportError( + conversation, + error instanceof Error ? error.message : 'Unknown error', + ); + }); + }); + }; + + const exportErrorMessage = (conversation: ConversationState): string => { + return t(translations.exportError, { + error: conversation.exportErrorMessage ?? '', + }); + }; + + return ( + + + {t(translations.exportDialogHeader, { + exportCount: generatePageData.exportCount, + })} + + + {generatePageData.conversationIds.map((conversationId, index) => { + const conversation = generatePageData.conversations[conversationId]; + const questionData = conversation?.activeSnapshotEditedData.question; + const metadata = + generatePageData.conversationMetadata[conversationId]; + if (!conversation || !questionData || !metadata?.hasData) return null; + + const title = metadata.title || t(translations.untitledQuestion); + // Remove HTML tags from description + const description = questionData.description + ? questionData.description.replace(/<(\/)?[^>]+(>|$)/g, '') + : ''; + + // Get options from the conversation data + const options = + (conversation.activeSnapshotEditedData as McqMrqPrototypeFormData) + ?.options || []; + const hasOptions = options.length > 0; + const isExpanded = expandedQuestions.has(conversationId); + + return ( + +
+ + setToExport(conversation, !conversation.toExport) + } + /> + + + {title} + + +
+ {/* Options expand/collapse button */} + {hasOptions && ( + + )} +
+ + {conversation.exportStatus === 'pending' && ( + + )} + {conversation.exportStatus === 'exported' && ( + + )} + {conversation.exportStatus === 'exported' && + conversation.redirectEditUrl && ( + e.stopPropagation()} + opensInNewTab + to={conversation.redirectEditUrl} + variant="subtitle1" + > + + + )} +
+ +
+ {description && ( + + {description} + + )} + + {/* Collapsible options section */} + +
+ {hasOptions ? ( + options.map((option) => { + // Determine if this is MCQ or MRQ based on gradingScheme + const isMcq = + ( + conversation.activeSnapshotEditedData as McqMrqPrototypeFormData + )?.gradingScheme === 'any_correct'; + + return ( + + ); + }) + ) : ( + + {t(translations.noOptions)} + + )} +
+
+ + {conversation.exportStatus === 'error' && ( + + {exportErrorMessage(conversation)} + + )} +
+
+ ); + })} +
+ + + + +
+ ); +}; + +export default GenerateMcqMrqExportDialog; diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqPrototypeForm.tsx b/client/app/bundles/course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqPrototypeForm.tsx new file mode 100644 index 00000000000..d9e5f7d51fd --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqPrototypeForm.tsx @@ -0,0 +1,180 @@ +import { FC, useMemo } from 'react'; +import { Controller, FormProvider, UseFormReturn } from 'react-hook-form'; +import { defineMessages } from 'react-intl'; +import { Container } from '@mui/material'; + +import { generationActions as actions } from 'course/assessment/reducers/generation'; +import FormCheckboxField from 'lib/components/form/fields/CheckboxField'; +import FormRichTextField from 'lib/components/form/fields/RichTextField'; +import FormTextField from 'lib/components/form/fields/TextField'; +import { useAppDispatch } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { + mcqAdapter, + mrqAdapter, +} from '../../../question/multiple-responses/commons/translationAdapter'; +import OptionsManager, { + OptionsManagerRef, +} from '../../../question/multiple-responses/components/OptionsManager'; +import LockableSection from '../LockableSection'; +import { LockStates, McqMrqPrototypeFormData } from '../types'; + +const translations = defineMessages({ + title: { + id: 'course.assessment.question.multipleResponses.title', + defaultMessage: 'Title', + }, + description: { + id: 'course.assessment.question.multipleResponses.description', + defaultMessage: 'Description', + }, + alwaysGradeAsCorrect: { + id: 'course.assessment.question.multipleResponses.alwaysGradeAsCorrect', + defaultMessage: 'Always grade as correct', + }, +}); + +interface Props { + form: UseFormReturn; + lockStates: LockStates; + onToggleLock: (key: string) => void; + optionsRef: React.RefObject; + onOptionsDirtyChange: (isDirty: boolean) => void; + isMultipleChoice: boolean; +} + +const GenerateMcqMrqPrototypeForm: FC = (props) => { + const { + form, + lockStates, + onToggleLock, + optionsRef, + onOptionsDirtyChange, + isMultipleChoice, + } = props; + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const { onChange } = form.register('question.title', { + onChange: (e) => { + const title = e?.target?.value?.toString() || ''; + dispatch(actions.setActiveFormTitle({ title })); + }, + }); + + const adapter = isMultipleChoice ? mcqAdapter(t) : mrqAdapter(t); + + // Mark all options as drafts for immediate deletion in generation page + // Memoize to prevent unnecessary re-renders of OptionsManager + const draftOptions = useMemo(() => { + const options = form.watch('options') || []; + return options.map((option) => ({ + ...option, + draft: true, + })); + }, [form.watch('options')]); + + return ( + + + ( + + )} + /> + + + + + ( + + )} + /> + + + + +
+ ( + + )} + /> +
+
+ + + +
+ +
+
+
+
+ ); +}; + +export default GenerateMcqMrqPrototypeForm; diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqQuestionPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqQuestionPage.tsx new file mode 100644 index 00000000000..04b13b13e7d --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqQuestionPage.tsx @@ -0,0 +1,676 @@ +import { useEffect, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { defineMessages } from 'react-intl'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Container, Divider, Grid } from '@mui/material'; +import { McqMrqFormData } from 'types/course/assessment/question/multiple-responses'; +import { McqMrqGeneratedOption } from 'types/course/assessment/question-generation'; +import * as yup from 'yup'; + +import GenerateTabs from 'course/assessment/pages/AssessmentGenerate/GenerateTabs'; +import GenerateMcqMrqConversation from 'course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqConversation'; +import GenerateMcqMrqExportDialog from 'course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqExportDialog'; +import GenerateMcqMrqPrototypeForm from 'course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqPrototypeForm'; +import { getAssessmentGenerateQuestionsData } from 'course/assessment/pages/AssessmentGenerate/selectors'; +import { + ConversationState, + McqMrqGenerateFormData, + McqMrqPrototypeFormData, + SnapshotState, +} from 'course/assessment/pages/AssessmentGenerate/types'; +import { + buildMcqMrqGenerateRequestPayload, + buildPrototypeFromMcqMrqQuestionData, + extractMcqMrqQuestionPrototypeData, + replaceUnlockedMcqMrqPrototypeFields, +} from 'course/assessment/pages/AssessmentGenerate/utils'; +import { generationActions as actions } from 'course/assessment/reducers/generation'; +import { setNotification } from 'lib/actions'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; +import { DataHandle } from 'lib/hooks/router/dynamicNest'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { OptionsManagerRef } from '../../../question/multiple-responses/components/OptionsManager'; +import { + fetchEditMcqMrq, + generate, +} from '../../../question/multiple-responses/operations'; +import { + defaultMcqMrqGenerateFormData, + defaultMcqPrototypeFormData, + defaultMrqPrototypeFormData, +} from '../constants'; + +const translations = defineMessages({ + generateMrqPage: { + id: 'course.assessment.generation.generateMrqPage', + defaultMessage: 'Generate Multiple Response Question', + }, + generateMcqPage: { + id: 'course.assessment.generation.generateMcqPage', + defaultMessage: 'Generate Multiple Choice Question', + }, + generateMultipleSuccess: { + id: 'course.assessment.generation.generateMultipleSuccess', + defaultMessage: 'Successfully generated {count} questions!', + }, + generateError: { + id: 'course.assessment.generation.generateError', + defaultMessage: 'An error occurred generating question {title}.', + }, + loadingSourceError: { + id: 'course.assessment.generation.loadingSourceError', + defaultMessage: 'Unable to load source question data.', + }, + allFieldsLocked: { + id: 'course.assessment.generation.allFieldsLocked', + defaultMessage: 'All fields are locked, so nothing can be generated.', + }, +}); + +const compareFormData = ( + oldState, + newState, +): { [name: string]: boolean } | null => { + if (!oldState || !newState) return null; + return { + 'question.title': oldState.question.title === newState.question.title, + // remove html tags + 'question.description': + oldState.question.description.replace(/<(\/)?[^>]+(>|$)/g, '') === + newState.question.description.replace(/<(\/)?[^>]+(>|$)/g, ''), + 'question.options': + JSON.stringify(oldState.options) === JSON.stringify(newState.options), + }; +}; + +const getMcqMrqType = ( + params: URLSearchParams, +): McqMrqFormData['mcqMrqType'] => + params.get('multiple_choice') === 'true' ? 'mcq' : 'mrq'; + +const generateSnapshotId = (): string => Date.now().toString(16); + +const MAX_PROMPT_LENGTH = 10_000; +const NUM_OF_QN_MIN = 1; +const NUM_OF_QN_MAX = 10; + +const generateFormValidationSchema = yup.object({ + customPrompt: yup.string().min(1).max(MAX_PROMPT_LENGTH), + numberOfQuestions: yup + .number() + .min(NUM_OF_QN_MIN) + .max(NUM_OF_QN_MAX) + .required(), +}); + +const GenerateMcqMrqQuestionPage = (): JSX.Element => { + const { t } = useTranslation(); + const params = useParams(); + const id = parseInt(params?.assessmentId ?? '', 10) || undefined; + if (!id) + throw new Error(`GenerateMcqMrqQuestionPage was loaded with ID: ${id}.`); + + const [searchParams] = useSearchParams(); + const sourceId = + parseInt(searchParams.get('source_question_id') ?? '', 10) || undefined; + + const isMultipleChoice = searchParams.get('multiple_choice') === 'true'; + const questionType = isMultipleChoice ? 'mcq' : 'mrq'; + + const sourceDataInitializedRef = useRef(false); + const optionsRef = useRef(null); + + const dispatch = useAppDispatch(); + const [exportDialogOpen, setExportDialogOpen] = useState(false); + const [isOptionsDirty, setIsOptionsDirty] = useState(false); + const generatePageData = useAppSelector(getAssessmentGenerateQuestionsData); + + // Initialize generation state with the appropriate questionType + useEffect(() => { + dispatch(actions.initializeGeneration({ questionType })); + }, [questionType]); + + // upper form (submit to OpenAI) + const generateForm = useForm({ + defaultValues: defaultMcqMrqGenerateFormData, + resolver: yupResolver(generateFormValidationSchema), + }); + + // lower form (populate to new question page) + const prototypeForm = useForm({ + defaultValues: isMultipleChoice + ? defaultMcqPrototypeFormData + : defaultMrqPrototypeFormData, + }); + const questionFormData = prototypeForm.watch(); + + const defaultLockStates = { + 'question.title': false, + 'question.description': false, + 'question.options': false, + 'question.correct': false, + }; + const [lockStates, setLockStates] = useState<{ [name: string]: boolean }>( + defaultLockStates, + ); + + const activeConversationId = generatePageData.activeConversationId; + const activeConversationIndex = generatePageData.conversationIds.findIndex( + (conversationId) => conversationId === activeConversationId, + ); + const activeConversationSnapshots = + generatePageData.conversations?.[activeConversationId]?.snapshots; + const activeSnapshotId = + generatePageData.conversations[activeConversationId]?.activeSnapshotId; + const activeSnapshot = activeSnapshotId + ? generatePageData.conversations[activeConversationId]?.snapshots[ + activeSnapshotId + ] + : undefined; + const latestSnapshotId = + generatePageData.conversations[activeConversationId]?.latestSnapshotId; + + const questionFormDataEqual = (): boolean => { + // Get current form data including options from OptionsManager + const currentFormData = JSON.parse( + JSON.stringify(prototypeForm.getValues()), + ); + currentFormData.options = optionsRef.current?.getOptions() || []; + + const comp = compareFormData(activeSnapshot?.questionData, currentFormData); + const formDataEqual = comp === null || Object.values(comp).every((p) => p); + + // If options are dirty, the form is not equal + return formDataEqual && !isOptionsDirty; + }; + + // calling getValues() directly returns a "readonly" reference, which can lead to errors + // as the object is propagated across various state / handler functions + // so instead, these helper functions return a deep copy + const getActiveGenerateFormData = (): McqMrqGenerateFormData => + JSON.parse(JSON.stringify(generateForm.getValues())); + + const getActivePrototypeFormData = (): McqMrqPrototypeFormData => { + const formData = JSON.parse(JSON.stringify(prototypeForm.getValues())); + + // Update the form data with current options from OptionsManager + formData.options = optionsRef.current?.getOptions() || []; + + return formData; + }; + + const saveActiveFormData = (): void => { + dispatch( + actions.saveActiveData({ + conversationId: generatePageData.activeConversationId, + snapshotId: activeSnapshotId, + questionData: getActivePrototypeFormData(), + }), + ); + }; + + const switchToConversation = (conversation: ConversationState): void => { + saveActiveFormData(); + const snapshot = conversation.snapshots?.[conversation.activeSnapshotId]; + if (snapshot) { + dispatch( + actions.setActiveConversationId({ conversationId: conversation.id }), + ); + dispatch( + actions.setActiveFormTitle({ + title: conversation.activeSnapshotEditedData.question.title, + }), + ); + + // Set the correct generation mode based on snapshot state + const isSentinel = snapshot.state === 'sentinel'; + const defaultMode: 'create' | 'enhance' = isSentinel + ? 'create' + : 'enhance'; + const formDataWithCorrectMode: McqMrqGenerateFormData = { + ...defaultMcqMrqGenerateFormData, + generationMode: defaultMode, + }; + + generateForm.reset(formDataWithCorrectMode); + prototypeForm.reset(conversation.activeSnapshotEditedData); + setLockStates(snapshot.lockStates); + // Reset options dirty state when switching conversations + setIsOptionsDirty(false); + } + }; + + const createConversation = (): void => { + dispatch(actions.createConversation({ questionType })); + dispatch((_, getState) => { + const newState = getAssessmentGenerateQuestionsData(getState()); + const newConversationId = + newState.conversationIds[newState.conversationIds.length - 1]; + const newConversation = newState.conversations[newConversationId]; + + switchToConversation(newConversation); + }); + }; + + const duplicateConversation = (conversation: ConversationState): void => { + dispatch( + actions.duplicateConversation({ conversationId: conversation.id }), + ); + if (conversation.id === generatePageData.activeConversationId) { + // persist changes from the active tab to the duplicated tab + dispatch((_, getState) => { + const newState = getAssessmentGenerateQuestionsData(getState()); + const newConversation = Object.values(newState.conversations).find( + (otherConversation) => + otherConversation.duplicateFromId === conversation.id, + ); + if (newConversation) { + dispatch( + actions.saveActiveData({ + conversationId: newConversation.id, + snapshotId: newConversation.activeSnapshotId, + questionData: getActivePrototypeFormData(), + }), + ); + } + }); + } + }; + + const deleteConversation = (conversation: ConversationState): void => { + if (conversation?.id === generatePageData.activeConversationId) { + const newActiveConversationIndex = + activeConversationIndex > 0 ? activeConversationIndex - 1 : 1; + switchToConversation( + generatePageData.conversations[ + generatePageData.conversationIds[newActiveConversationIndex] + ], + ); + } + dispatch(actions.deleteConversation({ conversationId: conversation.id })); + }; + + const fetchSourceData = async (): Promise< + McqMrqFormData<'edit'> | undefined + > => { + if (sourceId) { + try { + return await fetchEditMcqMrq(sourceId); + } catch (error) { + dispatch(setNotification(t(translations.loadingSourceError))); + } + } + return undefined; + }; + + const preloadData = async (): Promise<{ + sourceData?: McqMrqFormData<'edit'>; + }> => { + const sourceData = await fetchSourceData(); + return { sourceData }; + }; + + return ( + } while={preloadData}> + {({ sourceData }): JSX.Element => { + if (sourceData && !sourceDataInitializedRef.current) { + sourceDataInitializedRef.current = true; + dispatch( + actions.setActiveFormTitle({ title: sourceData.question.title }), + ); + prototypeForm.reset( + buildPrototypeFromMcqMrqQuestionData(sourceData, isMultipleChoice), + ); + } + + return ( + <> + { + saveActiveFormData(); + dispatch(actions.clearErroredConversationData()); + setExportDialogOpen(true); + }} + resetConversation={() => { + prototypeForm.reset(activeSnapshot?.questionData); + + const resetTitle = + activeSnapshot?.questionData?.question?.title || ''; + dispatch(actions.setActiveFormTitle({ title: resetTitle })); + + optionsRef.current?.reset(); + setIsOptionsDirty(false); + }} + switchToConversation={switchToConversation} + /> + + + + + {activeConversationSnapshots && + activeSnapshotId && + latestSnapshotId && ( + { + if (snapshot.state === 'success') { + dispatch( + actions.saveActiveData({ + conversationId: + generatePageData.activeConversationId, + snapshotId: snapshot.id, + questionData: snapshot.questionData, + }), + ); + if (snapshot.questionData) { + dispatch( + actions.setActiveFormTitle({ + title: snapshot.questionData.question.title, + }), + ); + } + if (snapshot.generateFormData) { + generateForm.reset(snapshot.generateFormData); + } + if (snapshot.questionData) { + prototypeForm.reset(snapshot.questionData); + } + if (snapshot.lockStates) { + setLockStates(snapshot.lockStates); + } + + // Update OptionsManager with the snapshot's options + if (snapshot.questionData) { + const questionData = + snapshot.questionData as McqMrqPrototypeFormData; + if ( + questionData.options && + questionData.options.length > 0 + ) { + const draftOptions = questionData.options.map( + (option) => ({ + ...option, + draft: true, + }), + ); + optionsRef.current?.updateOptions(draftOptions); + } else { + // If no options, start with empty options + optionsRef.current?.updateOptions([]); + } + } + + // Reset options dirty state when switching snapshots + setIsOptionsDirty(false); + } + }} + onGenerate={async (generateFormData): Promise => { + if ( + Object.values(lockStates).reduce( + (a, b) => a && b, + true, + ) + ) { + dispatch( + setNotification(t(translations.allFieldsLocked)), + ); + return; + } + const newSnapshotId = Date.now().toString(16); + const conversationId = + generatePageData.activeConversationId; + dispatch( + actions.createSnapshot({ + snapshotId: newSnapshotId, + parentId: activeSnapshotId, + generateFormData: getActiveGenerateFormData(), + conversationId, + lockStates, + }), + ); + try { + const response = await generate( + buildMcqMrqGenerateRequestPayload( + generateFormData as McqMrqGenerateFormData, + questionFormData as McqMrqPrototypeFormData, + isMultipleChoice, + ), + ); + + // Handle multiple questions if they were generated + const allQuestions = response.data.allQuestions || [ + response.data, + ]; + const numberOfQuestions = + response.data.numberOfQuestions || 1; + + if ( + numberOfQuestions > 1 && + allQuestions.length > 1 + ) { + // Get the original conversation to copy snapshots from + const originalConversation = + generatePageData.conversations[conversationId]; + + // Create separate conversations for each additional question + for (let i = 1; i < allQuestions.length; i++) { + const additionalQuestion = allQuestions[i]; + const additionalQuestionTimestamp = + Date.now() + i; // Ensure unique timestamp + const additionalQuestionData = { + question: { + title: additionalQuestion.title, + description: additionalQuestion.description, + skipGrading: false, + randomizeOptions: false, + }, + options: additionalQuestion.options.map( + ( + option: McqMrqGeneratedOption, + index: number, + ) => ({ + ...option, + id: `option-${additionalQuestionTimestamp}-${index}`, + }), + ), + gradingScheme: isMultipleChoice + ? ('any_correct' as const) + : ('all_correct' as const), + }; + + // Copy only the latest snapshot from the original conversation + if (originalConversation) { + const newAdditionalQuestionSnapshotId = + generateSnapshotId(); + + // Create a new snapshot with the additional question data + const newSnapshot = { + id: newAdditionalQuestionSnapshotId, + parentId: undefined, // No parent since this is a fresh start + lockStates, + generateFormData, + state: 'success' as const, + questionData: additionalQuestionData, + }; + + // Create a new conversation with only the new snapshot + dispatch( + actions.createConversationWithSnapshots({ + questionType, + copiedSnapshots: { + [newAdditionalQuestionSnapshotId]: + newSnapshot, + }, + latestSnapshotId: + newAdditionalQuestionSnapshotId, + activeSnapshotId: + newAdditionalQuestionSnapshotId, + activeSnapshotEditedData: + additionalQuestionData, + }), + ); + } + } + + // Show success notification for multiple questions + dispatch( + setNotification( + t(translations.generateMultipleSuccess, { + count: numberOfQuestions, + }), + ), + ); + } + + // Handle the first/main question as before + const responseQuestionFormData = + extractMcqMrqQuestionPrototypeData( + response.data, + isMultipleChoice, + ); + const newQuestionFormData = + replaceUnlockedMcqMrqPrototypeFields( + questionFormData as McqMrqPrototypeFormData, + responseQuestionFormData as McqMrqPrototypeFormData, + lockStates, + ); + dispatch((_, getState) => { + const currentActiveConversationId = + getAssessmentGenerateQuestionsData( + getState(), + ).activeConversationId; + if ( + conversationId === currentActiveConversationId + ) { + generateForm.resetField('customPrompt', { + defaultValue: '', + }); + prototypeForm.reset(newQuestionFormData); + + // Update the OptionsManager with the new options + if ( + newQuestionFormData.options && + newQuestionFormData.options.length > 0 + ) { + // Mark options as drafts for immediate deletion in generation page + const draftOptions = + newQuestionFormData.options.map( + (option) => ({ + ...option, + draft: true, + }), + ); + optionsRef.current?.updateOptions( + draftOptions, + ); + } + } + dispatch( + actions.snapshotSuccess({ + snapshotId: newSnapshotId, + conversationId, + questionData: newQuestionFormData, + }), + ); + dispatch( + actions.saveActiveData({ + conversationId, + snapshotId: newSnapshotId, + questionData: newQuestionFormData, + }), + ); + if ( + currentActiveConversationId === conversationId + ) { + dispatch( + actions.setActiveFormTitle({ + title: newQuestionFormData.question.title, + }), + ); + } + }); + } catch (response) { + dispatch( + actions.snapshotError({ + snapshotId: newSnapshotId, + conversationId, + }), + ); + dispatch( + setNotification( + t(translations.generateError, { + title: + generatePageData.conversationMetadata[ + conversationId + ].title ?? 'Untitled Question', + }), + ), + ); + } + }} + onSaveActiveData={saveActiveFormData} + questionFormDataEqual={questionFormDataEqual} + snapshots={activeConversationSnapshots} + /> + )} + + + + { + setLockStates({ + ...lockStates, + [lockStateKey]: !lockStates[lockStateKey], + }); + }} + optionsRef={optionsRef} + /> + + + + + + setExportDialogOpen(false)} + open={exportDialogOpen} + /> + + ); + }} + + ); +}; + +const handle: DataHandle = (_, location) => { + const searchParams = new URLSearchParams(location.search); + + return getMcqMrqType(searchParams) === 'mcq' + ? translations.generateMcqPage + : translations.generateMrqPage; +}; + +export default Object.assign(GenerateMcqMrqQuestionPage, { handle }); diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateConversation.tsx b/client/app/bundles/course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingConversation.tsx similarity index 95% rename from client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateConversation.tsx rename to client/app/bundles/course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingConversation.tsx index d554b91f47a..5ec301aea50 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateConversation.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingConversation.tsx @@ -15,7 +15,7 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import FormSelectField from 'lib/components/form/fields/SelectField'; import useTranslation from 'lib/hooks/useTranslation'; -import { CodaveriGenerateFormData, SnapshotState } from './types'; +import { ProgrammingGenerateFormData, SnapshotState } from '../types'; const translations = defineMessages({ languageField: { @@ -54,7 +54,7 @@ const ConversationSnapshot: FC<{ {snapshot.state === 'success' && ( )} - {snapshot?.codaveriData?.customPrompt} + {snapshot?.generateFormData?.customPrompt} ); @@ -62,7 +62,7 @@ const ConversationSnapshot: FC<{ interface Props { onGenerate: () => Promise; - codaveriForm: UseFormReturn; + codaveriForm: UseFormReturn; languages: object[]; snapshots: { [id: string]: SnapshotState }; activeSnapshotId: string; @@ -70,7 +70,7 @@ interface Props { onClickSnapshot: (snapshot: SnapshotState) => void; } -const GenerateConversation: FC = (props) => { +const GenerateProgrammingConversation: FC = (props) => { const { t } = useTranslation(); const { languages, @@ -212,4 +212,4 @@ const GenerateConversation: FC = (props) => { ); }; -export default GenerateConversation; +export default GenerateProgrammingConversation; diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateExportDialog.tsx b/client/app/bundles/course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingExportDialog.tsx similarity index 72% rename from client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateExportDialog.tsx rename to client/app/bundles/course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingExportDialog.tsx index ef9442bd31e..c12a5840d42 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateExportDialog.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingExportDialog.tsx @@ -40,9 +40,9 @@ import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import formTranslations from 'lib/translations/form'; -import { getAssessmentGenerateQuestionsData } from './selectors'; -import { ConversationState, ExportError } from './types'; -import { buildQuestionDataFromPrototype } from './utils'; +import { getAssessmentGenerateQuestionsData } from '../selectors'; +import { ConversationState, ExportError } from '../types'; +import { buildProgrammingQuestionDataFromPrototype } from '../utils'; interface Props { open: boolean; @@ -62,11 +62,11 @@ const translations = defineMessages({ }, exportError: { id: 'course.assessment.generation.exportError', - defaultMessage: 'An error occured in exporting this question: {error}', + defaultMessage: 'An error occurred in exporting this question: {error}', }, }); -const GenerateExportDialog: FC = (props) => { +const GenerateProgrammingExportDialog: FC = (props) => { const { open, setOpen, saveActiveFormData, languages } = props; const { t } = useTranslation(); const dispatch = useAppDispatch(); @@ -109,9 +109,9 @@ const GenerateExportDialog: FC = (props) => { const pollQuestionExportJobs = (): void => { Object.values(generatePageData.conversations) .filter( - (conversation) => + (conversation): conversation is ConversationState => conversation.exportStatus === 'importing' && - conversation.importJobUrl, + conversation.importJobUrl !== undefined, ) .forEach((conversation) => { GlobalAPI.jobs @@ -119,7 +119,7 @@ const GenerateExportDialog: FC = (props) => { .then((response) => { if (response.data.status === 'completed') { dispatch( - actions.exportConversationSuccess({ + actions.exportProgrammingConversationSuccess({ conversationId: conversation.id, }), ); @@ -275,51 +275,70 @@ const GenerateExportDialog: FC = (props) => { snapshot && snapshot.state !== 'sentinel', ) - .forEach(({ conversation }, index) => { - const questionData = conversation.activeSnapshotEditedData; - const { codaveriData } = - conversation.snapshots[conversation.activeSnapshotId]; - const { id: languageId, editorMode: languageMode } = - languages.find( - (lang) => lang.id === codaveriData!.languageId, - )!; - const formData = buildFormData( - buildQuestionDataFromPrototype( - questionData!, - languageId, - languageMode, - ), - ); - dispatch( - actions.exportConversation({ - conversationId: conversation.id, - }), - ); - const operation = - conversation.questionId === undefined - ? create(formData) - : update(conversation.questionId, formData); - operation - .then((response) => { - if (response.importJobUrl) { - dispatch( - actions.exportConversationPendingImport({ - conversationId: conversation.id, - data: response, - }), - ); - } else { - dispatch( - actions.exportConversationSuccess({ - conversationId: conversation.id, - data: response, - }), - ); - } - }) - .catch((error) => { - handleExportError(conversation, error.message); - }); + .forEach(({ conversation, snapshot }, index) => { + // type guard for programming questions + if ( + snapshot && + 'generateFormData' in snapshot && + snapshot.generateFormData && + 'languageId' in snapshot.generateFormData + ) { + const questionData = conversation.activeSnapshotEditedData; + // type guard for ProgrammingPrototypeFormData + if ( + questionData && + 'testUi' in questionData && + questionData.testUi && + 'metadata' in questionData.testUi && + questionData.testUi.metadata && + 'solution' in questionData.testUi.metadata + ) { + const { generateFormData } = snapshot; + const language = languages.find( + (lang) => lang.id === generateFormData.languageId, + ); + if (!language) return; + const { id: languageId, editorMode: languageMode } = + language; + const formData = buildFormData( + buildProgrammingQuestionDataFromPrototype( + questionData, + languageId, + languageMode, + ), + ); + dispatch( + actions.exportConversation({ + conversationId: conversation.id, + }), + ); + const operation = + conversation.questionId === undefined + ? create(formData) + : update(conversation.questionId, formData); + operation + .then((response) => { + if (response.importJobUrl) { + dispatch( + actions.exportProgrammingConversationPendingImport({ + conversationId: conversation.id, + data: response, + }), + ); + } else { + dispatch( + actions.exportProgrammingConversationSuccess({ + conversationId: conversation.id, + data: response, + }), + ); + } + }) + .catch((error) => { + handleExportError(conversation, error.message); + }); + } + } }); }} variant="contained" @@ -331,4 +350,4 @@ const GenerateExportDialog: FC = (props) => { ); }; -export default GenerateExportDialog; +export default GenerateProgrammingExportDialog; diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateQuestionPrototypeForm.tsx b/client/app/bundles/course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingPrototypeForm.tsx similarity index 94% rename from client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateQuestionPrototypeForm.tsx rename to client/app/bundles/course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingPrototypeForm.tsx index 373a775acb9..93245747155 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateQuestionPrototypeForm.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingPrototypeForm.tsx @@ -14,15 +14,15 @@ import FormTextField from 'lib/components/form/fields/TextField'; import { useAppDispatch } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -import translations from '../../translations'; +import translations from '../../../translations'; +import { CODAVERI_EVALUATOR_ONLY_LANGUAGES } from '../constants'; +import LockableSection from '../LockableSection'; +import { LockStates, ProgrammingPrototypeFormData } from '../types'; -import { CODAVERI_EVALUATOR_ONLY_LANGUAGES } from './constants'; -import LockableSection from './LockableSection'; import TestCasesManager from './TestCasesManager'; -import { LockStates, QuestionPrototypeFormData } from './types'; interface Props { - prototypeForm: UseFormReturn; + prototypeForm: UseFormReturn; onToggleLock: (key: string) => void; lockStates: LockStates; editorMode: LanguageMode; @@ -43,7 +43,7 @@ const TestCaseComponentMapper: Record< typescript: ReorderableTestCase, }; -const GenerateQuestionPrototypeForm: FC = (props) => { +const GenerateProgrammingPrototypeForm: FC = (props) => { const { prototypeForm, lockStates, onToggleLock, editorMode } = props; const { t } = useTranslation(); const dispatch = useAppDispatch(); @@ -223,4 +223,4 @@ const GenerateQuestionPrototypeForm: FC = (props) => { ); }; -export default GenerateQuestionPrototypeForm; +export default GenerateProgrammingPrototypeForm; diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateProgrammingQuestionPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingQuestionPage.tsx similarity index 89% rename from client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateProgrammingQuestionPage.tsx rename to client/app/bundles/course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingQuestionPage.tsx index 7c5bd656f41..d268837d2de 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateProgrammingQuestionPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingQuestionPage.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { defineMessages } from 'react-intl'; import { useParams, useSearchParams } from 'react-router-dom'; @@ -10,19 +10,19 @@ import { } from 'types/course/assessment/question/programming'; import * as yup from 'yup'; -import GenerateConversation from 'course/assessment/pages/AssessmentGenerate/GenerateConversation'; -import GenerateQuestionPrototypeForm from 'course/assessment/pages/AssessmentGenerate/GenerateQuestionPrototypeForm'; import GenerateTabs from 'course/assessment/pages/AssessmentGenerate/GenerateTabs'; +import GenerateProgrammingConversation from 'course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingConversation'; +import GenerateProgrammingPrototypeForm from 'course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingPrototypeForm'; import { getAssessmentGenerateQuestionsData } from 'course/assessment/pages/AssessmentGenerate/selectors'; import { - CodaveriGenerateFormData, ConversationState, - QuestionPrototypeFormData, + ProgrammingGenerateFormData, + ProgrammingPrototypeFormData, SnapshotState, } from 'course/assessment/pages/AssessmentGenerate/types'; import { - buildGenerateRequestPayload, - buildPrototypeFromQuestionData, + buildProgrammingGenerateRequestPayload, + buildPrototypeFromProgrammingQuestionData, extractQuestionPrototypeData, replaceUnlockedPrototypeFields, } from 'course/assessment/pages/AssessmentGenerate/utils'; @@ -37,10 +37,13 @@ import { fetchCodaveriLanguages, fetchEdit, generate, -} from '../../question/programming/operations'; +} from '../../../question/programming/operations'; +import { + defaultProgrammingGenerateFormData, + defaultProgrammingPrototypeFormData, +} from '../constants'; -import { defaultCodaveriFormData, defaultQuestionFormData } from './constants'; -import GenerateExportDialog from './GenerateExportDialog'; +import GenerateProgrammingExportDialog from './GenerateProgrammingExportDialog'; const translations = defineMessages({ generatePage: { @@ -53,7 +56,7 @@ const translations = defineMessages({ }, generateError: { id: 'course.assessment.generation.generateError', - defaultMessage: 'An error occured generating question "{title}".', + defaultMessage: 'An error occurred generating question "{title}".', }, loadingSourceError: { id: 'course.assessment.generation.loadingSourceError', @@ -140,10 +143,15 @@ const GenerateProgrammingQuestionPage = (): JSX.Element => { const [exportDialogOpen, setExportDialogOpen] = useState(false); const generatePageData = useAppSelector(getAssessmentGenerateQuestionsData); + // Initialize generation state with programming questionType + useEffect(() => { + dispatch(actions.initializeGeneration({ questionType: 'programming' })); + }, []); + const { t } = useTranslation(); // upper form (submit to Codaveri) - const codaveriForm = useForm({ - defaultValues: defaultCodaveriFormData, + const codaveriForm = useForm({ + defaultValues: defaultProgrammingGenerateFormData, resolver: yupResolver(codaveriValidationSchema), }); const currentLanguageId = codaveriForm.watch('languageId'); @@ -151,7 +159,9 @@ const GenerateProgrammingQuestionPage = (): JSX.Element => { // lower form (populate to new programming question page) // TODO: We reuse ProgrammingFormData object here because test case UI mandates it. // Consider reworking type declarations in TestCases.tsx to enable creating an independent model class here. - const prototypeForm = useForm({ defaultValues: defaultQuestionFormData }); + const prototypeForm = useForm({ + defaultValues: defaultProgrammingPrototypeFormData, + }); const questionFormData = prototypeForm.watch(); const defaultLockStates = { @@ -196,10 +206,10 @@ const GenerateProgrammingQuestionPage = (): JSX.Element => { // calling getValues() directly returns a "readonly" reference, which can lead to errors // as the object is propagated across various state / handler functions // so instead, these helper functions return a deep copy - const getActiveCodaveriFormData = (): CodaveriGenerateFormData => + const getActiveCodaveriFormData = (): ProgrammingGenerateFormData => JSON.parse(JSON.stringify(codaveriForm.getValues())); - const getActivePrototypeFormData = (): QuestionPrototypeFormData => + const getActivePrototypeFormData = (): ProgrammingPrototypeFormData => JSON.parse(JSON.stringify(prototypeForm.getValues())); const saveActiveFormData = (): void => { @@ -215,8 +225,15 @@ const GenerateProgrammingQuestionPage = (): JSX.Element => { const switchToConversation = (conversation: ConversationState): void => { saveActiveFormData(); const snapshot = conversation.snapshots?.[conversation.activeSnapshotId]; - let languageId = snapshot?.codaveriData?.languageId ?? 0; - if (languageId === 0) languageId = currentLanguageId; + let languageId = 0; + if ( + snapshot?.generateFormData && + 'languageId' in snapshot.generateFormData + ) { + languageId = snapshot.generateFormData.languageId; + } + if (languageId === 0 && typeof currentLanguageId === 'number') + languageId = currentLanguageId; if (snapshot) { dispatch( actions.setActiveConversationId({ conversationId: conversation.id }), @@ -226,14 +243,14 @@ const GenerateProgrammingQuestionPage = (): JSX.Element => { title: conversation.activeSnapshotEditedData.question.title, }), ); - codaveriForm.reset({ ...defaultCodaveriFormData, languageId }); + codaveriForm.reset({ ...defaultProgrammingGenerateFormData, languageId }); prototypeForm.reset(conversation.activeSnapshotEditedData); setLockStates(snapshot.lockStates); } }; const createConversation = (): void => { - dispatch(actions.createConversation()); + dispatch(actions.createConversation({ questionType: 'programming' })); dispatch((_, getState) => { const newState = getAssessmentGenerateQuestionsData(getState()); const newConversationId = @@ -326,7 +343,9 @@ const GenerateProgrammingQuestionPage = (): JSX.Element => { dispatch( actions.setActiveFormTitle({ title: sourceData.question.title }), ); - prototypeForm.reset(buildPrototypeFromQuestionData(sourceData)); + prototypeForm.reset( + buildPrototypeFromProgrammingQuestionData(sourceData), + ); } return ( @@ -363,7 +382,7 @@ const GenerateProgrammingQuestionPage = (): JSX.Element => { {activeConversationSnapshots && activeSnapshotId && latestSnapshotId && ( - ({ @@ -388,8 +407,8 @@ const GenerateProgrammingQuestionPage = (): JSX.Element => { }), ); } - if (snapshot.codaveriData) { - codaveriForm.reset(snapshot.codaveriData); + if (snapshot.generateFormData) { + codaveriForm.reset(snapshot.generateFormData); } if (snapshot.questionData) { prototypeForm.reset(snapshot.questionData); @@ -421,13 +440,13 @@ const GenerateProgrammingQuestionPage = (): JSX.Element => { actions.createSnapshot({ snapshotId: newSnapshotId, parentId: activeSnapshotId, - codaveriData: getActiveCodaveriFormData(), + generateFormData: getActiveCodaveriFormData(), conversationId, lockStates, }), ); return generate( - buildGenerateRequestPayload( + buildProgrammingGenerateRequestPayload( codaveriFormData, questionFormData, isIncludingInlineCode, @@ -510,7 +529,7 @@ const GenerateProgrammingQuestionPage = (): JSX.Element => { ), ); setNotification( - 'An error occured in generating the question.', + 'An error occurred in generating the question.', ); }); }, @@ -521,7 +540,7 @@ const GenerateProgrammingQuestionPage = (): JSX.Element => { - { @@ -537,7 +556,7 @@ const GenerateProgrammingQuestionPage = (): JSX.Element => { - ; - setValue: UseFormSetValue; + control: Control; + setValue: UseFormSetValue; lockStates: LockStates; onToggleLock: (key: string) => void; component?: ElementType; diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/constants.ts b/client/app/bundles/course/assessment/pages/AssessmentGenerate/constants.ts index 18b5bec8235..cd2fcab5eda 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentGenerate/constants.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/constants.ts @@ -1,28 +1,34 @@ import { LanguageMode } from 'types/course/assessment/question/programming'; -import { CodaveriGenerateFormData, QuestionPrototypeFormData } from './types'; +import { + McqMrqGenerateFormData, + McqMrqPrototypeFormData, + ProgrammingGenerateFormData, + ProgrammingPrototypeFormData, +} from './types'; -export const defaultQuestionFormData: QuestionPrototypeFormData = { - question: { - title: '', - description: '', - }, - testUi: { - metadata: { - solution: '', - submission: '', - prepend: null, - append: null, - testCases: { - public: [], - private: [], - evaluation: [], +export const defaultProgrammingPrototypeFormData: ProgrammingPrototypeFormData = + { + question: { + title: '', + description: '', + }, + testUi: { + metadata: { + solution: '', + submission: '', + prepend: null, + append: null, + testCases: { + public: [], + private: [], + evaluation: [], + }, }, }, - }, -}; + }; -export const defaultCodaveriFormData: CodaveriGenerateFormData = { +export const defaultProgrammingGenerateFormData: ProgrammingGenerateFormData = { languageId: 0, customPrompt: '', difficulty: 'easy', @@ -36,3 +42,31 @@ export const CODAVERI_EVALUATOR_ONLY_LANGUAGES: LanguageMode[] = [ 'rust', 'typescript', ]; + +export const defaultMcqMrqGenerateFormData: McqMrqGenerateFormData = { + customPrompt: '', + numberOfQuestions: 1, + generationMode: 'create', +}; + +export const defaultMcqPrototypeFormData: McqMrqPrototypeFormData = { + question: { + title: '', + description: '', + skipGrading: false, + randomizeOptions: false, + }, + options: [], + gradingScheme: 'any_correct', +}; + +export const defaultMrqPrototypeFormData: McqMrqPrototypeFormData = { + question: { + title: '', + description: '', + skipGrading: false, + randomizeOptions: false, + }, + options: [], + gradingScheme: 'all_correct', +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/selectors.ts b/client/app/bundles/course/assessment/pages/AssessmentGenerate/selectors.ts index 519f1ca23d4..c3156e1925c 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentGenerate/selectors.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/selectors.ts @@ -11,10 +11,14 @@ export const getAssessmentGenerateQuestionsData = ( let title: string | undefined; if ( conversation.id === internalState.activeConversationId && - internalState.activeConversationFormTitle && - internalState.activeConversationFormTitle.length > 0 + internalState.activeConversationFormTitle !== undefined ) { - title = internalState.activeConversationFormTitle; + // For active conversation, always use activeConversationFormTitle + // This ensures that when user deletes the title, it shows "Untitled Question" + title = + internalState.activeConversationFormTitle.length > 0 + ? internalState.activeConversationFormTitle + : undefined; } else if ( conversation.activeSnapshotEditedData.question.title.length > 0 ) { diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/types.ts b/client/app/bundles/course/assessment/pages/AssessmentGenerate/types.ts index 6dad404a855..1b489005d25 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentGenerate/types.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/types.ts @@ -1,3 +1,4 @@ +import { OptionEntity } from 'types/course/assessment/question/multiple-responses'; import { LanguageData, MetadataTestCases, @@ -7,13 +8,19 @@ import { const CODAVERI_DIFFICULTIES = ['easy', 'medium', 'hard'] as const; type Difficulty = (typeof CODAVERI_DIFFICULTIES)[number]; -export interface CodaveriGenerateFormData { +export interface ProgrammingGenerateFormData { difficulty: Difficulty; languageId: LanguageData['id']; customPrompt: string; } -export interface QuestionPrototypeFormData { +export interface McqMrqGenerateFormData { + customPrompt: string; + numberOfQuestions: number; + generationMode: 'enhance' | 'create'; +} + +export interface ProgrammingPrototypeFormData { question: { title: string; description: string; @@ -29,6 +36,17 @@ export interface QuestionPrototypeFormData { }; } +export interface McqMrqPrototypeFormData { + question: { + title: string; + description: string; + skipGrading: boolean; + randomizeOptions: boolean; + }; + options: OptionEntity[]; + gradingScheme: 'any_correct' | 'all_correct'; +} + export type LockStates = Record; export interface GenerationState { @@ -59,7 +77,9 @@ export interface ConversationState { snapshots: { [id: string]: SnapshotState }; latestSnapshotId: string; activeSnapshotId: string; - activeSnapshotEditedData: QuestionPrototypeFormData; + activeSnapshotEditedData: + | ProgrammingPrototypeFormData + | McqMrqPrototypeFormData; duplicateFromId?: string; toExport: boolean; exportStatus: ExportStatus; @@ -81,7 +101,7 @@ export interface SnapshotState { id: string; parentId?: string; state: 'generating' | 'success' | 'sentinel'; - codaveriData?: CodaveriGenerateFormData; - questionData?: QuestionPrototypeFormData; + generateFormData?: ProgrammingGenerateFormData | McqMrqGenerateFormData; + questionData?: ProgrammingPrototypeFormData | McqMrqPrototypeFormData; lockStates: LockStates; } diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/utils.ts b/client/app/bundles/course/assessment/pages/AssessmentGenerate/utils.ts index 5a51568c0bb..733bdd966fe 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentGenerate/utils.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/utils.ts @@ -1,3 +1,7 @@ +import { + McqMrqData, + McqMrqFormData, +} from 'types/course/assessment/question/multiple-responses'; import { BasicMetadata, JavaMetadataTestCase, @@ -9,14 +13,23 @@ import { } from 'types/course/assessment/question/programming'; import { CodaveriGenerateResponseData, + McqMrqGeneratedOption, + McqMrqGenerateResponseData, TestcaseVisibility, } from 'types/course/assessment/question-generation'; import { CODAVERI_EVALUATOR_ONLY_LANGUAGES, - defaultQuestionFormData, + defaultMcqPrototypeFormData, + defaultMrqPrototypeFormData, + defaultProgrammingPrototypeFormData, } from './constants'; -import { CodaveriGenerateFormData, QuestionPrototypeFormData } from './types'; +import { + McqMrqGenerateFormData, + McqMrqPrototypeFormData, + ProgrammingGenerateFormData, + ProgrammingPrototypeFormData, +} from './types'; function buildFromExpressionTestCase( visibility: TestcaseVisibility, @@ -60,7 +73,7 @@ function buildTestCases( export function extractQuestionPrototypeData( response: CodaveriGenerateResponseData, -): QuestionPrototypeFormData { +): ProgrammingPrototypeFormData { return { question: { title: response.title, @@ -83,10 +96,10 @@ export function extractQuestionPrototypeData( } export function replaceUnlockedPrototypeFields( - oldData: QuestionPrototypeFormData, - newData: QuestionPrototypeFormData, + oldData: ProgrammingPrototypeFormData, + newData: ProgrammingPrototypeFormData, lockStates: Record, -): QuestionPrototypeFormData { +): ProgrammingPrototypeFormData { return { question: { title: lockStates['question.title'] @@ -146,18 +159,19 @@ const stringifyTestCases = ( return JSON.stringify(testCaseDict); }; -export const buildGenerateRequestPayload = ( - codaveriData: CodaveriGenerateFormData, - questionData: QuestionPrototypeFormData, +export const buildProgrammingGenerateRequestPayload = ( + generateFormData: ProgrammingGenerateFormData, + questionData: ProgrammingPrototypeFormData, isIncludingInlineCode: boolean, ): FormData => { const data = new FormData(); - const isDefaultQuestionFormData = - JSON.stringify(questionData) === JSON.stringify(defaultQuestionFormData); + const isDefaultProgrammingPrototypeFormData = + JSON.stringify(questionData) === + JSON.stringify(defaultProgrammingPrototypeFormData); data.append( 'is_default_question_form_data', - isDefaultQuestionFormData.toString(), + isDefaultProgrammingPrototypeFormData.toString(), ); if (questionData?.question?.title) { @@ -201,15 +215,15 @@ export const buildGenerateRequestPayload = ( ); } - data.append('custom_prompt', codaveriData.customPrompt); + data.append('custom_prompt', generateFormData.customPrompt); - data.append('language_id', codaveriData.languageId.toString()); - data.append('difficulty', codaveriData.difficulty); + data.append('language_id', generateFormData.languageId.toString()); + data.append('difficulty', generateFormData.difficulty); return data; }; -export const buildQuestionDataFromPrototype = ( - prefilledData: QuestionPrototypeFormData, +export const buildProgrammingQuestionDataFromPrototype = ( + prefilledData: ProgrammingPrototypeFormData, languageId: LanguageData['id'], languageMode: LanguageMode, ): ProgrammingFormRequestData => { @@ -249,9 +263,9 @@ export const buildQuestionDataFromPrototype = ( }; }; -export const buildPrototypeFromQuestionData = ( +export const buildPrototypeFromProgrammingQuestionData = ( questionData: ProgrammingFormData, -): QuestionPrototypeFormData => { +): ProgrammingPrototypeFormData => { return { question: questionData.question, testUi: { @@ -270,3 +284,176 @@ export const buildPrototypeFromQuestionData = ( }, }; }; + +// MCQ and MRQ utility functions +export function extractMcqMrqQuestionPrototypeData( + response: McqMrqGenerateResponseData, + isMultipleChoice: boolean, +): McqMrqPrototypeFormData { + const timestamp = Date.now(); + const options = + response.options && response.options.length > 0 + ? response.options.map( + (option: McqMrqGeneratedOption, index: number) => ({ + id: `option-${timestamp}-${index}`, + option: option.option, + correct: option.correct, + weight: index + 1, + explanation: option.explanation || '', + ignoreRandomization: false, + toBeDeleted: false, + }), + ) + : []; + + return { + question: { + title: response.title, + description: response.description, + skipGrading: false, + randomizeOptions: false, + }, + options, + gradingScheme: isMultipleChoice ? 'any_correct' : 'all_correct', + }; +} + +export function replaceUnlockedMcqMrqPrototypeFields( + oldData: McqMrqPrototypeFormData, + newData: McqMrqPrototypeFormData, + lockStates: Record, +): McqMrqPrototypeFormData { + return { + question: { + title: lockStates['question.title'] + ? oldData.question.title + : newData.question.title, + description: lockStates['question.description'] + ? oldData.question.description + : newData.question.description, + skipGrading: lockStates['question.skipGrading'] + ? oldData.question.skipGrading + : newData.question.skipGrading, + randomizeOptions: lockStates['question.randomizeOptions'] + ? oldData.question.randomizeOptions + : newData.question.randomizeOptions, + }, + options: lockStates['question.options'] ? oldData.options : newData.options, + gradingScheme: lockStates.gradingScheme + ? oldData.gradingScheme + : newData.gradingScheme, + }; +} + +export const buildMcqMrqGenerateRequestPayload = ( + generateFormData: McqMrqGenerateFormData, + prototypeFormData: McqMrqPrototypeFormData, + isMultipleChoice: boolean, +): FormData => { + const data = new FormData(); + + const isDefaultPrototypeFormData = isMultipleChoice + ? JSON.stringify(prototypeFormData) === + JSON.stringify(defaultMcqPrototypeFormData) + : JSON.stringify(prototypeFormData) === + JSON.stringify(defaultMrqPrototypeFormData); + + data.append('question_type', isMultipleChoice ? 'mcq' : 'mrq'); + + data.append( + 'is_default_question_form_data', + isDefaultPrototypeFormData.toString(), + ); + + // If generation mode is 'create', send empty source question data + // If generation mode is 'build', send the current prototype form data + const sourceQuestionData = + generateFormData.generationMode === 'create' + ? { title: '', description: '', options: [] } + : { + title: prototypeFormData?.question?.title || '', + description: prototypeFormData?.question?.description || '', + options: prototypeFormData?.options || [], + }; + + data.append('source_question_data', JSON.stringify(sourceQuestionData)); + + if (prototypeFormData?.question?.title) { + data.append('title', prototypeFormData.question.title); + } + + if (prototypeFormData?.question?.description) { + data.append('description', prototypeFormData.question.description); + } + + if (prototypeFormData?.options?.length > 0) { + data.append('options', JSON.stringify(prototypeFormData.options)); + } + + data.append('custom_prompt', generateFormData.customPrompt); + data.append( + 'number_of_questions', + generateFormData.numberOfQuestions.toString(), + ); + return data; +}; + +export const buildMcqMrqQuestionDataFromPrototype = ( + prefilledData: McqMrqPrototypeFormData, + isCreate: boolean = true, +): McqMrqData => { + // Filter out empty options before sending to backend + const filteredOptions = + prefilledData.options?.filter( + (option) => option.option && option.option.trim().length > 0, + ) || []; + + // For create operations, mark all options as draft so they get new IDs + // For update operations, preserve existing IDs + const processedOptions = isCreate + ? filteredOptions.map((option) => ({ + ...option, + draft: true, + })) + : filteredOptions; + + return { + gradingScheme: prefilledData.gradingScheme, + question: { + title: prefilledData.question.title, + description: prefilledData.question.description, + skipGrading: prefilledData.question.skipGrading, + randomizeOptions: prefilledData.question.randomizeOptions, + maximumGrade: '10.0', + staffOnlyComments: '', + skillIds: [], + }, + options: processedOptions, + }; +}; + +export const buildPrototypeFromMcqMrqQuestionData = ( + questionData: McqMrqFormData, + isMultipleChoice: boolean, +): McqMrqPrototypeFormData => { + const timestamp = Date.now(); + const options = (questionData.options || []).map((option, index) => ({ + ...option, + id: option.id?.toString().startsWith('option-') + ? option.id + : `option-${timestamp}-${index}`, + })); + + return { + question: { + title: questionData.question?.title || '', + description: questionData.question?.description || '', + skipGrading: questionData.question?.skipGrading || false, + randomizeOptions: questionData.question?.randomizeOptions || false, + }, + options, + gradingScheme: + questionData.gradingScheme || + (isMultipleChoice ? 'any_correct' : 'all_correct'), + }; +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx index 3f18d71d846..b46eee73f3e 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx @@ -1,15 +1,13 @@ import { useEffect, useState } from 'react'; -import { AutoFixHigh, InsertDriveFile } from '@mui/icons-material'; +import { InsertDriveFile } from '@mui/icons-material'; import { Alert, - Button, Chip, List, ListItem, ListItemIcon, ListItemText, Paper, - Tooltip, Typography, } from '@mui/material'; import { AssessmentData } from 'types/course/assessment/assessments'; @@ -27,6 +25,7 @@ import translations from '../../translations'; import AssessmentDetails from './AssessmentDetails'; import AssessmentShowHeader from './AssessmentShowHeader'; +import GenerateQuestionMenu from './GenerateQuestionMenu'; import NewQuestionMenu from './NewQuestionMenu'; import QuestionsManager from './QuestionsManager'; import UnavailableAlert from './UnavailableAlert'; @@ -180,26 +179,14 @@ const AssessmentShowPage = (props: AssessmentShowPageProps): JSX.Element => { title={t(translations.questions)} >
- {assessment.newQuestionUrls && ( - - )} - {assessment.generateQuestionUrl && ( - - - - - - )} + {assessment.newQuestionUrls && + assessment.newQuestionUrls.length > 0 && ( + + )} + {assessment.generateQuestionUrls && + assessment.generateQuestionUrls.length > 0 && ( + + )}
{assessment.hasUnautogradableQuestions && ( diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/GenerateQuestionMenu.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/GenerateQuestionMenu.tsx new file mode 100644 index 00000000000..e5645e89fdf --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/GenerateQuestionMenu.tsx @@ -0,0 +1,77 @@ +import { useRef, useState } from 'react'; +import { AutoFixHigh } from '@mui/icons-material'; +import { Button, Menu, MenuItem, Tooltip } from '@mui/material'; +import { AssessmentData } from 'types/course/assessment/assessments'; +import { QuestionType } from 'types/course/assessment/question'; + +import Link from 'lib/components/core/Link'; +import useTranslation, { Descriptor } from 'lib/hooks/useTranslation'; + +import translations from '../../translations'; + +interface GenerateQuestionMenuProps { + with: NonNullable; +} + +const GenerateQuestionMenu = ( + props: GenerateQuestionMenuProps, +): JSX.Element => { + const { with: generateQuestionUrls } = props; + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const generateButton = useRef(null); + + const handleClose = (): void => setOpen(false); + + const GENERATE_QUESTION_LABELS: Record< + keyof typeof QuestionType, + Descriptor + > = { + MultipleChoice: translations.multipleChoice, + MultipleResponse: translations.multipleResponse, + TextResponse: translations.textResponse, + VoiceResponse: translations.voiceResponse, + FileUpload: translations.fileUpload, + Programming: translations.programming, + Scribing: translations.scribing, + ForumPostResponse: translations.forumPostResponse, + Comprehension: translations.comprehension, + RubricBasedResponse: translations.rubricBasedResponse, + }; + + return ( + <> + + + + {generateQuestionUrls.map((url) => { + const label = t(GENERATE_QUESTION_LABELS[url.type]); + if (url.type === 'Programming') { + return ( + + + {label} + + + ); + } + return ( + + {label} + + ); + })} + + + ); +}; + +export default GenerateQuestionMenu; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/Question.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/Question.tsx index 2eac0fccce2..a0b7377e457 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/Question.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/Question.tsx @@ -127,7 +127,11 @@ const Question = (props: QuestionProps): JSX.Element => { {question.generateFromUrl && ( { underline="none" > diff --git a/client/app/bundles/course/assessment/question/multiple-responses/components/Option.tsx b/client/app/bundles/course/assessment/question/multiple-responses/components/Option.tsx index 25ddfdc5784..96a810747d5 100644 --- a/client/app/bundles/course/assessment/question/multiple-responses/components/Option.tsx +++ b/client/app/bundles/course/assessment/question/multiple-responses/components/Option.tsx @@ -44,6 +44,11 @@ const Option = forwardRef((props, ref): JSX.Element => { const { t } = useTranslation(); + useEffect(() => { + // Only update if the option ID changed (different option) + if (option.id !== originalOption.id) setOption(originalOption); + }, [originalOption.id]); + useImperativeHandle(ref, () => ({ getOption: () => option, reset: (): void => { diff --git a/client/app/bundles/course/assessment/question/multiple-responses/components/OptionsManager.tsx b/client/app/bundles/course/assessment/question/multiple-responses/components/OptionsManager.tsx index d4359ec2b6a..66bff1f126f 100644 --- a/client/app/bundles/course/assessment/question/multiple-responses/components/OptionsManager.tsx +++ b/client/app/bundles/course/assessment/question/multiple-responses/components/OptionsManager.tsx @@ -34,6 +34,7 @@ export interface OptionsManagerRef { reset: () => void; setErrors: (errors: OptionsErrors) => void; resetErrors: () => void; + updateOptions: (newOptions: OptionEntity[]) => void; } const OptionsManager = forwardRef( @@ -46,6 +47,11 @@ const OptionsManager = forwardRef( const { isDirty, mark, marker, reset } = useDirty(); const [error, setError] = useState(); + // Watch for changes to originalOptions and update internal state + useEffect(() => { + setOptions(originalOptions); + }, [originalOptions]); + const idToIndex = useMemo( () => originalOptions.reduce>( @@ -81,6 +87,11 @@ const OptionsManager = forwardRef( optionRefs.current[id]?.setError(optionError); }); }, + updateOptions: (newOptions: OptionEntity[]): void => { + setOptions(newOptions); + // Mark all new options as dirty to trigger the onDirtyChange callback + newOptions.forEach((option) => mark(option.id, true)); + }, })); const isOrderDirty = (currentOptions: OptionEntity[]): boolean => { @@ -113,7 +124,8 @@ const OptionsManager = forwardRef( const addNewOption = (): void => { const count = options.length; - const id = `new-option-${count}`; + const timestamp = Date.now(); + const id = `option-${timestamp}-${count}`; updateOption((draft) => { draft.push({ diff --git a/client/app/bundles/course/assessment/question/multiple-responses/operations.ts b/client/app/bundles/course/assessment/question/multiple-responses/operations.ts index d8145800cb8..17a628e5296 100644 --- a/client/app/bundles/course/assessment/question/multiple-responses/operations.ts +++ b/client/app/bundles/course/assessment/question/multiple-responses/operations.ts @@ -4,9 +4,10 @@ import { McqMrqFormData, McqMrqPostData, } from 'types/course/assessment/question/multiple-responses'; +import { McqMrqGenerateResponse } from 'types/course/assessment/question-generation'; import CourseAPI from 'api/course'; -import { JustRedirect } from 'api/types'; +import { RedirectWithEditUrl } from 'api/types'; export const fetchNewMrq = async (): Promise> => { const response = await CourseAPI.assessment.question.mcqMrq.fetchNewMrq(); @@ -47,13 +48,17 @@ const adaptPostData = (data: McqMrqData): McqMrqPostData => ({ }, }); -export const create = async (data: McqMrqData): Promise => { +export const updateMcqMrq = async ( + id: number, + data: McqMrqData, +): Promise => { const adaptedData = adaptPostData(data); try { - const response = - await CourseAPI.assessment.question.mcqMrq.create(adaptedData); - + const response = await CourseAPI.assessment.question.mcqMrq.update( + id, + adaptedData, + ); return response.data; } catch (error) { if (error instanceof AxiosError) throw error.response?.data?.errors; @@ -61,17 +66,14 @@ export const create = async (data: McqMrqData): Promise => { } }; -export const updateMcqMrq = async ( - id: number, +export const create = async ( data: McqMrqData, -): Promise => { +): Promise => { const adaptedData = adaptPostData(data); try { - const response = await CourseAPI.assessment.question.mcqMrq.update( - id, - adaptedData, - ); + const response = + await CourseAPI.assessment.question.mcqMrq.create(adaptedData); return response.data; } catch (error) { @@ -79,3 +81,15 @@ export const updateMcqMrq = async ( throw error; } }; + +export const generate = async ( + data: FormData, +): Promise => { + try { + const response = await CourseAPI.assessment.question.mcqMrq.generate(data); + return response.data; + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } +}; diff --git a/client/app/bundles/course/assessment/reducers/generation.ts b/client/app/bundles/course/assessment/reducers/generation.ts index 44b18f54378..39ee0a51cac 100644 --- a/client/app/bundles/course/assessment/reducers/generation.ts +++ b/client/app/bundles/course/assessment/reducers/generation.ts @@ -5,55 +5,125 @@ import { } from 'types/course/assessment/question/programming'; import { - CodaveriGenerateFormData, GenerationState, LockStates, - QuestionPrototypeFormData, + McqMrqGenerateFormData, + McqMrqPrototypeFormData, + ProgrammingGenerateFormData, + ProgrammingPrototypeFormData, SnapshotState, } from '../pages/AssessmentGenerate/types'; const generateConversationId = (): string => Date.now().toString(16); const generateSnapshotId = (): string => Date.now().toString(16); -const sentinelSnapshot = (): SnapshotState => ({ - id: generateSnapshotId(), - parentId: undefined, - state: 'sentinel', // 'generating' | 'success' | 'sentinel' - codaveriData: { languageId: 0, customPrompt: '', difficulty: 'easy' }, - questionData: { - question: { - title: '', - description: '', - }, - testUi: { - metadata: { - solution: '', - submission: '', - prepend: null, - append: null, - testCases: { - public: [], - private: [], - evaluation: [], +const sentinelSnapshot = ( + questionType: 'programming' | 'mrq' | 'mcq', +): SnapshotState => { + switch (questionType) { + case 'mrq': + return { + id: generateSnapshotId(), + parentId: undefined, + state: 'sentinel', + generateFormData: { + customPrompt: '', + numberOfQuestions: 1, + generationMode: 'create', }, - }, - }, - }, - lockStates: { - 'question.title': false, - 'question.description': false, - 'testUi.metadata.solution': false, - 'testUi.metadata.submission': false, - 'testUi.metadata.prepend': false, - 'testUi.metadata.append': false, - 'testUi.metadata.testCases.public': false, - 'testUi.metadata.testCases.private': false, - 'testUi.metadata.testCases.evaluation': false, - }, -}); + questionData: { + question: { + title: '', + description: '', + skipGrading: false, + randomizeOptions: false, + }, + options: [], + gradingScheme: 'all_correct', + }, + lockStates: { + 'question.title': false, + 'question.description': false, + 'question.options': false, + 'question.correct': false, + }, + }; + case 'mcq': + return { + id: generateSnapshotId(), + parentId: undefined, + state: 'sentinel', + generateFormData: { + customPrompt: '', + numberOfQuestions: 1, + generationMode: 'create', + }, + questionData: { + question: { + title: '', + description: '', + skipGrading: false, + randomizeOptions: false, + }, + options: [], + gradingScheme: 'any_correct', + }, + lockStates: { + 'question.title': false, + 'question.description': false, + 'question.options': false, + 'question.correct': false, + }, + }; + case 'programming': + default: + return { + id: generateSnapshotId(), + parentId: undefined, + state: 'sentinel', + generateFormData: { + languageId: 0, + customPrompt: '', + difficulty: 'easy', + }, + questionData: { + question: { + title: '', + description: '', + }, + testUi: { + metadata: { + solution: '', + submission: '', + prepend: null, + append: null, + testCases: { + public: [], + private: [], + evaluation: [], + }, + }, + }, + }, + lockStates: { + 'question.title': false, + 'question.description': false, + 'testUi.metadata.solution': false, + 'testUi.metadata.submission': false, + 'testUi.metadata.prepend': false, + 'testUi.metadata.append': false, + 'testUi.metadata.testCases.public': false, + 'testUi.metadata.testCases.private': false, + 'testUi.metadata.testCases.evaluation': false, + }, + }; + } +}; -const initialState = (): GenerationState => { +const initialState = ( + questionType: 'programming' | 'mrq' | 'mcq' = 'programming', +): GenerationState => { const newConversationId = generateConversationId(); - const snapshot = sentinelSnapshot(); + const snapshot = sentinelSnapshot(questionType); return { activeConversationId: newConversationId, conversations: { @@ -79,6 +149,13 @@ export const generationSlice = createSlice({ name: 'generation', initialState, reducers: { + initializeGeneration: ( + state, + action: PayloadAction<{ questionType: 'programming' | 'mrq' | 'mcq' }>, + ) => { + const newState = initialState(action.payload.questionType); + Object.assign(state, newState); + }, setActiveConversationId: ( state, action: PayloadAction<{ conversationId: string }>, @@ -88,11 +165,16 @@ export const generationSlice = createSlice({ state.activeConversationId = conversationId; } }, - createConversation: (state) => { - const newConversationId = generateConversationId(); - const snapshot = sentinelSnapshot(); - state.conversations[newConversationId] = { - id: newConversationId, + createConversation: ( + state, + action: PayloadAction<{ questionType: 'programming' | 'mrq' | 'mcq' }>, + ) => { + const conversationId = Date.now().toString(16); + const snapshot = sentinelSnapshot(action.payload.questionType); + + state.conversationIds.push(conversationId); + state.conversations[conversationId] = { + id: conversationId, snapshots: { [snapshot.id]: snapshot, }, @@ -101,10 +183,13 @@ export const generationSlice = createSlice({ activeSnapshotEditedData: JSON.parse( JSON.stringify(snapshot.questionData), ), - toExport: true, + toExport: false, exportStatus: 'none', }; - state.conversationIds.push(newConversationId); + + if (state.conversationIds.length === 1) { + state.activeConversationId = conversationId; + } }, duplicateConversation: ( state, @@ -152,21 +237,26 @@ export const generationSlice = createSlice({ state, action: PayloadAction<{ conversationId: string; - codaveriData: CodaveriGenerateFormData; + generateFormData: ProgrammingGenerateFormData | McqMrqGenerateFormData; snapshotId: string; parentId: string; lockStates: LockStates; }>, ) => { - const { conversationId, codaveriData, snapshotId, parentId, lockStates } = - action.payload; + const { + conversationId, + generateFormData, + snapshotId, + parentId, + lockStates, + } = action.payload; const conversation = state.conversations[conversationId]; if (conversation) { conversation.snapshots[snapshotId] = { id: snapshotId, parentId, lockStates, - codaveriData, + generateFormData, state: 'generating', }; } @@ -175,7 +265,7 @@ export const generationSlice = createSlice({ state, action: PayloadAction<{ conversationId: string; - questionData: QuestionPrototypeFormData; + questionData: ProgrammingPrototypeFormData | McqMrqPrototypeFormData; snapshotId: string; }>, ) => { @@ -185,6 +275,7 @@ export const generationSlice = createSlice({ conversation.snapshots[snapshotId].questionData = questionData; conversation.snapshots[snapshotId].state = 'success'; conversation.latestSnapshotId = snapshotId; + conversation.toExport = true; } }, snapshotError: ( @@ -205,7 +296,7 @@ export const generationSlice = createSlice({ action: PayloadAction<{ conversationId: string; snapshotId: string; - questionData?: QuestionPrototypeFormData; + questionData?: ProgrammingPrototypeFormData | McqMrqPrototypeFormData; }>, ) => { const { conversationId, snapshotId, questionData } = action.payload; @@ -259,7 +350,7 @@ export const generationSlice = createSlice({ conversation.exportStatus = 'pending'; } }, - exportConversationPendingImport: ( + exportProgrammingConversationPendingImport: ( state, action: PayloadAction<{ conversationId: string; @@ -277,7 +368,7 @@ export const generationSlice = createSlice({ conversation.questionId = data.id ?? conversation.questionId; } }, - exportConversationSuccess: ( + exportProgrammingConversationSuccess: ( state, action: PayloadAction<{ conversationId: string; @@ -296,6 +387,22 @@ export const generationSlice = createSlice({ } } }, + exportMcqMrqConversationSuccess: ( + state, + action: PayloadAction<{ + conversationId: string; + data?: { redirectEditUrl: string }; + }>, + ) => { + const { conversationId, data } = action.payload; + const conversation = state.conversations[conversationId]; + if (conversation) { + conversation.exportStatus = 'exported'; + if (data) { + conversation.redirectEditUrl = data.redirectEditUrl; + } + } + }, exportConversationError: ( state, action: PayloadAction<{ @@ -321,6 +428,36 @@ export const generationSlice = createSlice({ } }); }, + createConversationWithSnapshots: ( + state, + action: PayloadAction<{ + questionType: 'programming' | 'mrq' | 'mcq'; + copiedSnapshots: { [id: string]: SnapshotState }; + latestSnapshotId: string; + activeSnapshotId: string; + activeSnapshotEditedData: + | ProgrammingPrototypeFormData + | McqMrqPrototypeFormData; + }>, + ) => { + const conversationId = Date.now().toString(16); + + // Check if the conversation has actual data (not just sentinel snapshots) + const hasData = Object.values(action.payload.copiedSnapshots).some( + (snapshot) => snapshot.state !== 'sentinel', + ); + + state.conversationIds.push(conversationId); + state.conversations[conversationId] = { + id: conversationId, + snapshots: action.payload.copiedSnapshots, + latestSnapshotId: action.payload.latestSnapshotId, + activeSnapshotId: action.payload.activeSnapshotId, + activeSnapshotEditedData: action.payload.activeSnapshotEditedData, + toExport: hasData, + exportStatus: 'none', + }; + }, }, }); diff --git a/client/app/bundles/course/assessment/submission/reducers/answers.js b/client/app/bundles/course/assessment/submission/reducers/answers.js index 379d1a4dab7..143a58b2dbc 100644 --- a/client/app/bundles/course/assessment/submission/reducers/answers.js +++ b/client/app/bundles/course/assessment/submission/reducers/answers.js @@ -29,7 +29,7 @@ export default function (state = initialState, action) { const clientVersionBE = answerValue.clientVersion; const clientVersionFE = state.clientVersionByAnswerId[answerId]; - // When both client versions are different, it means that race condition has occured + // When both client versions are different, it means that race condition has occurred // i.e. FE answer has been updated (yet to be saved due to debouncing) but BE is returning older result // As such, keep FE answer and do not update the answer fields until the next autosave is triggered if (clientVersionFE !== clientVersionBE) { diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts index 76f7238884e..578d44116f4 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -196,7 +196,7 @@ const translations = defineMessages({ }, generate: { id: 'course.assessment.show.generate', - defaultMessage: 'Generate Programming Questions', + defaultMessage: 'Generate Questions', }, generateTooltip: { id: 'course.assessment.show.generateTooltip', @@ -579,6 +579,10 @@ const translations = defineMessages({ }, generateFromQuestion: { id: 'course.assessment.show.generateFromQuestion', + defaultMessage: 'Generate a similar question with AI', + }, + generateFromProgrammingQuestion: { + id: 'course.assessment.show.generateFromProgrammingQuestion', defaultMessage: 'Generate a similar question with Codaveri AI', }, duplicateToAssessment: { diff --git a/client/app/bundles/course/courses/components/buttons/TodoIgnoreButton.tsx b/client/app/bundles/course/courses/components/buttons/TodoIgnoreButton.tsx index 2111d46e515..62dbea2c8a8 100644 --- a/client/app/bundles/course/courses/components/buttons/TodoIgnoreButton.tsx +++ b/client/app/bundles/course/courses/components/buttons/TodoIgnoreButton.tsx @@ -21,7 +21,7 @@ const translations = defineMessages({ }, ignoreFailure: { id: 'course.courses.TodoIgnoreButton.ignoreFailure', - defaultMessage: 'An error occured', + defaultMessage: 'An error occurred', }, ignoreButtonText: { id: 'course.courses.TodoIgnoreButton.ignore.ignoreButtonText', diff --git a/client/app/bundles/course/courses/components/misc/CourseEnrolOptions.tsx b/client/app/bundles/course/courses/components/misc/CourseEnrolOptions.tsx index 988595a52ce..d7fd983ff9c 100644 --- a/client/app/bundles/course/courses/components/misc/CourseEnrolOptions.tsx +++ b/client/app/bundles/course/courses/components/misc/CourseEnrolOptions.tsx @@ -38,7 +38,7 @@ const translations = defineMessages({ }, requestFailedMessage: { id: 'course.courses.CourseEnrolOptions.requestFailedMessage', - defaultMessage: 'An error occured, please try again later.', + defaultMessage: 'An error occurred, please try again later.', }, }); diff --git a/client/app/routers/course/assessments/questions.tsx b/client/app/routers/course/assessments/questions.tsx index ff43a21acdd..c80e1d55e54 100644 --- a/client/app/routers/course/assessments/questions.tsx +++ b/client/app/routers/course/assessments/questions.tsx @@ -139,6 +139,22 @@ const questionsRouter: Translated = (_) => ({ }; }, }, + { + path: 'generate', + lazy: async (): Promise => { + const GenerateMcqMrqQuestionPage = ( + await import( + /* webpackChunkName: 'GenerateMcqMrqQuestionPage' */ + 'course/assessment/pages/AssessmentGenerate/MultipleResponse/GenerateMcqMrqQuestionPage' + ) + ).default; + + return { + Component: GenerateMcqMrqQuestionPage, + handle: GenerateMcqMrqQuestionPage.handle, + }; + }, + }, { path: ':questionId/edit', lazy: async (): Promise => ({ @@ -241,7 +257,7 @@ const questionsRouter: Translated = (_) => ({ const GenerateProgrammingQuestionPage = ( await import( /* webpackChunkName: 'GenerateProgrammingQuestionPage' */ - 'course/assessment/pages/AssessmentGenerate/GenerateProgrammingQuestionPage' + 'course/assessment/pages/AssessmentGenerate/Programming/GenerateProgrammingQuestionPage' ) ).default; diff --git a/client/app/types/course/assessment/assessments.ts b/client/app/types/course/assessment/assessments.ts index 02cb63b08ce..54e09411113 100644 --- a/client/app/types/course/assessment/assessments.ts +++ b/client/app/types/course/assessment/assessments.ts @@ -86,6 +86,11 @@ interface NewQuestionBuilderData { url: string; } +interface GenerateQuestionBuilderData { + type: keyof typeof QuestionType; + url: string; +} + export interface AssessmentData extends AssessmentActionsData { id: number; title: string; @@ -140,7 +145,7 @@ export interface AssessmentData extends AssessmentActionsData { hasUnautogradableQuestions?: boolean; questions?: QuestionData[]; newQuestionUrls?: NewQuestionBuilderData[]; - generateQuestionUrl?: string; + generateQuestionUrls?: GenerateQuestionBuilderData[]; } export interface UnauthenticatedAssessmentData { diff --git a/client/app/types/course/assessment/question-generation.ts b/client/app/types/course/assessment/question-generation.ts index 466ee59c248..dd9c3124edf 100644 --- a/client/app/types/course/assessment/question-generation.ts +++ b/client/app/types/course/assessment/question-generation.ts @@ -38,3 +38,33 @@ export interface CodaveriGenerateResponseData { visibility: TestcaseVisibility; }[]; } + +export interface McqMrqGenerateResponse { + success: boolean; + message: string; + data: McqMrqGenerateResponseData; +} + +export interface McqMrqGenerateResponseData { + title: string; + description: string; + options: McqMrqGeneratedOption[]; + allQuestions: McqMrqGeneratedQuestion[]; + numberOfQuestions: number; +} + +export interface McqMrqGeneratedQuestion { + title: string; + description: string; + options: McqMrqGeneratedOption[]; +} + +export interface McqMrqGeneratedOption { + id: number; + option: string; + correct: boolean; + weight: number; + explanation: string; + ignoreRandomization: boolean; + toBeDeleted: boolean; +} diff --git a/client/locales/en.json b/client/locales/en.json index a65526fb832..ad625d597d2 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -1406,6 +1406,75 @@ "course.assessment.generation.unlockTooltip": { "defaultMessage": "Unlock to continue editing this section" }, + "course.assessment.generation.mrq.numberOfQuestionsField": { + "defaultMessage": "Number of Questions" + }, + "course.assessment.generation.promptPlaceholder": { + "defaultMessage": "Type something here..." + }, + "course.assessment.generation.generateQuestion": { + "defaultMessage": "Generate" + }, + "course.assessment.generation.showInactive": { + "defaultMessage": "Show inactive items" + }, + "course.assessment.generation.mrq.numberOfQuestionsRange": { + "defaultMessage": "Please enter a number from {min} to {max}" + }, + "course.assessment.generation.enhanceMode": { + "defaultMessage": "Enhance" + }, + "course.assessment.generation.createMode": { + "defaultMessage": "Create New" + }, + "course.assessment.generation.enhanceModeTooltip": { + "defaultMessage": "Build upon your current question" + }, + "course.assessment.generation.createModeTooltip": { + "defaultMessage": "Generate fresh questions from scratch" + }, + "course.assessment.generation.mrq.exportDialogHeader": { + "defaultMessage": "Export Questions ({exportCount} selected)" + }, + "course.assessment.generation.requireNonEmptyOptionError": { + "defaultMessage": "Question must have at least one non-empty option" + }, + "course.assessment.generation.untitledQuestion": { + "defaultMessage": "Untitled Question" + }, + "course.assessment.question.multipleResponses.showOptions": { + "defaultMessage": "Show Options" + }, + "course.assessment.question.multipleResponses.hideOptions": { + "defaultMessage": "Hide Options" + }, + "course.assessment.question.multipleResponses.noOptions": { + "defaultMessage": "No options" + }, + "course.assessment.question.multipleResponses.title": { + "defaultMessage": "Title" + }, + "course.assessment.generation.generateMrqPage": { + "defaultMessage": "Generate Multiple Response Question" + }, + "course.assessment.generation.generateMcqPage": { + "defaultMessage": "Generate Multiple Choice Question" + }, + "course.assessment.generation.generateMultipleSuccess": { + "defaultMessage": "Successfully generated {count} questions!" + }, + "course.assessment.generation.generateSuccess": { + "defaultMessage": "Generation for {title} successful." + }, + "course.assessment.generation.generateError": { + "defaultMessage": "An error occurred generating question {title}." + }, + "course.assessment.generation.loadingSourceError": { + "defaultMessage": "Unable to load source question data." + }, + "course.assessment.generation.allFieldsLocked": { + "defaultMessage": "All fields are locked, so nothing can be generated." + }, "course.assessment.monitoring.alivePresenceHint": { "defaultMessage": "Last heartbeat was received in time." }, @@ -2238,12 +2307,15 @@ "defaultMessage": "Graded test cases" }, "course.assessment.show.generate": { - "defaultMessage": "Generate Programming Questions" + "defaultMessage": "Generate Questions" }, "course.assessment.show.generateTooltip": { "defaultMessage": "Collaborate with Codaveri AI to create questions" }, "course.assessment.show.generateFromQuestion": { + "defaultMessage": "Generate a similar question with AI" + }, + "course.assessment.show.generateFromProgrammingQuestion": { "defaultMessage": "Generate a similar question with Codaveri AI" }, "course.assessment.show.gradingMode": { @@ -3750,7 +3822,7 @@ "defaultMessage": "Your enrol request has been submitted." }, "course.courses.CourseEnrolOptions.requestFailedMessage": { - "defaultMessage": "An error occured, please try again later." + "defaultMessage": "An error occurred, please try again later." }, "course.courses.CourseInvitationCodeForm.codeSubmitFailure": { "defaultMessage": "Your code is incorrect" @@ -3906,7 +3978,7 @@ "defaultMessage": "Ignore" }, "course.courses.TodoIgnoreButton.ignoreFailure": { - "defaultMessage": "An error occured" + "defaultMessage": "An error occurred" }, "course.courses.TodoIgnoreButton.ignoreSuccess": { "defaultMessage": "Pending task successfully ignored" diff --git a/client/locales/ko.json b/client/locales/ko.json index 9c29fde2a3e..80c789331c5 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -1406,6 +1406,75 @@ "course.assessment.generation.unlockTooltip": { "defaultMessage": "이 섹션을 계속 편집하려면 잠금을 해제하세요" }, + "course.assessment.generation.mrq.numberOfQuestionsField": { + "defaultMessage": "문항 수" + }, + "course.assessment.generation.promptPlaceholder": { + "defaultMessage": "여기에 입력하세요..." + }, + "course.assessment.generation.generateQuestion": { + "defaultMessage": "생성" + }, + "course.assessment.generation.showInactive": { + "defaultMessage": "비활성 항목 보기" + }, + "course.assessment.generation.mrq.numberOfQuestionsRange": { + "defaultMessage": "{min}에서 {max} 사이의 숫자를 입력하세요" + }, + "course.assessment.generation.enhanceMode": { + "defaultMessage": "강화" + }, + "course.assessment.generation.createMode": { + "defaultMessage": "새로 만들기" + }, + "course.assessment.generation.enhanceModeTooltip": { + "defaultMessage": "현재 질문을 기반으로 확장합니다" + }, + "course.assessment.generation.createModeTooltip": { + "defaultMessage": "처음부터 새로운 질문을 생성합니다" + }, + "course.assessment.generation.mrq.exportDialogHeader": { + "defaultMessage": "문항 내보내기 ({exportCount}개 선택됨)" + }, + "course.assessment.generation.requireNonEmptyOptionError": { + "defaultMessage": "문항에는 최소 하나의 빈칸이 아닌 선택지가 있어야 합니다" + }, + "course.assessment.generation.untitledQuestion": { + "defaultMessage": "제목 없는 문항" + }, + "course.assessment.question.multipleResponses.showOptions": { + "defaultMessage": "옵션 보기" + }, + "course.assessment.question.multipleResponses.hideOptions": { + "defaultMessage": "옵션 숨기기" + }, + "course.assessment.question.multipleResponses.noOptions": { + "defaultMessage": "옵션 없음" + }, + "course.assessment.question.multipleResponses.title": { + "defaultMessage": "제목" + }, + "course.assessment.generation.generateMrqPage": { + "defaultMessage": "다중 선택 문항 생성" + }, + "course.assessment.generation.generateMcqPage": { + "defaultMessage": "객관식 문항 생성" + }, + "course.assessment.generation.generateMultipleSuccess": { + "defaultMessage": "{count}개의 문항이 성공적으로 생성되었습니다!" + }, + "course.assessment.generation.generateSuccess": { + "defaultMessage": "{title} 생성에 성공했습니다." + }, + "course.assessment.generation.generateError": { + "defaultMessage": "{title} 문항 생성 중 오류가 발생했습니다." + }, + "course.assessment.generation.loadingSourceError": { + "defaultMessage": "원본 문항 데이터를 불러올 수 없습니다." + }, + "course.assessment.generation.allFieldsLocked": { + "defaultMessage": "모든 필드가 잠겨 있어 생성을 진행할 수 없습니다." + }, "course.assessment.liveFeedback.comments": { "defaultMessage": "댓글" }, @@ -2244,12 +2313,15 @@ "defaultMessage": "포럼 게시물 응답" }, "course.assessment.show.generate": { - "defaultMessage": "프로그래밍 문제 생성" + "defaultMessage": "질문 생성" }, "course.assessment.show.generateTooltip": { "defaultMessage": "Codaveri AI와 협업하여 문제를 생성하세요" }, "course.assessment.show.generateFromQuestion": { + "defaultMessage": "AI로 유사한 질문 생성" + }, + "course.assessment.show.generateFromProgrammingQuestion": { "defaultMessage": "Codaveri AI로 유사한 질문 생성" }, "course.assessment.show.gradedTestCases": { diff --git a/client/locales/zh.json b/client/locales/zh.json index 8e32e0ff416..cc34ca675b4 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -1364,6 +1364,75 @@ "course.assessment.generation.unlockTooltip": { "defaultMessage": "解锁以继续编辑此部分" }, + "course.assessment.generation.mrq.numberOfQuestionsField": { + "defaultMessage": "题目数量" + }, + "course.assessment.generation.promptPlaceholder": { + "defaultMessage": "请输入内容..." + }, + "course.assessment.generation.generateQuestion": { + "defaultMessage": "生成" + }, + "course.assessment.generation.showInactive": { + "defaultMessage": "显示未启用项" + }, + "course.assessment.generation.mrq.numberOfQuestionsRange": { + "defaultMessage": "请输入 {min} 到 {max} 之间的数字" + }, + "course.assessment.generation.enhanceMode": { + "defaultMessage": "增强" + }, + "course.assessment.generation.createMode": { + "defaultMessage": "新建" + }, + "course.assessment.generation.enhanceModeTooltip": { + "defaultMessage": "在当前问题的基础上进行构建" + }, + "course.assessment.generation.createModeTooltip": { + "defaultMessage": "从头生成新的问题" + }, + "course.assessment.generation.mrq.exportDialogHeader": { + "defaultMessage": "导出题目(已选择 {exportCount} 项)" + }, + "course.assessment.generation.requireNonEmptyOptionError": { + "defaultMessage": "题目必须至少包含一个非空选项" + }, + "course.assessment.generation.untitledQuestion": { + "defaultMessage": "无标题题目" + }, + "course.assessment.question.multipleResponses.showOptions": { + "defaultMessage": "显示选项" + }, + "course.assessment.question.multipleResponses.hideOptions": { + "defaultMessage": "隐藏选项" + }, + "course.assessment.question.multipleResponses.noOptions": { + "defaultMessage": "无选项" + }, + "course.assessment.question.multipleResponses.title": { + "defaultMessage": "标题" + }, + "course.assessment.generation.generateMrqPage": { + "defaultMessage": "生成多选题" + }, + "course.assessment.generation.generateMcqPage": { + "defaultMessage": "生成单选题" + }, + "course.assessment.generation.generateMultipleSuccess": { + "defaultMessage": "成功生成了 {count} 道题目!" + }, + "course.assessment.generation.generateSuccess": { + "defaultMessage": "{title} 生成成功。" + }, + "course.assessment.generation.generateError": { + "defaultMessage": "生成题目 {title} 时发生错误。" + }, + "course.assessment.generation.loadingSourceError": { + "defaultMessage": "无法加载原始题目信息。" + }, + "course.assessment.generation.allFieldsLocked": { + "defaultMessage": "所有字段都已锁定,无法生成内容。" + }, "course.assessment.monitoring.alivePresenceHint": { "defaultMessage": "及时收到最后一次心跳" }, @@ -2184,12 +2253,15 @@ "defaultMessage": "论坛帖子回复" }, "course.assessment.show.generate": { - "defaultMessage": "生成编程问题" + "defaultMessage": "生成问题" }, "course.assessment.show.generateTooltip": { "defaultMessage": "与 Codaveri AI 合作创建问题" }, "course.assessment.show.generateFromQuestion": { + "defaultMessage": "使用 AI 生成类似问题" + }, + "course.assessment.show.generateFromProgrammingQuestion": { "defaultMessage": "使用 Codaveri AI 生成类似问题" }, "course.assessment.show.gradedTestCases": { diff --git a/config/routes.rb b/config/routes.rb index e49e31c90be..2de24813420 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -246,7 +246,9 @@ end namespace :question do - resources :multiple_responses, only: [:new, :create, :edit, :update, :destroy] + resources :multiple_responses, only: [:new, :create, :edit, :update, :destroy] do + post :generate, on: :collection + end resources :text_responses, only: [:new, :create, :edit, :update, :destroy] resources :rubric_based_responses, only: [:new, :create, :edit, :update, :destroy] resources :programming, only: [:new, :create, :edit, :update, :destroy] do diff --git a/spec/controllers/course/assessment/question/multiple_response_controller_spec.rb b/spec/controllers/course/assessment/question/multiple_response_controller_spec.rb index 5e6df5597e4..600a01f3887 100644 --- a/spec/controllers/course/assessment/question/multiple_response_controller_spec.rb +++ b/spec/controllers/course/assessment/question/multiple_response_controller_spec.rb @@ -17,9 +17,7 @@ before do controller_sign_in(controller, user) - return unless multiple_response - - controller.instance_variable_set(:@multiple_response_question, multiple_response) + controller.instance_variable_set(:@multiple_response_question, multiple_response) if multiple_response end describe '#create' do @@ -43,6 +41,288 @@ end end + describe '#generate' do + let(:valid_params) do + { + course_id: course, + assessment_id: assessment, + custom_prompt: 'Generate questions about mathematics', + number_of_questions: 2, + question_type: 'mrq', + source_question_data: '{"title": "Sample", "description": "Sample desc", "options": []}' + } + end + + let(:mock_generation_service) { instance_double(Course::Assessment::Question::MrqGenerationService) } + let(:mock_generated_questions) do + { + 'questions' => [ + { + 'title' => 'Generated Question 1', + 'description' => 'Description for question 1', + 'options' => [ + { 'option' => 'Option A', 'correct' => true, 'explanation' => 'Correct answer' }, + { 'option' => 'Option B', 'correct' => false, 'explanation' => 'Wrong answer' } + ] + }, + { + 'title' => 'Generated Question 2', + 'description' => 'Description for question 2', + 'options' => [ + { 'option' => 'Option C', 'correct' => true, 'explanation' => 'Correct answer' }, + { 'option' => 'Option D', 'correct' => false, 'explanation' => 'Wrong answer' } + ] + } + ] + } + end + + before do + allow(Course::Assessment::Question::MrqGenerationService).to receive(:new).and_return(mock_generation_service) + allow(mock_generation_service).to receive(:generate_questions).and_return(mock_generated_questions) + end + + context 'with valid parameters' do + it 'generates questions successfully' do + post :generate, params: valid_params + + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response['success']).to be true + expect(json_response['data']['title']).to eq('Generated Question 1') + expect(json_response['data']['description']).to eq('Description for question 1') + expect(json_response['data']['options']).to be_an(Array) + expect(json_response['data']['allQuestions']).to be_an(Array) + expect(json_response['data']['numberOfQuestions']).to eq(2) + end + + it 'calls the generation service with correct parameters' do + expected_params = { + custom_prompt: 'Generate questions about mathematics', + number_of_questions: 2, + question_type: 'mrq', + source_question_data: { 'title' => 'Sample', 'description' => 'Sample desc', 'options' => [] } + } + + expect(Course::Assessment::Question::MrqGenerationService).to receive(:new).with(assessment, expected_params) + expect(mock_generation_service).to receive(:generate_questions) + + post :generate, params: valid_params + end + end + + context 'with invalid parameters' do + it 'returns error when custom_prompt is missing' do + post :generate, params: valid_params.except(:custom_prompt) + + expect(response).to have_http_status(:bad_request) + json_response = JSON.parse(response.body) + expect(json_response['success']).to be false + expect(json_response['message']).to eq('Invalid parameters') + end + + it 'returns error when number_of_questions is less than 1' do + post :generate, params: valid_params.merge(number_of_questions: 0) + + expect(response).to have_http_status(:bad_request) + json_response = JSON.parse(response.body) + expect(json_response['success']).to be false + expect(json_response['message']).to eq('Invalid parameters') + end + + it 'returns error when number_of_questions is greater than 10' do + post :generate, params: valid_params.merge(number_of_questions: 11) + + expect(response).to have_http_status(:bad_request) + json_response = JSON.parse(response.body) + expect(json_response['success']).to be false + expect(json_response['message']).to eq('Invalid parameters') + end + + it 'returns error when question_type is invalid' do + post :generate, params: valid_params.merge(question_type: 'invalid_type') + + expect(response).to have_http_status(:bad_request) + json_response = JSON.parse(response.body) + expect(json_response['success']).to be false + expect(json_response['message']).to eq('Invalid parameters') + end + + it 'accepts valid question types' do + ['mrq', 'mcq'].each do |question_type| + post :generate, params: valid_params.merge(question_type: question_type) + expect(response).to have_http_status(:ok) + end + end + end + + context 'when generation service returns empty questions' do + before do + allow(mock_generation_service).to receive(:generate_questions).and_return({ 'questions' => [] }) + end + + it 'returns error when no questions are generated' do + post :generate, params: valid_params + + expect(response).to have_http_status(:bad_request) + json_response = JSON.parse(response.body) + expect(json_response['success']).to be false + expect(json_response['message']).to eq('No questions were generated') + end + end + + context 'when generation service raises an error' do + before do + allow(mock_generation_service).to receive(:generate_questions).and_raise(StandardError, 'Service error') + end + + it 'handles errors gracefully' do + post :generate, params: valid_params + + expect(response).to have_http_status(:internal_server_error) + json_response = JSON.parse(response.body) + expect(json_response['success']).to be false + expect(json_response['message']).to eq('An error occurred while generating questions') + end + end + + context 'with source_question_data' do + it 'handles valid JSON source_question_data' do + post :generate, params: valid_params + + expect(response).to have_http_status(:ok) + end + + it 'handles invalid JSON source_question_data gracefully' do + post :generate, params: valid_params.merge(source_question_data: 'invalid json') + + expect(response).to have_http_status(:ok) # Should still work with empty source data + end + + it 'handles missing source_question_data' do + post :generate, params: valid_params.except(:source_question_data) + + expect(response).to have_http_status(:ok) + end + end + end + + describe '#validate_generation_params' do + let(:controller_instance) { controller } + + context 'with valid parameters' do + it 'returns true for valid parameters' do + valid_params = { + custom_prompt: 'Valid prompt', + number_of_questions: 5, + question_type: 'mrq' + } + + result = controller_instance.send(:validate_generation_params, valid_params) + expect(result).to be true + end + + it 'accepts mcq question type' do + valid_params = { + custom_prompt: 'Valid prompt', + number_of_questions: 3, + question_type: 'mcq' + } + + result = controller_instance.send(:validate_generation_params, valid_params) + expect(result).to be true + end + + it 'accepts minimum number of questions' do + valid_params = { + custom_prompt: 'Valid prompt', + number_of_questions: 1, + question_type: 'mrq' + } + + result = controller_instance.send(:validate_generation_params, valid_params) + expect(result).to be true + end + + it 'accepts maximum number of questions' do + valid_params = { + custom_prompt: 'Valid prompt', + number_of_questions: 10, + question_type: 'mrq' + } + + result = controller_instance.send(:validate_generation_params, valid_params) + expect(result).to be true + end + end + + context 'with invalid parameters' do + it 'returns false when custom_prompt is missing' do + invalid_params = { + number_of_questions: 5, + question_type: 'mrq' + } + + result = controller_instance.send(:validate_generation_params, invalid_params) + expect(result).to be false + end + + it 'returns false when custom_prompt is empty' do + invalid_params = { + custom_prompt: '', + number_of_questions: 5, + question_type: 'mrq' + } + + result = controller_instance.send(:validate_generation_params, invalid_params) + expect(result).to be false + end + + it 'returns false when number_of_questions is less than 1' do + invalid_params = { + custom_prompt: 'Valid prompt', + number_of_questions: 0, + question_type: 'mrq' + } + + result = controller_instance.send(:validate_generation_params, invalid_params) + expect(result).to be false + end + + it 'returns false when number_of_questions is greater than 10' do + invalid_params = { + custom_prompt: 'Valid prompt', + number_of_questions: 11, + question_type: 'mrq' + } + + result = controller_instance.send(:validate_generation_params, invalid_params) + expect(result).to be false + end + + it 'returns false when question_type is invalid' do + invalid_params = { + custom_prompt: 'Valid prompt', + number_of_questions: 5, + question_type: 'invalid_type' + } + + result = controller_instance.send(:validate_generation_params, invalid_params) + expect(result).to be false + end + + it 'returns false when question_type is missing' do + invalid_params = { + custom_prompt: 'Valid prompt', + number_of_questions: 5 + } + + result = controller_instance.send(:validate_generation_params, invalid_params) + expect(result).to be false + end + end + end + describe '#update' do let(:multiple_response) { immutable_mrq } subject do diff --git a/spec/services/course/assessment/question/mrq_generation_service_spec.rb b/spec/services/course/assessment/question/mrq_generation_service_spec.rb new file mode 100644 index 00000000000..c821f7f0a96 --- /dev/null +++ b/spec/services/course/assessment/question/mrq_generation_service_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Question::MrqGenerationService do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:user) { create(:administrator) } + let(:course) { create(:course, creator: user) } + let(:assessment) { create(:assessment, course: course, creator: user) } + let(:params) do + { + custom_prompt: 'Generate questions about basic mathematics', + number_of_questions: 2, + source_question_data: { + title: 'Sample Question', + description: 'Sample description', + options: [ + { 'option' => 'Option 1', 'correct' => true }, + { 'option' => 'Option 2', 'correct' => false } + ] + } + } + end + + subject { described_class.new(assessment, params) } + + describe '#initialize' do + it 'initializes with the correct assessment and parameters' do + service = described_class.new(assessment, params) + + expect(service.instance_variable_get(:@assessment)).to eq(assessment) + expect(service.instance_variable_get(:@custom_prompt)).to eq('Generate questions about basic mathematics') + expect(service.instance_variable_get(:@number_of_questions)).to eq(2) + expect(service.instance_variable_get(:@source_question_data)).to eq(params[:source_question_data]) + expect(service.instance_variable_get(:@question_type)).to eq('mrq') + end + + it 'sets default values when parameters are missing' do + minimal_params = { custom_prompt: 'Test prompt' } + service = described_class.new(assessment, minimal_params) + + expect(service.instance_variable_get(:@custom_prompt)).to eq('Test prompt') + expect(service.instance_variable_get(:@number_of_questions)).to eq(1) + expect(service.instance_variable_get(:@source_question_data)).to be_nil + expect(service.instance_variable_get(:@question_type)).to eq('mrq') + end + + it 'handles question_type parameter correctly' do + mcq_params = params.merge(question_type: 'mcq') + service = described_class.new(assessment, mcq_params) + + expect(service.instance_variable_get(:@question_type)).to eq('mcq') + end + + it 'converts number_of_questions to integer' do + string_params = params.merge(number_of_questions: '3') + service = described_class.new(assessment, string_params) + + expect(service.instance_variable_get(:@number_of_questions)).to eq(3) + end + + it 'converts custom_prompt to string' do + symbol_params = params.merge(custom_prompt: :test_symbol) + service = described_class.new(assessment, symbol_params) + + expect(service.instance_variable_get(:@custom_prompt)).to eq('test_symbol') + end + end + + describe '#generate_questions' do + before do + described_class.llm = Langchain::LlmStubs::STUBBED_LANGCHAIN_OPENAI + end + + it 'generates questions using the LLM service' do + result = subject.generate_questions + + expect(result).to be_a(Hash) + expect(result['questions']).to be_an(Array) + expect(result['questions'].length).to eq(2) + + result['questions'].each do |question| + expect(question).to have_key('title') + expect(question).to have_key('description') + expect(question).to have_key('options') + expect(question['options']).to be_an(Array) + expect(question['options'].length).to be >= 4 + + question['options'].each do |option| + expect(option).to have_key('option') + expect(option).to have_key('correct') + expect(option['correct']).to be_in([true, false]) + end + end + end + + it 'formats source question options correctly' do + result = subject.generate_questions + + expect(result['questions']).to be_an(Array) + expect(result['questions'].length).to eq(2) + end + + context 'with empty source question data' do + let(:params) do + { + custom_prompt: 'Generate questions about basic mathematics', + number_of_questions: 1, + source_question_data: {} + } + end + + it 'handles empty source data gracefully' do + result = subject.generate_questions + expect(result['questions']).to be_an(Array) + expect(result['questions'].length).to eq(1) + end + end + end + + describe '#format_source_options' do + it 'formats options correctly' do + options = [ + { 'option' => 'First option', 'correct' => true }, + { 'option' => 'Second option', 'correct' => false } + ] + + formatted = subject.send(:format_source_options, options) + expect(formatted).to include('Option 1: First option (Correct: true)') + expect(formatted).to include('Option 2: Second option (Correct: false)') + end + + it 'returns "None" for empty options' do + formatted = subject.send(:format_source_options, []) + expect(formatted).to eq('None') + end + end + end +end diff --git a/spec/support/stubs/langchain/llm_stubs.rb b/spec/support/stubs/langchain/llm_stubs.rb index 7ebccbff23b..0c43ffdb9d2 100644 --- a/spec/support/stubs/langchain/llm_stubs.rb +++ b/spec/support/stubs/langchain/llm_stubs.rb @@ -16,6 +16,8 @@ def chat(messages: [], **_kwargs) # rubocop:disable Metrics/CyclomaticComplexity # add more llm response use cases here as needed if rubric_grading_request?(system_message, user_message) handle_rubric_grading(system_message, user_message) + elsif mrq_generation_request?(system_message, user_message) + handle_mrq_generation(system_message, user_message) elsif output_fixing_request?(system_message, user_message) handle_output_fixing(system_message, user_message) else @@ -29,6 +31,11 @@ def rubric_grading_request?(system_message, _user_message) system_message.include?('rubric') && system_message.include?('grade') end + def mrq_generation_request?(system_message, _user_message) + system_message.include?('multiple response questions') || + system_message.include?('multiple choice questions') + end + def output_fixing_request?(_system_message, user_message) user_message.include?('JSON Schema') end @@ -64,6 +71,54 @@ def handle_rubric_grading(system_message, _user_message) MockChatResponse.new(mock_response.to_json) end + def handle_mrq_generation(_system_message, user_message) + number_match = user_message.match(/EXACTLY (\d+) multiple/) + number_of_questions = number_match ? number_match[1].to_i : 1 + is_mcq = user_message.include?('multiple choice question') + + questions = [] + number_of_questions.times do |i| + question_number = i + 1 + questions << (is_mcq ? build_mock_mcq(question_number) : build_mock_mrq(question_number)) + end + mock_response = { 'questions' => questions } + MockChatResponse.new(mock_response.to_json) + end + + def build_mock_mcq(question_number) + { + 'title' => "Mock generated MCQ Question #{question_number}", + 'description' => "Mock description for multiple choice question #{question_number}.", + 'options' => [ + { 'option' => "Option A for question #{question_number}", 'correct' => true, + 'explanation' => 'This is correct' }, + { 'option' => "Option B for question #{question_number}", 'correct' => false, + 'explanation' => 'This is incorrect' }, + { 'option' => "Option C for question #{question_number}", 'correct' => false, + 'explanation' => 'This is incorrect' }, + { 'option' => "Option D for question #{question_number}", 'correct' => false, + 'explanation' => 'This is incorrect' } + ] + } + end + + def build_mock_mrq(question_number) + { + 'title' => "Mock generated MRQ Question #{question_number}", + 'description' => "Mock description for multiple response question #{question_number}.", + 'options' => [ + { 'option' => "Option A for question #{question_number}", 'correct' => true, + 'explanation' => 'This is correct' }, + { 'option' => "Option B for question #{question_number}", 'correct' => true, + 'explanation' => 'This is also correct' }, + { 'option' => "Option C for question #{question_number}", 'correct' => false, + 'explanation' => 'This is incorrect' }, + { 'option' => "Option D for question #{question_number}", 'correct' => false, + 'explanation' => 'This is also incorrect' } + ] + } + end + def extract_random_criterion(system_message) category_sections = system_message.split(/(?=Category ID: \d+)/).reject(&:empty?)