rairo commited on
Commit
7ec4af5
·
verified ·
1 Parent(s): 33de748

Create learner_model.py

Browse files
Files changed (1) hide show
  1. learner_model.py +174 -0
learner_model.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LearnerModel — In-memory per-session mastery tracking.
3
+ Designed so Unity dev can later persist/restore via get_state/set_state.
4
+ """
5
+
6
+ import time
7
+ from typing import Optional
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Default mastery structure (all rules start at 0.0)
11
+ # ---------------------------------------------------------------------------
12
+ DEFAULT_MASTERY = {
13
+ "topic_marker": 0.0,
14
+ "copula": 0.0,
15
+ "negative_copula": 0.0,
16
+ "indirect_quote_dago": 0.0,
17
+ "indirect_quote_commands": 0.0,
18
+ "indirect_quote_questions": 0.0,
19
+ "indirect_quote_suggestions": 0.0,
20
+ "regret_expression": 0.0,
21
+ }
22
+
23
+ # Mastery thresholds for difficulty escalation
24
+ MASTERY_THRESHOLD_MID = 0.5 # Unlock difficulty 2
25
+ MASTERY_THRESHOLD_HIGH = 0.75 # Unlock difficulty 3
26
+
27
+ # Score weights
28
+ CORRECT_WEIGHT = 0.15 # +15% mastery per correct
29
+ INCORRECT_WEIGHT = 0.05 # -5% mastery per incorrect (floor 0)
30
+ MAX_HISTORY = 20 # Keep last 20 entries per session
31
+
32
+
33
+ class LearnerModel:
34
+ def __init__(self, session_id: str):
35
+ self.session_id = session_id
36
+ self.mastery = dict(DEFAULT_MASTERY)
37
+ self.history: list = []
38
+ self.difficulty: int = 1 # 1 = beginner, 2 = intermediate, 3 = advanced
39
+ self.question_count: int = 0
40
+ self.correct_count: int = 0
41
+ self.streak: int = 0
42
+ self.created_at: float = time.time()
43
+ self.last_active: float = time.time()
44
+
45
+ def record_outcome(self, grammar_rule: str, correct: bool, interaction_mode: str = ""):
46
+ """Update mastery score and history for a question outcome."""
47
+ self.last_active = time.time()
48
+ self.question_count += 1
49
+
50
+ if correct:
51
+ self.correct_count += 1
52
+ self.streak += 1
53
+ delta = CORRECT_WEIGHT
54
+ else:
55
+ self.streak = 0
56
+ delta = -INCORRECT_WEIGHT
57
+
58
+ if grammar_rule in self.mastery:
59
+ new_score = self.mastery[grammar_rule] + delta
60
+ self.mastery[grammar_rule] = max(0.0, min(1.0, new_score))
61
+
62
+ self.history.append({
63
+ "grammar_rule": grammar_rule,
64
+ "correct": correct,
65
+ "interaction_mode": interaction_mode,
66
+ "timestamp": time.time(),
67
+ })
68
+
69
+ # Trim history
70
+ if len(self.history) > MAX_HISTORY:
71
+ self.history = self.history[-MAX_HISTORY:]
72
+
73
+ # Auto-escalate difficulty
74
+ self._update_difficulty()
75
+
76
+ def _update_difficulty(self):
77
+ avg_mastery = sum(self.mastery.values()) / len(self.mastery)
78
+ if avg_mastery >= MASTERY_THRESHOLD_HIGH:
79
+ self.difficulty = 3
80
+ elif avg_mastery >= MASTERY_THRESHOLD_MID:
81
+ self.difficulty = 2
82
+ else:
83
+ self.difficulty = 1
84
+
85
+ def get_weakest_rule(self) -> str:
86
+ """Return the grammar rule with the lowest mastery score."""
87
+ return min(self.mastery, key=lambda k: self.mastery[k])
88
+
89
+ def get_strongest_rule(self) -> str:
90
+ """Return the grammar rule with the highest mastery score."""
91
+ return max(self.mastery, key=lambda k: self.mastery[k])
92
+
93
+ def get_recommended_rule(self) -> str:
94
+ """
95
+ Smart rule selection:
96
+ - Early sessions: cycle through all rules
97
+ - Later: weight toward weakest rules with some randomness
98
+ """
99
+ import random
100
+
101
+ # First pass: if any rule has never been tested, prioritize it
102
+ untested = [r for r, score in self.mastery.items() if score == 0.0]
103
+ if untested and self.question_count < len(DEFAULT_MASTERY) * 2:
104
+ return random.choice(untested)
105
+
106
+ # Otherwise weight selection toward weaker rules
107
+ rules = list(self.mastery.keys())
108
+ weights = [max(0.05, 1.0 - score) for score in self.mastery.values()]
109
+ return random.choices(rules, weights=weights, k=1)[0]
110
+
111
+ def get_state(self) -> dict:
112
+ """
113
+ Returns full serializable state.
114
+ Unity dev can store this and send it back via set_state on reconnect.
115
+ """
116
+ return {
117
+ "session_id": self.session_id,
118
+ "mastery": dict(self.mastery),
119
+ "history": list(self.history),
120
+ "difficulty": self.difficulty,
121
+ "question_count": self.question_count,
122
+ "correct_count": self.correct_count,
123
+ "streak": self.streak,
124
+ "created_at": self.created_at,
125
+ "last_active": self.last_active,
126
+ }
127
+
128
+ def set_state(self, state: dict):
129
+ """Restore state from a previously saved snapshot (for Unity persistence)."""
130
+ self.mastery = state.get("mastery", dict(DEFAULT_MASTERY))
131
+ self.history = state.get("history", [])
132
+ self.difficulty = state.get("difficulty", 1)
133
+ self.question_count = state.get("question_count", 0)
134
+ self.correct_count = state.get("correct_count", 0)
135
+ self.streak = state.get("streak", 0)
136
+
137
+ def reset(self):
138
+ """Full reset for this session."""
139
+ self.mastery = dict(DEFAULT_MASTERY)
140
+ self.history = []
141
+ self.difficulty = 1
142
+ self.question_count = 0
143
+ self.correct_count = 0
144
+ self.streak = 0
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Session Store — maps session_id → LearnerModel
149
+ # ---------------------------------------------------------------------------
150
+ _sessions: dict[str, LearnerModel] = {}
151
+ SESSION_TIMEOUT = 3600 # 1 hour
152
+
153
+
154
+ def get_or_create_session(session_id: str) -> LearnerModel:
155
+ if session_id not in _sessions:
156
+ _sessions[session_id] = LearnerModel(session_id)
157
+ else:
158
+ _sessions[session_id].last_active = time.time()
159
+ return _sessions[session_id]
160
+
161
+
162
+ def get_session(session_id: str) -> Optional[LearnerModel]:
163
+ return _sessions.get(session_id)
164
+
165
+
166
+ def delete_session(session_id: str):
167
+ _sessions.pop(session_id, None)
168
+
169
+
170
+ def purge_stale_sessions():
171
+ """Remove sessions inactive for more than SESSION_TIMEOUT seconds."""
172
+ now = time.time()
173
+ stale = [sid for sid, model in _sessions.items()
174
+ if now - model.last_active > SESSION_TIMEOUT]