questions.py 12.5 KB

# python standard library
import random
import re
from os import path
import logging
import asyncio

# user installed libraries
import yaml

# this project
from perguntations.tools import 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__)


# ===========================================================================
# 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,
            'comments': '',
            'files': {},
            })

    # FIXME unused. do childs need do override this?
    # def updateAnswer(answer=None):
    #     self['answer'] = answer

    def correct(self):
        self['grade'] = 0.0
        return 0.0

    async def correct_async(self):
        loop = asyncio.get_running_loop()
        grade = await loop.run_in_executor(None, self.correct)
        return grade

    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)
        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 __init__(self, q):
        super().__init__(q)

        n = len(self['options'])

        # set defaults if missing
        self.set_defaults({
            'text': '',
            'correct': 0,
            'shuffle': True,
            'discount': True,
            })

        # 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 self['shuffle']:
            # separate right from wrong options
            right = [i for i in range(n) if self['correct'][i] == 1]
            wrong = [i for i in range(n) if self['correct'][i] < 1]

            self.set_defaults({'choose': 1+len(wrong)})

            # choose 1 correct option
            r = random.choice(right)
            options = [ self['options'][r] ]
            correct = [ 1.0 ]

            # choose remaining wrong options
            random.shuffle(wrong)
            nwrong = self['choose']-1
            options.extend(self['options'][i] for i in wrong[:nwrong])
            correct.extend(self['correct'][i] for i in wrong[:nwrong])

            # final shuffle of the options
            perm = random.sample(range(self['choose']), self['choose'])
            self['options'] = [ str(options[i]) for i in perm ]
            self['correct'] = [ float(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)
        choose (int)
        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': [1.0] * n,     # Using 0.0 breaks the (right, wrong) options
            'shuffle': True,
            'discount': True,
            'choose': n,              # number of options
            })

        if len(self['correct']) != n:
            logger.error(f'Options and correct size mismatch in "{self["ref"]}", file "{self["filename"]}".')

        # if an option is a list of (right, wrong), pick one
        # FIXME it's possible that all options are chosen wrong
        options = []
        correct = []
        for o, c in zip(self['options'], self['correct']):
            if isinstance(o, list):
                r = random.randint(0,1)
                o = o[r]
                c = c if r==0 else -c
            options.append(str(o))
            correct.append(float(c))

        # generate random permutation, e.g. [2,1,4,0,3]
        # and apply to `options` and `correct`
        if self['shuffle']:
            perm = random.sample(range(n), self['choose'])
            self['options'] = [options[i] for i in perm]
            self['correct'] = [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'] = 1.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:         # replace , by . and convert to float
                answer = float(self['answer'].replace(',', '.', 1))
            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(  # and parse yaml ouput
                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']