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: | ... | ... |