Commit ceb3fd15 authored by Mehmedovski's avatar Mehmedovski
Browse files

Initial commit: KNIGHT adaptivequiz for Moodle 5.0-5.2 (based on upstream...

Initial commit: KNIGHT adaptivequiz for Moodle 5.0-5.2 (based on upstream MOODLE_500, tested with 5.2, KNIGHT customizations)
parents
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_adaptivequiz\form;
use context;
use context_module;
use core\notification;
use core\output\html_writer;
use core_form\dynamic_form;
use mod_adaptivequiz\item_bank;
use moodle_url;
/**
* Assigns Moodle question banks to an adaptive quiz's item bank.
*
* @package mod_adaptivequiz
* @copyright 2026 Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class assign_question_bank_form extends dynamic_form {
/**
* Implements the abstract method.
*/
protected function definition() {
$form = $this->_form;
$form->addElement('html', html_writer::tag('h5', get_string('itembankthiscourseqbanks', 'adaptivequiz')));
$name = get_string('itembankselectqbank', 'adaptivequiz');
$options = [
'ajax' => 'mod_adaptivequiz/question_banks_datasource',
'multiple' => true,
'data-contextid' => $this->get_context_for_dynamic_submission()->id,
'data-incourseid' => $this->optional_param('course', null, PARAM_INT),
'id' => 'addqbanksthis',
];
$form->addElement('autocomplete', 'addqbanksthis', $name, [], $options);
$form->addElement('html', html_writer::tag('h5', get_string('itembankothercoursesqbanks', 'adaptivequiz')));
$name = get_string('itembankselectqbank', 'adaptivequiz');
$options = [
'ajax' => 'mod_adaptivequiz/question_banks_datasource',
'multiple' => true,
'data-contextid' => $this->get_context_for_dynamic_submission()->id,
'data-notincourseid' => $this->optional_param('course', null, PARAM_INT),
'id' => 'addqbanksother',
];
$form->addElement('autocomplete', 'addqbanksother', $name, [], $options);
$form->addElement('hidden', 'id');
$form->setType('id', PARAM_INT);
}
/**
* Implements the abstract method.
*/
public function process_dynamic_submission(): void {
global $DB;
$id = $this->optional_param('id', null, PARAM_INT);
$cm = get_coursemodule_from_id('adaptivequiz', $id, 0, false, MUST_EXIST);
$adaptivequiz = $DB->get_record('adaptivequiz', ['id' => $cm->instance], '*', MUST_EXIST);
$data = $this->get_data();
$addqbankidlist = array_merge($data->addqbanksthis, $data->addqbanksother);
item_bank::assign_qbanks_to_adaptivequiz($adaptivequiz->id, $addqbankidlist);
notification::success(get_string('itembanknewassignflash', 'adaptivequiz'));
}
/**
* Implements the abstract method.
*/
public function set_data_for_dynamic_submission(): void {
$this->set_data((object) ['id' => $this->optional_param('id', null, PARAM_INT)]);
}
/**
* Implements the abstract method.
*/
protected function get_context_for_dynamic_submission(): context {
$id = $this->optional_param('id', null, PARAM_INT);
$cm = get_coursemodule_from_id('adaptivequiz', $id, 0, false, MUST_EXIST);
return context_module::instance($cm->id);
}
/**
* Implements the abstract method.
*/
protected function check_access_for_dynamic_submission(): void {
// KNIGHT: assigning/removing a question bank is a managing action, so guard the AJAX endpoint
// with the manage capability (the upstream stub left it open to any logged-in user).
require_capability('mod/adaptivequiz:manage', $this->get_context_for_dynamic_submission());
}
/**
* Implements the abstract method.
*/
protected function get_page_url_for_dynamic_submission(): moodle_url {
$id = $this->optional_param('id', null, PARAM_INT);
return new moodle_url('/mod/adaptivequiz/itembank.php', ['id' => $id]);
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_adaptivequiz\form;
use context;
use context_module;
use core_form\dynamic_form;
use mod_adaptivequiz\item_administration_params_helper;
use mod_adaptivequiz\item_bank_helper;
use moodle_url;
/**
* Edits item administration settings for an 'adaptivequiz' instance.
*
* @package mod_adaptivequiz
* @copyright 2026 Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class item_administration_params_form extends dynamic_form {
#[\Override]
protected function definition() {
$form = $this->_form;
$levelattrs = ['size' => '3', 'maxlength' => '3'];
$form->addElement('text', 'highestlevel', get_string('highestlevel', 'adaptivequiz'), $levelattrs);
$form->addHelpButton('highestlevel', 'highestlevel', 'adaptivequiz');
$form->addRule('highestlevel', get_string('formelementempty', 'adaptivequiz'), 'required', null, 'client');
$form->addRule('highestlevel', get_string('formelementnumeric', 'adaptivequiz'), 'numeric', null, 'client');
$form->setType('highestlevel', PARAM_INT);
$form->addElement('text', 'lowestlevel', get_string('lowestlevel', 'adaptivequiz'), $levelattrs);
$form->addHelpButton('lowestlevel', 'lowestlevel', 'adaptivequiz');
$form->addRule('lowestlevel', get_string('formelementempty', 'adaptivequiz'), 'required', null, 'client');
$form->addRule('lowestlevel', get_string('formelementnumeric', 'adaptivequiz'), 'numeric', null, 'client');
$form->setType('lowestlevel', PARAM_INT);
$form->addElement('text', 'startinglevel', get_string('startinglevel', 'adaptivequiz'), $levelattrs);
$form->addHelpButton('startinglevel', 'startinglevel', 'adaptivequiz');
$form->addRule('startinglevel', get_string('formelementempty', 'adaptivequiz'), 'required', null, 'client');
$form->addRule('startinglevel', get_string('formelementnumeric', 'adaptivequiz'), 'numeric', null, 'client');
$form->setType('startinglevel', PARAM_INT);
$qnumattrs = ['size' => '3', 'maxlength' => '3'];
$form->addElement('text', 'minimumquestions', get_string('minimumquestions', 'adaptivequiz'), $qnumattrs);
$form->addHelpButton('minimumquestions', 'minimumquestions', 'adaptivequiz');
$form->addRule('minimumquestions', get_string('formelementempty', 'adaptivequiz'), 'required', null, 'client');
$form->addRule('minimumquestions', get_string('formelementnumeric', 'adaptivequiz'), 'numeric', null, 'client');
$form->setType('minimumquestions', PARAM_INT);
$form->addElement('text', 'maximumquestions', get_string('maximumquestions', 'adaptivequiz'), $qnumattrs);
$form->addHelpButton('maximumquestions', 'maximumquestions', 'adaptivequiz');
$form->addRule('maximumquestions', get_string('formelementempty', 'adaptivequiz'), 'required', null, 'client');
$form->addRule('maximumquestions', get_string('formelementnumeric', 'adaptivequiz'), 'numeric', null, 'client');
$form->setType('maximumquestions', PARAM_INT);
$standarderrorattrs = ['size' => '10', 'maxlength' => '10'];
$form->addElement('text', 'standarderror', get_string('standarderror', 'adaptivequiz'), $standarderrorattrs);
$form->addHelpButton('standarderror', 'standarderror', 'adaptivequiz');
$form->addRule('standarderror', get_string('formelementempty', 'adaptivequiz'), 'required', null, 'client');
$form->addRule('standarderror', get_string('formelementdecimal', 'adaptivequiz'), 'numeric', null, 'client');
$form->setDefault('standarderror', 5.0);
$form->setType('standarderror', PARAM_FLOAT);
// KNIGHT: acceptance threshold for partial-credit questions (0..1).
$thresholdattrs = ['size' => '4', 'maxlength' => '4'];
$form->addElement('text', 'acceptancethreshold', get_string('acceptancethreshold', 'adaptivequiz'), $thresholdattrs);
$form->addHelpButton('acceptancethreshold', 'acceptancethreshold', 'adaptivequiz');
$form->addRule('acceptancethreshold', get_string('formelementdecimal', 'adaptivequiz'), 'numeric', null, 'client');
$form->setDefault('acceptancethreshold', 0);
$form->setType('acceptancethreshold', PARAM_FLOAT);
// KNIGHT (Feature 3): completed-attempts threshold after which a question-analysis review is advised.
// It belongs with the question pool configuration, so it lives here rather than on the settings form.
$form->addElement(
'text',
'questionchecktrigger',
get_string('questionchecktrigger', 'adaptivequiz'),
['size' => '3', 'maxlength' => '3']
);
$form->addHelpButton('questionchecktrigger', 'questionchecktrigger', 'adaptivequiz');
$form->addRule('questionchecktrigger', get_string('formelementnumeric', 'adaptivequiz'), 'numeric', null, 'client');
$form->setDefault('questionchecktrigger', 0);
$form->setType('questionchecktrigger', PARAM_INT);
$form->addElement('hidden', 'id');
$form->setType('id', PARAM_INT);
}
/**
* Implements the abstract method.
*/
public function process_dynamic_submission(): void {
$id = $this->optional_param('id', null, PARAM_INT);
$cm = get_coursemodule_from_id('adaptivequiz', $id, 0, false, MUST_EXIST);
$data = $this->get_data();
$data->id = $cm->instance;
adaptivequiz_update_item_administration_params($data);
}
/**
* Implements the abstract method.
*/
public function set_data_for_dynamic_submission(): void {
global $DB;
$id = $this->optional_param('id', null, PARAM_INT);
$cm = get_coursemodule_from_id('adaptivequiz', $id, 0, false, MUST_EXIST);
$adaptivequiz = $DB->get_record('adaptivequiz', ['id' => $cm->instance], '*', MUST_EXIST);
$fields = item_administration_params_helper::fields();
$formdata = ['id' => $id];
foreach ($fields as $field) {
$formdata[$field] = $adaptivequiz->{$field};
}
// KNIGHT: acceptancethreshold and questionchecktrigger (Feature 3) are loaded separately - they
// are not among the "required" item administration params (a value of 0 is valid for both), so
// they must not join that completeness check.
$formdata['acceptancethreshold'] = $adaptivequiz->acceptancethreshold;
$formdata['questionchecktrigger'] = $adaptivequiz->questionchecktrigger;
$this->set_data($formdata);
}
/**
* Implements the abstract method.
*/
protected function get_context_for_dynamic_submission(): context {
$id = $this->optional_param('id', null, PARAM_INT);
$cm = get_coursemodule_from_id('adaptivequiz', $id, 0, false, MUST_EXIST);
return context_module::instance($cm->id);
}
/**
* Implements the abstract method.
*/
protected function check_access_for_dynamic_submission(): void {
// KNIGHT: this form writes the item administration / scoring settings (levels, standard error
// and the acceptance threshold), so guard it with the same manage capability as the item bank.
require_capability('mod/adaptivequiz:manage', $this->get_context_for_dynamic_submission());
}
/**
* Implements the abstract method.
*/
protected function get_page_url_for_dynamic_submission(): moodle_url {
$id = $this->optional_param('id', null, PARAM_INT);
return new moodle_url('/mod/adaptivequiz/itembank.php', ['id' => $id]);
}
#[\Override]
public function validation($data, $files) {
$errors = parent::validation($data, $files);
if (0 >= $data['minimumquestions']) {
$errors['minimumquestions'] = get_string('formelementnegative', 'adaptivequiz');
}
if (0 >= $data['maximumquestions']) {
$errors['maximumquestions'] = get_string('formelementnegative', 'adaptivequiz');
}
if (0 >= $data['startinglevel']) {
$errors['startinglevel'] = get_string('formelementnegative', 'adaptivequiz');
}
if (0 >= $data['lowestlevel']) {
$errors['lowestlevel'] = get_string('formelementnegative', 'adaptivequiz');
}
if (0 >= $data['highestlevel']) {
$errors['highestlevel'] = get_string('formelementnegative', 'adaptivequiz');
}
if (0.0 > (float) $data['standarderror'] || 50.0 <= (float) $data['standarderror']) {
$errors['standarderror'] = get_string('formstderror', 'adaptivequiz');
}
// KNIGHT: the acceptance threshold is a point fraction within 0..1 and is stored to two
// decimal places, so reject any finer input rather than silently rounding it (e.g. 0.001).
$threshold = (float) $data['acceptancethreshold'];
if (0.0 > $threshold || 1.0 < $threshold) {
$errors['acceptancethreshold'] = get_string('formacceptanceleveloutofbounds', 'adaptivequiz');
} else if (abs(round($threshold, 2) - $threshold) > 1e-9) {
$errors['acceptancethreshold'] = get_string('formacceptancethresholdprecision', 'adaptivequiz');
}
// Validate higher and lower values.
if ($data['minimumquestions'] >= $data['maximumquestions']) {
$errors['minimumquestions'] = get_string('formminquestgreaterthan', 'adaptivequiz');
}
if ($data['lowestlevel'] >= $data['highestlevel']) {
$errors['lowestlevel'] = get_string('formlowlevelgreaterthan', 'adaptivequiz');
}
if (!($data['startinglevel'] >= $data['lowestlevel'] && $data['startinglevel'] <= $data['highestlevel'])) {
$errors['startinglevel'] = get_string('formstartleveloutofbounds', 'adaptivequiz');
}
// No need to trigger the database when there's some basic error already.
if ($errors) {
return $errors;
}
$cm = get_coursemodule_from_id('adaptivequiz', $data['id'], 0, false, MUST_EXIST);
$lowestlevelnum = item_bank_helper::count_adaptivequiz_questions_with_difficulty_level(
$cm->instance,
$data['lowestlevel']
);
if (!$lowestlevelnum) {
$errors['lowestlevel'] = get_string('itembankinvalidlevel', 'adaptivequiz');
}
$highestlevelnum = item_bank_helper::count_adaptivequiz_questions_with_difficulty_level(
$cm->instance,
$data['highestlevel']
);
if (!$highestlevelnum) {
$errors['highestlevel'] = get_string('itembankinvalidlevel', 'adaptivequiz');
}
// TODO: further validation.
return $errors;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Adaptivequiz required password form
*
* @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\form;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir.'/formslib.php');
use moodleform;
use html_writer;
class requiredpassword extends moodleform {
/** @var string $passwordmessage a string containing text for a failed password attempt */
public $passwordmessage = '';
/**
* This method is refactored code from the quiz's password rule add_preflight_check_form_fields() method.
* It prints a form for the user to enter a password
*/
protected function definition() {
$mform = $this->_form;
foreach ($this->_customdata['hidden'] as $name => $value) {
if ($name === 'sesskey') {
continue;
}
if ($name === 'cmid' || $name === 'uniqueid') {
$mform->setType($name, PARAM_INT);
}
$mform->addElement('hidden', $name, $value);
}
$mform->addElement('header', 'passwordheader', get_string('password'));
$mform->addElement('static', 'passwordmessage', '', get_string('requirepasswordmessage', 'adaptivequiz'));
$attr = array('style' => 'color:red;', 'class' => 'wrongpassword');
$html = html_writer::start_tag('div', $attr);
$mform->addElement('html', $html);
$mform->addElement('static', 'message');
$html = html_writer::end_tag('div');
$mform->addElement('html', $html);
// Don't use the 'proper' field name of 'password' since that get's
// Firefox's password auto-complete over-excited.
$mform->addElement('password', 'quizpassword', get_string('enterrequiredpassword', 'adaptivequiz'));
$this->add_action_buttons(true, get_string('continue'));
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_adaptivequiz;
use stdClass;
/**
* Provides methods to read information about item administration parameters.
*
* @package mod_adaptivequiz
* @copyright 2026 Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class item_administration_params_helper {
/**
* @var string[] Proeprties of the 'adaptivequiz' module related to item administration.
*/
private const PARAMS = ['highestlevel', 'lowestlevel', 'startinglevel', 'minimumquestions', 'maximumquestions',
'standarderror'];
/**
* Returns names of the fields related to item administration settings.
*
* @return string[] An array of 'adaptivequiz' module's field names.
*/
public static function fields(): array {
return self::PARAMS;
}
/**
* Reports whether all required parameters for item administration are set.
*
* @param stdClass $adaptivequiz An 'adaptivequiz' instance.
*/
public static function is_all_set_for_adaptivequiz(stdClass $adaptivequiz): bool {
// If at least one fails the whole thing does as well.
foreach (self::PARAMS as $field) {
if (!$adaptivequiz->{$field}) {
return false;
}
}
return true;
}
/**
* Provides detailed information on the validity of item administration parameters.
*
* @param stdClass $adaptivequiz An 'adaptivequiz' instance.
* @return array The key is a param shortname, the value is an error message (if the param is invalid).
*/
public static function get_validation_results_for_adaptivequiz(stdClass $adaptivequiz): array {
$return = [];
foreach (self::PARAMS as $param) {
$return[$param] = '';
}
$lowestlevelnum = item_bank_helper::count_adaptivequiz_questions_with_difficulty_level(
$adaptivequiz->id,
$adaptivequiz->lowestlevel
);
if (!$lowestlevelnum) {
$return['lowestlevel'] = get_string('itembankinvalidlevel', 'adaptivequiz');
}
$highestlevelnum = item_bank_helper::count_adaptivequiz_questions_with_difficulty_level(
$adaptivequiz->id,
$adaptivequiz->highestlevel
);
if (!$highestlevelnum) {
$return['highestlevel'] = get_string('itembankinvalidlevel', 'adaptivequiz');
}
return $return;
}
/**
* A shortcut method to wrap all checks for item administration parameters.
*
* @param stdClass $adaptivequiz An 'adaptivequiz' instance.
*/
public static function is_all_valid_for_adaptivequiz(stdClass $adaptivequiz): bool {
if (!self::is_all_set_for_adaptivequiz($adaptivequiz)) {
return false;
}
$paramsvalidation = self::get_validation_results_for_adaptivequiz($adaptivequiz);
foreach ($paramsvalidation as $validationresult) {
$validationfailed = $validationresult != '';
if ($validationfailed) {
return false;
}
}
return true;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_adaptivequiz;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/mod/adaptivequiz/locallib.php');
use context_module;
use core_question\local\bank\question_bank_helper;
use stdClass;
/**
* A high level class to manage item banks for adaptive quizzes.
*
* It contains only static methods wrapping operations as a whole with no explicit dependencies. The class is intended
* to be used for item bank management only.
*
* @package mod_adaptivequiz
* @copyright 2026 Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class item_bank {
/**
* Creates new links between the given adaptive quiz instance and questions banks.
*
* @param int $adaptivequizid ID of the adaptive quiz instance to link the question banks to.
* @param int[] $qbankidlist List of ID of question bank instances to link.
*/
public static function assign_qbanks_to_adaptivequiz(int $adaptivequizid, array $qbankidlist): void {
global $DB;
$cm = get_coursemodule_from_instance('adaptivequiz', $adaptivequizid, 0, false, MUST_EXIST);
$context = context_module::instance($cm->id);
// A way to validate the questions banks being assigned. We fetch what's available to match the passed
// question banks against.
$availableqbanks = question_bank_helper::get_activity_instances_with_shareable_questions(
filtercontext: $context
);
$qbankidlist = array_intersect(
$qbankidlist,
array_map(fn($qbank): int => $qbank->cminfo->instance, $availableqbanks)
);
// Filter out what's added already.
$linkedqbankidlist = $DB->get_fieldset('adaptivequiz_qbank', 'qbankid', ['adaptivequizid' => $adaptivequizid]);
$qbankidlist = array_diff($qbankidlist, $linkedqbankidlist);
$insert = array_map(function(int $qbankid) use ($adaptivequizid): stdClass {
$qbankcm = get_coursemodule_from_instance('qbank', $qbankid, 0, false, MUST_EXIST);
$qbankcontext = context_module::instance($qbankcm->id);
return (object) [
'adaptivequizid' => $adaptivequizid,
'qbankid' => $qbankid,
'qbankcontextid' => $qbankcontext->id,
];
}, $qbankidlist);
$DB->insert_records('adaptivequiz_qbank', $insert);
}
/**
* Unlinks the given question bank from the adaptive quiz instance.
*
* Can be used as a high-level API method, contains all necessary permissions checks.
*
* @param int $adaptivequizid ID of the adaptive quiz instance to unlink the question bank from.
* @param int $qbankid ID of th question bank to unlink.
*/
public static function unassign_qbank_from_adaptivequiz(int $adaptivequizid, int $qbankid): void {
global $DB;
$cm = get_coursemodule_from_instance(
modulename: 'adaptivequiz',
instance: $adaptivequizid,
strictness: MUST_EXIST
);
$context = context_module::instance($cm->id);
require_capability('mod/adaptivequiz:manage', $context);
$DB->delete_records('adaptivequiz_qbank', ['adaptivequizid' => $adaptivequizid, 'qbankid' => $qbankid]);
}
/**
* Unlinks the given question category from the adaptive quiz instance.
*
* Can be used as a high-level API method, contains all necessary permissions checks.
*
* @param int $adaptivequizid ID of the adaptive quiz instance to unlink the question category from.
* @param int $qcatid ID of th question category to unlink.
*/
public static function unassign_question_category_from_adaptivequiz(int $adaptivequizid, int $qcatid): void {
global $DB;
$cm = get_coursemodule_from_instance(
modulename: 'adaptivequiz',
instance: $adaptivequizid,
strictness: MUST_EXIST
);
$context = context_module::instance($cm->id);
require_capability('mod/adaptivequiz:manage', $context);
$DB->delete_records('adaptivequiz_question', ['instance' => $adaptivequizid, 'questioncategory' => $qcatid]);
}
/**
* Returns the list of question banks assigned to the given adaptive quiz instance.
*
* It may distinguish question banks by courses: either fetch from a particular course or skip question banks in
* a particular course. If no course parameters are specified it fetches all the question banks assigned for
* the adaptive quiz instance.
*
* @param int $adaptivequizid
* @param string $fields Comma separated list of fields to return for each item.
* @param int|null $incourseid A specific course question banks must be from.
* @param int|null $notincourseid A specific course question banks must not be from.
* @return stdClass[] An array of question bank instances.
*/
public static function get_question_banks_assigned_to_adaptivequiz(
int $adaptivequizid,
string $fields = '*',
?int $incourseid = null,
?int $notincourseid = null
): array {
global $DB;
$whereextra = '';
$paramsextra = [];
if ($incourseid) {
$whereextra .= "AND qb.course = ?";
$paramsextra[] = $incourseid;
}
if ($notincourseid) {
$whereextra .= "AND qb.course != ?";
$paramsextra[] = $notincourseid;
}
$sql = "SELECT qb.{$fields}
FROM {adaptivequiz_qbank} aqb
JOIN {qbank} qb ON qb.id = aqb.qbankid
WHERE aqb.adaptivequizid = ?
{$whereextra}";
$params = array_merge([$adaptivequizid], $paramsextra);
return $DB->get_records_sql($sql, $params);
}
/**
* Provides information about single question categories linked to the given adaptive quiz activity.
*
* @return stdClass[] Each item is a record from {question_categories} + 'cmid' and 'qbankname' fields.
*/
public static function get_question_categories_assigned_to_adaptivequiz(
int $adaptivequizid,
string $fields = '*',
?int $incourseid = null,
?int $notincourseid = null
): array {
global $DB;
$whereextra = '';
$paramsextra = [];
if ($incourseid) {
$whereextra .= "AND cm.course = ?";
$paramsextra[] = $incourseid;
}
if ($notincourseid) {
$whereextra .= "AND cm.course != ?";
$paramsextra[] = $notincourseid;
}
// Add a prefix to the fields to be returned.
$fieldlist = array_map(fn (string $field) => 'qc.' . $field, explode(',', $fields));
$fields = implode(',', $fieldlist);
// TODO: consider post-loading of cm_info instances for categories' qbanks.
$sql = "SELECT {$fields}, cm.id AS cmid, qb.name AS qbankname
FROM {adaptivequiz_question} aq
JOIN {question_categories} qc ON qc.id = aq.questioncategory
JOIN {context} c ON c.id = qc.contextid
JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = ?
JOIN {qbank} qb ON qb.id = cm.instance
WHERE aq.instance = ?
{$whereextra}";
$params = array_merge([CONTEXT_MODULE, $adaptivequizid], $paramsextra);
return $DB->get_records_sql($sql, $params);
}
/**
* A wrapper method to know whether the adaptive quiz has any question banks or single question categories assigned.
*
* @param int $adaptivequizid ID of the adaptive quiz instance.
*/
public static function adaptive_quiz_instance_has_question_banks_or_categories_linked(int $adaptivequizid): bool {
global $DB;
$hasqbanks = $DB->record_exists('adaptivequiz_qbank', ['adaptivequizid' => $adaptivequizid]);
$hasqcats = $DB->record_exists('adaptivequiz_question', ['instance' => $adaptivequizid]);
return $hasqbanks || $hasqcats;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_adaptivequiz;
use mod_adaptivequiz\local\repository\questions_repository;
/**
* Provides methods to read information from item banks.
*
* The purpose of this class is to provide methods for the item administration context only.
*
* @package mod_adaptivequiz
* @copyright 2026 Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class item_bank_helper {
/**
* Counts questions with the given difficulty level.
*
* @param int $adaptivequizid ID of the 'adaptivequiz' instance.
* @param int $level The difficulty level to search questions by.
* @return int The number of questions found.
*/
public static function count_adaptivequiz_questions_with_difficulty_level(int $adaptivequizid, int $level): int {
$qcategoryidlist = self::get_question_categories($adaptivequizid);
if (!$qcategoryidlist) {
return 0;
}
return questions_repository::count_adaptive_questions_in_pool_with_level($qcategoryidlist, $level);
}
/**
* Gets the list of question categories in the instance's item bank.
*
* @param int $adaptivequizid ID of the 'adaptivequiz' instance.
* @return int[] A list of question category ID.
*/
public static function get_question_categories(int $adaptivequizid): array {
global $DB;
// Single categories.
$return = $DB->get_fieldset('adaptivequiz_question', 'questioncategory', ['instance' => $adaptivequizid]);
// Entire question banks.
$sql = "SELECT qc.id
FROM {adaptivequiz_qbank} aqb
JOIN {question_categories} qc ON aqb.qbankcontextid = qc.contextid
WHERE aqb.adaptivequizid = ?";
$params = [$adaptivequizid];
$return = array_merge($return, $DB->get_fieldset_sql($sql, $params));
$return = array_unique($return);
return $return;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This class contains information about the attempt parameters
*
* @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local;
use coding_exception;
use dml_exception;
use mod_adaptivequiz\local\attempt\attempt_state;
use moodle_exception;
use question_bank;
use question_engine;
use question_state_gaveup;
use question_state_gradedpartial;
use question_state_gradedright;
use question_state_gradedwrong;
use question_usage_by_activity;
use stdClass;
class attempt {
private const TABLE = 'adaptivequiz_attempt';
/**
* The name of the module
*/
const MODULENAME = 'mod_adaptivequiz';
/**
* The behaviour to use be default
*/
const ATTEMPTBEHAVIOUR = 'deferredfeedback';
/**
* @var attempt_state $attemptstate
*/
private $attemptstate;
/**
* Flag to denote developer debugging is enabled and this class should write message to the debug
* wrap on multiple lines
* @var bool
*/
protected $debugenabled = false;
/** @var array $debug debugging array of messages */
protected $debug = array();
/**
* @var stdClass $adaptivequiz object, properties come from the adaptivequiz table.
* This property also contains the context and cm objects
*/
protected $adaptivequiz;
/** @var stdClass $adpqattempt object, properties come from the adaptivequiz_attempt table */
protected $adpqattempt;
/** @var int $userid user id */
protected $userid;
/** @var int $uniqueid a unique number identifying the activity usage of questions */
protected $uniqueid;
/** @var int $questionsattempted the total of question attempted */
protected $questionsattempted;
/** @var float $standarderror the standard error of the attempt */
protected $standarderror;
/** @var question_usage_by_activity $quba - A question usage by activity object */
protected $quba = null;
/** @var int $slot - a question slot number */
protected $slot = 0;
/** @var array $tags an array of tags that used to identify eligible questions for the attempt */
protected $tags = array();
/** @var array $status status message storing the reason why the attempt was stopped */
protected $status = '';
/** @var int $level the difficulty level the attempt is currently set at */
protected $level = 0;
/** @var int $lastdifficultylevel the last difficulty level used in the attempt if any */
protected $lastdifficultylevel = null;
/**
* Constructor initializes required data to process the attempt
* @param stdClass $adaptivequiz adaptivequiz record object from adaptivequiz table
* @param int $userid user id
* @param array $tags an array of acceptible tags
*/
public function __construct($adaptivequiz, $userid, $tags = array()) {
$this->adaptivequiz = $adaptivequiz;
$this->userid = $userid;
$this->tags = $tags;
$this->tags[] = ADAPTIVEQUIZ_QUESTION_TAG;
if (debugging('', DEBUG_DEVELOPER)) {
$this->debugenabled = true;
}
}
/**
* This function returns the debug array
* @return array array of debugging messages
*/
public function get_debug() {
return $this->debug;
}
/**
* This function returns the adaptivequiz property
* @return stdClass adaptivequiz record
*/
public function get_adaptivequiz() {
return $this->adaptivequiz;
}
/**
* This function returns the $level property
* @return int level property
*/
public function get_level() {
return $this->level;
}
/**
* This function sets the $level property
* @param int $level difficulty level to fetch
*/
public function set_level($level) {
$this->level = $level;
}
/**
* Set the last difficulty level that was used.
* This may influence the next question chosing process.
*
* @param int $lastdifficultylevel
* @return void
*/
public function set_last_difficulty_level($lastdifficultylevel) {
if (is_null($lastdifficultylevel)) {
$this->lastdifficultylevel = null;
} else {
$this->lastdifficultylevel = (int) $lastdifficultylevel;
}
}
/**
* This function returns the current slot number set for the attempt
* @return int question slot number
*/
public function get_question_slot_number() {
return $this->slot;
}
/**
* This function sets the current slot number set for the attempt
* @throws coding_exception - exception is thrown the argument is not a positive integer
* @param int $slot slot number
*/
public function set_question_slot_number($slot) {
if (!is_int($slot) || 0 >= $slot) {
throw new coding_exception('adaptiveattempt: Argument 1 is not an positive integer', 'Slot must be a positive integer');
}
$this->slot = $slot;
}
/**
* This function returns the current question usage by activity object
* @return question_usage_by_activity a question usage by activity object loaded with the attempt unique id
*/
public function get_quba() {
return $this->quba;
}
/**
* This function sets the current question usage by activity object.
* @throws coding_exception - exception is thrown argument is not an instance of question_usage_by_activity class
* @param question_usage_by_activity $quba an object loaded with the unique id of the attempt
*/
public function set_quba($quba) {
if (!$quba instanceof question_usage_by_activity) {
throw new coding_exception('adaptiveattempt: Argument 1 is not a question_usage_by_activity object',
'Question usage by activity must be an instance of question_usage_by_activity');
}
$this->quba = $quba;
}
/**
* This function checks to see if the difficulty level is out of the boundries set for the attempt
* @param int $level the difficulty level requested
* @param stdClass $adaptivequiz an adaptivequiz record
* @return bool true if the level is in bounds, otherwise false
*/
public function level_in_bounds($level, $adaptivequiz) {
if ($adaptivequiz->lowestlevel <= $level && $adaptivequiz->highestlevel >= $level) {
return true;
}
return false;
}
/**
* This function returns the currently set status message.
*
* @return string The status message property.
*/
public function get_status() {
return $this->status;
}
/**
* This function does the work of initializing data required to fetch a new question for the attempt.
*
* @return bool True if attempt started okay otherwise false.
*/
public function start_attempt() {
// Get most recent attempt or start a new one.
$adpqattempt = $this->get_attempt();
// Check if the level requested is out of the minimum/maximum boundries for the attempt.
if (!$this->level_in_bounds($this->level, $this->adaptivequiz)) {
$var = new stdClass();
$var->level = $this->level;
$this->status = get_string('leveloutofbounds', 'adaptivequiz', $var);
return false;
}
// Check if the attempt has reached the maximum number of questions attempted.
if ($this->max_questions_answered()) {
$this->status = get_string('maxquestattempted', 'adaptivequiz');
return false;
}
// Initialize the question usage by activity property.
$this->initialize_quba();
// Find the last question viewed/answered by the user.
$this->slot = $this->find_last_quest_used_by_attempt($this->quba);
// Create a an instance of the fetchquestion class.
$fetchquestion = new fetchquestion($this->adaptivequiz, 1, $this->adaptivequiz->lowestlevel,
$this->adaptivequiz->highestlevel);
// Check if this is the beginning of an attempt (and pass the starting level) or the continuation of an attempt.
if (empty($this->slot) && 0 == $adpqattempt->questionsattempted) {
// Set the starting difficulty level.
$fetchquestion->set_level((int) $this->adaptivequiz->startinglevel);
// Sets the level class property.
$this->level = $this->adaptivequiz->startinglevel;
// Set the rebuild flag for fetchquestion class.
$fetchquestion->rebuild = true;
$this->print_debug("start_attempt() - Brand new attempt. Set starting level: {$this->adaptivequiz->startinglevel}.");
} else if (!empty($this->slot) && $this->was_answer_submitted_to_question($this->quba, $this->slot)) {
// If the attempt already has a question attached to it, check if an answer was submitted to the question.
// If so fetch a new question.
// Provide the question-fetching process with limits based on our last question.
// KNIGHT: the last question counts as correct when it reaches the acceptance threshold
// (was: mark > 0), judged by the same shared logic the algorithm uses everywhere.
$acceptancethreshold = (float) ($this->adaptivequiz->acceptancethreshold ?? 0);
if (catalgo::question_is_correct($this->quba, $this->slot, $acceptancethreshold) === true) {
// Only ask questions harder than the last question unless we are already at the top of the ability scale.
if (!is_null($this->lastdifficultylevel) && $this->lastdifficultylevel < $this->adaptivequiz->highestlevel) {
$fetchquestion->set_minimum_level($this->lastdifficultylevel + 1);
// Do not ask a question of the same level unless we are already at the max.
if ($this->lastdifficultylevel == $this->level) {
$this->print_debug("start_attempt() - Last difficulty is the same as the new difficulty, ".
"incrementing level from {$this->level} to ".($this->level + 1).".");
$this->level++;
}
}
} else {
// If the last question was wrong...
// Only ask questions easier than the last question unless we are already at the bottom of the ability scale.
if (!is_null($this->lastdifficultylevel) && $this->lastdifficultylevel > $this->adaptivequiz->lowestlevel) {
$fetchquestion->set_maximum_level($this->lastdifficultylevel - 1);
// Do not ask a question of the same level unless we are already at the min.
if ($this->lastdifficultylevel == $this->level) {
$this->print_debug("start_attempt() - Last difficulty is the same as the new difficulty, ".
"decrementing level from {$this->level} to ".($this->level - 1).".");
$this->level--;
}
}
}
// Reset the slot number back to zero, since we are going to fetch a new question.
$this->slot = 0;
// Set the level of difficulty to fetch.
$fetchquestion->set_level((int) $this->level);
$this->print_debug("start_attempt() - Continuing attempt. Set level: {$this->level}.");
} else if (empty($this->slot) && 0 < $adpqattempt->questionsattempted) {
// If this condition is met, then something went wrong because the slot id is empty BUT the questions attempted is
// Greater than zero. Stop attempt.
$this->print_debug('start_attempt() - something went horribly wrong since the quba has no slot number AND the number '.
'of question answered is greater than 0');
$this->status = get_string('errorattemptstate', 'adaptivequiz');
return false;
}
// If the slot property is set, then we have a question that is ready to be attempted. No more process is required.
if (!empty($this->slot)) {
return true;
}
// If we are here, then the slot property was unset and a new question needs to prepared for display.
$status = $this->get_question_ready($fetchquestion);
if (empty($status)) {
$var = new stdClass();
$var->level = $this->level;
$this->status = get_string('errorfetchingquest', 'adaptivequiz', $var);
return false;
}
return $status;
}
/**
* This function returns a random array element
* @param array $questions an array of question ids. Array key values are question ids
* @return int a question id
*/
public function return_random_question($questions) {
if (empty($questions)) {
return 0;
}
$questionid = array_rand($questions);
$this->print_debug('return_random_question() - random question chosen questionid: '.$questionid);
return (int) $questionid;
}
/**
* This function checks to see if the student answered the maximum number of questions
* @return bool true if the attempt is starting for the first time. Otherwise false
*/
public function max_questions_answered() {
if ($this->adpqattempt->questionsattempted >= $this->adaptivequiz->maximumquestions) {
$this->print_debug('max_questions_answered() - maximum number of questions answered');
return true;
}
return false;
}
/**
* This function checks to see if the student answered the minimum number of questions
* @return bool true if the attempt is starting for the first time. Otherwise false
*/
public function min_questions_answered() {
if ($this->adpqattempt->questionsattempted > $this->adaptivequiz->minimumquestions) {
$this->print_debug('min_questions_answered() - minimum number of questions answered');
return true;
}
return false;
}
/**
* This function retrieves the last question that was used in the attempt
* @throws moodle_exception - exception is thrown function parameter is not an instance of question_usage_by_activity class
* @param question_usage_by_activity $quba an object loaded with the unique id of the attempt
* @return int question slot or 0 if no unmarked question could be found
*/
public function find_last_quest_used_by_attempt($quba) {
if (!$quba instanceof question_usage_by_activity) {
throw new coding_exception('find_last_quest_used_by_attempt() - Argument was not a question_usage_by_activity object',
$this->vardump($quba));
}
// The last slot in the array should be the last question that was attempted (meaning it was either shown to the user
// or the user submitted an answer to it).
$questslots = $quba->get_slots();
if (empty($questslots) || !is_array($questslots)) {
$this->print_debug('find_last_quest_used_by_attempt() - No question slots found for this '.
'question_usage_by_activity object');
return 0;
}
$questslot = end($questslots);
$this->print_debug('find_last_quest_used_by_attempt() - Found a question slot: '.$questslot);
return $questslot;
}
/**
* This function determines if the user submitted an answer to the question
* @param question_usage_by_activity $quba an object loaded with the unique id of the attempt
* @param int $slot question slot id
* @return bool true if an answer to the question was submitted, otherwise false
*/
public function was_answer_submitted_to_question($quba, $slotid) {
$state = $quba->get_question_state($slotid);
// Check if the state of the quesiton attempted was graded right, partially right, wrong or gave up, count the question has
// having an answer submitted.
$marked = $state instanceof question_state_gradedright || $state instanceof question_state_gradedpartial
|| $state instanceof question_state_gradedwrong || $state instanceof question_state_gaveup;
if ($marked) {
return true;
} else {
// Save some debugging information.
$this->print_debug('was_answer_submitted_to_question() - question state is unrecognized state: '.get_class($state).'
question slotid: '.$slotid.' quba id: '.$quba->get_id());
}
return false;
}
/**
* This function initializes the question_usage_by_activity object. If an attempt unfinished attempt
* has a usage id, a question_usage_by_activity object will be loaded using the usage id. Otherwise a new
* question_usage_by_activity object is created.
*
* @throws moodle_exception Exception is thrown when required behaviour could not be found.
* @return question_usage_by_activity|null Returns a question usage by activity object or null.
*/
public function initialize_quba() {
if (!$this->behaviour_exists()) {
throw new moodle_exception('Missing '.self::ATTEMPTBEHAVIOUR.' behaviour', 'Behaviour: '.self::ATTEMPTBEHAVIOUR.
' must exist in order to use this activity');
}
if (0 == $this->adpqattempt->uniqueid) {
// Init question usage and set default behaviour of usage.
$quba = question_engine::make_questions_usage_by_activity(self::MODULENAME, $this->adaptivequiz->context);
$quba->set_preferred_behaviour(self::ATTEMPTBEHAVIOUR);
$this->quba = $quba;
$this->print_debug('initialized_quba() - question usage created');
} else {
// Load a previously used question by usage object.
$quba = question_engine::load_questions_usage_by_activity($this->adpqattempt->uniqueid);
$this->print_debug('initialized_quba() - Re-using unfinishd attempt');
}
// Set class property.
$this->quba = $quba;
return $quba;
}
/**
* This function retrieves the most recent attempt, whose state is 'inprogress'. If no attempt is found
* it creates a new attempt. Lastly $adpqattempt instance property gets set.
*
* @return stdClass adaptivequiz_attempt data object
*/
public function get_attempt() {
global $DB;
$param = ['instance' => $this->adaptivequiz->id, 'userid' => $this->userid, 'attemptstate' => attempt_state::IN_PROGRESS];
$attempt = $DB->get_records(self::TABLE, $param, 'timemodified DESC', '*', 0, 1);
if (empty($attempt)) {
$time = time();
$attempt = new stdClass();
$attempt->instance = $this->adaptivequiz->id;
$attempt->userid = $this->userid;
$attempt->uniqueid = 0;
$attempt->attemptstate = attempt_state::IN_PROGRESS;
$attempt->questionsattempted = 0;
$attempt->standarderror = 999;
$attempt->timecreated = $time;
$attempt->timemodified = $time;
$id = $DB->insert_record(self::TABLE, $attempt);
$attempt->id = $id;
$this->adpqattempt = $attempt;
$this->print_debug('get_attempt() - new attempt created: '.$this->vardump($attempt));
} else {
$attempt = current($attempt);
$this->adpqattempt = $attempt;
$this->print_debug('get_attempt() - previous attempt loaded: '.$this->vardump($attempt));
}
return $attempt;
}
/**
* This function determins whether the user answered the question correctly or incorrectly.
* If the answer is partially correct it is seen as correct.
* @param question_usage_by_activity $quba an object loaded with the unique id of the attempt
* @param int $slotid the slot id of the question
* @return float a float representing the user's mark. Or null if there was no mark
*/
public function get_question_mark($quba, $slotid) {
$mark = $quba->get_question_mark($slotid);
if (is_float($mark)) {
return $mark;
}
$this->print_debug('get_question_mark() - Question mark was not a float slot id: '.$slotid.'. Returning zero');
return 0;
}
/**
* This functions returns an array of all question ids that have been used in this attempt
*
* @return array an array of question ids
*/
public function get_all_questions_in_attempt($uniqueid) {
global $DB;
$questions = $DB->get_records_menu('question_attempts', array('questionusageid' => $uniqueid), 'id ASC', 'id,questionid');
return $questions;
}
/**
* @throws dml_exception
*/
public static function user_has_completed_on_quiz(int $adaptivequizid, int $userid): bool {
global $DB;
return $DB->record_exists(self::TABLE,
['userid' => $userid, 'instance' => $adaptivequizid, 'attemptstate' => attempt_state::COMPLETED]);
}
/**
* This function adds a message to the debugging array
* @param string $message details of the debugging message
*/
protected function print_debug($message = '') {
if ($this->debugenabled) {
$this->debug[] = $message;
}
}
/**
* Answer a string view of a variable for debugging purposes
* @param mixed $variable
*/
protected function vardump($variable) {
ob_start();
var_dump($variable);
return ob_get_clean();
}
/**
* This function gets the question ready for display to the user.
* @param fetchquestion $fetchquestion a fetchquestion object initialized to the activity instance of the attempt
* @return bool true if everything went okay, otherwise false
*/
protected function get_question_ready($fetchquestion) {
// Fetch questions already attempted.
$exclude = $this->get_all_questions_in_attempt($this->adpqattempt->uniqueid);
// Fetch questions for display.
$questionids = $fetchquestion->fetch_questions($exclude);
if (empty($questionids)) {
$this->print_debug('get_question_ready() - Unable to fetch a question $questionsids:'.$this->vardump($questionids));
return false;
}
// Select one random question.
$questiontodisplay = $this->return_random_question($questionids);
if (empty($questiontodisplay)) {
$this->print_debug('get_question_ready() - Unable to randomly select a question $questionstodisplay:'.
$questiontodisplay);
return false;
}
// Load basic question data.
$questionobj = question_preload_questions(array($questiontodisplay));
get_question_options($questionobj);
$this->print_debug('get_question_ready() - setup question options');
// Make a copy of the array and pop off the first (and only) element (current() didn't work for some reason).
$quest = $questionobj;
$quest = array_pop($quest);
// Create the question_definition object.
$question = question_bank::load_question($quest->id);
// Add the question to the usage question_usable_by_activity object.
$this->slot = $this->quba->add_question($question);
// Start the question attempt.
$this->quba->start_question($this->slot);
// Save the question usage and question attempt state to the DB.
question_engine::save_questions_usage_by_activity($this->quba);
// Update the attempt unique id.
$this->set_attempt_uniqueid();
// Set class level property to the difficulty level of the question returned from fetchquestion class.
$this->level = $fetchquestion->get_level();
$this->print_debug('get_question_ready() - Question: '.$this->vardump($question).' loaded and attempt started. '.
'Question_usage_by_activity saved.');
return true;
}
/**
* This function updates the current attempt with the question_usage_by_activity id.
*/
protected function set_attempt_uniqueid(): void {
global $DB;
$this->adpqattempt->uniqueid = $this->quba->get_id();
$DB->update_record(self::TABLE, $this->adpqattempt);
$this->print_debug('set_attempt_uniqueid() - attempt uniqueid set: '.$this->adpqattempt->uniqueid);
}
/**
* This function retrives archetypal behaviours and sets the attempt behavour to to manual grade
* @return bool true if the behaviour exists, else false
*/
protected function behaviour_exists() {
$exists = false;
$behaviours = question_engine::get_archetypal_behaviours();
if (!empty($behaviours)) {
foreach ($behaviours as $key => $behaviour) {
if (0 == strcmp(self::ATTEMPTBEHAVIOUR, $key)) {
// Behaviour found, exit the loop.
$exists = true;
break;
}
}
}
return $exists;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* A class to emulate enum type for attempt state.
*
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
declare(strict_types=1);
namespace mod_adaptivequiz\local\attempt;
final class attempt_state {
public const IN_PROGRESS = 'inprogress';
public const COMPLETED = 'complete';
/**
* @var string $stateasstring
*/
private $stateasstring;
private function __construct(string $state) {
$this->stateasstring = $state;
}
public function is_in_progress(): bool {
return self::IN_PROGRESS === $this->stateasstring;
}
public function is_completed(): bool {
return self::COMPLETED === $this->stateasstring;
}
public static function in_progress(): self {
return new self(self::IN_PROGRESS);
}
public static function completed(): self {
return new self(self::COMPLETED);
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This class performs the simple algorithm to determine the next level of difficulty a student should attempt.
* It also recommends whether the calculation has reached an acceptable level of error.
*
* @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local;
use coding_exception;
use dml_missing_record_exception;
use moodle_exception;
use question_state_gradedpartial;
use question_state_gradedright;
use question_state_gradedwrong;
use question_state_todo;
use question_usage_by_activity;
use stdClass;
class catalgo {
/** @var $quba a question_usage_by_activity object */
protected $quba = null;
/** @var $attemptid an adaptivequiz_attempt attempt id */
protected $attemptid = 0;
/**
* @var bool $debugenabled flag to denote developer debugging is enabled and this class should write message to the debug array
*/
protected $debugenabled = false;
/** @var array $debug debugging array of messages */
protected $debug = array();
/** @var int $level level of difficulty of the most recently attempted question */
protected $level = 0;
/**
* @var float $levelogit the logit value of the difficulty level represented as a percentage of the minimum and maximum
* difficulty @see compute_next_difficulty()
*/
protected $levellogit = 0.0;
/** @var bool $readytostop flag to denote whether to assume the student has met the minimum requirements */
protected $readytostop = true;
/** @var int $questattempted the sum number of questions attempted */
protected $questattempted = 0;
/** @var $difficultysum the sum of the difficulty levels attempted */
protected $difficultysum = 0;
/** @var int $nextdifficulty the next dificulty level to administer */
protected $nextdifficulty = 0;
/** @var int $sumofcorrectanswers the sum of questions answered correctly */
protected $sumofcorrectanswers;
/** @var int @sumofincorrectanswers the sum of questions answered incorretly */
protected $sumofincorrectanswers;
/** @var float $measure the ability measure */
protected $measure = 0.0;
/** @var float $standarderror the standard error of the measure */
protected $standarderror = 0.0;
/** @var string $status status message storing the reason why the attempt needs to be stopped */
protected $status = '';
/**
* Constructor to initialize the parameters needed by the adaptive alrogithm
* @throws moodle_exception - exception is thrown if first argument is not an instance of question_usage_by_activity class or
* second argument is not a positive integer.
* @param question_usage_by_activity $quba an object loaded using the unique id of the attempt
* @param int $attemptid the adaptivequiz_attempt attempt id
* @param bool $readytostop true of the algo should assume the user has answered the minimum number of question and should
* compare the results againts the standard error
* @param int $level the level of difficulty for the most recently attempted question
* @return void
*/
public function __construct($quba, $attemptid, $readytostop = true, $level = 0) {
if (!$quba instanceof question_usage_by_activity) {
throw new coding_exception('catalgo: Argument 1 is not a question_usage_by_activity object',
'Question usage by activity must be a question_usage_by_activity object');
}
if (!is_int($attemptid) || 0 >= $attemptid) {
throw new coding_exception('catalgo: Argument 2 not a positive integer',
'Attempt id argument must be a positive integer');
}
if (!is_int($level) || 0 >= $level) {
throw new coding_exception('catalgo: Argument 4 not a positive integer', 'level must be a positive integer');
}
$this->quba = $quba;
$this->attemptid = $attemptid;
$this->readytostop = $readytostop;
$this->level = $level;
if (debugging('', DEBUG_DEVELOPER)) {
$this->debugenabled = true;
}
}
/**
* This function adds a message to the debugging array
* @param string $message details of the debugging message
* @return void
*/
protected function print_debug($message = '') {
if ($this->debugenabled) {
$this->debug[] = $message;
}
}
/**
* Answer a string view of a variable for debugging purposes
* @param mixed $variable
*/
protected function vardump($variable) {
ob_start();
var_dump($variable);
return ob_get_clean();
}
/**
* This function returns the debug array
* @return array array of debugging messages
*/
public function get_debug() {
return $this->debug;
}
/**
* This function returns the $difficultysum property
* @return int returns the $difficultysum property
*/
public function get_difficultysum() {
return $this->difficultysum;
}
/**
* This function returns the $levellogit property
* @return float retuns the $levellogit property
*/
public function get_levellogit() {
return $this->levellogit;
}
/**
* This function returns the $standarderror property
* @return float retuns the $standarderror property
*/
public function get_standarderror() {
return $this->standarderror;
}
/**
* This function returns the $measure property
* @return float retuns the $measure property
*/
public function get_measure() {
return $this->measure;
}
/**
* This functions retrieves the attempt record, the highest and lowest difficulty level set for the attempt
* @throws dml_missing_record_exception
* @param int $attemptid the attempt id record
* @return stdClass adaptivequiz_attempt record
*/
public function retrieve_attempt_record($attemptid) {
global $DB;
$param = array('id' => $attemptid);
$sql = "SELECT aa.id, aa.questionsattempted, aa.difficultysum, aa.standarderror, a.highestlevel, a.lowestlevel,
a.acceptancethreshold, aa.measure
FROM {adaptivequiz_attempt} aa
JOIN {adaptivequiz} a ON a.id = aa.instance
WHERE aa.id = :id
ORDER BY id DESC";
$record = $DB->get_record_sql($sql, $param, MUST_EXIST);
return $record;
}
/**
* Refactored code from adaptiveattempt.class.php @see find_last_quest_used_by_attempt()
* This function retrieves the last question that was used in the attempt
* @return int question slot or 0 if no unmarked question could be found
*/
protected function find_last_quest_used_by_attempt() {
if (!$this->quba instanceof question_usage_by_activity) {
$this->print_debug('find_last_quest_used_by_attempt() - Argument was not a question_usage_by_activity object');
return 0;
}
// The last slot in the array should be the last question that was attempted (meaning it was either shown to the user or the
// user submitted an answer to it).
$questslots = $this->quba->get_slots();
if (empty($questslots) || !is_array($questslots)) {
$this->print_debug('find_last_quest_used_by_attempt() - No question slots found for this question_usage_by_activity '.
'object');
return 0;
}
$questslot = end($questslots);
$this->print_debug('find_last_quest_used_by_attempt() - Found a question slot: '.$questslot);
return $questslot;
}
/**
* Refactored code from adaptiveattempt.class.php @see was_answer_submitted_to_question()
* This function determines if the user submitted an answer to the question
* @param int $slot question slot id
* @return bool true if an answer to the question was submitted, otherwise false
*/
protected function was_answer_submitted_to_question($slotid) {
if (empty($slotid)) {
$this->print_debug('was_answer_submitted_to_question() refactored - slot id was zero');
return false;
}
$state = $this->quba->get_question_state($slotid);
// Check if the state of the quesiton attempted was graded right, partially right or wrong.
$marked = $state instanceof question_state_gradedright || $state instanceof question_state_gradedpartial
|| $state instanceof question_state_gradedwrong;
if ($marked) {
return true;
} else {
// Save some debugging information.
$debugmsg = 'was_answer_submitted_to_question() refactored - question state is unrecognized state: '.get_class($state);
$debugmsg .= ' questionslotid: '.$slotid.' quba id: '.$this->quba->get_id();
$this->print_debug($debugmsg);
}
return false;
}
/**
* This function determins whether the user answered the question correctly or incorrectly.
* If the answer is partially correct it is seen as correct.
* @param question_usage_by_activity $quba an object loaded using the unique id of the attempt
* @param int $slotid the slot id of the question
* @return float|null a float representing the user's mark. Or null if there was no mark
*/
public function get_question_mark($quba, $slotid) {
$mark = $quba->get_question_mark($slotid);
if (is_float($mark)) {
return $mark;
}
$this->print_debug('get_question_mark() - Question mark was not a float slot id: '.$slotid);
return null;
}
/**
* Tolerance used when comparing a question's point fraction against the acceptance threshold.
*
* It absorbs floating-point artefacts (e.g. a fully correct multiple-choice question scoring
* 0.9999999 instead of 1.0), which removes the need to round and rewrite the stored fraction.
*/
const FRACTION_TOLERANCE = 1e-6;
/**
* KNIGHT: Decide whether a point fraction reaches the acceptance threshold.
*
* A question is considered correct when its fraction is at least the threshold configured in
* the activity settings. A threshold of 0 is treated as the special "any partial credit counts"
* case, i.e. the fraction must be strictly greater than zero.
*
* @param float $fraction The point fraction received for the question (typically 0..1).
* @param float $threshold The acceptance threshold from the activity settings (0..1).
* @return bool True if the fraction meets the threshold.
*/
public static function fraction_meets_threshold(float $fraction, float $threshold): bool {
if ($threshold <= 0.0) {
return $fraction > self::FRACTION_TOLERANCE;
}
return $fraction >= $threshold - self::FRACTION_TOLERANCE;
}
/**
* KNIGHT: Turn a question's point representation into a normalized 0..1 fraction.
*
* Question types expose points differently: some provide a ready-made point fraction,
* others only a raw mark that has to be scaled by the maximum mark. When a fraction is
* available it is used directly; otherwise it is derived from mark / maxmark. Returns null
* when neither representation is available, and guards against a zero maximum mark.
*
* @param float|null $fraction The point fraction reported for the question, if any.
* @param float|null $mark The raw mark received for the question, if any.
* @param float|null $maxmark The maximum mark obtainable for the question, if any.
* @return float|null The normalized fraction, or null if it cannot be determined.
*/
public static function normalize_fraction(?float $fraction, ?float $mark, ?float $maxmark): ?float {
if (!is_null($fraction)) {
return $fraction;
}
if (is_null($mark) || is_null($maxmark) || $maxmark == 0.0) {
return null;
}
return $mark / $maxmark;
}
/**
* KNIGHT: Decide whether the answer in a question slot reaches the acceptance threshold.
*
* This is the single place where question correctness is judged. It reads the question's
* points from the usage (handling the per-question-type fraction/mark differences via
* {@see normalize_fraction()}) and compares them against the threshold via
* {@see fraction_meets_threshold()}.
*
* @param \question_usage_by_activity $quba The question usage holding the attempt.
* @param int $slot The question slot to judge.
* @param float $threshold The acceptance threshold from the activity settings (0..1).
* @return bool|null True/false for correct/incorrect, or null if the points cannot be determined.
*/
public static function question_is_correct(\question_usage_by_activity $quba, int $slot, float $threshold): ?bool {
$fraction = $quba->get_question_fraction($slot);
$mark = $quba->get_question_mark($slot);
$maxmark = $quba->get_question_max_mark($slot);
// The question engine reports whole-number points as ints (e.g. a fully correct true/false
// question yields int 1), so accept any numeric value and normalize to float rather than
// rejecting non-floats.
$normalized = self::normalize_fraction(
is_numeric($fraction) ? (float) $fraction : null,
is_numeric($mark) ? (float) $mark : null,
is_numeric($maxmark) ? (float) $maxmark : null
);
if (is_null($normalized)) {
return null;
}
return self::fraction_meets_threshold($normalized, $threshold);
}
/** @var float|null $acceptancethreshold cached acceptance threshold for this attempt */
protected $acceptancethreshold = null;
/**
* KNIGHT: Lazily load and cache the acceptance threshold configured for this attempt.
*
* @return float The acceptance threshold (0..1).
*/
protected function get_acceptance_threshold(): float {
if (is_null($this->acceptancethreshold)) {
$record = $this->retrieve_attempt_record($this->attemptid);
$this->acceptancethreshold = (float) $record->acceptancethreshold;
}
return $this->acceptancethreshold;
}
/**
* KNIGHT: Decide whether the answer in a slot is correct for this attempt's threshold.
*
* Single instance-level entry point so every correctness decision in the algorithm uses the
* same logic and the same configured threshold.
*
* @param \question_usage_by_activity $quba The question usage to read points from.
* @param int $slot The question slot to judge.
* @return bool|null True/false, or null if the points cannot be determined.
*/
protected function slot_is_correct($quba, int $slot): ?bool {
return self::question_is_correct($quba, $slot, $this->get_acceptance_threshold());
}
/**
* This function retreives the mark received from the student's submission to the question
* @return bool|null true if the question was marked correct. False if the question was marked incorrect or null if there is an
* error determining mark
*/
public function question_was_marked_correct() {
// Find the last question attempted by the user.
$slotid = $this->find_last_quest_used_by_attempt();
if (empty($slotid)) {
return null;
}
// Check if the question was marked.
if (!$this->was_answer_submitted_to_question($slotid)) {
// If no answer was submitted then the question muast be marked as incorrect. Increment questions attempted.
$this->questattempted++;
return false;
}
// KNIGHT: judge correctness against the acceptance threshold (was: mark > 0).
$correct = $this->slot_is_correct($this->quba, $slotid);
if (is_null($correct)) {
return null;
}
// Increment questions attempted.
$this->questattempted++;
return $correct;
}
/**
* This function retrieves the allowed standard error (as a percent) for the attempt
* @throws dml_missing_record_exception
* @param int $attemptid adaptivequiz_attempt id
* @return float the standard error allowed
*/
public function retrieve_standard_error($attemptid) {
global $DB;
$param = array('aaid' => $attemptid);
$sql = "SELECT a.standarderror
FROM {adaptivequiz} a
JOIN {adaptivequiz_attempt} aa ON a.id = aa.instance
WHERE aa.id = :aaid
ORDER BY a.standarderror ASC";
return (float) $DB->get_field_sql($sql, $param, MUST_EXIST);
}
/**
* This function performs the different steps in the CAT simple algorithm
* @return int returns the next difficulty level or 0 if there was an error
*/
public function perform_calculation_steps() {
// Retrieve attempt record.
$record = $this->retrieve_attempt_record($this->attemptid);
$this->difficultysum = $record->difficultysum;
$this->questattempted = $record->questionsattempted;
// If the user answered the previous question correctly, calculate the sum of correct answers.
$correct = $this->question_was_marked_correct();
if (true === $correct) {
// Compute the next difficulty level for the next question.
$this->nextdifficulty = $this->compute_next_difficulty($this->level, $this->questattempted, true, $record);
} else if (false === $correct) {
// Compute the next difficulty level for the next question.
$this->nextdifficulty = $this->compute_next_difficulty($this->level, $this->questattempted, false, $record);
} else {
$this->status = get_string('errorlastattpquest', 'adaptivequiz');
$this->print_debug('perform_calculation_steps() - Last question attempted returned a null as an answer');
return 0;
}
// If he user hasn't met the minimum requirements to end the attempt, then return with the next difficulty level.
if (empty($this->readytostop)) {
$this->print_debug('perform_calculation_steps() - Not ready to stop the attempt, returning next difficulty number');
return $this->nextdifficulty;
}
// Calculate the sum of correct answers and the sum of incorrect answers.
$this->sumofcorrectanswers = $this->compute_right_answers($this->quba);
$this->sumofincorrectanswers = $this->compute_wrong_answers($this->quba);
if (0 == $this->questattempted) {
$this->status = get_string('errornumattpzero', 'adaptivequiz');
$this->print_debug('perform_calculation_steps() - number of questions attempted equals zero');
return 0;
}
// Test that the sum of incorrect and correct answers equal to the sum of question attempted.
$validatenumbers = $this->sumofcorrectanswers + $this->sumofincorrectanswers;
if ($validatenumbers != $this->questattempted) {
$this->status = get_string('errorsumrightwrong', 'adaptivequiz');
$this->print_debug('perform_calculation_steps() - Sum of correct and incorrect answers ('.$validatenumbers.') '.
'doesn\'t equals the total number of questions attempted ('.$this->questattempted.')');
return 0;
}
// Get the measure estimate.
$this->measure = self::estimate_measure($this->difficultysum, $this->questattempted, $this->sumofcorrectanswers,
$this->sumofincorrectanswers);
// Get the standard error estimate.
$this->standarderror = self::estimate_standard_error($this->questattempted, $this->sumofcorrectanswers,
$this->sumofincorrectanswers);
$this->print_debug('perform_calculation_steps() - difficultysum: '.$this->difficultysum.', questattempted: '.
$this->questattempted.', sumofcorrectanswers: '.$this->sumofcorrectanswers.', sumofincorrectanswers: '.
$this->sumofincorrectanswers.' =&gt; measure: '.$this->measure.', standard error: '.$this->standarderror);
// Retrieve the standard error (as a percent) set for the attempt, convert it into a decimal percent then
// convert to a logit.
$quizdefinederror = $this->retrieve_standard_error($this->attemptid);
$quizdefinederror = $quizdefinederror / 100;
$quizdefinederror = self::convert_percent_to_logit($quizdefinederror);
// If the calculated standard error is within the parameters of the attempt then populate the status message.
if ($this->standard_error_within_parameters($this->standarderror, $quizdefinederror)) {
// Convert logits to percent for display.
$val = new stdClass();
$val->calerror = self::convert_logit_to_percent($this->standarderror);
$val->calerror = 100 * round($val->calerror, 2);
$val->definederror = self::convert_logit_to_percent($quizdefinederror);
$val->definederror = 100 * round($val->definederror, 2);
$this->status = get_string('calcerrorwithinlimits', 'adaptivequiz', $val);
}
$this->print_debug('perform_calculation_steps() - measure: '.$this->measure.' standard error: '.$this->standarderror);
return $this->nextdifficulty;
}
/**
* This function returns the currently set status message
* @return string the status message property
*/
public function get_status() {
return $this->status;
}
/**
* This function takes a percent as a float between 0 and less than 0.5 and converts it into a logit value
* @throws coding_exception if percent is out of bounds
* @param float $percent percent represented as a decimal 15% = 0.15
* @return float logit value of percent
*/
public static function convert_percent_to_logit($percent) {
if ($percent < 0 || $percent >= 0.5) {
throw new coding_exception('convert_percent_to_logit: percent is out of bounds', 'Percent must be 0 >= and < 0.5');
}
return log( (0.5 + $percent) / (0.5 - $percent) );
}
/**
* This function takes a logit as a float greater than or equal to 0 and converts it into a percent
* @throws coding_exception if logit is out of bounds
* @param float $logit logit value
* @return float logit value of percent
*/
public static function convert_logit_to_percent($logit) {
if ($logit < 0) {
throw new coding_exception('convert_logit_to_percent: logit is out of bounds',
'logit must be greater than or equal to 0');
}
return ( 1 / ( 1 + exp(0 - $logit) ) ) - 0.5;
}
/**
* Convert a logit value to a fraction between 0 and 1.
* @param float $logit logit value
* @return float the logit value mapped as a fraction
*/
public static function convert_logit_to_fraction($logit) {
return exp($logit) / ( 1 + exp($logit) );
}
/**
* This function takes the inverse of a logit value, then maps the value onto the scale defined for the attempt
* @param float $logit logit value
* @param int $max the maximum value of the scale
* @param int $min the minimum value of the scale
* @return float the logit value mapped onto the scale
*/
public static function map_logit_to_scale($logit, $max, $min) {
$fraction = self::convert_logit_to_fraction($logit);
$scaledvalue = ( ( $max - $min ) * $fraction ) + $min;
return $scaledvalue;
}
/**
* This function compares the calulated standard error with the activity defined standard error allowd for the attempt
* @param float $calculatederror the error calculated from the parameters of the user's current attempt
* @param float $definederror the allowed error set for the activity instance
* @return bool true if the calulated error is less than or equal to the defined error, otherwise false
*/
public function standard_error_within_parameters($calculatederror, $definederror) {
if ($calculatederror <= $definederror) {
return true;
} else {
return false;
}
}
/**
* This function estimates the standard error in the measurement
* @param int $questattempt the number of question attempted
* @param int $sumcorrect the sum of correct answers
* @param int $sumincorrect the sum of incorrect answers
* @return float a decimal rounded to 5 places is returned
*/
public static function estimate_standard_error($questattempt, $sumcorrect, $sumincorrect) {
if ($sumincorrect == 0) {
$standarderror = sqrt($questattempt / ( ($sumcorrect - 0.5) * ($sumincorrect + 0.5) ) );
} else if ($sumcorrect == 0) {
$standarderror = sqrt($questattempt / ( ($sumcorrect + 0.5) * ($sumincorrect - 0.5) ) );
} else {
$standarderror = sqrt($questattempt / ( $sumcorrect * $sumincorrect ) );
}
return round($standarderror, 5);
}
/**
* This function estimates the measure of ability
* @param float $diffsum the sum of difficulty levels expressed as logits
* @param int $questattempt the number of question attempted
* @param int $sumcorrect the sum of correct answers
* @param int $sumincorrect the sum of incorrect answers
* @return float an estimate of the measure of ability
*/
public static function estimate_measure($diffsum, $questattempt, $sumcorrect, $sumincorrect) {
if ($sumincorrect == 0) {
$measure = ($diffsum / $questattempt) + log( ($sumcorrect - 0.5) / ($sumincorrect + 0.5) );
} else if ($sumcorrect == 0) {
$measure = ($diffsum / $questattempt) + log( ($sumcorrect + 0.5) / ($sumincorrect - 0.5) );
} else {
$measure = ($diffsum / $questattempt) + log( $sumcorrect / $sumincorrect );
}
return round($measure, 5, PHP_ROUND_HALF_UP);
}
/**
* This function counts the total number of correct answers for the attempt
* @param question_usage_by_activity $quba an object loaded using the unique id of the attempt
* @return int the number of correct answer submission
*/
public function compute_right_answers($quba) {
$correctanswers = 0;
// Get question slots for the attempt.
$slots = $quba->get_slots();
// Iterate over slots and count correct answers.
// KNIGHT: a slot counts as correct when it reaches the acceptance threshold (was: mark > 0).
foreach ($slots as $slot) {
if ($this->slot_is_correct($quba, $slot) === true) {
$correctanswers++;
}
}
$this->print_debug('compute_right_answers() - Sum of correct answers: '.$correctanswers);
return $correctanswers;
}
/**
* This function counts the total number of incorrect answers for the attempt
* @param question_usage_by_activity $quba an object loaded using the unique id of the attempt
* @return int the number of correct answer submission
*/
public function compute_wrong_answers($quba) {
$incorrectanswers = 0;
// Get question slots for the attempt.
$slots = $quba->get_slots();
// Iterate over slots and count incorrect answers.
// KNIGHT: anything not reaching the acceptance threshold (or undeterminable) counts as wrong.
foreach ($slots as $slot) {
if ($this->slot_is_correct($quba, $slot) !== true) {
$incorrectanswers++;
}
}
$this->print_debug('compute_right_answers() - Sum of incorrect answers: '.$incorrectanswers);
return $incorrectanswers;
}
/**
* This function is a helper method to compute the current difficult level the attempt is at
* @throws coding_exception if any of the parameters contain invalid data
* @param question_usage_by_activity $quba a question usage by activity set to an attempt id
* @param int $startinglevel the starting level of difficulty for the attempt
* @param stdClass $attemptobj an object with the following properties: lowestlevel and highestlevel
* @return int the current level of difficulty
*/
public function get_current_diff_level($quba, $level, $attemptobj) {
// Check if level is a positive integer.
if (!is_int($level) || 0 >= $level) {
throw new coding_exception('get_current_diff_level: Arg 2 needs to be a positive integer',
'Invalid level of :'.$level.' was passed');
}
// Check if quba is a valid instance of question_usage_by_activity.
if (!$quba instanceof question_usage_by_activity) {
throw new coding_exception('get_current_diff_level: Arg 1 needs to be an instance of question_usage_by_activity',
'Invalid quba of :'.get_class($quba));
}
// Check if attempt object has required properties defined.
if (!isset($attemptobj->lowestlevel) || !isset($attemptobj->highestlevel)) {
throw new coding_exception('get_current_diff_level: Arg 3 needs to have lowestlevel and highestlevel properties',
'Invalid attemptobj of :'.$this->vardump($attemptobj));
}
// Check if attempt object has required property value types.
$conditions = !is_int($attemptobj->lowestlevel) || 0 >= $attemptobj->lowestlevel || !is_int($attemptobj->highestlevel)
|| 0 >= $attemptobj->highestlevel || $attemptobj->lowestlevel >= $attemptobj->highestlevel;
if ($conditions) {
throw new coding_exception('get_current_diff_level: Arg 3 lowestlevel and highestlevel properties must be positive '.
'integers', 'Invalid attemptobj of :'.$this->vardump($attemptobj));
}
// KNIGHT: Seed the acceptance threshold from the activity settings we already have here, so the
// correctness checks below reuse it instead of reloading the attempt record from the database.
// attempt.php calls this with a placeholder attempt id ("constructor arguments are not important"),
// where that lookup would throw dml_missing_record_exception.
if (isset($attemptobj->acceptancethreshold)) {
$this->acceptancethreshold = (float) $attemptobj->acceptancethreshold;
}
return $this->return_current_diff_level($quba, $level, $attemptobj);
}
/**
* This function calculates the currently difficulty level of the attempt.
* @param question_usage_by_activity $quba a question usage by activity set to an attempt id
* @param int $level the starting level of difficulty for the attempt
* @param stdClass $attemptobj an object with the following properties: lowestlevel and highestlevel
* @return int the current level of difficulty
*/
protected function return_current_diff_level($quba, $level, $attemptobj) {
$questattempted = 0;
$correct = false;
// Set current difficulty to the starting level.
$currdiff = $level;
// Get question slots for the attempt.
$slots = $quba->get_slots();
if (empty($slots)) {
return 0;
}
// Get the last question's state.
$state = $quba->get_question_state(end($slots));
// If the state of the last question in the attempt is 'todo' remove it from the array, as the user never submitted their
// answer.
if ($state instanceof question_state_todo) {
array_pop($slots);
}
// Reset the array pointer back to the beginning.
reset($slots);
// Iterate over slots and count correct answers.
// KNIGHT: correctness is judged against the acceptance threshold (was: mark > 0).
foreach ($slots as $slot) {
$correct = ($this->slot_is_correct($quba, $slot) === true);
$questattempted++;
$currdiff = $this->compute_next_difficulty($currdiff, $questattempted, $correct, $attemptobj);
}
return $currdiff;
}
/**
* This function does the work to determine the next difficulty level
* @param int $level the difficulty level of the last question attempted
* @param int $questattempted the sum of questions attempted
* @param bool $correct true of the user got the previous question correct, otherwise false
* @param stdClass $attempt a data record returned from @see retrieve_attempt_record()
* @return int the next difficult level
*/
public function compute_next_difficulty($level, $questattempted, $correct, $attempt) {
$nextdifficulty = 0;
// Map the linear scale to a logrithmic logit scale.
$ls = self::convert_linear_to_logit($level, $attempt->lowestlevel, $attempt->highestlevel);
// Set the logit value of the previously attempted question's difficulty level.
$this->levellogit = $ls;
$this->difficultysum = $this->difficultysum + $this->levellogit;
// Check if the last question was marked correctly.
if ($correct) {
$nextdifficulty = $ls + 2 / $questattempted;
} else {
$nextdifficulty = $ls - 2 / $questattempted;
}
// Calculate the inverse to translate the value into a difficulty level.
$invps = 1 / ( 1 + exp( (-1 * $nextdifficulty) ) );
$invps = round($invps, 2);
$difflevel = $attempt->lowestlevel + ( $invps * ($attempt->highestlevel - $attempt->lowestlevel) );
$difflevel = round($difflevel);
$this->print_debug('compute_next_difficulty() - Next difficulty level is: '.$difflevel);
return (int) $difflevel;
}
/**
* Map an linear-scale difficulty/ability level to a logit scale
*
* @param int $level An integer level
* @param int $min The lower bound of the scale
* @param int $max The upper bound of the scale
* @return float
*/
public static function convert_linear_to_logit($level, $min, $max) {
// Map the level on a linear percentage scale.
$percent = ($level - $min) / ($max - $min);
// We will use a limit that is 1/2th the granularity of the question levels as our base.
// For example, for levels 1-100, we will use a base of 0.5% (5.3 logits),
// for levels 1-1000 we will use a base of 0.05% (7.6 logits).
//
// Note that the choice of 1/2 the granularity is somewhat arbitrary.
// The floor value for the ends of the scale is being chosen so that answers
// at the end of the scale do not excessively weight the ability measure
// in ways that are not recoverable by subsequent answers.
//
// For example, lets say that on a scale of 1-10, a user of level 5 makes
// a dumb mistake and answers two level 1 questions wrong, but then continues
// the test and answers 20 more questions with every question up to level 5
// right and those above wrong. The test should likely score the user somewhere
// a bit below 5 with 5 being included in the Standard Error.
//
// Several test runs with different floors showed that 1/1000 gave far too
// much weight to answers at the edge of the scale. 1/10 did ok, but
// 1/2 seemed to allow recovery from spurrious answers at the edges while
// still allowing consistent answers at the edges to trend the ability measure to
// the top/bottom level.
$granularity = 1 / ($max - $min);
$percentfloor = $granularity / 2;
// Clamp the percentage into the open interval (0, 1). Besides avoiding a division by zero,
// this keeps the result finite for levels outside [min, max]: such a level yields a percentage
// below 0 or above 1, whose logit would be log() of a negative number (NAN) and slip past the
// is_infinite() check below, crashing the attempt reports. Out-of-range levels can occur, e.g.,
// when a question is tagged with a difficulty outside the activity's configured range.
if ($percent >= 1) {
$percent = 1 - $percentfloor;
} else if ($percent <= 0) {
$percent = $percentfloor;
}
// Map the percentage scale to a logrithmic logit scale.
$logit = log( $percent / (1 - $percent) );
// Check if result is inifinite.
if (is_infinite($logit)) {
$logitfloor = log( $percentfloor / (1 - $percentfloor) );
if ($logit > 0) {
return -1 * $logitfloor;
} else {
return $logitfloor;
}
}
return $logit;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_adaptivequiz\local;
use coding_exception;
use dml_exception;
use dml_read_exception;
use invalid_parameter_exception;
use mod_adaptivequiz\item_bank_helper;
use mod_adaptivequiz\local\repository\questions_number_per_difficulty;
use mod_adaptivequiz\local\repository\questions_repository;
use mod_adaptivequiz\local\repository\tags_repository;
use moodle_exception;
use stdClass;
/**
* This class does the work of fetching questions associated with a level of difficulty in the item bank.
*
* @package mod_adaptivequiz
* @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class fetchquestion {
/**
* The maximum number of attempts at finding a tag containing questions
*/
const MAXTAGRETRY = 5;
/**
* The maximum number of tries at finding avaiable questions
*/
const MAXNUMTRY = 100000;
/** @var stdClass $adaptivequiz object, properties come from the adaptivequiz table */
protected $adaptivequiz;
/**
* @var bool $debugenabled flag to denote developer debugging is enabled and this class should write message to the debug array
*/
protected $debugenabled = false;
/** @var array $debug array containing debugging information */
protected $debug = array();
/** @var array $tags an array of tags that used to identify eligible questions for the attempt */
protected $tags = array();
/** @var int $level the level of difficutly that will be used to fetch questions */
protected $level = 1;
/** @var int[] $questcatids An array of question category ID. */
protected $questcatids = [];
/** @var int $minimumlevel the minimum level achievable in the attempt */
protected $minimumlevel;
/** @var int $maximumlevel the maximum level achievable in the attempt */
protected $maximumlevel;
/**
* @var array $tagquestsum an array whose keys are difficulty numbers and values are the sum of questions associated with the
* difficulty level
*/
protected $tagquestsum = array();
/** @var bool $rebuild a flag used to force the rebuilding of the $tagquestsum property */
public $rebuild = false;
/**
* The constructor.
*
* @param stdClass $adaptivequiz A record object from {adaptivequiz}.
* @param int $level Level of difficulty to look for when fetching a question.
* @param int $minimumlevel The minimum level the student can achieve.
* @param int $maximumlevel The maximum level the student can achieve.
* @param array $tags An array of accepted tags.
* @throws coding_exception
*/
public function __construct($adaptivequiz, $level, $minimumlevel, $maximumlevel, $tags = []) {
global $SESSION;
$this->adaptivequiz = $adaptivequiz;
$this->tags = $tags;
$this->tags[] = ADAPTIVEQUIZ_QUESTION_TAG;
$this->minimumlevel = $minimumlevel;
$this->maximumlevel = $maximumlevel;
if (!is_int($level) || 0 >= $level) {
throw new coding_exception('Argument 2 is not an positive integer', 'Second parameter must be a positive integer');
}
if ($minimumlevel >= $maximumlevel) {
throw new coding_exception('Minimum level is greater than maximum level',
'Invalid minimum and maximum parameters passed');
}
$this->level = $level;
// Initialize $tagquestsum property.
if (!isset($SESSION->adpqtagquestsum)) {
$SESSION->adpqtagquestsum = array();
$this->tagquestsum = $SESSION->adpqtagquestsum;
} else {
$this->tagquestsum = $SESSION->adpqtagquestsum;
}
if (debugging('', DEBUG_DEVELOPER)) {
$this->debugenabled = true;
}
}
/**
* This function sets the level of difficulty property
* @param int $level level of difficulty
* @return void
*/
public function set_level($level = 1) {
if (!is_int($level) || 0 >= $level) {
throw new coding_exception('Argument 1 is not an positive integer', 'First parameter must be a positive integer');
}
$this->level = $level;
}
/**
* This function returns the level of difficulty property
* @return int - level of difficulty
*/
public function get_level() {
return $this->level;
}
/**
* Reset the maximum question level to search for to a new value
*
* @param int $maximumlevel
* @return void
* @throws coding_exception if the maximum level is less than minimum level
*/
public function set_maximum_level($maximumlevel) {
if ($maximumlevel < $this->minimumlevel) {
throw new coding_exception('Maximum level is less than minimum level', 'Invalid maximum level set.');
}
$this->maximumlevel = $maximumlevel;
}
/**
* Reset the maximum question level to search for to a new value
*
* @param int $maximumlevel
* @return void
* @throws coding_exception if the minimum level is less than maximum level
*/
public function set_minimum_level($minimumlevel) {
if ($minimumlevel > $this->maximumlevel) {
throw new coding_exception('Minimum level is less than maximum level', 'Invalid minimum level set.');
}
$this->minimumlevel = $minimumlevel;
}
/**
* This functions adds a message to the debugging array
* @param string $message: details of the debugging message
* @return void
*/
protected function print_debug($message = '') {
if ($this->debugenabled) {
$this->debug[] = $message;
}
}
/**
* Answer a string view of a variable for debugging purposes
* @param mixed $variable
*/
protected function vardump($variable) {
ob_start();
var_dump($variable);
return ob_get_clean();
}
/**
* This function returns the debug array
* @return array - array of debugging messages
*/
public function get_debug() {
return $this->debug;
}
/**
* This functions returns the $tagquestsum class property
* @return array an array whose keys are difficulty levels and values are the sum of questions associated with the difficulty
*/
public function get_tagquestsum() {
return $this->tagquestsum;
}
/**
* This functions sets the $tagquestsum class property
* @param array an array whose keys are difficulty levels and values are the sum of questions associated with the difficulty
*/
public function set_tagquestsum($tagquestsum) {
$this->tagquestsum = $tagquestsum;
}
/**
* This function decrements 1 from the sum of questions in a difficulty level
* @param array $tagquestsum an array equal to the $tagquestsum property, where the key is the difficulty level and the value
* is the total number of
* questions associated with it. This parameter will be modified.
* @param int $level the difficulty level
* @return array an array whose keys are difficulty levels and values are the sum of questions associated with the difficulty
*/
public function decrement_question_sum_from_difficulty($tagquestsum, $level) {
if (array_key_exists($level, $tagquestsum)) {
$tagquestsum[$level] -= 1;
}
return $tagquestsum;
}
/**
* This function first checks if the session variable already contains a mapping of difficulty levels and the number of
* questions associated with each level. Otherwise it constructos a mapping of difficulty levels and the number of questions
* in each difficulty level.
* @param array $tagquestsum an array equal to the $tagquestsum property, where the key is the difficulty level and the value
* is the total number of
* questions associated with it. This parameter will be modified.
* @param array $tags an array of tags used by the activity
* @param int $min the minimum difficulty allowed for the attempt
* @param int $max the maximum difficulty allowed for the attempt
* @param bool $rebuild true to force the rebuilding the difficulty question count array, otherwise false. Set to "true" only
* for brand new attempts
* @return array an array whose keys are difficulty levels and values are the sum of questions associated with the difficulty
*/
public function initalize_tags_with_quest_count($tagquestsum, $tags, $min, $max, $rebuild = false) {
global $SESSION;
// Check to see if the tagquestsum argument is initialized.
$count = count($tagquestsum);
if (empty($count) || !empty($rebuild)) {
$tagquestsum = array();
// Retrieve the question categories set for this activity.
$questcat = $this->retrieve_question_categories();
// Traverse through the array of configured tags used by the activity.
foreach ($tags as $tag) {
// Retrieve all of id for the configured tag.
$tagids = $this->retrieve_all_tag_ids($min, $max, $tag);
// Retrieve a count of all of the questions associated with each tag.
$difficultiesquestionsnumber = $this->retrieve_tags_with_question_count($tagids, $questcat);
// Traverse the $difficultiesquestionsnumber array and add the values with the values current in the
// $tagquestsum argument.
foreach ($difficultiesquestionsnumber as $questionsnumberperdifficulty) {
$difflevel = $questionsnumberperdifficulty->difficulty();
$totalquestindiff = $questionsnumberperdifficulty->questions_number();
// If the array key exists, then add the sum to what is already in the array.
if (array_key_exists($difflevel, $tagquestsum)) {
$tagquestsum[$difflevel] += $totalquestindiff;
} else {
$tagquestsum[$difflevel] = $totalquestindiff;
}
}
}
} else {
$tagquestsum = $SESSION->adpqtagquestsum;
}
return $tagquestsum;
}
/**
* This function retrieves a question associated with a Moodle tag level of difficulty. If the search for the tag turns up
* empty the function tries to find another tag whose difficulty level is either higher or lower
* @param array $excquestids an array of question ids to exclude from the search
* @return array an array of question ids
*/
public function fetch_questions($excquestids = array()) {
$questids = array();
// Initialize the difficulty tag question sum property for searching.
$this->tagquestsum = $this->initalize_tags_with_quest_count($this->tagquestsum, $this->tags, $this->minimumlevel,
$this->maximumlevel, $this->rebuild);
// If tagquestsum property ie empty then return with nothing.
if (empty($this->tagquestsum)) {
$this->print_debug('fetch_questions() - tagquestsum is empty');
return array();
}
// Check if the requested level has available questions.
if (array_key_exists($this->level, $this->tagquestsum) && 0 < $this->tagquestsum[$this->level]) {
$tagids = $this->retrieve_tag($this->level);
$questids = $this->find_questions_with_tags($tagids, $excquestids);
$this->print_debug('fetch_questions() - Requested level '.$this->level.' has available questions. '.
$this->tagquestsum[$this->level].' question remaining.');
return $questids;
}
// Look for a level that has avaialbe qustions.
$level = $this->level;
for ($i = 1; $i <= self::MAXNUMTRY; $i++) {
// Check if the offset level is now out of bounds and stop the loop.
if ($this->minimumlevel > $level - $i && $this->maximumlevel < $level + $i) {
$i += self::MAXNUMTRY + 1;
$this->print_debug('fetch_questions() - searching levels has gone out of bounds of the min and max levels. '.
'No questions returned');
continue;
}
// First check a level higher than the originally requested level.
$newlevel = $level + $i;
/*
* If the level is within the boundries set for the attempt and the level exists and the count of question is greater
* than zero, retrieve the tag id and the questions available
*/
$condition = $newlevel <= $this->maximumlevel && array_key_exists($newlevel, $this->tagquestsum)
&& 0 < $this->tagquestsum[$newlevel];
if ($condition) {
$tagids = $this->retrieve_tag($newlevel);
$questids = $this->find_questions_with_tags($tagids, $excquestids);
$this->level = $newlevel;
$i += self::MAXNUMTRY + 1;
$this->print_debug('fetch_questions() - original level could not be found. Returned a question from level '.
$newlevel.' instead');
continue;
}
// Check a level lower than the originally requested level.
$newlevel = $level - $i;
/*
* If the level is within the boundries set for the attempt and the level exists and the count of question is greater
* than zero, retrieve the tag id and thequestions available
*/
$condition = $newlevel >= $this->minimumlevel && array_key_exists($newlevel, $this->tagquestsum)
&& 0 < $this->tagquestsum[$newlevel];
if ($condition) {
$tagids = $this->retrieve_tag($newlevel);
$questids = $this->find_questions_with_tags($tagids, $excquestids);
$this->level = $newlevel;
$i += self::MAXNUMTRY + 1;
$this->print_debug('fetch_questions() - original level could not be found. Returned a question from level '
.$newlevel.' instead');
continue;
}
}
return $questids;
}
/**
* This function retrieves all the tag ids that can be used in this attempt.
*
* @param int $minimumlevel The minimum level the student can achieve.
* @param int $maximumlevel The maximum level the student can achieve.
* @param string $tagprefix The tag prefix used.
* @return array An array whose keys represent the difficulty level and values are tag ids.
* @throws coding_exception
* @throws dml_exception
* @throws moodle_exception
*/
public function retrieve_all_tag_ids(int $minimumlevel, int $maximumlevel, string $tagprefix): array {
if (empty(trim($tagprefix))) {
throw new invalid_parameter_exception('Tag prefix cannot be empty.');
}
$tags = array_map(function(int $level): string {
return ADAPTIVEQUIZ_QUESTION_TAG . $level;
}, range($minimumlevel, $maximumlevel));
if (!$leveltagidmap = tags_repository::get_question_level_to_tag_id_mapping_by_tag_names($tags)) {
return [];
}
return $leveltagidmap;
}
/**
* This function determines how many questions are associated with a tag, for questions contained in the category
* used by the activity.
*
* @param array $tagids an array whose key is the difficulty level and value is the tag id representing the difficulty level
* @param array $categories an array whose key and value is the question category id
* @return questions_number_per_difficulty[]
* @throws coding_exception
* @throws dml_read_exception
* @throws dml_exception
*/
public function retrieve_tags_with_question_count($tagids, $categories): array {
return questions_repository::count_questions_number_per_difficulty($tagids, $categories);
}
/**
* This function retrieves all tag ids, used by this activity and associated with a particular level of difficulty.
*
* @param int $level The level of difficulty (optional). If 0 is passed then the function will use the level class
* property, otherwise the argument value will be used.
* @return array An array whose keys represent the difficulty level and values are tag ids.
* @throws dml_exception
* @throws coding_exception
*/
public function retrieve_tag(int $level = 0): array {
$tags = array_map(function(string $tag) use($level): string {
return $tag . $level;
}, $this->tags);
if (!$tagidlist = tags_repository::get_tag_id_list_by_tag_names($tags)) {
return [];
}
return $tagidlist;
}
/**
* This function retrieves questions within the assigned question categories and
* questions associated with tagids
* @param array $tagids an array of tag is
* @param array $exclude an array of question ids to exclude from the search
* @return array an array whose keys are qustion ids and values are the question names
*/
public function find_questions_with_tags($tagids = [], $exclude = []) {
$questcat = $this->retrieve_question_categories();
return questions_repository::find_questions_with_tags($tagids, $questcat, $exclude);
}
/**
* This function retrieves all of the question categories used the activity.
*
* @return int[] An array of quesiton category ids.
*/
protected function retrieve_question_categories(): array {
// Check cached result.
if (!empty($this->questcatids)) {
return $this->questcatids;
}
$qcategoryidlist = item_bank_helper::get_question_categories($this->adaptivequiz->id);
// Cache the results.
$this->questcatids = $qcategoryidlist;
return $qcategoryidlist;
}
/**
* The destruct method saves the difficult level and qustion number mapping to the session variable
*/
public function __destruct() {
global $SESSION;
$SESSION->adpqtagquestsum = $this->tagquestsum;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This class provides access to various numeric representations of a score.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis;
use mod_adaptivequiz\local\catalgo;
class attempt_score {
/** @var float $measuredabilitylogits The measured ability of the attempt in logits. */
protected $measuredabilitylogits = null;
/** @var float $standarderrorlogits The standard error in the score in logits. */
protected $standarderrorlogits = null;
/** @var float $lowestlevel The lowest level of question in the adaptive quiz. */
protected $lowestlevel = null;
/** @var float $highestlevel The highest level of question in the adaptive quiz. */
protected $highestlevel = null;
/**
* Constructor
*
* @return void
*/
public function __construct ($measuredabilitylogits, $standarderrorlogits, $lowestlevel, $highestlevel) {
$this->measuredabilitylogits = $measuredabilitylogits;
$this->standarderrorlogits = $standarderrorlogits;
$this->lowestlevel = $lowestlevel;
$this->highestlevel = $highestlevel;
}
/**
* Answer the measured ability in logits.
*
* @return float
*/
public function measured_ability_in_logits () {
return $this->measuredabilitylogits;
}
/**
* Answer the standard error in logits.
*
* @return float
*/
public function standard_error_in_logits () {
return $this->standarderrorlogits;
}
/**
* Answer the measured ability as a fraction 0-1.
*
* @return float
*/
public function measured_ability_in_fraction () {
return catalgo::convert_logit_to_fraction($this->measuredabilitylogits);
}
/**
* Answer the standard error a fraction 0-0.5.
*
* @return float
*/
public function standard_error_in_fraction () {
return catalgo::convert_logit_to_percent($this->standarderrorlogits);
}
/**
* Answer the measured ability on the adaptive quiz's scale
*
* @return float
*/
public function measured_ability_in_scale () {
return catalgo::map_logit_to_scale($this->measuredabilitylogits, $this->highestlevel, $this->lowestlevel);
}
/**
* Answer the standard error on the adaptive quiz's scale
*
* @return float
*/
public function standard_error_in_scale () {
return catalgo::convert_logit_to_percent($this->standarderrorlogits) * ($this->highestlevel - $this->lowestlevel);
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This class provides a mechanism for analysing the usage, performance, and efficacy
* of a single question in an adaptive quiz.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis;
use context;
use InvalidArgumentException;
use mod_adaptivequiz\local\catalgo;
use mod_adaptivequiz\local\questionanalysis\statistics\question_statistic;
use mod_adaptivequiz\local\questionanalysis\statistics\question_statistic_result;
use question_definition;
use stdClass;
class question_analyser {
/** @var context the context this usage belongs to. */
protected $context;
/** @var question_definition $definition The question */
protected $definition = null;
/** @var float $level The question level */
protected $level = null;
/** @var float $lowestlevel The lowest question-level in the adaptive quiz */
protected $lowestlevel = null;
/** @var float $highestlevel The highest question-level in the adaptive quiz */
protected $highestlevel = null;
/** @var array $results An array of the re objects */
protected $results = array();
/** @var array $statistics An array of the adaptivequiz_question_statistic added to this report */
protected $statistics = array();
/** @var array $statisticresults An array of the adaptivequiz_question_statistic_result added to this report */
protected $statisticresults = array();
/**
* Constructor - Create a new analyser.
*
* @param object $context
* @param question_definition $definition
* @param float $level The level (0-1) of the question.
* @return void
*/
public function __construct ($context, question_definition $definition, $level, $lowestlevel, $highestlevel) {
$this->context = $context;
$this->definition = $definition;
$this->level = $level;
$this->lowestlevel = $lowestlevel;
$this->highestlevel = $highestlevel;
}
/**
* Add an usage result for this question.
*
* @param attempt_score $score The user's score on this attempt.
* @param boolean $correct True if the user answered correctly.
* @param string $answer
* @return void
*/
public function add_result ($attemptid, $user, attempt_score $score, $correct, $answer) {
$result = new stdClass();
$result->attemptid = $attemptid;
$result->user = $user;
$result->score = $score;
$result->correct = $correct;
$result->answer = $answer;
$this->results[] = $result;
}
/**
* @return context the context this usage belongs to.
*/
public function get_owning_context() {
return $this->context;
}
/**
* Answer the question definition for this question.
*
* @return question_definition
*/
public function get_question_definition () {
return $this->definition;
}
/**
* Answer the question level for this question.
*
* @return int
*/
public function get_question_level () {
return $this->level;
}
/**
* Answer the question level for this question in logits.
*
* @return int
*/
public function get_question_level_in_logits () {
return catalgo::convert_linear_to_logit($this->level, $this->lowestlevel, $this->highestlevel);
}
/**
* Answer the results for this question
*
* @return array An array of stdClass objects.
*/
public function get_results () {
return $this->results;
}
/**
* Add and calculate a statistic.
*
* @param string $key A key to identify this statistic for sorting and printing.
* @param question_statistic $statistic
* @return void
*/
public function add_statistic ($key, question_statistic $statistic) {
if (!empty($this->statistics[$key])) {
throw new InvalidArgumentException("Statistic key '$key' is already in use.");
}
$this->statistics[$key] = $statistic;
$this->statisticresults[$key] = $statistic->calculate($this);
}
/**
* Answer a statistic result.
*
* @param string $key A key to identify this statistic.
* @return question_statistic_result
*/
public function get_statistic_result ($key) {
if (empty($this->statisticresults[$key])) {
throw new InvalidArgumentException("Unknown statistic key '$key'.");
}
return $this->statisticresults[$key];
}
/**
* Utility function to map a logit value to this question's scale
*
* @param $logit
* @return float Scaled value
*/
public function map_logit_to_scale ($logit) {
return catalgo::map_logit_to_scale($logit, $this->highestlevel, $this->lowestlevel);
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This class stores information about a particular attempt's result on a question
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis;
use Exception;
use InvalidArgumentException;
class question_result {
/** @var float $_measuredability The measured ability of the user who attempted this question */
protected $_measuredability = null;
/** @var boolean $_correct True if the user was correct in their answer */
protected $_correct = null;
/**
* Constructor - Create a new result.
*
* @param float $measuredability The measured ability (0-1) of the user in this attempt.
* @param boolean $correct
* @return void
*/
public function __construct ($measuredability, $correct) {
if (!is_numeric($measuredability) || $measuredability < 0 || $measuredability > 1) {
throw new InvalidArgumentException('$measuredability must be a float between 0 and 1.');
}
$this->_measuredability = $measuredability;
$this->_correct = (bool)$correct;
}
/**
* Magic method to provide read-only access to our parameters
*
* @param $key
* @return mixed
*/
public function __get ($key) {
$param = '$_'.$key;
if (isset($this->$param)) {
return $this->$param;
} else {
throw new Exception('Unknown property, '.get_class($this).'->'.$key.'.');
}
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_adaptivequiz\local\questionanalysis;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot.'/tag/lib.php');
use core_tag_tag;
use Exception;
use InvalidArgumentException;
use mod_adaptivequiz\local\attempt\attempt_state;
use mod_adaptivequiz\local\catalgo;
use mod_adaptivequiz\local\questionanalysis\statistics\question_statistic;
use question_engine;
use stdClass;
/**
* Questions-analyser class.
*
* The class provides a mechanism for loading and analysing question usage, performance, and efficacy.
*
* @package mod_adaptivequiz
* @copyright 2013 Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class quiz_analyser {
/** @var array $questions An array of all questions loaded and their stats */
protected $questions = array();
/** @var array $statistics An array of the statistics added to this report */
protected $statistics = array();
/**
* Constructor - Create a new analyser.
*
* @return void
*/
public function __construct() {
}
/**
* Load attempts from an adaptive quiz instance.
*
* @param int $instance
*/
public function load_attempts(int $instance): void {
global $DB;
$adaptivequiz = $DB->get_record('adaptivequiz', ['id' => $instance], '*');
// Get all of the completed attempts for this adaptive quiz instance.
$attempts = $DB->get_records('adaptivequiz_attempt',
['instance' => $instance, 'attemptstate' => attempt_state::COMPLETED]);
foreach ($attempts as $attempt) {
if ($attempt->uniqueid == 0) {
continue;
}
$user = $DB->get_record('user', ['id' => $attempt->userid]);
if (!$user) {
$user = new stdClass();
$user->firstname = get_string('unknownuser', 'adaptivequiz');
$user->lastname = '#' . $attempt->userid;
}
// For each attempt, get the attempt's final score.
$score = new attempt_score($attempt->measure, $attempt->standarderror, $adaptivequiz->lowestlevel,
$adaptivequiz->highestlevel);
// For each attempt, loop through all questions asked and add that usage
// to the question.
$quba = question_engine::load_questions_usage_by_activity($attempt->uniqueid);
foreach ($quba->get_slots() as $i => $slot) {
$question = $quba->get_question($slot);
// Create a question-analyser for the question.
if (empty($this->questions[$question->id])) {
$tags = core_tag_tag::get_item_tags_array('core_question', 'question', $question->id);
$difficulty = adaptivequiz_get_difficulty_from_tags($tags);
$this->questions[$question->id] = new question_analyser($quba->get_owning_context(), $question,
$difficulty, $adaptivequiz->lowestlevel, $adaptivequiz->highestlevel);
}
// Record the attempt score and the individual question result.
// KNIGHT: correctness is judged against the acceptance threshold (was: mark > 0).
$acceptancethreshold = (float) ($adaptivequiz->acceptancethreshold ?? 0);
$correct = (catalgo::question_is_correct($quba, $slot, $acceptancethreshold) === true);
$answer = $quba->get_response_summary($slot);
$this->questions[$question->id]->add_result($attempt->id, $user, $score, $correct, $answer);
}
}
}
/**
* Add a statistic to calculate.
*
* @param string $key A key to identify this statistic for sorting and printing.
* @param question_statistic $statistic
* @return void
*/
public function add_statistic($key, question_statistic $statistic) {
if (!empty($this->statistics[$key])) {
throw new InvalidArgumentException("Statistic key '$key' is already in use.");
}
$this->statistics[$key] = $statistic;
foreach ($this->questions as $question) {
$question->add_statistic($key, $statistic);
}
}
/**
* Answer a header row.
*
* @return array
*/
public function get_header() {
$header = array();
$header['id'] = get_string('id', 'adaptivequiz');
$header['name'] = get_string('adaptivequizname', 'adaptivequiz');
$header['level'] = get_string('attemptquestion_level', 'adaptivequiz');
foreach ($this->statistics as $key => $statistic) {
$header[$key] = $statistic->get_display_name();
}
return $header;
}
/**
* Return an array of table records, sorted by the statistics given.
*
* @param string $sort Which statistic to sort on.
* @param string $direction ASC or DESC.
* @return array
*/
public function get_records($sort = null, $direction = 'ASC') {
if (empty($this->questions)) {
return [];
}
$records = [];
foreach ($this->questions as $question) {
$record = [];
$record[] = $question->get_question_definition()->id;
$record[] = $question->get_question_definition()->name;
$record[] = $question->get_question_level();
foreach ($this->statistics as $key => $statistic) {
$record[] = $question->get_statistic_result($key)->printable();
}
$records[] = $record;
}
if ($direction != 'ASC' && $direction != 'DESC') {
throw new InvalidArgumentException('Invalid sort direction. Must be SORT_ASC or SORT_DESC, \''.$direction.'\' given.');
}
if ($direction == 'DESC') {
$direction = SORT_DESC;
} else {
$direction = SORT_ASC;
}
if (!is_null($sort)) {
$sortkeys = [];
foreach ($this->questions as $question) {
if ($sort == 'name') {
$sortkeys[] = $question->get_question_definition()->name;
$sorttype = SORT_REGULAR;
} else if ($sort == 'level') {
$sortkeys[] = $question->get_question_level();
$sorttype = SORT_NUMERIC;
} else {
$sortkeys[] = $question->get_statistic_result($sort)->sortable();
$sorttype = SORT_NUMERIC;
}
}
array_multisort($sortkeys, $direction, $sorttype, $records);
}
return $records;
}
/**
* Answer a question-analyzer for a particular question id analyze
*
* @param int $qid The question id
* @return question_analyser
* @throws Exception
*/
public function get_question_analyzer($qid) {
if (!isset($this->questions[$qid])) {
throw new Exception('Question-id not found.');
}
return $this->questions[$qid];
}
/**
* Answer the record for a single question
*
* @param int $qid The question id
* @return array
* @throws Exception
*/
public function get_record($qid) {
$question = $this->get_question_analyzer($qid);
$record = [];
$record[] = $question->get_question_definition()->id;
$record[] = $question->get_question_definition()->name;
$record[] = $question->get_question_level();
foreach ($this->statistics as $key => $statistic) {
$record[] = $question->get_statistic_result($key)->printable();
}
return $record;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_adaptivequiz\local\questionanalysis\statistics;
use html_writer;
use mod_adaptivequiz\local\questionanalysis\question_analyser;
use moodle_url;
use stdClass;
/**
* This interface defines the methods required for pluggable statistics that may be added to the question analysis.
*
* @package mod_adaptivequiz
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class answers_statistic implements question_statistic {
/**
* Answer a display-name for this statistic.
*
* @return string
*/
public function get_display_name () {
return get_string('answers_display_name', 'adaptivequiz');
}
/**
* Calculate this statistic for a question's results.
*
* @param question_analyser $analyser
* @return question_statistic_result
*/
public function calculate(question_analyser $analyser): question_statistic_result {
// Sort the results.
$results = $analyser->get_results();
foreach ($results as $result) {
$sortkeys[] = $result->score->measured_ability_in_logits();
}
array_multisort($sortkeys, SORT_NUMERIC, SORT_DESC, $results);
// Sort the results into three arrays based on how far above or below the question-level the users are.
$high = array();
$mid = array();
$low = array();
foreach ($results as $result) {
$ceiling = $result->score->measured_ability_in_logits() + $result->score->standard_error_in_logits();
$floor = $result->score->measured_ability_in_logits() - $result->score->standard_error_in_logits();
if ($analyser->get_question_level_in_logits() < $floor) {
// User is significantly above the question-level.
$high[] = $result;
} else if ($analyser->get_question_level_in_logits() > $ceiling) {
// User is significantly below the question-level.
$low[] = $result;
} else {
// User's ability overlaps the question level.
$mid[] = $result;
}
}
ob_start();
print html_writer::end_tag('tr');
print html_writer::start_tag('tr');
print html_writer::tag('th', get_string('attemptquestion_ability', 'adaptivequiz'));
print html_writer::tag('th', get_string('user', 'adaptivequiz'));
print html_writer::tag('th', get_string('result', 'adaptivequiz'));
print html_writer::tag('th', get_string('answer', 'adaptivequiz'));
print html_writer::tag('th', '');
print html_writer::end_tag('tr');
$headings = ob_get_clean();
ob_start();
print html_writer::start_tag('table', array('class' => 'adpq_answers_table'));
print html_writer::start_tag('thead');
print html_writer::start_tag('tr');
print html_writer::tag('th', get_string('highlevelusers', 'adaptivequiz').':',
array('colspan' => '5', 'class' => 'section'));
print $headings;
print html_writer::end_tag('thead');
print html_writer::start_tag('tbody', array('class' => 'adpq_highlevel'));
if (count($high)) {
foreach ($high as $result) {
$this->print_user_result($result);
}
} else {
$this->print_empty_user_result();
}
print html_writer::end_tag('tbody');
print html_writer::start_tag('thead');
print html_writer::start_tag('tr');
print html_writer::tag('th', get_string('midlevelusers', 'adaptivequiz').':',
array('colspan' => '5', 'class' => 'section'));
print $headings;
print html_writer::end_tag('thead');
print html_writer::start_tag('tbody', array('class' => 'adpq_midlevel'));
if (count($mid)) {
foreach ($mid as $result) {
$this->print_user_result($result);
}
} else {
$this->print_empty_user_result();
}
print html_writer::end_tag('tbody');
print html_writer::start_tag('thead');
print html_writer::start_tag('tr');
print html_writer::tag('th', get_string('lowlevelusers', 'adaptivequiz').':',
array('colspan' => '5', 'class' => 'section'));
print $headings;
print html_writer::end_tag('thead');
print html_writer::start_tag('tbody', array('class' => 'adpq_lowlevel'));
if (count($low)) {
foreach ($low as $result) {
$this->print_user_result($result);
}
} else {
$this->print_empty_user_result();
}
print html_writer::end_tag('tbody');
print html_writer::end_tag('table');
return new answers_statistic_result(count($results), ob_get_clean());
}
/**
* Print out a user result.
*
* @param stdClass $result
*/
public function print_user_result(stdClass $result): void {
if ($result->correct) {
$class = 'adpq_correct';
} else {
$class = 'adpq_incorrect';
}
$url = new moodle_url('/mod/adaptivequiz/reviewattempt.php', ['attempt' => $result->attemptid]);
print html_writer::start_tag('tr', ['class' => $class]);
print html_writer::tag('td', round($result->score->measured_ability_in_scale(), 2));
print html_writer::tag('td', $result->user->firstname." ".$result->user->lastname);
print html_writer::tag('td', (($result->correct) ? "correct" : "incorrect"));
print html_writer::tag('td', $result->answer);
print html_writer::tag('td', html_writer::link($url, get_string('reviewattempt', 'adaptivequiz')));
print html_writer::end_tag('tr');
}
/**
* Print out an empty user-result row.
*
* @param question_analyser $analyser
* @param stdClass $result
* @return void
*/
public function print_empty_user_result () {
print html_writer::start_tag('tr');
print html_writer::tag('td', '');
print html_writer::tag('td', '');
print html_writer::tag('td', '');
print html_writer::tag('td', '');
print html_writer::tag('td', '');
print html_writer::end_tag('tr');
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This interface defines the methods required for pluggable statistic-results that may be added to the question analysis.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis\statistics;
class answers_statistic_result implements question_statistic_result {
/** @var int $count */
protected $count = null;
/** @var string $printable */
protected $printable = null;
/**
* Constructor
*
* @param int $count
* @return void
*/
public function __construct ($count, $printable) {
$this->count = $count;
$this->printable = $printable;
}
/**
* A sortable version of the result.
*
* @return mixed string or numeric
*/
public function sortable () {
return $this->count;
}
/**
* A printable version of the result.
*
* @param numeric $result
* @return mixed string or numeric
*/
public function printable () {
return $this->printable;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This interface defines the methods required for pluggable statistics that may be added to the question analysis.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis\statistics;
use mod_adaptivequiz\local\questionanalysis\question_analyser;
class discrimination_statistic implements question_statistic {
/**
* Answer a display-name for this statistic.
*
* @return string
*/
public function get_display_name () {
return get_string('discrimination_display_name', 'adaptivequiz');
}
/**
* Calculate this statistic for a question's results
*
* @param question_analyser $analyser
* @return question_statistic_result
*/
public function calculate (question_analyser $analyser) {
// Discrimination is generally defined as comparing the results of two sub-groups,
// the top 27% of test-takers (the upper group) and the bottom 27% of test-takers (the lower group),
// assuming a normal distribution of scores).
//
// Given that likely have a very sparse data-set we will instead categorize our
// responses into the upper group if the respondent's overall ability measure minus the measure's standard error
// is greater than the question's level. Likewise, responses will be categorized into the lower group if the respondent's
// ability measure plus the measure's standard error is less than the question's level.
// Responses where the user's ability measure and error-range include the question level will be ignored.
$level = $analyser->get_question_level_in_logits();
$uppergroupsize = 0;
$uppergroupcorrect = 0;
$lowergroupsize = 0;
$lowergroupcorrect = 0;
foreach ($analyser->get_results() as $result) {
if ($result->score->measured_ability_in_logits() - $result->score->standard_error_in_logits() > $level) {
// Upper group.
$uppergroupsize++;
if ($result->correct) {
$uppergroupcorrect++;
}
} else if ($result->score->measured_ability_in_logits() + $result->score->standard_error_in_logits() < $level) {
// Lower Group.
$lowergroupsize++;
if ($result->correct) {
$lowergroupcorrect++;
}
}
}
if ($uppergroupsize > 0 && $lowergroupsize > 0) {
// We need at least one result in the upper and lower groups.
$upperproportion = $uppergroupcorrect / $uppergroupsize;
$lowerproportion = $lowergroupcorrect / $lowergroupsize;
$discrimination = $upperproportion - $lowerproportion;
return new discrimination_statistic_result($discrimination);
} else {
// If we don't have any responses in the upper or lower group, then we don't have a meaningful result.
return new discrimination_statistic_result(null);
}
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This interface defines the methods required for pluggable statistic-results that may be added to the question analysis.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis\statistics;
class discrimination_statistic_result implements question_statistic_result {
/** @var float $discrimination */
protected $discrimination = null;
/**
* Constructor
*
* @param float $discrimination
* @return void
*/
public function __construct ($discrimination) {
$this->discrimination = $discrimination;
}
/**
* A sortable version of the result.
*
* @return mixed string or numeric
*/
public function sortable () {
if (is_null($this->discrimination)) {
return -2;
} else {
return $this->discrimination;
}
}
/**
* A printable version of the result.
*
* @param numeric $result
* @return mixed string or numeric
*/
public function printable () {
if (is_null($this->discrimination)) {
return 'n/a';
} else {
return round($this->discrimination, 3);
}
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This interface defines the methods required for pluggable statistics that may be added to the question analysis.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis\statistics;
use mod_adaptivequiz\local\questionanalysis\question_analyser;
class percent_correct_statistic implements question_statistic {
/**
* Answer a display-name for this statistic.
*
* @return string
*/
public function get_display_name () {
return get_string('percent_correct_display_name', 'adaptivequiz');
}
/**
* Calculate this statistic for a question's results
*
* @param question_analyser $analyser
* @return question_statistic_result
*/
public function calculate (question_analyser $analyser) {
$correct = 0;
$total = 0;
foreach ($analyser->get_results() as $result) {
$total++;
if ($result->correct) {
$correct++;
}
}
if ($total) {
return new percent_correct_statistic_result ($correct / $total);
} else {
return new percent_correct_statistic_result (0);
}
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This interface defines the methods required for pluggable statistic-results that may be added to the question analysis.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis\statistics;
class percent_correct_statistic_result implements question_statistic_result {
/** @var float $fraction */
protected $fraction = null;
/**
* Constructor
*
* @param float $fraction
* @return void
*/
public function __construct ($fraction) {
$this->fraction = $fraction;
}
/**
* A sortable version of the result.
*
* @return mixed string or numeric
*/
public function sortable () {
return $this->fraction;
}
/**
* A printable version of the result.
*
* @param numeric $result
* @return mixed string or numeric
*/
public function printable () {
return round($this->fraction * 100).'%';
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment