generalKnowledge.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. <?php
  2. /**
  3. * General knowledge ("generalKnowledge"): a multiple choice question from the
  4. * tkGeneralKnowledge table.
  5. *
  6. * Replaces the placeholder, which was a copy of the "find the missing number"
  7. * generator under another class name: it never read tkGeneralKnowledge and
  8. * still used the old client-side checkerData, so every answer would have
  9. * been refused.
  10. *
  11. * - Works on the table as imported from general_knowledge_10000_mcq.csv, and
  12. * on the cleaned table (general_knowledge/tkGeneralKnowledge.sql). That file
  13. * holds 1,018 questions, each copied about ten times with
  14. * "(Choose the correct answer.)" added to the question (and the question
  15. * mark dropped). So the question text is cleaned when it is read, and rows
  16. * with the same cleaned question count as one.
  17. * - The columns are found by name, ignoring case, spaces and underscores:
  18. * Question, Option A..D (option_a, optionA ...), Correct Answer
  19. * (correct_answer ...), Correct Option, Category. The correct answer is
  20. * taken from the answer text, or the letter, or both (a row where they
  21. * disagree is never asked).
  22. * - The options are shuffled for each question.
  23. * - A question isn't repeated within the student's last 200 answers.
  24. * - A category is picked first, with big categories counted for less than
  25. * their size (square root), so Geography (three quarters of the file) is
  26. * not nearly everything.
  27. * - The Difficulty column is not used: in the file it just repeats Easy,
  28. * Medium, Hard by row.
  29. *
  30. * The right answer and the options as shown are stored server-side
  31. * (TkAnswerStore). The student ticks a letter; the checker
  32. * (answerChecker2::checkGeneralKnowledge) turns it back into the option text.
  33. */
  34. class generalKnowledge {
  35. const SUBJECT = 'generalKnowledge';
  36. const TABLE = 'tkGeneralKnowledge';
  37. const RECENT = 200; // questions not repeated within this many of a student's answers
  38. const SAMPLE = 40; // random rows looked at to find a question
  39. private $user;
  40. /**
  41. * @param string $user User to supply the question for.
  42. */
  43. public function __construct($user) {
  44. $this->user = $user;
  45. }
  46. /**
  47. * Make a question, show it, and remember it as the student's open question.
  48. */
  49. public function getGeneralKnowledge() {
  50. $problem = $this->getProblem();
  51. if ($problem === null) {
  52. e107::getRender()->tablerender('General knowledge', 'There are no general knowledge questions available yet. Please refresh the page.');
  53. return;
  54. }
  55. $questionHtml = $this->htmlify($problem);
  56. $db = e107::getDb();
  57. $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
  58. $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
  59. e107::getRender()->tablerender('General knowledge', $questionHtml);
  60. }
  61. /**
  62. * Choose a question without showing it.
  63. *
  64. * @return array|null ['answer1' => correct option text, 'question' => string, 'type' => 'generalKnowledge',
  65. * 'options' => the four options in the order shown, 'category' => string],
  66. * or null when no question can be asked.
  67. */
  68. public function getProblem() {
  69. $columns = $this->columns();
  70. if ($columns === null) {
  71. return null;
  72. }
  73. $recent = $this->recentQuestions();
  74. $category = $this->pickCategory($columns);
  75. $problem = $this->pickQuestion($columns, $category, $recent);
  76. if ($problem === null && $category !== null) {
  77. $problem = $this->pickQuestion($columns, null, $recent); // that category had nothing new
  78. }
  79. if ($problem === null) {
  80. $problem = $this->pickQuestion($columns, null, []); // everything was asked recently: repeat
  81. }
  82. return $problem;
  83. }
  84. /**
  85. * The real column names, found by name: ['question', 'a', 'b', 'c', 'd', and 'answer' and/or 'letter',
  86. * optionally 'category'], or null when the table isn't usable.
  87. */
  88. private function columns() {
  89. $db = e107::getDb();
  90. $db->gen("SHOW COLUMNS FROM `" . MPREFIX . self::TABLE . "`");
  91. $found = [];
  92. while ($row = $db->fetch()) {
  93. $found[strtolower(preg_replace('/[^a-z0-9]/i', '', $row['Field']))] = $row['Field'];
  94. }
  95. $aliases = [
  96. 'question' => ['question', 'questiontext'],
  97. 'a' => ['optiona', 'choicea', 'answera', 'a'],
  98. 'b' => ['optionb', 'choiceb', 'answerb', 'b'],
  99. 'c' => ['optionc', 'choicec', 'answerc', 'c'],
  100. 'd' => ['optiond', 'choiced', 'answerd', 'd'],
  101. 'answer' => ['correctanswer', 'answertext', 'rightanswer'],
  102. 'letter' => ['correctoption', 'correctletter', 'correctchoice'],
  103. 'category' => ['category', 'topic', 'subject'],
  104. ];
  105. $map = [];
  106. foreach ($aliases as $name => $names) {
  107. foreach ($names as $alias) {
  108. if (isset($found[$alias])) {
  109. $map[$name] = $found[$alias];
  110. break;
  111. }
  112. }
  113. }
  114. foreach (['question', 'a', 'b', 'c', 'd'] as $required) {
  115. if (!isset($map[$required])) {
  116. return null;
  117. }
  118. }
  119. if (!isset($map['answer']) && !isset($map['letter'])) {
  120. return null;
  121. }
  122. return $map;
  123. }
  124. /**
  125. * Keys of the questions in the student's last answers (cleaned text, lower case).
  126. * The report stores the question as "General knowledge: <question>".
  127. */
  128. private function recentQuestions() {
  129. $db = e107::getDb();
  130. $recent = [];
  131. $db->gen("SELECT `question` FROM `" . MPREFIX . "tkReport`
  132. WHERE `username` = '" . $db->escape($this->user) . "' AND `subject` = '" . self::SUBJECT . "'
  133. ORDER BY `id` DESC LIMIT " . (int) self::RECENT);
  134. while ($row = $db->fetch()) {
  135. $recent[$this->key(preg_replace('/^General knowledge:\s*/', '', (string) $row['question']))] = true;
  136. }
  137. return $recent;
  138. }
  139. /**
  140. * A category chosen at random, each weighted by the square root of how many
  141. * rows it has. Null when there is no category column.
  142. */
  143. private function pickCategory(array $columns) {
  144. if (!isset($columns['category'])) {
  145. return null;
  146. }
  147. $db = e107::getDb();
  148. $field = '`' . $columns['category'] . '`';
  149. $db->gen("SELECT " . $field . " AS category, COUNT(*) AS n FROM `" . MPREFIX . self::TABLE . "` GROUP BY " . $field);
  150. $weights = [];
  151. $total = 0.0;
  152. while ($row = $db->fetch()) {
  153. $weight = sqrt((int) $row['n']);
  154. if ($weight > 0) {
  155. $weights[$row['category']] = $weight;
  156. $total += $weight;
  157. }
  158. }
  159. if ($total <= 0) {
  160. return null;
  161. }
  162. $point = (mt_rand() / mt_getrandmax()) * $total;
  163. foreach ($weights as $category => $weight) {
  164. $point -= $weight;
  165. if ($point <= 0) {
  166. return (string) $category;
  167. }
  168. }
  169. return (string) key($weights);
  170. }
  171. /**
  172. * The first usable question, not asked recently, among SAMPLE random rows.
  173. */
  174. private function pickQuestion(array $columns, $category, array $recent) {
  175. $db = e107::getDb();
  176. $where = '';
  177. if ($category !== null && isset($columns['category'])) {
  178. $where = " WHERE `" . $columns['category'] . "` = '" . $db->escape($category) . "'";
  179. }
  180. $db->gen("SELECT * FROM `" . MPREFIX . self::TABLE . "`" . $where . " ORDER BY RAND() LIMIT " . (int) self::SAMPLE);
  181. $rows = [];
  182. while ($row = $db->fetch()) {
  183. $rows[] = $row;
  184. }
  185. foreach ($rows as $row) {
  186. $problem = $this->buildProblem($columns, $row);
  187. if ($problem !== null && !isset($recent[$this->key($problem['question'])])) {
  188. return $problem;
  189. }
  190. }
  191. return null;
  192. }
  193. /**
  194. * One table row as a question, or null when the row can't be used (missing
  195. * text, repeated options, no single correct option, or the answer text and
  196. * the letter disagree).
  197. */
  198. private function buildProblem(array $columns, array $row) {
  199. $question = $this->cleanQuestion($row[$columns['question']]);
  200. $options = [
  201. $this->decode($row[$columns['a']]),
  202. $this->decode($row[$columns['b']]),
  203. $this->decode($row[$columns['c']]),
  204. $this->decode($row[$columns['d']]),
  205. ];
  206. if ($question === '' || in_array('', $options, true) || count(array_unique($options)) < 4) {
  207. return null;
  208. }
  209. $correct = null;
  210. if (isset($columns['answer'])) {
  211. $text = $this->decode($row[$columns['answer']]);
  212. if (count(array_keys($options, $text, true)) === 1) {
  213. $correct = $text;
  214. }
  215. }
  216. if (isset($columns['letter'])) {
  217. $letter = strtoupper(trim((string) $row[$columns['letter']]));
  218. $byLetter = (strlen($letter) === 1 && $letter >= 'A' && $letter <= 'D') ? $options[ord($letter) - 65] : null;
  219. if ($correct === null) {
  220. $correct = $byLetter;
  221. } elseif ($byLetter !== null && $byLetter !== $correct) {
  222. return null;
  223. }
  224. }
  225. if ($correct === null) {
  226. return null;
  227. }
  228. shuffle($options);
  229. return [
  230. 'answer1' => $correct,
  231. 'question' => $question,
  232. 'type' => self::SUBJECT,
  233. 'options' => $options,
  234. 'category' => isset($columns['category']) ? $this->decode($row[$columns['category']]) : '',
  235. ];
  236. }
  237. /**
  238. * e107 may have stored apostrophes as &#039;: show and compare the real text.
  239. */
  240. private function decode($value) {
  241. return trim(html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
  242. }
  243. /**
  244. * The question as it should be shown: decoded, without the
  245. * "(Choose the correct answer.)" padding of the imported file (which also
  246. * dropped the question mark: it is put back).
  247. */
  248. private function cleanQuestion($text) {
  249. $text = $this->decode($text);
  250. $stripped = preg_replace('/(?:\s*\(Choose the correct answer\.\))+\s*$/u', '', $text, -1, $count);
  251. if ($stripped === null) {
  252. return $text;
  253. }
  254. if ($count > 0) {
  255. $stripped = rtrim(trim($stripped), '?') . '?';
  256. }
  257. return trim($stripped);
  258. }
  259. /**
  260. * What makes two questions "the same": the cleaned text, lower case, no final punctuation.
  261. */
  262. private function key($question) {
  263. return mb_strtolower(rtrim(trim((string) $question), "?. \t"), 'UTF-8');
  264. }
  265. private function htmlify($problem) {
  266. // The right answer and the order of the options stay on the server; the form only carries a token.
  267. $token = TkAnswerStore::issue($this->user, $problem);
  268. $frm = e107::getForm();
  269. $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
  270. $fString .= $frm->label('<h4>General knowledge</h4>') . "</br>\n";
  271. if ($problem['category'] !== '') {
  272. $fString .= '<p class="smalltext">Category: ' . htmlspecialchars($problem['category']) . '</p>';
  273. }
  274. $fString .= '<p style="font-size:140%;font-weight:bold;">' . htmlspecialchars($problem['question']) . '</p>';
  275. foreach ($problem['options'] as $i => $option) {
  276. $letter = chr(65 + $i);
  277. $fString .= '<div style="margin:8px 0;"><label style="font-size:120%;cursor:pointer;">'
  278. . '<input type="radio" name="a1" value="' . $letter . '" required> '
  279. . '<b>' . $letter . '.</b> ' . htmlspecialchars($option)
  280. . '</label></div>';
  281. }
  282. $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
  283. $fString .= $frm->hidden('subject', self::SUBJECT, ['id' => 'subject']);
  284. $fString .= '</br>' . "\n";
  285. $fString .= $frm->button('Answer', 'Check answer') . "\n";
  286. $fString .= $frm->close();
  287. $fString .= '</br></br>';
  288. return $fString;
  289. }
  290. }