# We start with an empty QuestionFactory() that will be populated with # question generators that we can load from YAML files. # To generate an instance of a question we use the method generate(ref) where # the argument is the reference of the question we wish to produce. # # Example: # # # read everything from question files # factory = QuestionFactory() # factory.load_files(['file1.yaml', 'file1.yaml'], '/path/to') # # question = factory.generate('some_ref') # # # experiment answering one question and correct it # question['answer'] = 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 # base import random import re from os import path import logging import sys # packages import yaml # this project from tools import load_yaml, run_script # regular expressions in yaml files, e.g. correct: !regex '[aA]zul' yaml.add_constructor('!regex', lambda l, n: re.compile(l.construct_scalar(n))) # setup logger for this module logger = logging.getLogger(__name__) # =========================================================================== class QuestionFactoryException(Exception): pass # =========================================================================== # 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): 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)] elif len(self['correct']) != n: logger.error(f'Number of options and correct mismatch in "{self["ref"]}", file "{self["filename"]}".') # 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): 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 = (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(f'Number of options and correct mismatch in "{self["ref"]}", file "{self["filename"]}".') # 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): 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'] = 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): super().correct() if self['answer'] is not None: self['grade'] = 1.0 if self['answer'] 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): super().correct() if self['answer'] is not None: try: self['grade'] = 1.0 if re.match(self['correct'], self['answer']) else 0.0 except TypeError: logger.error('While matching regex {self["correct"]} with answer {self["answer"]}.') return self['grade'] # =========================================================================== class QuestionNumericInterval(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): super().correct() if self['answer'] is not None: lower, upper = self['correct'] try: answer = float(self['answer']) # TODO: # alternative using locale (1.2 vs 1,2) # import locale # locale.setlocale(locale.LC_ALL, 'pt_PT') # answer = locale.atof(self['answer']) except ValueError: self['comments'] = 'A resposta não é numérica.' self['grade'] = 0.0 else: self['grade'] = 1.0 if lower <= answer <= upper else 0.0 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): 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.join(self['path'], self['correct']) self['correct'] = path.abspath(path.normpath(path.join(self['path'], self['correct']))) #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): super().correct() if self['answer'] is not None: # correct answer out = run_script( script=self['correct'], stdin=self['answer'], 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(f'Correction script of "{self["ref"]}" returned nonfloat.') except KeyError: logger.error('Correction script of "{self["ref"]}" returned no "grade".') 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): super().correct() self['grade'] = 1.0 # always "correct" but points should be zero! return self['grade'] # =========================================================================== # This class contains a pool of questions generators from which particular # Question() instances are generated using QuestionsFactory.generate(ref). # =========================================================================== class QuestionFactory(dict): _types = { 'radio' : QuestionRadio, 'checkbox' : QuestionCheckbox, 'text' : QuestionText, 'text-regex': QuestionTextRegex, 'numeric-interval': QuestionNumericInterval, 'textarea' : QuestionTextArea, # -- informative panels -- 'information': QuestionInformation, 'info': QuestionInformation, 'warning' : QuestionInformation, 'warn': QuestionInformation, 'alert' : QuestionInformation, 'success' : QuestionInformation, } # ----------------------------------------------------------------------- def __init__(self): super().__init__() # ----------------------------------------------------------------------- # Add single question provided in a dictionary. # After this, each question will have at least 'ref' and 'type' keys. # ----------------------------------------------------------------------- def add_question(self, question): # if missing defaults to ref='/path/file.yaml:3' question.setdefault('ref', f'{question["filename"]}:{question["index"]}') if question['ref'] in self: logger.error(f'Duplicate reference "{question["ref"]}" replaces the original.') question.setdefault('type', 'information') self[question['ref']] = question logger.debug(f'Added question "{question["ref"]}" to the pool.') # ----------------------------------------------------------------------- # load single YAML questions file # ----------------------------------------------------------------------- def load_file(self, pathfile, questions_dir=''): # questions_dir is a base directory # pathfile is a path of a file under the questions_dir # For example, if # pathfile = 'math/questions.yaml' # questions_dir = '/home/john/questions' # then the complete path is # fullpath = '/home/john/questions/math/questions.yaml' fullpath = path.normpath(path.join(questions_dir, pathfile)) (dirname, filename) = path.split(fullpath) questions = load_yaml(fullpath, default=[]) for i, q in enumerate(questions): try: q.update({ 'filename': filename, 'path': dirname, 'index': i # position in the file, 0 based }) except AttributeError: logger.error(f'Question {pathfile}:{i} is not a dictionary. Skipped!') else: self.add_question(q) logger.info(f'Loaded {len(self)} questions from "{pathfile}".') # ----------------------------------------------------------------------- # load multiple YAML question files # ----------------------------------------------------------------------- def load_files(self, files, questions_dir=''): for filename in files: self.load_file(filename, questions_dir) # ----------------------------------------------------------------------- # Given a ref returns an instance of a descendent of Question(), # i.e. a question object (radio, checkbox, ...). # ----------------------------------------------------------------------- def generate(self, ref): # Shallow copy so that script generated questions will not replace # the original generators try: q = self[ref].copy() except KeyError: #FIXME exception type? logger.error(f'Can\'t find question "{ref}".') raise QuestionFactoryException() # 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 converted to a dictionary and `q` becomes that dict. if q['type'] == 'generator': logger.debug('Running script to generate question "{0}".'.format(q['ref'])) q.setdefault('arg', '') # optional arguments will be sent to stdin script = path.normpath(path.join(q['path'], q['script'])) out = run_script(script=script, stdin=q['arg']) try: q.update(out) except: q.update({ 'type': 'alert', 'title': 'Erro interno', 'text': 'Ocorreu um erro a gerar esta pergunta.' }) # The generator was replaced by a question but not yet instantiated # 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'Unknown type "{q["type"]}" in "{q["filename"]}:{q["ref"]}".') raise e except: logger.error(f'Failed to create question "{q["ref"]}" from file "{q["filename"]}".') raise else: logger.debug(f'Generated question "{ref}".') return qinstance # =========================================================================== # 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) # # 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