""" File: perguntations/questions.py Description: Classes the implement several types of questions. """ # python standard library from datetime import datetime import logging import os import random import re from typing import Any, Dict, NewType import uuid # this project from .tools import run_script, run_script_async # setup logger for this module logger = logging.getLogger(__name__) QDict = NewType("QDict", Dict[str, Any]) class QuestionException(Exception): """Exceptions raised in this module""" # ============================================================================ # 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 questions for each student. Instances can shuffle options or automatically generate questions. """ def gen(self) -> None: """ Sets defaults that are valid for any question type """ # add required keys if missing self.set_defaults( QDict( { "title": "", "answer": None, "comments": "", "solution": "", "files": {}, } ) ) def set_answer(self, ans) -> None: """set answer field and register time""" self["answer"] = ans self["finish_time"] = datetime.now() def correct(self) -> None: """default correction (synchronous version)""" self["comments"] = "" self["grade"] = 0.0 async def correct_async(self) -> None: """default correction (async version)""" self.correct() def set_defaults(self, qdict: QDict) -> None: """Add k:v pairs from default dict d for nonexistent keys""" for k, val in qdict.items(): self.setdefault(k, val) # ============================================================================ class QuestionRadio(Question): """An instance of QuestionRadio will always have the keys: type (str) text (str) options (list of strings) correct (list of floats) discount (bool, default=True) answer (None or an actual answer) shuffle (bool, default=True) choose (int) # only used if shuffle=True """ # ------------------------------------------------------------------------ def gen(self) -> None: """ Sets defaults, performs checks and generates the actual question by modifying the options and correct values """ super().gen() try: nopts = len(self["options"]) except KeyError as exc: msg = f'Missing `options`. In question "{self["ref"]}"' logger.error(msg) raise QuestionException(msg) from exc except TypeError as exc: msg = f'`options` must be a list. In question "{self["ref"]}"' logger.error(msg) raise QuestionException(msg) from exc self.set_defaults( QDict( { "text": "", "correct": 0, "shuffle": True, "discount": True, "max_tries": (nopts + 3) // 4, # 1 try for each 4 options } ) ) # 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"] < nopts: msg = f'"{self["ref"]}": correct out of range 0..{nopts-1}' logger.error(msg) raise QuestionException(msg) self["correct"] = [ 1.0 if x == self["correct"] else 0.0 for x in range(nopts) ] elif isinstance(self["correct"], list): # must match number of options if len(self["correct"]) != nopts: msg = f'"{self["ref"]}": number of options/correct mismatch' logger.error(msg) raise QuestionException(msg) # make sure is a list of floats try: self["correct"] = [float(x) for x in self["correct"]] except (ValueError, TypeError) as exc: msg = f'"{self["ref"]}": correct must contain floats or bools' logger.error(msg) raise QuestionException(msg) from exc # check grade boundaries if self["discount"] and not all(0.0 <= x <= 1.0 for x in self["correct"]): msg = f'"{self["ref"]}": correct must be in [0.0, 1.0]' logger.error(msg) raise QuestionException(msg) # at least one correct option if all(x < 1.0 for x in self["correct"]): msg = f'"{self["ref"]}": has no correct options' logger.error(msg) 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(nopts) if self["correct"][i] >= 1] wrong = [i for i in range(nopts) if self["correct"][i] < 1] self.set_defaults(QDict({"choose": 1 + len(wrong)})) # try to choose 1 correct option if right: sel = random.choice(right) options = [self["options"][sel]] correct = [self["correct"][sel]] else: options = [] correct = [] # choose remaining wrong options nwrong = self["choose"] - len(correct) wrongsample = random.sample(wrong, k=nwrong) options += [self["options"][i] for i in wrongsample] correct += [self["correct"][i] for i in wrongsample] # 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"] = [correct[i] for i in perm] # ------------------------------------------------------------------------ def correct(self) -> None: """ Correct `answer` and set `grade`. Can assign negative grades for wrong answers """ super().correct() if self["answer"] is not None: grade = self["correct"][int(self["answer"])] # grade of the answer nopts = len(self["options"]) grade_aver = sum(self["correct"]) / nopts # expected value # 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 grade_aver != 1.0: grade = (grade - grade_aver) / (1.0 - grade_aver) self["grade"] = 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) choose (int) answer (None or an actual answer) """ # ------------------------------------------------------------------------ def gen(self) -> None: super().gen() try: nopts = len(self["options"]) except KeyError as exc: msg = f'Missing `options`. In question "{self["ref"]}"' logger.error(msg) raise QuestionException(msg) from exc except TypeError as exc: msg = f'`options` must be a list. In question "{self["ref"]}"' logger.error(msg) raise QuestionException(msg) from exc # set defaults if missing self.set_defaults( QDict( { "text": "", "correct": [1.0] * nopts, # Using 0.0 breaks (right, wrong) "shuffle": True, "discount": True, "choose": nopts, # number of options "max_tries": max(1, min(nopts - 1, 3)), } ) ) # must be a list of numbers if not isinstance(self["correct"], list): msg = "Correct must be a list of numbers or booleans" logger.error(msg) raise QuestionException(msg) # must match number of options if len(self["correct"]) != nopts: msg = ( f'{nopts} options vs {len(self["correct"])} correct. ' f'In question "{self["ref"]}"' ) logger.error(msg) raise QuestionException(msg) # make sure is a list of floats try: self["correct"] = [float(x) for x in self["correct"]] except (ValueError, TypeError) as exc: msg = "`correct` must be list of numbers or booleans." f'In "{self["ref"]}"' logger.error(msg) raise QuestionException(msg) from exc # check grade boundaries if self["discount"] and not all(0.0 <= x <= 1.0 for x in self["correct"]): msg = ( "values in the `correct` field of checkboxes must be in " "the [0.0, 1.0] interval. " f'Please fix "{self["ref"]}" in "{self["path"]}"' ) logger.error(msg) raise QuestionException(msg) # if an option is a list of (right, wrong), pick one options = [] correct = [] for option, corr in zip(self["options"], self["correct"]): if isinstance(option, list): sel = random.randint(0, 1) option = option[sel] if sel == 1: corr = 1.0 - corr options.append(str(option)) correct.append(corr) # generate random permutation, e.g. [2,1,4,0,3] # and apply to `options` and `correct` if self["shuffle"]: perm = random.sample(range(nopts), k=self["choose"]) self["options"] = [options[i] for i in perm] self["correct"] = [correct[i] for i in perm] else: self["options"] = options[: self["choose"]] self["correct"] = correct[: self["choose"]] # ------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self) -> None: super().correct() if self["answer"] is not None: grade = 0.0 if self["discount"]: sum_abs = sum(abs(2 * p - 1) for p in self["correct"]) for i, pts in enumerate(self["correct"]): grade += 2 * pts - 1 if str(i) in self["answer"] else 1 - 2 * pts else: sum_abs = sum(abs(p) for p in self["correct"]) for i, pts in enumerate(self["correct"]): grade += pts if str(i) in self["answer"] else 0.0 try: self["grade"] = grade / sum_abs except ZeroDivisionError: self["grade"] = 1.0 # limit p->0 # ============================================================================ 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 gen(self) -> None: super().gen() self.set_defaults( QDict( { "text": "", "correct": [], # no correct answers, always wrong "transform": [], # transformations applied to the answer, in order } ) ) # make sure its always a list of possible correct answers if not isinstance(self["correct"], list): self["correct"] = [str(self["correct"])] else: # make sure all elements of the list are strings self["correct"] = [str(a) for a in self["correct"]] for transform in self["transform"]: if transform not in ( "remove_space", "trim", "normalize_space", "lower", "upper", ): msg = f'Unknown transform "{transform}" in "{self["ref"]}"' raise QuestionException(msg) # check if answers are invariant with respect to the transforms if any(c != self.transform(c) for c in self["correct"]): logger.warning( 'in "%s", correct answers are not invariant wrt ' "transformations => never correct", self["ref"], ) # ------------------------------------------------------------------------ def transform(self, ans): """apply optional filters to the answer""" # apply transformations in sequence for transform in self["transform"]: if transform == "remove_space": # removes all spaces ans = ans.replace(" ", "") elif transform == "trim": # removes spaces around ans = ans.strip() elif transform == "normalize_space": # replaces many spaces by one ans = re.sub(r"\s+", " ", ans.strip()) elif transform == "lower": # convert to lowercase ans = ans.lower() elif transform == "upper": # convert to uppercase ans = ans.upper() else: logger.warning( 'in "%s", unknown transform "%s"', self["ref"], transform ) return ans # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() if self["answer"] is not None: answer = self.transform(self["answer"]) self["grade"] = 1.0 if answer in self["correct"] else 0.0 # ============================================================================ class QuestionTextRegex(Question): """An instance of QuestionTextRegex will always have the keys: type (str) text (str) correct (str or list[str]) answer (None or an actual answer) The correct strings are python standard regular expressions. Grade is 1.0 when the answer matches any of the regex in the list. """ # ------------------------------------------------------------------------ def gen(self) -> None: super().gen() self.set_defaults( QDict( { "text": "", "correct": ["$.^"], # will always return false } ) ) # make sure its always a list of regular expressions if not isinstance(self["correct"], list): self["correct"] = [self["correct"]] # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() if self["answer"] is not None: for regex in self["correct"]: try: if re.fullmatch(regex, self["answer"]): self["grade"] = 1.0 return except TypeError: logger.error( 'While matching regex "%s" with answer "%s".', regex, self["answer"], ) self["grade"] = 0.0 # ============================================================================ 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 gen(self) -> None: super().gen() self.set_defaults( QDict( { "text": "", "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"]}' ) logger.error(msg) raise QuestionException(msg) try: self["correct"] = [float(n) for n in self["correct"]] except Exception as exc: msg = ( f"Numeric interval must be a list with two numbers, in " f'{self["ref"]}' ) logger.error(msg) raise QuestionException(msg) from exc # invalid else: msg = ( f"Numeric interval must be a list with two numbers, in " f'{self["ref"]}' ) logger.error(msg) raise QuestionException(msg) # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() if self["answer"] is not None: lower, upper = self["correct"] try: # replace , by . and convert to float answer = float(self["answer"].replace(",", ".", 1)) except ValueError: self["comments"] = ( "A resposta tem de ser numérica, " "por exemplo `12.345`." ) self["grade"] = 0.0 else: self["grade"] = 1.0 if lower <= answer <= upper else 0.0 # ============================================================================ 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) """ # ------------------------------------------------------------------------ def gen(self) -> None: super().gen() self.set_defaults( QDict( { "text": "", "timeout": 5, # seconds "correct": "", # trying to execute this will fail => grade 0.0 "args": [], } ) ) self["correct"] = os.path.join(self["path"], self["correct"]) # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() if self["answer"] is not None: # correct answer and parse yaml ouput out = run_script( script=self["correct"], args=self["args"], stdin=self["answer"], timeout=self["timeout"], ) if out is None: logger.warning('No grade after running "%s".', self["correct"]) self["comments"] = "O programa de correcção abortou..." self["grade"] = 0.0 elif isinstance(out, dict): self["comments"] = out.get("comments", "") try: self["grade"] = float(out["grade"]) except ValueError: logger.error('Output error in "%s".', self["correct"]) except KeyError: logger.error('No grade in "%s".', self["correct"]) else: try: self["grade"] = float(out) except (TypeError, ValueError): logger.error('Invalid grade in "%s".', self["correct"]) # ------------------------------------------------------------------------ async def correct_async(self) -> None: super().correct() if self["answer"] is not None: # correct answer and parse yaml ouput out = await run_script_async( script=self["correct"], args=self["args"], stdin=self["answer"], timeout=self["timeout"], ) if out is None: logger.warning('No grade after running "%s".', self["correct"]) self["comments"] = "O programa de correcção abortou..." self["grade"] = 0.0 elif isinstance(out, dict): self["comments"] = out.get("comments", "") try: self["grade"] = float(out["grade"]) except ValueError: logger.error('Output error in "%s".', self["correct"]) except KeyError: logger.error('No grade in "%s".', self["correct"]) else: try: self["grade"] = float(out) except (TypeError, ValueError): logger.error('Invalid grade in "%s".', self["correct"]) # ============================================================================ class QuestionInformation(Question): """ Not really a question, just an information panel. The correction is always right. """ # ------------------------------------------------------------------------ def gen(self) -> None: super().gen() self.set_defaults( QDict( { "text": "", } ) ) # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() self["grade"] = 1.0 # always "correct" but points should be zero! # ============================================================================ def question_from(qdict: QDict) -> Question: """ Converts a question specified in a dict into an instance of Question() """ types = { "radio": QuestionRadio, "checkbox": QuestionCheckbox, "text": QuestionText, "text-regex": QuestionTextRegex, "numeric-interval": QuestionNumericInterval, "textarea": QuestionTextArea, # -- informative panels -- "information": QuestionInformation, "success": QuestionInformation, "warning": QuestionInformation, "alert": QuestionInformation, } # Get class for this question type try: qclass = types[qdict["type"]] except KeyError: logger.error('Invalid type "%s" in "%s"', qdict["type"], qdict["ref"]) raise # Create an instance of Question() of appropriate type try: qinstance = qclass(qdict.copy()) except QuestionException: logger.error('Generating "%s" in %s', qdict["ref"], qdict["filename"]) raise return qinstance # ============================================================================ class QFactory: """ 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 gen_async(). It returns a question instance of the correct class. The method is async but it only awaits on generator questions. The others are run until completion. Example: # make a factory for a question qfactory = QFactory({ 'type': 'radio', 'text': 'Choose one', 'options': ['a', 'b'] }) # generate asynchronously question = await qfactory.gen_async() # answer one question and correct it question.set_answer(42) # set answer question.correct() # correct answer grade = question['grade'] # get grade """ def __init__(self, qdict: QDict = QDict({})) -> None: self.qdict = qdict # ------------------------------------------------------------------------ async def gen_async(self) -> Question: """ generates a question instance of QuestionRadio, QuestionCheckbox, ..., which is a descendent of base class Question. """ logger.debug("generating %s...", self.qdict["ref"]) # Shallow copy so that script generated questions will not replace # the original generators qdict = QDict(self.qdict.copy()) qdict["qid"] = str(uuid.uuid4()) # unique for each question # 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 qdict["type"] == "generator": logger.debug(' \\_ Running "%s"', qdict["script"]) qdict.setdefault("args", []) qdict.setdefault("stdin", "") script = os.path.join(qdict["path"], qdict["script"]) out = await run_script_async( script=script, args=qdict["args"], stdin=qdict["stdin"] ) qdict.update(out) question = question_from(qdict) # returns a Question instance question.gen() return question