Commit d823a4d87455beadabebfd26c488199af065fcea

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

Lots of changes:

- changes checkbox correct list to the same semantics as radio with
  interval values in the interval [0,1]
- adds many sanity checks to questions.
- fix display math centering in radio and checkbox options
- fix check for nonexisting goals
- fix missing database
- fix bug where last question would not show images in the solution
BUGS.md
1 1  
2 2 # BUGS
3 3  
4   -
5   -Traceback (most recent call last):
6   - File "/home/mjsb/.local/lib/python3.7/site-packages/tornado/web.py", line 1697, in _execute
7   - result = method(*self.path_args, **self.path_kwargs)
8   - File "/home/mjsb/.local/lib/python3.7/site-packages/tornado/web.py", line 3174, in wrapper
9   - return method(self, *args, **kwargs)
10   - File "/usr/home/mjsb/Work/Projects/aprendizations/aprendizations/serve.py", line 213, in get
11   - self.learn.start_course(uid, course)
12   - File "/usr/home/mjsb/Work/Projects/aprendizations/aprendizations/learnapp.py", line 275, in start_course
13   - student.start_course(course)
14   - File "/usr/home/mjsb/Work/Projects/aprendizations/aprendizations/student.py", line 57, in start_course
15   - self.topic_sequence = self.recommend_topic_sequence(topics)
16   - File "/usr/home/mjsb/Work/Projects/aprendizations/aprendizations/student.py", line 216, in recommend_topic_sequence
17   - ts.update(nx.ancestors(G, t))
18   - File "/home/mjsb/.local/lib/python3.7/site-packages/networkx/algorithms/dag.py", line 92, in ancestors
19   - raise nx.NetworkXError("The node %s is not in the graph." % source)
20   -networkx.exception.NetworkXError: The node programming/languages/pseudo-tcg/functions-produtorio is not in the graph.
21   -
22   -
23   -
24   -- detectar se em courses.yaml falta declarar ficheiro. Por exemplo se houver goals que não estao em lado nenhum.
25   -- se num topico, a ultima pergunta tem imagens, o servidor nao fornece as imagengs porque o current_topic passa a None antes de carregar no botao continuar. O caminho é prefix+None e dá erro.
26   -- registar last_seen e remover os antigos de cada vez que houver um login.
  4 +- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)
  5 +- nao esta a seguir o max_tries definido no ficheiro de dependencias.
27 6 - initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a)
28   -- permite definir goal, mas nao verifica se esta no grafo. rebenta no start_topic.
29 7 - double click submits twice.
30   -- nao esta a seguir o max_tries definido no ficheiro de dependencias.
31   -- impedir que quando students.db não é encontrado, crie um ficheiro vazio.
32   -- classificacoes so devia mostrar os que ja fizeram alguma coisa
33   -- QFactory.generate() devia fazer run da gen_async, ou remover.
34   -- marking all options right in a radio question breaks!
35   -- opcao --prefix devia afectar a base de dados?
36 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
37   -- quando termina topico devia apagar as perguntas todas (se falhar a gerar novo topico, aparecem perguntas do antigo)
38   -- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)
39 9 - devia mostrar timeout para o aluno saber a razao.
40 10 - permitir configuracao para escolher entre static files locais ou remotos
41   -- sqlalchemy.pool.impl.NullPool: Exception during reset or similar
42   -sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread.
43 11 - templates question-*.html tem input hidden question_ref que não é usado. remover?
44   -- guardar o estado a meio de um nível.
45   -- safari as vezes envia dois gets no inicio do topico. nesses casos, a segunda pergunta não é actualizada no browser... o topico tem de ser gerado qd se escolhe o topico em main_topics. O get nao deve alterar o estado.
46   -- click numa opcao checkbox fora da checkbox+label não está a funcionar.
47 12 - shift-enter não está a funcionar
48   -- 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.
49 13 - default prefix should be obtained from each course (yaml conf)?
50   -- tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question.
51 14  
52 15 # TODO
53 16  
  17 +- registar last_seen e remover os antigos de cada vez que houver um login.
54 18 - indicar qtos topicos faltam (>=50%) para terminar o curso.
55   -- use run_script_async to run run_script using asyncio.run?
56   -- ao fim de 3 tentativas de login, envial email para aluno com link para definir nova password (com timeout de 5 minutos).
  19 +- ao fim de 3 tentativas com password errada, envia email com nova password.
57 20 - mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo expande as dependencias.
58 21 - mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado topicos.
59 22 - botão não sei...
... ... @@ -65,14 +28,23 @@ sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in
65 28 - tabela com perguntas / quantidade de respostas certas/erradas.
66 29 - tabela com topicos / quantidade de estrelas.
67 30 - pymips: activar/desactivar instruções
68   -- implementar servidor http com redirect para https.
69   -- ao fim de 3 tentativas com password errada, envia email com nova password.
70 31 - titulos das perguntas não suportam markdown.
71 32 - pagina report que permita ver tabela alunos/topicos, estatisticas perguntas mais falhadas, tempo médio por pergunta.
72 33 - normalizar com perguntations.
73 34  
74 35 # FIXED
75 36  
  37 +- 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.
  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.
  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.
  43 +- QFactory.generate() devia fazer run da gen_async, ou remover.
  44 +- classificacoes so devia mostrar os que ja fizeram alguma coisa
  45 +- impedir que quando students.db não é encontrado, crie um ficheiro vazio.
  46 +- permite definir goal, mas nao verifica se esta no grafo. rebenta no start_topic.
  47 +- se num topico, a ultima pergunta tem imagens, o servidor nao fornece as imagengs porque o current_topic passa a None antes de carregar no botao continuar. O caminho é prefix+None e dá erro.
76 48 - caixas com os cursos não se ajustam bem com ecran estreito.
77 49 - obter rankings por curso GET course=course_id
78 50 - no curso de linear algebra, as perguntas estao shuffled, mas nao deviam estar... nao esta a obedecer a keyword shuffle.
... ...
aprendizations/learnapp.py
... ... @@ -16,10 +16,11 @@ import sqlalchemy as sa
16 16  
17 17 # this project
18 18 from .models import Student, Answer, Topic, StudentTopic
19   -from .questions import Question, QFactory, QDict
  19 +from .questions import Question, QFactory, QDict, QuestionException
20 20 from .student import StudentState
21 21 from .tools import load_yaml
22 22  
  23 +
23 24 # setup logger for this module
24 25 logger = logging.getLogger(__name__)
25 26  
... ... @@ -68,28 +69,36 @@ class LearnApp(object):
68 69  
69 70 config: Dict[str, Any] = load_yaml(courses)
70 71  
71   - # --- courses dict
72   - self.courses = config['courses']
73   - logger.info(f'Courses: {", ".join(self.courses.keys())}')
74   -
75 72 # --- topic dependencies are shared between all courses
76 73 self.deps = nx.DiGraph(prefix=prefix)
77   - logger.info('Populating graph:')
  74 + logger.info('Populating topic graph:')
78 75  
79 76 t = config.get('topics', {}) # topics defined directly in courses file
80 77 self.populate_graph(t)
81 78 logger.info(f'{len(t):>6} topics in {courses}')
82 79 for f in config.get('topics_from', []):
83 80 c = load_yaml(f) # course configuration
84   - logger.info(f'{len(c["topics"]):>6} topics from {f}')
  81 +
  82 + # FIXME set defaults??
  83 + logger.info(f'{len(c["topics"]):>6} topics imported from {f}')
85 84 self.populate_graph(c)
86 85 logger.info(f'Graph has {len(self.deps)} topics')
87 86  
  87 + # --- courses dict
  88 + self.courses = config['courses']
  89 + logger.info(f'Courses: {", ".join(self.courses.keys())}')
  90 + for c, d in self.courses.items():
  91 + for goal in d['goals']:
  92 + if goal not in self.deps.nodes():
  93 + # logger.error(f'Goal "{goal}" of "{c}"" not in the graph')
  94 + raise LearnException(f'Goal "{goal}" from course "{c}" '
  95 + ' does not exist')
  96 +
88 97 # --- factory is a dict with question generators for all topics
89 98 self.factory: Dict[str, QFactory] = self.make_factory()
90 99  
91 100 # if graph has topics that are not in the database, add them
92   - self.db_add_missing_topics(self.deps.nodes())
  101 + self.add_missing_topics(self.deps.nodes())
93 102  
94 103 if check:
95 104 self.sanity_check_questions()
... ... @@ -103,8 +112,8 @@ class LearnApp(object):
103 112 logger.debug(f'checking {qref}...')
104 113 try:
105 114 q = self.factory[qref].generate()
106   - except Exception:
107   - logger.error(f'Failed to generate "{qref}".')
  115 + except QuestionException as e:
  116 + logger.error(e)
108 117 errors += 1
109 118 continue # to next question
110 119  
... ... @@ -127,7 +136,7 @@ class LearnApp(object):
127 136 continue # to next test
128 137  
129 138 if errors > 0:
130   - logger.error(f'{errors:>6} errors found.')
  139 + logger.error(f'{errors:>6} error(s) found.')
131 140 raise LearnException('Sanity checks')
132 141 else:
133 142 logger.info(' 0 errors found.')
... ... @@ -216,13 +225,13 @@ class LearnApp(object):
216 225 return True
217 226  
218 227 # ------------------------------------------------------------------------
219   - # checks answer (updating student state) and returns grade. FIXME type of answer
  228 + # Checks answer and update database. Returns corrected question.
220 229 # ------------------------------------------------------------------------
221   - async def check_answer(self, uid: str, answer) -> Tuple[Question, str]:
  230 + async def check_answer(self, uid: str, answer) -> Question:
222 231 student = self.online[uid]['state']
223 232 topic = student.get_current_topic()
224 233  
225   - q, action = await student.check_answer(answer) # may move questions
  234 + q = await student.check_answer(answer)
226 235  
227 236 logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"')
228 237  
... ... @@ -237,8 +246,8 @@ class LearnApp(object):
237 246 topic_id=topic))
238 247 logger.debug(f'db insert answer of {q["ref"]}')
239 248  
  249 + # save topic if finished
240 250 if student.topic_has_finished():
241   - # finished topic, save into database
242 251 logger.info(f'User "{uid}" finished "{topic}"')
243 252 level: float = student.get_topic_level(topic)
244 253 date: str = str(student.get_topic_date(topic))
... ... @@ -264,7 +273,13 @@ class LearnApp(object):
264 273  
265 274 s.add(a)
266 275  
267   - return q, action
  276 + return q
  277 +
  278 + # ------------------------------------------------------------------------
  279 + # get the question to show (current or new one)
  280 + # ------------------------------------------------------------------------
  281 + async def get_question(self, uid: str) -> Optional[Question]:
  282 + return await self.online[uid]['state'].get_question()
268 283  
269 284 # ------------------------------------------------------------------------
270 285 # Start course
... ... @@ -277,7 +292,7 @@ class LearnApp(object):
277 292 logger.warning(f'"{uid}" could not start course "{course}": {e}')
278 293 raise
279 294 else:
280   - logger.info(f'"{uid}" started course "{course}"')
  295 + logger.info(f'User "{uid}" started course "{course}"')
281 296  
282 297 # ------------------------------------------------------------------------
283 298 # Start new topic
... ... @@ -294,7 +309,7 @@ class LearnApp(object):
294 309 # ------------------------------------------------------------------------
295 310 # Fill db table 'Topic' with topics from the graph if not already there.
296 311 # ------------------------------------------------------------------------
297   - def db_add_missing_topics(self, topics: List[str]) -> None:
  312 + def add_missing_topics(self, topics: List[str]) -> None:
298 313 with self.db_session() as s:
299 314 new_topics = [Topic(id=t) for t in topics
300 315 if (t,) not in s.query(Topic.id)]
... ... @@ -310,6 +325,10 @@ class LearnApp(object):
310 325 def db_setup(self, db: str) -> None:
311 326  
312 327 logger.info(f'Checking database "{db}":')
  328 + if not path.exists(db):
  329 + raise LearnException('Database does not exist. '
  330 + 'Use "initdb-aprendizations" to create')
  331 +
313 332 engine = sa.create_engine(f'sqlite:///{db}', echo=False)
314 333 self.Session = sa.orm.sessionmaker(bind=engine)
315 334 try:
... ... @@ -325,7 +344,7 @@ class LearnApp(object):
325 344 logger.info(f'{m:6} topics')
326 345 logger.info(f'{q:6} answers')
327 346  
328   - # ============================================================================
  347 + # ========================================================================
329 348 # Populates a digraph.
330 349 #
331 350 # Nodes are the topic references e.g. 'my/topic'
... ... @@ -380,7 +399,7 @@ class LearnApp(object):
380 399 def make_factory(self) -> Dict[str, QFactory]:
381 400  
382 401 logger.info('Building questions factory:')
383   - factory: Dict[str, QFactory] = {}
  402 + factory = {}
384 403 g = self.deps
385 404 for tref in g.nodes():
386 405 t = g.nodes[tref]
... ... @@ -389,6 +408,7 @@ class LearnApp(object):
389 408 topicpath: str = path.join(g.graph['prefix'], tref)
390 409 fullpath: str = path.join(topicpath, t['file'])
391 410  
  411 + logger.debug(f' Loading {fullpath}')
392 412 questions: List[QDict] = load_yaml(fullpath, default=[])
393 413  
394 414 # update refs to include topic as prefix.
... ... @@ -397,7 +417,7 @@ class LearnApp(object):
397 417 # within the file
398 418 for i, q in enumerate(questions):
399 419 qref = q.get('ref', str(i)) # ref or number
400   - q['ref'] = tref + ':' + qref
  420 + q['ref'] = f'{tref}:{qref}'
401 421 q['path'] = topicpath
402 422 q.setdefault('append_wrong', t['append_wrong'])
403 423  
... ... @@ -410,10 +430,11 @@ class LearnApp(object):
410 430 for q in questions:
411 431 if q['ref'] in t['questions']:
412 432 factory[q['ref']] = QFactory(q)
  433 + logger.debug(f' + {q["ref"]}')
413 434  
414   - logger.info(f'{len(t["questions"]):6} {tref}')
  435 + logger.info(f'{len(t["questions"]):6} questions in {tref}')
415 436  
416   - logger.info(f'Factory contains {len(factory)} questions')
  437 + logger.info(f'Factory has {len(factory)} questions')
417 438 return factory
418 439  
419 440 # ------------------------------------------------------------------------
... ...
aprendizations/main.py
... ... @@ -9,7 +9,7 @@ import sys
9 9 from typing import Any, Dict
10 10  
11 11 # this project
12   -from .learnapp import LearnApp, DatabaseUnusableError
  12 +from .learnapp import LearnApp, DatabaseUnusableError, LearnException
13 13 from .serve import run_webserver
14 14 from .tools import load_yaml
15 15 from . import APP_NAME, APP_VERSION
... ... @@ -160,7 +160,7 @@ def main():
160 160 else:
161 161 logging.info('SSL certificates loaded')
162 162  
163   - # --- start application
  163 + # --- start application --------------------------------------------------
164 164 try:
165 165 learnapp = LearnApp(courses=arg.courses,
166 166 prefix=arg.prefix,
... ... @@ -178,13 +178,16 @@ def main():
178 178 '--------------------------------------------------------------',
179 179 sep='\n')
180 180 sys.exit(1)
  181 + except LearnException as e:
  182 + logging.critical(e)
  183 + sys.exit(1)
181 184 except Exception:
182 185 logging.critical('Failed to start backend.')
183 186 sys.exit(1)
184 187 else:
185   - logging.info('Backend started')
  188 + logging.info('LearnApp started')
186 189  
187   - # --- run webserver forever
  190 + # --- run webserver forever ----------------------------------------------
188 191 run_webserver(app=learnapp, ssl=ssl_ctx, port=arg.port, debug=arg.debug)
189 192  
190 193  
... ...
aprendizations/questions.py
... ... @@ -42,7 +42,6 @@ class Question(dict):
42 42 'comments': '',
43 43 'solution': '',
44 44 'files': {},
45   - # 'max_tries': 3,
46 45 }))
47 46  
48 47 def correct(self) -> None:
... ... @@ -72,7 +71,6 @@ class QuestionRadio(Question):
72 71 '''
73 72  
74 73 # ------------------------------------------------------------------------
75   - # FIXME marking all options right breaks
76 74 def __init__(self, q: QDict) -> None:
77 75 super().__init__(q)
78 76  
... ... @@ -86,18 +84,46 @@ class QuestionRadio(Question):
86 84 'max_tries': (n + 3) // 4 # 1 try for each 4 options
87 85 }))
88 86  
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!)
  87 + # check correct bounds and convert int to list,
  88 + # e.g. correct: 2 --> correct: [0,0,1,0,0]
91 89 if isinstance(self['correct'], int):
  90 + if not (0 <= self['correct'] < n):
  91 + msg = (f'Correct option not in range 0..{n-1} in '
  92 + f'"{self["ref"]}"')
  93 + raise QuestionException(msg)
  94 +
92 95 self['correct'] = [1.0 if x == self['correct'] else 0.0
93 96 for x in range(n)]
94 97  
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   -
  98 + elif isinstance(self['correct'], list):
  99 + # must match number of options
  100 + if len(self['correct']) != n:
  101 + msg = (f'Incompatible sizes: {n} options vs '
  102 + f'{len(self["correct"])} correct in "{self["ref"]}"')
  103 + raise QuestionException(msg)
  104 + # make sure is a list of floats
  105 + try:
  106 + self['correct'] = [float(x) for x in self['correct']]
  107 + except (ValueError, TypeError):
  108 + msg = (f'Correct list must contain numbers [0.0, 1.0] or '
  109 + f'booleans in "{self["ref"]}"')
  110 + raise QuestionException(msg)
  111 +
  112 + # check grade boundaries
  113 + if self['discount'] and not all(0.0 <= x <= 1.0
  114 + for x in self['correct']):
  115 + msg = (f'Correct values must be in the interval [0.0, 1.0] in '
  116 + f'"{self["ref"]}"')
  117 + raise QuestionException(msg)
  118 +
  119 + # at least one correct option
  120 + if all(x < 1.0 for x in self['correct']):
  121 + msg = (f'At least one correct option is required in '
  122 + f'"{self["ref"]}"')
  123 + raise QuestionException(msg)
  124 +
  125 + # If shuffle==false, all options are shown as defined
  126 + # otherwise, select 1 correct and choose a few wrong ones
101 127 if self['shuffle']:
102 128 # lists with indices of right and wrong options
103 129 right = [i for i in range(n) if self['correct'][i] >= 1]
... ... @@ -123,7 +149,7 @@ class QuestionRadio(Question):
123 149 # final shuffle of the options
124 150 perm = random.sample(range(self['choose']), k=self['choose'])
125 151 self['options'] = [str(options[i]) for i in perm]
126   - self['correct'] = [float(correct[i]) for i in perm]
  152 + self['correct'] = [correct[i] for i in perm]
127 153  
128 154 # ------------------------------------------------------------------------
129 155 # can assign negative grades for wrong answers
... ... @@ -131,10 +157,13 @@ class QuestionRadio(Question):
131 157 super().correct()
132 158  
133 159 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
  160 + x = self['correct'][int(self['answer'])] # get grade of the answer
  161 + n = len(self['options'])
  162 + x_aver = sum(self['correct']) / n # expected value of grade
  163 +
  164 + # note: there are no numerical errors when summing 1.0s so the
  165 + # x_aver can be exactly 1.0 if all options are right
  166 + if self['discount'] and x_aver != 1.0:
138 167 x = (x - x_aver) / (1.0 - x_aver)
139 168 self['grade'] = x
140 169  
... ... @@ -168,12 +197,44 @@ class QuestionCheckbox(Question):
168 197 'max_tries': max(1, min(n - 1, 3))
169 198 }))
170 199  
  200 + # must be a list of numbers
  201 + if not isinstance(self['correct'], list):
  202 + msg = 'Correct must be a list of numbers or booleans'
  203 + raise QuestionException(msg)
  204 +
  205 + # must match number of options
171 206 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)
  207 + msg = (f'Incompatible sizes: {n} options vs '
  208 + f'{len(self["correct"])} correct in "{self["ref"]}"')
  209 + raise QuestionException(msg)
  210 +
  211 + # make sure is a list of floats
  212 + try:
  213 + self['correct'] = [float(x) for x in self['correct']]
  214 + except (ValueError, TypeError):
  215 + msg = (f'Correct list must contain numbers or '
  216 + f'booleans in "{self["ref"]}"')
175 217 raise QuestionException(msg)
176 218  
  219 + # check grade boundaries
  220 + if self['discount'] and not all(0.0 <= x <= 1.0
  221 + for x in self['correct']):
  222 +
  223 + msg0 = ('+-------------- BEHAVIOR CHANGE NOTICE --------------+')
  224 + msg1 = ('| Correct values must be in the interval [0.0, 1.0]. |')
  225 + msg2 = ('| I will convert to the new behavior, but you should |')
  226 + msg3 = ('| fix it in the question. |')
  227 + msg4 = ('+----------------------------------------------------+')
  228 + logger.warning(msg0)
  229 + logger.warning(msg1)
  230 + logger.warning(msg2)
  231 + logger.warning(msg3)
  232 + logger.warning(msg4)
  233 + logger.warning(f'please fix "{self["ref"]}"')
  234 +
  235 + # normalize to [0,1]
  236 + self['correct'] = [(x+1)/2 for x in self['correct']]
  237 +
177 238 # if an option is a list of (right, wrong), pick one
178 239 options = []
179 240 correct = []
... ... @@ -182,9 +243,10 @@ class QuestionCheckbox(Question):
182 243 r = random.randint(0, 1)
183 244 o = o[r]
184 245 if r == 1:
185   - c = -c
  246 + # c = -c
  247 + c = 1.0 - c
186 248 options.append(str(o))
187   - correct.append(float(c))
  249 + correct.append(c)
188 250  
189 251 # generate random permutation, e.g. [2,1,4,0,3]
190 252 # and apply to `options` and `correct`
... ... @@ -202,21 +264,20 @@ class QuestionCheckbox(Question):
202 264 super().correct()
203 265  
204 266 if self['answer'] is not None:
205   - sum_abs = sum(abs(p) for p in self['correct'])
206   - if sum_abs < 1e-6: # case correct [0,...,0] avoid div-by-zero
207   - self['grade'] = 1.0
208   -
  267 + x = 0.0
  268 + if self['discount']:
  269 + sum_abs = sum(abs(2*p-1) for p in self['correct'])
  270 + for i, p in enumerate(self['correct']):
  271 + x += 2*p-1 if str(i) in self['answer'] else 1-2*p
209 272 else:
210   - x = 0.0
211   -
212   - if self['discount']:
213   - for i, p in enumerate(self['correct']):
214   - x += p if str(i) in self['answer'] else -p
215   - else:
216   - for i, p in enumerate(self['correct']):
217   - x += p if str(i) in self['answer'] else 0.0
  273 + sum_abs = sum(abs(p) for p in self['correct'])
  274 + for i, p in enumerate(self['correct']):
  275 + x += p if str(i) in self['answer'] else 0.0
218 276  
  277 + try:
219 278 self['grade'] = x / sum_abs
  279 + except ZeroDivisionError:
  280 + self['grade'] = 1.0 # limit p->0
220 281  
221 282  
222 283 # ============================================================================
... ... @@ -240,15 +301,21 @@ class QuestionText(Question):
240 301  
241 302 # make sure its always a list of possible correct answers
242 303 if not isinstance(self['correct'], list):
243   - self['correct'] = [self['correct']]
  304 + self['correct'] = [str(self['correct'])]
  305 + else:
  306 + # make sure all elements of the list are strings
  307 + self['correct'] = [str(a) for a in self['correct']]
244 308  
245   - # make sure all elements of the list are strings
246   - self['correct'] = [str(a) for a in self['correct']]
  309 + for f in self['transform']:
  310 + if f not in ('remove_space', 'trim', 'normalize_space', 'lower',
  311 + 'upper'):
  312 + msg = (f'Unknown transform "{f}" in "{self["ref"]}"')
  313 + raise QuestionException(msg)
247 314  
248   - # make sure that the answers are invariant with respect to the filters
  315 + # check if answers are invariant with respect to the transforms
249 316 if any(c != self.transform(c) for c in self['correct']):
250 317 logger.warning(f'in "{self["ref"]}", correct answers are not '
251   - 'invariant wrt transformations')
  318 + 'invariant wrt transformations => never correct')
252 319  
253 320 # ------------------------------------------------------------------------
254 321 # apply optional filters to the answer
... ... @@ -302,8 +369,12 @@ class QuestionTextRegex(Question):
302 369 if not isinstance(self['correct'], list):
303 370 self['correct'] = [self['correct']]
304 371  
305   - # make sure all elements of the list are strings
306   - self['correct'] = [str(a) for a in self['correct']]
  372 + # converts patterns to compiled versions
  373 + try:
  374 + self['correct'] = [re.compile(a) for a in self['correct']]
  375 + except Exception:
  376 + msg = f'Failed to compile regex in "{self["ref"]}"'
  377 + raise QuestionException(msg)
307 378  
308 379 # ------------------------------------------------------------------------
309 380 def correct(self) -> None:
... ... @@ -312,12 +383,12 @@ class QuestionTextRegex(Question):
312 383 self['grade'] = 0.0
313 384 for r in self['correct']:
314 385 try:
315   - if re.match(r, self['answer']):
  386 + if r.match(self['answer']):
316 387 self['grade'] = 1.0
317 388 return
318 389 except TypeError:
319   - logger.error(f'While matching regex {self["correct"]} with'
320   - f' answer "{self["answer"]}".')
  390 + logger.error(f'While matching regex {r.pattern} with '
  391 + f'answer "{self["answer"]}".')
321 392  
322 393  
323 394 # ============================================================================
... ... @@ -339,6 +410,30 @@ class QuestionNumericInterval(Question):
339 410 'correct': [1.0, -1.0], # will always return false
340 411 }))
341 412  
  413 + # if only one number n is given, make an interval [n,n]
  414 + if isinstance(self['correct'], (int, float)):
  415 + self['correct'] = [float(self['correct']), float(self['correct'])]
  416 +
  417 + # make sure its a list of two numbers
  418 + elif isinstance(self['correct'], list):
  419 + if len(self['correct']) != 2:
  420 + msg = (f'Numeric interval must be a list with two numbers, in '
  421 + f'{self["ref"]}')
  422 + raise QuestionException(msg)
  423 +
  424 + try:
  425 + self['correct'] = [float(n) for n in self['correct']]
  426 + except Exception:
  427 + msg = (f'Numeric interval must be a list with two numbers, in '
  428 + f'{self["ref"]}')
  429 + raise QuestionException(msg)
  430 +
  431 + # invalid
  432 + else:
  433 + msg = (f'Numeric interval must be a list with two numbers, in '
  434 + f'{self["ref"]}')
  435 + raise QuestionException(msg)
  436 +
342 437 # ------------------------------------------------------------------------
343 438 def correct(self) -> None:
344 439 super().correct()
... ... @@ -520,23 +615,27 @@ class QFactory(object):
520 615 if q['type'] == 'generator':
521 616 logger.debug(f' \\_ Running "{q["script"]}".')
522 617 q.setdefault('args', [])
523   - q.setdefault('stdin', '') # FIXME is it really necessary?
  618 + q.setdefault('stdin', '')
524 619 script = path.join(q['path'], q['script'])
525 620 out = await run_script_async(script=script, args=q['args'],
526 621 stdin=q['stdin'])
527 622 q.update(out)
528 623  
529   - # Finally we create an instance of Question()
  624 + # Get class for this question type
530 625 try:
531   - qinstance = self._types[q['type']](QDict(q)) # of matching class
532   - except QuestionException as e:
533   - logger.error(e)
534   - raise e
  626 + qclass = self._types[q['type']]
535 627 except KeyError:
536 628 logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
537 629 raise
538   - else:
539   - return qinstance
  630 +
  631 + # Finally create an instance of Question()
  632 + try:
  633 + qinstance = qclass(QDict(q))
  634 + except QuestionException as e:
  635 + # logger.error(e)
  636 + raise e
  637 +
  638 + return qinstance
540 639  
541 640 # ------------------------------------------------------------------------
542 641 def generate(self) -> Question:
... ...
aprendizations/serve.py
... ... @@ -19,6 +19,7 @@ from tornado.escape import to_unicode
19 19 from .tools import md_to_html
20 20 from . import APP_NAME
21 21  
  22 +
22 23 # setup logger for this module
23 24 logger = logging.getLogger(__name__)
24 25  
... ... @@ -294,10 +295,10 @@ class QuestionHandler(BaseHandler):
294 295  
295 296 # --- get question to render
296 297 @tornado.web.authenticated
297   - def get(self):
298   - logger.debug('[QuestionHandler.get]')
  298 + async def get(self):
  299 + logger.debug('[QuestionHandler]')
299 300 user = self.current_user
300   - q = self.learn.get_current_question(user)
  301 + q = await self.learn.get_question(user)
301 302  
302 303 if q is not None:
303 304 qhtml = self.render_string(self.templates[q['type']],
... ... @@ -326,15 +327,13 @@ class QuestionHandler(BaseHandler):
326 327 # --- post answer, returns what to do next: shake, new_question, finished
327 328 @tornado.web.authenticated
328 329 async def post(self) -> None:
329   - logger.debug('[QuestionHandler.post]')
330 330 user = self.current_user
331 331 answer = self.get_body_arguments('answer') # list
332   - logger.debug(f'user = {user}, answer = {answer}')
  332 + qid = self.get_body_arguments('qid')[0]
  333 + logger.debug(f'[QuestionHandler] answer={answer}')
333 334  
334 335 # --- check if browser opened different questions simultaneously
335   - answer_qid = self.get_body_arguments('qid')[0]
336   - current_qid = self.learn.get_current_question_id(user)
337   - if answer_qid != current_qid:
  336 + if qid != self.learn.get_current_question_id(user):
338 337 logger.info(f'User {user} desynchronized questions')
339 338 self.write({
340 339 'method': 'invalid',
... ... @@ -346,7 +345,7 @@ class QuestionHandler(BaseHandler):
346 345 return
347 346  
348 347 # --- brain hacking ;)
349   - await asyncio.sleep(1)
  348 + await asyncio.sleep(2)
350 349  
351 350 # --- answers are in a list. fix depending on question type
352 351 qtype = self.learn.get_student_question_type(user)
... ... @@ -361,11 +360,11 @@ class QuestionHandler(BaseHandler):
361 360 ans = answer
362 361  
363 362 # --- check answer (nonblocking) and get corrected question and action
364   - q, action = await self.learn.check_answer(user, ans)
  363 + q = await self.learn.check_answer(user, ans)
365 364  
366 365 # --- built response to return
367   - response = {'method': action, 'params': {}}
368   - if action == 'right': # get next question in the topic
  366 + response = {'method': q['status'], 'params': {}}
  367 + if q['status'] == 'right': # get next question in the topic
369 368 comments_html = self.render_string(
370 369 'comments-right.html', comments=q['comments'], md=md_to_html)
371 370  
... ... @@ -379,7 +378,7 @@ class QuestionHandler(BaseHandler):
379 378 'solution': to_unicode(solution_html),
380 379 'tries': q['tries'],
381 380 }
382   - elif action == 'try_again':
  381 + elif q['status'] == 'try_again':
383 382 comments_html = self.render_string(
384 383 'comments.html', comments=q['comments'], md=md_to_html)
385 384  
... ... @@ -389,7 +388,7 @@ class QuestionHandler(BaseHandler):
389 388 'comments': to_unicode(comments_html),
390 389 'tries': q['tries'],
391 390 }
392   - elif action == 'wrong': # no more tries
  391 + elif q['status'] == 'wrong': # no more tries
393 392 comments_html = self.render_string(
394 393 'comments.html', comments=q['comments'], md=md_to_html)
395 394  
... ... @@ -404,7 +403,7 @@ class QuestionHandler(BaseHandler):
404 403 'tries': q['tries'],
405 404 }
406 405 else:
407   - logger.error(f'Unknown action: {action}')
  406 + logger.error(f'Unknown question status: {q["status"]}')
408 407  
409 408 self.write(response)
410 409  
... ... @@ -457,7 +456,7 @@ def run_webserver(app,
457 456  
458 457 # --- run webserver
459 458 signal.signal(signal.SIGINT, signal_handler)
460   - logger.info('Webserver running. (Ctrl-C to stop)')
  459 + logger.info('Webserver running... (Ctrl-C to stop)')
461 460  
462 461 try:
463 462 tornado.ioloop.IOLoop.current().start() # running...
... ...
aprendizations/static/css/topic.css
... ... @@ -27,3 +27,6 @@ html {
27 27 border: 1px solid #eee;
28 28 height: auto;
29 29 }
  30 +label {
  31 + display: block;
  32 +}
... ...
aprendizations/student.py
... ... @@ -74,11 +74,11 @@ class StudentState(object):
74 74 logger.debug(f'is locked "{topic}"')
75 75 return
76 76  
77   - # starting new topic
  77 + # choose k questions
78 78 self.current_topic = topic
  79 + # self.current_question = None
79 80 self.correct_answers = 0
80 81 self.wrong_answers = 0
81   -
82 82 t = self.deps.nodes[topic]
83 83 k = t['choose']
84 84 if t['shuffle_questions']:
... ... @@ -90,8 +90,7 @@ class StudentState(object):
90 90 self.questions: List[Question] = [await self.factory[ref].gen_async()
91 91 for ref in questions]
92 92  
93   - n = len(self.questions)
94   - logger.debug(f'generated {n} questions')
  93 + logger.debug(f'generated {len(self.questions)} questions')
95 94  
96 95 # get first question
97 96 self.next_question()
... ... @@ -110,62 +109,70 @@ class StudentState(object):
110 109 self.wrong_answers)
111 110 }
112 111 self.current_topic = None
  112 + self.current_question = None
113 113 self.unlock_topics()
114 114  
115 115 # ------------------------------------------------------------------------
116   - # corrects current question with provided answer.
117   - # implements the logic:
118   - # - if answer ok, goes to next question
119   - # - if wrong, counts number of tries. If exceeded, moves on.
  116 + # corrects current question
120 117 # ------------------------------------------------------------------------
121 118 async def check_answer(self, answer) -> Tuple[Question, str]:
122   - q: Question = self.current_question
  119 + q = self.current_question
123 120 q['answer'] = answer
124 121 q['finish_time'] = datetime.now()
125   - logger.debug(f'checking answer of {q["ref"]}...')
126 122 await q.correct_async()
127   - logger.debug(f'grade = {q["grade"]:.2}')
128 123  
129 124 if q['grade'] > 0.999:
130 125 self.correct_answers += 1
131   - self.next_question()
132   - action = 'right'
  126 + q['status'] = 'right'
133 127  
134 128 else:
135 129 self.wrong_answers += 1
136   - self.current_question['tries'] -= 1
137   -
138   - if self.current_question['tries'] > 0:
139   - action = 'try_again'
  130 + q['tries'] -= 1
  131 + if q['tries'] > 0:
  132 + q['status'] = 'try_again'
140 133 else:
141   - action = 'wrong'
142   - if self.current_question['append_wrong']:
143   - logger.debug('wrong answer, append new question')
144   - # self.questions.append(self.factory[q['ref']].generate())
145   - new_question = await self.factory[q['ref']].gen_async()
146   - self.questions.append(new_question)
147   - self.next_question()
  134 + q['status'] = 'wrong'
148 135  
149   - # returns corrected question (not new one) which might include comments
150   - return q, action
  136 + logger.debug(f'ref = {q["ref"]}, status = {q["status"]}')
  137 + return q
151 138  
152 139 # ------------------------------------------------------------------------
153   - # Move to next question, or None
  140 + # get question to show, current or next
154 141 # ------------------------------------------------------------------------
155   - def next_question(self) -> Optional[Question]:
  142 + async def get_question(self) -> Optional[Question]:
  143 + q = self.current_question
  144 + logger.debug(f'{q["ref"]} status = {q["status"]}')
  145 +
  146 + if q['status'] == 'right':
  147 + self.next_question()
  148 + elif q['status'] == 'wrong':
  149 + if q['append_wrong']:
  150 + logger.debug(' wrong answer => append new question')
  151 + new_question = await self.factory[q['ref']].gen_async()
  152 + self.questions.append(new_question)
  153 + self.next_question()
  154 + # elif q['status'] == 'new':
  155 + # pass
  156 + # elif q['status'] == 'try_again':
  157 + # pass
  158 +
  159 + return self.current_question
  160 +
  161 + # ------------------------------------------------------------------------
  162 + # moves to next question
  163 + # ------------------------------------------------------------------------
  164 + def next_question(self) -> None:
156 165 try:
157   - self.current_question = self.questions.pop(0)
  166 + q = self.questions.pop(0)
158 167 except IndexError:
159   - self.current_question = None
160 168 self.finish_topic()
161   - else:
162   - self.current_question['start_time'] = datetime.now()
163   - default_maxtries = self.deps.nodes[self.current_topic]['max_tries']
164   - maxtries = self.current_question.get('max_tries', default_maxtries)
165   - self.current_question['tries'] = maxtries
166   - logger.debug(f'current_question = {self.current_question["ref"]}')
  169 + return
167 170  
168   - return self.current_question # question or None
  171 + t = self.deps.nodes[self.current_topic]
  172 + q['start_time'] = datetime.now()
  173 + q['tries'] = q.get('max_tries', t['max_tries'])
  174 + q['status'] = 'new'
  175 + self.current_question = q
169 176  
170 177 # ------------------------------------------------------------------------
171 178 # Update proficiency level of the topics using a forgetting factor
... ... @@ -178,7 +185,7 @@ class StudentState(object):
178 185 forgetting_factor = self.deps.nodes[tref]['forgetting_factor']
179 186 s['level'] *= forgetting_factor ** dt.days # forgetting factor
180 187 except KeyError:
181   - logger.warning(f'Topic {tref} is not on the graph!')
  188 + logger.warning(f'Update topic levels: {tref} not in the graph')
182 189  
183 190 # ------------------------------------------------------------------------
184 191 # Unlock topics whose dependencies are satisfied (> min_level)
... ...
aprendizations/templates/question-checkbox.html
... ... @@ -9,7 +9,9 @@
9 9 <div class="custom-control custom-checkbox">
10 10 <input type="checkbox" class="custom-control-input"
11 11 id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}">
12   - <label for="{{ n }}" class="custom-control-label">{{ md(opt, strip_p_tag=True) }}</label>
  12 + <label for="{{ n }}" class="custom-control-label">
  13 + {{ md(opt, strip_p_tag=True) }}
  14 + </label>
13 15 </div>
14 16 </a>
15 17 {% end %}
... ...
aprendizations/templates/question-radio.html
... ... @@ -9,7 +9,9 @@
9 9 <div class="custom-control custom-radio">
10 10 <input type="radio" class="custom-control-input"
11 11 id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}">
12   - <label for="{{ n }}" class="custom-control-label">{{ md(opt, strip_p_tag=True) }}</label>
  12 + <label for="{{ n }}" class="custom-control-label">
  13 + {{ md(opt, strip_p_tag=True) }}
  14 + </label>
13 15 </div>
14 16 </a>
15 17 {% end %}
... ...
config/logger-debug.yaml
... ... @@ -2,50 +2,52 @@
2 2 version: 1
3 3  
4 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 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 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.yaml
... ... @@ -3,11 +3,11 @@
3 3 # optional values applied to each topic, if undefined there
4 4 # ----------------------------------------------------------------------------
5 5  
6   -# defaults:
  6 +# defaults: FIXME not working
7 7 # file: questions.yaml
8 8 # shuffle_questions: true
9 9 # choose: 6
10   -# max_tries: 2
  10 +# max_tries: 200
11 11 # forgetting_factor: 0.97
12 12 # min_level: 0.01
13 13 # append_wrong: true
... ...
demo/astronomy/solar-system/questions.yaml
... ... @@ -28,6 +28,7 @@
28 28 - Têm todos o mesmo tamanho
29 29 # opcional
30 30 correct: 2
  31 + # discount: true
31 32 shuffle: false
32 33 solution: |
33 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 5 script: addition-two-digits.py
6 6 args: [10, 20]
7 7  
  8 +# ---------------------------------------------------------------------------
8 9 - type: checkbox
9 10 ref: addition-properties
10 11 title: Propriedades da adição
... ... @@ -17,6 +18,6 @@
17 18 - Propriedade comutativa, $x+y=y+x$.
18 19 # wrong
19 20 - Existência de elemento absorvente, $x+1=1$.
20   - correct: [1, 1, 1, 1, -1]
  21 + correct: [1, 1, 1, 1, 0]
21 22 solution: |
22 23 A adição não tem elemento absorvente.
... ...
package-lock.json
... ... @@ -2,244 +2,26 @@
2 2 "requires": true,
3 3 "lockfileVersion": 1,
4 4 "dependencies": {
5   - "@babel/code-frame": {
6   - "version": "7.5.5",
7   - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz",
8   - "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==",
9   - "requires": {
10   - "@babel/highlight": "^7.0.0"
11   - }
12   - },
13   - "@babel/core": {
14   - "version": "7.6.0",
15   - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.6.0.tgz",
16   - "integrity": "sha512-FuRhDRtsd6IptKpHXAa+4WPZYY2ZzgowkbLBecEDDSje1X/apG7jQM33or3NdOmjXBKWGOg4JmSiRfUfuTtHXw==",
17   - "requires": {
18   - "@babel/code-frame": "^7.5.5",
19   - "@babel/generator": "^7.6.0",
20   - "@babel/helpers": "^7.6.0",
21   - "@babel/parser": "^7.6.0",
22   - "@babel/template": "^7.6.0",
23   - "@babel/traverse": "^7.6.0",
24   - "@babel/types": "^7.6.0",
25   - "convert-source-map": "^1.1.0",
26   - "debug": "^4.1.0",
27   - "json5": "^2.1.0",
28   - "lodash": "^4.17.13",
29   - "resolve": "^1.3.2",
30   - "semver": "^5.4.1",
31   - "source-map": "^0.5.0"
32   - }
33   - },
34   - "@babel/generator": {
35   - "version": "7.6.0",
36   - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.6.0.tgz",
37   - "integrity": "sha512-Ms8Mo7YBdMMn1BYuNtKuP/z0TgEIhbcyB8HVR6PPNYp4P61lMsABiS4A3VG1qznjXVCf3r+fVHhm4efTYVsySA==",
38   - "requires": {
39   - "@babel/types": "^7.6.0",
40   - "jsesc": "^2.5.1",
41   - "lodash": "^4.17.13",
42   - "source-map": "^0.5.0",
43   - "trim-right": "^1.0.1"
44   - }
45   - },
46   - "@babel/helper-function-name": {
47   - "version": "7.1.0",
48   - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz",
49   - "integrity": "sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==",
50   - "requires": {
51   - "@babel/helper-get-function-arity": "^7.0.0",
52   - "@babel/template": "^7.1.0",
53   - "@babel/types": "^7.0.0"
54   - }
55   - },
56   - "@babel/helper-get-function-arity": {
57   - "version": "7.0.0",
58   - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz",
59   - "integrity": "sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==",
60   - "requires": {
61   - "@babel/types": "^7.0.0"
62   - }
63   - },
64   - "@babel/helper-split-export-declaration": {
65   - "version": "7.4.4",
66   - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz",
67   - "integrity": "sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q==",
68   - "requires": {
69   - "@babel/types": "^7.4.4"
70   - }
71   - },
72   - "@babel/helpers": {
73   - "version": "7.6.0",
74   - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.6.0.tgz",
75   - "integrity": "sha512-W9kao7OBleOjfXtFGgArGRX6eCP0UEcA2ZWEWNkJdRZnHhW4eEbeswbG3EwaRsnQUAEGWYgMq1HsIXuNNNy2eQ==",
76   - "requires": {
77   - "@babel/template": "^7.6.0",
78   - "@babel/traverse": "^7.6.0",
79   - "@babel/types": "^7.6.0"
80   - }
81   - },
82   - "@babel/highlight": {
83   - "version": "7.5.0",
84   - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz",
85   - "integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==",
86   - "requires": {
87   - "chalk": "^2.0.0",
88   - "esutils": "^2.0.2",
89   - "js-tokens": "^4.0.0"
90   - }
91   - },
92   - "@babel/parser": {
93   - "version": "7.6.0",
94   - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.6.0.tgz",
95   - "integrity": "sha512-+o2q111WEx4srBs7L9eJmcwi655eD8sXniLqMB93TBK9GrNzGrxDWSjiqz2hLU0Ha8MTXFIP0yd9fNdP+m43ZQ=="
96   - },
97   - "@babel/template": {
98   - "version": "7.6.0",
99   - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.6.0.tgz",
100   - "integrity": "sha512-5AEH2EXD8euCk446b7edmgFdub/qfH1SN6Nii3+fyXP807QRx9Q73A2N5hNwRRslC2H9sNzaFhsPubkS4L8oNQ==",
101   - "requires": {
102   - "@babel/code-frame": "^7.0.0",
103   - "@babel/parser": "^7.6.0",
104   - "@babel/types": "^7.6.0"
105   - }
106   - },
107   - "@babel/traverse": {
108   - "version": "7.6.0",
109   - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.6.0.tgz",
110   - "integrity": "sha512-93t52SaOBgml/xY74lsmt7xOR4ufYvhb5c5qiM6lu4J/dWGMAfAh6eKw4PjLes6DI6nQgearoxnFJk60YchpvQ==",
111   - "requires": {
112   - "@babel/code-frame": "^7.5.5",
113   - "@babel/generator": "^7.6.0",
114   - "@babel/helper-function-name": "^7.1.0",
115   - "@babel/helper-split-export-declaration": "^7.4.4",
116   - "@babel/parser": "^7.6.0",
117   - "@babel/types": "^7.6.0",
118   - "debug": "^4.1.0",
119   - "globals": "^11.1.0",
120   - "lodash": "^4.17.13"
121   - }
122   - },
123   - "@babel/types": {
124   - "version": "7.6.1",
125   - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.6.1.tgz",
126   - "integrity": "sha512-X7gdiuaCmA0uRjCmRtYJNAVCc/q+5xSgsfKJHqMN4iNLILX39677fJE1O40arPMh0TTtS9ItH67yre6c7k6t0g==",
127   - "requires": {
128   - "esutils": "^2.0.2",
129   - "lodash": "^4.17.13",
130   - "to-fast-properties": "^2.0.0"
131   - }
132   - },
133 5 "@fortawesome/fontawesome-free": {
134 6 "version": "5.11.2",
135 7 "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.11.2.tgz",
136 8 "integrity": "sha512-XiUPoS79r1G7PcpnNtq85TJ7inJWe0v+b5oZJZKb0pGHNIV6+UiNeQWiFGmuQ0aj7GEhnD/v9iqxIsjuRKtEnQ=="
137 9 },
138   - "ansi-styles": {
139   - "version": "3.2.1",
140   - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
141   - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
142   - "requires": {
143   - "color-convert": "^1.9.0"
144   - }
145   - },
146   - "chalk": {
147   - "version": "2.4.2",
148   - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
149   - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
150   - "requires": {
151   - "ansi-styles": "^3.2.1",
152   - "escape-string-regexp": "^1.0.5",
153   - "supports-color": "^5.3.0"
154   - }
155   - },
156 10 "codemirror": {
157   - "version": "5.49.0",
158   - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.49.0.tgz",
159   - "integrity": "sha512-Hyzr0HToBdZpLBN9dYFO/KlJAsKH37/cXVHPAqa+imml0R92tb9AkmsvjnXL+SluEvjjdfkDgRjc65NG5jnMYA=="
160   - },
161   - "color-convert": {
162   - "version": "1.9.3",
163   - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
164   - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
165   - "requires": {
166   - "color-name": "1.1.3"
167   - }
168   - },
169   - "color-name": {
170   - "version": "1.1.3",
171   - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
172   - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
  11 + "version": "5.49.2",
  12 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.49.2.tgz",
  13 + "integrity": "sha512-dwJ2HRPHm8w51WB5YTF9J7m6Z5dtkqbU9ntMZ1dqXyFB9IpjoUFDj80ahRVEoVanfIp6pfASJbOlbWdEf8FOzQ=="
173 14 },
174 15 "commander": {
175 16 "version": "3.0.1",
176 17 "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.1.tgz",
177 18 "integrity": "sha512-UNgvDd+csKdc9GD4zjtkHKQbT8Aspt2jCBqNSPp53vAS0L1tS9sXB2TCEOPHJ7kt9bN/niWkYj8T3RQSoMXdSQ=="
178 19 },
179   - "convert-source-map": {
180   - "version": "1.6.0",
181   - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.6.0.tgz",
182   - "integrity": "sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A==",
183   - "requires": {
184   - "safe-buffer": "~5.1.1"
185   - }
186   - },
187   - "debug": {
188   - "version": "4.1.1",
189   - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
190   - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
191   - "requires": {
192   - "ms": "^2.1.1"
193   - }
194   - },
195   - "escape-string-regexp": {
196   - "version": "1.0.5",
197   - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
198   - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
199   - },
200 20 "esm": {
201 21 "version": "3.2.25",
202 22 "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
203 23 "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA=="
204 24 },
205   - "esutils": {
206   - "version": "2.0.3",
207   - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
208   - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
209   - },
210   - "globals": {
211   - "version": "11.12.0",
212   - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
213   - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="
214   - },
215   - "has-flag": {
216   - "version": "3.0.0",
217   - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
218   - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
219   - },
220   - "js-tokens": {
221   - "version": "4.0.0",
222   - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
223   - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
224   - },
225   - "jsesc": {
226   - "version": "2.5.2",
227   - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
228   - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA=="
229   - },
230   - "json5": {
231   - "version": "2.1.0",
232   - "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.0.tgz",
233   - "integrity": "sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ==",
234   - "requires": {
235   - "minimist": "^1.2.0"
236   - }
237   - },
238   - "lodash": {
239   - "version": "4.17.15",
240   - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
241   - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
242   - },
243 25 "mathjax": {
244 26 "version": "3.0.0",
245 27 "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.0.0.tgz",
... ... @@ -258,56 +40,15 @@
258 40 }
259 41 },
260 42 "mdbootstrap": {
261   - "version": "4.8.10",
262   - "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.8.10.tgz",
263   - "integrity": "sha512-pUjs7Vds4J+MwepOo4obUy7bQ5aMeB8j1c3IxIcEYXOXmn8GOWMSpiRcfSXpH9R4Fgdfie++e0fm5+SebRnTYA==",
264   - "requires": {
265   - "@babel/core": "^7.3.3"
266   - }
267   - },
268   - "minimist": {
269   - "version": "1.2.0",
270   - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
271   - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
  43 + "version": "4.9.0",
  44 + "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.9.0.tgz",
  45 + "integrity": "sha512-6R3j5D9Qmp+Aa90FblOVAwVDSqpAICYW2dpNxh6uaVB9E9MCaBLdaTKLrXCB7xznReHEaA57pNABXgFoi2z7Rg=="
272 46 },
273 47 "mj-context-menu": {
274 48 "version": "0.2.0",
275 49 "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-0.2.0.tgz",
276 50 "integrity": "sha512-yJxrWBHCjFZEHsZgfs7m5g9OSCNzsVYadW6f6lX3pgZL67vmodtSW/4zhsYmuDKweXfHs0M1kJge1uQIasWA+g=="
277 51 },
278   - "ms": {
279   - "version": "2.1.2",
280   - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
281   - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
282   - },
283   - "path-parse": {
284   - "version": "1.0.6",
285   - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
286   - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
287   - },
288   - "resolve": {
289   - "version": "1.12.0",
290   - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz",
291   - "integrity": "sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==",
292   - "requires": {
293   - "path-parse": "^1.0.6"
294   - }
295   - },
296   - "safe-buffer": {
297   - "version": "5.1.2",
298   - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
299   - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
300   - },
301   - "semver": {
302   - "version": "5.7.1",
303   - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
304   - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
305   - },
306   - "source-map": {
307   - "version": "0.5.7",
308   - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
309   - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
310   - },
311 52 "speech-rule-engine": {
312 53 "version": "3.0.0-beta.6",
313 54 "resolved": "https://registry.npmjs.org/speech-rule-engine/-/speech-rule-engine-3.0.0-beta.6.tgz",
... ... @@ -318,24 +59,6 @@
318 59 "xmldom-sre": "^0.1.31"
319 60 }
320 61 },
321   - "supports-color": {
322   - "version": "5.5.0",
323   - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
324   - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
325   - "requires": {
326   - "has-flag": "^3.0.0"
327   - }
328   - },
329   - "to-fast-properties": {
330   - "version": "2.0.0",
331   - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
332   - "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4="
333   - },
334   - "trim-right": {
335   - "version": "1.0.1",
336   - "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz",
337   - "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM="
338   - },
339 62 "wicked-good-xpath": {
340 63 "version": "1.3.0",
341 64 "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz",
... ...
package.json
... ... @@ -3,9 +3,9 @@
3 3 "email": "mjsb@uevora.pt",
4 4 "dependencies": {
5 5 "@fortawesome/fontawesome-free": "^5.11.2",
6   - "codemirror": "^5.49.0",
  6 + "codemirror": "^5.49.2",
7 7 "mathjax": "^3",
8   - "mdbootstrap": "^4.8.10"
  8 + "mdbootstrap": "^4.9.0"
9 9 },
10 10 "private": true
11 11 }
... ...