Commit 289991dc23e4ce5d43a196d5da043499ee202fc9

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

- several fixes, including fixed use of deprecated networkx functions.

- supports several courses.
- replaced 'topics' by 'goals' in the courses.yaml definition.
@@ -3,4 +3,7 @@ @@ -3,4 +3,7 @@
3 /aprendizations/__pycache__/ 3 /aprendizations/__pycache__/
4 /demo/students.db 4 /demo/students.db
5 /node_modules/ 5 /node_modules/
6 -/.mypy_cache/  
7 \ No newline at end of file 6 \ No newline at end of file
  7 +/.mypy_cache/
  8 +.DS_Store
  9 +demo/.DS_Store
  10 +demo/solar_system/.DS_Store
1 1
2 # BUGS 2 # BUGS
3 3
4 -- não suporta definition lists em markdown 4 +- obter rankings por curso GET course=course_id
  5 +- indicar qtos topicos faltam (>=50%) para terminar o curso.
  6 +- permite definir goal, mas nao verifica se esta no grafo. rebenta no start_topic.
5 - double click submits twice. 7 - double click submits twice.
6 - nao esta a seguir o max_tries definido no ficheiro de dependencias. 8 - nao esta a seguir o max_tries definido no ficheiro de dependencias.
7 -- obter rankings por curso GET course=course_id  
8 - impedir que quando students.db não é encontrado, crie um ficheiro vazio. 9 - impedir que quando students.db não é encontrado, crie um ficheiro vazio.
9 - classificacoes so devia mostrar os que ja fizeram alguma coisa 10 - classificacoes so devia mostrar os que ja fizeram alguma coisa
10 - QFactory.generate() devia fazer run da gen_async, ou remover. 11 - QFactory.generate() devia fazer run da gen_async, ou remover.
aprendizations/__init__.py
@@ -30,7 +30,7 @@ are progressively uncovered as the students progress. @@ -30,7 +30,7 @@ are progressively uncovered as the students progress.
30 ''' 30 '''
31 31
32 APP_NAME = 'aprendizations' 32 APP_NAME = 'aprendizations'
33 -APP_VERSION = '2019.07.dev3' 33 +APP_VERSION = '2019.10.dev1'
34 APP_DESCRIPTION = __doc__ 34 APP_DESCRIPTION = __doc__
35 35
36 __author__ = 'Miguel Barão' 36 __author__ = 'Miguel Barão'
aprendizations/learnapp.py
@@ -67,18 +67,24 @@ class LearnApp(object): @@ -67,18 +67,24 @@ class LearnApp(object):
67 67
68 config: Dict[str, Any] = load_yaml(courses) 68 config: Dict[str, Any] = load_yaml(courses)
69 69
70 - # courses dict 70 + # --- courses dict
71 self.courses = config['courses'] 71 self.courses = config['courses']
72 logger.info(f'Courses: {", ".join(self.courses.keys())}') 72 logger.info(f'Courses: {", ".join(self.courses.keys())}')
73 73
74 - # topic dependencies are shared between all courses 74 + # --- topic dependencies are shared between all courses
75 self.deps = nx.DiGraph(prefix=prefix) 75 self.deps = nx.DiGraph(prefix=prefix)
76 logger.info('Populating graph:') 76 logger.info('Populating graph:')
77 - for f in config['deps']:  
78 - self.populate_graph(f) 77 +
  78 + t = config.get('topics', {}) # topics defined directly in courses file
  79 + self.populate_graph(t)
  80 + logger.info(f'{len(t):>6} topics in {courses}')
  81 + for f in config.get('topics_from', []):
  82 + c = load_yaml(f) # course configuration
  83 + logger.info(f'{len(c["topics"]):>6} topics from {f}')
  84 + self.populate_graph(c)
79 logger.info(f'Graph has {len(self.deps)} topics') 85 logger.info(f'Graph has {len(self.deps)} topics')
80 86
81 - # factory is a dict with question generators for all topics 87 + # --- factory is a dict with question generators for all topics
82 self.factory: Dict[str, QFactory] = self.make_factory() 88 self.factory: Dict[str, QFactory] = self.make_factory()
83 89
84 # if graph has topics that are not in the database, add them 90 # if graph has topics that are not in the database, add them
@@ -322,29 +328,30 @@ class LearnApp(object): @@ -322,29 +328,30 @@ class LearnApp(object):
322 # Populates a digraph. 328 # Populates a digraph.
323 # 329 #
324 # Nodes are the topic references e.g. 'my/topic' 330 # Nodes are the topic references e.g. 'my/topic'
325 - # g.node['my/topic']['name'] name of the topic  
326 - # g.node['my/topic']['questions'] list of question refs 331 + # g.nodes['my/topic']['name'] name of the topic
  332 + # g.nodes['my/topic']['questions'] list of question refs
327 # 333 #
328 # Edges are obtained from the deps defined in the YAML file for each topic. 334 # Edges are obtained from the deps defined in the YAML file for each topic.
329 # ------------------------------------------------------------------------ 335 # ------------------------------------------------------------------------
330 - def populate_graph(self, conffile: str) -> None:  
331 - config: Dict[str, Any] = load_yaml(conffile) # course configuration  
332 -  
333 - # default attributes that apply to the topics  
334 - default_file: str = config.get('file', 'questions.yaml')  
335 - default_shuffle_questions: bool = config.get('shuffle_questions', True)  
336 - default_choose: int = config.get('choose', 9999)  
337 - default_forgetting_factor: float = config.get('forgetting_factor', 1.0)  
338 - default_maxtries: int = config.get('max_tries', 1)  
339 - default_append_wrong: bool = config.get('append_wrong', True)  
340 - default_min_level: float = config.get('min_level', 0.01) # to unlock 336 + def populate_graph(self, config: Dict[str, Any]) -> None:
  337 + g = self.deps # dependency graph
  338 + defaults = {
  339 + 'type': 'topic',
  340 + 'file': 'questions.yaml',
  341 + 'shuffle_questions': True,
  342 + 'choose': 9999,
  343 + 'forgetting_factor': 1.0, # no forgetting
  344 + 'max_tries': 1, # in every question
  345 + 'append_wrong': True,
  346 + 'min_level': 0.01, # to unlock topic
  347 + }
  348 + defaults.update(config.get('defaults', {}))
341 349
342 # iterate over topics and populate graph 350 # iterate over topics and populate graph
343 topics: Dict[str, Dict] = config.get('topics', {}) 351 topics: Dict[str, Dict] = config.get('topics', {})
344 - g = self.deps # dependency graph  
345 -  
346 g.add_nodes_from(topics.keys()) 352 g.add_nodes_from(topics.keys())
347 for tref, attr in topics.items(): 353 for tref, attr in topics.items():
  354 + logger.debug(f' + {tref}...')
348 for d in attr.get('deps', []): 355 for d in attr.get('deps', []):
349 if d not in g.nodes(): 356 if d not in g.nodes():
350 logger.error(f'Topic "{tref}" depends on "{d}" but it ' 357 logger.error(f'Topic "{tref}" depends on "{d}" but it '
@@ -353,22 +360,15 @@ class LearnApp(object): @@ -353,22 +360,15 @@ class LearnApp(object):
353 else: 360 else:
354 g.add_edge(d, tref) 361 g.add_edge(d, tref)
355 362
356 - t = g.node[tref] # get current topic node  
357 - t['type'] = attr.get('type', 'topic') 363 + t = g.nodes[tref] # get current topic node
358 t['name'] = attr.get('name', tref) 364 t['name'] = attr.get('name', tref)
359 - t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic  
360 - t['file'] = attr.get('file', default_file) # questions.yaml  
361 - t['shuffle_questions'] = attr.get('shuffle_questions',  
362 - default_shuffle_questions)  
363 - t['max_tries'] = attr.get('max_tries', default_maxtries)  
364 - t['forgetting_factor'] = attr.get('forgetting_factor',  
365 - default_forgetting_factor)  
366 - t['min_level'] = attr.get('min_level', default_min_level)  
367 - t['choose'] = attr.get('choose', default_choose)  
368 - t['append_wrong'] = attr.get('append_wrong', default_append_wrong)  
369 t['questions'] = attr.get('questions', []) 365 t['questions'] = attr.get('questions', [])
370 366
371 - logger.info(f'{len(topics):>6} topics in {conffile}') 367 + for k, default in defaults.items():
  368 + t[k] = attr.get(k, default)
  369 +
  370 + t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic
  371 +
372 372
373 # ======================================================================== 373 # ========================================================================
374 # methods that do not change state (pure functions) 374 # methods that do not change state (pure functions)
@@ -378,12 +378,11 @@ class LearnApp(object): @@ -378,12 +378,11 @@ class LearnApp(object):
378 # Buils dictionary of question factories 378 # Buils dictionary of question factories
379 # ------------------------------------------------------------------------ 379 # ------------------------------------------------------------------------
380 def make_factory(self) -> Dict[str, QFactory]: 380 def make_factory(self) -> Dict[str, QFactory]:
381 -  
382 logger.info('Building questions factory:') 381 logger.info('Building questions factory:')
383 factory: Dict[str, QFactory] = {} 382 factory: Dict[str, QFactory] = {}
384 g = self.deps 383 g = self.deps
385 for tref in g.nodes(): 384 for tref in g.nodes():
386 - t = g.node[tref] 385 + t = g.nodes[tref]
387 386
388 # load questions as list of dicts 387 # load questions as list of dicts
389 topicpath: str = path.join(g.graph['prefix'], tref) 388 topicpath: str = path.join(g.graph['prefix'], tref)
@@ -457,13 +456,8 @@ class LearnApp(object): @@ -457,13 +456,8 @@ class LearnApp(object):
457 return self.online[uid]['state'].get_current_course_id() 456 return self.online[uid]['state'].get_current_course_id()
458 457
459 # ------------------------------------------------------------------------ 458 # ------------------------------------------------------------------------
460 - # def get_title(self) -> str:  
461 - # # return self.deps.graph.get('title', '')  
462 - # return self.  
463 -  
464 - # ------------------------------------------------------------------------  
465 def get_topic_name(self, ref: str) -> str: 459 def get_topic_name(self, ref: str) -> str:
466 - return self.deps.node[ref]['name'] 460 + return self.deps.nodes[ref]['name']
467 461
468 # ------------------------------------------------------------------------ 462 # ------------------------------------------------------------------------
469 def get_current_public_dir(self, uid: str) -> str: 463 def get_current_public_dir(self, uid: str) -> str:
aprendizations/static/js/topic.js
@@ -67,13 +67,13 @@ function new_question(type, question, tries, progress) { @@ -67,13 +67,13 @@ function new_question(type, question, tries, progress) {
67 }); 67 });
68 } 68 }
69 69
70 - // prevent enter submit.  
71 - //input:text, input:radio, input:checkbox  
72 - $("body").keyup(function (e) {  
73 - if (e.keyCode == 13 && e.shiftKey) {  
74 - $("#submit").click(); 70 + // disable enter in some question types (prevent submission on enter)
  71 + $("input:text, input:radio, input:checkbox").keydown(function (e) {
  72 + if (e.keyCode == 13 || e.keyCode == 169) {
  73 + e.preventDefault();
75 return false; 74 return false;
76 - }}); 75 + }
  76 + });
77 } 77 }
78 78
79 79
aprendizations/student.py
@@ -53,7 +53,7 @@ class StudentState(object): @@ -53,7 +53,7 @@ class StudentState(object):
53 else: 53 else:
54 logger.debug(f'starting course {course}') 54 logger.debug(f'starting course {course}')
55 self.current_course = course 55 self.current_course = course
56 - topics = self.courses[course]['topics'] 56 + topics = self.courses[course]['goals']
57 self.topic_sequence = self.recommend_topic_sequence(topics) 57 self.topic_sequence = self.recommend_topic_sequence(topics)
58 58
59 # ------------------------------------------------------------------------ 59 # ------------------------------------------------------------------------
@@ -79,7 +79,7 @@ class StudentState(object): @@ -79,7 +79,7 @@ class StudentState(object):
79 self.correct_answers = 0 79 self.correct_answers = 0
80 self.wrong_answers = 0 80 self.wrong_answers = 0
81 81
82 - t = self.deps.node[topic] 82 + t = self.deps.nodes[topic]
83 k = t['choose'] 83 k = t['choose']
84 if t['shuffle_questions']: 84 if t['shuffle_questions']:
85 questions = random.sample(t['questions'], k=k) 85 questions = random.sample(t['questions'], k=k)
@@ -174,7 +174,7 @@ class StudentState(object): @@ -174,7 +174,7 @@ class StudentState(object):
174 now = datetime.now() 174 now = datetime.now()
175 for tref, s in self.state.items(): 175 for tref, s in self.state.items():
176 dt = now - s['date'] 176 dt = now - s['date']
177 - forgetting_factor = self.deps.node[tref]['forgetting_factor'] 177 + forgetting_factor = self.deps.nodes[tref]['forgetting_factor']
178 s['level'] *= forgetting_factor ** dt.days # forgetting factor 178 s['level'] *= forgetting_factor ** dt.days # forgetting factor
179 179
180 # ------------------------------------------------------------------------ 180 # ------------------------------------------------------------------------
@@ -184,7 +184,7 @@ class StudentState(object): @@ -184,7 +184,7 @@ class StudentState(object):
184 for topic in self.deps.nodes(): 184 for topic in self.deps.nodes():
185 if topic not in self.state: # if locked 185 if topic not in self.state: # if locked
186 pred = self.deps.predecessors(topic) 186 pred = self.deps.predecessors(topic)
187 - min_level = self.deps.node[topic]['min_level'] 187 + min_level = self.deps.nodes[topic]['min_level']
188 if all(d in self.state and self.state[d]['level'] > min_level 188 if all(d in self.state and self.state[d]['level'] > min_level
189 for d in pred): # all deps are greater than min_level 189 for d in pred): # all deps are greater than min_level
190 190
@@ -206,12 +206,12 @@ class StudentState(object): @@ -206,12 +206,12 @@ class StudentState(object):
206 # ------------------------------------------------------------------------ 206 # ------------------------------------------------------------------------
207 # compute recommended sequence of topics ['a', 'b', ...] 207 # compute recommended sequence of topics ['a', 'b', ...]
208 # ------------------------------------------------------------------------ 208 # ------------------------------------------------------------------------
209 - def recommend_topic_sequence(self, targets: List[str] = []) -> List[str]: 209 + def recommend_topic_sequence(self, goals: List[str] = []) -> List[str]:
210 G = self.deps 210 G = self.deps
211 - ts = set(targets)  
212 - for t in targets: 211 + ts = set(goals)
  212 + for t in goals:
213 ts.update(nx.ancestors(G, t)) 213 ts.update(nx.ancestors(G, t))
214 - 214 + # FIXME filter by level done, only level < 50% are included
215 tl = list(nx.topological_sort(G.subgraph(ts))) 215 tl = list(nx.topological_sort(G.subgraph(ts)))
216 216
217 # sort with unlocked first 217 # sort with unlocked first
@@ -20,8 +20,13 @@ setup( @@ -20,8 +20,13 @@ setup(
20 include_package_data=True, # install files from MANIFEST.in 20 include_package_data=True, # install files from MANIFEST.in
21 python_requires='>=3.7.*', 21 python_requires='>=3.7.*',
22 install_requires=[ 22 install_requires=[
23 - 'tornado', 'mistune', 'pyyaml', 'pygments', 'sqlalchemy', 'bcrypt',  
24 - 'networkx' 23 + 'tornado>=6.0',
  24 + 'mistune',
  25 + 'pyyaml>=5.1',
  26 + 'pygments',
  27 + 'sqlalchemy',
  28 + 'bcrypt>=3.1',
  29 + 'networkx>=2.4'
25 ], 30 ],
26 entry_points={ 31 entry_points={
27 'console_scripts': [ 32 'console_scripts': [