Commit e8e453f8ecc1d25e9c555057b0596f94b05a9ce0
1 parent
3dd184c9
Exists in
master
and in
1 other branch
- fixed to work with multiple courses.
- set supported versions of python packages. - changes key 'topics' to 'goals' when setting a course. - fix use of deprecated 'g.node[]' from networkx.
Showing
6 changed files
with
58 additions
and
55 deletions
Show diff stats
.gitignore
| @@ -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 |
BUGS.md
| 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/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 |
setup.py
| @@ -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': [ |