Skip to content

feat(GenerateMcqMrqQuestion): add generate MCQ/MRQ questions with AI #8019

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
114 changes: 114 additions & 0 deletions app/services/course/assessment/question/mrq_generation_service.rb
Original file line number Diff line number Diff line change
@@ -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<Hash>] 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
Original file line number Diff line number Diff line change
@@ -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}"
}
Original file line number Diff line number Diff line change
@@ -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."
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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}"
}
Original file line number Diff line number Diff line change
@@ -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."
}
24 changes: 23 additions & 1 deletion app/views/course/assessment/assessments/show.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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|
Expand Down
Loading