Commit b5140b82404a2e521a92bd51873cbfbdaa623815

Authored by Miguel Barão
1 parent 4d7a7e9a
Exists in master and in 1 other branch dev

changes behavior of checkbox correct and updated tutorial.

demo/questions/questions-tutorial.yaml
... ... @@ -185,11 +185,13 @@
185 185 title: Escolha múltipla, várias opções correctas
186 186 text: |
187 187 As perguntas de escolha múltipla permitem apresentar um conjunto de opções
188   - podendo ser seleccionadas várias em simultaneo.
  188 + podendo ser seleccionadas várias em simultâneo.
189 189 Funcionam como múltiplas perguntas independentes de resposta sim/não.
190 190  
191   - Cada opção seleccionada (`sim`) recebe a cotação indicada em `correct`.
192   - Cada opção não seleccionadas (`não`) tem a cotação simétrica.
  191 + As respostas que devem ou não ser seleccionadas são indicadas com `1` e `0`
  192 + ou com booleanos `true` e `false`.
  193 + Cada resposta errada desconta um valor que é o simétrico da resposta certa.
  194 + Se acertar uma opção ganha `+1`, se errar obtém `-1`.
193 195  
194 196 ```yaml
195 197 - type: checkbox
... ... @@ -203,14 +205,15 @@
203 205 - Opção 2
204 206 - Opção 3 (certa)
205 207 - Opção 4
206   - correct: [+1, -1, -1, +1, -1]
  208 + correct: [1, 0, 0, 1, 0]
207 209 ```
208 210  
209   - Neste exemplo, seleccionando as opções 0 e 3 obtém-se cotação +1 em cada
210   - uma, enquanto que seleccionando as opções 1, 2 e 4 obtém-se cotação -1.
211   - As opções não seleccionadas pelo aluno dão a cotação simétrica à indicada.
212   - Por exemplo se não seleccionar a opção 0, tem cotação -1, e não
213   - seleccionando a opção 1 obtém-se +1.
  211 + Neste exemplo, seleccionando as opções 0 e 3 obtém-se cotação `+1` em cada
  212 + uma, enquanto que seleccionando erradamente as opções 1, 2 e 4 obtém-se
  213 + cotação `-1`.
  214 + Do mesmo modo, não seleccionando as opções certas 0 e 3 obtém-se a cotação
  215 + `-1` em cada uma, e não seleccionando (correctamente) as 1, 2 e 4 obtém-se
  216 + `+1` em cada.
214 217  
215 218 *(Neste tipo de perguntas não há forma de responder a apenas algumas delas,
216 219 são sempre todas corrigidas. Se um aluno só sabe a resposta a algumas das
... ... @@ -230,7 +233,7 @@
230 233 ```
231 234  
232 235 Assume-se que a primeira alternativa de cada opção tem a cotação indicada
233   - em `correct`, enquanto a segunda alternativa tem a cotação simétrica.
  236 + em `correct`, enquanto a segunda alternativa tem a cotação contrária.
234 237  
235 238 Tal como nas perguntas do tipo `radio`, podem ser usadas as configurações
236 239 `shuffle` e `discount` com valor `false` para as desactivar.
... ... @@ -241,7 +244,7 @@
241 244 - ['Opção 1 (não)', 'Opção 1 (sim)']
242 245 - Opção 2 (não)
243 246 - Opção 3 (sim)
244   - correct: [1, -1, -1, 1]
  247 + correct: [1, 0, 0, 1]
245 248 shuffle: false
246 249  
247 250 # ----------------------------------------------------------------------------
... ... @@ -266,8 +269,7 @@
266 269 Neste caso, as respostas aceites são `azul`, `Azul` ou `AZUL`.
267 270  
268 271 Em alguns casos pode ser conveniente transformar a resposta antes de a
269   - comparar, por exemplo para remover espaços ou converter para maiúsculas ou
270   - maiúsculas.
  272 + comparar, por exemplo para remover espaços ou converter para maiúsculas.
271 273 A opção `transform` permite dar uma sequência de transformações a aplicar à
272 274 resposta do aluno, por exemplo:
273 275  
... ... @@ -284,7 +286,7 @@
284 286 * `normalize_space` remove espaços do início e fim (trim), e substitui
285 287 múltiplos espaços por um único espaço (no meio).
286 288 * `lower` e `upper` convertem respectivamente para minúsculas e maiúsculas.
287   - transform: ['remove_spaces', 'lower']
  289 + transform: ['trim', 'lower']
288 290 correct: ['azul']
289 291  
290 292 # ---------------------------------------------------------------------------
... ... @@ -349,6 +351,9 @@
349 351 Neste exemplo o intervalo de respostas correctas é o intervalo fechado
350 352 [3.14, 3.15].
351 353  
  354 + Se em vez de dar um intervalo, apenas for indicado um valor numérico $n$,
  355 + este é automaticamente convertido para para um intervalo $[n,n]$.
  356 +
352 357 **Atenção:** as respostas têm de usar o ponto como separador decimal.
353 358 Em geral são aceites números inteiros, como `123`,
354 359 ou em vírgula flutuante, como em `0.23`, `1e-3`.
... ... @@ -362,8 +367,8 @@
362 367 ref: tut-textarea
363 368 title: Resposta em múltiplas linhas de texto
364 369 text: |
365   - Este tipo de perguntas permitem respostas em múltiplas linhas de texto, que
366   - podem ser úteis por exemplo para introduzir código.
  370 + Este tipo de perguntas permitem respostas em múltiplas linhas de texto e
  371 + são as mais flexíveis.
367 372  
368 373 A resposta é enviada para um programa externo para ser avaliada.
369 374 O programa externo é um programa escrito numa linguagem qualquer, desde que
... ... @@ -401,8 +406,8 @@
401 406 0.75
402 407 ```
403 408  
404   - ou opcionalmente escrever em formato yaml, eventualmente com um comentário
405   - que será arquivado com o teste.
  409 + ou opcionalmente escrever em formato json ou yaml, eventualmente com um
  410 + comentário que será arquivado com o teste.
406 411 Exemplo:
407 412  
408 413 ```yaml
... ...
perguntations/questions.py
... ... @@ -42,7 +42,6 @@ class Question(dict):
42 42 'comments': '',
43 43 'solution': '',
44 44 'files': {},
45   - # 'max_tries': 3,
46 45 }))
47 46  
48 47 def correct(self) -> None:
... ... @@ -72,7 +71,6 @@ class QuestionRadio(Question):
72 71 '''
73 72  
74 73 # ------------------------------------------------------------------------
75   - # FIXME marking all options right breaks
76 74 def __init__(self, q: QDict) -> None:
77 75 super().__init__(q)
78 76  
... ... @@ -86,18 +84,46 @@ class QuestionRadio(Question):
86 84 'max_tries': (n + 3) // 4 # 1 try for each 4 options
87 85 }))
88 86  
89   - # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0]
90   - # correctness levels from 0.0 to 1.0 (no discount here!)
  87 + # check correct bounds and convert int to list,
  88 + # e.g. correct: 2 --> correct: [0,0,1,0,0]
91 89 if isinstance(self['correct'], int):
  90 + if not (0 <= self['correct'] < n):
  91 + msg = (f'Correct option not in range 0..{n-1} in '
  92 + f'"{self["ref"]}"')
  93 + raise QuestionException(msg)
  94 +
92 95 self['correct'] = [1.0 if x == self['correct'] else 0.0
93 96 for x in range(n)]
94 97  
95   - if len(self['correct']) != n:
96   - msg = ('Number of options and correct differ in '
97   - f'"{self["ref"]}", file "{self["filename"]}".')
98   - logger.error(msg)
99   - raise QuestionException(msg)
100   -
  98 + elif isinstance(self['correct'], list):
  99 + # must match number of options
  100 + if len(self['correct']) != n:
  101 + msg = (f'Incompatible sizes: {n} options vs '
  102 + f'{len(self["correct"])} correct in "{self["ref"]}"')
  103 + raise QuestionException(msg)
  104 + # make sure is a list of floats
  105 + try:
  106 + self['correct'] = [float(x) for x in self['correct']]
  107 + except (ValueError, TypeError):
  108 + msg = (f'Correct list must contain numbers [0.0, 1.0] or '
  109 + f'booleans in "{self["ref"]}"')
  110 + raise QuestionException(msg)
  111 +
  112 + # check grade boundaries
  113 + if self['discount'] and not all(0.0 <= x <= 1.0
  114 + for x in self['correct']):
  115 + msg = (f'Correct values must be in the interval [0.0, 1.0] in '
  116 + f'"{self["ref"]}"')
  117 + raise QuestionException(msg)
  118 +
  119 + # at least one correct option
  120 + if all(x < 1.0 for x in self['correct']):
  121 + msg = (f'At least one correct option is required in '
  122 + f'"{self["ref"]}"')
  123 + raise QuestionException(msg)
  124 +
  125 + # If shuffle==false, all options are shown as defined
  126 + # otherwise, select 1 correct and choose a few wrong ones
101 127 if self['shuffle']:
102 128 # lists with indices of right and wrong options
103 129 right = [i for i in range(n) if self['correct'][i] >= 1]
... ... @@ -123,7 +149,7 @@ class QuestionRadio(Question):
123 149 # final shuffle of the options
124 150 perm = random.sample(range(self['choose']), k=self['choose'])
125 151 self['options'] = [str(options[i]) for i in perm]
126   - self['correct'] = [float(correct[i]) for i in perm]
  152 + self['correct'] = [correct[i] for i in perm]
127 153  
128 154 # ------------------------------------------------------------------------
129 155 # can assign negative grades for wrong answers
... ... @@ -131,10 +157,13 @@ class QuestionRadio(Question):
131 157 super().correct()
132 158  
133 159 if self['answer'] is not None:
134   - x = self['correct'][int(self['answer'])]
135   - if self['discount']:
136   - n = len(self['options']) # number of options
137   - x_aver = sum(self['correct']) / n
  160 + x = self['correct'][int(self['answer'])] # get grade of the answer
  161 + n = len(self['options'])
  162 + x_aver = sum(self['correct']) / n # expected value of grade
  163 +
  164 + # note: there are no numerical errors when summing 1.0s so the
  165 + # x_aver can be exactly 1.0 if all options are right
  166 + if self['discount'] and x_aver != 1.0:
138 167 x = (x - x_aver) / (1.0 - x_aver)
139 168 self['grade'] = x
140 169  
... ... @@ -168,12 +197,44 @@ class QuestionCheckbox(Question):
168 197 'max_tries': max(1, min(n - 1, 3))
169 198 }))
170 199  
  200 + # must be a list of numbers
  201 + if not isinstance(self['correct'], list):
  202 + msg = 'Correct must be a list of numbers or booleans'
  203 + raise QuestionException(msg)
  204 +
  205 + # must match number of options
171 206 if len(self['correct']) != n:
172   - msg = (f'Options and correct size mismatch in '
173   - f'"{self["ref"]}", file "{self["filename"]}".')
174   - logger.error(msg)
  207 + msg = (f'Incompatible sizes: {n} options vs '
  208 + f'{len(self["correct"])} correct in "{self["ref"]}"')
  209 + raise QuestionException(msg)
  210 +
  211 + # make sure is a list of floats
  212 + try:
  213 + self['correct'] = [float(x) for x in self['correct']]
  214 + except (ValueError, TypeError):
  215 + msg = (f'Correct list must contain numbers or '
  216 + f'booleans in "{self["ref"]}"')
175 217 raise QuestionException(msg)
176 218  
  219 + # check grade boundaries
  220 + if self['discount'] and not all(0.0 <= x <= 1.0
  221 + for x in self['correct']):
  222 +
  223 + msg0 = ('+-------------- BEHAVIOR CHANGE NOTICE --------------+')
  224 + msg1 = ('| Correct values must be in the interval [0.0, 1.0]. |')
  225 + msg2 = ('| I will convert to the new behavior, but you should |')
  226 + msg3 = ('| fix it in the question. |')
  227 + msg4 = ('+----------------------------------------------------+')
  228 + logger.warning(msg0)
  229 + logger.warning(msg1)
  230 + logger.warning(msg2)
  231 + logger.warning(msg3)
  232 + logger.warning(msg4)
  233 + logger.warning(f'-> please fix "{self["ref"]}"')
  234 +
  235 + # normalize to [0,1]
  236 + self['correct'] = [(x+1)/2 for x in self['correct']]
  237 +
177 238 # if an option is a list of (right, wrong), pick one
178 239 options = []
179 240 correct = []
... ... @@ -182,9 +243,10 @@ class QuestionCheckbox(Question):
182 243 r = random.randint(0, 1)
183 244 o = o[r]
184 245 if r == 1:
185   - c = -c
  246 + # c = -c
  247 + c = 1.0 - c
186 248 options.append(str(o))
187   - correct.append(float(c))
  249 + correct.append(c)
188 250  
189 251 # generate random permutation, e.g. [2,1,4,0,3]
190 252 # and apply to `options` and `correct`
... ... @@ -202,21 +264,20 @@ class QuestionCheckbox(Question):
202 264 super().correct()
203 265  
204 266 if self['answer'] is not None:
205   - sum_abs = sum(abs(p) for p in self['correct'])
206   - if sum_abs < 1e-6: # case correct [0,...,0] avoid div-by-zero
207   - self['grade'] = 1.0
208   -
  267 + x = 0.0
  268 + if self['discount']:
  269 + sum_abs = sum(abs(2*p-1) for p in self['correct'])
  270 + for i, p in enumerate(self['correct']):
  271 + x += 2*p-1 if str(i) in self['answer'] else 1-2*p
209 272 else:
210   - x = 0.0
211   -
212   - if self['discount']:
213   - for i, p in enumerate(self['correct']):
214   - x += p if str(i) in self['answer'] else -p
215   - else:
216   - for i, p in enumerate(self['correct']):
217   - x += p if str(i) in self['answer'] else 0.0
  273 + sum_abs = sum(abs(p) for p in self['correct'])
  274 + for i, p in enumerate(self['correct']):
  275 + x += p if str(i) in self['answer'] else 0.0
218 276  
  277 + try:
219 278 self['grade'] = x / sum_abs
  279 + except ZeroDivisionError:
  280 + self['grade'] = 1.0 # limit p->0
220 281  
221 282  
222 283 # ============================================================================
... ... @@ -240,29 +301,35 @@ class QuestionText(Question):
240 301  
241 302 # make sure its always a list of possible correct answers
242 303 if not isinstance(self['correct'], list):
243   - self['correct'] = [self['correct']]
  304 + self['correct'] = [str(self['correct'])]
  305 + else:
  306 + # make sure all elements of the list are strings
  307 + self['correct'] = [str(a) for a in self['correct']]
244 308  
245   - # make sure all elements of the list are strings
246   - self['correct'] = [str(a) for a in self['correct']]
  309 + for f in self['transform']:
  310 + if f not in ('remove_space', 'trim', 'normalize_space', 'lower',
  311 + 'upper'):
  312 + msg = (f'Unknown transform "{f}" in "{self["ref"]}"')
  313 + raise QuestionException(msg)
247 314  
248   - # make sure that the answers are invariant with respect to the filters
  315 + # check if answers are invariant with respect to the transforms
249 316 if any(c != self.transform(c) for c in self['correct']):
250 317 logger.warning(f'in "{self["ref"]}", correct answers are not '
251   - 'invariant wrt transformations')
  318 + 'invariant wrt transformations => never correct')
252 319  
253 320 # ------------------------------------------------------------------------
254 321 # apply optional filters to the answer
255 322 def transform(self, ans):
256 323 for f in self['transform']:
257   - if f == 'remove_space':
  324 + if f == 'remove_space': # removes all spaces
258 325 ans = ans.replace(' ', '')
259   - elif f == 'trim':
  326 + elif f == 'trim': # removes spaces around
260 327 ans = ans.strip()
261   - elif f == 'normalize_space':
  328 + elif f == 'normalize_space': # replaces multiple spaces by one
262 329 ans = re.sub(r'\s+', ' ', ans.strip())
263   - elif f == 'lower':
  330 + elif f == 'lower': # convert to lowercase
264 331 ans = ans.lower()
265   - elif f == 'upper':
  332 + elif f == 'upper': # convert to uppercase
266 333 ans = ans.upper()
267 334 else:
268 335 logger.warning(f'in "{self["ref"]}", unknown transform "{f}"')
... ... @@ -302,8 +369,12 @@ class QuestionTextRegex(Question):
302 369 if not isinstance(self['correct'], list):
303 370 self['correct'] = [self['correct']]
304 371  
305   - # make sure all elements of the list are strings
306   - self['correct'] = [str(a) for a in self['correct']]
  372 + # converts patterns to compiled versions
  373 + try:
  374 + self['correct'] = [re.compile(a) for a in self['correct']]
  375 + except Exception:
  376 + msg = f'Failed to compile regex in "{self["ref"]}"'
  377 + raise QuestionException(msg)
307 378  
308 379 # ------------------------------------------------------------------------
309 380 def correct(self) -> None:
... ... @@ -312,12 +383,12 @@ class QuestionTextRegex(Question):
312 383 self['grade'] = 0.0
313 384 for r in self['correct']:
314 385 try:
315   - if re.match(r, self['answer']):
  386 + if r.match(self['answer']):
316 387 self['grade'] = 1.0
317 388 return
318 389 except TypeError:
319   - logger.error(f'While matching regex {self["correct"]} with'
320   - f' answer "{self["answer"]}".')
  390 + logger.error(f'While matching regex {r.pattern} with '
  391 + f'answer "{self["answer"]}".')
321 392  
322 393  
323 394 # ============================================================================
... ... @@ -339,6 +410,30 @@ class QuestionNumericInterval(Question):
339 410 'correct': [1.0, -1.0], # will always return false
340 411 }))
341 412  
  413 + # if only one number n is given, make an interval [n,n]
  414 + if isinstance(self['correct'], (int, float)):
  415 + self['correct'] = [float(self['correct']), float(self['correct'])]
  416 +
  417 + # make sure its a list of two numbers
  418 + elif isinstance(self['correct'], list):
  419 + if len(self['correct']) != 2:
  420 + msg = (f'Numeric interval must be a list with two numbers, in '
  421 + f'{self["ref"]}')
  422 + raise QuestionException(msg)
  423 +
  424 + try:
  425 + self['correct'] = [float(n) for n in self['correct']]
  426 + except Exception:
  427 + msg = (f'Numeric interval must be a list with two numbers, in '
  428 + f'{self["ref"]}')
  429 + raise QuestionException(msg)
  430 +
  431 + # invalid
  432 + else:
  433 + msg = (f'Numeric interval must be a list with two numbers, in '
  434 + f'{self["ref"]}')
  435 + raise QuestionException(msg)
  436 +
342 437 # ------------------------------------------------------------------------
343 438 def correct(self) -> None:
344 439 super().correct()
... ... @@ -520,23 +615,27 @@ class QFactory(object):
520 615 if q['type'] == 'generator':
521 616 logger.debug(f' \\_ Running "{q["script"]}".')
522 617 q.setdefault('args', [])
523   - q.setdefault('stdin', '') # FIXME is it really necessary?
  618 + q.setdefault('stdin', '')
524 619 script = path.join(q['path'], q['script'])
525 620 out = await run_script_async(script=script, args=q['args'],
526 621 stdin=q['stdin'])
527 622 q.update(out)
528 623  
529   - # Finally we create an instance of Question()
  624 + # Get class for this question type
530 625 try:
531   - qinstance = self._types[q['type']](QDict(q)) # of matching class
532   - except QuestionException as e:
533   - logger.error(e)
534   - raise e
  626 + qclass = self._types[q['type']]
535 627 except KeyError:
536 628 logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
537 629 raise
538   - else:
539   - return qinstance
  630 +
  631 + # Finally create an instance of Question()
  632 + try:
  633 + qinstance = qclass(QDict(q))
  634 + except QuestionException as e:
  635 + # logger.error(e)
  636 + raise e
  637 +
  638 + return qinstance
540 639  
541 640 # ------------------------------------------------------------------------
542 641 def generate(self) -> Question:
... ...
perguntations/test.py
... ... @@ -141,7 +141,7 @@ class TestFactory(dict):
141 141 logger.warning('Undefined title!')
142 142  
143 143 if self['scale_points']:
144   - logger.info(f'Grades are scaled to the interval [{self["scale_min"]}, {self["scale_max"]}]')
  144 + logger.info(f'Grades will be scaled to [{self["scale_min"]}, {self["scale_max"]}]')
145 145 else:
146 146 logger.info('Grades are just the sum of points defined for the questions, not being scaled.')
147 147  
... ...