@rookiestar/eng-lang-tutor 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.claude/settings.local.json +22 -0
  2. package/.gitignore +32 -0
  3. package/CHANGELOG.md +37 -0
  4. package/CLAUDE.md +275 -0
  5. package/README.md +369 -0
  6. package/SKILL.md +613 -0
  7. package/bin/eng-lang-tutor.js +177 -0
  8. package/docs/OPENCLAW_DEPLOYMENT.md +241 -0
  9. package/examples/sample_keypoint_a1.json +112 -0
  10. package/examples/sample_keypoint_a2.json +124 -0
  11. package/examples/sample_keypoint_b1.json +135 -0
  12. package/examples/sample_keypoint_b2.json +137 -0
  13. package/examples/sample_keypoint_c1.json +134 -0
  14. package/examples/sample_keypoint_c2.json +141 -0
  15. package/examples/sample_quiz_a1.json +94 -0
  16. package/examples/sample_quiz_a2.json +94 -0
  17. package/examples/sample_quiz_b1.json +92 -0
  18. package/examples/sample_quiz_b2.json +94 -0
  19. package/examples/sample_quiz_c1.json +94 -0
  20. package/examples/sample_quiz_c2.json +104 -0
  21. package/package.json +41 -0
  22. package/references/resources.md +292 -0
  23. package/requirements.txt +16 -0
  24. package/scripts/__init__.py +28 -0
  25. package/scripts/audio/__init__.py +23 -0
  26. package/scripts/audio/composer.py +367 -0
  27. package/scripts/audio/converter.py +331 -0
  28. package/scripts/audio/feishu_voice.py +404 -0
  29. package/scripts/audio/tts/__init__.py +30 -0
  30. package/scripts/audio/tts/base.py +166 -0
  31. package/scripts/audio/tts/manager.py +306 -0
  32. package/scripts/audio/tts/providers/__init__.py +12 -0
  33. package/scripts/audio/tts/providers/edge.py +111 -0
  34. package/scripts/audio/tts/providers/xunfei.py +205 -0
  35. package/scripts/audio/utils.py +63 -0
  36. package/scripts/cli/__init__.py +7 -0
  37. package/scripts/cli/cli.py +229 -0
  38. package/scripts/cli/command_parser.py +336 -0
  39. package/scripts/core/__init__.py +30 -0
  40. package/scripts/core/constants.py +125 -0
  41. package/scripts/core/error_notebook.py +308 -0
  42. package/scripts/core/gamification.py +405 -0
  43. package/scripts/core/scorer.py +295 -0
  44. package/scripts/core/state_manager.py +814 -0
  45. package/scripts/eng-lang-tutor +16 -0
  46. package/scripts/scheduling/__init__.py +6 -0
  47. package/scripts/scheduling/cron_push.py +229 -0
  48. package/scripts/utils/__init__.py +12 -0
  49. package/scripts/utils/dedup.py +331 -0
  50. package/scripts/utils/helpers.py +82 -0
  51. package/templates/keypoint_schema.json +420 -0
  52. package/templates/prompt_templates.md +73 -0
  53. package/templates/prompts/display_guide.md +106 -0
  54. package/templates/prompts/initialization.md +350 -0
  55. package/templates/prompts/keypoint_generation.md +272 -0
  56. package/templates/prompts/output_rules.md +106 -0
  57. package/templates/prompts/quiz_generation.md +190 -0
  58. package/templates/prompts/responses.md +339 -0
  59. package/templates/prompts/shared_enums.md +252 -0
  60. package/templates/quiz_schema.json +214 -0
  61. package/templates/state_schema.json +277 -0
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Gamification - Duolingo-style achievement system for eng-lang-tutor.
4
+
5
+ Components:
6
+ 1. XP & Levels: XP accumulates, levels up at thresholds
7
+ 2. Streaks: Consecutive days of study, with freeze protection
8
+ 3. Badges: Achievement milestones
9
+ 4. Gems: Currency for streak freeze, hints
10
+
11
+ Note: Leagues removed - not applicable for single-user scenario.
12
+ """
13
+
14
+ from typing import Dict, Any, List, Tuple, Optional
15
+ from datetime import datetime, date, timedelta
16
+
17
+ from .constants import (
18
+ LEVEL_THRESHOLDS,
19
+ get_level_name,
20
+ calculate_level,
21
+ get_streak_multiplier,
22
+ STREAK_FREEZE_COST,
23
+ HINT_COST
24
+ )
25
+
26
+
27
+ class GamificationManager:
28
+ """Manages gamification elements: levels, streaks, badges, gems."""
29
+
30
+ # Badge definitions
31
+ BADGES = {
32
+ 'first_steps': {
33
+ 'name': 'First Steps',
34
+ 'description': 'Complete your first quiz',
35
+ 'condition': 'total_quizzes >= 1',
36
+ 'gem_reward': 10
37
+ },
38
+ 'week_warrior': {
39
+ 'name': 'Week Warrior',
40
+ 'description': '7-day streak',
41
+ 'condition': 'streak >= 7',
42
+ 'gem_reward': 25
43
+ },
44
+ 'month_master': {
45
+ 'name': 'Month Master',
46
+ 'description': '30-day streak',
47
+ 'condition': 'streak >= 30',
48
+ 'gem_reward': 100
49
+ },
50
+ 'perfect_10': {
51
+ 'name': 'Perfect 10',
52
+ 'description': '10 perfect quizzes',
53
+ 'condition': 'perfect_quizzes >= 10',
54
+ 'gem_reward': 50
55
+ },
56
+ 'vocab_hunter': {
57
+ 'name': 'Vocab Hunter',
58
+ 'description': 'Learn 100 expressions',
59
+ 'condition': 'expressions_learned >= 100',
60
+ 'gem_reward': 75
61
+ },
62
+ 'error_slayer': {
63
+ 'name': 'Error Slayer',
64
+ 'description': 'Clear 30 errors from notebook',
65
+ 'condition': 'errors_cleared >= 30',
66
+ 'gem_reward': 30
67
+ }
68
+ }
69
+
70
+ def __init__(self, state_manager=None):
71
+ """
72
+ Initialize the gamification manager.
73
+
74
+ Args:
75
+ state_manager: Optional StateManager instance
76
+ """
77
+ self.state_manager = state_manager
78
+
79
+ # Wrapper methods for backward compatibility with tests
80
+ def calculate_level(self, xp: int) -> int:
81
+ """Wrapper for calculate_level function."""
82
+ return calculate_level(xp)
83
+
84
+ def get_level_name(self, level: int) -> str:
85
+ """Wrapper for get_level_name function."""
86
+ return get_level_name(level)
87
+
88
+ def get_streak_multiplier(self, streak: int) -> float:
89
+ """Wrapper for get_streak_multiplier function."""
90
+ return get_streak_multiplier(streak)
91
+
92
+ def update_streak(self, state: Dict[str, Any], study_date: str) -> Tuple[int, bool, str]:
93
+ """
94
+ Update streak based on study activity.
95
+
96
+ Args:
97
+ state: Current state dictionary
98
+ study_date: Date of study (YYYY-MM-DD)
99
+
100
+ Returns:
101
+ Tuple of (new_streak, streak_continued, message)
102
+ """
103
+ user = state.get('user', {})
104
+ progress = state.get('progress', {})
105
+
106
+ last_study = progress.get('last_study_date')
107
+ current_streak = user.get('streak', 0)
108
+
109
+ try:
110
+ today = datetime.strptime(study_date, '%Y-%m-%d').date()
111
+ except ValueError:
112
+ return (current_streak, False, "Invalid date format")
113
+
114
+ if last_study:
115
+ try:
116
+ last = datetime.strptime(last_study, '%Y-%m-%d').date()
117
+ except ValueError:
118
+ last = None
119
+
120
+ if last == today:
121
+ # Already studied today
122
+ return (current_streak, False, "Already studied today")
123
+
124
+ days_diff = (today - last).days
125
+
126
+ if days_diff == 1:
127
+ # Consecutive day - streak continues
128
+ new_streak = current_streak + 1
129
+ state['user']['streak'] = new_streak
130
+ return (new_streak, True, f"Streak continued! {new_streak} days")
131
+
132
+ elif days_diff == 2 and user.get('streak_freeze', 0) > 0:
133
+ # Use streak freeze - preserves streak continuity
134
+ state['user']['streak_freeze'] -= 1
135
+ new_streak = current_streak + 1
136
+ state['user']['streak'] = new_streak
137
+ state['user']['last_freeze_used'] = study_date # Track for display
138
+ remaining = state['user']['streak_freeze']
139
+ return (new_streak, True, f"Streak freeze used! {new_streak} days ({remaining} freeze remaining)")
140
+
141
+ else:
142
+ # Streak broken
143
+ state['user']['streak'] = 1
144
+ return (1, False, f"Streak broken. Starting over at 1 day")
145
+
146
+ else:
147
+ # First study ever
148
+ state['user']['streak'] = 1
149
+ return (1, True, "Started your streak!")
150
+
151
+ def check_level_up(self, old_xp: int, new_xp: int) -> Tuple[bool, Optional[int]]:
152
+ """
153
+ Check if user leveled up.
154
+
155
+ Args:
156
+ old_xp: XP before this session
157
+ new_xp: XP after this session
158
+
159
+ Returns:
160
+ Tuple of (leveled_up, new_level)
161
+ """
162
+ old_level = calculate_level(old_xp)
163
+ new_level = calculate_level(new_xp)
164
+
165
+ if new_level > old_level:
166
+ return (True, new_level)
167
+ return (False, None)
168
+
169
+ def update_level(self, state: Dict[str, Any]) -> Tuple[int, bool]:
170
+ """
171
+ Update level based on current XP.
172
+
173
+ Args:
174
+ state: Current state (will be modified in place)
175
+
176
+ Returns:
177
+ Tuple of (level, just_leveled_up)
178
+ """
179
+ xp = state.get('user', {}).get('xp', 0)
180
+ current_level = state.get('user', {}).get('level', 1)
181
+ new_level = calculate_level(xp)
182
+
183
+ state['user']['level'] = new_level
184
+
185
+ leveled_up = new_level > current_level
186
+ return (new_level, leveled_up)
187
+
188
+ def _check_badge_condition(self, badge_id: str, check_data: Dict[str, int]) -> bool:
189
+ """
190
+ Check if a badge condition is met using explicit condition functions.
191
+
192
+ Args:
193
+ badge_id: Badge identifier
194
+ check_data: Dictionary with stats for condition checking
195
+
196
+ Returns:
197
+ True if condition is met, False otherwise
198
+ """
199
+ # Explicit condition functions for each badge (no eval() for security)
200
+ conditions = {
201
+ 'first_steps': lambda d: d.get('total_quizzes', 0) >= 1,
202
+ 'week_warrior': lambda d: d.get('streak', 0) >= 7,
203
+ 'month_master': lambda d: d.get('streak', 0) >= 30,
204
+ 'perfect_10': lambda d: d.get('perfect_quizzes', 0) >= 10,
205
+ 'vocab_hunter': lambda d: d.get('expressions_learned', 0) >= 100,
206
+ 'error_slayer': lambda d: d.get('errors_cleared', 0) >= 30,
207
+ }
208
+ checker = conditions.get(badge_id)
209
+ return checker(check_data) if checker else False
210
+
211
+ def check_badges(self, state: Dict[str, Any]) -> List[Dict[str, Any]]:
212
+ """
213
+ Check and award new badges.
214
+
215
+ Args:
216
+ state: Current state
217
+
218
+ Returns:
219
+ List of newly earned badges with details
220
+ """
221
+ earned = []
222
+ current_badges = state.get('user', {}).get('badges', [])
223
+ progress = state.get('progress', {})
224
+ user = state.get('user', {})
225
+
226
+ # Prepare badge check data
227
+ check_data = {
228
+ 'total_quizzes': progress.get('total_quizzes', 0),
229
+ 'streak': user.get('streak', 0),
230
+ 'perfect_quizzes': progress.get('perfect_quizzes', 0),
231
+ 'expressions_learned': progress.get('expressions_learned', 0),
232
+ 'errors_cleared': sum(1 for e in state.get('error_notebook', []) if e.get('reviewed'))
233
+ }
234
+
235
+ for badge_id, badge_info in self.BADGES.items():
236
+ if badge_id in current_badges:
237
+ continue
238
+
239
+ if self._check_badge_condition(badge_id, check_data):
240
+ earned.append({
241
+ 'id': badge_id,
242
+ 'name': badge_info['name'],
243
+ 'description': badge_info['description'],
244
+ 'gem_reward': badge_info['gem_reward']
245
+ })
246
+ current_badges.append(badge_id)
247
+
248
+ state['user']['badges'] = current_badges
249
+ return earned
250
+
251
+ def award_gems(self, state: Dict[str, Any], action: str, amount: int = 0) -> int:
252
+ """
253
+ Award gems for various actions.
254
+
255
+ Args:
256
+ state: Current state
257
+ action: Type of action
258
+ amount: Specific amount (overrides default)
259
+
260
+ Returns:
261
+ Number of gems awarded
262
+ """
263
+ GEM_REWARDS = {
264
+ 'quiz_complete': 5,
265
+ 'perfect_quiz': 10,
266
+ 'streak_milestone': 20,
267
+ 'level_up': 25,
268
+ 'badge_earned': 0, # Use badge-specific amount
269
+ 'daily_goal': 15
270
+ }
271
+
272
+ gems = amount if amount > 0 else GEM_REWARDS.get(action, 0)
273
+ state['user']['gems'] = state.get('user', {}).get('gems', 0) + gems
274
+
275
+ return gems
276
+
277
+ def spend_gems(self, state: Dict[str, Any], action: str) -> Tuple[bool, int]:
278
+ """
279
+ Spend gems on items.
280
+
281
+ Reserved for future gem economy features:
282
+ - Buy streak freeze: 50 gems
283
+ - Buy quiz hint: 10 gems
284
+
285
+ Args:
286
+ state: Current state
287
+ action: What to spend gems on
288
+
289
+ Returns:
290
+ Tuple of (success, gems_spent)
291
+ """ # noqa: DOC501 - Reserved for future use
292
+ GEM_COSTS = {
293
+ 'streak_freeze': STREAK_FREEZE_COST,
294
+ 'hint': HINT_COST
295
+ }
296
+
297
+ cost = GEM_COSTS.get(action, 0)
298
+ current_gems = state.get('user', {}).get('gems', 0)
299
+
300
+ if current_gems >= cost:
301
+ state['user']['gems'] = current_gems - cost
302
+
303
+ if action == 'streak_freeze':
304
+ state['user']['streak_freeze'] = state.get('user', {}).get('streak_freeze', 0) + 1
305
+
306
+ return (True, cost)
307
+
308
+ return (False, 0)
309
+
310
+ def get_progress_summary(self, state: Dict[str, Any]) -> Dict[str, Any]:
311
+ """
312
+ Get a summary of user's progress.
313
+
314
+ Args:
315
+ state: Current state
316
+
317
+ Returns:
318
+ Summary dictionary
319
+ """
320
+ user = state.get('user', {})
321
+ progress = state.get('progress', {})
322
+
323
+ xp = user.get('xp', 0)
324
+ level = user.get('level', 1)
325
+
326
+ # Calculate XP to next level
327
+ next_threshold = LEVEL_THRESHOLDS[min(level, 19)]
328
+ current_threshold = LEVEL_THRESHOLDS[level - 1]
329
+ xp_in_level = xp - current_threshold
330
+ xp_to_next = next_threshold - xp if level < 20 else 0
331
+
332
+ # Safe division for level progress
333
+ threshold_diff = next_threshold - current_threshold
334
+ if level < 20 and threshold_diff > 0:
335
+ level_progress = (xp_in_level / threshold_diff) * 100
336
+ else:
337
+ level_progress = 100.0 if level >= 20 else 0.0
338
+
339
+ return {
340
+ 'xp': xp,
341
+ 'level': level,
342
+ 'level_name': get_level_name(level),
343
+ 'xp_in_level': xp_in_level,
344
+ 'xp_to_next_level': xp_to_next,
345
+ 'level_progress': round(level_progress, 1),
346
+ 'streak': user.get('streak', 0),
347
+ 'streak_multiplier': get_streak_multiplier(user.get('streak', 0)),
348
+ 'gems': user.get('gems', 0),
349
+ 'badges': len(user.get('badges', [])),
350
+ 'total_badges': len(self.BADGES)
351
+ }
352
+
353
+
354
+ # CLI interface for testing
355
+ if __name__ == "__main__":
356
+ import argparse
357
+ import json
358
+
359
+ parser = argparse.ArgumentParser(description="Gamification for eng-lang-tutor")
360
+ parser.add_argument('--demo', action='store_true', help='Run demo')
361
+ parser.add_argument('--state', type=str, help='Path to state JSON file')
362
+
363
+ args = parser.parse_args()
364
+
365
+ gm = GamificationManager()
366
+
367
+ if args.demo:
368
+ state = {
369
+ "user": {
370
+ "xp": 1250,
371
+ "level": 7,
372
+ "streak": 5,
373
+ "streak_freeze": 2,
374
+ "gems": 100,
375
+ "badges": ["first_steps"]
376
+ },
377
+ "progress": {
378
+ "total_quizzes": 15,
379
+ "perfect_quizzes": 3,
380
+ "expressions_learned": 45,
381
+ "last_study_date": "2026-02-19"
382
+ },
383
+ "error_notebook": [
384
+ {"reviewed": True}, {"reviewed": True}, {"reviewed": False}
385
+ ]
386
+ }
387
+
388
+ print("=== Progress Summary ===")
389
+ summary = gm.get_progress_summary(state)
390
+ print(json.dumps(summary, indent=2))
391
+
392
+ print("\n=== Update Streak (today: 2026-02-20) ===")
393
+ streak, continued, msg = gm.update_streak(state, "2026-02-20")
394
+ print(f"Streak: {streak}, Continued: {continued}, Message: {msg}")
395
+
396
+ print("\n=== Check Badges ===")
397
+ new_badges = gm.check_badges(state)
398
+ print(f"New badges earned: {new_badges}")
399
+
400
+ elif args.state:
401
+ with open(args.state) as f:
402
+ state = json.load(f)
403
+
404
+ summary = gm.get_progress_summary(state)
405
+ print(json.dumps(summary, indent=2))
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Scorer - Answer evaluation and XP calculation for eng-lang-tutor.
4
+
5
+ XP Rules (Duolingo-style):
6
+ - Correct answer: +10-15 XP (based on difficulty)
7
+ - Perfect quiz (100%): +20 bonus XP
8
+ - Streak multiplier: 1.0 + (streak_days * 0.05), max 2.0
9
+ - Wrong answer: 0 XP, add to error notebook
10
+ """
11
+
12
+ from typing import Dict, Any, List, Tuple, Optional
13
+ from datetime import datetime
14
+
15
+ from .constants import LEVEL_THRESHOLDS, calculate_level, get_streak_multiplier
16
+
17
+
18
+ class Scorer:
19
+ """Handles answer evaluation and XP calculation."""
20
+
21
+ # Base XP values by question type
22
+ BASE_XP = {
23
+ 'multiple_choice': 10,
24
+ 'fill_blank': 12,
25
+ 'dialogue_completion': 15,
26
+ 'chinglish_fix': 15
27
+ }
28
+
29
+ # Bonus XP
30
+ PERFECT_QUIZ_BONUS = 20
31
+
32
+ def __init__(self, state_manager=None):
33
+ """
34
+ Initialize the scorer.
35
+
36
+ Args:
37
+ state_manager: Optional StateManager instance for state updates
38
+ """
39
+ self.state_manager = state_manager
40
+
41
+ # Wrapper methods for backward compatibility with tests
42
+ def calculate_level(self, xp: int) -> int:
43
+ """Wrapper for calculate_level function."""
44
+ return calculate_level(xp)
45
+
46
+ def get_streak_multiplier(self, streak: int) -> float:
47
+ """Wrapper for get_streak_multiplier function."""
48
+ return get_streak_multiplier(streak)
49
+
50
+ def evaluate_quiz(
51
+ self,
52
+ quiz: Dict[str, Any],
53
+ user_answers: Dict[str, Any],
54
+ state: Dict[str, Any]
55
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
56
+ """
57
+ Evaluate user answers and calculate XP.
58
+
59
+ Args:
60
+ quiz: Quiz dictionary with questions and correct answers
61
+ user_answers: User's answers (question_id -> answer)
62
+ state: Current state dictionary
63
+
64
+ Returns:
65
+ Tuple of (results, updated_state)
66
+ """
67
+ results = {
68
+ 'date': datetime.now().strftime('%Y-%m-%d'),
69
+ 'quiz_date': quiz.get('quiz_date', ''),
70
+ 'total_questions': len(quiz.get('questions', [])),
71
+ 'correct_count': 0,
72
+ 'wrong_count': 0,
73
+ 'base_xp': 0,
74
+ 'streak_multiplier': 1.0,
75
+ 'bonus_xp': 0,
76
+ 'total_xp_earned': 0,
77
+ 'accuracy': 0.0,
78
+ 'passed': False,
79
+ 'details': [],
80
+ 'errors': []
81
+ }
82
+
83
+ questions = quiz.get('questions', [])
84
+ total_base_xp = 0
85
+ correct_count = 0
86
+
87
+ for question in questions:
88
+ qid = str(question.get('id', ''))
89
+ user_answer = user_answers.get(qid, '').strip()
90
+ is_correct = self._check_answer(question, user_answer)
91
+
92
+ question_type = question.get('type', 'multiple_choice')
93
+ base_xp = self.BASE_XP.get(question_type, 10)
94
+
95
+ detail = {
96
+ 'question_id': qid,
97
+ 'question_type': question_type,
98
+ 'user_answer': user_answer,
99
+ 'correct_answer': str(question.get('correct_answer', '')),
100
+ 'is_correct': is_correct,
101
+ 'base_xp': base_xp if is_correct else 0
102
+ }
103
+
104
+ if is_correct:
105
+ total_base_xp += base_xp
106
+ correct_count += 1
107
+ else:
108
+ # Record error
109
+ results['errors'].append({
110
+ 'question_id': qid,
111
+ 'question': question.get('question', ''),
112
+ 'question_type': question_type,
113
+ 'user_answer': user_answer,
114
+ 'correct_answer': question.get('correct_answer', ''),
115
+ 'explanation': question.get('explanation', '')
116
+ })
117
+
118
+ results['details'].append(detail)
119
+
120
+ # Calculate accuracy
121
+ total_questions = len(questions)
122
+ accuracy = (correct_count / total_questions * 100) if total_questions > 0 else 0
123
+
124
+ # Calculate streak multiplier
125
+ streak = state.get('user', {}).get('streak', 0)
126
+ streak_multiplier = get_streak_multiplier(streak)
127
+
128
+ # Calculate total XP with streak bonus
129
+ total_xp = int(total_base_xp * streak_multiplier)
130
+
131
+ # Perfect quiz bonus
132
+ bonus_xp = 0
133
+ if correct_count == total_questions and total_questions > 0:
134
+ bonus_xp = self.PERFECT_QUIZ_BONUS
135
+ total_xp += bonus_xp
136
+
137
+ # Update results
138
+ results['correct_count'] = correct_count
139
+ results['wrong_count'] = total_questions - correct_count
140
+ results['base_xp'] = total_base_xp
141
+ results['streak_multiplier'] = streak_multiplier
142
+ results['bonus_xp'] = bonus_xp
143
+ results['total_xp_earned'] = total_xp
144
+ results['accuracy'] = round(accuracy, 1)
145
+ results['passed'] = accuracy >= quiz.get('passing_score', 70)
146
+
147
+ # Update state
148
+ updated_state = self._update_state(state, results)
149
+
150
+ return results, updated_state
151
+
152
+ def _check_answer(self, question: Dict[str, Any], user_answer: str) -> bool:
153
+ """
154
+ Check if user answer matches correct answer.
155
+
156
+ Args:
157
+ question: Question dictionary with correct_answer
158
+ user_answer: User's answer string
159
+
160
+ Returns:
161
+ True if correct, False otherwise
162
+ """
163
+ correct = str(question.get('correct_answer', '')).strip()
164
+ user = user_answer.strip()
165
+
166
+ # Case-insensitive comparison
167
+ return user.lower() == correct.lower()
168
+
169
+ def _update_state(self, state: Dict[str, Any], results: Dict[str, Any]) -> Dict[str, Any]:
170
+ """
171
+ Update state with quiz results (modifies state in place).
172
+
173
+ Args:
174
+ state: Current state (will be modified)
175
+ results: Quiz evaluation results
176
+
177
+ Returns:
178
+ The same state dict (for convenience)
179
+ """
180
+ # Update XP
181
+ state['user']['xp'] = state.get('user', {}).get('xp', 0) + results['total_xp_earned']
182
+
183
+ # Update progress
184
+ progress = state.get('progress', {})
185
+ old_total = progress.get('total_quizzes', 0)
186
+ old_rate = progress.get('correct_rate', 0.0)
187
+
188
+ # Update correct rate (running average)
189
+ new_total = old_total + 1
190
+ new_rate = ((old_rate * old_total) + results['accuracy']) / new_total
191
+
192
+ progress['total_quizzes'] = new_total
193
+ progress['correct_rate'] = round(new_rate, 1)
194
+ progress['last_study_date'] = results['date']
195
+
196
+ # Track perfect quizzes
197
+ if results['accuracy'] == 100:
198
+ progress['perfect_quizzes'] = progress.get('perfect_quizzes', 0) + 1
199
+
200
+ state['progress'] = progress
201
+
202
+ # Add errors to notebook
203
+ if results['errors']:
204
+ error_notebook = state.get('error_notebook', [])
205
+ for error in results['errors']:
206
+ error_entry = {
207
+ 'date': results['date'],
208
+ 'question': error.get('question', ''),
209
+ 'user_answer': error.get('user_answer', ''),
210
+ 'correct_answer': error.get('correct_answer', ''),
211
+ 'explanation': error.get('explanation', ''),
212
+ 'reviewed': False
213
+ }
214
+ error_notebook.append(error_entry)
215
+ state['error_notebook'] = error_notebook
216
+
217
+ return state
218
+
219
+ def get_xp_for_next_level(self, current_xp: int) -> Tuple[int, int]:
220
+ """
221
+ Get XP needed for next level.
222
+
223
+ Args:
224
+ current_xp: Current total XP
225
+
226
+ Returns:
227
+ Tuple of (xp_needed, xp_into_current_level)
228
+ """
229
+ current_level = calculate_level(current_xp)
230
+
231
+ if current_level >= 20:
232
+ return (0, current_xp - LEVEL_THRESHOLDS[-1])
233
+
234
+ next_threshold = LEVEL_THRESHOLDS[current_level]
235
+ current_threshold = LEVEL_THRESHOLDS[current_level - 1]
236
+
237
+ xp_needed = next_threshold - current_xp
238
+ xp_into_level = current_xp - current_threshold
239
+
240
+ return (xp_needed, xp_into_level)
241
+
242
+
243
+ # CLI interface for testing
244
+ if __name__ == "__main__":
245
+ import argparse
246
+ import json
247
+
248
+ parser = argparse.ArgumentParser(description="Scorer for eng-lang-tutor")
249
+ parser.add_argument('--quiz', type=str, help='Path to quiz JSON file')
250
+ parser.add_argument('--answers', type=str, help='Path to user answers JSON file')
251
+ parser.add_argument('--state', type=str, help='Path to state JSON file')
252
+ parser.add_argument('--demo', action='store_true', help='Run demo')
253
+
254
+ args = parser.parse_args()
255
+
256
+ scorer = Scorer()
257
+
258
+ if args.demo:
259
+ # Demo quiz
260
+ quiz = {
261
+ "quiz_date": "2026-02-20",
262
+ "questions": [
263
+ {"id": 1, "type": "multiple_choice", "correct_answer": "B", "xp_value": 10},
264
+ {"id": 2, "type": "fill_blank", "correct_answer": "gonna", "xp_value": 12},
265
+ {"id": 3, "type": "dialogue_completion", "correct_answer": "Touch base", "xp_value": 15}
266
+ ],
267
+ "passing_score": 70
268
+ }
269
+
270
+ # Demo answers (2 correct, 1 wrong)
271
+ answers = {"1": "B", "2": "gonna", "3": "wrong answer"}
272
+
273
+ # Demo state
274
+ state = {
275
+ "user": {"xp": 100, "streak": 5, "level": 2},
276
+ "progress": {"total_quizzes": 10, "correct_rate": 75.0}
277
+ }
278
+
279
+ results, updated = scorer.evaluate_quiz(quiz, answers, state)
280
+ print("Results:")
281
+ print(json.dumps(results, indent=2))
282
+ print("\nUpdated State (user section):")
283
+ print(json.dumps(updated['user'], indent=2))
284
+ print(json.dumps(updated['progress'], indent=2))
285
+
286
+ elif args.quiz and args.answers and args.state:
287
+ with open(args.quiz) as f:
288
+ quiz = json.load(f)
289
+ with open(args.answers) as f:
290
+ answers = json.load(f)
291
+ with open(args.state) as f:
292
+ state = json.load(f)
293
+
294
+ results, updated = scorer.evaluate_quiz(quiz, answers, state)
295
+ print(json.dumps(results, indent=2))