Commit 9734364e2c46a7f7a778d18df4b1758db259d4c0
1 parent
c7c528c1
Exists in
master
and in
1 other branch
add set_answer() to Question base class
make questions.py similar to the one in aprendizations checkbox correct now raises error instead of warning about behavior change in values
Showing
4 changed files
with
95 additions
and
43 deletions
Show diff stats
demo/questions/questions-tutorial.yaml
... | ... | @@ -227,8 +227,8 @@ |
227 | 227 | |
228 | 228 | ```yaml |
229 | 229 | options: |
230 | - - ["O céu é azul", "O céu não é azul"] | |
231 | - - ["Um triangulo tem 3 lados", "Um triangulo tem 2 lados"] | |
230 | + - ['O céu é azul', 'O céu não é azul'] | |
231 | + - ['Um triangulo tem 3 lados', 'Um triangulo tem 2 lados'] | |
232 | 232 | - O nosso planeta tem um satélite natural |
233 | 233 | correct: [1, 1, 1] |
234 | 234 | ``` | ... | ... |
perguntations/app.py
... | ... | @@ -105,9 +105,9 @@ class App(): |
105 | 105 | logger.info('Database "%s" has %s students.', dbfile, num) |
106 | 106 | |
107 | 107 | # pre-generate tests |
108 | - logger.info('Generating tests for %d students:', num) | |
108 | + logger.info('Generating tests for %d students...', num) | |
109 | 109 | self._pregenerate_tests(num) |
110 | - logger.info('Tests are ready.') | |
110 | + logger.info('Tests done.') | |
111 | 111 | |
112 | 112 | # command line option --allow-all |
113 | 113 | if conf['allow_all']: | ... | ... |
perguntations/questions.py
... | ... | @@ -5,10 +5,11 @@ Classes the implement several types of questions. |
5 | 5 | |
6 | 6 | # python standard library |
7 | 7 | import asyncio |
8 | +from datetime import datetime | |
8 | 9 | import logging |
10 | +from os import path | |
9 | 11 | import random |
10 | 12 | import re |
11 | -from os import path | |
12 | 13 | from typing import Any, Dict, NewType |
13 | 14 | import uuid |
14 | 15 | |
... | ... | @@ -48,6 +49,11 @@ class Question(dict): |
48 | 49 | 'files': {}, |
49 | 50 | })) |
50 | 51 | |
52 | + def set_answer(self, ans) -> None: | |
53 | + '''set answer field and register time''' | |
54 | + self['answer'] = ans | |
55 | + self['finish_time'] = datetime.now() | |
56 | + | |
51 | 57 | def correct(self) -> None: |
52 | 58 | '''default correction (synchronous version)''' |
53 | 59 | self['comments'] = '' |
... | ... | @@ -80,7 +86,16 @@ class QuestionRadio(Question): |
80 | 86 | def __init__(self, q: QDict) -> None: |
81 | 87 | super().__init__(q) |
82 | 88 | |
83 | - nopts = len(self['options']) | |
89 | + try: | |
90 | + nopts = len(self['options']) | |
91 | + except KeyError as exc: | |
92 | + msg = f'Missing `options`. In question "{self["ref"]}"' | |
93 | + logger.error(msg) | |
94 | + raise QuestionException(msg) from exc | |
95 | + except TypeError as exc: | |
96 | + msg = f'`options` must be a list. In question "{self["ref"]}"' | |
97 | + logger.error(msg) | |
98 | + raise QuestionException(msg) from exc | |
84 | 99 | |
85 | 100 | self.set_defaults(QDict({ |
86 | 101 | 'text': '', |
... | ... | @@ -94,8 +109,9 @@ class QuestionRadio(Question): |
94 | 109 | # e.g. correct: 2 --> correct: [0,0,1,0,0] |
95 | 110 | if isinstance(self['correct'], int): |
96 | 111 | if not 0 <= self['correct'] < nopts: |
97 | - msg = (f'Correct option not in range 0..{nopts-1} in ' | |
98 | - f'"{self["ref"]}"') | |
112 | + msg = (f'`correct` out of range 0..{nopts-1}. ' | |
113 | + f'In question "{self["ref"]}"') | |
114 | + logger.error(msg) | |
99 | 115 | raise QuestionException(msg) |
100 | 116 | |
101 | 117 | self['correct'] = [1.0 if x == self['correct'] else 0.0 |
... | ... | @@ -104,28 +120,33 @@ class QuestionRadio(Question): |
104 | 120 | elif isinstance(self['correct'], list): |
105 | 121 | # must match number of options |
106 | 122 | if len(self['correct']) != nopts: |
107 | - msg = (f'Incompatible sizes: {nopts} options vs ' | |
108 | - f'{len(self["correct"])} correct in "{self["ref"]}"') | |
123 | + msg = (f'{nopts} options vs {len(self["correct"])} correct. ' | |
124 | + f'In question "{self["ref"]}"') | |
125 | + logger.error(msg) | |
109 | 126 | raise QuestionException(msg) |
127 | + | |
110 | 128 | # make sure is a list of floats |
111 | 129 | try: |
112 | 130 | self['correct'] = [float(x) for x in self['correct']] |
113 | 131 | except (ValueError, TypeError) as exc: |
114 | - msg = (f'Correct list must contain numbers [0.0, 1.0] or ' | |
115 | - f'booleans in "{self["ref"]}"') | |
132 | + msg = ('`correct` must be list of numbers or booleans.' | |
133 | + f'In "{self["ref"]}"') | |
134 | + logger.error(msg) | |
116 | 135 | raise QuestionException(msg) from exc |
117 | 136 | |
118 | 137 | # check grade boundaries |
119 | 138 | if self['discount'] and not all(0.0 <= x <= 1.0 |
120 | 139 | for x in self['correct']): |
121 | - msg = (f'Correct values must be in the interval [0.0, 1.0] in ' | |
122 | - f'"{self["ref"]}"') | |
140 | + msg = ('`correct` values must be in the interval [0.0, 1.0]. ' | |
141 | + f'In "{self["ref"]}"') | |
142 | + logger.error(msg) | |
123 | 143 | raise QuestionException(msg) |
124 | 144 | |
125 | 145 | # at least one correct option |
126 | 146 | if all(x < 1.0 for x in self['correct']): |
127 | - msg = (f'At least one correct option is required in ' | |
128 | - f'"{self["ref"]}"') | |
147 | + msg = ('At least one correct option is required. ' | |
148 | + f'In "{self["ref"]}"') | |
149 | + logger.error(msg) | |
129 | 150 | raise QuestionException(msg) |
130 | 151 | |
131 | 152 | # If shuffle==false, all options are shown as defined |
... | ... | @@ -158,14 +179,17 @@ class QuestionRadio(Question): |
158 | 179 | self['correct'] = [correct[i] for i in perm] |
159 | 180 | |
160 | 181 | # ------------------------------------------------------------------------ |
161 | - # can assign negative grades for wrong answers | |
162 | 182 | def correct(self) -> None: |
183 | + ''' | |
184 | + Correct `answer` and set `grade`. | |
185 | + Can assign negative grades for wrong answers | |
186 | + ''' | |
163 | 187 | super().correct() |
164 | 188 | |
165 | 189 | if self['answer'] is not None: |
166 | 190 | grade = self['correct'][int(self['answer'])] # grade of the answer |
167 | 191 | nopts = len(self['options']) |
168 | - grade_aver = sum(self['correct']) / nopts # expected value | |
192 | + grade_aver = sum(self['correct']) / nopts # expected value | |
169 | 193 | |
170 | 194 | # note: there are no numerical errors when summing 1.0s so the |
171 | 195 | # x_aver can be exactly 1.0 if all options are right |
... | ... | @@ -191,7 +215,16 @@ class QuestionCheckbox(Question): |
191 | 215 | def __init__(self, q: QDict) -> None: |
192 | 216 | super().__init__(q) |
193 | 217 | |
194 | - nopts = len(self['options']) | |
218 | + try: | |
219 | + nopts = len(self['options']) | |
220 | + except KeyError as exc: | |
221 | + msg = f'Missing `options`. In question "{self["ref"]}"' | |
222 | + logger.error(msg) | |
223 | + raise QuestionException(msg) from exc | |
224 | + except TypeError as exc: | |
225 | + msg = f'`options` must be a list. In question "{self["ref"]}"' | |
226 | + logger.error(msg) | |
227 | + raise QuestionException(msg) from exc | |
195 | 228 | |
196 | 229 | # set defaults if missing |
197 | 230 | self.set_defaults(QDict({ |
... | ... | @@ -199,47 +232,53 @@ class QuestionCheckbox(Question): |
199 | 232 | 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong) |
200 | 233 | 'shuffle': True, |
201 | 234 | 'discount': True, |
202 | - 'choose': nopts, # number of options | |
235 | + 'choose': nopts, # number of options | |
203 | 236 | 'max_tries': max(1, min(nopts - 1, 3)) |
204 | 237 | })) |
205 | 238 | |
206 | 239 | # must be a list of numbers |
207 | 240 | if not isinstance(self['correct'], list): |
208 | 241 | msg = 'Correct must be a list of numbers or booleans' |
242 | + logger.error(msg) | |
209 | 243 | raise QuestionException(msg) |
210 | 244 | |
211 | 245 | # must match number of options |
212 | 246 | if len(self['correct']) != nopts: |
213 | - msg = (f'Incompatible sizes: {nopts} options vs ' | |
214 | - f'{len(self["correct"])} correct in "{self["ref"]}"') | |
247 | + msg = (f'{nopts} options vs {len(self["correct"])} correct. ' | |
248 | + f'In question "{self["ref"]}"') | |
249 | + logger.error(msg) | |
215 | 250 | raise QuestionException(msg) |
216 | 251 | |
217 | 252 | # make sure is a list of floats |
218 | 253 | try: |
219 | 254 | self['correct'] = [float(x) for x in self['correct']] |
220 | 255 | except (ValueError, TypeError) as exc: |
221 | - msg = (f'Correct list must contain numbers or ' | |
222 | - f'booleans in "{self["ref"]}"') | |
256 | + msg = ('`correct` must be list of numbers or booleans.' | |
257 | + f'In "{self["ref"]}"') | |
258 | + logger.error(msg) | |
223 | 259 | raise QuestionException(msg) from exc |
224 | 260 | |
225 | 261 | # check grade boundaries |
226 | 262 | if self['discount'] and not all(0.0 <= x <= 1.0 |
227 | 263 | for x in self['correct']): |
228 | - | |
229 | - msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+') | |
230 | - msg1 = ('| Correct values in checkbox questions must be in the |') | |
231 | - msg2 = ('| interval [0.0, 1.0]. I will convert to the new |') | |
232 | - msg3 = ('| behavior, for now, but you should fix it. |') | |
233 | - msg4 = ('+------------------------------------------------------+') | |
234 | - logger.warning(msg0) | |
235 | - logger.warning(msg1) | |
236 | - logger.warning(msg2) | |
237 | - logger.warning(msg3) | |
238 | - logger.warning(msg4) | |
239 | - logger.warning('please fix "%s"', self["ref"]) | |
240 | - | |
241 | - # normalize to [0,1] | |
242 | - self['correct'] = [(x+1)/2 for x in self['correct']] | |
264 | + msg = ('values in the `correct` field of checkboxes must be in ' | |
265 | + 'the [0.0, 1.0] interval. ' | |
266 | + f'Please fix "{self["ref"]}" in "{self["path"]}"') | |
267 | + logger.error(msg) | |
268 | + raise QuestionException(msg) | |
269 | + # msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+') | |
270 | + # msg1 = ('| Correct values in checkbox questions must be in the |') | |
271 | + # msg2 = ('| interval [0.0, 1.0]. I will convert to the new |') | |
272 | + # msg3 = ('| behavior, for now, but you should fix it. |') | |
273 | + # msg4 = ('+------------------------------------------------------+') | |
274 | + # logger.warning(msg0) | |
275 | + # logger.warning(msg1) | |
276 | + # logger.warning(msg2) | |
277 | + # logger.warning(msg3) | |
278 | + # logger.warning(msg4) | |
279 | + # logger.warning('please fix "%s"', self["ref"]) | |
280 | + # # normalize to [0,1] | |
281 | + # self['correct'] = [(x+1)/2 for x in self['correct']] | |
243 | 282 | |
244 | 283 | # if an option is a list of (right, wrong), pick one |
245 | 284 | options = [] |
... | ... | @@ -381,6 +420,7 @@ class QuestionTextRegex(Question): |
381 | 420 | self['correct'] = [re.compile(a) for a in self['correct']] |
382 | 421 | except Exception as exc: |
383 | 422 | msg = f'Failed to compile regex in "{self["ref"]}"' |
423 | + logger.error(msg) | |
384 | 424 | raise QuestionException(msg) from exc |
385 | 425 | |
386 | 426 | # ------------------------------------------------------------------------ |
... | ... | @@ -426,6 +466,7 @@ class QuestionNumericInterval(Question): |
426 | 466 | if len(self['correct']) != 2: |
427 | 467 | msg = (f'Numeric interval must be a list with two numbers, in ' |
428 | 468 | f'{self["ref"]}') |
469 | + logger.error(msg) | |
429 | 470 | raise QuestionException(msg) |
430 | 471 | |
431 | 472 | try: |
... | ... | @@ -433,12 +474,14 @@ class QuestionNumericInterval(Question): |
433 | 474 | except Exception as exc: |
434 | 475 | msg = (f'Numeric interval must be a list with two numbers, in ' |
435 | 476 | f'{self["ref"]}') |
477 | + logger.error(msg) | |
436 | 478 | raise QuestionException(msg) from exc |
437 | 479 | |
438 | 480 | # invalid |
439 | 481 | else: |
440 | 482 | msg = (f'Numeric interval must be a list with two numbers, in ' |
441 | 483 | f'{self["ref"]}') |
484 | + logger.error(msg) | |
442 | 485 | raise QuestionException(msg) |
443 | 486 | |
444 | 487 | # ------------------------------------------------------------------------ |
... | ... | @@ -629,11 +672,12 @@ class QFactory(): |
629 | 672 | # which will print a valid question in yaml format to stdout. This |
630 | 673 | # output is then yaml parsed into a dictionary `q`. |
631 | 674 | if question['type'] == 'generator': |
632 | - logger.debug(' \\_ Running "%s".', question["script"]) | |
675 | + logger.debug(' \\_ Running "%s".', question['script']) | |
633 | 676 | question.setdefault('args', []) |
634 | 677 | question.setdefault('stdin', '') |
635 | 678 | script = path.join(question['path'], question['script']) |
636 | - out = await run_script_async(script=script, args=question['args'], | |
679 | + out = await run_script_async(script=script, | |
680 | + args=question['args'], | |
637 | 681 | stdin=question['stdin']) |
638 | 682 | question.update(out) |
639 | 683 | |
... | ... | @@ -642,14 +686,17 @@ class QFactory(): |
642 | 686 | qclass = self._types[question['type']] |
643 | 687 | except KeyError: |
644 | 688 | logger.error('Invalid type "%s" in "%s"', |
645 | - question["type"], question["ref"]) | |
689 | + question['type'], question['ref']) | |
646 | 690 | raise |
647 | 691 | |
648 | 692 | # Finally create an instance of Question() |
649 | 693 | try: |
650 | 694 | qinstance = qclass(QDict(question)) |
651 | 695 | except QuestionException: |
652 | - logger.error('Error generating question %s', question['ref']) | |
696 | + logger.error('Error generating question "%s". See "%s/%s"', | |
697 | + question['ref'], | |
698 | + question['path'], | |
699 | + question['filename']) | |
653 | 700 | raise |
654 | 701 | |
655 | 702 | return qinstance | ... | ... |
perguntations/static/css/test.css