Commit d5cd0d100041fcdcb912c0dd98a4365f41e7665c

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

- 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.
1 1
2 # BUGS 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 - quando a pergunta devolve comments, este é apresentado, mas fica persistente nas tentativas seguintes. devia ser limpo apos a segunda submissao. 5 - quando a pergunta devolve comments, este é apresentado, mas fica persistente nas tentativas seguintes. devia ser limpo apos a segunda submissao.
9 - a opcao max_tries na especificacao das perguntas é cumbersome... usar antes tries? 6 - a opcao max_tries na especificacao das perguntas é cumbersome... usar antes tries?
10 - tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question. 7 - tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question.
@@ -32,7 +29,10 @@ @@ -32,7 +29,10 @@
32 - normalizar com perguntations. 29 - normalizar com perguntations.
33 30
34 # FIXED 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 - max tries não avança para seguinte ao fim das tentativas. 36 - max tries não avança para seguinte ao fim das tentativas.
37 - ver se ref guardada na BD é só a da pergunta ou inclui o path. -> so ref 37 - ver se ref guardada na BD é só a da pergunta ou inclui o path. -> so ref
38 - nao esta a guardar as respostas erradas. 38 - nao esta a guardar as respostas erradas.
@@ -24,15 +24,16 @@ class StudentKnowledge(object): @@ -24,15 +24,16 @@ class StudentKnowledge(object):
24 # ======================================================================= 24 # =======================================================================
25 # methods that update state 25 # methods that update state
26 # ======================================================================= 26 # =======================================================================
27 - def __init__(self, deps, state={}): 27 + def __init__(self, deps, factory, state={}):
28 self.deps = deps # dependency graph shared among students 28 self.deps = deps # dependency graph shared among students
  29 + self.factory = factory # question factory
29 self.state = state # {'topic': {'level':0.5, 'date': datetime}, ...} 30 self.state = state # {'topic': {'level':0.5, 'date': datetime}, ...}
30 31
31 self.update_topic_levels() # applies forgetting factor 32 self.update_topic_levels() # applies forgetting factor
32 self.topic_sequence = self.recommend_topic_sequence() # ['a', 'b', ...] 33 self.topic_sequence = self.recommend_topic_sequence() # ['a', 'b', ...]
33 self.unlock_topics() # whose dependencies have been completed 34 self.unlock_topics() # whose dependencies have been completed
34 self.current_topic = None 35 self.current_topic = None
35 - # self.MAX_QUESTIONS = deps.graph['config'].get('choose', None) 36 +
36 37
37 # ------------------------------------------------------------------------ 38 # ------------------------------------------------------------------------
38 # Updates the proficiency levels of the topics, with forgetting factor 39 # Updates the proficiency levels of the topics, with forgetting factor
@@ -40,9 +41,9 @@ class StudentKnowledge(object): @@ -40,9 +41,9 @@ class StudentKnowledge(object):
40 # ------------------------------------------------------------------------ 41 # ------------------------------------------------------------------------
41 def update_topic_levels(self): 42 def update_topic_levels(self):
42 now = datetime.now() 43 now = datetime.now()
43 - for s in self.state.values(): 44 + for tref, s in self.state.items():
44 dt = now - s['date'] 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,25 +81,21 @@ class StudentKnowledge(object):
80 81
81 # starting new topic 82 # starting new topic
82 self.current_topic = topic 83 self.current_topic = topic
83 -  
84 - factory = self.deps.node[topic]['factory']  
85 - questionlist = self.deps.node[topic]['questions']  
86 -  
87 self.correct_answers = 0 84 self.correct_answers = 0
88 self.wrong_answers = 0 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 # generate instances of questions 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 logger.debug(f'Total: {len(self.questions)} questions') 93 logger.debug(f'Total: {len(self.questions)} questions')
98 94
99 # get first question 95 # get first question
100 self.next_question() 96 self.next_question()
101 97
  98 +
102 # ------------------------------------------------------------------------ 99 # ------------------------------------------------------------------------
103 # The topic has finished and there are no more questions. 100 # The topic has finished and there are no more questions.
104 # The topic level is updated in state and unlocks are performed. 101 # The topic level is updated in state and unlocks are performed.
@@ -148,13 +145,14 @@ class StudentKnowledge(object): @@ -148,13 +145,14 @@ class StudentKnowledge(object):
148 # move to the next question 145 # move to the next question
149 if self.current_question['tries'] <= 0: 146 if self.current_question['tries'] <= 0:
150 logger.debug("Appending new instance of this question to the end") 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 self.questions.append(factory[q['ref']].generate()) 149 self.questions.append(factory[q['ref']].generate())
153 self.next_question() 150 self.next_question()
154 151
155 # returns answered and corrected question (not new one) 152 # returns answered and corrected question (not new one)
156 return q 153 return q
157 154
  155 +
158 # ------------------------------------------------------------------------ 156 # ------------------------------------------------------------------------
159 # Move to next question 157 # Move to next question
160 # ------------------------------------------------------------------------ 158 # ------------------------------------------------------------------------
@@ -168,25 +166,11 @@ class StudentKnowledge(object): @@ -168,25 +166,11 @@ class StudentKnowledge(object):
168 self.current_question['tries'] = self.current_question.get('max_tries', 3) # FIXME hardcoded 3 166 self.current_question['tries'] = self.current_question.get('max_tries', 3) # FIXME hardcoded 3
169 logger.debug(f'Next question is "{self.current_question["ref"]}"') 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 # pure functions of the state (no side effects) 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 # compute recommended sequence of topics ['a', 'b', ...] 175 # compute recommended sequence of topics ['a', 'b', ...]
192 # ------------------------------------------------------------------------ 176 # ------------------------------------------------------------------------
@@ -211,6 +195,7 @@ class StudentKnowledge(object): @@ -211,6 +195,7 @@ class StudentKnowledge(object):
211 # Topics unlocked but not yet done have level 0.0. 195 # Topics unlocked but not yet done have level 0.0.
212 # ------------------------------------------------------------------------ 196 # ------------------------------------------------------------------------
213 def get_knowledge_state(self): 197 def get_knowledge_state(self):
  198 + # print(self.topic_sequence)
214 return [{ 199 return [{
215 'ref': ref, 200 'ref': ref,
216 'type': self.deps.nodes[ref]['type'], 201 'type': self.deps.nodes[ref]['type'],
@@ -21,10 +21,6 @@ from tools import load_yaml @@ -21,10 +21,6 @@ from tools import load_yaml
21 # setup logger for this module 21 # setup logger for this module
22 logger = logging.getLogger(__name__) 22 logger = logging.getLogger(__name__)
23 23
24 -# ============================================================================  
25 -# class LearnAppException(Exception):  
26 -# pass  
27 -  
28 24
29 # ============================================================================ 25 # ============================================================================
30 # helper functions 26 # helper functions
@@ -69,6 +65,7 @@ class LearnApp(object): @@ -69,6 +65,7 @@ class LearnApp(object):
69 for c in config_files: 65 for c in config_files:
70 self.populate_graph(c) 66 self.populate_graph(c)
71 67
  68 + self.build_factory() # for all questions of all topics
72 self.db_add_missing_topics(self.deps.nodes()) 69 self.db_add_missing_topics(self.deps.nodes())
73 70
74 # ------------------------------------------------------------------------ 71 # ------------------------------------------------------------------------
@@ -103,7 +100,7 @@ class LearnApp(object): @@ -103,7 +100,7 @@ class LearnApp(object):
103 self.online[uid] = { 100 self.online[uid] = {
104 'number': uid, 101 'number': uid,
105 'name': name, 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 else: 106 else:
@@ -232,12 +229,89 @@ class LearnApp(object): @@ -232,12 +229,89 @@ class LearnApp(object):
232 logger.info(f'{m:6} topics') 229 logger.info(f'{m:6} topics')
233 logger.info(f'{q:6} answers') 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 # methods that do not change state (pure functions) 312 # methods that do not change state (pure functions)
238 # ======================================================================== 313 # ========================================================================
239 314
240 -  
241 # ------------------------------------------------------------------------ 315 # ------------------------------------------------------------------------
242 def get_student_name(self, uid): 316 def get_student_name(self, uid):
243 return self.online[uid].get('name', '') 317 return self.online[uid].get('name', '')
@@ -277,82 +351,6 @@ class LearnApp(object): @@ -277,82 +351,6 @@ class LearnApp(object):
277 # ------------------------------------------------------------------------ 351 # ------------------------------------------------------------------------
278 def get_current_public_dir(self, uid): 352 def get_current_public_dir(self, uid):
279 topic = self.online[uid]['state'].get_current_topic() 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
@@ -60,6 +60,7 @@ class WebApplication(tornado.web.Application): @@ -60,6 +60,7 @@ class WebApplication(tornado.web.Application):
60 super().__init__(handlers, **settings) 60 super().__init__(handlers, **settings)
61 self.learn = learnapp 61 self.learn = learnapp
62 62
  63 +
63 # ============================================================================ 64 # ============================================================================
64 # Handlers 65 # Handlers
65 # ============================================================================ 66 # ============================================================================
@@ -100,6 +101,7 @@ class LoginHandler(BaseHandler): @@ -100,6 +101,7 @@ class LoginHandler(BaseHandler):
100 else: 101 else:
101 self.render("login.html", error='Número ou senha incorrectos') 102 self.render("login.html", error='Número ou senha incorrectos')
102 103
  104 +
103 # ---------------------------------------------------------------------------- 105 # ----------------------------------------------------------------------------
104 class LogoutHandler(BaseHandler): 106 class LogoutHandler(BaseHandler):
105 @tornado.web.authenticated 107 @tornado.web.authenticated
@@ -110,6 +112,7 @@ class LogoutHandler(BaseHandler): @@ -110,6 +112,7 @@ class LogoutHandler(BaseHandler):
110 def on_finish(self): 112 def on_finish(self):
111 self.learn.logout(self.current_user) 113 self.learn.logout(self.current_user)
112 114
  115 +
113 # ---------------------------------------------------------------------------- 116 # ----------------------------------------------------------------------------
114 class ChangePasswordHandler(BaseHandler): 117 class ChangePasswordHandler(BaseHandler):
115 @tornado.web.authenticated 118 @tornado.web.authenticated
@@ -124,6 +127,7 @@ class ChangePasswordHandler(BaseHandler): @@ -124,6 +127,7 @@ class ChangePasswordHandler(BaseHandler):
124 notification = tornado.escape.to_unicode(self.render_string('notification.html', type='danger', msg='A password não foi alterada!')) 127 notification = tornado.escape.to_unicode(self.render_string('notification.html', type='danger', msg='A password não foi alterada!'))
125 self.write({'msg': notification}) 128 self.write({'msg': notification})
126 129
  130 +
127 # ---------------------------------------------------------------------------- 131 # ----------------------------------------------------------------------------
128 # Main page: / 132 # Main page: /
129 # Shows a list of topics and proficiency (stars, locked). 133 # Shows a list of topics and proficiency (stars, locked).
@@ -140,6 +144,7 @@ class RootHandler(BaseHandler): @@ -140,6 +144,7 @@ class RootHandler(BaseHandler):
140 # get_topic_type=self.learn.get_topic_type, # function 144 # get_topic_type=self.learn.get_topic_type, # function
141 ) 145 )
142 146
  147 +
143 # ---------------------------------------------------------------------------- 148 # ----------------------------------------------------------------------------
144 # Start a given topic: /topic/... 149 # Start a given topic: /topic/...
145 # ---------------------------------------------------------------------------- 150 # ----------------------------------------------------------------------------
@@ -286,7 +291,9 @@ class QuestionHandler(BaseHandler): @@ -286,7 +291,9 @@ class QuestionHandler(BaseHandler):
286 logger.error(f'Unknown action {action}') 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 def signal_handler(signal, frame): 297 def signal_handler(signal, frame):
291 r = input(' --> Stop webserver? (yes/no) ').lower() 298 r = input(' --> Stop webserver? (yes/no) ').lower()
292 if r == 'yes': 299 if r == 'yes':
@@ -139,11 +139,11 @@ def load_yaml(filename, default=None): @@ -139,11 +139,11 @@ def load_yaml(filename, default=None):
139 try: 139 try:
140 f = open(filename, 'r', encoding='utf-8') 140 f = open(filename, 'r', encoding='utf-8')
141 except FileNotFoundError: 141 except FileNotFoundError:
142 - logger.error(f'Can\'t open "{filename}": not found') 142 + logger.error(f'Cannot open "{filename}": not found')
143 except PermissionError: 143 except PermissionError:
144 - logger.error(f'Can\'t open "{filename}": no permission') 144 + logger.error(f'Cannot open "{filename}": no permission')
145 except IOError: 145 except IOError:
146 - logger.error(f'Can\'t open file "{filename}"') 146 + logger.error(f'Cannot open file "{filename}"')
147 else: 147 else:
148 with f: 148 with f:
149 try: 149 try: