Commit c4841a5d90f6510ffd4beb571ea295e1ce7050ab
Exists in
master
and in
1 other branch
Merge branch 'dev'
Showing
3 changed files
with
178 additions
and
74 deletions
Show diff stats
demo/questions/questions-tutorial.yaml
| @@ -185,11 +185,13 @@ | @@ -185,11 +185,13 @@ | ||
| 185 | title: Escolha múltipla, várias opções correctas | 185 | title: Escolha múltipla, várias opções correctas |
| 186 | text: | | 186 | text: | |
| 187 | As perguntas de escolha múltipla permitem apresentar um conjunto de opções | 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 | Funcionam como múltiplas perguntas independentes de resposta sim/não. | 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 | ```yaml | 196 | ```yaml |
| 195 | - type: checkbox | 197 | - type: checkbox |
| @@ -203,14 +205,15 @@ | @@ -203,14 +205,15 @@ | ||
| 203 | - Opção 2 | 205 | - Opção 2 |
| 204 | - Opção 3 (certa) | 206 | - Opção 3 (certa) |
| 205 | - Opção 4 | 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 | *(Neste tipo de perguntas não há forma de responder a apenas algumas delas, | 218 | *(Neste tipo de perguntas não há forma de responder a apenas algumas delas, |
| 216 | são sempre todas corrigidas. Se um aluno só sabe a resposta a algumas das | 219 | são sempre todas corrigidas. Se um aluno só sabe a resposta a algumas das |
| @@ -230,7 +233,7 @@ | @@ -230,7 +233,7 @@ | ||
| 230 | ``` | 233 | ``` |
| 231 | 234 | ||
| 232 | Assume-se que a primeira alternativa de cada opção tem a cotação indicada | 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 | Tal como nas perguntas do tipo `radio`, podem ser usadas as configurações | 238 | Tal como nas perguntas do tipo `radio`, podem ser usadas as configurações |
| 236 | `shuffle` e `discount` com valor `false` para as desactivar. | 239 | `shuffle` e `discount` com valor `false` para as desactivar. |
| @@ -241,7 +244,7 @@ | @@ -241,7 +244,7 @@ | ||
| 241 | - ['Opção 1 (não)', 'Opção 1 (sim)'] | 244 | - ['Opção 1 (não)', 'Opção 1 (sim)'] |
| 242 | - Opção 2 (não) | 245 | - Opção 2 (não) |
| 243 | - Opção 3 (sim) | 246 | - Opção 3 (sim) |
| 244 | - correct: [1, -1, -1, 1] | 247 | + correct: [1, 0, 0, 1] |
| 245 | shuffle: false | 248 | shuffle: false |
| 246 | 249 | ||
| 247 | # ---------------------------------------------------------------------------- | 250 | # ---------------------------------------------------------------------------- |
| @@ -266,8 +269,7 @@ | @@ -266,8 +269,7 @@ | ||
| 266 | Neste caso, as respostas aceites são `azul`, `Azul` ou `AZUL`. | 269 | Neste caso, as respostas aceites são `azul`, `Azul` ou `AZUL`. |
| 267 | 270 | ||
| 268 | Em alguns casos pode ser conveniente transformar a resposta antes de a | 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 | A opção `transform` permite dar uma sequência de transformações a aplicar à | 273 | A opção `transform` permite dar uma sequência de transformações a aplicar à |
| 272 | resposta do aluno, por exemplo: | 274 | resposta do aluno, por exemplo: |
| 273 | 275 | ||
| @@ -284,7 +286,7 @@ | @@ -284,7 +286,7 @@ | ||
| 284 | * `normalize_space` remove espaços do início e fim (trim), e substitui | 286 | * `normalize_space` remove espaços do início e fim (trim), e substitui |
| 285 | múltiplos espaços por um único espaço (no meio). | 287 | múltiplos espaços por um único espaço (no meio). |
| 286 | * `lower` e `upper` convertem respectivamente para minúsculas e maiúsculas. | 288 | * `lower` e `upper` convertem respectivamente para minúsculas e maiúsculas. |
| 287 | - transform: ['remove_spaces', 'lower'] | 289 | + transform: ['trim', 'lower'] |
| 288 | correct: ['azul'] | 290 | correct: ['azul'] |
| 289 | 291 | ||
| 290 | # --------------------------------------------------------------------------- | 292 | # --------------------------------------------------------------------------- |
| @@ -349,6 +351,9 @@ | @@ -349,6 +351,9 @@ | ||
| 349 | Neste exemplo o intervalo de respostas correctas é o intervalo fechado | 351 | Neste exemplo o intervalo de respostas correctas é o intervalo fechado |
| 350 | [3.14, 3.15]. | 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 | **Atenção:** as respostas têm de usar o ponto como separador decimal. | 357 | **Atenção:** as respostas têm de usar o ponto como separador decimal. |
| 353 | Em geral são aceites números inteiros, como `123`, | 358 | Em geral são aceites números inteiros, como `123`, |
| 354 | ou em vírgula flutuante, como em `0.23`, `1e-3`. | 359 | ou em vírgula flutuante, como em `0.23`, `1e-3`. |
| @@ -362,8 +367,8 @@ | @@ -362,8 +367,8 @@ | ||
| 362 | ref: tut-textarea | 367 | ref: tut-textarea |
| 363 | title: Resposta em múltiplas linhas de texto | 368 | title: Resposta em múltiplas linhas de texto |
| 364 | text: | | 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 | A resposta é enviada para um programa externo para ser avaliada. | 373 | A resposta é enviada para um programa externo para ser avaliada. |
| 369 | O programa externo é um programa escrito numa linguagem qualquer, desde que | 374 | O programa externo é um programa escrito numa linguagem qualquer, desde que |
| @@ -401,8 +406,8 @@ | @@ -401,8 +406,8 @@ | ||
| 401 | 0.75 | 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 | Exemplo: | 411 | Exemplo: |
| 407 | 412 | ||
| 408 | ```yaml | 413 | ```yaml |
perguntations/questions.py
| @@ -42,7 +42,6 @@ class Question(dict): | @@ -42,7 +42,6 @@ class Question(dict): | ||
| 42 | 'comments': '', | 42 | 'comments': '', |
| 43 | 'solution': '', | 43 | 'solution': '', |
| 44 | 'files': {}, | 44 | 'files': {}, |
| 45 | - # 'max_tries': 3, | ||
| 46 | })) | 45 | })) |
| 47 | 46 | ||
| 48 | def correct(self) -> None: | 47 | def correct(self) -> None: |
| @@ -72,7 +71,6 @@ class QuestionRadio(Question): | @@ -72,7 +71,6 @@ class QuestionRadio(Question): | ||
| 72 | ''' | 71 | ''' |
| 73 | 72 | ||
| 74 | # ------------------------------------------------------------------------ | 73 | # ------------------------------------------------------------------------ |
| 75 | - # FIXME marking all options right breaks | ||
| 76 | def __init__(self, q: QDict) -> None: | 74 | def __init__(self, q: QDict) -> None: |
| 77 | super().__init__(q) | 75 | super().__init__(q) |
| 78 | 76 | ||
| @@ -86,18 +84,46 @@ class QuestionRadio(Question): | @@ -86,18 +84,46 @@ class QuestionRadio(Question): | ||
| 86 | 'max_tries': (n + 3) // 4 # 1 try for each 4 options | 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 | if isinstance(self['correct'], int): | 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 | self['correct'] = [1.0 if x == self['correct'] else 0.0 | 95 | self['correct'] = [1.0 if x == self['correct'] else 0.0 |
| 93 | for x in range(n)] | 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 | if self['shuffle']: | 127 | if self['shuffle']: |
| 102 | # lists with indices of right and wrong options | 128 | # lists with indices of right and wrong options |
| 103 | right = [i for i in range(n) if self['correct'][i] >= 1] | 129 | right = [i for i in range(n) if self['correct'][i] >= 1] |
| @@ -123,7 +149,7 @@ class QuestionRadio(Question): | @@ -123,7 +149,7 @@ class QuestionRadio(Question): | ||
| 123 | # final shuffle of the options | 149 | # final shuffle of the options |
| 124 | perm = random.sample(range(self['choose']), k=self['choose']) | 150 | perm = random.sample(range(self['choose']), k=self['choose']) |
| 125 | self['options'] = [str(options[i]) for i in perm] | 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 | # can assign negative grades for wrong answers | 155 | # can assign negative grades for wrong answers |
| @@ -131,10 +157,13 @@ class QuestionRadio(Question): | @@ -131,10 +157,13 @@ class QuestionRadio(Question): | ||
| 131 | super().correct() | 157 | super().correct() |
| 132 | 158 | ||
| 133 | if self['answer'] is not None: | 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 | x = (x - x_aver) / (1.0 - x_aver) | 167 | x = (x - x_aver) / (1.0 - x_aver) |
| 139 | self['grade'] = x | 168 | self['grade'] = x |
| 140 | 169 | ||
| @@ -168,12 +197,44 @@ class QuestionCheckbox(Question): | @@ -168,12 +197,44 @@ class QuestionCheckbox(Question): | ||
| 168 | 'max_tries': max(1, min(n - 1, 3)) | 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 | if len(self['correct']) != n: | 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 | raise QuestionException(msg) | 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 | # if an option is a list of (right, wrong), pick one | 238 | # if an option is a list of (right, wrong), pick one |
| 178 | options = [] | 239 | options = [] |
| 179 | correct = [] | 240 | correct = [] |
| @@ -182,9 +243,10 @@ class QuestionCheckbox(Question): | @@ -182,9 +243,10 @@ class QuestionCheckbox(Question): | ||
| 182 | r = random.randint(0, 1) | 243 | r = random.randint(0, 1) |
| 183 | o = o[r] | 244 | o = o[r] |
| 184 | if r == 1: | 245 | if r == 1: |
| 185 | - c = -c | 246 | + # c = -c |
| 247 | + c = 1.0 - c | ||
| 186 | options.append(str(o)) | 248 | options.append(str(o)) |
| 187 | - correct.append(float(c)) | 249 | + correct.append(c) |
| 188 | 250 | ||
| 189 | # generate random permutation, e.g. [2,1,4,0,3] | 251 | # generate random permutation, e.g. [2,1,4,0,3] |
| 190 | # and apply to `options` and `correct` | 252 | # and apply to `options` and `correct` |
| @@ -202,21 +264,20 @@ class QuestionCheckbox(Question): | @@ -202,21 +264,20 @@ class QuestionCheckbox(Question): | ||
| 202 | super().correct() | 264 | super().correct() |
| 203 | 265 | ||
| 204 | if self['answer'] is not None: | 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 | else: | 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 | self['grade'] = x / sum_abs | 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,29 +301,35 @@ class QuestionText(Question): | ||
| 240 | 301 | ||
| 241 | # make sure its always a list of possible correct answers | 302 | # make sure its always a list of possible correct answers |
| 242 | if not isinstance(self['correct'], list): | 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 | if any(c != self.transform(c) for c in self['correct']): | 316 | if any(c != self.transform(c) for c in self['correct']): |
| 250 | logger.warning(f'in "{self["ref"]}", correct answers are not ' | 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 | # apply optional filters to the answer | 321 | # apply optional filters to the answer |
| 255 | def transform(self, ans): | 322 | def transform(self, ans): |
| 256 | for f in self['transform']: | 323 | for f in self['transform']: |
| 257 | - if f == 'remove_space': | 324 | + if f == 'remove_space': # removes all spaces |
| 258 | ans = ans.replace(' ', '') | 325 | ans = ans.replace(' ', '') |
| 259 | - elif f == 'trim': | 326 | + elif f == 'trim': # removes spaces around |
| 260 | ans = ans.strip() | 327 | ans = ans.strip() |
| 261 | - elif f == 'normalize_space': | 328 | + elif f == 'normalize_space': # replaces multiple spaces by one |
| 262 | ans = re.sub(r'\s+', ' ', ans.strip()) | 329 | ans = re.sub(r'\s+', ' ', ans.strip()) |
| 263 | - elif f == 'lower': | 330 | + elif f == 'lower': # convert to lowercase |
| 264 | ans = ans.lower() | 331 | ans = ans.lower() |
| 265 | - elif f == 'upper': | 332 | + elif f == 'upper': # convert to uppercase |
| 266 | ans = ans.upper() | 333 | ans = ans.upper() |
| 267 | else: | 334 | else: |
| 268 | logger.warning(f'in "{self["ref"]}", unknown transform "{f}"') | 335 | logger.warning(f'in "{self["ref"]}", unknown transform "{f}"') |
| @@ -302,8 +369,12 @@ class QuestionTextRegex(Question): | @@ -302,8 +369,12 @@ class QuestionTextRegex(Question): | ||
| 302 | if not isinstance(self['correct'], list): | 369 | if not isinstance(self['correct'], list): |
| 303 | self['correct'] = [self['correct']] | 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 | def correct(self) -> None: | 380 | def correct(self) -> None: |
| @@ -312,12 +383,12 @@ class QuestionTextRegex(Question): | @@ -312,12 +383,12 @@ class QuestionTextRegex(Question): | ||
| 312 | self['grade'] = 0.0 | 383 | self['grade'] = 0.0 |
| 313 | for r in self['correct']: | 384 | for r in self['correct']: |
| 314 | try: | 385 | try: |
| 315 | - if re.match(r, self['answer']): | 386 | + if r.match(self['answer']): |
| 316 | self['grade'] = 1.0 | 387 | self['grade'] = 1.0 |
| 317 | return | 388 | return |
| 318 | except TypeError: | 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,6 +410,30 @@ class QuestionNumericInterval(Question): | ||
| 339 | 'correct': [1.0, -1.0], # will always return false | 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 | def correct(self) -> None: | 438 | def correct(self) -> None: |
| 344 | super().correct() | 439 | super().correct() |
| @@ -520,23 +615,27 @@ class QFactory(object): | @@ -520,23 +615,27 @@ class QFactory(object): | ||
| 520 | if q['type'] == 'generator': | 615 | if q['type'] == 'generator': |
| 521 | logger.debug(f' \\_ Running "{q["script"]}".') | 616 | logger.debug(f' \\_ Running "{q["script"]}".') |
| 522 | q.setdefault('args', []) | 617 | q.setdefault('args', []) |
| 523 | - q.setdefault('stdin', '') # FIXME is it really necessary? | 618 | + q.setdefault('stdin', '') |
| 524 | script = path.join(q['path'], q['script']) | 619 | script = path.join(q['path'], q['script']) |
| 525 | out = await run_script_async(script=script, args=q['args'], | 620 | out = await run_script_async(script=script, args=q['args'], |
| 526 | stdin=q['stdin']) | 621 | stdin=q['stdin']) |
| 527 | q.update(out) | 622 | q.update(out) |
| 528 | 623 | ||
| 529 | - # Finally we create an instance of Question() | 624 | + # Get class for this question type |
| 530 | try: | 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 | except KeyError: | 627 | except KeyError: |
| 536 | logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') | 628 | logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') |
| 537 | raise | 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 | def generate(self) -> Question: | 641 | def generate(self) -> Question: |
perguntations/test.py
| @@ -141,7 +141,7 @@ class TestFactory(dict): | @@ -141,7 +141,7 @@ class TestFactory(dict): | ||
| 141 | logger.warning('Undefined title!') | 141 | logger.warning('Undefined title!') |
| 142 | 142 | ||
| 143 | if self['scale_points']: | 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 | else: | 145 | else: |
| 146 | logger.info('Grades are just the sum of points defined for the questions, not being scaled.') | 146 | logger.info('Grades are just the sum of points defined for the questions, not being scaled.') |
| 147 | 147 |