@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.
- package/.claude/settings.local.json +22 -0
- package/.gitignore +32 -0
- package/CHANGELOG.md +37 -0
- package/CLAUDE.md +275 -0
- package/README.md +369 -0
- package/SKILL.md +613 -0
- package/bin/eng-lang-tutor.js +177 -0
- package/docs/OPENCLAW_DEPLOYMENT.md +241 -0
- package/examples/sample_keypoint_a1.json +112 -0
- package/examples/sample_keypoint_a2.json +124 -0
- package/examples/sample_keypoint_b1.json +135 -0
- package/examples/sample_keypoint_b2.json +137 -0
- package/examples/sample_keypoint_c1.json +134 -0
- package/examples/sample_keypoint_c2.json +141 -0
- package/examples/sample_quiz_a1.json +94 -0
- package/examples/sample_quiz_a2.json +94 -0
- package/examples/sample_quiz_b1.json +92 -0
- package/examples/sample_quiz_b2.json +94 -0
- package/examples/sample_quiz_c1.json +94 -0
- package/examples/sample_quiz_c2.json +104 -0
- package/package.json +41 -0
- package/references/resources.md +292 -0
- package/requirements.txt +16 -0
- package/scripts/__init__.py +28 -0
- package/scripts/audio/__init__.py +23 -0
- package/scripts/audio/composer.py +367 -0
- package/scripts/audio/converter.py +331 -0
- package/scripts/audio/feishu_voice.py +404 -0
- package/scripts/audio/tts/__init__.py +30 -0
- package/scripts/audio/tts/base.py +166 -0
- package/scripts/audio/tts/manager.py +306 -0
- package/scripts/audio/tts/providers/__init__.py +12 -0
- package/scripts/audio/tts/providers/edge.py +111 -0
- package/scripts/audio/tts/providers/xunfei.py +205 -0
- package/scripts/audio/utils.py +63 -0
- package/scripts/cli/__init__.py +7 -0
- package/scripts/cli/cli.py +229 -0
- package/scripts/cli/command_parser.py +336 -0
- package/scripts/core/__init__.py +30 -0
- package/scripts/core/constants.py +125 -0
- package/scripts/core/error_notebook.py +308 -0
- package/scripts/core/gamification.py +405 -0
- package/scripts/core/scorer.py +295 -0
- package/scripts/core/state_manager.py +814 -0
- package/scripts/eng-lang-tutor +16 -0
- package/scripts/scheduling/__init__.py +6 -0
- package/scripts/scheduling/cron_push.py +229 -0
- package/scripts/utils/__init__.py +12 -0
- package/scripts/utils/dedup.py +331 -0
- package/scripts/utils/helpers.py +82 -0
- package/templates/keypoint_schema.json +420 -0
- package/templates/prompt_templates.md +73 -0
- package/templates/prompts/display_guide.md +106 -0
- package/templates/prompts/initialization.md +350 -0
- package/templates/prompts/keypoint_generation.md +272 -0
- package/templates/prompts/output_rules.md +106 -0
- package/templates/prompts/quiz_generation.md +190 -0
- package/templates/prompts/responses.md +339 -0
- package/templates/prompts/shared_enums.md +252 -0
- package/templates/quiz_schema.json +214 -0
- 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))
|