Skip to content
Snippets Groups Projects
question.php 86.9 KiB
Newer Older
  • Learn to ignore specific revisions
  • // This file is part of Stack - http://stack.maths.ed.ac.uk/
    
    // Stack is free software: you can redistribute it and/or modify
    
    // it under the terms of the GNU General Public License as published by
    // the Free Software Foundation, either version 3 of the License, or
    // (at your option) any later version.
    //
    
    // Stack is distributed in the hope that it will be useful,
    
    // but WITHOUT ANY WARRANTY; without even the implied warranty of
    // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    // GNU General Public License for more details.
    //
    // You should have received a copy of the GNU General Public License
    
    // along with Stack.  If not, see <http://www.gnu.org/licenses/>.
    
     * @package   qtype_stack
     * @copyright 2012 The Open University
     * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
    
    require_once(__DIR__ . '/stack/input/factory.class.php');
    require_once(__DIR__ . '/stack/cas/keyval.class.php');
    
    require_once(__DIR__ . '/stack/cas/castext2/castext2_evaluatable.class.php');
    
    require_once(__DIR__ . '/stack/cas/cassecurity.class.php');
    
    Chris Sangwin's avatar
    Chris Sangwin committed
    require_once($CFG->dirroot . '/question/behaviour/adaptivemultipart/behaviour.php');
    require_once(__DIR__ . '/locallib.php');
    
    require_once(__DIR__ . '/questiontype.php');
    require_once(__DIR__ . '/stack/cas/secure_loader.class.php');
    
    require_once(__DIR__ . '/stack/prt.class.php');
    
    require_once(__DIR__ . '/stack/prt.evaluatable.class.php');
    
    require_once(__DIR__ . '/vle_specific.php');
    
     * Represents a Stack question.
    
     * @copyright 2012 The Open University
     * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
    
    class qtype_stack_question extends question_graded_automatically_with_countback
            implements question_automatically_gradable_with_multiple_parts {
    
        /**
         * @var string STACK specific: Holds the version of the question when it was last saved.
         */
        public $stackversion;
    
    
        /**
         * @var string STACK specific: variables, as authored by the teacher.
         */
        public $questionvariables;
    
        /**
    
    Chris Sangwin's avatar
    Chris Sangwin committed
         * @var string STACK specific: variables, as authored by the teacher.
         */
    
    Chris Sangwin's avatar
    Chris Sangwin committed
        /**
         * @var string STACK specific: allow a question to carry some description/discussion.
         */
        public $questiondescription;
    
        /** @var int one of the FORMAT_... constants */
        public $questiondescriptionformat;
    
    
        /**
         * @var string Any specific feedback for this question. This is displayed
         * in the 'yellow' feedback area of the question. It can contain PRTfeedback
         * tags, but not IEfeedback.
         */
        public $specificfeedback;
    
    
        /** @var int one of the FORMAT_... constants */
    
        public $specificfeedbackformat;
    
    
        /** @var string Feedback that is displayed for any PRT that returns a score of 1. */
    
        public $prtcorrect;
    
        /** @var int one of the FORMAT_... constants */
        public $prtcorrectformat;
    
    
        /** @var string Feedback that is displayed for any PRT that returns a score between 0 and 1. */
    
        public $prtpartiallycorrect;
    
        /** @var int one of the FORMAT_... constants */
        public $prtpartiallycorrectformat;
    
    
        /** @var string Feedback that is displayed for any PRT that returns a score of 0. */
    
        public $prtincorrect;
    
        /** @var int one of the FORMAT_... constants */
        public $prtincorrectformat;
    
    
        /** @var string if set, this is used to control the pseudo-random generation of the seed. */
        public $variantsselectionseed;
    
    
         * @var stack_input[] STACK specific: string name as it appears in the question text => stack_input
    
        public $inputs = array();
    
         * @var stack_potentialresponse_tree_lite[] STACK specific: respones tree number => ...
    
         * @var stack_options STACK specific: question-level options.
    
         * @var int[] of seed values that have been deployed.
    
         */
        public $deployedseeds;
    
    
         * @var int STACK specific: seeds Maxima's random number generator.
    
        public $seed = null;
    
    Tim Hunt's avatar
    Tim Hunt committed
         * @var stack_cas_session2 STACK specific: session of variables.
    
         */
        protected $session;
    
    
         * @var stack_ast_container[] STACK specific: the teacher's answers for each input.
    
        /**
         * @var stack_cas_security the question level common security
         * settings, i.e. forbidden keys and wether units are in play.
         * Note that the security-object is used to enforce read-only
         * identifiers and therefore wether we are dealing with units
         * is important to it, as obviously one should not redefine units.
         */
        private $security;
    
    
         * @var castext2_evaluatable STACK specific: variant specifying castext fragment.
    
        protected $questionnoteinstantiated = null;
    
    Chris Sangwin's avatar
    Chris Sangwin committed
        /**
         * @var castext2_evaluatable STACK specific.
         */
        protected $questiondescriptioninstantiated = null;
    
    
         * @var castext2_evaluatable instantiated version of questiontext.
    
         * Initialised in start_attempt / apply_attempt_state.
    
         */
        public $questiontextinstantiated;
    
        /**
    
         * @var castext2_evaluatable instantiated version of specificfeedback.
    
         * Initialised in start_attempt / apply_attempt_state.
    
         */
        public $specificfeedbackinstantiated;
    
    
         * @var castext2_evaluatable instantiated version of generalfeedback.
         * Init depends of config.
         */
        private $generalfeedbackinstantiated = null;
    
        /**
         * @var castext2_evaluatable instantiated version of prtcorrect.
    
         * Initialised in start_attempt / apply_attempt_state.
    
         * NOTE: used in rederer.php:standard_prt_feedback() in an uncommon way.
    
         * @var castext2_evaluatable instantiated version of prtpartiallycorrect.
    
         * Initialised in start_attempt / apply_attempt_state.
         */
        public $prtpartiallycorrectinstantiated;
    
        /**
    
         * @var castext2_evaluatable instantiated version of prtincorrect.
    
         * Initialised in start_attempt / apply_attempt_state.
         */
        public $prtincorrectinstantiated;
    
    
    Chris Sangwin's avatar
    Chris Sangwin committed
         * @var castext2_processor an accesspoint to the question attempt for
    
         * the castext2 post-processing logic for pluginfile url-writing.
         */
        public $castextprocessor = null;
    
    
        /**
         * @var array Errors generated at runtime.
         * Any errors are stored as the keys to prevent duplicates.  Values are ignored.
         */
        public $runtimeerrors = array();
    
    
        /**
         * The next three fields cache the results of some expensive computations.
    
         * The chache is only valid for a particular response, so we store the current
         * response, so that we can learn the cached information in the result changes.
    
         * See {@link validate_cache()}.
    
         */
        protected $lastresponse = null;
    
    
        /**
         * @var bool like $lastresponse, but for the $acceptvalid argument to {@link validate_cache()}.
         */
        protected $lastacceptvalid = null;
    
    
         * @var stack_input_state[] input name => stack_input_state.
    
         * This caches the results of validate_student_response for $lastresponse.
    
         */
        protected $inputstates = array();
    
        /**
    
         * @var array prt name => result of evaluate_response, if known.
    
        protected $prtresults = array();
    
    
        /**
         * @var array set of expensive to evaluate but static things.
         */
        public $compiledcache = [];
    
    
        /**
         * Make sure the cache is valid for the current response. If not, clear it.
    
         *
         * @param array $response the response.
    
    Chris Sangwin's avatar
    Chris Sangwin committed
         * @param bool $acceptvalid if this is true, then we will grade things even
         * if the corresponding inputs are only VALID, and not SCORE.
    
        protected function validate_cache($response, $acceptvalid = null) {
    
            if (is_null($this->lastresponse)) {
                $this->lastresponse = $response;
    
                $this->lastacceptvalid = $acceptvalid;
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            // We really need the PHP === here, as "0.040" == "0.04", even as strings.
            // See https://stackoverflow.com/questions/80646/ for details.
            if ($this->lastresponse === $response && (
    
                    $this->lastacceptvalid === null || $acceptvalid === null || $this->lastacceptvalid === $acceptvalid)) {
                if ($this->lastacceptvalid === null) {
                    $this->lastacceptvalid = $acceptvalid;
                }
    
                return; // Cache is good.
            }
    
            // Clear the cache.
            $this->lastresponse = $response;
    
            $this->lastacceptvalid = $acceptvalid;
    
            $this->inputstates = array();
            $this->prtresults = array();
        }
    
        /**
         * @return bool do any of the inputs in this question require the student
    
         *      validate the input.
    
         */
        protected function any_inputs_require_validation() {
            foreach ($this->inputs as $name => $input) {
                if ($input->requires_validation()) {
                    return true;
                }
            }
            return false;
        }
    
        public function make_behaviour(question_attempt $qa, $preferredbehaviour) {
    
            if (empty($this->inputs)) {
                return question_engine::make_behaviour('informationitem', $qa, $preferredbehaviour);
            }
    
            if (empty($this->prts)) {
                return question_engine::make_behaviour('manualgraded', $qa, $preferredbehaviour);
            }
    
    
            if (!empty($this->inputs)) {
                foreach ($this->inputs as $input) {
                    if ($input->get_extra_option('manualgraded')) {
                        return question_engine::make_behaviour('manualgraded', $qa, $preferredbehaviour);
                    }
                }
            }
    
    
            if ($preferredbehaviour == 'adaptive' || $preferredbehaviour == 'adaptivenopenalty') {
                return question_engine::make_behaviour('adaptivemultipart', $qa, $preferredbehaviour);
            }
    
    
            if ($preferredbehaviour == 'deferredfeedback' && $this->any_inputs_require_validation()) {
                return question_engine::make_behaviour('dfexplicitvaildate', $qa, $preferredbehaviour);
            }
    
    
            if ($preferredbehaviour == 'deferredcbm' && $this->any_inputs_require_validation()) {
                return question_engine::make_behaviour('dfcbmexplicitvaildate', $qa, $preferredbehaviour);
            }
    
    
            return parent::make_behaviour($qa, $preferredbehaviour);
        }
    
    
        public function start_attempt(question_attempt_step $step, $variant) {
    
            // @codingStandardsIgnoreStart
    
            // Work out the right seed to use.
            if (!is_null($this->seed)) {
    
                // This empty if statement is a hack, but if seed has already been set, then use that.
                // This is used by the questiontestrun.php script to allow non-deployed
    
                // variants to be browsed.
            } else if (!$this->has_random_variants()) {
                // Randomisation not used.
                $this->seed = 1;
            } else if (!empty($this->deployedseeds)) {
                // Question has a fixed number of variants.
                $this->seed = $this->deployedseeds[$variant - 1] + 0;
                // Don't know why this is coming out as a string. + 0 converts to int.
            } else {
                // This question uses completely free randomisation.
                $this->seed = $variant;
            }
    
            // @codingStandardsIgnoreEnd
    
            $step->set_qt_var('_seed', $this->seed);
    
            $this->initialise_question_from_seed();
        }
    
        /**
         * Once we know the random seed, we can initialise all the other parts of the question.
         */
        public function initialise_question_from_seed() {
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            // We can detect a logically faulty question by checking if the cache can
    
            // return anything if it can't then we can simply skip to the output of errors.
            if ($this->get_cached('units') !== null) {
                // Build up the question session out of all the bits that need to go into it.
                // 1. question variables.
                $session = new stack_cas_session2([], $this->options, $this->seed);
    
                // If we are using localisation we should tell the CAS side logic about it.
                // For castext rendering and other tasks.
                if (count($this->get_cached('langs')) > 0) {
                    $ml = new stack_multilang();
                    $selected = $ml->pick_lang($this->get_cached('langs'));
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                    $session->add_statement(new stack_secure_loader('%_STACK_LANG:' .
                        stack_utils::php_string_to_maxima_string($selected), 'language setting'), false);
    
                // Construct the security object. But first units declaration into the session.
                $units = (boolean) $this->get_cached('units');
    
    
                // If we have units we might as well include the units declaration in the session.
                // To simplify authors work and remove the need to call that long function.
                // TODO: Maybe add this to the preable to save lines, but for now documented here.
                if ($units) {
                    $session->add_statement(new stack_secure_loader('stack_unit_si_declare(true)',
                            'automatic unit declaration'), false);
                }
    
    Tim Hunt's avatar
    Tim Hunt committed
    
    
                if ($this->get_cached('preamble-qv') !== null) {
                    $session->add_statement(new stack_secure_loader($this->get_cached('preamble-qv'), 'preamble'));
                }
                // Context variables should be first.
                if ($this->get_cached('contextvariables-qv') !== null) {
    
                    $session->add_statement(new stack_secure_loader($this->get_cached('contextvariables-qv'), '/qv'));
    
                }
                if ($this->get_cached('statement-qv') !== null) {
    
                    $session->add_statement(new stack_secure_loader($this->get_cached('statement-qv'), '/qv'));
    
                // Note that at this phase the security object has no "words".
                // The student's answer may not contain any of the variable names with which
                // the teacher has defined question variables. Otherwise when it is evaluated
                // in a PRT, the student's answer will take these values.   If the teacher defines
                // 'ta' to be the answer, the student could type in 'ta'!  We forbid this.
    
                // TODO: shouldn't we also protect variables used in PRT logic? Feedback vars
                // and so on?
                $forbiddenkeys = array();
                if ($this->get_cached('forbiddenkeys') !== null) {
                    $forbiddenkeys = $this->get_cached('forbiddenkeys');
                }
                $this->security = new stack_cas_security($units, '', '', $forbiddenkeys);
    
                // The session to keep. Note we do not need to reinstantiate the teachers answers.
                $sessiontokeep = new stack_cas_session2($session->get_session(), $this->options, $this->seed);
    
                // 2. correct answer for all inputs.
                foreach ($this->inputs as $name => $input) {
                    $cs = stack_ast_container::make_from_teacher_source($input->get_teacher_answer(),
                            '', $this->security);
                    $this->tas[$name] = $cs;
                    $session->add_statement($cs);
                }
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                // Check for signs of errors.
                if ($this->get_cached('static-castext-strings') === null) {
                    throw new stack_exception(implode('; ', array_keys($this->runtimeerrors)));
                }
    
    
                // 3.0 setup common CASText2 staticreplacer.
                $static = new castext2_static_replacer($this->get_cached('static-castext-strings'));
    
    
                // 3. CAS bits inside the question text.
    
                $questiontext = castext2_evaluatable::make_from_compiled($this->get_cached('castext-qt'), '/qt', $static);
    
                if ($questiontext->requires_evaluation()) {
                    $session->add_statement($questiontext);
                }
    
                // 4. CAS bits inside the specific feedback.
    
                $feedbacktext = castext2_evaluatable::make_from_compiled($this->get_cached('castext-sf'), '/sf', $static);
    
                if ($feedbacktext->requires_evaluation()) {
                    $session->add_statement($feedbacktext);
                }
    
    Tim Hunt's avatar
    Tim Hunt committed
    
    
                // Add the context to the security, needs some unpacking of the cached.
    
                if ($this->get_cached('security-context') === null) {
    
                    $this->security->set_context([]);
                } else {
    
                    $this->security->set_context($this->get_cached('security-context'));
    
                // The session to keep. Note we do not need to reinstantiate the teachers answers.
                $sessiontokeep = new stack_cas_session2($session->get_session(), $this->options, $this->seed);
    
                // 5. CAS bits inside the question note.
    
                $notetext = castext2_evaluatable::make_from_compiled($this->get_cached('castext-qn'), '/qn', $static);
    
                if ($notetext->requires_evaluation()) {
                    $session->add_statement($notetext);
                }
    
                // 6. The standard PRT feedback.
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                $prtcorrect          = castext2_evaluatable::make_from_compiled($this->get_cached('castext-prt-c'),
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                $prtpartiallycorrect = castext2_evaluatable::make_from_compiled($this->get_cached('castext-prt-pc'),
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                $prtincorrect        = castext2_evaluatable::make_from_compiled($this->get_cached('castext-prt-ic'),
    
                if ($prtcorrect->requires_evaluation()) {
                    $session->add_statement($prtcorrect);
                }
                if ($prtpartiallycorrect->requires_evaluation()) {
                    $session->add_statement($prtpartiallycorrect);
                }
                if ($prtincorrect->requires_evaluation()) {
                    $session->add_statement($prtincorrect);
                }
    
                // Now instantiate the session.
                if ($session->get_valid()) {
                    $session->instantiate();
                }
                if ($session->get_errors()) {
                    // In previous versions we threw an exception here.
                    // Upgrade and import stops errors being caught during validation when the question was edited or deployed.
                    // This breaks bulk testing in a nasty way.
                    $this->runtimeerrors[$session->get_errors(true)] = true;
                }
    
                // Finally, store only those values really needed for later.
                $this->questiontextinstantiated        = $questiontext;
                if ($questiontext->get_errors()) {
                    $s = stack_string('runtimefielderr',
                        array('field' => stack_string('questiontext'), 'err' => $questiontext->get_errors()));
                    $this->runtimeerrors[$s] = true;
                }
                $this->specificfeedbackinstantiated    = $feedbacktext;
                if ($feedbacktext->get_errors()) {
                    $s = stack_string('runtimefielderr',
                        array('field' => stack_string('specificfeedback'), 'err' => $feedbacktext->get_errors()));
                    $this->runtimeerrors[$s] = true;
                }
                $this->questionnoteinstantiated        = $notetext;
                if ($notetext->get_errors()) {
                    $s = stack_string('runtimefielderr',
                        array('field' => stack_string('questionnote'), 'err' => $notetext->get_errors()));
                    $this->runtimeerrors[$s] = true;
                }
                $this->prtcorrectinstantiated          = $prtcorrect;
                $this->prtpartiallycorrectinstantiated = $prtpartiallycorrect;
                $this->prtincorrectinstantiated        = $prtincorrect;
                $this->session = $sessiontokeep;
                if ($sessiontokeep->get_errors()) {
                    $s = stack_string('runtimefielderr',
                        array('field' => stack_string('questionvariables'), 'err' => $sessiontokeep->get_errors(true)));
                    $this->runtimeerrors[$s] = true;
                }
    
                // Allow inputs to update themselves based on the model answers.
                $this->adapt_inputs();
    
            if ($this->runtimeerrors) {
    
                // It is quite possible that questions will, legitimately, throw some kind of error.
                // For example, if one of the question variables is 1/0.
                // This should not be a show stopper.
    
                // Something has gone wrong here, and the student will be shown nothing.
                $s = html_writer::tag('span', stack_string('runtimeerror'), array('class' => 'stackruntimeerrror'));
                $errmsg = '';
                foreach ($this->runtimeerrors as $key => $val) {
                    $errmsg .= html_writer::tag('li', $key);
    
                $s .= html_writer::tag('ul', $errmsg);
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                // So we have this logic where a raw string needs to turn to a CASText2 object.
                // As we do not know what it contains we escape it.
    
                $this->questiontextinstantiated = castext2_evaluatable::make_from_source('[[escape]]' . $s . '[[/escape]]', '/qt');
    
                // It is a static string and by calling this we make it look like it was evaluated.
    
                $this->questiontextinstantiated->requires_evaluation();
    
    
                // Do some setup for the features that do not work.
                $this->security = new stack_cas_security();
                $this->tas = [];
                $this->session = new stack_cas_session2([]);
    
        }
    
        public function apply_attempt_state(question_attempt_step $step) {
    
            $this->seed = (int) $step->get_qt_var('_seed');
    
            $this->initialise_question_from_seed();
    
        }
    
        /**
         * Give all the input elements a chance to configure themselves given the
         * teacher's model answers.
         */
        protected function adapt_inputs() {
            foreach ($this->inputs as $name => $input) {
    
                // TODO: again should we give the whole thing to the input.
    
                $teacheranswer = '';
                if ($this->tas[$name]->is_correctly_evaluated()) {
                    $teacheranswer = $this->tas[$name]->get_value();
                }
    
                $input->adapt_to_model_answer($teacheranswer);
    
                if ($this->get_cached('contextvariables-qv') !== null) {
    
                    $input->add_contextsession(new stack_secure_loader($this->get_cached('contextvariables-qv'), '/qv'));
    
        /**
         * Get the cattext for a hint, instantiated within the question's session.
         * @param question_hint $hint the hint.
         * @return stack_cas_text the castext.
         */
    
        public function get_hint_castext(question_hint $hint) {
    
            // TODO: These are not currently cached as compiled fragments, maybe they should be.
    
    
            $hinttext = castext2_evaluatable::make_from_source($hint->hint, 'hint');
    
    
            $session = null;
            if ($this->session === null) {
                $session = new stack_cas_session2([], $this->options, $this->seed);
            } else {
                $session = new stack_cas_session2($this->session->get_session(), $this->options, $this->seed);
            }
    
            if (count($this->get_cached('langs')) > 0) {
                $ml = new stack_multilang();
                $selected = $ml->pick_lang($this->get_cached('langs'));
                $session->add_statement(new stack_secure_loader('%_STACK_LANG:' .
                    stack_utils::php_string_to_maxima_string($selected), 'language setting'), false);
            }
    
            $session->add_statement($hinttext);
            $session->instantiate();
    
    
            if ($hinttext->get_errors()) {
    
                $this->runtimeerrors[$hinttext->get_errors()] = true;
    
            return $hinttext;
    
        /**
         * Get the cattext for the general feedback, instantiated within the question's session.
         * @return stack_cas_text the castext.
         */
        public function get_generalfeedback_castext() {
    
            // Could be that this is instantiated already.
            if ($this->generalfeedbackinstantiated !== null) {
    
                return $this->generalfeedbackinstantiated;
            }
            // We can have a failed question.
            if ($this->get_cached('castext-gf') === null) {
    
                $ct = castext2_evaluatable::make_from_compiled('"Broken question."', '/gf',
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                    new castext2_static_replacer([])); // This mainly for the bulk-test script.
    
                $ct->requires_evaluation(); // Makes it as if it were evaluated.
                return $ct;
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $this->generalfeedbackinstantiated = castext2_evaluatable::make_from_compiled($this->get_cached('castext-gf'),
    
                '/gf', new castext2_static_replacer($this->get_cached('static-castext-strings')));
    
            // Might not require any evaluation anyway.
            if (!$this->generalfeedbackinstantiated->requires_evaluation()) {
                return $this->generalfeedbackinstantiated;
            }
    
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            // Init a session with question-variables and the related details.
    
            $session = new stack_cas_session2([], $this->options, $this->seed);
            if ($this->get_cached('preamble-qv') !== null) {
                $session->add_statement(new stack_secure_loader($this->get_cached('preamble-qv'), 'preamble'));
            }
            if ($this->get_cached('contextvariables-qv') !== null) {
    
                $session->add_statement(new stack_secure_loader($this->get_cached('contextvariables-qv'), '/qv'));
    
            }
            if ($this->get_cached('statement-qv') !== null) {
    
                $session->add_statement(new stack_secure_loader($this->get_cached('statement-qv'), '/qv'));
    
            }
    
            // Then add the general-feedback code.
            $session->add_statement($this->generalfeedbackinstantiated);
            $session->instantiate();
    
            if ($this->generalfeedbackinstantiated->get_errors()) {
                $this->runtimeerrors[$this->generalfeedbackinstantiated->get_errors()] = true;
    
            return $this->generalfeedbackinstantiated;
    
    Chris Sangwin's avatar
    Chris Sangwin committed
        /**
         * Get the cattext for the question description, instantiated within the question's session.
         * @return stack_cas_text the castext.
         */
        public function get_questiondescription_castext() {
            // Could be that this is instantiated already.
            if ($this->questiondescriptioninstantiated !== null) {
                return $this->questiondescriptioninstantiated;
            }
            // We can have a failed question.
            if ($this->get_cached('castext-gf') === null) {
                $ct = castext2_evaluatable::make_from_compiled('"Broken question."', '/gf',
                    new castext2_static_replacer([])); // This mainly for the bulk-test script.
                    $ct->requires_evaluation(); // Makes it as if it were evaluated.
                    return $ct;
            }
    
            $this->questiondescriptioninstantiated = castext2_evaluatable::make_from_compiled($this->get_cached('castext-qd'),
                '/gf', new castext2_static_replacer($this->get_cached('static-castext-strings')));
            // Might not require any evaluation anyway.
            if (!$this->questiondescriptioninstantiated->requires_evaluation()) {
                return $this->questiondescriptioninstantiated;
            }
    
            // Init a session with question-variables and the related details.
            $session = new stack_cas_session2([], $this->options, $this->seed);
            if ($this->get_cached('preamble-qv') !== null) {
                $session->add_statement(new stack_secure_loader($this->get_cached('preamble-qv'), 'preamble'));
            }
            if ($this->get_cached('contextvariables-qv') !== null) {
                $session->add_statement(new stack_secure_loader($this->get_cached('contextvariables-qv'), '/qv'));
            }
            if ($this->get_cached('statement-qv') !== null) {
                $session->add_statement(new stack_secure_loader($this->get_cached('statement-qv'), '/qv'));
            }
    
            // Then add the description code.
            $session->add_statement($this->questiondescriptioninstantiated);
            $session->instantiate();
    
            if ($this->questiondescriptioninstantiated->get_errors()) {
                $this->runtimeerrors[$this->questiondescriptioninstantiated->get_errors()] = true;
            }
    
            return $this->questiondescriptioninstantiated;
        }
    
    
        /**
         * We need to make sure the inputs are displayed in the order in which they
         * occur in the question text. This is not necessarily the order in which they
         * are listed in the array $this->inputs.
         */
    
        public function format_correct_response($qa) {
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $feedback = '';
    
            $inputs = stack_utils::extract_placeholders($this->questiontextinstantiated->get_rendered(), 'input');
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            foreach ($inputs as $name) {
                $input = $this->inputs[$name];
    
                $feedback .= html_writer::tag('p', $input->get_teacher_answer_display($this->tas[$name]->get_dispvalue(),
                        $this->tas[$name]->get_latex()));
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            }
    
            return stack_ouput_castext($feedback);
    
        public function get_expected_data() {
            $expected = array();
    
            foreach ($this->inputs as $input) {
                $expected += $input->get_expected_data();
    
        public function get_question_summary() {
    
            if ($this->questionnoteinstantiated !== null &&
                '' !== $this->questionnoteinstantiated->get_rendered()) {
                return $this->questionnoteinstantiated->get_rendered();
    
            return stack_string('questionnote_missing');
    
        public function summarise_response(array $response) {
    
            // Provide seed information on student's version via the normal moodle quiz report.
            $bits = array('Seed: ' . $this->seed);
    
            foreach ($this->inputs as $name => $input) {
    
                $state = $this->get_input_state($name, $response);
                if (stack_input::BLANK != $state->status) {
    
                    $bits[] = $input->summarise_response($name, $state, $response);
    
            // Add in the answer note for this response.
            foreach ($this->prts as $name => $prt) {
                $state = $this->get_prt_result($name, $response, false);
    
    Matti Harjula's avatar
    Matti Harjula committed
                $note = implode(' | ', array_map('trim', $state->get_answernotes()));
    
                    $score = "# = " . $state->get_score();
                    if ($prt->is_formative()) {
                        $score .= ' [formative]';
                    }
                    $score .= " | ";
    
                if ($state->get_errors()) {
                    $score = '[RUNTIME_ERROR] ' . $score . implode("|", $state->get_errors());
    
                if ($state->get_fverrors()) {
    
                    $score = '[RUNTIME_FV_ERROR] ' . $score . implode("|", $state->get_fverrors()) . ' | ';
    
            return implode('; ', $bits);
    
    Chris Sangwin's avatar
    Chris Sangwin committed
        // Used in reporting - needs to return an array.
    
        public function summarise_response_data(array $response) {
            $bits = array();
            foreach ($this->inputs as $name => $input) {
                $state = $this->get_input_state($name, $response);
                $bits[$name] = $state->status;
            }
            return $bits;
        }
    
    
        public function get_correct_response() {
    
            if ($this->runtimeerrors || $this->get_cached('units') === null) {
                return [];
            }
    
            foreach ($this->inputs as $name => $input) {
    
                $teacheranswer = array_merge($teacheranswer,
    
                        $input->get_correct_response($this->tas[$name]->get_dispvalue()));
    
        }
    
        public function is_same_response(array $prevresponse, array $newresponse) {
    
            foreach ($this->get_expected_data() as $name => $notused) {
    
                if (!question_utils::arrays_same_at_key_missing_is_blank(
    
                        $prevresponse, $newresponse, $name)) {
                    return false;
                }
            }
            return true;
        }
    
    
    Tim Hunt's avatar
    Tim Hunt committed
        public function is_same_response_for_part($index, array $prevresponse, array $newresponse) {
            $previnput = $this->get_prt_input($index, $prevresponse, true);
            $newinput = $this->get_prt_input($index, $newresponse, true);
    
            return $this->is_same_prt_input($index, $previnput, $newinput);
        }
    
    
        /**
         * Get the results of validating one of the input elements.
         * @param string $name the name of one of the input elements.
    
         * @param array $response the response, in Maxima format.
         * @param bool $rawinput the response in raw form. Needs converting to Maxima format by the input.
    
         * @return stack_input_state|string the result of calling validate_student_response() on the input.
    
        public function get_input_state($name, $response, $rawinput=false) {
    
            $this->validate_cache($response, null);
    
            if (array_key_exists($name, $this->inputstates)) {
                return $this->inputstates[$name];
            }
    
    
            $lang = null;
            if ($this->get_cached('langs') !== null && count($this->get_cached('langs')) > 0) {
                $ml = new stack_multilang();
                $lang = $ml->pick_lang($this->get_cached('langs'));
            }
    
    
            // TODO: we should probably give the whole ast_container to the input.
            // Direct access to LaTeX and the AST might be handy.
    
            $teacheranswer = '';
    
            if (array_key_exists($name, $this->tas)) {
                if ($this->tas[$name]->is_correctly_evaluated()) {
                    $teacheranswer = $this->tas[$name]->get_value();
                }
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            if (array_key_exists($name, $this->inputs)) {
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                $qv = [];
                $qv['preamble-qv']         = $this->get_cached('preamble-qv');
                $qv['contextvariables-qv'] = $this->get_cached('contextvariables-qv');
                $qv['statement-qv']        = $this->get_cached('statement-qv');
    
    
                $this->inputstates[$name] = $this->inputs[$name]->validate_student_response(
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                    $response, $this->options, $teacheranswer, $this->security, $rawinput,
    
                    $this->castextprocessor, $qv, $lang);
    
        /**
         * @param array $response the current response being processed.
         * @return boolean whether any of the inputs are blank.
         */
        public function is_any_input_blank(array $response) {
            foreach ($this->inputs as $name => $input) {
                if (stack_input::BLANK == $this->get_input_state($name, $response)->status) {
                    return true;
                }
            }
            return false;
        }
    
    
        public function is_any_part_invalid(array $response) {
    
            // Invalid if any input is invalid, ...
    
            foreach ($this->inputs as $name => $input) {
                if (stack_input::INVALID == $this->get_input_state($name, $response)->status) {
                    return true;
                }
            }
    
    
            // ... or any PRT gives an error.
    
            foreach ($this->prts as $name => $prt) {
                $result = $this->get_prt_result($name, $response, false);
                if ($result->get_errors()) {
    
            return false;
        }
    
    
        public function is_complete_response(array $response) {
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            // If all PRTs are gradable, then the question is complete. Optional inputs may be blank.
    
                // Formative PRTs do not contribute to complete responses.
                if (!$prt->is_formative() && !$this->can_execute_prt($prt, $response, false)) {
    
    
            // If there are no PRTs, then check that all inputs are complete.
            if (!$this->prts) {
                foreach ($this->inputs as $name => $notused) {
                    if (stack_input::SCORE != $this->get_input_state($name, $response)->status) {
                        return false;
                    }
                }
            }
    
    
        }
    
        public function is_gradable_response(array $response) {
    
            // Manually graded answers are always gradable.
            if (!empty($this->inputs)) {
                foreach ($this->inputs as $input) {
                    if ($input->get_extra_option('manualgraded')) {
                        return true;
                    }
                }
            }
    
            // If any PRT is gradable, then we can grade the question.
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $noprts = true;
    
            foreach ($this->prts as $index => $prt) {
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                $noprts = false;
    
                // Whether formative PRTs can be executed is not relevant to gradability.
                if (!$prt->is_formative() && $this->can_execute_prt($prt, $response, true)) {
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            // In the case of no PRTs,  questions are in state "is_gradable" if we have
            // at least one input in the "score" or "valid" state.
            if ($noprts) {
                foreach ($this->inputstates as $key => $inputstate) {
                    if ($inputstate->status == 'score' || $inputstate->status == 'valid') {
                        return true;
                    }
                }
            }
            // Otherwise we are not "is_gradable".
    
        }
    
        public function get_validation_error(array $response) {
    
            if ($this->is_any_part_invalid($response)) {
                // There will already be a more specific validation error displayed.
                return '';
    
            } else if ($this->is_any_input_blank($response)) {
    
                return stack_string('pleaseananswerallparts');
    
                return stack_string('pleasecheckyourinputs');
    
        }
    
        public function grade_response(array $response) {
    
            // If we have one or more notes input which needs manual grading, then mark it as needs grading.
            if (!empty($this->inputs)) {
                foreach ($this->inputs as $input) {
                    if ($input->get_extra_option('manualgraded')) {
                        return question_state::$needsgrading;
                    }
                }
            }
    
            foreach ($this->prts as $name => $prt) {
    
                    $results = $this->get_prt_result($name, $response, true);
                    $fraction += $results->get_fraction();
    
            }
            return array($fraction, question_state::graded_state_for_fraction($fraction));
    
        protected function is_same_prt_input($index, $prtinput1, $prtinput2) {
    
            foreach ($this->get_cached('required')[$this->prts[$index]->get_name()] as $name => $ignore) {
    
                if (!question_utils::arrays_same_at_key_missing_is_blank($prtinput1, $prtinput2, $name)) {
                    return false;
                }
            }
            return true;
        }
    
    
        public function get_parts_and_weights() {
            $weights = array();
            foreach ($this->prts as $index => $prt) {
    
                if (!$prt->is_formative()) {
                    $weights[$index] = $prt->get_value();
                }
    
        public function grade_parts_that_can_be_graded(array $response, array $lastgradedresponses, $finalsubmit) {
            $partresults = array();
    
            // At the moment, this method is not written as efficiently as it might
            // be in terms of caching. For now I will be happy it computes the right score.
            // Once we are confident enough, we can try to optimise.
    
            foreach ($this->prts as $index => $prt) {
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                // Some optimisation now hidden behind this, it will eval all PRTs
    
                // of the question for this input.
    
                $results = $this->get_prt_result($index, $response, $finalsubmit);
    
                if (!$results->is_evaluated()) {
    
                if (!$results->get_valid()) {
    
                    $partresults[$index] = new qbehaviour_adaptivemultipart_part_result($index, null, null, true);
    
                if (array_key_exists($index, $lastgradedresponses)) {
                    $lastresponse = $lastgradedresponses[$index];
                } else {
                    $lastresponse = array();
                }
    
                $lastinput = $this->get_prt_input($index, $lastresponse, $finalsubmit);
                $prtinput = $this->get_prt_input($index, $response, $finalsubmit);
    
                if ($this->is_same_prt_input($index, $lastinput, $prtinput)) {
                    continue;
                }
    
                $partresults[$index] = new qbehaviour_adaptivemultipart_part_result(
    
                        $index, $results->get_score(), $results->get_penalty());
    
        public function compute_final_grade($responses, $totaltries) {
            // This method is used by the interactive behaviour to compute the final
            // grade after all the tries are done.
    
    
            // At the moment, this method is not written as efficiently as it might