Note:

If you want to create a new page for developers, you should create it on the Moodle Developer Resource site.

Question Engine 2:Developing the Multianswer (Cloze) Question Type

From MoodleDocs

Template:Multianswer (Cloze) question

Introduction

This page will describe details about implementing the multianswer ( Cloze) question type in the new question engine code.

The detailed day-to-day interactions between Tim Hunt and me (Pierre Pichet) can be found in MDL-20636 .

Note: This page is a work-in-progress. Feedback and suggested improvements are welcome. Please join the discussion on moodle.org or use the page comments.


The multianswer question type is more an enclosure for subquestions than a real question type as shortanwer or multichoice. The subquestions are enclosed in the multianswer question text using a specific coding i.e

{1:MULTICHOICE:Wrong answer#Feedback for this wrong answer
~=Correct answer#Feedback for correct answer
~%50%Answer that gives half the credit#Feedback for half credit answer}

In the pre engine multianswer the following subquestuons are allowed

  • short answers (SHORTANSWER or SA or MW), case is unimportant,
  • short answers (SHORTANSWER_C or SAC or MWC), case must match,
  • numerical answers (NUMERICAL or NM),
  • multiple choice (MULTICHOICE or MC), represented as a dropdown menu in-line in the text
  • multiple choice (MULTICHOICE_V or MCV), represented a vertical column of radio buttons, or
  • multiple choice (MULTICHOICE_H or MCH), represented as a horizontal row of radio-buttons.

As the new engine allow to store easily more subquestions parameters we decide to implement MULTICHOICE Multi answer and shuffling for multichoice subquestions. So we add the following types

  • multiple choice multi answer (M_MULTICHOICE_V or MMCV), represented a vertical column of radio buttons, or
  • multiple choice multi answer (M_MULTICHOICE_H or MMCH), represented as a horizontal row of radio-buttons

and the shuffled anwers types

  • multiple choice (MULTICHOICE_S or MCS), represented as a dropdown menu in-line in the text
  • multiple choice (MULTICHOICE_V_S or MCVS), represented a vertical column of radio buttons, or
  • multiple choice (MULTICHOICE_H_S or MCHS), represented as a horizontal row of radio-buttons.
  • multiple choice multi answer (M_MULTICHOICE_V_S or MCVS), represented a vertical column of radio buttons, or
  • multiple choice multi answer (M_MULTICHOICE_H_S or MMCHS), represented as a horizontal row of radio-buttons.

This give thirteen variants.

The new engine is built as a single question that is handled by the correponding renderer and the various interaction models (behaviour).

The attempt object has no initial provision for subquestions although match question type could be considered as having subquestions.

We need first a way to identify in the quiz or preview form and in the attempt data, the answers coming from the different subquestions which are themselves identified in the multianswer (Cloze) question by there appearance order in the question text ( i.e 1,2, 3, etc.). The identifier chosen was _sub_subquestion_index giving _sub3-answer parameter for the answer step value of the 3rd subquestion.

Question classes

The question engine initialise_question_instance in question/type/multianswer/questiontype.php is

    protected function initialise_question_instance(question_definition $question, $questiondata) {
 ....
     parent::initialise_question_instance($question, $questiondata);
     foreach($questiondata->options->questions as $key =>$wrapped) {
     $class = "";
     switch ($wrapped->qtype){
        case "shortanswer":$class = "qtype_multianswer_shortanswer_question";
                           ....
                           break;
        case "numerical": $class = "qtype_multianswer_numerical_question";
                           ....
                           break;                                           
        case "multichoice":
             if ($wrapped->options->single) {                                    
                switch ($wrapped->options->layout){
                     case VERTICAL   : 
                     case HORIZONTAL : $class = "qtype_multianswer_multichoice_single_question";
                                       break;
                     default : $class = "qtype_multianswer_multichoice_single_inline_question";
                }
                        
              } else {
                    $class = "qtype_multianswer_multichoice_multi_question";
              }
$question->subquestions[$key]->shuffleanswers handle the shuffling options. 

The 5 classes qtype_multianswer_..._question in addition to the qtype_multianswer_question are defined in question/type/multianswer/question.php.

Renderer classes

With careful design we could limit the renderer function formulation_and_controls() to 4 types although there are 6 renderer classes ( multianswer, shortanswer, numerical, single answer multichoice, single answer multichoice inline, multi answer multichoice ) The main class qtype_multianswer_renderer extends qtype_renderer { whose public function formulation_and_controls() follows the same coding as the function print_question_formulation_and_controls of the question/type/multianswer/questiontype.php

        $qtextremaining = $question->format_questiontext();
 
        // The regex will recognize text snippets of type {#X}
        // where the X can be any text not containg } or white-space characters.
 
        while (ereg('\{#([^[:space:]}]*)}', $qtextremaining, $regs)) {
            $qtextsplits = explode($regs[0], $qtextremaining, 2);
            $result .= $qtextsplits[0];
            $qtextremaining = $qtextsplits[1];
            $positionkey = $regs[1];
       // transfer to the specific subquestion renderer                 
            if (isset($question->subquestions[$positionkey]) &&
                $question->subquestions[$positionkey] != ''){
                   $wrapped = &$question->subquestions[$positionkey];
                   $qout = $wrapped->get_renderer();
                   $qa->subquestionindex = $positionkey ;
                   $result .= $qout->formulation_and_controls($qa,$options);

Shortanswer and numerical use the same function formulation_and_controls().

The multianswer or single answer multiple choice (vertical or horizontal display) use the same function formulation_and_controls().

Back and forth between 'answer' and '_subi_answer'

The QA Question attempt expects to interact with a multianswer question object but has no initial provision to handle subquestions. So all step parameters like 'order' in the case of multichoice questions and 'answer' or 'choice' related to the student answer to a given subquestion has to be identified specifically. We choose to add a prefix _subi- where i is the subquestion index in the question text.

Here an example of the HTML code for a multianswer question which has a shortanswer subquestion (i=1) followed by a single multichoice in-line (i=2).

This question consists of some text with after that you will have to deal with this short answer 
<label>
<input type="text" name="q1037,1__sub1_answer" id="q1037,1__sub1_answer" size="37" />
</label> and a multichoice embedded right here 
 
 <select id="menuq1037,1__sub2_answer" class="select menuq1037,1__sub2_answer" name="q1037,1__sub2_answer" >
  <option value=""> </option>
  <option value="1">Correct answer</option>
  <option value="2">Another wrong answer</option>
  <option value="3">Wrong answer</option>
  <option value="4">Answer that gives half the credit</option>
 </select>

the attempt data will be something like

   [_sub2_order] => 294,293,295,292 giving the answers id  order in the select element

the step data if the student answer Correct response for the shortanswer and select first choice for the multiple choice will be

   [_sub1_answer] => Correct Answer
   [_sub2_answer] => 1

So the renderer.php will need to add

_sub1_
to the name or id of the input text element (i.e. name="q1037,1__sub1-answer" )
_sub2_
to the name or id of the select element (i.e. name="q1037,1__sub2-answer" )

However the multianswer/question.php andmultianswer/renderer.php will need to handle correctly the step data

   [_sub1_answer] => Correct Answer
   [_sub2_answer] => 1

knowing that the shortanswer/question.php expect the step data as

   [answer] => Correct Answer

and multichoice/question.php expect the data as

   [order] => 294,293,295,292 giving the answers id order in the select element

and expect the step data as

   [answer] => 1

or in if multiple answer multiplechoice as

   [choice1] => 1 

Various strategies were used to correctly handle the subquestions data and step data.

  • Universal function
  • Specific step handler
  • The main strategy for multianswer question functions ( i.e. get_correct_response(), is_complete_response() etc.) is to redirect the call to the subquestions and assembling the subquestion results correctly.

However we had sometimes to recode some subquestion functions to handle the data .

Adding _subi_ prefix to 'answer', 'choice, 'order' ...

Adding $fieldid to subquestion classes

In the subquestion we need to add the _subi- prefix so a new parameter public $fieldid = was added to each subquestion classes and initialize in the initialise_question_instance()

    case "shortanswer":$class = "qtype_multianswer_shortanswer_question";
                       $question->subquestions[$key]= new $class();
                       $question->subquestions[$key]->subquestionindex = $key ;
                       $question->subquestions[$key]->fieldid = '_sub' . $key . '_'; 
                       parent::initialise_question_instance($question->subquestions[$key],$wrapped);
                       $question->subquestions[$key]->qtype = $QTYPES['shortanswer'] ;
                       $this->initialise_question_answers($question->subquestions[$key], $wrapped);


Specific attempt_step_subquestion_adapter

The QA Question attempt expects to interact with a multianswer question object but has no initial provision to handle subquestions using the _subi- convention. So Tim create a specific question_attempt_step class .

 For the functions that involve directly the $step, Tim design the
 /**
 * This is an adapter class that wraps a {@link question_attempt_step} and
 * modifies the get/set_*_data methods so that they operate only on the parts
 * that belong to a particular subquestion, as indicated by an extra prefix.
 *
 * @copyright © 2010 The Open University
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
 class question_attempt_step_subquestion_adapter extends question_attempt_step {
    /** @var question_attempt_step the step we are wrapping. */
    protected $realqas;
    /** @var string the exta prefix on fields we work with. */
    protected $extraprefix;

    /**
     * 
     * @param question_attempt_step $realqas the step to wrap.
     * @param unknown_type $extraprefix the extra prefix that is used for date fields.
     */
    public function __construct(question_attempt_step $realqas, $extraprefix) {
        $this->realqas = $realqas;
        $this->extraprefix = $extraprefix;
    }

    /**
     * Add the extra prefix to a field name.
     * @param string $field the plain field name.
     * @return string the field name with the extra bit of prefix added.
     */
    protected function adjust_field_name($field) {
        if (substr($field, 0, 2) == '!_') {
            return '!_' . $this->extraprefix . substr($field, 2);
        } else if (substr($field, 0, 2) == '!') {
            return '!' . $this->extraprefix . substr($field, 1);
        } else if (substr($field, 0, 2) == '_') {
            return '_' . $this->extraprefix . substr($field, 1);
        } else {
            return $this->extraprefix . $field;
        }
    }
 .....

We could then code the function related to init and expected data.

function init_first_step()

   public function init_first_step(question_attempt_step $step) {
    foreach ($this->subquestions as $i => $subq) {
        $substep = new question_attempt_step_subquestion_adapter($step, '_sub' . $i);
        $subq->init_first_step($substep);        
    } 
 }

function init_first_step() for subquestions

This handle well the init_first_step() from already existing questions (shortanswer, numerical and multichoice ) as here with multichoice

    public function init_first_step(question_attempt_step $step) {
        if ($step->has_qt_var('_order')) {
            $this->order = explode(',', $step->get_qt_var('_order'));
        } else {
            $this->order = array_keys($this->answers);
            if ($this->shuffleanswers) {
                shuffle($this->order);
            }
            $step->set_qt_var('_order', implode(',', $this->order));
        }
    }

In the inline select element for multichoice the first select empty element cannot be shuffled so we adapt the code from the match question to obtain

    public function init_first_step(question_attempt_step $step) {
        if ($step->has_qt_var('_order')) {
            $choiceorder = explode(',', $step->get_qt_var('_order'));
        } else {
            $choiceorder = array_keys($this->answers);
            if ($this->shuffleanswers) {
                shuffle($choiceorder);
            }             
            $step->set_qt_var('_order', implode(',', $choiceorder));
        }
        $this->order = array();
        foreach ($choiceorder as $key => $value) {
            $this->order[$key + 1] = $value;
        }
    }

and

        protected function init_order(question_attempt $qa) {
        if (is_null($this->order)) {
            $choiceorder = explode(',', $step->get_qt_var($this->fieldid.'_order'));
            $this->order = array();
            foreach ($choiceorder as $key => $value) {
                $this->order[$key + 1] = $value;
            }            
        }
    }

Adding _subi_ to form elements

In the renderers the input elements are renamed using $fieldid.

   protected function get_input_name(question_attempt $qa, $value) {
       $questiontot = $qa->get_question();
       $subquestion = $questiontot->subquestions[$qa->subquestionindex];       
       return $qa->get_qt_field_name($subquestion->fieldid.'answer');
   }

Decoding _subi- and transfer to subquestions

Question grading

In the interaction model, after a submit from the student, there is a test ( is_same_response()) that controls if there are some new elements that should be managed. For this function as seen upward we were able to design an universal function without refering to the subquestions.

function is_same_response()

    public function is_same_response(array $prevresponse, array $newresponse) {

        if ( $newresponse == '' || count($prevresponse) != count($newresponse)){
            return false ;
        }
        foreach($newresponse as $arraykey => $value){
            if($value == '' ||  ! array_key_exists($arraykey, $prevresponse) 
                || $value !== $prevresponse[$arraykey]){
               return false ;
          }
        }
        return true ;       
    }

If the test is false i.e. something new, then one of the process will be to grade the new responses using is_gradable_response() which generally refers to is_complete_response(). If is_gradable_response() true then grade_response() can be called (shortanswer,numerical and multichoice are all gradable questions).


So we need to remove the _subi_ prefix before submitting the response data to a subquestion function.

function decode_subquestion_responses(array $response)

    /**
    * Split responses into the separate bits for each question.
    *
    */

    public function decode_subquestion_responses(array $response) {
    $subresponses = array();
    foreach ($response as $key => $value) {
     if (preg_match('/^_sub(\d+)_(.*)/', $key, $matches)) {
            $subresponses[$matches[1]][$matches[2]] = $value;
        }
    }
    return $subresponses ;
    }


Here the case for

function is_gradable_response()

 function is_gradable_response(array $response) {
     // Split responses into the separate bits for each question.
    $subresponses = $this->decode_subquestion_responses($response);
    $result = false ;
 
    foreach ($this->subquestions as $i => $subq) {
        if (!empty($subresponses[$i])) {
            $result = $result || $subq->is_gradable_response($subresponses[$i]);
        }
    }
        return $result;
    }

For single answer question type ( shortanswer, numerical and single answer multichoice questions) is_gradable_response() tranfer to is_complete_response().

is_complete_response(array $response)

    public function is_complete_response(array $response) { // used
    // Split responses into the separate bits for each question.
    $subresponses = $this->decode_subquestion_responses($response);
    
    // Now test each subquestion and  combine.
    // return positive is at least one subquestion test positive
    // so that it could be graded at the next step

    $result = false ;
    foreach ($this->subquestions as $i => $subq) {
        if (isset($subresponses[$i])) {   // empty test is bad if 0 is one value 
            if($subq->is_complete_response($subresponses[$i])){
                return true ;
            }
        }
    }
    return $result ;
 }

grade_response(array $response)

Then grade_response(array $response) which uses the same stategy

    public function grade_response(array $response) {
    // Split responses into the separate bits for each question.
    $subresponses = $this->decode_subquestion_responses($response);
    // Now grade each bit and  combine.
    $totfraction = 0 ;
    foreach ($this->subquestions as $i => $subq) { 
        if (!empty($subresponses[$i])) {
            $res = $subq->grade_response($subresponses[$i]); // fraction=>state 
            $subgrade= $res[0]* $subq->defaultmark ;
            $subq->subgrade = $subgrade ;               // store the resulting grade
            $totfraction += $res[0] * $subq->defaultmark/$this->defaultmark ;
        }
    }
        return array($totfraction, question_state::graded_state_for_fraction($totfraction));
    }


Renderer functions

The main function is formulation_and_controls and the main paramater is question_attempt $qa which give access to $qa->question i.e. multianswer question.

However we want to handle subquestion data (answer, response etc.)with the _subi- convention.

 public function formulation_and_controls(question_attempt $qa,
            question_display_options $options) {
        $questiontot = $qa->get_question();
        $subquestion = $questiontot->subquestions[$qa->subquestionindex];
        $answername = $subquestion->field('');
        $currentanswer = $qa->get_last_qt_var($answername);
        // renaming the input element
        $inputname = $qa->get_qt_field_name($answername);
        ....

As correct_response(question_attempt $qa) uses $qa to access question but we need to access subquestions, the function was duplicated accordingly from shortanswer, numerical or multiplechoice renderer.php. Here the multichoice case

    public function correct_response(question_attempt $qa) {
        $questiontot = $qa->get_question();
        $question = $questiontot->subquestions[$qa->subquestionindex];
 
        $right = array();
        foreach ($question->answers as $ans) {
            if ($ans->fraction > 0) {
                $right[] = $question->format_text($ans->answer);
            }
        }
 
        if (!empty($right)) {
                return get_string('correctansweris', 'qtype_multichoice',
                        implode(', ', $right));
             
        }
        return '';
    }