# QFactory is a class that can generate question instances, e.g. by shuffling # options, running a script to generate the question, etc. # # To generate an instance of a question we use the method generate() where # the argument is the reference of the question we wish to produce. # # Example: # # # read question from file # qdict = tools.load_yaml(filename) # qfactory = QFactory(question) # question = qfactory.generate() # # # experiment answering one question and correct it # question.updateAnswer('42') # insert answer # grade = question.correct() # correct answer # An instance of an actual question is an object that inherits from Question() # # Question - base class inherited by other classes # QuestionRadio - single choice from a list of options # QuestionCheckbox - multiple choice, equivalent to multiple true/false # QuestionText - line of text compared to a list of acceptable answers # QuestionTextRegex - line of text matched against a regular expression # QuestionTextArea - corrected by an external program # QuestionInformation - not a question, just a box with content import random import re from os import path import logging import sys # setup logger for this module logger = logging.getLogger(__name__) try: import yaml except ImportError: logger.critical('Python package missing. See README.md for instructions.') sys.exit(1) else: # allow regular expressions in yaml files, for example # correct: !regex '[aA]zul' yaml.add_constructor('!regex', lambda l, n: re.compile(l.construct_scalar(n))) from tools import load_yaml, run_script # =========================================================================== # Questions derived from Question are already instantiated and ready to be # presented to students. # =========================================================================== class Question(dict): ''' Classes derived from this base class are meant to instantiate a question to a student. Instances can shuffle options, or automatically generate questions. ''' def __init__(self, q): super().__init__(q) # add these if missing self.set_defaults({ 'title': '', 'answer': None, 'files': {}, }) def updateAnswer(answer=None): self['answer'] = answer def correct(self, answer=None): if answer is not None: self['answer'] = answer self['grade'] = 0.0 self['comments'] = '' return 0.0 def set_defaults(self, d): 'Add k:v pairs from default dict d for nonexistent keys' for k,v in d.items(): self.setdefault(k, v) # =========================================================================== class QuestionRadio(Question): '''An instance of QuestionRadio will always have the keys: type (str) text (str) options (list of strings) shuffle (bool, default=True) correct (list of floats) discount (bool, default=True) answer (None or an actual answer) ''' #------------------------------------------------------------------------ def __init__(self, q): super().__init__(q) # set defaults if missing self.set_defaults({ 'text': '', 'correct': 0, 'shuffle': True, 'discount': True, }) n = len(self['options']) # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0] # correctness levels from 0.0 to 1.0 (no discount here!) if isinstance(self['correct'], int): self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] if len(self['correct']) != n: logger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) # generate random permutation, e.g. [2,1,4,0,3] # and apply to `options` and `correct` if self['shuffle']: perm = list(range(n)) random.shuffle(perm) self['options'] = [ str(self['options'][i]) for i in perm ] self['correct'] = [ float(self['correct'][i]) for i in perm ] #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self, answer=None): super().correct(answer) if self['answer']: x = self['correct'][int(self['answer'][0])] if self['discount']: n = len(self['options']) # number of options x_aver = sum(self['correct']) / n x = (x - x_aver) / (1.0 - x_aver) self['grade'] = x return self['grade'] # =========================================================================== class QuestionCheckbox(Question): '''An instance of QuestionCheckbox will always have the keys: type (str) text (str) options (list of strings) shuffle (bool, default True) correct (list of floats) discount (bool, default True) answer (None or an actual answer) ''' #------------------------------------------------------------------------ def __init__(self, q): super().__init__(q) n = len(self['options']) # set defaults if missing self.set_defaults({ 'text': '', 'correct': [0.0] * n, # useful for questionaries 'shuffle': True, 'discount': True, }) if len(self['correct']) != n: logger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) # generate random permutation, e.g. [2,1,4,0,3] # and apply to `options` and `correct` if self['shuffle']: perm = list(range(n)) random.shuffle(perm) self['options'] = [ str(self['options'][i]) for i in perm ] self['correct'] = [ float(self['correct'][i]) for i in perm ] #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self, answer=None): super().correct(answer) 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'] = 0.0 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 self['grade'] = x / sum_abs return self['grade'] # =========================================================================== class QuestionText(Question): '''An instance of QuestionText will always have the keys: type (str) text (str) correct (list of str) answer (None or an actual answer) ''' #------------------------------------------------------------------------ def __init__(self, q): super().__init__(q) self.set_defaults({ 'text': '', 'correct': [], }) # make sure its always a list of possible correct answers 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']] #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self, answer=None): super().correct(answer) if self['answer']: self['grade'] = 1.0 if self['answer'][0] in self['correct'] else 0.0 return self['grade'] # =========================================================================== class QuestionTextRegex(Question): '''An instance of QuestionTextRegex will always have the keys: type (str) text (str) correct (str with regex) answer (None or an actual answer) ''' #------------------------------------------------------------------------ def __init__(self, q): super().__init__(q) self.set_defaults({ 'text': '', 'correct': '$.^', # will always return false }) #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self, answer=None): super().correct(answer) if self['answer']: try: self['grade'] = 1.0 if re.match(self['correct'], self['answer'][0]) else 0.0 except TypeError: logger.error('While matching regex {0} with answer {1}.'.format(self['correct'], self['answer'][0])) return self['grade'] # =========================================================================== class QuestionTextNumeric(Question): '''An instance of QuestionTextNumeric will always have the keys: type (str) text (str) correct (list [lower bound, upper bound]) answer (None or an actual answer) An answer is correct if it's in the closed interval. ''' #------------------------------------------------------------------------ def __init__(self, q): super().__init__(q) self.set_defaults({ 'text': '', 'correct': [1.0, -1.0], # will always return false }) #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self, answer=None): super().correct(answer) if self['answer']: lower, upper = self['correct'] try: self['grade'] = 1.0 if lower <= float(self['answer'][0]) <= upper else 0.0 except TypeError: logger.error('While matching regex {0} with answer {1}.'.format(self['correct'], self['answer'][0])) except ValueError: self['comments'] = f'A resposta "{self["answer"][0]}" não é numérica.' return self['grade'] # =========================================================================== class QuestionTextArea(Question): '''An instance of QuestionTextArea will always have the keys: type (str) text (str) correct (str with script to run) answer (None or an actual answer) lines (int) ''' #------------------------------------------------------------------------ def __init__(self, q): # logger.debug('QuestionTextArea.__init__()') super().__init__(q) self.set_defaults({ 'text': '', 'lines': 8, 'timeout': 5, # seconds 'correct': '' # trying to execute this will fail => grade 0.0 }) self['correct'] = path.abspath(path.normpath(path.join(self['path'], self['correct']))) #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self, answer=None): super().correct(answer) if self['answer']: # correct answer out = run_script( script=self['correct'], stdin=self['answer'][0], timeout=self['timeout'] ) if type(out) in (int, float): self['grade'] = float(out) elif isinstance(out, dict): self['comments'] = out.get('comments', '') try: self['grade'] = float(out['grade']) except ValueError: logger.error('Correction script of "{0}" returned nonfloat.'.format(self['ref'])) except KeyError: logger.error('Correction script of "{0}" returned no "grade" key.'.format(self['ref'])) return self['grade'] # =========================================================================== class QuestionInformation(Question): '''An instance of QuestionInformation will always have the keys: type (str) text (str) points (0.0) ''' #------------------------------------------------------------------------ def __init__(self, q): super().__init__(q) self.set_defaults({ 'text': '', }) #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self, answer=None): super().correct(answer) self['grade'] = 1.0 # always "correct" but points should be zero! return self['grade'] # =========================================================================== # Question Factory # =========================================================================== class QFactory(object): # Depending on the type of question, a different question class will be # instantiated. All these classes derive from the base class `Question`. _types = { 'radio' : QuestionRadio, 'checkbox' : QuestionCheckbox, 'text' : QuestionText, 'text_regex': QuestionTextRegex, 'text-regex': QuestionTextRegex, 'text_numeric': QuestionTextNumeric, 'text-numeric': QuestionTextNumeric, 'textarea' : QuestionTextArea, # -- informative panels -- 'information': QuestionInformation, 'info': QuestionInformation, 'warning' : QuestionInformation, 'warn': QuestionInformation, 'alert' : QuestionInformation, 'success' : QuestionInformation, } def __init__(self, question_dict): self.question = question_dict # ----------------------------------------------------------------------- # Given a ref returns an instance of a descendent of Question(), # i.e. a question object (radio, checkbox, ...). # ----------------------------------------------------------------------- def generate(self): logger.debug(f'Generating "{self.question["ref"]}"') # Shallow copy so that script generated questions will not replace # the original generators q = self.question.copy() # If question is of generator type, an external program will be run # which will print a valid question in yaml format to stdout. This # output is then yaml parsed into a dictionary `q`. if q['type'] == 'generator': logger.debug(f'Running script "{q["script"]}"...') q.setdefault('arg', '') # optional arguments will be sent to stdin script = path.join(q['path'], q['script']) out = run_script(script=script, stdin=q['arg']) q.update(out) # try: # q.update(out) # except: # logger.error(f'Question generator "{q["ref"]}"') # q.update({ # 'type': 'alert', # 'title': 'Erro interno', # 'text': 'Ocorreu um erro a gerar esta pergunta.' # }) # Finally we create an instance of Question() try: qinstance = self._types[q['type']](q) # instance with correct class except KeyError as e: logger.error(f'Failed to generate question "{q["ref"]}"') raise e else: return qinstance