""" LearnerModel — In-memory per-session mastery tracking. Designed so Unity dev can later persist/restore via get_state/set_state. """ import time from typing import Optional # --------------------------------------------------------------------------- # Default mastery structure (all rules start at 0.0) # --------------------------------------------------------------------------- DEFAULT_MASTERY = { "topic_marker": 0.0, "copula": 0.0, "negative_copula": 0.0, "indirect_quote_dago": 0.0, "indirect_quote_commands": 0.0, "indirect_quote_questions": 0.0, "indirect_quote_suggestions": 0.0, "regret_expression": 0.0, } # Mastery thresholds for difficulty escalation MASTERY_THRESHOLD_MID = 0.5 # Unlock difficulty 2 MASTERY_THRESHOLD_HIGH = 0.75 # Unlock difficulty 3 # Score weights CORRECT_WEIGHT = 0.15 # +15% mastery per correct INCORRECT_WEIGHT = 0.05 # -5% mastery per incorrect (floor 0) MAX_HISTORY = 20 # Keep last 20 entries per session class LearnerModel: def __init__(self, session_id: str): self.session_id = session_id self.mastery = dict(DEFAULT_MASTERY) self.history: list = [] self.difficulty: int = 1 # 1 = beginner, 2 = intermediate, 3 = advanced self.question_count: int = 0 self.correct_count: int = 0 self.streak: int = 0 self.created_at: float = time.time() self.last_active: float = time.time() def record_outcome(self, grammar_rule: str, correct: bool, interaction_mode: str = ""): """Update mastery score and history for a question outcome.""" self.last_active = time.time() self.question_count += 1 if correct: self.correct_count += 1 self.streak += 1 delta = CORRECT_WEIGHT else: self.streak = 0 delta = -INCORRECT_WEIGHT if grammar_rule in self.mastery: new_score = self.mastery[grammar_rule] + delta self.mastery[grammar_rule] = max(0.0, min(1.0, new_score)) self.history.append({ "grammar_rule": grammar_rule, "correct": correct, "interaction_mode": interaction_mode, "timestamp": time.time(), }) # Trim history if len(self.history) > MAX_HISTORY: self.history = self.history[-MAX_HISTORY:] # Auto-escalate difficulty self._update_difficulty() def _update_difficulty(self): avg_mastery = sum(self.mastery.values()) / len(self.mastery) if avg_mastery >= MASTERY_THRESHOLD_HIGH: self.difficulty = 3 elif avg_mastery >= MASTERY_THRESHOLD_MID: self.difficulty = 2 else: self.difficulty = 1 def get_weakest_rule(self) -> str: """Return the grammar rule with the lowest mastery score.""" return min(self.mastery, key=lambda k: self.mastery[k]) def get_strongest_rule(self) -> str: """Return the grammar rule with the highest mastery score.""" return max(self.mastery, key=lambda k: self.mastery[k]) def get_recommended_rule(self) -> str: """ Smart rule selection: - Early sessions: cycle through all rules - Later: weight toward weakest rules with some randomness """ import random # First pass: if any rule has never been tested, prioritize it untested = [r for r, score in self.mastery.items() if score == 0.0] if untested and self.question_count < len(DEFAULT_MASTERY) * 2: return random.choice(untested) # Otherwise weight selection toward weaker rules rules = list(self.mastery.keys()) weights = [max(0.05, 1.0 - score) for score in self.mastery.values()] return random.choices(rules, weights=weights, k=1)[0] def get_state(self) -> dict: """ Returns full serializable state. Unity dev can store this and send it back via set_state on reconnect. """ return { "session_id": self.session_id, "mastery": dict(self.mastery), "history": list(self.history), "difficulty": self.difficulty, "question_count": self.question_count, "correct_count": self.correct_count, "streak": self.streak, "created_at": self.created_at, "last_active": self.last_active, } def set_state(self, state: dict): """Restore state from a previously saved snapshot (for Unity persistence).""" self.mastery = state.get("mastery", dict(DEFAULT_MASTERY)) self.history = state.get("history", []) self.difficulty = state.get("difficulty", 1) self.question_count = state.get("question_count", 0) self.correct_count = state.get("correct_count", 0) self.streak = state.get("streak", 0) def reset(self): """Full reset for this session.""" self.mastery = dict(DEFAULT_MASTERY) self.history = [] self.difficulty = 1 self.question_count = 0 self.correct_count = 0 self.streak = 0 # --------------------------------------------------------------------------- # Session Store — maps session_id → LearnerModel # --------------------------------------------------------------------------- _sessions: dict[str, LearnerModel] = {} SESSION_TIMEOUT = 3600 # 1 hour def get_or_create_session(session_id: str) -> LearnerModel: if session_id not in _sessions: _sessions[session_id] = LearnerModel(session_id) else: _sessions[session_id].last_active = time.time() return _sessions[session_id] def get_session(session_id: str) -> Optional[LearnerModel]: return _sessions.get(session_id) def delete_session(session_id: str): _sessions.pop(session_id, None) def purge_stale_sessions(): """Remove sessions inactive for more than SESSION_TIMEOUT seconds.""" now = time.time() stale = [sid for sid, model in _sessions.items() if now - model.last_active > SESSION_TIMEOUT]