File size: 12,192 Bytes
ba633b9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
"""
Core tutor components: curriculum, ASR, scoring, knowledge tracing, storage.
"""

import json
import sqlite3
from datetime import datetime
from typing import Dict, Optional
import numpy as np


# ============================================================================
# PART 1: CURRICULUM LOADER
# ============================================================================

class CurriculumLoader:
    """Load and manage math curriculum items."""
    
    def __init__(self, curriculum_path: str = "tutor/data/curriculum.json"):
        """Load curriculum from JSON file."""
        try:
            with open(curriculum_path, 'r') as f:
                self.items = json.load(f)
        except FileNotFoundError:
            print(f"⚠️ Curriculum file not found: {curriculum_path}")
            self.items = []
        
        # Index by skill
        self.by_skill = {}
        for item in self.items:
            skill = item.get('skill', 'unknown')
            if skill not in self.by_skill:
                self.by_skill[skill] = []
            self.by_skill[skill].append(item)
        
        # Sort by difficulty
        for skill in self.by_skill:
            self.by_skill[skill].sort(key=lambda x: x.get('difficulty', 0))
    
    def get_item_by_id(self, item_id: str) -> Optional[Dict]:
        """Fetch a single item by ID."""
        for item in self.items:
            if item.get('id') == item_id:
                return item
        return None
    
    def get_initial_item(self) -> Optional[Dict]:
        """Return the easiest item (difficulty 1)."""
        for item in self.items:
            if item.get('difficulty') == 1:
                return item
        return self.items[0] if self.items else None
    
    def get_next_item(self, skill: str, current_difficulty: int) -> Optional[Dict]:
        """Get next item above current difficulty."""
        candidates = [
            item for item in self.by_skill.get(skill, [])
            if item.get('difficulty', 0) > current_difficulty
        ]
        return candidates[0] if candidates else None


# ============================================================================
# PART 2: ASR + LANGUAGE DETECTION
# ============================================================================

class ChildASRAdapter:
    """Transcribe and detect language."""
    
    def __init__(self):
        """Initialize ASR adapter."""
        try:
            import whisper
            self.model = whisper.load_model("tiny")
            self.asr_available = True
        except (ImportError, Exception) as e:
            print(f"⚠️ Whisper not available: {e}")
            self.model = None
            self.asr_available = False
        
        self.language_keywords = {
            'en': ['one', 'two', 'three', 'four', 'five', 'apple', 'goat', 'yes'],
            'fr': ['un', 'deux', 'trois', 'quatre', 'cinq', 'pomme', 'chèvre', 'oui'],
            'kin': ['rimwe', 'kabiri', 'gatatu', 'ine', 'itanu', 'pome', 'ihene', 'yego']
        }
    
    def transcribe(self, audio_path: str, language: Optional[str] = None) -> str:
        """Transcribe audio using Whisper."""
        if not self.asr_available or self.model is None:
            return "[ASR disabled]"
        
        try:
            result = self.model.transcribe(audio_path, language=language)
            return result['text'].strip().lower()
        except Exception as e:
            print(f"⚠️ ASR error: {e}")
            return ""
    
    def detect_language(self, transcript: str) -> str:
        """Detect language from transcript."""
        if not transcript:
            return 'en'
        
        scores = {lang: 0 for lang in ['en', 'fr', 'kin']}
        transcript_lower = transcript.lower()
        
        for lang, keywords in self.language_keywords.items():
            scores[lang] = sum(1 for kw in keywords if kw in transcript_lower)
        
        detected = [lang for lang, score in scores.items() if score > 0]
        
        if len(detected) > 1:
            return 'mixed'
        elif detected:
            return detected[0]
        else:
            return 'en'


# ============================================================================
# PART 3: SCORING
# ============================================================================

class ResponseScorer:
    """Score child responses."""
    
    @staticmethod
    def score_response(expected_answer: int, transcript: str, item: Dict = None) -> bool:
        """Determine if response is correct."""
        
        number_words = {
            'en': {1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five'},
            'fr': {1: 'un', 2: 'deux', 3: 'trois', 4: 'quatre', 5: 'cinq'},
            'kin': {1: 'rimwe', 2: 'kabiri', 3: 'gatatu', 4: 'ine', 5: 'itanu'}
        }
        
        transcript_lower = transcript.lower()
        
        for lang, words in number_words.items():
            if expected_answer in words:
                if words[expected_answer] in transcript_lower:
                    return True
        
        if str(expected_answer) in transcript_lower:
            return True
        
        return False


# ============================================================================
# PART 4: KNOWLEDGE TRACING
# ============================================================================

class BayesianKnowledgeTracing:
    """Bayesian Knowledge Tracing model."""
    
    def __init__(self, skill: str, p_init: float = 0.1, p_g: float = 0.25,
                 p_s: float = 0.05, p_t: float = 0.1):
        """Initialize BKT for a skill."""
        self.skill = skill
        self.p_init = p_init
        self.p_g = p_g
        self.p_s = p_s
        self.p_t = p_t
        self.p_learned = p_init
        self.history = []
    
    def update(self, correct: bool) -> float:
        """Update P(learned) based on response."""
        
        if correct:
            p_correct = (self.p_learned * (1 - self.p_s)) + \
                       ((1 - self.p_learned) * self.p_g)
        else:
            p_correct = (self.p_learned * self.p_s) + \
                       ((1 - self.p_learned) * (1 - self.p_g))
        
        if p_correct < 1e-10:
            return self.p_learned
        
        if correct:
            posterior = (self.p_learned * (1 - self.p_s)) / p_correct
        else:
            posterior = (self.p_learned * self.p_s) / p_correct
        
        self.p_learned = posterior + ((1 - posterior) * self.p_t)
        self.p_learned = np.clip(self.p_learned, 0, 1)
        
        self.history.append({
            'correct': correct,
            'p_learned': self.p_learned
        })
        
        return self.p_learned
    
    def predict_next_response(self) -> float:
        """Predict P(next response is correct)."""
        return (self.p_learned * (1 - self.p_s)) + \
               ((1 - self.p_learned) * self.p_g)
    
    def next_item_difficulty(self) -> int:
        """Recommend difficulty change: -1, 0, or +1."""
        if self.p_learned > 0.85:
            return +1
        elif self.p_learned < 0.3:
            return -1
        else:
            return 0


class LearnerState:
    """Track learner's knowledge across all skills."""
    
    def __init__(self, learner_id: str):
        """Initialize learner."""
        self.learner_id = learner_id
        self.skills = {
            'counting': BayesianKnowledgeTracing('counting'),
            'addition': BayesianKnowledgeTracing('addition'),
            'subtraction': BayesianKnowledgeTracing('subtraction'),
            'number_sense': BayesianKnowledgeTracing('number_sense'),
            'word_problem': BayesianKnowledgeTracing('word_problem'),
        }
        self.current_skill = 'counting'
        self.response_count = 0
    
    def record_response(self, skill: str, correct: bool):
        """Update BKT model."""
        if skill in self.skills:
            self.skills[skill].update(correct)
            self.response_count += 1
    
    def get_next_item_difficulty(self, skill: str) -> int:
        """Get recommended difficulty change."""
        if skill in self.skills:
            return self.skills[skill].next_item_difficulty()
        return 0


# ============================================================================
# PART 5: FEEDBACK GENERATION
# ============================================================================

class FeedbackGenerator:
    """Generate multilingual feedback."""
    
    @staticmethod
    def generate_feedback(correct: bool, language: str, expected_answer: int) -> str:
        """Generate feedback."""
        
        templates = {
            ('en', True): "Correct! Very good!",
            ('en', False): f"Not quite. The answer is {expected_answer}.",
            ('fr', True): "Correct! Très bien!",
            ('fr', False): f"Non. La réponse est {expected_answer}.",
            ('kin', True): "Wembe! Neza cyane!",
            ('kin', False): f"Ntabwo. Igisubizo ni {expected_answer}.",
        }
        
        key = (language if language in ['en', 'fr', 'kin'] else 'en', correct)
        return templates.get(key, templates[('en', correct)])


# ============================================================================
# PART 6: LOCAL STORAGE
# ============================================================================

class LocalProgressStore:
    """Store learner progress in SQLite."""
    
    def __init__(self, db_path: str = "tutor/data/progress.db"):
        """Initialize SQLite database."""
        self.db_path = db_path
        self._init_db()
    
    def _init_db(self):
        """Create tables if they don't exist."""
        conn = sqlite3.connect(self.db_path)
        c = conn.cursor()
        
        c.execute('''
            CREATE TABLE IF NOT EXISTS learners (
                learner_id TEXT PRIMARY KEY,
                name TEXT,
                language TEXT DEFAULT 'en',
                created_at TIMESTAMP
            )
        ''')
        
        c.execute('''
            CREATE TABLE IF NOT EXISTS responses (
                response_id INTEGER PRIMARY KEY,
                learner_id TEXT,
                skill TEXT,
                item_id TEXT,
                correct BOOLEAN,
                transcript TEXT,
                timestamp TIMESTAMP,
                FOREIGN KEY(learner_id) REFERENCES learners(learner_id)
            )
        ''')
        
        conn.commit()
        conn.close()
    
    def add_learner(self, learner_id: str, name: str, language: str = 'en'):
        """Register a learner."""
        conn = sqlite3.connect(self.db_path)
        c = conn.cursor()
        
        c.execute(
            'INSERT OR IGNORE INTO learners VALUES (?, ?, ?, ?)',
            (learner_id, name, language, datetime.now())
        )
        
        conn.commit()
        conn.close()
    
    def add_response(self, learner_id: str, skill: str, item_id: str,
                     correct: bool, transcript: str):
        """Log a response."""
        conn = sqlite3.connect(self.db_path)
        c = conn.cursor()
        
        c.execute(
            'INSERT INTO responses VALUES (NULL, ?, ?, ?, ?, ?, ?)',
            (learner_id, skill, item_id, correct, transcript, datetime.now())
        )
        
        conn.commit()
        conn.close()
    
    def get_stats(self, learner_id: str) -> Dict:
        """Get learner stats."""
        conn = sqlite3.connect(self.db_path)
        c = conn.cursor()
        
        c.execute('''
            SELECT skill, COUNT(*) as attempts, SUM(correct) as correct_count
            FROM responses
            WHERE learner_id = ?
            GROUP BY skill
        ''', (learner_id,))
        
        rows = c.fetchall()
        conn.close()
        
        stats = {}
        for skill, attempts, correct_count in rows:
            accuracy = correct_count / attempts if attempts > 0 else 0
            stats[skill] = {
                'accuracy': accuracy,
                'attempts': attempts,
                'correct': correct_count
            }
        
        return stats