@rookiestar/eng-lang-tutor 1.0.0
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.
Potentially problematic release.
This version of @rookiestar/eng-lang-tutor might be problematic. Click here for more details.
- package/CLAUDE.md +259 -0
- package/README.md +224 -0
- package/README_EN.md +224 -0
- package/SKILL.md +495 -0
- package/bin/eng-lang-tutor.js +177 -0
- package/docs/OPENCLAW_DEPLOYMENT.md +228 -0
- package/examples/sample_keypoint.json +130 -0
- package/examples/sample_quiz.json +92 -0
- package/npm-scripts/install.js +132 -0
- package/package.json +46 -0
- package/references/resources.md +292 -0
- package/requirements.txt +9 -0
- package/scripts/command_parser.py +336 -0
- package/scripts/cron_push.py +226 -0
- package/scripts/dedup.py +325 -0
- package/scripts/gamification.py +406 -0
- package/scripts/scorer.py +323 -0
- package/scripts/state_manager.py +1025 -0
- package/scripts/tts/__init__.py +30 -0
- package/scripts/tts/base.py +109 -0
- package/scripts/tts/manager.py +290 -0
- package/scripts/tts/providers/__init__.py +10 -0
- package/scripts/tts/providers/xunfei.py +192 -0
- package/templates/keypoint_schema.json +420 -0
- package/templates/prompt_templates.md +1261 -0
- package/templates/quiz_schema.json +201 -0
- package/templates/state_schema.json +241 -0
|
@@ -0,0 +1,406 @@
|
|
|
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
|
+
|
|
18
|
+
class GamificationManager:
|
|
19
|
+
"""Manages gamification elements: levels, streaks, badges, gems."""
|
|
20
|
+
|
|
21
|
+
# Level thresholds (XP needed for each level)
|
|
22
|
+
LEVEL_THRESHOLDS = [
|
|
23
|
+
0, 50, 100, 200, 350, # 1-5 Beginner
|
|
24
|
+
550, 800, 1100, 1500, 2000, # 6-10 Intermediate
|
|
25
|
+
2600, 3300, 4100, 5000, 6000, # 11-15 Advanced
|
|
26
|
+
7200, 8500, 10000, 12000, 15000 # 16-20 Expert
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Badge definitions
|
|
30
|
+
BADGES = {
|
|
31
|
+
'first_steps': {
|
|
32
|
+
'name': 'First Steps',
|
|
33
|
+
'description': 'Complete your first quiz',
|
|
34
|
+
'condition': 'total_quizzes >= 1',
|
|
35
|
+
'gem_reward': 10
|
|
36
|
+
},
|
|
37
|
+
'week_warrior': {
|
|
38
|
+
'name': 'Week Warrior',
|
|
39
|
+
'description': '7-day streak',
|
|
40
|
+
'condition': 'streak >= 7',
|
|
41
|
+
'gem_reward': 25
|
|
42
|
+
},
|
|
43
|
+
'month_master': {
|
|
44
|
+
'name': 'Month Master',
|
|
45
|
+
'description': '30-day streak',
|
|
46
|
+
'condition': 'streak >= 30',
|
|
47
|
+
'gem_reward': 100
|
|
48
|
+
},
|
|
49
|
+
'perfect_10': {
|
|
50
|
+
'name': 'Perfect 10',
|
|
51
|
+
'description': '10 perfect quizzes',
|
|
52
|
+
'condition': 'perfect_quizzes >= 10',
|
|
53
|
+
'gem_reward': 50
|
|
54
|
+
},
|
|
55
|
+
'vocab_hunter': {
|
|
56
|
+
'name': 'Vocab Hunter',
|
|
57
|
+
'description': 'Learn 100 expressions',
|
|
58
|
+
'condition': 'expressions_learned >= 100',
|
|
59
|
+
'gem_reward': 75
|
|
60
|
+
},
|
|
61
|
+
'error_slayer': {
|
|
62
|
+
'name': 'Error Slayer',
|
|
63
|
+
'description': 'Clear 30 errors from notebook',
|
|
64
|
+
'condition': 'errors_cleared >= 30',
|
|
65
|
+
'gem_reward': 30
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Gem costs
|
|
70
|
+
STREAK_FREEZE_COST = 50
|
|
71
|
+
HINT_COST = 5
|
|
72
|
+
|
|
73
|
+
def __init__(self, state_manager=None):
|
|
74
|
+
"""
|
|
75
|
+
Initialize the gamification manager.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
state_manager: Optional StateManager instance
|
|
79
|
+
"""
|
|
80
|
+
self.state_manager = state_manager
|
|
81
|
+
|
|
82
|
+
def update_streak(self, state: Dict[str, Any], study_date: str) -> Tuple[int, bool, str]:
|
|
83
|
+
"""
|
|
84
|
+
Update streak based on study activity.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
state: Current state dictionary
|
|
88
|
+
study_date: Date of study (YYYY-MM-DD)
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Tuple of (new_streak, streak_continued, message)
|
|
92
|
+
"""
|
|
93
|
+
user = state.get('user', {})
|
|
94
|
+
progress = state.get('progress', {})
|
|
95
|
+
|
|
96
|
+
last_study = progress.get('last_study_date')
|
|
97
|
+
current_streak = user.get('streak', 0)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
today = datetime.strptime(study_date, '%Y-%m-%d').date()
|
|
101
|
+
except ValueError:
|
|
102
|
+
return (current_streak, False, "Invalid date format")
|
|
103
|
+
|
|
104
|
+
if last_study:
|
|
105
|
+
try:
|
|
106
|
+
last = datetime.strptime(last_study, '%Y-%m-%d').date()
|
|
107
|
+
except ValueError:
|
|
108
|
+
last = None
|
|
109
|
+
|
|
110
|
+
if last == today:
|
|
111
|
+
# Already studied today
|
|
112
|
+
return (current_streak, False, "Already studied today")
|
|
113
|
+
|
|
114
|
+
days_diff = (today - last).days
|
|
115
|
+
|
|
116
|
+
if days_diff == 1:
|
|
117
|
+
# Consecutive day - streak continues
|
|
118
|
+
new_streak = current_streak + 1
|
|
119
|
+
state['user']['streak'] = new_streak
|
|
120
|
+
return (new_streak, True, f"Streak continued! {new_streak} days")
|
|
121
|
+
|
|
122
|
+
elif days_diff == 2 and user.get('streak_freeze', 0) > 0:
|
|
123
|
+
# Use streak freeze
|
|
124
|
+
state['user']['streak_freeze'] -= 1
|
|
125
|
+
new_streak = current_streak + 1
|
|
126
|
+
state['user']['streak'] = new_streak
|
|
127
|
+
return (new_streak, True, f"Streak freeze used! {new_streak} days")
|
|
128
|
+
|
|
129
|
+
else:
|
|
130
|
+
# Streak broken
|
|
131
|
+
state['user']['streak'] = 1
|
|
132
|
+
return (1, False, f"Streak broken. Starting over at 1 day")
|
|
133
|
+
|
|
134
|
+
else:
|
|
135
|
+
# First study ever
|
|
136
|
+
state['user']['streak'] = 1
|
|
137
|
+
return (1, True, "Started your streak!")
|
|
138
|
+
|
|
139
|
+
def calculate_level(self, xp: int) -> int:
|
|
140
|
+
"""
|
|
141
|
+
Calculate level from total XP.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
xp: Total experience points
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Level (1-20)
|
|
148
|
+
"""
|
|
149
|
+
for i in range(len(self.LEVEL_THRESHOLDS) - 1, -1, -1):
|
|
150
|
+
if xp >= self.LEVEL_THRESHOLDS[i]:
|
|
151
|
+
return i + 1
|
|
152
|
+
return 1
|
|
153
|
+
|
|
154
|
+
def get_level_name(self, level: int) -> str:
|
|
155
|
+
"""Get the name for a level range (Journey Stage).
|
|
156
|
+
|
|
157
|
+
Note: This is Activity Level (活跃等级), measuring engagement depth,
|
|
158
|
+
NOT language ability. For language ability, see CEFR level.
|
|
159
|
+
"""
|
|
160
|
+
if level <= 5:
|
|
161
|
+
return "Starter" # 启程者
|
|
162
|
+
elif level <= 10:
|
|
163
|
+
return "Traveler" # 行路人
|
|
164
|
+
elif level <= 15:
|
|
165
|
+
return "Explorer" # 探索者
|
|
166
|
+
else:
|
|
167
|
+
return "Pioneer" # 开拓者
|
|
168
|
+
|
|
169
|
+
def check_level_up(self, old_xp: int, new_xp: int) -> Tuple[bool, Optional[int]]:
|
|
170
|
+
"""
|
|
171
|
+
Check if user leveled up.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
old_xp: XP before this session
|
|
175
|
+
new_xp: XP after this session
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Tuple of (leveled_up, new_level)
|
|
179
|
+
"""
|
|
180
|
+
old_level = self.calculate_level(old_xp)
|
|
181
|
+
new_level = self.calculate_level(new_xp)
|
|
182
|
+
|
|
183
|
+
if new_level > old_level:
|
|
184
|
+
return (True, new_level)
|
|
185
|
+
return (False, None)
|
|
186
|
+
|
|
187
|
+
def update_level(self, state: Dict[str, Any]) -> Tuple[int, bool]:
|
|
188
|
+
"""
|
|
189
|
+
Update level based on current XP.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
state: Current state (will be modified in place)
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Tuple of (level, just_leveled_up)
|
|
196
|
+
"""
|
|
197
|
+
xp = state.get('user', {}).get('xp', 0)
|
|
198
|
+
current_level = state.get('user', {}).get('level', 1)
|
|
199
|
+
new_level = self.calculate_level(xp)
|
|
200
|
+
|
|
201
|
+
state['user']['level'] = new_level
|
|
202
|
+
|
|
203
|
+
leveled_up = new_level > current_level
|
|
204
|
+
return (new_level, leveled_up)
|
|
205
|
+
|
|
206
|
+
def check_badges(self, state: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
207
|
+
"""
|
|
208
|
+
Check and award new badges.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
state: Current state
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of newly earned badges with details
|
|
215
|
+
"""
|
|
216
|
+
earned = []
|
|
217
|
+
current_badges = state.get('user', {}).get('badges', [])
|
|
218
|
+
progress = state.get('progress', {})
|
|
219
|
+
user = state.get('user', {})
|
|
220
|
+
|
|
221
|
+
# Prepare badge check data
|
|
222
|
+
check_data = {
|
|
223
|
+
'total_quizzes': progress.get('total_quizzes', 0),
|
|
224
|
+
'streak': user.get('streak', 0),
|
|
225
|
+
'perfect_quizzes': progress.get('perfect_quizzes', 0),
|
|
226
|
+
'expressions_learned': progress.get('expressions_learned', 0),
|
|
227
|
+
'errors_cleared': sum(1 for e in state.get('error_notebook', []) if e.get('reviewed'))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for badge_id, badge_info in self.BADGES.items():
|
|
231
|
+
if badge_id in current_badges:
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
condition = badge_info['condition']
|
|
235
|
+
try:
|
|
236
|
+
if eval(condition, {"__builtins__": {}}, check_data):
|
|
237
|
+
earned.append({
|
|
238
|
+
'id': badge_id,
|
|
239
|
+
'name': badge_info['name'],
|
|
240
|
+
'description': badge_info['description'],
|
|
241
|
+
'gem_reward': badge_info['gem_reward']
|
|
242
|
+
})
|
|
243
|
+
current_badges.append(badge_id)
|
|
244
|
+
except:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
state['user']['badges'] = current_badges
|
|
248
|
+
return earned
|
|
249
|
+
|
|
250
|
+
def award_gems(self, state: Dict[str, Any], action: str, amount: int = 0) -> int:
|
|
251
|
+
"""
|
|
252
|
+
Award gems for various actions.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
state: Current state
|
|
256
|
+
action: Type of action
|
|
257
|
+
amount: Specific amount (overrides default)
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Number of gems awarded
|
|
261
|
+
"""
|
|
262
|
+
GEM_REWARDS = {
|
|
263
|
+
'quiz_complete': 5,
|
|
264
|
+
'perfect_quiz': 10,
|
|
265
|
+
'streak_milestone': 20,
|
|
266
|
+
'level_up': 25,
|
|
267
|
+
'badge_earned': 0, # Use badge-specific amount
|
|
268
|
+
'daily_goal': 15
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
gems = amount if amount > 0 else GEM_REWARDS.get(action, 0)
|
|
272
|
+
state['user']['gems'] = state.get('user', {}).get('gems', 0) + gems
|
|
273
|
+
|
|
274
|
+
return gems
|
|
275
|
+
|
|
276
|
+
def spend_gems(self, state: Dict[str, Any], action: str) -> Tuple[bool, int]:
|
|
277
|
+
"""
|
|
278
|
+
Spend gems on items.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
state: Current state
|
|
282
|
+
action: What to spend gems on
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Tuple of (success, gems_spent)
|
|
286
|
+
"""
|
|
287
|
+
GEM_COSTS = {
|
|
288
|
+
'streak_freeze': self.STREAK_FREEZE_COST,
|
|
289
|
+
'hint': self.HINT_COST
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
cost = GEM_COSTS.get(action, 0)
|
|
293
|
+
current_gems = state.get('user', {}).get('gems', 0)
|
|
294
|
+
|
|
295
|
+
if current_gems >= cost:
|
|
296
|
+
state['user']['gems'] = current_gems - cost
|
|
297
|
+
|
|
298
|
+
if action == 'streak_freeze':
|
|
299
|
+
state['user']['streak_freeze'] = state.get('user', {}).get('streak_freeze', 0) + 1
|
|
300
|
+
|
|
301
|
+
return (True, cost)
|
|
302
|
+
|
|
303
|
+
return (False, 0)
|
|
304
|
+
|
|
305
|
+
def get_streak_multiplier(self, streak: int) -> float:
|
|
306
|
+
"""
|
|
307
|
+
Calculate XP multiplier based on streak.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
streak: Current streak days
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Multiplier (1.0 to 2.0)
|
|
314
|
+
"""
|
|
315
|
+
return min(1.0 + (streak * 0.05), 2.0)
|
|
316
|
+
|
|
317
|
+
def get_progress_summary(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
318
|
+
"""
|
|
319
|
+
Get a summary of user's progress.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
state: Current state
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Summary dictionary
|
|
326
|
+
"""
|
|
327
|
+
user = state.get('user', {})
|
|
328
|
+
progress = state.get('progress', {})
|
|
329
|
+
|
|
330
|
+
xp = user.get('xp', 0)
|
|
331
|
+
level = user.get('level', 1)
|
|
332
|
+
|
|
333
|
+
# Calculate XP to next level
|
|
334
|
+
next_threshold = self.LEVEL_THRESHOLDS[min(level, 19)]
|
|
335
|
+
current_threshold = self.LEVEL_THRESHOLDS[level - 1]
|
|
336
|
+
xp_in_level = xp - current_threshold
|
|
337
|
+
xp_to_next = next_threshold - xp if level < 20 else 0
|
|
338
|
+
level_progress = (xp_in_level / (next_threshold - current_threshold)) * 100 if level < 20 else 100
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
'xp': xp,
|
|
342
|
+
'level': level,
|
|
343
|
+
'level_name': self.get_level_name(level),
|
|
344
|
+
'xp_in_level': xp_in_level,
|
|
345
|
+
'xp_to_next_level': xp_to_next,
|
|
346
|
+
'level_progress': round(level_progress, 1),
|
|
347
|
+
'streak': user.get('streak', 0),
|
|
348
|
+
'streak_multiplier': self.get_streak_multiplier(user.get('streak', 0)),
|
|
349
|
+
'gems': user.get('gems', 0),
|
|
350
|
+
'badges': len(user.get('badges', [])),
|
|
351
|
+
'total_badges': len(self.BADGES)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# CLI interface for testing
|
|
356
|
+
if __name__ == "__main__":
|
|
357
|
+
import argparse
|
|
358
|
+
import json
|
|
359
|
+
|
|
360
|
+
parser = argparse.ArgumentParser(description="Gamification for eng-lang-tutor")
|
|
361
|
+
parser.add_argument('--demo', action='store_true', help='Run demo')
|
|
362
|
+
parser.add_argument('--state', type=str, help='Path to state JSON file')
|
|
363
|
+
|
|
364
|
+
args = parser.parse_args()
|
|
365
|
+
|
|
366
|
+
gm = GamificationManager()
|
|
367
|
+
|
|
368
|
+
if args.demo:
|
|
369
|
+
state = {
|
|
370
|
+
"user": {
|
|
371
|
+
"xp": 1250,
|
|
372
|
+
"level": 7,
|
|
373
|
+
"streak": 5,
|
|
374
|
+
"streak_freeze": 2,
|
|
375
|
+
"gems": 100,
|
|
376
|
+
"badges": ["first_steps"]
|
|
377
|
+
},
|
|
378
|
+
"progress": {
|
|
379
|
+
"total_quizzes": 15,
|
|
380
|
+
"perfect_quizzes": 3,
|
|
381
|
+
"expressions_learned": 45,
|
|
382
|
+
"last_study_date": "2026-02-19"
|
|
383
|
+
},
|
|
384
|
+
"error_notebook": [
|
|
385
|
+
{"reviewed": True}, {"reviewed": True}, {"reviewed": False}
|
|
386
|
+
]
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
print("=== Progress Summary ===")
|
|
390
|
+
summary = gm.get_progress_summary(state)
|
|
391
|
+
print(json.dumps(summary, indent=2))
|
|
392
|
+
|
|
393
|
+
print("\n=== Update Streak (today: 2026-02-20) ===")
|
|
394
|
+
streak, continued, msg = gm.update_streak(state, "2026-02-20")
|
|
395
|
+
print(f"Streak: {streak}, Continued: {continued}, Message: {msg}")
|
|
396
|
+
|
|
397
|
+
print("\n=== Check Badges ===")
|
|
398
|
+
new_badges = gm.check_badges(state)
|
|
399
|
+
print(f"New badges earned: {new_badges}")
|
|
400
|
+
|
|
401
|
+
elif args.state:
|
|
402
|
+
with open(args.state) as f:
|
|
403
|
+
state = json.load(f)
|
|
404
|
+
|
|
405
|
+
summary = gm.get_progress_summary(state)
|
|
406
|
+
print(json.dumps(summary, indent=2))
|