Select Git revision
question.php
-
Chris Sangwin authoredChris Sangwin authored
question.php 86.95 KiB
<?php
// 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/>.
/**
* Stack question definition class.
*
* @package qtype_stack
* @copyright 2012 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
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');
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;
/**
* @var string STACK specific: variables, as authored by the teacher.
*/
public $questionnote;
/**
* @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 => ...
*/
public $prts = array();
/**
* @var stack_options STACK specific: question-level options.
*/
public $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;
/**
* @var stack_cas_session2 STACK specific: session of variables.
*/
protected $session;
/**
* @var stack_ast_container[] STACK specific: the teacher's answers for each input.
*/
private $tas;
/**
* @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;
/**
* @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.
*/
public $prtcorrectinstantiated;
/**
* @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;
/**
* @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()}.
* @var array
*/
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.
* @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;
return;
}
// 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() {
// 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'));
$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);
}
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);
}
// 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);
}
// 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.
$prtcorrect = castext2_evaluatable::make_from_compiled($this->get_cached('castext-prt-c'),
'/pc', $static);
$prtpartiallycorrect = castext2_evaluatable::make_from_compiled($this->get_cached('castext-prt-pc'),
'/pp', $static);
$prtincorrect = castext2_evaluatable::make_from_compiled($this->get_cached('castext-prt-ic'),
'/pi', $static);
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);
// 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',
new castext2_static_replacer([])); // This mainly for the bulk-test script.
$ct->requires_evaluation(); // Makes it as if it were evaluated.
return $ct;
}
$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;
}
// 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;
}
/**
* 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) {
$feedback = '';
$inputs = stack_utils::extract_placeholders($this->questiontextinstantiated->get_rendered(), 'input');
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()));
}
return stack_ouput_castext($feedback);
}
public function get_expected_data() {
$expected = array();
foreach ($this->inputs as $input) {
$expected += $input->get_expected_data();
}
return $expected;
}
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);
$note = implode(' | ', array_map('trim', $state->get_answernotes()));
$score = '';
if (trim($note) == '') {
$note = '!';
} else {
$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()) . ' | ';
}
$bits[] = $name . ": " . $score . $note;
}
return implode('; ', $bits);
}
// 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() {
$teacheranswer = array();
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()));
}
return $teacheranswer;
}
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;
}
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();
}
}
if (array_key_exists($name, $this->inputs)) {
$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(
$response, $this->options, $teacheranswer, $this->security, $rawinput,
$this->castextprocessor, $qv, $lang);
return $this->inputstates[$name];
}
return '';
}
/**
* @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 true;
}
}
return false;
}
public function is_complete_response(array $response) {
// If all PRTs are gradable, then the question is complete. Optional inputs may be blank.
foreach ($this->prts as $prt) {
// Formative PRTs do not contribute to complete responses.
if (!$prt->is_formative() && !$this->can_execute_prt($prt, $response, false)) {
return 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;
}
}
}
return true;
}
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.
$noprts = true;
foreach ($this->prts as $index => $prt) {
$noprts = false;
// Whether formative PRTs can be executed is not relevant to gradability.
if (!$prt->is_formative() && $this->can_execute_prt($prt, $response, true)) {
return true;
}
}
// 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".
return false;
}
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');
} else {
return stack_string('pleasecheckyourinputs');
}
}
public function grade_response(array $response) {
$fraction = 0;
// 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) {
if (!$prt->is_formative()) {
$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();
}
}
return $weights;
}
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) {
// 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()) {
continue;
}
if (!$results->get_valid()) {
$partresults[$index] = new qbehaviour_adaptivemultipart_part_result($index, null, null, true);
continue;
}
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());
}
return $partresults;
}
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
// be in terms of caching. For now I am happy it computes the right score.
// Once we are confident enough, we could try switching the nesting
// of the loops to increase efficiency.
// TODO: switch the nesting, now that the eval is by response and not by PRT.
// Current CAS-cache helps but it is wasted cycles to go to it so many times.
$fraction = 0;
foreach ($this->prts as $index => $prt) {
if ($prt->is_formative()) {
continue;
}
$accumulatedpenalty = 0;
$lastinput = array();
$penaltytoapply = null;
$results = new stdClass();
$results->fraction = 0;
$frac = 0;
foreach ($responses as $response) {
$prtinput = $this->get_prt_input($index, $response, true);
if (!$this->is_same_prt_input($index, $lastinput, $prtinput)) {
$penaltytoapply = $accumulatedpenalty;
$lastinput = $prtinput;
}
if ($this->can_execute_prt($this->prts[$index], $response, true)) {
$results = $this->get_prt_result($index, $response, true);
$accumulatedpenalty += $results->get_fractionalpenalty();
$frac = $results->get_fraction();
}
}
$fraction += max($frac - $penaltytoapply, 0);
}
return $fraction;
}
/**
* Do we have all the necessary inputs to execute one of the potential response trees?
* @param stack_potentialresponse_tree_lite $prt the tree in question.
* @param array $response the response.
* @param bool $acceptvalid if this is true, then we will grade things even
* if the corresponding inputs are only VALID, and not SCORE.
* @return bool can this PRT be executed for that response.
*/
protected function has_necessary_prt_inputs(stack_potentialresponse_tree_lite $prt, $response, $acceptvalid) {
// Some kind of time-time error in the question, so bail here.
if ($this->get_cached('required') === null) {
return false;
}
foreach ($this->get_cached('required')[$prt->get_name()] as $name => $ignore) {
$status = $this->get_input_state($name, $response)->status;
if (!(stack_input::SCORE == $status || ($acceptvalid && stack_input::VALID == $status))) {
return false;
}
}
return true;
}
/**
* Do we have all the necessary inputs to execute one of the potential response trees?
* @param stack_potentialresponse_tree_lite $prt the tree in question.
* @param array $response the response.
* @param bool $acceptvalid if this is true, then we will grade things even
* if the corresponding inputs are only VALID, and not SCORE.
* @return bool can this PRT be executed for that response.
*/
protected function can_execute_prt(stack_potentialresponse_tree_lite $prt, $response, $acceptvalid) {
// The only way to find out is to actually try evaluating it. This calls
// has_necessary_prt_inputs, and then does the computation, which ensures
// there are no CAS errors.
$result = $this->get_prt_result($prt->get_name(), $response, $acceptvalid);
return $result->is_evaluated() && !$result->get_errors();
}
/**
* Extract the input for a given PRT from a full response.
* @param string $index the name of the PRT.
* @param array $response the full response data.
* @param bool $acceptvalid if this is true, then we will grade things even
* if the corresponding inputs are only VALID, and not SCORE.
* @return array the input required by that PRT.
*/
protected function get_prt_input($index, $response, $acceptvalid) {
if (!array_key_exists($index, $this->prts)) {
$msg = '"' . $this->name . '" (' . $this->id . ') seed = ' .
$this->seed . ' and STACK version = ' . $this->stackversion;
throw new stack_exception ("get_prt_input called for PRT " . $index ." which does not exist in question " . $msg);
}
$prt = $this->prts[$index];
$prtinput = array();
foreach ($this->get_cached('required')[$prt->get_name()] as $name => $ignore) {
$state = $this->get_input_state($name, $response);
if (stack_input::SCORE == $state->status || ($acceptvalid && stack_input::VALID == $state->status)) {
$val = $state->contentsmodified;
if ($state->simp === true) {
$val = 'ev(' . $val . ',simp)';
}
$prtinput[$name] = $val;
}
}
return $prtinput;
}
/**
* Evaluate a PRT for a particular response.
* @param string $index the index of the PRT to evaluate.
* @param array $response the response to process.
* @param bool $acceptvalid if this is true, then we will grade things even
* if the corresponding inputs are only VALID, and not SCORE.
* @return prt_evaluatable the result from traversing the prt.
*/
public function get_prt_result($index, $response, $acceptvalid) {
$this->validate_cache($response, $acceptvalid);
if (array_key_exists($index, $this->prtresults)) {
return $this->prtresults[$index];
}
// We can end up with a null prt at this point if we have question tests for a deleted PRT.
// Alternatively we have a question that could not be compiled.
if (!array_key_exists($index, $this->prts) || $this->get_cached('units') === null) {
// Bail here with an empty state to avoid a later exception which prevents question test editing.
return new prt_evaluatable('prt_' . $index . '(???)', 1, new castext2_static_replacer([]), array());
}
// If we do not have inputs for this then no need to continue.
if (!$this->has_necessary_prt_inputs($this->prts[$index], $response, $acceptvalid)) {
$this->prtresults[$index] = new prt_evaluatable($this->get_cached('prt-signature')[$index],
$this->prts[$index]->get_value(),
new castext2_static_replacer($this->get_cached('static-castext-strings')),
$this->get_cached('prt-trace')[$index]);
return $this->prtresults[$index];
}
// First figure out which PRTs can be called.
$prts = [];
$inputs = [];
foreach ($this->prts as $name => $prt) {
if ($this->has_necessary_prt_inputs($prt, $response, $acceptvalid)) {
$prts[$name] = $prt;
$inputs += $this->get_prt_input($name, $response, $acceptvalid);
}
}
// So now we build a session to evaluate all the PRTs.
$session = new stack_cas_session2([], $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);
}
// 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);
}
if ($this->get_cached('preamble-qv') !== null) {
$session->add_statement(new stack_secure_loader($this->get_cached('preamble-qv'), 'preamble'));
}
// Add preamble from PRTs as well.
foreach ($this->get_cached('prt-preamble') as $name => $stmt) {
if (isset($prts[$name])) {
$session->add_statement(new stack_secure_loader($stmt, 'preamble PRT: ' . $name));
}
}
// 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'));
}
// Add contextvars from PRTs as well.
foreach ($this->get_cached('prt-contextvariables') as $name => $stmt) {
if (isset($prts[$name])) {
$session->add_statement(new stack_secure_loader($stmt, 'contextvariables PRT: ' . $name));
}
}
if ($this->get_cached('statement-qv') !== null) {
$session->add_statement(new stack_secure_loader($this->get_cached('statement-qv'), '/qv'));
}
// Then the definitions of the PRT-functions. Note not just statements for a reason.
foreach ($this->get_cached('prt-definition') as $name => $stmt) {
if (isset($prts[$name])) {
$session->add_statement(new stack_secure_loader($stmt, 'definition PRT: ' . $name));
}
}
// Suppress simplification of raw inputs.
$session->add_statement(new stack_secure_loader('simp:false', 'input-simplification'));
// Now push in the input values and the new _INPUT_STRING.
// Note these have been validated in the input system.
$is = '_INPUT_STRING:["stack_map"';
foreach ($inputs as $key => $value) {
$session->add_statement(new stack_secure_loader($key . ':' . $value, 'i/' .
array_search($key, array_keys($this->inputs)) . '/s'));
$is .= ',[' . stack_utils::php_string_to_maxima_string($key) . ',';
if (strpos($value, 'ev(') === 0) { // Unpack the value if we have simp...
$is .= stack_utils::php_string_to_maxima_string(mb_substr($value, 3, -6)) . ']';
} else {
$is .= stack_utils::php_string_to_maxima_string($value) . ']';
}
}
$is .= ']';
$session->add_statement(new stack_secure_loader($is, 'input-strings'));
// Generate, cache and instantiate the results.
foreach ($this->prts as $name => $prt) {
// Put the input string map in the trace.
$trace = array_merge(array($is . '$', '/* ------------------- */'), $this->get_cached('prt-trace')[$name]);
$p = new prt_evaluatable($this->get_cached('prt-signature')[$name],
$prt->get_value(), new castext2_static_replacer($this->get_cached('static-castext-strings')),
$trace);
if (isset($prts[$name])) {
// Always make sure it gets called with simp:false.
$session->add_statement(new stack_secure_loader('simp:false', 'prt-simplification'));
$session->add_statement($p);
}
$this->prtresults[$name] = $p;
}
$session->instantiate();
return $this->prtresults[$index];
}
/**
* For a possibly nested array, replace all the values with $newvalue.
* @param string|array $arrayorscalar input array/value.
* @param mixed $newvalue the new value to set.
* @return string|array array.
*/
protected function set_value_in_nested_arrays($arrayorscalar, $newvalue) {
if (!is_array($arrayorscalar)) {
return $newvalue;
}
$newarray = array();
foreach ($arrayorscalar as $key => $value) {
$newarray[$key] = $this->set_value_in_nested_arrays($value, $newvalue);
}
return $newarray;
}
/**
* Pollute the question's input state and PRT result caches so that each
* input appears to contain the name of the input, and each PRT feedback
* area displays "Feedback from PRT {name}". Naturally, this method should
* only be used for special purposes, namely the tidyquestion.php script.
*/
public function setup_fake_feedback_and_input_validation() {
// Set the cached input stats as if the user types the input name into each box.
foreach ($this->inputstates as $name => $inputstate) {
$this->inputstates[$name] = new stack_input_state(
$inputstate->status, $this->set_value_in_nested_arrays($inputstate->contents, $name),
$inputstate->contentsmodified, $inputstate->contentsdisplayed, $inputstate->errors, $inputstate->note, '');
}
// Set the cached prt results as if the feedback for each PRT was
// "Feedback from PRT {name}".
foreach ($this->prtresults as $name => $prtresult) {
$prtresult->override_feedback(stack_string('feedbackfromprtx', $name));
}
}
/**
* @return bool whether this question uses randomisation.
*/
public function has_random_variants() {
return $this->random_variants_check($this->questionvariables);
}
/**
* @param string Input text (raw keyvals) to check for random functions, or use of stack_seed.
* @return bool Actual test of whether text uses randomisation.
*/
public static function random_variants_check($text) {
return preg_match('~\brand~', $text) || preg_match('~\bmultiselqn~', $text)
|| preg_match('~\bstack_seed~', $text);
}
public function get_num_variants() {
if (!$this->has_random_variants()) {
// This question does not use randomisation. Only declare one variant.
return 1;
}
if (!empty($this->deployedseeds)) {
// Fixed number of deployed variants, declare that.
return count($this->deployedseeds);
}
// Random question without fixed variants. We will use the seed from Moodle raw.
return 1000000;
}
public function get_variants_selection_seed() {
if (!empty($this->variantsselectionseed)) {
return $this->variantsselectionseed;
} else {
return parent::get_variants_selection_seed();
}
}
public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) {
if ($component == 'qtype_stack' && $filearea == 'specificfeedback') {
// Specific feedback files only visibile when the feedback is.
return $options->feedback;
} else if ($component == 'qtype_stack' && in_array($filearea,
array('prtcorrect', 'prtpartiallycorrect', 'prtincorrect'))) {
// This is a bit lax, but anything else is computationally very expensive.
return $options->feedback;
} else if ($component == 'qtype_stack' && in_array($filearea,
array('prtnodefalsefeedback', 'prtnodetruefeedback'))) {
// This is a bit lax, but anything else is computationally very expensive.
return $options->feedback;
} else if ($component == 'question' && $filearea == 'hint') {
return $this->check_hint_file_access($qa, $options, $args);
} else {
return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload);
}
}
public function get_context() {
return context::instance_by_id($this->contextid);
}
protected function has_question_capability($type) {
global $USER;
$context = $this->get_context();
return has_capability("moodle/question:{$type}all", $context) ||
($USER->id == $this->createdby && has_capability("moodle/question:{$type}mine", $context));
}
/* Get the values of all variables which have a key. So, function definitions
* and assignments are ignored by this method. Used to display the values of
* variables used in a question variant. Beware that some functions have side
* effects in Maxima, e.g. orderless. If you use these values you may not get
* the same results as if you recreate the whole session from $this->questionvariables.
*/
public function get_question_session_keyval_representation() {
// After the cached compilation update the session no longer returns these.
// So we will build another session just for this.
// First we replace the compiled statements with the raw keyval statements.
$tmp = $this->session->get_session();
$tmp = array_filter($tmp, function($v) {
return method_exists($v, 'is_correctly_evaluated');
});
$kv = new stack_cas_keyval($this->questionvariables, $this->options, $this->seed);
$kv->get_valid();
$session = $kv->get_session();
$session->add_statements($tmp);
$session->get_valid();
if ($session->get_valid()) {
$session->instantiate();
}
// We always want the values when this method is called.
return $session->get_keyval_representation(true);
}
/**
* Add all the question variables to a give CAS session. This can be used to
* initialise that session, so expressions can be evaluated in the context of
* the question variables.
* @param stack_cas_session2 $session the CAS session to add the question variables to.
*/
public function add_question_vars_to_session(stack_cas_session2 $session) {
// Question vars will always get added to the beginning of whatever session you give.
$this->session->prepend_to_session($session);
}
/**
* Enable the renderer to access the teacher's answer in the session.
* TODO: should we give the whole thing?
* @param string $vname variable name.
*/
public function get_ta_for_input(string $vname): string {
if (isset($this->tas[$vname]) && $this->tas[$vname]->is_correctly_evaluated()) {
return $this->tas[$vname]->get_value();
}
return '';
}
public function classify_response(array $response) {
$classification = array();
foreach ($this->prts as $index => $prt) {
if ($prt->is_formative()) {
continue;
}
if (!$this->can_execute_prt($prt, $response, true)) {
foreach ($prt->get_nodes_summary() as $nodeid => $choices) {
$classification[$index . '-' . $nodeid] = question_classified_response::no_response();
}
continue;
}
$results = $this->get_prt_result($index, $response, true);
$answernotes = implode(' | ', array_map('trim', $results->get_answernotes()));
foreach ($prt->get_nodes_summary() as $nodeid => $choices) {
if (in_array($choices->trueanswernote, $results->get_answernotes())) {
$classification[$index . '-' . $nodeid] = new question_classified_response(
$choices->trueanswernote, $answernotes, $results->get_fraction());
} else if (in_array($choices->falseanswernote, $results->get_answernotes())) {
$classification[$index . '-' . $nodeid] = new question_classified_response(
$choices->falseanswernote, $answernotes, $results->get_fraction());
} else {
$classification[$index . '-' . $nodeid] = question_classified_response::no_response();
}
}
}
return $classification;
}
/**
* Deploy a variant of this question.
* @param int $seed the seed to deploy.
*/
public function deploy_variant($seed) {
$this->qtype->deploy_variant($this->id, $seed);
}
/**
* Un-deploy a variant of this question.
* @param int $seed the seed to un-deploy.
*/
public function undeploy_variant($seed) {
$this->qtype->undeploy_variant($this->id, $seed);
}
/**
* This function is called by the bulk testing script on upgrade.
* This checks if questions use features which have changed.
*/
public function validate_against_stackversion($context) {
$errors = array();
$qfields = array('questiontext', 'questionvariables', 'questionnote', 'questiondescription',
'specificfeedback', 'generalfeedback');
$stackversion = (int) $this->stackversion;
// Things no longer allowed in questions.
$patterns = array(
array('pat' => 'addrow', 'ver' => 2018060601, 'alt' => 'rowadd'),
array('pat' => 'texdecorate', 'ver' => 2018080600),
array('pat' => 'logbase', 'ver' => 2019031300, 'alt' => 'lg')
);
foreach ($patterns as $checkpat) {
if ($stackversion < $checkpat['ver']) {
foreach ($qfields as $field) {
if (strstr($this->$field ?? '', $checkpat['pat'])) {
$a = array('pat' => $checkpat['pat'], 'ver' => $checkpat['ver'], 'qfield' => stack_string($field));
$err = stack_string('stackversionerror', $a);
if (array_key_exists('alt', $checkpat)) {
$err .= ' ' . stack_string('stackversionerroralt', $checkpat['alt']);
}
$errors[] = $err;
}
}
// Look inside the PRT feedback variables. Should probably check the feedback as well.
foreach ($this->prts as $name => $prt) {
$kv = $prt->get_feedbackvariables_keyvals();
if (strstr($kv, $checkpat['pat'])) {
$a = array('pat' => $checkpat['pat'], 'ver' => $checkpat['ver'],
'qfield' => stack_string('feedbackvariables') . ' (' . $name . ')');
$err = stack_string('stackversionerror', $a);
if (array_key_exists('alt', $checkpat)) {
$err .= ' ' . stack_string('stackversionerroralt', $checkpat['alt']);
}
$errors[] = $err;
}
}
}
}
// Mul is no longer supported.
// We don't need to include a date check here because it is not a change in behaviour.
foreach ($this->inputs as $inputname => $input) {
if (!preg_match('/^([a-zA-Z]+|[a-zA-Z]+[0-9a-zA-Z_]*[0-9a-zA-Z]+)$/', $inputname)) {
$errors[] = stack_string('inputnameform', $inputname);
}
$options = $input->get_parameter('options');
if (trim($options ?? '') !== '') {
$options = explode(',', $options);
foreach ($options as $opt) {
$opt = strtolower(trim($opt));
if ($opt === 'mul') {
$errors[] = stack_string('stackversionmulerror');
}
}
}
}
// Look for RexExp answer test which is no longer supported.
foreach ($this->prts as $name => $prt) {
if (array_key_exists('RegExp', $prt->get_answertests())) {
$errors[] = stack_string('stackversionregexp');
}
}
// Check files use match the files in the question.
$fs = get_file_storage();
$pat = '/@@PLUGINFILE@@([^@"])*[\'"]/';
$fields = array('questiontext', 'specificfeedback', 'generalfeedback', 'questiondescription');
foreach ($fields as $field) {
$text = $this->$field;
$filesexpected = preg_match($pat, $text ?? '');
$filesfound = $fs->get_area_files($context->id, 'question', $field, $this->id);
if (!$filesexpected && $filesfound != array()) {
$errors[] = stack_string('stackfileuseerror', stack_string($field));
}
}
// Add in any warnings.
$errors = array_merge($errors, $this->validate_warnings(true));
return implode(' ', $errors);
}
/*
* Unfortunately, "errors" stop a question being saved. So, we have a parallel warning mechanism.
* Warnings need to be addressed but should not stop a question being saved.
*/
public function validate_warnings($errors = false) {
$warnings = array();
// 1. Answer tests which require raw inputs actually have SAns a calculated value.
foreach ($this->prts as $prt) {
foreach ($prt->get_raw_sans_used() as $key => $sans) {
if (!array_key_exists(trim($sans), $this->inputs)) {
$warnings[] = stack_string_error('AT_raw_sans_needed', array('prt' => $key));
}
}
foreach ($prt->get_raw_arguments_used() as $name => $ans) {
$tvalue = trim($ans);
$tvalue = substr($tvalue, strlen($tvalue) - 1);
if ($tvalue === ';') {
$warnings[] = stack_string('nosemicolon') . ':' . $name;
}
}
}
// 2. Check alt-text exists.
// Reminder: previous approach in Oct 2021 tried to use libxml_use_internal_errors, but this was a dead end.
$tocheck = array();
$text = '';
if ($this->questiontextinstantiated !== null) {
$text = trim($this->questiontextinstantiated->get_rendered());
}
if ($text !== '') {
$tocheck[stack_string('questiontext')] = $text;
}
$ct = $this->get_generalfeedback_castext();
$text = trim($ct->get_rendered($this->castextprocessor));
if ($text !== '') {
$tocheck[stack_string('generalfeedback')] = $text;
}
// This is a compromise. We concatinate all nodes and we don't instantiate this!
foreach ($this->prts as $prt) {
$text = trim($prt->get_feedback_test());
if ($text !== '') {
$tocheck[$prt->get_name()] = $text;
}
}
foreach ($tocheck as $field => $text) {
// Replace unprotected & symbols, which happens a lot inside LaTeX equations.
$text = preg_replace("/&(?!\S+;)/", "&", $text);
$missingalt = stack_utils::count_missing_alttext($text);
if ($missingalt > 0) {
$warnings[] = stack_string_error('alttextmissing', array('field' => $field, 'num' => $missingalt));
}
}
// 3. Check for todo blocks.
$tocheck = array();
$fields = array('questiontext', 'specificfeedback', 'generalfeedback', 'questiondescription');
foreach ($fields as $field) {
$tocheck[stack_string($field)] = $this->$field;
}
foreach ($this->prts as $prt) {
$text = trim($prt->get_feedback_test());
if ($text !== '') {
$tocheck[$prt->get_name()] = $text;
}
}
$pat = '/\[\[todo/';
foreach ($tocheck as $field => $text) {
if (preg_match($pat, $text ?? '')) {
$warnings[] = stack_string_error('todowarning', array('field' => $field));
}
}
// 4. Language warning checks.
// Put language warning checks last (see guard clause below).
// Check multi-language versions all have the same languages.
$ml = new stack_multilang();
$qlangs = $ml->languages_used($this->questiontext);
asort($qlangs);
if ($qlangs != array() && !$errors) {
$warnings['questiontext'] = stack_string('questiontextlanguages', implode(', ', $qlangs));
}
// Language tags don't exist.
if ($qlangs == array()) {
return $warnings;
}
$problems = false;
$missinglang = array();
$extralang = array();
$fields = array('specificfeedback', 'generalfeedback');
foreach ($fields as $field) {
$text = $this->$field;
// Strip out feedback tags (to help non-trivial content check)..
foreach ($this->prts as $prt) {
$text = str_replace('[[feedback:' . $prt->get_name() . ']]', '', $text);
}
if ($ml->non_trivial_content_for_check($text)) {
$langs = $ml->languages_used($text);
foreach ($qlangs as $expectedlang) {
if (!in_array($expectedlang, $langs)) {
$problems = true;
$missinglang[$expectedlang][] = stack_string($field);
}
}
foreach ($langs as $lang) {
if (!in_array($lang, $qlangs)) {
$problems = true;
$extralang[stack_string($field)][] = $lang;
}
}
}
}
foreach ($this->prts as $prt) {
foreach ($prt->get_feedback_languages() as $nodes) {
// The nodekey is really the answernote from one branch of the node.
// No actually it is not in the new PRT-system, it's just 'true' or 'false'.
foreach ($nodes as $nodekey => $langs) {
foreach ($qlangs as $expectedlang) {
if (!in_array($expectedlang, $langs)) {
$problems = true;
$missinglang[$expectedlang][] = $nodekey;
}
}
foreach ($langs as $lang) {
if (!in_array($lang, $qlangs)) {
$problems = true;
$extralang[$nodekey][] = $lang;
}
}
}
}
}
if ($problems) {
$warnings[] = stack_string_error('languageproblemsexist');
}
foreach ($missinglang as $lang => $missing) {
$warnings[] = stack_string('languageproblemsmissing',
array('lang' => $lang, 'missing' => implode(', ', $missing)));
}
foreach ($extralang as $field => $langs) {
$warnings[] = stack_string('languageproblemsextra',
array('field' => $field, 'langs' => implode(', ', $langs)));
}
return $warnings;
}
/**
* Cache management.
*
* Returns named items from the cache and rebuilds it if the cache
* has been cleared.
*/
public function get_cached(string $key) {
global $DB;
if ($this->compiledcache !== null && isset($this->compiledcache['FAIL'])) {
// This question failed compilation, no need to try again in this request.
// Make sure the error is back in the error list.
$this->runtimeerrors[$this->compiledcache['FAIL']] = true;
return null;
}
// Do we have that particular thing in the cache?
if ($this->compiledcache === null || !array_key_exists($key, $this->compiledcache)) {
// If not do the compilation.
try {
$this->compiledcache = self::compile($this->id,
$this->questionvariables, $this->inputs, $this->prts,
$this->options, $this->questiontext,
$this->questiontextformat,
$this->questionnote,
$this->generalfeedback, $this->generalfeedbackformat,
$this->specificfeedback, $this->specificfeedbackformat,
$this->questiondescription, $this->questiondescriptionformat,
$this->prtcorrect, $this->prtcorrectformat,
$this->prtpartiallycorrect, $this->prtpartiallycorrectformat,
$this->prtincorrect, $this->prtincorrectformat, $this->penalty);
// Invalidate Moodle question-cache and add there.
if (is_integer($this->id) || is_numeric($this->id)) {
// Save to DB. If the question is there.
// Could not be in some API situations.
$sql = 'UPDATE {qtype_stack_options} SET compiledcache = ? WHERE questionid = ?';
$params[] = json_encode($this->compiledcache);
$params[] = $this->id;
$DB->execute($sql, $params);
// Invalidate the question definition cache.
// First from the next sessions.
stack_clear_vle_question_cache($this->id);
}
} catch (stack_exception $e) {
// TODO: what exactly do we use here as the key
// and what sort of errors does the compilation generate.
// CHRIS: The compilation generates errors that relate to the static validation of
// the question, any such errors are fatal and will be apparent on the first opening
// of the question in bulk tests or elsewhere, silencing them makes no sense.
// These are not runtime errors they are validation errors for materials that should
// not have managed to get through the editor.
$this->runtimeerrors[$e->getMessage()] = true;
$this->compiledcache = ['FAIL' => $e->getMessage()];
}
}
// A runtime error means we don't have the $key in the cache.
// We don't want an error here, we want to degrade gracefully.
$ret = null;
if (is_array($this->compiledcache) && array_key_exists($key, $this->compiledcache)) {
$ret = $this->compiledcache[$key];
}
return $ret;
}
/**
* Helper method for "compiling" a question, validates and finds all the things
* that do not change unless the question changes and stores them in a dictionary.
*
* Note that does throw exceptions about validation details.
*
* Currently the cache contains the following keys:
* 'units' for declaring the units-mode.
* 'forbiddenkeys' for the lsit of those.
* 'contextvariables-qv' the pre-validated question-variables which are context variables.
* 'statement-qv' the pre-validated question-variables.
* 'preamble-qv' the matching blockexternals.
* 'required' the lists of inputs required by given PRTs an array by PRT-name.
* 'castext-qt' for the question-text as compiled CASText2.
* 'castext-qn' for the question-note as compiled CASText2.
* 'castext-...' for the model-solution and prtpartiallycorrect etc.
* 'castext-td-...' for downloadable generated text content.
* 'security-context' mainly lists keys that are student inputs.
* 'prt-*' the compiled PRT-logics in an array. Divided by usage.
* 'langs' a list of language codes used in this question.
*
* In the future expect the following:
* 'security-config' extended logic for cas-security, e.g. custom-units.
*
* @param int the identifier of this question fot use if we have pluginfiles
* @param string the questionvariables
* @param array inputs as objects, keyed by input name
* @param array PRTs as objects
* @param stack_options the options in use, if they would ever matter
* @param string question-text
* @param string question-text format
* @param string question-note
* @param string general-feedback
* @param string general-feedback format...
* @param defaultpenalty
* @return array a dictionary of things that might be expensive to generate.
*/
public static function compile($id, $questionvariables, $inputs, $prts, $options,
$questiontext, $questiontextformat,
$questionnote,
$generalfeedback, $generalfeedbackformat,
$specificfeedback, $specificfeedbackformat,
$questiondescription, $questiondescriptionformat,
$prtcorrect, $prtcorrectformat,
$prtpartiallycorrect, $prtpartiallycorrectformat,
$prtincorrect, $prtincorrectformat, $defaultpenalty) {
// NOTE! We do not compile during question save as that would make
// import actions slow. We could compile during fromform-validation
// but we really should look at refactoring that to better interleave
// the compilation.
//
// As we currently compile at the first use things start slower than they could.
// The cache will be a dictionary with many things.
$cc = [];
// Some details are globals built from many sources.
$units = false;
$forbiddenkeys = [];
$sec = new stack_cas_security();
// Some counter resets to ensure that the result is the same even if
// we for some reason would compile twice in a session.
// Happens during first preview and can lead to cache being always out
// of sync if textdownload is in play.
stack_cas_castext2_textdownload::$countfiles = 1;
// Static string extrraction now for CASText2 in top level text blobs and PRTs,
// question varaibles and in the future probably also from input2.
$map = new castext2_static_replacer([]);
// First handle the question variables.
if ($questionvariables === null || trim($questionvariables) === '') {
$cc['statement-qv'] = null;
$cc['preamble-qv'] = null;
$cc['contextvariables-qv'] = null;
$cc['security-context'] = [];
} else {
$kv = new stack_cas_keyval($questionvariables, $options);
$kv->get_security($sec);
if (!$kv->get_valid()) {
throw new stack_exception('Error(s) in question-variables: ' . implode('; ', $kv->get_errors()));
}
$c = $kv->compile('/qv', $map);
// Store the pre-validated statement representing the whole qv.
$cc['statement-qv'] = $c['statement'];
// Store any contextvariables, e.g. assume statements.
$cc['contextvariables-qv'] = $c['contextvariables'];
// Store the possible block external features.
$cc['preamble-qv'] = $c['blockexternal'];
// Finally extend the forbidden keys set if we saw any variables written.
if (isset($c['references']['write'])) {
$forbiddenkeys = array_merge($forbiddenkeys, $c['references']['write']);
}
if (isset($c['includes'])) {
$cc['includes']['keyval'] = $c['includes'];
}
}
// Collect the language codes in use. For our purposes the question-text
// is all that is needed. Other places may have other values but these are
// enough after all the validations have passed.
$ml = new stack_multilang();
$cc['langs'] = $ml->languages_used($questiontext);
// Then do some basic detail collection related to the inputs and PRTs.
foreach ($inputs as $input) {
if (is_a($input, 'stack_units_input')) {
$units = true;
break;
}
}
$cc['required'] = [];
$cc['prt-preamble'] = [];
$cc['prt-contextvariables'] = [];
$cc['prt-signature'] = [];
$cc['prt-definition'] = [];
$cc['prt-trace'] = [];
$i = 0;
foreach ($prts as $name => $prt) {
$path = '/p/' . $i;
$i = $i + 1;
$r = $prt->compile($inputs, $forbiddenkeys, $defaultpenalty, $sec, $path, $map);
$cc['required'][$name] = $r['required'];
if ($r['be'] !== null && $r['be'] !== '') {
$cc['prt-preamble'][$name] = $r['be'];
}
if ($r['cv'] !== null && $r['cv'] !== '') {
$cc['prt-contextvariables'][$name] = $r['cv'];
}
$cc['prt-signature'][$name] = $r['sig'];
$cc['prt-definition'][$name] = $r['def'];
$cc['prt-trace'][$name] = $r['trace'];
$units = $units || $r['units'];
if (isset($r['includes'])) {
if (!isset($cc['includes'])) {
$cc['includes'] = $r['includes'];
} else {
if (isset($r['includes']['keyval'])) {
if (!isset($cc['includes']['keyval'])) {
$cc['includes']['keyval'] = [];
}
$cc['includes']['keyval'] = array_unique(array_merge($cc['includes']['keyval'],
$r['includes']['keyval']));
}
if (isset($r['includes']['castext'])) {
if (!isset($cc['includes']['castext'])) {
$cc['includes']['castext'] = [];
}
$cc['includes']['castext'] = array_unique(array_merge($cc['includes']['castext'],
$r['includes']['castext']));
}
}
}
}
// Note that instead of just adding the unit loading to the 'preamble-qv'
// and forgetting about units we do keep this bit of information stored
// as it may be used in input configuration at some later time.
$cc['units'] = $units;
$cc['forbiddenkeys'] = $forbiddenkeys;
// Do some pluginfile mapping. Note that the PRT-nodes are mapped in PRT-compiler.
$questiontext = stack_castext_file_filter($questiontext, [
'questionid' => $id,
'field' => 'questiontext'
]);
$generalfeedback = stack_castext_file_filter($generalfeedback, [
'questionid' => $id,
'field' => 'generalfeedback'
]);
$specificfeedback = stack_castext_file_filter($specificfeedback, [
'questionid' => $id,
'field' => 'specificfeedback'
]);
// Legacy questions may have a null description before being saved/compiled.
if ($questiondescription === null) {
$questiondescription = '';
}
$questiondescription = stack_castext_file_filter($questiondescription, [
'questionid' => $id,
'field' => 'questiondescription'
]);
$prtcorrect = stack_castext_file_filter($prtcorrect, [
'questionid' => $id,
'field' => 'prtcorrect'
]);
$prtpartiallycorrect = stack_castext_file_filter($prtpartiallycorrect, [
'questionid' => $id,
'field' => 'prtpartiallycorrect'
]);
$prtincorrect = stack_castext_file_filter($prtincorrect, [
'questionid' => $id,
'field' => 'prtincorrect'
]);
// Compile the castext fragments.
$ctoptions = [
'bound-vars' => $forbiddenkeys,
'prt-names' => array_flip(array_keys($prts)),
'io-blocks-as-raw' => 'pre-input2',
'static string extractor' => $map
];
$ct = castext2_evaluatable::make_from_source($questiontext, '/qt');
if (!$ct->get_valid($questiontextformat, $ctoptions, $sec)) {
throw new stack_exception('Error(s) in question-text: ' . implode('; ', $ct->get_errors(false)));
} else {
$cc['castext-qt'] = $ct->get_evaluationform();
// Note that only with "question-text" may we get inlined downloads.
foreach ($ct->get_special_content() as $key => $values) {
if ($key === 'text-download') {
foreach ($values as $k => $v) {
$cc['castext-td-' . $k] = $v;
}
} else if ($key === 'castext-includes') {
if (!isset($cc['includes'])) {
$cc['includes'] = ['castext' => $values];
} else if (!isset($cc['includes']['castext'])) {
$cc['includes']['castext'] = $values;
} else {
foreach ($values as $url) {
if (array_search($url, $cc['includes']['castext']) === false) {
$cc['includes']['castext'][] = $url;
}
}
}
}
}
}
$ct = castext2_evaluatable::make_from_source($questionnote, '/qn');
if (!$ct->get_valid(FORMAT_HTML, $ctoptions, $sec)) {
throw new stack_exception('Error(s) in question-note: ' . implode('; ', $ct->get_errors(false)));
} else {
$cc['castext-qn'] = $ct->get_evaluationform();
}
$ct = castext2_evaluatable::make_from_source($generalfeedback, '/gf');
if (!$ct->get_valid($generalfeedbackformat, $ctoptions, $sec)) {
throw new stack_exception('Error(s) in general-feedback: ' . implode('; ', $ct->get_errors(false)));
} else {
$cc['castext-gf'] = $ct->get_evaluationform();
}
$ct = castext2_evaluatable::make_from_source($specificfeedback, '/sf');
if (!$ct->get_valid($specificfeedbackformat, $ctoptions, $sec)) {
throw new stack_exception('Error(s) in specific-feedback: ' . implode('; ', $ct->get_errors(false)));
} else {
$cc['castext-sf'] = $ct->get_evaluationform();
}
$ct = castext2_evaluatable::make_from_source($questiondescription, '/qd');
if (!$ct->get_valid($questiondescriptionformat, $ctoptions, $sec)) {
throw new stack_exception('Error(s) in question description: ' . implode('; ', $ct->get_errors(false)));
} else {
$cc['castext-qd'] = $ct->get_evaluationform();
}
$ct = castext2_evaluatable::make_from_source($prtcorrect, '/pc');
if (!$ct->get_valid($prtcorrectformat, $ctoptions, $sec)) {
throw new stack_exception('Error(s) in PRT-correct message: ' . implode('; ', $ct->get_errors(false)));
} else {
$cc['castext-prt-c'] = $ct->get_evaluationform();
}
$ct = castext2_evaluatable::make_from_source($prtpartiallycorrect, '/pp');
if (!$ct->get_valid($prtpartiallycorrectformat, $ctoptions, $sec)) {
throw new stack_exception('Error(s) in PRT-partially correct message: ' . implode('; ', $ct->get_errors(false)));
} else {
$cc['castext-prt-pc'] = $ct->get_evaluationform();
}
$ct = castext2_evaluatable::make_from_source($prtincorrect, '/pi');
if (!$ct->get_valid($prtincorrectformat, $ctoptions, $sec)) {
throw new stack_exception('Error(s) in PRT-incorrect message: ' . implode('; ', $ct->get_errors(false)));
} else {
$cc['castext-prt-ic'] = $ct->get_evaluationform();
}
// Remember to collect the extracted strings once all has been done.
$cc['static-castext-strings'] = $map->get_map();
// The time of the security context as it were during 2021 was short, now only
// the input variables remain.
$si = [];
// Mark all inputs. To let us know that they have special types.
foreach ($inputs as $key => $value) {
if (!isset($si[$key])) {
$si[$key] = [];
}
$si[$key][-2] = -2;
}
$cc['security-context'] = $si;
return $cc;
}
/**
* Moodle specific acessor for question capabilities.
*/
public function has_cap(string $capname): bool {
return $this->has_question_capability($capname);
}
}