diff --git a/BUGS.md b/BUGS.md index 72ffa78..7d9e5e9 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,10 +1,7 @@ # BUGS -- na definicao dos topicos, indicar: - "file: questions.yaml" (default questions.yaml) - "shuffle: True/False" (default False) - "choose: 6" (default tudo) +- default prefix should be obtained from each course (yaml conf)? - quando a pergunta devolve comments, este é apresentado, mas fica persistente nas tentativas seguintes. devia ser limpo apos a segunda submissao. - a opcao max_tries na especificacao das perguntas é cumbersome... usar antes tries? - tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question. @@ -32,7 +29,10 @@ - normalizar com perguntations. # FIXED - +- na definicao dos topicos, indicar: + "file: questions.yaml" (default questions.yaml) + "shuffle: True/False" (default False) + "choose: 6" (default tudo) - max tries não avança para seguinte ao fim das tentativas. - ver se ref guardada na BD é só a da pergunta ou inclui o path. -> so ref - nao esta a guardar as respostas erradas. diff --git a/knowledge.py b/knowledge.py index 8dbf560..083d326 100644 --- a/knowledge.py +++ b/knowledge.py @@ -24,15 +24,16 @@ class StudentKnowledge(object): # ======================================================================= # methods that update state # ======================================================================= - def __init__(self, deps, state={}): + def __init__(self, deps, factory, state={}): self.deps = deps # dependency graph shared among students + self.factory = factory # question factory self.state = state # {'topic': {'level':0.5, 'date': datetime}, ...} self.update_topic_levels() # applies forgetting factor self.topic_sequence = self.recommend_topic_sequence() # ['a', 'b', ...] self.unlock_topics() # whose dependencies have been completed self.current_topic = None - # self.MAX_QUESTIONS = deps.graph['config'].get('choose', None) + # ------------------------------------------------------------------------ # Updates the proficiency levels of the topics, with forgetting factor @@ -40,9 +41,9 @@ class StudentKnowledge(object): # ------------------------------------------------------------------------ def update_topic_levels(self): now = datetime.now() - for s in self.state.values(): + for tref, s in self.state.items(): dt = now - s['date'] - s['level'] *= 0.95 ** dt.days # forgetting factor 0.95 + s['level'] *= 0.95 ** dt.days # forgetting factor 0.95 FIXME # ------------------------------------------------------------------------ @@ -80,25 +81,21 @@ class StudentKnowledge(object): # starting new topic self.current_topic = topic - - factory = self.deps.node[topic]['factory'] - questionlist = self.deps.node[topic]['questions'] - self.correct_answers = 0 self.wrong_answers = 0 - # select a random set of questions for this topic - size = len(questionlist) # number of questions FIXME get from topic config - questionlist = random.sample(questionlist, k=size) - logger.debug(f'Questions: {", ".join(questionlist)}') + t = self.deps.node[topic] + questions = random.sample(t['questions'], k=t['choose']) + logger.debug(f'Questions: {", ".join(questions)}') # generate instances of questions - self.questions = [factory[qref].generate() for qref in questionlist] + self.questions = [self.factory[qref].generate() for qref in questions] logger.debug(f'Total: {len(self.questions)} questions') # get first question self.next_question() + # ------------------------------------------------------------------------ # The topic has finished and there are no more questions. # The topic level is updated in state and unlocks are performed. @@ -148,13 +145,14 @@ class StudentKnowledge(object): # move to the next question if self.current_question['tries'] <= 0: logger.debug("Appending new instance of this question to the end") - factory = self.deps.node[self.current_topic]['factory'] + factory = self.factory # self.deps.node[self.current_topic]['factory'] self.questions.append(factory[q['ref']].generate()) self.next_question() # returns answered and corrected question (not new one) return q + # ------------------------------------------------------------------------ # Move to next question # ------------------------------------------------------------------------ @@ -168,25 +166,11 @@ class StudentKnowledge(object): self.current_question['tries'] = self.current_question.get('max_tries', 3) # FIXME hardcoded 3 logger.debug(f'Next question is "{self.current_question["ref"]}"') - # def next_question_in_lesson(self): - # try: - # self.current_question = self.questions.pop(0) - # except IndexError: - # self.current_question = None - # else: - # logger.debug(f'Next question is "{self.current_question["ref"]}"') - # ======================================================================== # pure functions of the state (no side effects) # ======================================================================== - def get_max_questions(self, topic=None): - if topic is not None: - node = self.deps.nodes[topic] - max_questions = node.get('choose', len(node['questions'])) - - # ------------------------------------------------------------------------ # compute recommended sequence of topics ['a', 'b', ...] # ------------------------------------------------------------------------ @@ -211,6 +195,7 @@ class StudentKnowledge(object): # Topics unlocked but not yet done have level 0.0. # ------------------------------------------------------------------------ def get_knowledge_state(self): + # print(self.topic_sequence) return [{ 'ref': ref, 'type': self.deps.nodes[ref]['type'], diff --git a/learnapp.py b/learnapp.py index 8979ed4..9626ba7 100644 --- a/learnapp.py +++ b/learnapp.py @@ -21,10 +21,6 @@ from tools import load_yaml # setup logger for this module logger = logging.getLogger(__name__) -# ============================================================================ -# class LearnAppException(Exception): -# pass - # ============================================================================ # helper functions @@ -69,6 +65,7 @@ class LearnApp(object): for c in config_files: self.populate_graph(c) + self.build_factory() # for all questions of all topics self.db_add_missing_topics(self.deps.nodes()) # ------------------------------------------------------------------------ @@ -103,7 +100,7 @@ class LearnApp(object): self.online[uid] = { 'number': uid, 'name': name, - 'state': StudentKnowledge(self.deps, state=state), + 'state': StudentKnowledge(deps=self.deps, factory=self.factory, state=state), } else: @@ -232,12 +229,89 @@ class LearnApp(object): logger.info(f'{m:6} topics') logger.info(f'{q:6} answers') + # ============================================================================ + # Populates a digraph. + # + # Nodes are the topic references e.g. 'my/topic' + # g.node['my/topic']['name'] name of the topic + # g.node['my/topic']['questions'] list of question refs + # + # Edges are obtained from the deps defined in the YAML file for each topic. + # ------------------------------------------------------------------------ + def populate_graph(self, conffile): + logger.info(f'Populating graph from: {conffile}') + config = load_yaml(conffile) # course configuration + + # default attributes that apply to the topics + default_file = config.get('file', 'questions.yaml') + default_shuffle = config.get('shuffle', True) + default_choose = config.get('choose', 9999) + default_forgetting_factor = config.get('forgetting_factor', 1.0) + + # iterate over topics and populate graph + topics = config.get('topics', {}) + g = self.deps # the dependency graph + + for tref, attr in topics.items(): + # g.add_node(tref) + g.add_edges_from((d,tref) for d in attr.get('deps', [])) + + t = g.node[tref] # current topic node + t['type'] = attr.get('type', 'topic') + t['name'] = attr.get('name', tref) + t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic + t['file'] = attr.get('file', default_file) # questions.yaml + t['shuffle'] = attr.get('shuffle', default_shuffle) + t['forgetting_factor'] = attr.get('forgetting_factor', default_forgetting_factor) + t['choose'] = attr.get('choose', default_choose) + t['questions'] = attr.get('questions', []) + + logger.info(f'Loaded {g.number_of_nodes()} topics') + + + # ------------------------------------------------------------------------ + # Buils dictionary of question factories + # ------------------------------------------------------------------------ + def build_factory(self): + logger.info('Building questions factory') + self.factory = {} # {'qref': QFactory()} + g = self.deps + for tref in g.nodes(): + t = g.node[tref] + + # load questions as list of dicts + topicpath = path.join(g.graph['prefix'], tref) + questions = load_yaml(path.join(topicpath, t['file']), default=[]) + + # update refs to include topic as prefix. + # refs are required to be unique only within the file. + # undefined are set to topic:n, where n is the question number + # within the file + for i, q in enumerate(questions): + qref = q.get('ref', str(i)) # ref or number + q['ref'] = tref + ':' + qref + q['path'] = topicpath + + # if questions are left undefined, include all. + if not t['questions']: + t['questions'] = [q['ref'] for q in questions] + + t['choose'] = min(t['choose'], len(t['questions'])) + + for q in questions: + if q['ref'] in t['questions']: + self.factory[q['ref']] = QFactory(q) + + logger.info(f'{len(t["questions"]):6} {tref}') + + logger.info(f'Factory contains {len(self.factory)} questions') + + # ======================================================================== # methods that do not change state (pure functions) # ======================================================================== - # ------------------------------------------------------------------------ def get_student_name(self, uid): return self.online[uid].get('name', '') @@ -277,82 +351,6 @@ class LearnApp(object): # ------------------------------------------------------------------------ def get_current_public_dir(self, uid): topic = self.online[uid]['state'].get_current_topic() - p = self.deps.graph['prefix'] # FIXME not defined!!! - - return path.join(p, topic, 'public') - - - # ============================================================================ - # Populates a digraph. - # - # First, topics such as `computer/mips/exceptions` are added as nodes - # together with dependencies. Then, questions are loaded to a factory. - # - # g.graph['path'] base path where topic directories are located - # g.graph['title'] title defined in the configuration YAML - # g.graph['database'] sqlite3 database file to use - # - # Nodes are the topic references e.g. 'my/topic' - # g.node['my/topic']['name'] name of the topic - # g.node['my/topic']['questions'] list of question refs defined in YAML - # g.node['my/topic']['factory'] dict with question factories - # - # Edges are obtained from the deps defined in the YAML file for each topic. - # ---------------------------------------------------------------------------- - def populate_graph(self, conffile): - logger.info(f'Loading {conffile} and populating graph:') - g = self.deps # the graph - config = load_yaml(conffile) # course configuration - - # default attributes that apply to the topics - default_file = config.get('file', 'questions.yaml') - default_shuffle = config.get('shuffle', True) - default_choose = config.get('choose', 9999) - default_forgetting_factor = config.get('forgetting_factor', 1.0) - - # iterate over topics and populate graph - topics = config.get('topics', {}) - tcount = qcount = 0 # topic and question counters - for tref, attr in topics.items(): - if tref in g: - logger.error(f'--> Topic {tref} already exists. Skipped.') - continue - - # add topic to the graph - g.add_node(tref) - t = g.node[tref] # current topic node - - topicpath = path.join(g.graph['prefix'], tref) - - t['type'] = attr.get('type', 'topic') - t['name'] = attr.get('name', tref) - t['path'] = topicpath # prefix/topic - t['file'] = attr.get('file', default_file) # questions.yaml - t['shuffle'] = attr.get('shuffle', default_shuffle) - t['forgetting_factor'] = attr.get('forgetting_factor', default_forgetting_factor) - g.add_edges_from((d,tref) for d in attr.get('deps', [])) - - # load questions as list of dicts - questions = load_yaml(path.join(topicpath, t['file']), default=[]) - - # if questions are left undefined, include all. - # refs undefined in questions.yaml are set to topic:n - t['questions'] = attr.get('questions', - [q.setdefault('ref', f'{tref}:{i}') for i, q in enumerate(questions)]) - - # topic will generate a certain amount of questions - t['choose'] = min(attr.get('choose', default_choose), len(t['questions'])) - - # make questions factory (without repeating same question) FIXME move to somewhere else? - t['factory'] = {} - for q in questions: - if q['ref'] in t['questions']: - q['path'] = topicpath # fullpath added to each question - t['factory'][q['ref']] = QFactory(q) - - logger.info(f'{len(t["questions"]):6} {tref}') - qcount += len(t["questions"]) # count total questions - tcount += 1 - - logger.info(f'Total loaded: {tcount} topics, {qcount} questions') + prefix = self.deps.graph['prefix'] + return path.join(prefix, topic, 'public') diff --git a/serve.py b/serve.py index 273beab..7db8537 100755 --- a/serve.py +++ b/serve.py @@ -60,6 +60,7 @@ class WebApplication(tornado.web.Application): super().__init__(handlers, **settings) self.learn = learnapp + # ============================================================================ # Handlers # ============================================================================ @@ -100,6 +101,7 @@ class LoginHandler(BaseHandler): else: self.render("login.html", error='Número ou senha incorrectos') + # ---------------------------------------------------------------------------- class LogoutHandler(BaseHandler): @tornado.web.authenticated @@ -110,6 +112,7 @@ class LogoutHandler(BaseHandler): def on_finish(self): self.learn.logout(self.current_user) + # ---------------------------------------------------------------------------- class ChangePasswordHandler(BaseHandler): @tornado.web.authenticated @@ -124,6 +127,7 @@ class ChangePasswordHandler(BaseHandler): notification = tornado.escape.to_unicode(self.render_string('notification.html', type='danger', msg='A password não foi alterada!')) self.write({'msg': notification}) + # ---------------------------------------------------------------------------- # Main page: / # Shows a list of topics and proficiency (stars, locked). @@ -140,6 +144,7 @@ class RootHandler(BaseHandler): # get_topic_type=self.learn.get_topic_type, # function ) + # ---------------------------------------------------------------------------- # Start a given topic: /topic/... # ---------------------------------------------------------------------------- @@ -286,7 +291,9 @@ class QuestionHandler(BaseHandler): logger.error(f'Unknown action {action}') -# ------------------------------------------------------------------------- +# ---------------------------------------------------------------------------- +# Signal handler to catch Ctrl-C and abort server +# ---------------------------------------------------------------------------- def signal_handler(signal, frame): r = input(' --> Stop webserver? (yes/no) ').lower() if r == 'yes': diff --git a/tools.py b/tools.py index 5db1547..d78be3b 100644 --- a/tools.py +++ b/tools.py @@ -139,11 +139,11 @@ def load_yaml(filename, default=None): try: f = open(filename, 'r', encoding='utf-8') except FileNotFoundError: - logger.error(f'Can\'t open "{filename}": not found') + logger.error(f'Cannot open "{filename}": not found') except PermissionError: - logger.error(f'Can\'t open "{filename}": no permission') + logger.error(f'Cannot open "{filename}": no permission') except IOError: - logger.error(f'Can\'t open file "{filename}"') + logger.error(f'Cannot open file "{filename}"') else: with f: try: -- libgit2 0.21.2