diff --git a/demo/questions/questions-tutorial.yaml b/demo/questions/questions-tutorial.yaml index 9cee83a..81cb6b7 100644 --- a/demo/questions/questions-tutorial.yaml +++ b/demo/questions/questions-tutorial.yaml @@ -185,11 +185,13 @@ title: Escolha múltipla, várias opções correctas text: | As perguntas de escolha múltipla permitem apresentar um conjunto de opções - podendo ser seleccionadas várias em simultaneo. + podendo ser seleccionadas várias em simultâneo. Funcionam como múltiplas perguntas independentes de resposta sim/não. - Cada opção seleccionada (`sim`) recebe a cotação indicada em `correct`. - Cada opção não seleccionadas (`não`) tem a cotação simétrica. + As respostas que devem ou não ser seleccionadas são indicadas com `1` e `0` + ou com booleanos `true` e `false`. + Cada resposta errada desconta um valor que é o simétrico da resposta certa. + Se acertar uma opção ganha `+1`, se errar obtém `-1`. ```yaml - type: checkbox @@ -203,14 +205,15 @@ - Opção 2 - Opção 3 (certa) - Opção 4 - correct: [+1, -1, -1, +1, -1] + correct: [1, 0, 0, 1, 0] ``` - Neste exemplo, seleccionando as opções 0 e 3 obtém-se cotação +1 em cada - uma, enquanto que seleccionando as opções 1, 2 e 4 obtém-se cotação -1. - As opções não seleccionadas pelo aluno dão a cotação simétrica à indicada. - Por exemplo se não seleccionar a opção 0, tem cotação -1, e não - seleccionando a opção 1 obtém-se +1. + Neste exemplo, seleccionando as opções 0 e 3 obtém-se cotação `+1` em cada + uma, enquanto que seleccionando erradamente as opções 1, 2 e 4 obtém-se + cotação `-1`. + Do mesmo modo, não seleccionando as opções certas 0 e 3 obtém-se a cotação + `-1` em cada uma, e não seleccionando (correctamente) as 1, 2 e 4 obtém-se + `+1` em cada. *(Neste tipo de perguntas não há forma de responder a apenas algumas delas, são sempre todas corrigidas. Se um aluno só sabe a resposta a algumas das @@ -230,7 +233,7 @@ ``` Assume-se que a primeira alternativa de cada opção tem a cotação indicada - em `correct`, enquanto a segunda alternativa tem a cotação simétrica. + em `correct`, enquanto a segunda alternativa tem a cotação contrária. Tal como nas perguntas do tipo `radio`, podem ser usadas as configurações `shuffle` e `discount` com valor `false` para as desactivar. @@ -241,7 +244,7 @@ - ['Opção 1 (não)', 'Opção 1 (sim)'] - Opção 2 (não) - Opção 3 (sim) - correct: [1, -1, -1, 1] + correct: [1, 0, 0, 1] shuffle: false # ---------------------------------------------------------------------------- @@ -266,8 +269,7 @@ Neste caso, as respostas aceites são `azul`, `Azul` ou `AZUL`. Em alguns casos pode ser conveniente transformar a resposta antes de a - comparar, por exemplo para remover espaços ou converter para maiúsculas ou - maiúsculas. + comparar, por exemplo para remover espaços ou converter para maiúsculas. A opção `transform` permite dar uma sequência de transformações a aplicar à resposta do aluno, por exemplo: @@ -284,7 +286,7 @@ * `normalize_space` remove espaços do início e fim (trim), e substitui múltiplos espaços por um único espaço (no meio). * `lower` e `upper` convertem respectivamente para minúsculas e maiúsculas. - transform: ['remove_spaces', 'lower'] + transform: ['trim', 'lower'] correct: ['azul'] # --------------------------------------------------------------------------- @@ -349,6 +351,9 @@ Neste exemplo o intervalo de respostas correctas é o intervalo fechado [3.14, 3.15]. + Se em vez de dar um intervalo, apenas for indicado um valor numérico $n$, + este é automaticamente convertido para para um intervalo $[n,n]$. + **Atenção:** as respostas têm de usar o ponto como separador decimal. Em geral são aceites números inteiros, como `123`, ou em vírgula flutuante, como em `0.23`, `1e-3`. @@ -362,8 +367,8 @@ ref: tut-textarea title: Resposta em múltiplas linhas de texto text: | - Este tipo de perguntas permitem respostas em múltiplas linhas de texto, que - podem ser úteis por exemplo para introduzir código. + Este tipo de perguntas permitem respostas em múltiplas linhas de texto e + são as mais flexíveis. A resposta é enviada para um programa externo para ser avaliada. O programa externo é um programa escrito numa linguagem qualquer, desde que @@ -401,8 +406,8 @@ 0.75 ``` - ou opcionalmente escrever em formato yaml, eventualmente com um comentário - que será arquivado com o teste. + ou opcionalmente escrever em formato json ou yaml, eventualmente com um + comentário que será arquivado com o teste. Exemplo: ```yaml diff --git a/perguntations/questions.py b/perguntations/questions.py index d66c2c6..4d17694 100644 --- a/perguntations/questions.py +++ b/perguntations/questions.py @@ -42,7 +42,6 @@ class Question(dict): 'comments': '', 'solution': '', 'files': {}, - # 'max_tries': 3, })) def correct(self) -> None: @@ -72,7 +71,6 @@ class QuestionRadio(Question): ''' # ------------------------------------------------------------------------ - # FIXME marking all options right breaks def __init__(self, q: QDict) -> None: super().__init__(q) @@ -86,18 +84,46 @@ class QuestionRadio(Question): 'max_tries': (n + 3) // 4 # 1 try for each 4 options })) - # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0] - # correctness levels from 0.0 to 1.0 (no discount here!) + # check correct bounds and convert int to list, + # e.g. correct: 2 --> correct: [0,0,1,0,0] if isinstance(self['correct'], int): + if not (0 <= self['correct'] < n): + msg = (f'Correct option not in range 0..{n-1} in ' + f'"{self["ref"]}"') + raise QuestionException(msg) + self['correct'] = [1.0 if x == self['correct'] else 0.0 for x in range(n)] - if len(self['correct']) != n: - msg = ('Number of options and correct differ in ' - f'"{self["ref"]}", file "{self["filename"]}".') - logger.error(msg) - raise QuestionException(msg) - + elif isinstance(self['correct'], list): + # must match number of options + if len(self['correct']) != n: + msg = (f'Incompatible sizes: {n} options vs ' + f'{len(self["correct"])} correct in "{self["ref"]}"') + raise QuestionException(msg) + # make sure is a list of floats + try: + self['correct'] = [float(x) for x in self['correct']] + except (ValueError, TypeError): + msg = (f'Correct list must contain numbers [0.0, 1.0] or ' + f'booleans in "{self["ref"]}"') + raise QuestionException(msg) + + # check grade boundaries + if self['discount'] and not all(0.0 <= x <= 1.0 + for x in self['correct']): + msg = (f'Correct values must be in the interval [0.0, 1.0] in ' + f'"{self["ref"]}"') + raise QuestionException(msg) + + # at least one correct option + if all(x < 1.0 for x in self['correct']): + msg = (f'At least one correct option is required in ' + f'"{self["ref"]}"') + raise QuestionException(msg) + + # If shuffle==false, all options are shown as defined + # otherwise, select 1 correct and choose a few wrong ones if self['shuffle']: # lists with indices of right and wrong options right = [i for i in range(n) if self['correct'][i] >= 1] @@ -123,7 +149,7 @@ class QuestionRadio(Question): # final shuffle of the options perm = random.sample(range(self['choose']), k=self['choose']) self['options'] = [str(options[i]) for i in perm] - self['correct'] = [float(correct[i]) for i in perm] + self['correct'] = [correct[i] for i in perm] # ------------------------------------------------------------------------ # can assign negative grades for wrong answers @@ -131,10 +157,13 @@ class QuestionRadio(Question): super().correct() if self['answer'] is not None: - x = self['correct'][int(self['answer'])] - if self['discount']: - n = len(self['options']) # number of options - x_aver = sum(self['correct']) / n + x = self['correct'][int(self['answer'])] # get grade of the answer + n = len(self['options']) + x_aver = sum(self['correct']) / n # expected value of grade + + # note: there are no numerical errors when summing 1.0s so the + # x_aver can be exactly 1.0 if all options are right + if self['discount'] and x_aver != 1.0: x = (x - x_aver) / (1.0 - x_aver) self['grade'] = x @@ -168,12 +197,44 @@ class QuestionCheckbox(Question): 'max_tries': max(1, min(n - 1, 3)) })) + # must be a list of numbers + if not isinstance(self['correct'], list): + msg = 'Correct must be a list of numbers or booleans' + raise QuestionException(msg) + + # must match number of options if len(self['correct']) != n: - msg = (f'Options and correct size mismatch in ' - f'"{self["ref"]}", file "{self["filename"]}".') - logger.error(msg) + msg = (f'Incompatible sizes: {n} options vs ' + f'{len(self["correct"])} correct in "{self["ref"]}"') + raise QuestionException(msg) + + # make sure is a list of floats + try: + self['correct'] = [float(x) for x in self['correct']] + except (ValueError, TypeError): + msg = (f'Correct list must contain numbers or ' + f'booleans in "{self["ref"]}"') raise QuestionException(msg) + # check grade boundaries + if self['discount'] and not all(0.0 <= x <= 1.0 + for x in self['correct']): + + msg0 = ('+-------------- BEHAVIOR CHANGE NOTICE --------------+') + msg1 = ('| Correct values must be in the interval [0.0, 1.0]. |') + msg2 = ('| I will convert to the new behavior, but you should |') + msg3 = ('| fix it in the question. |') + msg4 = ('+----------------------------------------------------+') + logger.warning(msg0) + logger.warning(msg1) + logger.warning(msg2) + logger.warning(msg3) + logger.warning(msg4) + logger.warning(f'-> please fix "{self["ref"]}"') + + # normalize to [0,1] + self['correct'] = [(x+1)/2 for x in self['correct']] + # if an option is a list of (right, wrong), pick one options = [] correct = [] @@ -182,9 +243,10 @@ class QuestionCheckbox(Question): r = random.randint(0, 1) o = o[r] if r == 1: - c = -c + # c = -c + c = 1.0 - c options.append(str(o)) - correct.append(float(c)) + correct.append(c) # generate random permutation, e.g. [2,1,4,0,3] # and apply to `options` and `correct` @@ -202,21 +264,20 @@ class QuestionCheckbox(Question): super().correct() if self['answer'] is not None: - sum_abs = sum(abs(p) for p in self['correct']) - if sum_abs < 1e-6: # case correct [0,...,0] avoid div-by-zero - self['grade'] = 1.0 - + x = 0.0 + if self['discount']: + sum_abs = sum(abs(2*p-1) for p in self['correct']) + for i, p in enumerate(self['correct']): + x += 2*p-1 if str(i) in self['answer'] else 1-2*p else: - x = 0.0 - - if self['discount']: - for i, p in enumerate(self['correct']): - x += p if str(i) in self['answer'] else -p - else: - for i, p in enumerate(self['correct']): - x += p if str(i) in self['answer'] else 0.0 + sum_abs = sum(abs(p) for p in self['correct']) + for i, p in enumerate(self['correct']): + x += p if str(i) in self['answer'] else 0.0 + try: self['grade'] = x / sum_abs + except ZeroDivisionError: + self['grade'] = 1.0 # limit p->0 # ============================================================================ @@ -240,29 +301,35 @@ class QuestionText(Question): # make sure its always a list of possible correct answers if not isinstance(self['correct'], list): - self['correct'] = [self['correct']] + self['correct'] = [str(self['correct'])] + else: + # make sure all elements of the list are strings + self['correct'] = [str(a) for a in self['correct']] - # make sure all elements of the list are strings - self['correct'] = [str(a) for a in self['correct']] + for f in self['transform']: + if f not in ('remove_space', 'trim', 'normalize_space', 'lower', + 'upper'): + msg = (f'Unknown transform "{f}" in "{self["ref"]}"') + raise QuestionException(msg) - # make sure that the answers are invariant with respect to the filters + # check if answers are invariant with respect to the transforms if any(c != self.transform(c) for c in self['correct']): logger.warning(f'in "{self["ref"]}", correct answers are not ' - 'invariant wrt transformations') + 'invariant wrt transformations => never correct') # ------------------------------------------------------------------------ # apply optional filters to the answer def transform(self, ans): for f in self['transform']: - if f == 'remove_space': + if f == 'remove_space': # removes all spaces ans = ans.replace(' ', '') - elif f == 'trim': + elif f == 'trim': # removes spaces around ans = ans.strip() - elif f == 'normalize_space': + elif f == 'normalize_space': # replaces multiple spaces by one ans = re.sub(r'\s+', ' ', ans.strip()) - elif f == 'lower': + elif f == 'lower': # convert to lowercase ans = ans.lower() - elif f == 'upper': + elif f == 'upper': # convert to uppercase ans = ans.upper() else: logger.warning(f'in "{self["ref"]}", unknown transform "{f}"') @@ -302,8 +369,12 @@ class QuestionTextRegex(Question): if not isinstance(self['correct'], list): self['correct'] = [self['correct']] - # make sure all elements of the list are strings - self['correct'] = [str(a) for a in self['correct']] + # converts patterns to compiled versions + try: + self['correct'] = [re.compile(a) for a in self['correct']] + except Exception: + msg = f'Failed to compile regex in "{self["ref"]}"' + raise QuestionException(msg) # ------------------------------------------------------------------------ def correct(self) -> None: @@ -312,12 +383,12 @@ class QuestionTextRegex(Question): self['grade'] = 0.0 for r in self['correct']: try: - if re.match(r, self['answer']): + if r.match(self['answer']): self['grade'] = 1.0 return except TypeError: - logger.error(f'While matching regex {self["correct"]} with' - f' answer "{self["answer"]}".') + logger.error(f'While matching regex {r.pattern} with ' + f'answer "{self["answer"]}".') # ============================================================================ @@ -339,6 +410,30 @@ class QuestionNumericInterval(Question): 'correct': [1.0, -1.0], # will always return false })) + # if only one number n is given, make an interval [n,n] + if isinstance(self['correct'], (int, float)): + self['correct'] = [float(self['correct']), float(self['correct'])] + + # make sure its a list of two numbers + elif isinstance(self['correct'], list): + if len(self['correct']) != 2: + msg = (f'Numeric interval must be a list with two numbers, in ' + f'{self["ref"]}') + raise QuestionException(msg) + + try: + self['correct'] = [float(n) for n in self['correct']] + except Exception: + msg = (f'Numeric interval must be a list with two numbers, in ' + f'{self["ref"]}') + raise QuestionException(msg) + + # invalid + else: + msg = (f'Numeric interval must be a list with two numbers, in ' + f'{self["ref"]}') + raise QuestionException(msg) + # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() @@ -520,23 +615,27 @@ class QFactory(object): if q['type'] == 'generator': logger.debug(f' \\_ Running "{q["script"]}".') q.setdefault('args', []) - q.setdefault('stdin', '') # FIXME is it really necessary? + q.setdefault('stdin', '') script = path.join(q['path'], q['script']) out = await run_script_async(script=script, args=q['args'], stdin=q['stdin']) q.update(out) - # Finally we create an instance of Question() + # Get class for this question type try: - qinstance = self._types[q['type']](QDict(q)) # of matching class - except QuestionException as e: - logger.error(e) - raise e + qclass = self._types[q['type']] except KeyError: logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') raise - else: - return qinstance + + # Finally create an instance of Question() + try: + qinstance = qclass(QDict(q)) + except QuestionException as e: + # logger.error(e) + raise e + + return qinstance # ------------------------------------------------------------------------ def generate(self) -> Question: diff --git a/perguntations/test.py b/perguntations/test.py index d85f9db..3990f47 100644 --- a/perguntations/test.py +++ b/perguntations/test.py @@ -141,7 +141,7 @@ class TestFactory(dict): logger.warning('Undefined title!') if self['scale_points']: - logger.info(f'Grades are scaled to the interval [{self["scale_min"]}, {self["scale_max"]}]') + logger.info(f'Grades will be scaled to [{self["scale_min"]}, {self["scale_max"]}]') else: logger.info('Grades are just the sum of points defined for the questions, not being scaled.') -- libgit2 0.21.2