Commit d5cd0d100041fcdcb912c0dd98a4365f41e7665c
1 parent
2b30310a
Exists in
master
and in
1 other branch
- added options 'choose', 'shuffle' and 'file' in the course yaml configuration.
- question factories moved out of the graph. Now they are in a dict where the key is the question ref. - construction of the graph and questions factory was separated into two different functions. - code cleanup.
Showing
5 changed files
with
111 additions
and
121 deletions
Show diff stats
BUGS.md
| 1 | 1 | |
| 2 | 2 | # BUGS |
| 3 | 3 | |
| 4 | -- na definicao dos topicos, indicar: | |
| 5 | - "file: questions.yaml" (default questions.yaml) | |
| 6 | - "shuffle: True/False" (default False) | |
| 7 | - "choose: 6" (default tudo) | |
| 4 | +- default prefix should be obtained from each course (yaml conf)? | |
| 8 | 5 | - quando a pergunta devolve comments, este é apresentado, mas fica persistente nas tentativas seguintes. devia ser limpo apos a segunda submissao. |
| 9 | 6 | - a opcao max_tries na especificacao das perguntas é cumbersome... usar antes tries? |
| 10 | 7 | - tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question. |
| ... | ... | @@ -32,7 +29,10 @@ |
| 32 | 29 | - normalizar com perguntations. |
| 33 | 30 | |
| 34 | 31 | # FIXED |
| 35 | - | |
| 32 | +- na definicao dos topicos, indicar: | |
| 33 | + "file: questions.yaml" (default questions.yaml) | |
| 34 | + "shuffle: True/False" (default False) | |
| 35 | + "choose: 6" (default tudo) | |
| 36 | 36 | - max tries não avança para seguinte ao fim das tentativas. |
| 37 | 37 | - ver se ref guardada na BD é só a da pergunta ou inclui o path. -> so ref |
| 38 | 38 | - nao esta a guardar as respostas erradas. | ... | ... |
knowledge.py
| ... | ... | @@ -24,15 +24,16 @@ class StudentKnowledge(object): |
| 24 | 24 | # ======================================================================= |
| 25 | 25 | # methods that update state |
| 26 | 26 | # ======================================================================= |
| 27 | - def __init__(self, deps, state={}): | |
| 27 | + def __init__(self, deps, factory, state={}): | |
| 28 | 28 | self.deps = deps # dependency graph shared among students |
| 29 | + self.factory = factory # question factory | |
| 29 | 30 | self.state = state # {'topic': {'level':0.5, 'date': datetime}, ...} |
| 30 | 31 | |
| 31 | 32 | self.update_topic_levels() # applies forgetting factor |
| 32 | 33 | self.topic_sequence = self.recommend_topic_sequence() # ['a', 'b', ...] |
| 33 | 34 | self.unlock_topics() # whose dependencies have been completed |
| 34 | 35 | self.current_topic = None |
| 35 | - # self.MAX_QUESTIONS = deps.graph['config'].get('choose', None) | |
| 36 | + | |
| 36 | 37 | |
| 37 | 38 | # ------------------------------------------------------------------------ |
| 38 | 39 | # Updates the proficiency levels of the topics, with forgetting factor |
| ... | ... | @@ -40,9 +41,9 @@ class StudentKnowledge(object): |
| 40 | 41 | # ------------------------------------------------------------------------ |
| 41 | 42 | def update_topic_levels(self): |
| 42 | 43 | now = datetime.now() |
| 43 | - for s in self.state.values(): | |
| 44 | + for tref, s in self.state.items(): | |
| 44 | 45 | dt = now - s['date'] |
| 45 | - s['level'] *= 0.95 ** dt.days # forgetting factor 0.95 | |
| 46 | + s['level'] *= 0.95 ** dt.days # forgetting factor 0.95 FIXME | |
| 46 | 47 | |
| 47 | 48 | |
| 48 | 49 | # ------------------------------------------------------------------------ |
| ... | ... | @@ -80,25 +81,21 @@ class StudentKnowledge(object): |
| 80 | 81 | |
| 81 | 82 | # starting new topic |
| 82 | 83 | self.current_topic = topic |
| 83 | - | |
| 84 | - factory = self.deps.node[topic]['factory'] | |
| 85 | - questionlist = self.deps.node[topic]['questions'] | |
| 86 | - | |
| 87 | 84 | self.correct_answers = 0 |
| 88 | 85 | self.wrong_answers = 0 |
| 89 | 86 | |
| 90 | - # select a random set of questions for this topic | |
| 91 | - size = len(questionlist) # number of questions FIXME get from topic config | |
| 92 | - questionlist = random.sample(questionlist, k=size) | |
| 93 | - logger.debug(f'Questions: {", ".join(questionlist)}') | |
| 87 | + t = self.deps.node[topic] | |
| 88 | + questions = random.sample(t['questions'], k=t['choose']) | |
| 89 | + logger.debug(f'Questions: {", ".join(questions)}') | |
| 94 | 90 | |
| 95 | 91 | # generate instances of questions |
| 96 | - self.questions = [factory[qref].generate() for qref in questionlist] | |
| 92 | + self.questions = [self.factory[qref].generate() for qref in questions] | |
| 97 | 93 | logger.debug(f'Total: {len(self.questions)} questions') |
| 98 | 94 | |
| 99 | 95 | # get first question |
| 100 | 96 | self.next_question() |
| 101 | 97 | |
| 98 | + | |
| 102 | 99 | # ------------------------------------------------------------------------ |
| 103 | 100 | # The topic has finished and there are no more questions. |
| 104 | 101 | # The topic level is updated in state and unlocks are performed. |
| ... | ... | @@ -148,13 +145,14 @@ class StudentKnowledge(object): |
| 148 | 145 | # move to the next question |
| 149 | 146 | if self.current_question['tries'] <= 0: |
| 150 | 147 | logger.debug("Appending new instance of this question to the end") |
| 151 | - factory = self.deps.node[self.current_topic]['factory'] | |
| 148 | + factory = self.factory # self.deps.node[self.current_topic]['factory'] | |
| 152 | 149 | self.questions.append(factory[q['ref']].generate()) |
| 153 | 150 | self.next_question() |
| 154 | 151 | |
| 155 | 152 | # returns answered and corrected question (not new one) |
| 156 | 153 | return q |
| 157 | 154 | |
| 155 | + | |
| 158 | 156 | # ------------------------------------------------------------------------ |
| 159 | 157 | # Move to next question |
| 160 | 158 | # ------------------------------------------------------------------------ |
| ... | ... | @@ -168,25 +166,11 @@ class StudentKnowledge(object): |
| 168 | 166 | self.current_question['tries'] = self.current_question.get('max_tries', 3) # FIXME hardcoded 3 |
| 169 | 167 | logger.debug(f'Next question is "{self.current_question["ref"]}"') |
| 170 | 168 | |
| 171 | - # def next_question_in_lesson(self): | |
| 172 | - # try: | |
| 173 | - # self.current_question = self.questions.pop(0) | |
| 174 | - # except IndexError: | |
| 175 | - # self.current_question = None | |
| 176 | - # else: | |
| 177 | - # logger.debug(f'Next question is "{self.current_question["ref"]}"') | |
| 178 | - | |
| 179 | 169 | |
| 180 | 170 | # ======================================================================== |
| 181 | 171 | # pure functions of the state (no side effects) |
| 182 | 172 | # ======================================================================== |
| 183 | 173 | |
| 184 | - def get_max_questions(self, topic=None): | |
| 185 | - if topic is not None: | |
| 186 | - node = self.deps.nodes[topic] | |
| 187 | - max_questions = node.get('choose', len(node['questions'])) | |
| 188 | - | |
| 189 | - | |
| 190 | 174 | # ------------------------------------------------------------------------ |
| 191 | 175 | # compute recommended sequence of topics ['a', 'b', ...] |
| 192 | 176 | # ------------------------------------------------------------------------ |
| ... | ... | @@ -211,6 +195,7 @@ class StudentKnowledge(object): |
| 211 | 195 | # Topics unlocked but not yet done have level 0.0. |
| 212 | 196 | # ------------------------------------------------------------------------ |
| 213 | 197 | def get_knowledge_state(self): |
| 198 | + # print(self.topic_sequence) | |
| 214 | 199 | return [{ |
| 215 | 200 | 'ref': ref, |
| 216 | 201 | 'type': self.deps.nodes[ref]['type'], | ... | ... |
learnapp.py
| ... | ... | @@ -21,10 +21,6 @@ from tools import load_yaml |
| 21 | 21 | # setup logger for this module |
| 22 | 22 | logger = logging.getLogger(__name__) |
| 23 | 23 | |
| 24 | -# ============================================================================ | |
| 25 | -# class LearnAppException(Exception): | |
| 26 | -# pass | |
| 27 | - | |
| 28 | 24 | |
| 29 | 25 | # ============================================================================ |
| 30 | 26 | # helper functions |
| ... | ... | @@ -69,6 +65,7 @@ class LearnApp(object): |
| 69 | 65 | for c in config_files: |
| 70 | 66 | self.populate_graph(c) |
| 71 | 67 | |
| 68 | + self.build_factory() # for all questions of all topics | |
| 72 | 69 | self.db_add_missing_topics(self.deps.nodes()) |
| 73 | 70 | |
| 74 | 71 | # ------------------------------------------------------------------------ |
| ... | ... | @@ -103,7 +100,7 @@ class LearnApp(object): |
| 103 | 100 | self.online[uid] = { |
| 104 | 101 | 'number': uid, |
| 105 | 102 | 'name': name, |
| 106 | - 'state': StudentKnowledge(self.deps, state=state), | |
| 103 | + 'state': StudentKnowledge(deps=self.deps, factory=self.factory, state=state), | |
| 107 | 104 | } |
| 108 | 105 | |
| 109 | 106 | else: |
| ... | ... | @@ -232,12 +229,89 @@ class LearnApp(object): |
| 232 | 229 | logger.info(f'{m:6} topics') |
| 233 | 230 | logger.info(f'{q:6} answers') |
| 234 | 231 | |
| 232 | + # ============================================================================ | |
| 233 | + # Populates a digraph. | |
| 234 | + # | |
| 235 | + # Nodes are the topic references e.g. 'my/topic' | |
| 236 | + # g.node['my/topic']['name'] name of the topic | |
| 237 | + # g.node['my/topic']['questions'] list of question refs | |
| 238 | + # | |
| 239 | + # Edges are obtained from the deps defined in the YAML file for each topic. | |
| 240 | + # ------------------------------------------------------------------------ | |
| 241 | + def populate_graph(self, conffile): | |
| 242 | + logger.info(f'Populating graph from: {conffile}') | |
| 243 | + config = load_yaml(conffile) # course configuration | |
| 244 | + | |
| 245 | + # default attributes that apply to the topics | |
| 246 | + default_file = config.get('file', 'questions.yaml') | |
| 247 | + default_shuffle = config.get('shuffle', True) | |
| 248 | + default_choose = config.get('choose', 9999) | |
| 249 | + default_forgetting_factor = config.get('forgetting_factor', 1.0) | |
| 250 | + | |
| 251 | + # iterate over topics and populate graph | |
| 252 | + topics = config.get('topics', {}) | |
| 253 | + g = self.deps # the dependency graph | |
| 254 | + | |
| 255 | + for tref, attr in topics.items(): | |
| 256 | + # g.add_node(tref) | |
| 257 | + g.add_edges_from((d,tref) for d in attr.get('deps', [])) | |
| 258 | + | |
| 259 | + t = g.node[tref] # current topic node | |
| 260 | + t['type'] = attr.get('type', 'topic') | |
| 261 | + t['name'] = attr.get('name', tref) | |
| 262 | + t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic | |
| 263 | + t['file'] = attr.get('file', default_file) # questions.yaml | |
| 264 | + t['shuffle'] = attr.get('shuffle', default_shuffle) | |
| 265 | + t['forgetting_factor'] = attr.get('forgetting_factor', default_forgetting_factor) | |
| 266 | + t['choose'] = attr.get('choose', default_choose) | |
| 267 | + t['questions'] = attr.get('questions', []) | |
| 268 | + | |
| 269 | + logger.info(f'Loaded {g.number_of_nodes()} topics') | |
| 270 | + | |
| 271 | + | |
| 272 | + # ------------------------------------------------------------------------ | |
| 273 | + # Buils dictionary of question factories | |
| 274 | + # ------------------------------------------------------------------------ | |
| 275 | + def build_factory(self): | |
| 276 | + logger.info('Building questions factory') | |
| 277 | + self.factory = {} # {'qref': QFactory()} | |
| 278 | + g = self.deps | |
| 279 | + for tref in g.nodes(): | |
| 280 | + t = g.node[tref] | |
| 281 | + | |
| 282 | + # load questions as list of dicts | |
| 283 | + topicpath = path.join(g.graph['prefix'], tref) | |
| 284 | + questions = load_yaml(path.join(topicpath, t['file']), default=[]) | |
| 285 | + | |
| 286 | + # update refs to include topic as prefix. | |
| 287 | + # refs are required to be unique only within the file. | |
| 288 | + # undefined are set to topic:n, where n is the question number | |
| 289 | + # within the file | |
| 290 | + for i, q in enumerate(questions): | |
| 291 | + qref = q.get('ref', str(i)) # ref or number | |
| 292 | + q['ref'] = tref + ':' + qref | |
| 293 | + q['path'] = topicpath | |
| 294 | + | |
| 295 | + # if questions are left undefined, include all. | |
| 296 | + if not t['questions']: | |
| 297 | + t['questions'] = [q['ref'] for q in questions] | |
| 298 | + | |
| 299 | + t['choose'] = min(t['choose'], len(t['questions'])) | |
| 300 | + | |
| 301 | + for q in questions: | |
| 302 | + if q['ref'] in t['questions']: | |
| 303 | + self.factory[q['ref']] = QFactory(q) | |
| 304 | + | |
| 305 | + logger.info(f'{len(t["questions"]):6} {tref}') | |
| 306 | + | |
| 307 | + logger.info(f'Factory contains {len(self.factory)} questions') | |
| 308 | + | |
| 309 | + | |
| 235 | 310 | |
| 236 | 311 | # ======================================================================== |
| 237 | 312 | # methods that do not change state (pure functions) |
| 238 | 313 | # ======================================================================== |
| 239 | 314 | |
| 240 | - | |
| 241 | 315 | # ------------------------------------------------------------------------ |
| 242 | 316 | def get_student_name(self, uid): |
| 243 | 317 | return self.online[uid].get('name', '') |
| ... | ... | @@ -277,82 +351,6 @@ class LearnApp(object): |
| 277 | 351 | # ------------------------------------------------------------------------ |
| 278 | 352 | def get_current_public_dir(self, uid): |
| 279 | 353 | topic = self.online[uid]['state'].get_current_topic() |
| 280 | - p = self.deps.graph['prefix'] # FIXME not defined!!! | |
| 281 | - | |
| 282 | - return path.join(p, topic, 'public') | |
| 283 | - | |
| 284 | - | |
| 285 | - # ============================================================================ | |
| 286 | - # Populates a digraph. | |
| 287 | - # | |
| 288 | - # First, topics such as `computer/mips/exceptions` are added as nodes | |
| 289 | - # together with dependencies. Then, questions are loaded to a factory. | |
| 290 | - # | |
| 291 | - # g.graph['path'] base path where topic directories are located | |
| 292 | - # g.graph['title'] title defined in the configuration YAML | |
| 293 | - # g.graph['database'] sqlite3 database file to use | |
| 294 | - # | |
| 295 | - # Nodes are the topic references e.g. 'my/topic' | |
| 296 | - # g.node['my/topic']['name'] name of the topic | |
| 297 | - # g.node['my/topic']['questions'] list of question refs defined in YAML | |
| 298 | - # g.node['my/topic']['factory'] dict with question factories | |
| 299 | - # | |
| 300 | - # Edges are obtained from the deps defined in the YAML file for each topic. | |
| 301 | - # ---------------------------------------------------------------------------- | |
| 302 | - def populate_graph(self, conffile): | |
| 303 | - logger.info(f'Loading {conffile} and populating graph:') | |
| 304 | - g = self.deps # the graph | |
| 305 | - config = load_yaml(conffile) # course configuration | |
| 306 | - | |
| 307 | - # default attributes that apply to the topics | |
| 308 | - default_file = config.get('file', 'questions.yaml') | |
| 309 | - default_shuffle = config.get('shuffle', True) | |
| 310 | - default_choose = config.get('choose', 9999) | |
| 311 | - default_forgetting_factor = config.get('forgetting_factor', 1.0) | |
| 312 | - | |
| 313 | - # iterate over topics and populate graph | |
| 314 | - topics = config.get('topics', {}) | |
| 315 | - tcount = qcount = 0 # topic and question counters | |
| 316 | - for tref, attr in topics.items(): | |
| 317 | - if tref in g: | |
| 318 | - logger.error(f'--> Topic {tref} already exists. Skipped.') | |
| 319 | - continue | |
| 320 | - | |
| 321 | - # add topic to the graph | |
| 322 | - g.add_node(tref) | |
| 323 | - t = g.node[tref] # current topic node | |
| 324 | - | |
| 325 | - topicpath = path.join(g.graph['prefix'], tref) | |
| 326 | - | |
| 327 | - t['type'] = attr.get('type', 'topic') | |
| 328 | - t['name'] = attr.get('name', tref) | |
| 329 | - t['path'] = topicpath # prefix/topic | |
| 330 | - t['file'] = attr.get('file', default_file) # questions.yaml | |
| 331 | - t['shuffle'] = attr.get('shuffle', default_shuffle) | |
| 332 | - t['forgetting_factor'] = attr.get('forgetting_factor', default_forgetting_factor) | |
| 333 | - g.add_edges_from((d,tref) for d in attr.get('deps', [])) | |
| 334 | - | |
| 335 | - # load questions as list of dicts | |
| 336 | - questions = load_yaml(path.join(topicpath, t['file']), default=[]) | |
| 337 | - | |
| 338 | - # if questions are left undefined, include all. | |
| 339 | - # refs undefined in questions.yaml are set to topic:n | |
| 340 | - t['questions'] = attr.get('questions', | |
| 341 | - [q.setdefault('ref', f'{tref}:{i}') for i, q in enumerate(questions)]) | |
| 342 | - | |
| 343 | - # topic will generate a certain amount of questions | |
| 344 | - t['choose'] = min(attr.get('choose', default_choose), len(t['questions'])) | |
| 345 | - | |
| 346 | - # make questions factory (without repeating same question) FIXME move to somewhere else? | |
| 347 | - t['factory'] = {} | |
| 348 | - for q in questions: | |
| 349 | - if q['ref'] in t['questions']: | |
| 350 | - q['path'] = topicpath # fullpath added to each question | |
| 351 | - t['factory'][q['ref']] = QFactory(q) | |
| 352 | - | |
| 353 | - logger.info(f'{len(t["questions"]):6} {tref}') | |
| 354 | - qcount += len(t["questions"]) # count total questions | |
| 355 | - tcount += 1 | |
| 356 | - | |
| 357 | - logger.info(f'Total loaded: {tcount} topics, {qcount} questions') | |
| 354 | + prefix = self.deps.graph['prefix'] | |
| 355 | + return path.join(prefix, topic, 'public') | |
| 358 | 356 | ... | ... |
serve.py
| ... | ... | @@ -60,6 +60,7 @@ class WebApplication(tornado.web.Application): |
| 60 | 60 | super().__init__(handlers, **settings) |
| 61 | 61 | self.learn = learnapp |
| 62 | 62 | |
| 63 | + | |
| 63 | 64 | # ============================================================================ |
| 64 | 65 | # Handlers |
| 65 | 66 | # ============================================================================ |
| ... | ... | @@ -100,6 +101,7 @@ class LoginHandler(BaseHandler): |
| 100 | 101 | else: |
| 101 | 102 | self.render("login.html", error='Número ou senha incorrectos') |
| 102 | 103 | |
| 104 | + | |
| 103 | 105 | # ---------------------------------------------------------------------------- |
| 104 | 106 | class LogoutHandler(BaseHandler): |
| 105 | 107 | @tornado.web.authenticated |
| ... | ... | @@ -110,6 +112,7 @@ class LogoutHandler(BaseHandler): |
| 110 | 112 | def on_finish(self): |
| 111 | 113 | self.learn.logout(self.current_user) |
| 112 | 114 | |
| 115 | + | |
| 113 | 116 | # ---------------------------------------------------------------------------- |
| 114 | 117 | class ChangePasswordHandler(BaseHandler): |
| 115 | 118 | @tornado.web.authenticated |
| ... | ... | @@ -124,6 +127,7 @@ class ChangePasswordHandler(BaseHandler): |
| 124 | 127 | notification = tornado.escape.to_unicode(self.render_string('notification.html', type='danger', msg='A password não foi alterada!')) |
| 125 | 128 | self.write({'msg': notification}) |
| 126 | 129 | |
| 130 | + | |
| 127 | 131 | # ---------------------------------------------------------------------------- |
| 128 | 132 | # Main page: / |
| 129 | 133 | # Shows a list of topics and proficiency (stars, locked). |
| ... | ... | @@ -140,6 +144,7 @@ class RootHandler(BaseHandler): |
| 140 | 144 | # get_topic_type=self.learn.get_topic_type, # function |
| 141 | 145 | ) |
| 142 | 146 | |
| 147 | + | |
| 143 | 148 | # ---------------------------------------------------------------------------- |
| 144 | 149 | # Start a given topic: /topic/... |
| 145 | 150 | # ---------------------------------------------------------------------------- |
| ... | ... | @@ -286,7 +291,9 @@ class QuestionHandler(BaseHandler): |
| 286 | 291 | logger.error(f'Unknown action {action}') |
| 287 | 292 | |
| 288 | 293 | |
| 289 | -# ------------------------------------------------------------------------- | |
| 294 | +# ---------------------------------------------------------------------------- | |
| 295 | +# Signal handler to catch Ctrl-C and abort server | |
| 296 | +# ---------------------------------------------------------------------------- | |
| 290 | 297 | def signal_handler(signal, frame): |
| 291 | 298 | r = input(' --> Stop webserver? (yes/no) ').lower() |
| 292 | 299 | if r == 'yes': | ... | ... |
tools.py
| ... | ... | @@ -139,11 +139,11 @@ def load_yaml(filename, default=None): |
| 139 | 139 | try: |
| 140 | 140 | f = open(filename, 'r', encoding='utf-8') |
| 141 | 141 | except FileNotFoundError: |
| 142 | - logger.error(f'Can\'t open "{filename}": not found') | |
| 142 | + logger.error(f'Cannot open "{filename}": not found') | |
| 143 | 143 | except PermissionError: |
| 144 | - logger.error(f'Can\'t open "{filename}": no permission') | |
| 144 | + logger.error(f'Cannot open "{filename}": no permission') | |
| 145 | 145 | except IOError: |
| 146 | - logger.error(f'Can\'t open file "{filename}"') | |
| 146 | + logger.error(f'Cannot open file "{filename}"') | |
| 147 | 147 | else: |
| 148 | 148 | with f: |
| 149 | 149 | try: | ... | ... |