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