''' Implementation of the StudentState class. Each object of this class will contain the state of a student while logged in. Manages things like current course, topic, question, etc, and defines the logic of the application in what it applies to a single student. ''' # python standard library from datetime import datetime import logging import random from typing import List, Optional # third party libraries import networkx as nx # this project from aprendizations.questions import Question # setup logger for this module logger = logging.getLogger(__name__) # ============================================================================ class StudentState(): ''' kowledge state of a student: uid - string with userid, e.g. '12345' state - dict of unlocked topics and their levels {'topic1': {'level': 0.5, 'date': datetime}, ...} topic_sequence - recommended topic sequence ['topic1', 'topic2', ...] questions - [Question, ...] for the current topic current_course - string or None current_topic - string or None current_question - Question or None also has access to shared data between students: courses - dictionary {course: [topic1, ...]} deps - dependency graph as a networkx digraph factory - dictionary {ref: QFactory} ''' # ======================================================================== # methods that update state # ======================================================================== def __init__(self, uid, state, courses, deps, factory) -> None: # shared application data between all students self.deps = deps # dependency graph self.factory = factory # question factory self.courses = courses # {'course': ['topic_id1', 'topic_id2',...]} # data of this student self.uid = uid # user id '12345' self.state = state # {'topic': {'level': 0.5, 'date': datetime}, ...} # prepare for running self.update_topic_levels() # applies forgetting factor self.unlock_topics() # whose dependencies have been completed self.start_course(None) # ------------------------------------------------------------------------ def start_course(self, course: Optional[str]) -> None: ''' Tries to start a course. Finds the recommended sequence of topics for the student. ''' if course is None: logger.debug('no active course') self.current_course: Optional[str] = None self.topic_sequence: List[str] = [] self.current_topic: Optional[str] = None else: try: topics = self.courses[course]['goals'] except KeyError: logger.debug('course "%s" does not exist', course) raise logger.debug('starting course "%s"', course) self.current_course = course self.topic_sequence = self._recommend_sequence(topics) # ------------------------------------------------------------------------ async def start_topic(self, topic_ref: str) -> None: ''' Start a new topic. questions: list of generated questions to do in the given topic current_question: the current question to be presented ''' logger.debug('start topic "%s"', topic_ref) # avoid regenerating questions in the middle of the current topic if self.current_topic == topic_ref and self.uid != '0': logger.info('Restarting current topic is not allowed.') return # do not allow locked topics if self.is_locked(topic_ref) and self.uid != '0': logger.debug('is locked "%s"', topic_ref) return self.previous_topic: Optional[str] = None # choose k questions self.current_topic = topic_ref self.correct_answers = 0 self.wrong_answers = 0 topic = self.deps.nodes[topic_ref] k = topic['choose'] if topic['shuffle_questions']: questions = random.sample(topic['questions'], k=k) else: questions = topic['questions'][:k] logger.debug('selected questions: %s', ', '.join(questions)) self.questions: List[Question] = [await self.factory[ref].gen_async() for ref in questions] logger.debug('generated %s questions', len(self.questions)) # get first question self.next_question() # ------------------------------------------------------------------------ async def check_answer(self, answer) -> None: ''' Corrects current question. Updates keys: `answer`, `grade`, `finish_time`, `status`, `tries` ''' question = self.current_question if question is None: logger.error('check_answer called but current_question is None!') return None question.set_answer(answer) await question.correct_async() # updates q['grade'] if question['grade'] > 0.999: self.correct_answers += 1 question['status'] = 'right' else: self.wrong_answers += 1 question['tries'] -= 1 if question['tries'] > 0: question['status'] = 'try_again' else: question['status'] = 'wrong' logger.debug('ref = %s, status = %s', question["ref"], question["status"]) # ------------------------------------------------------------------------ async def get_question(self) -> Optional[Question]: ''' Gets next question to show if the status is 'right' or 'wrong', otherwise just returns the current question. ''' question = self.current_question if question is None: logger.error('get_question called but current_question is None!') return None logger.debug('%s status = %s', question["ref"], question["status"]) if question['status'] == 'right': self.next_question() elif question['status'] == 'wrong': if question['append_wrong']: logger.debug(' wrong answer => append new question') new_question = await self.factory[question['ref']].gen_async() self.questions.append(new_question) self.next_question() return self.current_question # ------------------------------------------------------------------------ def next_question(self) -> None: ''' Moves to next question ''' try: question = self.questions.pop(0) except IndexError: self.finish_topic() return topic = self.deps.nodes[self.current_topic] question['start_time'] = datetime.now() question['tries'] = question.get('max_tries', topic['max_tries']) question['status'] = 'new' self.current_question: Optional[Question] = question # ------------------------------------------------------------------------ def finish_topic(self) -> None: ''' The topic has finished and there are no more questions. The topic level is updated in state and unlocks are performed. The current topic is unchanged. ''' logger.debug('finished %s in %s', self.current_topic, self.current_course) self.state[self.current_topic] = { 'date': datetime.now(), 'level': self.correct_answers / (self.correct_answers + self.wrong_answers) } self.previous_topic = self.current_topic self.current_topic = None self.current_question = None self.unlock_topics() # ------------------------------------------------------------------------ def update_topic_levels(self) -> None: ''' Update proficiency level of the topics using a forgetting factor ''' now = datetime.now() for tref, state in self.state.items(): elapsed = now - state['date'] try: forgetting_factor = self.deps.nodes[tref]['forgetting_factor'] state['level'] *= forgetting_factor ** elapsed.days except KeyError: logger.warning('Update topic levels: %s not in the graph', tref) # ------------------------------------------------------------------------ def unlock_topics(self) -> None: ''' Unlock topics whose dependencies are satisfied (> min_level) ''' for topic in self.deps.nodes(): if topic not in self.state: # if locked pred = self.deps.predecessors(topic) min_level = self.deps.nodes[topic]['min_level'] if all(d in self.state and self.state[d]['level'] > min_level for d in pred): # all deps are greater than min_level self.state[topic] = { 'level': 0.0, # unlock 'date': datetime.now() } logger.debug('unlocked "%s"', topic) # else: # lock this topic if deps do not satisfy min_level # del self.state[topic] # ======================================================================== # pure functions of the state (no side effects) # ======================================================================== def topic_has_finished(self) -> bool: ''' Checks if the all the questions in the current topic have been answered. ''' return self.current_topic is None and self.previous_topic is not None # ------------------------------------------------------------------------ def _recommend_sequence(self, goals: List[str]) -> List[str]: ''' compute recommended sequence of topics ['a', 'b', ...] ''' topics = set(goals) # include dependencies not in goals for topic in goals: topics.update(nx.ancestors(self.deps, topic)) todo = [] for topic in topics: level = self.state[topic]['level'] if topic in self.state else 0.0 min_level = self.deps.nodes[topic]['min_level'] if topic in goals or level < min_level: todo.append(topic) logger.debug(' %s total topics, %s listed ', len(topics), len(todo)) # FIXME: topological sort is a poor way to sort topics topic_seq = list(nx.topological_sort(self.deps.subgraph(todo))) # sort with unlocked first unlocked = [t for t in topic_seq if t in self.state] locked = [t for t in topic_seq if t not in unlocked] return unlocked + locked # ------------------------------------------------------------------------ def get_current_question(self) -> Optional[Question]: '''gets current question''' return self.current_question # ------------------------------------------------------------------------ def get_current_topic(self) -> Optional[str]: '''gets current topic''' return self.current_topic # ------------------------------------------------------------------------ def get_previous_topic(self) -> Optional[str]: '''gets previous topic''' return self.previous_topic # ------------------------------------------------------------------------ def get_current_course_title(self) -> str: '''gets current course title''' return str(self.courses[self.current_course]['title']) # ------------------------------------------------------------------------ def get_current_course_id(self) -> Optional[str]: '''gets current course id''' return self.current_course # ------------------------------------------------------------------------ def is_locked(self, topic: str) -> bool: '''checks if a given topic is locked''' return topic not in self.state # ------------------------------------------------------------------------ def get_knowledge_state(self): ''' Return list of {ref: 'xpto', name: 'long name', leve: 0.5} Levels are in the interval [0, 1] if unlocked or None if locked. Topics unlocked but not yet done have level 0.0. ''' return [{ 'ref': ref, 'type': self.deps.nodes[ref]['type'], 'name': self.deps.nodes[ref]['name'], 'level': self.state[ref]['level'] if ref in self.state else None } for ref in self.topic_sequence] # ------------------------------------------------------------------------ def get_topic_progress(self) -> float: '''computes progress of the current topic''' return self.correct_answers / (1 + self.correct_answers + len(self.questions)) # ------------------------------------------------------------------------ def get_topic_level(self, topic: str) -> float: '''gets level of a given topic''' return float(self.state[topic]['level']) # ------------------------------------------------------------------------ def get_topic_date(self, topic: str): '''gets date of a given topic''' return self.state[topic]['date']