Commit 8f643ad6a247b911b08270ee1a9cfd9cd02c2a52

Authored by Miguel Barão
1 parent e0d5ddfe
Exists in master and in 1 other branch dev

fix error in radio questions where all options right would break

added more checks in radio questions
1 1
2 # BUGS 2 # BUGS
3 3
  4 +- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)
4 - nao esta a seguir o max_tries definido no ficheiro de dependencias. 5 - nao esta a seguir o max_tries definido no ficheiro de dependencias.
5 -- registar last_seen e remover os antigos de cada vez que houver um login.  
6 - initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a) 6 - initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a)
7 - double click submits twice. 7 - double click submits twice.
8 -- marking all options right in a radio question breaks!  
9 - duplo clicks no botao "responder" dessincroniza as questões, ver debounce em https://stackoverflow.com/questions/20281546/how-to-prevent-calling-of-en-event-handler-twice-on-fast-clicks 8 - duplo clicks no botao "responder" dessincroniza as questões, ver debounce em https://stackoverflow.com/questions/20281546/how-to-prevent-calling-of-en-event-handler-twice-on-fast-clicks
10 -- quando termina topico devia apagar as perguntas todas (se falhar a gerar novo topico, aparecem perguntas do antigo)  
11 -- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)  
12 - devia mostrar timeout para o aluno saber a razao. 9 - devia mostrar timeout para o aluno saber a razao.
13 - permitir configuracao para escolher entre static files locais ou remotos 10 - permitir configuracao para escolher entre static files locais ou remotos
14 - templates question-*.html tem input hidden question_ref que não é usado. remover? 11 - templates question-*.html tem input hidden question_ref que não é usado. remover?
15 -- click numa opcao checkbox fora da checkbox+label não está a funcionar.  
16 - shift-enter não está a funcionar 12 - shift-enter não está a funcionar
17 - default prefix should be obtained from each course (yaml conf)? 13 - default prefix should be obtained from each course (yaml conf)?
18 -- tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question.  
19 14
20 # TODO 15 # TODO
21 16
  17 +- checkbox devia ter correct no intervalo [0,1] tal como radio. em caso de desconto a correccção faz 2*x-1. isto permite a mesma semantica nos dois tipos de perguntas.
  18 +- registar last_seen e remover os antigos de cada vez que houver um login.
22 - indicar qtos topicos faltam (>=50%) para terminar o curso. 19 - indicar qtos topicos faltam (>=50%) para terminar o curso.
23 -- use run_script_async to run run_script using asyncio.run?  
24 -- ao fim de 3 tentativas de login, envial email para aluno com link para definir nova password (com timeout de 5 minutos). 20 +- ao fim de 3 tentativas com password errada, envia email com nova password.
25 - mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo expande as dependencias. 21 - mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo expande as dependencias.
26 - mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado topicos. 22 - mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado topicos.
27 - botão não sei... 23 - botão não sei...
@@ -33,14 +29,16 @@ @@ -33,14 +29,16 @@
33 - tabela com perguntas / quantidade de respostas certas/erradas. 29 - tabela com perguntas / quantidade de respostas certas/erradas.
34 - tabela com topicos / quantidade de estrelas. 30 - tabela com topicos / quantidade de estrelas.
35 - pymips: activar/desactivar instruções 31 - pymips: activar/desactivar instruções
36 -- implementar servidor http com redirect para https.  
37 -- ao fim de 3 tentativas com password errada, envia email com nova password.  
38 - titulos das perguntas não suportam markdown. 32 - titulos das perguntas não suportam markdown.
39 - pagina report que permita ver tabela alunos/topicos, estatisticas perguntas mais falhadas, tempo médio por pergunta. 33 - pagina report que permita ver tabela alunos/topicos, estatisticas perguntas mais falhadas, tempo médio por pergunta.
40 - normalizar com perguntations. 34 - normalizar com perguntations.
41 35
42 # FIXED 36 # FIXED
43 37
  38 +- marking all options right in a radio question breaks!
  39 +- implementar servidor http com redirect para https.
  40 +- tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question.
  41 +- click numa opcao checkbox fora da checkbox+label não está a funcionar.
44 - mathjax, formulas $$f(x)$$ nas opções de escolha multipla, não ficam centradas em toda a coluna mas apenas na largura do parágrafo. 42 - mathjax, formulas $$f(x)$$ nas opções de escolha multipla, não ficam centradas em toda a coluna mas apenas na largura do parágrafo.
45 - QFactory.generate() devia fazer run da gen_async, ou remover. 43 - QFactory.generate() devia fazer run da gen_async, ou remover.
46 - classificacoes so devia mostrar os que ja fizeram alguma coisa 44 - classificacoes so devia mostrar os que ja fizeram alguma coisa
aprendizations/learnapp.py
@@ -16,7 +16,7 @@ import sqlalchemy as sa @@ -16,7 +16,7 @@ import sqlalchemy as sa
16 16
17 # this project 17 # this project
18 from .models import Student, Answer, Topic, StudentTopic 18 from .models import Student, Answer, Topic, StudentTopic
19 -from .questions import Question, QFactory, QDict 19 +from .questions import Question, QFactory, QDict, QuestionException
20 from .student import StudentState 20 from .student import StudentState
21 from .tools import load_yaml 21 from .tools import load_yaml
22 22
@@ -100,7 +100,6 @@ class LearnApp(object): @@ -100,7 +100,6 @@ class LearnApp(object):
100 # if graph has topics that are not in the database, add them 100 # if graph has topics that are not in the database, add them
101 self.add_missing_topics(self.deps.nodes()) 101 self.add_missing_topics(self.deps.nodes())
102 102
103 -  
104 if check: 103 if check:
105 self.sanity_check_questions() 104 self.sanity_check_questions()
106 105
@@ -113,8 +112,8 @@ class LearnApp(object): @@ -113,8 +112,8 @@ class LearnApp(object):
113 logger.debug(f'checking {qref}...') 112 logger.debug(f'checking {qref}...')
114 try: 113 try:
115 q = self.factory[qref].generate() 114 q = self.factory[qref].generate()
116 - except Exception:  
117 - logger.error(f'Failed to generate "{qref}".') 115 + except QuestionException as e:
  116 + logger.error(e)
118 errors += 1 117 errors += 1
119 continue # to next question 118 continue # to next question
120 119
@@ -137,7 +136,7 @@ class LearnApp(object): @@ -137,7 +136,7 @@ class LearnApp(object):
137 continue # to next test 136 continue # to next test
138 137
139 if errors > 0: 138 if errors > 0:
140 - logger.error(f'{errors:>6} errors found.') 139 + logger.error(f'{errors:>6} error(s) found.')
141 raise LearnException('Sanity checks') 140 raise LearnException('Sanity checks')
142 else: 141 else:
143 logger.info(' 0 errors found.') 142 logger.info(' 0 errors found.')
@@ -345,7 +344,7 @@ class LearnApp(object): @@ -345,7 +344,7 @@ class LearnApp(object):
345 logger.info(f'{m:6} topics') 344 logger.info(f'{m:6} topics')
346 logger.info(f'{q:6} answers') 345 logger.info(f'{q:6} answers')
347 346
348 - # ============================================================================ 347 + # ========================================================================
349 # Populates a digraph. 348 # Populates a digraph.
350 # 349 #
351 # Nodes are the topic references e.g. 'my/topic' 350 # Nodes are the topic references e.g. 'my/topic'
@@ -400,7 +399,7 @@ class LearnApp(object): @@ -400,7 +399,7 @@ class LearnApp(object):
400 def make_factory(self) -> Dict[str, QFactory]: 399 def make_factory(self) -> Dict[str, QFactory]:
401 400
402 logger.info('Building questions factory:') 401 logger.info('Building questions factory:')
403 - factory: Dict[str, QFactory] = {} 402 + factory = {}
404 g = self.deps 403 g = self.deps
405 for tref in g.nodes(): 404 for tref in g.nodes():
406 t = g.nodes[tref] 405 t = g.nodes[tref]
@@ -418,7 +417,7 @@ class LearnApp(object): @@ -418,7 +417,7 @@ class LearnApp(object):
418 # within the file 417 # within the file
419 for i, q in enumerate(questions): 418 for i, q in enumerate(questions):
420 qref = q.get('ref', str(i)) # ref or number 419 qref = q.get('ref', str(i)) # ref or number
421 - q['ref'] = tref + ':' + qref 420 + q['ref'] = f'{tref}:{qref}'
422 q['path'] = topicpath 421 q['path'] = topicpath
423 q.setdefault('append_wrong', t['append_wrong']) 422 q.setdefault('append_wrong', t['append_wrong'])
424 423
aprendizations/questions.py
@@ -72,7 +72,6 @@ class QuestionRadio(Question): @@ -72,7 +72,6 @@ class QuestionRadio(Question):
72 ''' 72 '''
73 73
74 # ------------------------------------------------------------------------ 74 # ------------------------------------------------------------------------
75 - # FIXME marking all options right breaks  
76 def __init__(self, q: QDict) -> None: 75 def __init__(self, q: QDict) -> None:
77 super().__init__(q) 76 super().__init__(q)
78 77
@@ -86,18 +85,46 @@ class QuestionRadio(Question): @@ -86,18 +85,46 @@ class QuestionRadio(Question):
86 'max_tries': (n + 3) // 4 # 1 try for each 4 options 85 'max_tries': (n + 3) // 4 # 1 try for each 4 options
87 })) 86 }))
88 87
89 - # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0]  
90 - # correctness levels from 0.0 to 1.0 (no discount here!) 88 + # check correct bounds and convert int to list,
  89 + # e.g. correct: 2 --> correct: [0,0,1,0,0]
91 if isinstance(self['correct'], int): 90 if isinstance(self['correct'], int):
  91 + if not (0 <= self['correct'] < n):
  92 + msg = (f'Correct option not in range 0..{n-1} in '
  93 + f'"{self["ref"]}"')
  94 + raise QuestionException(msg)
  95 +
92 self['correct'] = [1.0 if x == self['correct'] else 0.0 96 self['correct'] = [1.0 if x == self['correct'] else 0.0
93 for x in range(n)] 97 for x in range(n)]
94 98
95 - if len(self['correct']) != n:  
96 - msg = ('Number of options and correct differ in '  
97 - f'"{self["ref"]}", file "{self["filename"]}".')  
98 - logger.error(msg)  
99 - raise QuestionException(msg)  
100 - 99 + elif isinstance(self['correct'], list):
  100 + # must match number of options
  101 + if len(self['correct']) != n:
  102 + msg = (f'Incompatible sizes: {n} options vs '
  103 + f'{len(self["correct"])} correct in "{self["ref"]}"')
  104 + raise QuestionException(msg)
  105 + # make sure is a list of floats
  106 + try:
  107 + self['correct'] = [float(x) for x in self['correct']]
  108 + except (ValueError, TypeError):
  109 + msg = (f'Correct list must contain numbers [0.0, 1.0] or '
  110 + f'booleans in "{self["ref"]}"')
  111 + raise QuestionException(msg)
  112 +
  113 + # check grade boundaries
  114 + if self['discount'] and not all(0.0 <= x <= 1.0
  115 + for x in self['correct']):
  116 + msg = (f'If discount=true, correct values must be in the '
  117 + f'interval [0.0, 1.0] in "{self["ref"]}"')
  118 + raise QuestionException(msg)
  119 +
  120 + # at least one correct option
  121 + if all(x < 1.0 for x in self['correct']):
  122 + msg = (f'At least one correct option is required in '
  123 + f'"{self["ref"]}"')
  124 + raise QuestionException(msg)
  125 +
  126 + # If shuffle==false, all options are shown as defined
  127 + # otherwise, select 1 correct and choose a few wrong ones
101 if self['shuffle']: 128 if self['shuffle']:
102 # lists with indices of right and wrong options 129 # lists with indices of right and wrong options
103 right = [i for i in range(n) if self['correct'][i] >= 1] 130 right = [i for i in range(n) if self['correct'][i] >= 1]
@@ -123,7 +150,7 @@ class QuestionRadio(Question): @@ -123,7 +150,7 @@ class QuestionRadio(Question):
123 # final shuffle of the options 150 # final shuffle of the options
124 perm = random.sample(range(self['choose']), k=self['choose']) 151 perm = random.sample(range(self['choose']), k=self['choose'])
125 self['options'] = [str(options[i]) for i in perm] 152 self['options'] = [str(options[i]) for i in perm]
126 - self['correct'] = [float(correct[i]) for i in perm] 153 + self['correct'] = [correct[i] for i in perm]
127 154
128 # ------------------------------------------------------------------------ 155 # ------------------------------------------------------------------------
129 # can assign negative grades for wrong answers 156 # can assign negative grades for wrong answers
@@ -131,10 +158,13 @@ class QuestionRadio(Question): @@ -131,10 +158,13 @@ class QuestionRadio(Question):
131 super().correct() 158 super().correct()
132 159
133 if self['answer'] is not None: 160 if self['answer'] is not None:
134 - x = self['correct'][int(self['answer'])]  
135 - if self['discount']:  
136 - n = len(self['options']) # number of options  
137 - x_aver = sum(self['correct']) / n 161 + x = self['correct'][int(self['answer'])] # get grade of the answer
  162 + n = len(self['options'])
  163 + x_aver = sum(self['correct']) / n # expected value of grade
  164 +
  165 + # note: there are no numerical errors when summing 1.0s so the
  166 + # x_aver can be exactly 1.0 if all options are right
  167 + if self['discount'] and x_aver != 1.0:
138 x = (x - x_aver) / (1.0 - x_aver) 168 x = (x - x_aver) / (1.0 - x_aver)
139 self['grade'] = x 169 self['grade'] = x
140 170
@@ -168,12 +198,32 @@ class QuestionCheckbox(Question): @@ -168,12 +198,32 @@ class QuestionCheckbox(Question):
168 'max_tries': max(1, min(n - 1, 3)) 198 'max_tries': max(1, min(n - 1, 3))
169 })) 199 }))
170 200
  201 + # must be a list of numbers
  202 + if not isinstance(self['correct'], list):
  203 + msg = 'Correct must be a list of numbers or booleans'
  204 + raise QuestionException(msg)
  205 +
  206 + # must match number of options
171 if len(self['correct']) != n: 207 if len(self['correct']) != n:
172 - msg = (f'Options and correct size mismatch in '  
173 - f'"{self["ref"]}", file "{self["filename"]}".')  
174 - logger.error(msg) 208 + msg = (f'Incompatible sizes: {n} options vs '
  209 + f'{len(self["correct"])} correct in "{self["ref"]}"')
175 raise QuestionException(msg) 210 raise QuestionException(msg)
176 211
  212 + # make sure is a list of floats
  213 + try:
  214 + self['correct'] = [float(x) for x in self['correct']]
  215 + except (ValueError, TypeError):
  216 + msg = (f'Correct list must contain numbers or '
  217 + f'booleans in "{self["ref"]}"')
  218 + raise QuestionException(msg)
  219 +
  220 + # check grade boundaries (FUTURE)
  221 + # if self['discount'] and not all(0.0 <= x <= 1.0
  222 + # for x in self['correct']):
  223 + # msg = (f'If discount=true, correct values must be in the '
  224 + # f'interval [0.0, 1.0] in "{self["ref"]}"')
  225 + # raise QuestionException(msg)
  226 +
177 # if an option is a list of (right, wrong), pick one 227 # if an option is a list of (right, wrong), pick one
178 options = [] 228 options = []
179 correct = [] 229 correct = []
@@ -526,17 +576,21 @@ class QFactory(object): @@ -526,17 +576,21 @@ class QFactory(object):
526 stdin=q['stdin']) 576 stdin=q['stdin'])
527 q.update(out) 577 q.update(out)
528 578
529 - # Finally we create an instance of Question() 579 + # Get class for this question type
530 try: 580 try:
531 - qinstance = self._types[q['type']](QDict(q)) # of matching class  
532 - except QuestionException as e:  
533 - logger.error(e)  
534 - raise e 581 + qclass = self._types[q['type']]
535 except KeyError: 582 except KeyError:
536 logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') 583 logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
537 raise 584 raise
538 - else:  
539 - return qinstance 585 +
  586 + # Finally create an instance of Question()
  587 + try:
  588 + qinstance = qclass(QDict(q))
  589 + except QuestionException as e:
  590 + # logger.error(e)
  591 + raise e
  592 +
  593 + return qinstance
540 594
541 # ------------------------------------------------------------------------ 595 # ------------------------------------------------------------------------
542 def generate(self) -> Question: 596 def generate(self) -> Question:
aprendizations/serve.py
@@ -254,7 +254,6 @@ class TopicHandler(BaseHandler): @@ -254,7 +254,6 @@ class TopicHandler(BaseHandler):
254 class FileHandler(BaseHandler): 254 class FileHandler(BaseHandler):
255 @tornado.web.authenticated 255 @tornado.web.authenticated
256 async def get(self, filename): 256 async def get(self, filename):
257 - # logger.debug(f'[FileHandler] {filename}')  
258 uid = self.current_user 257 uid = self.current_user
259 public_dir = self.learn.get_current_public_dir(uid) 258 public_dir = self.learn.get_current_public_dir(uid)
260 filepath = path.expanduser(path.join(public_dir, filename)) 259 filepath = path.expanduser(path.join(public_dir, filename))
@@ -331,7 +330,7 @@ class QuestionHandler(BaseHandler): @@ -331,7 +330,7 @@ class QuestionHandler(BaseHandler):
331 user = self.current_user 330 user = self.current_user
332 answer = self.get_body_arguments('answer') # list 331 answer = self.get_body_arguments('answer') # list
333 qid = self.get_body_arguments('qid')[0] 332 qid = self.get_body_arguments('qid')[0]
334 - logger.debug(f'[QuestionHandler] user={user}, answer={answer}') 333 + logger.debug(f'[QuestionHandler] answer={answer}')
335 334
336 # --- check if browser opened different questions simultaneously 335 # --- check if browser opened different questions simultaneously
337 if qid != self.learn.get_current_question_id(user): 336 if qid != self.learn.get_current_question_id(user):
config/logger-debug.yaml
@@ -2,50 +2,52 @@ @@ -2,50 +2,52 @@
2 version: 1 2 version: 1
3 3
4 formatters: 4 formatters:
5 - void:  
6 - format: ''  
7 - standard:  
8 - format: '%(asctime)s | %(thread)-15d | %(levelname)-8s | %(module)-10s | %(funcName)-20s | %(message)s'  
9 - # datefmt: '%H:%M:%S' 5 + void:
  6 + format: ''
  7 + standard:
  8 + format: '%(asctime)s | %(levelname)-8s | %(module)-10s | %(funcName)-22s |
  9 + %(message)s'
  10 + # | %(thread)-15d
  11 + # datefmt: '%H:%M:%S'
10 12
11 handlers: 13 handlers:
12 - default:  
13 - level: 'DEBUG'  
14 - class: 'logging.StreamHandler'  
15 - formatter: 'standard'  
16 - stream: 'ext://sys.stdout' 14 + default:
  15 + level: 'DEBUG'
  16 + class: 'logging.StreamHandler'
  17 + formatter: 'standard'
  18 + stream: 'ext://sys.stdout'
17 19
18 loggers: 20 loggers:
19 - '':  
20 - handlers: ['default']  
21 - level: 'DEBUG'  
22 -  
23 - 'aprendizations.factory':  
24 - handlers: ['default']  
25 - level: 'DEBUG'  
26 - propagate: false  
27 -  
28 - 'aprendizations.student':  
29 - handlers: ['default']  
30 - level: 'DEBUG'  
31 - propagate: false  
32 -  
33 - 'aprendizations.learnapp':  
34 - handlers: ['default']  
35 - level: 'DEBUG'  
36 - propagate: false  
37 -  
38 - 'aprendizations.questions':  
39 - handlers: ['default']  
40 - level: 'DEBUG'  
41 - propagate: false  
42 -  
43 - 'aprendizations.tools':  
44 - handlers: ['default']  
45 - level: 'DEBUG'  
46 - propagate: false  
47 -  
48 - 'aprendizations.serve':  
49 - handlers: ['default']  
50 - level: 'DEBUG'  
51 - propagate: false 21 + '':
  22 + handlers: ['default']
  23 + level: 'DEBUG'
  24 +
  25 + 'aprendizations.factory':
  26 + handlers: ['default']
  27 + level: 'DEBUG'
  28 + propagate: false
  29 +
  30 + 'aprendizations.student':
  31 + handlers: ['default']
  32 + level: 'DEBUG'
  33 + propagate: false
  34 +
  35 + 'aprendizations.learnapp':
  36 + handlers: ['default']
  37 + level: 'DEBUG'
  38 + propagate: false
  39 +
  40 + 'aprendizations.questions':
  41 + handlers: ['default']
  42 + level: 'DEBUG'
  43 + propagate: false
  44 +
  45 + 'aprendizations.tools':
  46 + handlers: ['default']
  47 + level: 'DEBUG'
  48 + propagate: false
  49 +
  50 + 'aprendizations.serve':
  51 + handlers: ['default']
  52 + level: 'DEBUG'
  53 + propagate: false
demo/astronomy/solar-system/questions.yaml
@@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
28 - Têm todos o mesmo tamanho 28 - Têm todos o mesmo tamanho
29 # opcional 29 # opcional
30 correct: 2 30 correct: 2
  31 + # discount: true
31 shuffle: false 32 shuffle: false
32 solution: | 33 solution: |
33 O maior planeta é Júpiter. Tem uma massa 1000 vezes inferior ao Sol, mas 34 O maior planeta é Júpiter. Tem uma massa 1000 vezes inferior ao Sol, mas
demo/math/addition/questions.yaml
@@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
5 script: addition-two-digits.py 5 script: addition-two-digits.py
6 args: [10, 20] 6 args: [10, 20]
7 7
  8 +# ---------------------------------------------------------------------------
8 - type: checkbox 9 - type: checkbox
9 ref: addition-properties 10 ref: addition-properties
10 title: Propriedades da adição 11 title: Propriedades da adição