@rookiestar/eng-lang-tutor 1.0.2 → 1.0.3

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.

Files changed (35) hide show
  1. package/CLAUDE.md +5 -4
  2. package/README.md +16 -1
  3. package/README_EN.md +16 -1
  4. package/SKILL.md +113 -92
  5. package/docs/OPENCLAW_DEPLOYMENT.md +7 -4
  6. package/package.json +1 -1
  7. package/scripts/__pycache__/cli.cpython-313.pyc +0 -0
  8. package/scripts/__pycache__/command_parser.cpython-313.pyc +0 -0
  9. package/scripts/__pycache__/constants.cpython-313.pyc +0 -0
  10. package/scripts/__pycache__/cron_push.cpython-313.pyc +0 -0
  11. package/scripts/__pycache__/dedup.cpython-313.pyc +0 -0
  12. package/scripts/__pycache__/gamification.cpython-313.pyc +0 -0
  13. package/scripts/__pycache__/scorer.cpython-313.pyc +0 -0
  14. package/scripts/__pycache__/state_manager.cpython-313.pyc +0 -0
  15. package/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  16. package/scripts/cli.py +203 -0
  17. package/scripts/constants.py +56 -0
  18. package/scripts/gamification.py +48 -34
  19. package/scripts/scorer.py +14 -32
  20. package/scripts/state_manager.py +41 -206
  21. package/scripts/tts/__pycache__/__init__.cpython-313.pyc +0 -0
  22. package/scripts/tts/__pycache__/base.cpython-313.pyc +0 -0
  23. package/scripts/tts/__pycache__/manager.cpython-313.pyc +0 -0
  24. package/scripts/tts/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  25. package/scripts/tts/providers/__pycache__/xunfei.cpython-313.pyc +0 -0
  26. package/scripts/utils.py +78 -0
  27. package/templates/prompt_templates.md +47 -1235
  28. package/templates/prompts/display_guide.md +106 -0
  29. package/templates/prompts/initialization.md +213 -0
  30. package/templates/prompts/keypoint_generation.md +268 -0
  31. package/templates/prompts/output_rules.md +106 -0
  32. package/templates/prompts/quiz_generation.md +187 -0
  33. package/templates/prompts/responses.md +291 -0
  34. package/templates/prompts/shared_enums.md +97 -0
  35. package/templates/state_schema.json +1 -6
package/scripts/cli.py ADDED
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CLI interface for eng-lang-tutor state management.
4
+
5
+ This module provides command-line access to state management operations.
6
+ """
7
+
8
+ import argparse
9
+ import json
10
+ from datetime import datetime
11
+ from typing import Optional
12
+
13
+ from state_manager import StateManager
14
+
15
+
16
+ def main():
17
+ """Main CLI entry point."""
18
+ parser = argparse.ArgumentParser(description="State Manager for eng-lang-tutor")
19
+ parser.add_argument('--data-dir', default=None,
20
+ help='Data directory path (default: ~/.openclaw/state/eng-lang-tutor or OPENCLAW_STATE_DIR env)')
21
+ parser.add_argument('command', nargs='?',
22
+ choices=['show', 'backup', 'save_daily', 'record_view',
23
+ 'stats', 'config', 'errors', 'schedule'],
24
+ help='Command to execute')
25
+ parser.add_argument('--content-type', help='Content type for save_daily (keypoint, quiz)')
26
+ parser.add_argument('--content', help='JSON content for save_daily')
27
+ parser.add_argument('--date', help='Date for content (YYYY-MM-DD format)')
28
+ # Errors command options
29
+ parser.add_argument('--page', type=int, default=1, help='Page number for errors list')
30
+ parser.add_argument('--per-page', type=int, default=5, help='Items per page for errors')
31
+ parser.add_argument('--month', help='Filter errors by month (YYYY-MM)')
32
+ parser.add_argument('--random', type=int, help='Get N random errors')
33
+ parser.add_argument('--stats', action='store_true', help='Get error statistics')
34
+ parser.add_argument('--review', type=int, help='Get N errors for review session')
35
+ # Config command options
36
+ parser.add_argument('--cefr', help='Set CEFR level (A1-C2)')
37
+ parser.add_argument('--style', help='Set tutor style')
38
+ parser.add_argument('--oral-ratio', type=int, help='Set oral/written ratio (0-100)')
39
+ # Schedule command options
40
+ parser.add_argument('--keypoint-time', help='Set keypoint push time (HH:MM)')
41
+ parser.add_argument('--quiz-time', help='Set quiz push time (HH:MM)')
42
+
43
+ args = parser.parse_args()
44
+
45
+ sm = StateManager(args.data_dir)
46
+
47
+ if args.command == 'show' or not args.command:
48
+ state = sm.load_state()
49
+ print(json.dumps(state, indent=2, ensure_ascii=False))
50
+
51
+ elif args.command == 'backup':
52
+ backup_path = sm.backup_state()
53
+ print(f"Backup created: {backup_path}")
54
+
55
+ elif args.command == 'save_daily':
56
+ if not args.content_type or not args.content:
57
+ print("Error: --content-type and --content are required for save_daily")
58
+ exit(1)
59
+
60
+ try:
61
+ content = json.loads(args.content)
62
+ except json.JSONDecodeError as e:
63
+ print(f"Error: Invalid JSON content: {e}")
64
+ exit(1)
65
+
66
+ target_date = None
67
+ if args.date:
68
+ try:
69
+ target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
70
+ except ValueError:
71
+ print("Error: Invalid date format. Use YYYY-MM-DD")
72
+ exit(1)
73
+
74
+ path = sm.save_daily_content(args.content_type, content, target_date)
75
+ print(f"Saved to: {path}")
76
+
77
+ elif args.command == 'record_view':
78
+ target_date = None
79
+ if args.date:
80
+ try:
81
+ target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
82
+ except ValueError:
83
+ print("Error: Invalid date format. Use YYYY-MM-DD")
84
+ exit(1)
85
+
86
+ state = sm.load_state()
87
+ sm.record_keypoint_view(state, target_date)
88
+ sm.save_state(state)
89
+ print("View recorded successfully")
90
+
91
+ elif args.command == 'stats':
92
+ """Display learning progress summary."""
93
+ from gamification import GamificationManager
94
+ state = sm.load_state()
95
+ gm = GamificationManager()
96
+ summary = gm.get_progress_summary(state)
97
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
98
+
99
+ elif args.command == 'config':
100
+ """Display or update user configuration."""
101
+ state = sm.load_state()
102
+
103
+ # If no update options, just show current config
104
+ if not any([args.cefr, args.style, args.oral_ratio is not None]):
105
+ config = {
106
+ "cefr_level": state.get("preferences", {}).get("cefr_level", "B1"),
107
+ "tutor_style": state.get("preferences", {}).get("tutor_style", "humorous"),
108
+ "oral_ratio": state.get("preferences", {}).get("oral_ratio", 70),
109
+ "topic_weights": state.get("preferences", {}).get("topic_weights", {}),
110
+ "schedule": state.get("schedule", {})
111
+ }
112
+ print(json.dumps(config, indent=2, ensure_ascii=False))
113
+ else:
114
+ # Update configuration
115
+ if args.cefr:
116
+ if args.cefr not in ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']:
117
+ print("Error: Invalid CEFR level. Must be A1, A2, B1, B2, C1, or C2")
118
+ exit(1)
119
+ state = sm.update_preferences(state, cefr_level=args.cefr)
120
+ print(f"Updated CEFR level to: {args.cefr}")
121
+
122
+ if args.style:
123
+ if args.style not in ['humorous', 'rigorous', 'casual', 'professional']:
124
+ print("Error: Invalid style. Must be humorous, rigorous, casual, or professional")
125
+ exit(1)
126
+ state = sm.update_preferences(state, tutor_style=args.style)
127
+ print(f"Updated tutor style to: {args.style}")
128
+
129
+ if args.oral_ratio is not None:
130
+ if not 0 <= args.oral_ratio <= 100:
131
+ print("Error: Oral ratio must be between 0 and 100")
132
+ exit(1)
133
+ state = sm.update_preferences(state, oral_ratio=args.oral_ratio)
134
+ print(f"Updated oral ratio to: {args.oral_ratio}%")
135
+
136
+ sm.save_state(state)
137
+ print("Configuration updated successfully")
138
+
139
+ elif args.command == 'errors':
140
+ """Error notebook operations."""
141
+ state = sm.load_state()
142
+
143
+ if args.stats:
144
+ # Get error statistics
145
+ stats = sm.get_error_stats(state)
146
+ print(json.dumps(stats, indent=2, ensure_ascii=False))
147
+
148
+ elif args.review is not None:
149
+ # Get errors for review session
150
+ errors = sm.get_review_errors(state, count=args.review)
151
+ result = {
152
+ "count": len(errors),
153
+ "errors": errors
154
+ }
155
+ print(json.dumps(result, indent=2, ensure_ascii=False))
156
+
157
+ else:
158
+ # Get paginated errors list
159
+ result = sm.get_errors_page(
160
+ state,
161
+ page=args.page,
162
+ per_page=args.per_page,
163
+ month=args.month,
164
+ random=args.random
165
+ )
166
+ print(json.dumps(result, indent=2, ensure_ascii=False))
167
+
168
+ elif args.command == 'schedule':
169
+ """Display or update schedule configuration."""
170
+ state = sm.load_state()
171
+
172
+ # If no update options, just show current schedule
173
+ if not any([args.keypoint_time, args.quiz_time]):
174
+ schedule = state.get("schedule", {})
175
+ print(json.dumps(schedule, indent=2, ensure_ascii=False))
176
+ else:
177
+ # Validate quiz_time must be later than keypoint_time
178
+ current_keypoint = state.get("schedule", {}).get("keypoint_time", "06:45")
179
+ current_quiz = state.get("schedule", {}).get("quiz_time", "22:45")
180
+
181
+ new_keypoint = args.keypoint_time or current_keypoint
182
+ new_quiz = args.quiz_time or current_quiz
183
+
184
+ # Time validation
185
+ def parse_time(t):
186
+ h, m = map(int, t.split(':'))
187
+ return h * 60 + m
188
+
189
+ if parse_time(new_quiz) <= parse_time(new_keypoint):
190
+ print("Error: Quiz time must be later than keypoint time")
191
+ exit(1)
192
+
193
+ state = sm.update_schedule(
194
+ state,
195
+ keypoint_time=new_keypoint,
196
+ quiz_time=new_quiz
197
+ )
198
+ sm.save_state(state)
199
+ print(f"Schedule updated: keypoint at {new_keypoint}, quiz at {new_quiz}")
200
+
201
+
202
+ if __name__ == "__main__":
203
+ main()
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared constants for eng-lang-tutor.
4
+
5
+ This module contains constants used across multiple scripts to avoid duplication.
6
+ """
7
+
8
+ # Level thresholds (XP needed for each level)
9
+ # Note: Level is Activity Level (活跃等级), measuring engagement depth,
10
+ # NOT language ability (which is CEFR level A1-C2)
11
+ LEVEL_THRESHOLDS = [
12
+ 0, 50, 100, 200, 350, # 1-5 Starter (启程者)
13
+ 550, 800, 1100, 1500, 2000, # 6-10 Traveler (行路人)
14
+ 2600, 3300, 4100, 5000, 6000, # 11-15 Explorer (探索者)
15
+ 7200, 8500, 10000, 12000, 15000 # 16-20 Pioneer (开拓者)
16
+ ]
17
+
18
+ # Level range to name mapping
19
+ LEVEL_NAMES = {
20
+ (1, 5): "Starter", # 启程者
21
+ (6, 10): "Traveler", # 行路人
22
+ (11, 15): "Explorer", # 探索者
23
+ (16, 20): "Pioneer" # 开拓者
24
+ }
25
+
26
+
27
+ def get_level_name(level: int) -> str:
28
+ """
29
+ Get the journey stage name for a level.
30
+
31
+ Args:
32
+ level: Activity level (1-20)
33
+
34
+ Returns:
35
+ Stage name (Starter/Traveler/Explorer/Pioneer)
36
+ """
37
+ for (min_level, max_level), name in LEVEL_NAMES.items():
38
+ if min_level <= level <= max_level:
39
+ return name
40
+ return "Unknown"
41
+
42
+
43
+ def calculate_level(xp: int) -> int:
44
+ """
45
+ Calculate level from total XP.
46
+
47
+ Args:
48
+ xp: Total experience points
49
+
50
+ Returns:
51
+ Level (1-20)
52
+ """
53
+ for i in range(len(LEVEL_THRESHOLDS) - 1, -1, -1):
54
+ if xp >= LEVEL_THRESHOLDS[i]:
55
+ return i + 1
56
+ return 1
@@ -14,17 +14,14 @@ Note: Leagues removed - not applicable for single-user scenario.
14
14
  from typing import Dict, Any, List, Tuple, Optional
15
15
  from datetime import datetime, date, timedelta
16
16
 
17
+ from constants import LEVEL_THRESHOLDS, get_level_name, calculate_level
18
+
17
19
 
18
20
  class GamificationManager:
19
21
  """Manages gamification elements: levels, streaks, badges, gems."""
20
22
 
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
- ]
23
+ # Use shared constants
24
+ LEVEL_THRESHOLDS = LEVEL_THRESHOLDS
28
25
 
29
26
  # Badge definitions
30
27
  BADGES = {
@@ -120,11 +117,13 @@ class GamificationManager:
120
117
  return (new_streak, True, f"Streak continued! {new_streak} days")
121
118
 
122
119
  elif days_diff == 2 and user.get('streak_freeze', 0) > 0:
123
- # Use streak freeze
120
+ # Use streak freeze - preserves streak continuity
124
121
  state['user']['streak_freeze'] -= 1
125
122
  new_streak = current_streak + 1
126
123
  state['user']['streak'] = new_streak
127
- return (new_streak, True, f"Streak freeze used! {new_streak} days")
124
+ state['user']['last_freeze_used'] = study_date # Track for display
125
+ remaining = state['user']['streak_freeze']
126
+ return (new_streak, True, f"Streak freeze used! {new_streak} days ({remaining} freeze remaining)")
128
127
 
129
128
  else:
130
129
  # Streak broken
@@ -146,10 +145,7 @@ class GamificationManager:
146
145
  Returns:
147
146
  Level (1-20)
148
147
  """
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
148
+ return calculate_level(xp)
153
149
 
154
150
  def get_level_name(self, level: int) -> str:
155
151
  """Get the name for a level range (Journey Stage).
@@ -157,14 +153,7 @@ class GamificationManager:
157
153
  Note: This is Activity Level (活跃等级), measuring engagement depth,
158
154
  NOT language ability. For language ability, see CEFR level.
159
155
  """
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" # 开拓者
156
+ return get_level_name(level)
168
157
 
169
158
  def check_level_up(self, old_xp: int, new_xp: int) -> Tuple[bool, Optional[int]]:
170
159
  """
@@ -203,6 +192,29 @@ class GamificationManager:
203
192
  leveled_up = new_level > current_level
204
193
  return (new_level, leveled_up)
205
194
 
195
+ def _check_badge_condition(self, badge_id: str, check_data: Dict[str, int]) -> bool:
196
+ """
197
+ Check if a badge condition is met using explicit condition functions.
198
+
199
+ Args:
200
+ badge_id: Badge identifier
201
+ check_data: Dictionary with stats for condition checking
202
+
203
+ Returns:
204
+ True if condition is met, False otherwise
205
+ """
206
+ # Explicit condition functions for each badge (no eval() for security)
207
+ conditions = {
208
+ 'first_steps': lambda d: d.get('total_quizzes', 0) >= 1,
209
+ 'week_warrior': lambda d: d.get('streak', 0) >= 7,
210
+ 'month_master': lambda d: d.get('streak', 0) >= 30,
211
+ 'perfect_10': lambda d: d.get('perfect_quizzes', 0) >= 10,
212
+ 'vocab_hunter': lambda d: d.get('expressions_learned', 0) >= 100,
213
+ 'error_slayer': lambda d: d.get('errors_cleared', 0) >= 30,
214
+ }
215
+ checker = conditions.get(badge_id)
216
+ return checker(check_data) if checker else False
217
+
206
218
  def check_badges(self, state: Dict[str, Any]) -> List[Dict[str, Any]]:
207
219
  """
208
220
  Check and award new badges.
@@ -231,18 +243,14 @@ class GamificationManager:
231
243
  if badge_id in current_badges:
232
244
  continue
233
245
 
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
+ if self._check_badge_condition(badge_id, check_data):
247
+ earned.append({
248
+ 'id': badge_id,
249
+ 'name': badge_info['name'],
250
+ 'description': badge_info['description'],
251
+ 'gem_reward': badge_info['gem_reward']
252
+ })
253
+ current_badges.append(badge_id)
246
254
 
247
255
  state['user']['badges'] = current_badges
248
256
  return earned
@@ -335,7 +343,13 @@ class GamificationManager:
335
343
  current_threshold = self.LEVEL_THRESHOLDS[level - 1]
336
344
  xp_in_level = xp - current_threshold
337
345
  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
346
+
347
+ # Safe division for level progress
348
+ threshold_diff = next_threshold - current_threshold
349
+ if level < 20 and threshold_diff > 0:
350
+ level_progress = (xp_in_level / threshold_diff) * 100
351
+ else:
352
+ level_progress = 100.0 if level >= 20 else 0.0
339
353
 
340
354
  return {
341
355
  'xp': xp,
package/scripts/scorer.py CHANGED
@@ -12,6 +12,8 @@ XP Rules (Duolingo-style):
12
12
  from typing import Dict, Any, List, Tuple, Optional
13
13
  from datetime import datetime
14
14
 
15
+ from constants import LEVEL_THRESHOLDS, calculate_level
16
+
15
17
 
16
18
  class Scorer:
17
19
  """Handles answer evaluation and XP calculation."""
@@ -164,23 +166,20 @@ class Scorer:
164
166
 
165
167
  def _update_state(self, state: Dict[str, Any], results: Dict[str, Any]) -> Dict[str, Any]:
166
168
  """
167
- Update state with quiz results.
169
+ Update state with quiz results (modifies state in place).
168
170
 
169
171
  Args:
170
- state: Current state
172
+ state: Current state (will be modified)
171
173
  results: Quiz evaluation results
172
174
 
173
175
  Returns:
174
- Updated state
176
+ The same state dict (for convenience)
175
177
  """
176
- import copy
177
- updated_state = copy.deepcopy(state)
178
-
179
178
  # Update XP
180
- updated_state['user']['xp'] += results['total_xp_earned']
179
+ state['user']['xp'] = state.get('user', {}).get('xp', 0) + results['total_xp_earned']
181
180
 
182
181
  # Update progress
183
- progress = updated_state.get('progress', {})
182
+ progress = state.get('progress', {})
184
183
  old_total = progress.get('total_quizzes', 0)
185
184
  old_rate = progress.get('correct_rate', 0.0)
186
185
 
@@ -196,10 +195,11 @@ class Scorer:
196
195
  if results['accuracy'] == 100:
197
196
  progress['perfect_quizzes'] = progress.get('perfect_quizzes', 0) + 1
198
197
 
199
- updated_state['progress'] = progress
198
+ state['progress'] = progress
200
199
 
201
200
  # Add errors to notebook
202
201
  if results['errors']:
202
+ error_notebook = state.get('error_notebook', [])
203
203
  for error in results['errors']:
204
204
  error_entry = {
205
205
  'date': results['date'],
@@ -209,9 +209,10 @@ class Scorer:
209
209
  'explanation': error.get('explanation', ''),
210
210
  'reviewed': False
211
211
  }
212
- updated_state['error_notebook'].append(error_entry)
212
+ error_notebook.append(error_entry)
213
+ state['error_notebook'] = error_notebook
213
214
 
214
- return updated_state
215
+ return state
215
216
 
216
217
  def calculate_level(self, xp: int) -> int:
217
218
  """
@@ -223,19 +224,7 @@ class Scorer:
223
224
  Returns:
224
225
  Level (1-20)
225
226
  """
226
- # Level thresholds (cumulative XP needed)
227
- # Note: Level is Activity Level (活跃等级), measuring engagement depth
228
- LEVEL_THRESHOLDS = [
229
- 0, 50, 100, 200, 350, # 1-5 Starter (启程者)
230
- 550, 800, 1100, 1500, 2000, # 6-10 Traveler (行路人)
231
- 2600, 3300, 4100, 5000, 6000, # 11-15 Explorer (探索者)
232
- 7200, 8500, 10000, 12000, 15000 # 16-20 Pioneer (开拓者)
233
- ]
234
-
235
- for i in range(len(LEVEL_THRESHOLDS) - 1, -1, -1):
236
- if xp >= LEVEL_THRESHOLDS[i]:
237
- return i + 1
238
- return 1
227
+ return calculate_level(xp)
239
228
 
240
229
  def get_xp_for_next_level(self, current_xp: int) -> Tuple[int, int]:
241
230
  """
@@ -247,14 +236,7 @@ class Scorer:
247
236
  Returns:
248
237
  Tuple of (xp_needed, xp_into_current_level)
249
238
  """
250
- LEVEL_THRESHOLDS = [
251
- 0, 50, 100, 200, 350,
252
- 550, 800, 1100, 1500, 2000,
253
- 2600, 3300, 4100, 5000, 6000,
254
- 7200, 8500, 10000, 12000, 15000
255
- ]
256
-
257
- current_level = self.calculate_level(current_xp)
239
+ current_level = calculate_level(current_xp)
258
240
 
259
241
  if current_level >= 20:
260
242
  return (0, current_xp - LEVEL_THRESHOLDS[-1])