| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 |
- <?php
- /**
- * General knowledge ("generalKnowledge"): a multiple choice question from the
- * tkGeneralKnowledge table.
- *
- * Replaces the placeholder, which was a copy of the "find the missing number"
- * generator under another class name: it never read tkGeneralKnowledge and
- * still used the old client-side checkerData, so every answer would have
- * been refused.
- *
- * - Works on the table as imported from general_knowledge_10000_mcq.csv, and
- * on the cleaned table (general_knowledge/tkGeneralKnowledge.sql). That file
- * holds 1,018 questions, each copied about ten times with
- * "(Choose the correct answer.)" added to the question (and the question
- * mark dropped). So the question text is cleaned when it is read, and rows
- * with the same cleaned question count as one.
- * - The columns are found by name, ignoring case, spaces and underscores:
- * Question, Option A..D (option_a, optionA ...), Correct Answer
- * (correct_answer ...), Correct Option, Category. The correct answer is
- * taken from the answer text, or the letter, or both (a row where they
- * disagree is never asked).
- * - The options are shuffled for each question.
- * - A question isn't repeated within the student's last 200 answers.
- * - A category is picked first, with big categories counted for less than
- * their size (square root), so Geography (three quarters of the file) is
- * not nearly everything.
- * - The Difficulty column is not used: in the file it just repeats Easy,
- * Medium, Hard by row.
- *
- * The right answer and the options as shown are stored server-side
- * (TkAnswerStore). The student ticks a letter; the checker
- * (answerChecker2::checkGeneralKnowledge) turns it back into the option text.
- */
- class generalKnowledge {
- const SUBJECT = 'generalKnowledge';
- const TABLE = 'tkGeneralKnowledge';
- const RECENT = 200; // questions not repeated within this many of a student's answers
- const SAMPLE = 40; // random rows looked at to find a question
- private $user;
- /**
- * @param string $user User to supply the question for.
- */
- public function __construct($user) {
- $this->user = $user;
- }
- /**
- * Make a question, show it, and remember it as the student's open question.
- */
- public function getGeneralKnowledge() {
- $problem = $this->getProblem();
- if ($problem === null) {
- e107::getRender()->tablerender('General knowledge', 'There are no general knowledge questions available yet. Please refresh the page.');
- return;
- }
- $questionHtml = $this->htmlify($problem);
- $db = e107::getDb();
- $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
- $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
- e107::getRender()->tablerender('General knowledge', $questionHtml);
- }
- /**
- * Choose a question without showing it.
- *
- * @return array|null ['answer1' => correct option text, 'question' => string, 'type' => 'generalKnowledge',
- * 'options' => the four options in the order shown, 'category' => string],
- * or null when no question can be asked.
- */
- public function getProblem() {
- $columns = $this->columns();
- if ($columns === null) {
- return null;
- }
- $recent = $this->recentQuestions();
- $category = $this->pickCategory($columns);
- $problem = $this->pickQuestion($columns, $category, $recent);
- if ($problem === null && $category !== null) {
- $problem = $this->pickQuestion($columns, null, $recent); // that category had nothing new
- }
- if ($problem === null) {
- $problem = $this->pickQuestion($columns, null, []); // everything was asked recently: repeat
- }
- return $problem;
- }
- /**
- * The real column names, found by name: ['question', 'a', 'b', 'c', 'd', and 'answer' and/or 'letter',
- * optionally 'category'], or null when the table isn't usable.
- */
- private function columns() {
- $db = e107::getDb();
- $db->gen("SHOW COLUMNS FROM `" . MPREFIX . self::TABLE . "`");
- $found = [];
- while ($row = $db->fetch()) {
- $found[strtolower(preg_replace('/[^a-z0-9]/i', '', $row['Field']))] = $row['Field'];
- }
- $aliases = [
- 'question' => ['question', 'questiontext'],
- 'a' => ['optiona', 'choicea', 'answera', 'a'],
- 'b' => ['optionb', 'choiceb', 'answerb', 'b'],
- 'c' => ['optionc', 'choicec', 'answerc', 'c'],
- 'd' => ['optiond', 'choiced', 'answerd', 'd'],
- 'answer' => ['correctanswer', 'answertext', 'rightanswer'],
- 'letter' => ['correctoption', 'correctletter', 'correctchoice'],
- 'category' => ['category', 'topic', 'subject'],
- ];
- $map = [];
- foreach ($aliases as $name => $names) {
- foreach ($names as $alias) {
- if (isset($found[$alias])) {
- $map[$name] = $found[$alias];
- break;
- }
- }
- }
- foreach (['question', 'a', 'b', 'c', 'd'] as $required) {
- if (!isset($map[$required])) {
- return null;
- }
- }
- if (!isset($map['answer']) && !isset($map['letter'])) {
- return null;
- }
- return $map;
- }
- /**
- * Keys of the questions in the student's last answers (cleaned text, lower case).
- * The report stores the question as "General knowledge: <question>".
- */
- private function recentQuestions() {
- $db = e107::getDb();
- $recent = [];
- $db->gen("SELECT `question` FROM `" . MPREFIX . "tkReport`
- WHERE `username` = '" . $db->escape($this->user) . "' AND `subject` = '" . self::SUBJECT . "'
- ORDER BY `id` DESC LIMIT " . (int) self::RECENT);
- while ($row = $db->fetch()) {
- $recent[$this->key(preg_replace('/^General knowledge:\s*/', '', (string) $row['question']))] = true;
- }
- return $recent;
- }
- /**
- * A category chosen at random, each weighted by the square root of how many
- * rows it has. Null when there is no category column.
- */
- private function pickCategory(array $columns) {
- if (!isset($columns['category'])) {
- return null;
- }
- $db = e107::getDb();
- $field = '`' . $columns['category'] . '`';
- $db->gen("SELECT " . $field . " AS category, COUNT(*) AS n FROM `" . MPREFIX . self::TABLE . "` GROUP BY " . $field);
- $weights = [];
- $total = 0.0;
- while ($row = $db->fetch()) {
- $weight = sqrt((int) $row['n']);
- if ($weight > 0) {
- $weights[$row['category']] = $weight;
- $total += $weight;
- }
- }
- if ($total <= 0) {
- return null;
- }
- $point = (mt_rand() / mt_getrandmax()) * $total;
- foreach ($weights as $category => $weight) {
- $point -= $weight;
- if ($point <= 0) {
- return (string) $category;
- }
- }
- return (string) key($weights);
- }
- /**
- * The first usable question, not asked recently, among SAMPLE random rows.
- */
- private function pickQuestion(array $columns, $category, array $recent) {
- $db = e107::getDb();
- $where = '';
- if ($category !== null && isset($columns['category'])) {
- $where = " WHERE `" . $columns['category'] . "` = '" . $db->escape($category) . "'";
- }
- $db->gen("SELECT * FROM `" . MPREFIX . self::TABLE . "`" . $where . " ORDER BY RAND() LIMIT " . (int) self::SAMPLE);
- $rows = [];
- while ($row = $db->fetch()) {
- $rows[] = $row;
- }
- foreach ($rows as $row) {
- $problem = $this->buildProblem($columns, $row);
- if ($problem !== null && !isset($recent[$this->key($problem['question'])])) {
- return $problem;
- }
- }
- return null;
- }
- /**
- * One table row as a question, or null when the row can't be used (missing
- * text, repeated options, no single correct option, or the answer text and
- * the letter disagree).
- */
- private function buildProblem(array $columns, array $row) {
- $question = $this->cleanQuestion($row[$columns['question']]);
- $options = [
- $this->decode($row[$columns['a']]),
- $this->decode($row[$columns['b']]),
- $this->decode($row[$columns['c']]),
- $this->decode($row[$columns['d']]),
- ];
- if ($question === '' || in_array('', $options, true) || count(array_unique($options)) < 4) {
- return null;
- }
- $correct = null;
- if (isset($columns['answer'])) {
- $text = $this->decode($row[$columns['answer']]);
- if (count(array_keys($options, $text, true)) === 1) {
- $correct = $text;
- }
- }
- if (isset($columns['letter'])) {
- $letter = strtoupper(trim((string) $row[$columns['letter']]));
- $byLetter = (strlen($letter) === 1 && $letter >= 'A' && $letter <= 'D') ? $options[ord($letter) - 65] : null;
- if ($correct === null) {
- $correct = $byLetter;
- } elseif ($byLetter !== null && $byLetter !== $correct) {
- return null;
- }
- }
- if ($correct === null) {
- return null;
- }
- shuffle($options);
- return [
- 'answer1' => $correct,
- 'question' => $question,
- 'type' => self::SUBJECT,
- 'options' => $options,
- 'category' => isset($columns['category']) ? $this->decode($row[$columns['category']]) : '',
- ];
- }
- /**
- * e107 may have stored apostrophes as ': show and compare the real text.
- */
- private function decode($value) {
- return trim(html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
- }
- /**
- * The question as it should be shown: decoded, without the
- * "(Choose the correct answer.)" padding of the imported file (which also
- * dropped the question mark: it is put back).
- */
- private function cleanQuestion($text) {
- $text = $this->decode($text);
- $stripped = preg_replace('/(?:\s*\(Choose the correct answer\.\))+\s*$/u', '', $text, -1, $count);
- if ($stripped === null) {
- return $text;
- }
- if ($count > 0) {
- $stripped = rtrim(trim($stripped), '?') . '?';
- }
- return trim($stripped);
- }
- /**
- * What makes two questions "the same": the cleaned text, lower case, no final punctuation.
- */
- private function key($question) {
- return mb_strtolower(rtrim(trim((string) $question), "?. \t"), 'UTF-8');
- }
- private function htmlify($problem) {
- // The right answer and the order of the options stay on the server; the form only carries a token.
- $token = TkAnswerStore::issue($this->user, $problem);
- $frm = e107::getForm();
- $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
- $fString .= $frm->label('<h4>General knowledge</h4>') . "</br>\n";
- if ($problem['category'] !== '') {
- $fString .= '<p class="smalltext">Category: ' . htmlspecialchars($problem['category']) . '</p>';
- }
- $fString .= '<p style="font-size:140%;font-weight:bold;">' . htmlspecialchars($problem['question']) . '</p>';
- foreach ($problem['options'] as $i => $option) {
- $letter = chr(65 + $i);
- $fString .= '<div style="margin:8px 0;"><label style="font-size:120%;cursor:pointer;">'
- . '<input type="radio" name="a1" value="' . $letter . '" required> '
- . '<b>' . $letter . '.</b> ' . htmlspecialchars($option)
- . '</label></div>';
- }
- $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
- $fString .= $frm->hidden('subject', self::SUBJECT, ['id' => 'subject']);
- $fString .= '</br>' . "\n";
- $fString .= $frm->button('Answer', 'Check answer') . "\n";
- $fString .= $frm->close();
- $fString .= '</br></br>';
- return $fString;
- }
- }
|