@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,125 @@
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
+ # =============================================================================
9
+ # GAME MECHANICS - Activity Levels & Progression
10
+ # =============================================================================
11
+
12
+ # Level thresholds (XP needed for each level)
13
+ # Note: Level is Activity Level (活跃等级), measuring engagement depth,
14
+ # NOT language ability (which is CEFR level A1-C2)
15
+ LEVEL_THRESHOLDS = [
16
+ 0, 50, 100, 200, 350, # 1-5 Starter (启程者)
17
+ 550, 800, 1100, 1500, 2000, # 6-10 Traveler (行路人)
18
+ 2600, 3300, 4100, 5000, 6000, # 11-15 Explorer (探索者)
19
+ 7200, 8500, 10000, 12000, 15000 # 16-20 Pioneer (开拓者)
20
+ ]
21
+
22
+ # Level range to name mapping
23
+ LEVEL_NAMES = {
24
+ (1, 5): "Starter", # 启程者
25
+ (6, 10): "Traveler", # 行路人
26
+ (11, 15): "Explorer", # 探索者
27
+ (16, 20): "Pioneer" # 开拓者
28
+ }
29
+
30
+ # Streak bonus configuration
31
+ STREAK_BONUS_PER_DAY = 0.05 # 5% bonus per day
32
+ STREAK_BONUS_CAP = 2.0 # Maximum 2x multiplier
33
+
34
+ # Gem economy
35
+ STREAK_FREEZE_COST = 50 # gems to buy one streak freeze
36
+ HINT_COST = 10 # gems to buy a quiz hint
37
+
38
+ # =============================================================================
39
+ # QUIZ XP VALUES
40
+ # =============================================================================
41
+
42
+ QUIZ_XP = {
43
+ "multiple_choice": 10,
44
+ "chinglish_fix": 15,
45
+ "fill_blank": 12,
46
+ "dialogue_completion": 15
47
+ }
48
+ QUIZ_PASS_THRESHOLD = 2 # 2/3 correct to pass
49
+ QUIZ_QUESTIONS_PER_DAY = 3
50
+
51
+ # =============================================================================
52
+ # ERROR NOTEBOOK
53
+ # =============================================================================
54
+
55
+ ERROR_ARCHIVE_WRONG_THRESHOLD = 3 # Archive if wrong_count >= 3
56
+ ERROR_ARCHIVE_DAYS_THRESHOLD = 30 # Archive if over 30 days old
57
+ ERROR_NOTEBOOK_MAX = 100 # Maximum errors in notebook
58
+
59
+ # =============================================================================
60
+ # AUDIO PROCESSING
61
+ # =============================================================================
62
+
63
+ AUDIO_TTS_TIMEOUT = 30 # seconds, for single TTS synthesis
64
+ AUDIO_CONCAT_TIMEOUT = 120 # seconds, for concatenating segments
65
+ AUDIO_CONVERSION_TIMEOUT = 60 # seconds, for format conversion
66
+
67
+ # Audio silence durations
68
+ AUDIO_SILENCE_LEAD_IN = 1.0 # seconds, after lead-in phrase
69
+ AUDIO_SILENCE_SECTION = 2.0 # seconds, between sections
70
+ AUDIO_SILENCE_DIALOGUE = 0.5 # seconds, between dialogue lines
71
+
72
+ # =============================================================================
73
+ # DEDUPLICATION
74
+ # =============================================================================
75
+
76
+ DEDUP_DEFAULT_DAYS = 14 # Default days for content deduplication
77
+
78
+
79
+ # =============================================================================
80
+ # HELPER FUNCTIONS
81
+ # =============================================================================
82
+
83
+ def get_level_name(level: int) -> str:
84
+ """
85
+ Get the journey stage name for a level.
86
+
87
+ Args:
88
+ level: Activity level (1-20)
89
+
90
+ Returns:
91
+ Stage name (Starter/Traveler/Explorer/Pioneer)
92
+ """
93
+ for (min_level, max_level), name in LEVEL_NAMES.items():
94
+ if min_level <= level <= max_level:
95
+ return name
96
+ return "Unknown"
97
+
98
+
99
+ def calculate_level(xp: int) -> int:
100
+ """
101
+ Calculate level from total XP.
102
+
103
+ Args:
104
+ xp: Total experience points
105
+
106
+ Returns:
107
+ Level (1-20)
108
+ """
109
+ for i in range(len(LEVEL_THRESHOLDS) - 1, -1, -1):
110
+ if xp >= LEVEL_THRESHOLDS[i]:
111
+ return i + 1
112
+ return 1
113
+
114
+
115
+ def get_streak_multiplier(streak: int) -> float:
116
+ """
117
+ Calculate XP multiplier based on streak.
118
+
119
+ Args:
120
+ streak: Number of consecutive days
121
+
122
+ Returns:
123
+ Multiplier (1.0 - 2.0)
124
+ """
125
+ return min(1.0 + (streak * STREAK_BONUS_PER_DAY), STREAK_BONUS_CAP)
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Error Notebook Manager - 错题本管理
4
+
5
+ 管理用户的错题本功能,包括:
6
+ - 添加错题
7
+ - 分页查询
8
+ - 统计信息
9
+ - 复习标记
10
+ - 自动归档
11
+
12
+ 从 state_manager.py 提取,遵循单一职责原则。
13
+ """
14
+
15
+ from typing import Dict, Any, List
16
+ from datetime import datetime, date
17
+ from collections import Counter
18
+
19
+ from .constants import (
20
+ ERROR_ARCHIVE_WRONG_THRESHOLD,
21
+ ERROR_ARCHIVE_DAYS_THRESHOLD,
22
+ ERROR_NOTEBOOK_MAX
23
+ )
24
+
25
+
26
+ class ErrorNotebookManager:
27
+ """错题本管理器"""
28
+
29
+ def __init__(self, state_manager=None):
30
+ """
31
+ 初始化错题本管理器
32
+
33
+ Args:
34
+ state_manager: StateManager 实例,用于事件日志
35
+ """
36
+ self.state_manager = state_manager
37
+
38
+ def add_to_error_notebook(self, state: Dict[str, Any],
39
+ error: Dict[str, Any]) -> Dict[str, Any]:
40
+ """
41
+ Add an error to the error notebook.
42
+
43
+ Args:
44
+ state: Current state
45
+ error: Error entry with date, question, user_answer, correct_answer, explanation
46
+
47
+ Returns:
48
+ Updated state
49
+ """
50
+ error['date'] = date.today().isoformat()
51
+ error['reviewed'] = False
52
+ error['wrong_count'] = error.get('wrong_count', 1)
53
+ # keypoint_date and question_type are optional
54
+
55
+ errors = state.get('error_notebook', [])
56
+ errors.append(error)
57
+
58
+ # Auto-archive oldest if over size limit
59
+ if len(errors) > ERROR_NOTEBOOK_MAX:
60
+ # Sort by date to find oldest
61
+ errors.sort(key=lambda x: x.get('date', ''))
62
+ # Archive the oldest
63
+ oldest = errors.pop(0)
64
+ archive = state.get('error_archive', [])
65
+ oldest['archived_at'] = date.today().isoformat()
66
+ oldest['archived_reason'] = 'notebook_full'
67
+ archive.append(oldest)
68
+ state['error_archive'] = archive
69
+
70
+ state['error_notebook'] = errors
71
+ return state
72
+
73
+ def get_errors_page(self, state: Dict[str, Any],
74
+ page: int = 1,
75
+ per_page: int = 5,
76
+ month: str = None,
77
+ random: int = None) -> Dict[str, Any]:
78
+ """
79
+ Get paginated errors from the error notebook.
80
+
81
+ Args:
82
+ state: Current state
83
+ page: Page number (1-indexed)
84
+ per_page: Items per page (default 5)
85
+ month: Filter by month (YYYY-MM format)
86
+ random: Return N random errors instead of paginated
87
+
88
+ Returns:
89
+ Dictionary with pagination info and error items:
90
+ {
91
+ 'total': total_count,
92
+ 'page': current_page,
93
+ 'per_page': items_per_page,
94
+ 'total_pages': total_pages,
95
+ 'has_more': bool,
96
+ 'errors': [error_items]
97
+ }
98
+ """
99
+ import random as random_module
100
+
101
+ errors = state.get('error_notebook', [])
102
+
103
+ # Sort by date descending (newest first)
104
+ errors = sorted(errors, key=lambda x: x.get('date', ''), reverse=True)
105
+
106
+ # Filter by month if specified
107
+ if month:
108
+ errors = [e for e in errors if e.get('date', '').startswith(month)]
109
+
110
+ total = len(errors)
111
+
112
+ # Random mode
113
+ if random and random > 0:
114
+ random_count = min(random, total)
115
+ selected = random_module.sample(errors, random_count) if total > 0 else []
116
+ return {
117
+ 'total': total,
118
+ 'page': 1,
119
+ 'per_page': random_count,
120
+ 'total_pages': 1,
121
+ 'has_more': total > random_count,
122
+ 'mode': 'random',
123
+ 'errors': selected
124
+ }
125
+
126
+ # Pagination
127
+ total_pages = (total + per_page - 1) // per_page if per_page > 0 else 1
128
+ page = max(1, min(page, total_pages)) if total_pages > 0 else 1
129
+
130
+ start = (page - 1) * per_page
131
+ end = start + per_page
132
+
133
+ return {
134
+ 'total': total,
135
+ 'page': page,
136
+ 'per_page': per_page,
137
+ 'total_pages': total_pages,
138
+ 'has_more': page < total_pages,
139
+ 'has_prev': page > 1,
140
+ 'mode': 'paginated',
141
+ 'errors': errors[start:end]
142
+ }
143
+
144
+ def get_error_stats(self, state: Dict[str, Any]) -> Dict[str, Any]:
145
+ """
146
+ Get statistics about the error notebook.
147
+
148
+ Args:
149
+ state: Current state
150
+
151
+ Returns:
152
+ Dictionary with error statistics
153
+ """
154
+ errors = state.get('error_notebook', [])
155
+
156
+ if not errors:
157
+ return {
158
+ 'total': 0,
159
+ 'reviewed': 0,
160
+ 'unreviewed': 0,
161
+ 'by_month': {}
162
+ }
163
+
164
+ reviewed = sum(1 for e in errors if e.get('reviewed', False))
165
+ unreviewed = len(errors) - reviewed
166
+
167
+ # Group by month
168
+ by_month = Counter()
169
+ for e in errors:
170
+ date_str = e.get('date', '')
171
+ if date_str:
172
+ month = date_str[:7] # YYYY-MM
173
+ by_month[month] += 1
174
+
175
+ # Sort by month descending
176
+ by_month_sorted = dict(sorted(by_month.items(), reverse=True))
177
+
178
+ return {
179
+ 'total': len(errors),
180
+ 'reviewed': reviewed,
181
+ 'unreviewed': unreviewed,
182
+ 'by_month': by_month_sorted
183
+ }
184
+
185
+ def review_error(self, state: Dict[str, Any], error_index: int,
186
+ correct: bool) -> Dict[str, Any]:
187
+ """
188
+ Mark an error as reviewed (correct answer) or increment wrong count.
189
+
190
+ Args:
191
+ state: Current state
192
+ error_index: Index of error in error_notebook
193
+ correct: Whether the user answered correctly
194
+
195
+ Returns:
196
+ Updated state
197
+ """
198
+ errors = state.get('error_notebook', [])
199
+ if 0 <= error_index < len(errors):
200
+ if correct:
201
+ # Mark as reviewed - contributes to Error Slayer badge
202
+ errors[error_index]['reviewed'] = True
203
+ else:
204
+ # Increment wrong count
205
+ errors[error_index]['wrong_count'] = errors[error_index].get('wrong_count', 1) + 1
206
+ state['error_notebook'] = errors
207
+ return state
208
+
209
+ def increment_wrong_count(self, state: Dict[str, Any],
210
+ error_index: int) -> Dict[str, Any]:
211
+ """
212
+ Increment wrong count for an error (convenience method).
213
+
214
+ Args:
215
+ state: Current state
216
+ error_index: Index of error in error_notebook
217
+
218
+ Returns:
219
+ Updated state
220
+ """
221
+ return self.review_error(state, error_index, correct=False)
222
+
223
+ def get_review_errors(self, state: Dict[str, Any],
224
+ count: int = 5) -> List[Dict[str, Any]]:
225
+ """
226
+ Get unreviewed errors for a review session.
227
+
228
+ Args:
229
+ state: Current state
230
+ count: Maximum number of errors to return
231
+
232
+ Returns:
233
+ List of unreviewed errors (most recent first)
234
+ """
235
+ errors = state.get('error_notebook', [])
236
+ # Filter unreviewed errors
237
+ unreviewed = [e for e in errors if not e.get('reviewed', False)]
238
+ # Sort by date descending (newest first)
239
+ unreviewed = sorted(unreviewed, key=lambda x: x.get('date', ''), reverse=True)
240
+ # Return up to count items
241
+ return unreviewed[:count]
242
+
243
+ def archive_stale_errors(self, state: Dict[str, Any]) -> Dict[str, Any]:
244
+ """
245
+ Archive errors with wrong_count >= threshold and over threshold days old.
246
+
247
+ Args:
248
+ state: Current state
249
+
250
+ Returns:
251
+ Updated state with stale errors moved to error_archive
252
+ """
253
+ errors = state.get('error_notebook', [])
254
+ archive = state.get('error_archive', [])
255
+ today = date.today()
256
+
257
+ remaining = []
258
+ archived_count = 0
259
+
260
+ for error in errors:
261
+ # Skip already reviewed
262
+ if error.get('reviewed', False):
263
+ remaining.append(error)
264
+ continue
265
+
266
+ wrong_count = error.get('wrong_count', 1)
267
+ error_date_str = error.get('date', '')
268
+
269
+ # Calculate days since error was created
270
+ try:
271
+ error_date = datetime.strptime(error_date_str, '%Y-%m-%d').date()
272
+ days_old = (today - error_date).days
273
+ except (ValueError, TypeError):
274
+ days_old = 0
275
+
276
+ # Archive if wrong_count and days_old meet thresholds
277
+ if wrong_count >= ERROR_ARCHIVE_WRONG_THRESHOLD and days_old >= ERROR_ARCHIVE_DAYS_THRESHOLD:
278
+ error['archived_at'] = today.isoformat()
279
+ archive.append(error)
280
+ archived_count += 1
281
+ else:
282
+ remaining.append(error)
283
+
284
+ state['error_notebook'] = remaining
285
+ state['error_archive'] = archive
286
+
287
+ # Log archival event via state_manager
288
+ if archived_count > 0 and self.state_manager:
289
+ self.state_manager.append_event("errors_archived", {"count": archived_count})
290
+
291
+ return state
292
+
293
+ def clear_reviewed_errors(self, state: Dict[str, Any]) -> Dict[str, Any]:
294
+ """
295
+ Remove reviewed errors from the notebook (keep them in archive for stats).
296
+
297
+ Args:
298
+ state: Current state
299
+
300
+ Returns:
301
+ Updated state
302
+ """
303
+ errors = state.get('error_notebook', [])
304
+ # Keep only unreviewed errors
305
+ state['error_notebook'] = [
306
+ e for e in errors if not e.get('reviewed', False)
307
+ ]
308
+ return state