@rookiestar/eng-lang-tutor 1.0.2 → 1.0.4
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 +5 -4
- package/README.md +16 -1
- package/README_EN.md +16 -1
- package/SKILL.md +113 -92
- package/docs/OPENCLAW_DEPLOYMENT.md +7 -4
- package/package.json +1 -1
- package/requirements.txt +2 -0
- package/scripts/__pycache__/audio_composer.cpython-313.pyc +0 -0
- package/scripts/__pycache__/audio_converter.cpython-313.pyc +0 -0
- package/scripts/__pycache__/audio_enhancer.cpython-313.pyc +0 -0
- package/scripts/__pycache__/state_manager.cpython-313.pyc +0 -0
- package/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/scripts/audio_composer.py +389 -0
- package/scripts/audio_converter.py +245 -0
- package/scripts/cli.py +223 -0
- package/scripts/constants.py +56 -0
- package/scripts/feishu_voice.py +421 -0
- package/scripts/gamification.py +48 -34
- package/scripts/scorer.py +14 -32
- package/scripts/state_manager.py +131 -207
- package/scripts/tts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/tts/__pycache__/base.cpython-313.pyc +0 -0
- package/scripts/tts/__pycache__/manager.cpython-313.pyc +0 -0
- package/scripts/tts/manager.py +14 -4
- package/scripts/tts/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/tts/providers/__pycache__/xunfei.cpython-313.pyc +0 -0
- package/scripts/tts/providers/xunfei.py +10 -1
- package/scripts/utils.py +78 -0
- package/templates/prompt_templates.md +47 -1235
- package/templates/prompts/display_guide.md +106 -0
- package/templates/prompts/initialization.md +213 -0
- package/templates/prompts/keypoint_generation.md +268 -0
- package/templates/prompts/output_rules.md +106 -0
- package/templates/prompts/quiz_generation.md +187 -0
- package/templates/prompts/responses.md +291 -0
- package/templates/prompts/shared_enums.md +97 -0
- package/templates/state_schema.json +1 -6
package/scripts/gamification.py
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
+
The same state dict (for convenience)
|
|
175
177
|
"""
|
|
176
|
-
import copy
|
|
177
|
-
updated_state = copy.deepcopy(state)
|
|
178
|
-
|
|
179
178
|
# Update XP
|
|
180
|
-
|
|
179
|
+
state['user']['xp'] = state.get('user', {}).get('xp', 0) + results['total_xp_earned']
|
|
181
180
|
|
|
182
181
|
# Update progress
|
|
183
|
-
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
|
-
|
|
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
|
-
|
|
212
|
+
error_notebook.append(error_entry)
|
|
213
|
+
state['error_notebook'] = error_notebook
|
|
213
214
|
|
|
214
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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])
|
package/scripts/state_manager.py
CHANGED
|
@@ -16,6 +16,8 @@ from datetime import datetime, date
|
|
|
16
16
|
from typing import Dict, Any, Optional, List
|
|
17
17
|
import shutil
|
|
18
18
|
|
|
19
|
+
from utils import deep_merge
|
|
20
|
+
|
|
19
21
|
|
|
20
22
|
def get_default_state_dir() -> Path:
|
|
21
23
|
"""
|
|
@@ -116,6 +118,16 @@ class StateManager:
|
|
|
116
118
|
except Exception as e:
|
|
117
119
|
print(f"Warning: Migration failed: {e}")
|
|
118
120
|
print("Will use new empty state directory.")
|
|
121
|
+
# Log migration failure to event log (after ensuring directories exist)
|
|
122
|
+
try:
|
|
123
|
+
self._ensure_directories()
|
|
124
|
+
self.append_event('migration_failed', {
|
|
125
|
+
"error": str(e),
|
|
126
|
+
"source_dir": str(old_data_dir),
|
|
127
|
+
"target_dir": str(self.data_dir)
|
|
128
|
+
})
|
|
129
|
+
except Exception:
|
|
130
|
+
pass # Silently ignore logging failures during migration
|
|
119
131
|
|
|
120
132
|
def _ensure_directories(self) -> None:
|
|
121
133
|
"""Create necessary directories if they don't exist."""
|
|
@@ -163,7 +175,6 @@ class StateManager:
|
|
|
163
175
|
"dedup_days": 14
|
|
164
176
|
},
|
|
165
177
|
"progress": {
|
|
166
|
-
"total_points": 0,
|
|
167
178
|
"total_quizzes": 0,
|
|
168
179
|
"correct_rate": 0.0,
|
|
169
180
|
"last_study_date": None,
|
|
@@ -195,26 +206,13 @@ class StateManager:
|
|
|
195
206
|
return self._default_state()
|
|
196
207
|
|
|
197
208
|
def _merge_with_defaults(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
198
|
-
"""
|
|
199
|
-
defaults
|
|
200
|
-
merged = defaults.copy()
|
|
201
|
-
|
|
202
|
-
# Handle top-level boolean/integer fields
|
|
203
|
-
for key in ['initialized', 'onboarding_step', 'version']:
|
|
204
|
-
if key in state:
|
|
205
|
-
merged[key] = state[key]
|
|
206
|
-
|
|
207
|
-
# Handle nested objects
|
|
208
|
-
for key in ['user', 'preferences', 'progress', 'completion_status', 'schedule']:
|
|
209
|
-
if key in state and isinstance(state[key], dict):
|
|
210
|
-
merged[key] = {**defaults.get(key, {}), **state[key]}
|
|
211
|
-
|
|
212
|
-
# Handle arrays
|
|
213
|
-
for key in ['recent_topics', 'error_notebook', 'error_archive']:
|
|
214
|
-
if key in state:
|
|
215
|
-
merged[key] = state[key]
|
|
209
|
+
"""
|
|
210
|
+
Merge loaded state with defaults to ensure all fields exist.
|
|
216
211
|
|
|
217
|
-
|
|
212
|
+
Uses deep merge to handle nested structures properly.
|
|
213
|
+
"""
|
|
214
|
+
defaults = self._default_state()
|
|
215
|
+
return deep_merge(defaults, state)
|
|
218
216
|
|
|
219
217
|
def save_state(self, state: Dict[str, Any]) -> None:
|
|
220
218
|
"""
|
|
@@ -289,7 +287,8 @@ class StateManager:
|
|
|
289
287
|
return daily_path
|
|
290
288
|
|
|
291
289
|
def save_daily_content(self, content_type: str, content: Dict[str, Any],
|
|
292
|
-
target_date: Optional[date] = None
|
|
290
|
+
target_date: Optional[date] = None,
|
|
291
|
+
generate_audio: bool = True) -> Path:
|
|
293
292
|
"""
|
|
294
293
|
Save content to the daily directory.
|
|
295
294
|
|
|
@@ -297,6 +296,7 @@ class StateManager:
|
|
|
297
296
|
content_type: Type of content ('keypoint', 'quiz', 'user_answers')
|
|
298
297
|
content: Content dictionary to save
|
|
299
298
|
target_date: Date for the content (defaults to today)
|
|
299
|
+
generate_audio: Whether to auto-generate audio for keypoints (default True)
|
|
300
300
|
|
|
301
301
|
Returns:
|
|
302
302
|
Path to the saved file
|
|
@@ -312,8 +312,95 @@ class StateManager:
|
|
|
312
312
|
with open(file_path, 'w', encoding='utf-8') as f:
|
|
313
313
|
json.dump(content, f, ensure_ascii=False, indent=2)
|
|
314
314
|
|
|
315
|
+
# Auto-generate audio for keypoints
|
|
316
|
+
if content_type == 'keypoint' and generate_audio:
|
|
317
|
+
try:
|
|
318
|
+
audio_result = self.generate_keypoint_audio(target_date)
|
|
319
|
+
if audio_result.get('success'):
|
|
320
|
+
# Update keypoint with audio metadata
|
|
321
|
+
content['audio'] = {
|
|
322
|
+
'composed': audio_result.get('audio_path'),
|
|
323
|
+
'duration_seconds': audio_result.get('duration_seconds'),
|
|
324
|
+
'generated_at': datetime.now().isoformat()
|
|
325
|
+
}
|
|
326
|
+
# Re-save with audio info
|
|
327
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
328
|
+
json.dump(content, f, ensure_ascii=False, indent=2)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
print(f"Warning: Audio generation failed: {e}")
|
|
331
|
+
|
|
315
332
|
return file_path
|
|
316
333
|
|
|
334
|
+
def generate_keypoint_audio(self, target_date: Optional[date] = None) -> Dict[str, Any]:
|
|
335
|
+
"""
|
|
336
|
+
Generate composed audio for a keypoint.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
target_date: Date for the keypoint (defaults to today)
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Dictionary with:
|
|
343
|
+
- success: bool
|
|
344
|
+
- audio_path: str (relative path from data_dir)
|
|
345
|
+
- duration_seconds: float
|
|
346
|
+
- error_message: str (if failed)
|
|
347
|
+
"""
|
|
348
|
+
try:
|
|
349
|
+
from .audio_composer import AudioComposer
|
|
350
|
+
from .tts import TTSManager
|
|
351
|
+
except ImportError:
|
|
352
|
+
from audio_composer import AudioComposer
|
|
353
|
+
from tts import TTSManager
|
|
354
|
+
|
|
355
|
+
if target_date is None:
|
|
356
|
+
target_date = date.today()
|
|
357
|
+
|
|
358
|
+
# Load the keypoint
|
|
359
|
+
keypoint = self.load_daily_content('keypoint', target_date)
|
|
360
|
+
if not keypoint:
|
|
361
|
+
return {
|
|
362
|
+
'success': False,
|
|
363
|
+
'error_message': f'No keypoint found for {target_date}'
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
# Prepare output path
|
|
367
|
+
date_str = target_date.strftime('%Y-%m-%d')
|
|
368
|
+
self.audio_dir.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
output_path = self.audio_dir / date_str / "keypoint_full.mp3"
|
|
370
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
# Initialize TTS and composer (handle both package and direct imports)
|
|
374
|
+
try:
|
|
375
|
+
from .audio_composer import AudioComposer
|
|
376
|
+
from .tts import TTSManager
|
|
377
|
+
except ImportError:
|
|
378
|
+
from audio_composer import AudioComposer
|
|
379
|
+
from tts import TTSManager
|
|
380
|
+
|
|
381
|
+
tts = TTSManager.from_env()
|
|
382
|
+
composer = AudioComposer(tts)
|
|
383
|
+
|
|
384
|
+
# Compose audio
|
|
385
|
+
result = composer.compose_keypoint_audio(keypoint, output_path)
|
|
386
|
+
|
|
387
|
+
if result.success:
|
|
388
|
+
return {
|
|
389
|
+
'success': True,
|
|
390
|
+
'audio_path': f"audio/{date_str}/keypoint_full.mp3",
|
|
391
|
+
'duration_seconds': result.duration_seconds
|
|
392
|
+
}
|
|
393
|
+
else:
|
|
394
|
+
return {
|
|
395
|
+
'success': False,
|
|
396
|
+
'error_message': result.error_message
|
|
397
|
+
}
|
|
398
|
+
except Exception as e:
|
|
399
|
+
return {
|
|
400
|
+
'success': False,
|
|
401
|
+
'error_message': str(e)
|
|
402
|
+
}
|
|
403
|
+
|
|
317
404
|
def load_daily_content(self, content_type: str,
|
|
318
405
|
target_date: Optional[date] = None) -> Optional[Dict[str, Any]]:
|
|
319
406
|
"""
|
|
@@ -375,6 +462,9 @@ class StateManager:
|
|
|
375
462
|
recent_content = self.get_recent_daily_content(days)
|
|
376
463
|
return [c.get('topic_fingerprint', '') for c in recent_content if c.get('topic_fingerprint')]
|
|
377
464
|
|
|
465
|
+
# Maximum size for error notebook before auto-archiving
|
|
466
|
+
MAX_ERROR_NOTEBOOK_SIZE = 100
|
|
467
|
+
|
|
378
468
|
def add_to_error_notebook(self, state: Dict[str, Any],
|
|
379
469
|
error: Dict[str, Any]) -> Dict[str, Any]:
|
|
380
470
|
"""
|
|
@@ -391,7 +481,23 @@ class StateManager:
|
|
|
391
481
|
error['reviewed'] = False
|
|
392
482
|
error['wrong_count'] = error.get('wrong_count', 1)
|
|
393
483
|
# keypoint_date and question_type are optional
|
|
394
|
-
|
|
484
|
+
|
|
485
|
+
errors = state.get('error_notebook', [])
|
|
486
|
+
errors.append(error)
|
|
487
|
+
|
|
488
|
+
# Auto-archive oldest if over size limit
|
|
489
|
+
if len(errors) > self.MAX_ERROR_NOTEBOOK_SIZE:
|
|
490
|
+
# Sort by date to find oldest
|
|
491
|
+
errors.sort(key=lambda x: x.get('date', ''))
|
|
492
|
+
# Archive the oldest
|
|
493
|
+
oldest = errors.pop(0)
|
|
494
|
+
archive = state.get('error_archive', [])
|
|
495
|
+
oldest['archived_at'] = date.today().isoformat()
|
|
496
|
+
oldest['archived_reason'] = 'notebook_full'
|
|
497
|
+
archive.append(oldest)
|
|
498
|
+
state['error_archive'] = archive
|
|
499
|
+
|
|
500
|
+
state['error_notebook'] = errors
|
|
395
501
|
return state
|
|
396
502
|
|
|
397
503
|
def get_errors_page(self, state: Dict[str, Any],
|
|
@@ -837,189 +943,7 @@ class StateManager:
|
|
|
837
943
|
return False
|
|
838
944
|
|
|
839
945
|
|
|
840
|
-
# CLI interface
|
|
946
|
+
# CLI interface - delegated to cli.py for better code organization
|
|
841
947
|
if __name__ == "__main__":
|
|
842
|
-
import
|
|
843
|
-
|
|
844
|
-
parser = argparse.ArgumentParser(description="State Manager for eng-lang-tutor")
|
|
845
|
-
parser.add_argument('--data-dir', default=None,
|
|
846
|
-
help='Data directory path (default: ~/.openclaw/state/eng-lang-tutor or OPENCLAW_STATE_DIR env)')
|
|
847
|
-
parser.add_argument('command', nargs='?',
|
|
848
|
-
choices=['show', 'backup', 'save_daily', 'record_view',
|
|
849
|
-
'stats', 'config', 'errors', 'schedule'],
|
|
850
|
-
help='Command to execute')
|
|
851
|
-
parser.add_argument('--content-type', help='Content type for save_daily (keypoint, quiz)')
|
|
852
|
-
parser.add_argument('--content', help='JSON content for save_daily')
|
|
853
|
-
parser.add_argument('--date', help='Date for content (YYYY-MM-DD format)')
|
|
854
|
-
# Errors command options
|
|
855
|
-
parser.add_argument('--page', type=int, default=1, help='Page number for errors list')
|
|
856
|
-
parser.add_argument('--per-page', type=int, default=5, help='Items per page for errors')
|
|
857
|
-
parser.add_argument('--month', help='Filter errors by month (YYYY-MM)')
|
|
858
|
-
parser.add_argument('--random', type=int, help='Get N random errors')
|
|
859
|
-
parser.add_argument('--stats', action='store_true', help='Get error statistics')
|
|
860
|
-
parser.add_argument('--review', type=int, help='Get N errors for review session')
|
|
861
|
-
# Config command options
|
|
862
|
-
parser.add_argument('--cefr', help='Set CEFR level (A1-C2)')
|
|
863
|
-
parser.add_argument('--style', help='Set tutor style')
|
|
864
|
-
parser.add_argument('--oral-ratio', type=int, help='Set oral/written ratio (0-100)')
|
|
865
|
-
# Schedule command options
|
|
866
|
-
parser.add_argument('--keypoint-time', help='Set keypoint push time (HH:MM)')
|
|
867
|
-
parser.add_argument('--quiz-time', help='Set quiz push time (HH:MM)')
|
|
868
|
-
|
|
869
|
-
args = parser.parse_args()
|
|
870
|
-
|
|
871
|
-
sm = StateManager(args.data_dir)
|
|
872
|
-
|
|
873
|
-
if args.command == 'show' or not args.command:
|
|
874
|
-
state = sm.load_state()
|
|
875
|
-
print(json.dumps(state, indent=2, ensure_ascii=False))
|
|
876
|
-
|
|
877
|
-
elif args.command == 'backup':
|
|
878
|
-
backup_path = sm.backup_state()
|
|
879
|
-
print(f"Backup created: {backup_path}")
|
|
880
|
-
|
|
881
|
-
elif args.command == 'save_daily':
|
|
882
|
-
if not args.content_type or not args.content:
|
|
883
|
-
print("Error: --content-type and --content are required for save_daily")
|
|
884
|
-
exit(1)
|
|
885
|
-
|
|
886
|
-
try:
|
|
887
|
-
content = json.loads(args.content)
|
|
888
|
-
except json.JSONDecodeError as e:
|
|
889
|
-
print(f"Error: Invalid JSON content: {e}")
|
|
890
|
-
exit(1)
|
|
891
|
-
|
|
892
|
-
target_date = None
|
|
893
|
-
if args.date:
|
|
894
|
-
try:
|
|
895
|
-
target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
|
|
896
|
-
except ValueError:
|
|
897
|
-
print("Error: Invalid date format. Use YYYY-MM-DD")
|
|
898
|
-
exit(1)
|
|
899
|
-
|
|
900
|
-
path = sm.save_daily_content(args.content_type, content, target_date)
|
|
901
|
-
print(f"Saved to: {path}")
|
|
902
|
-
|
|
903
|
-
elif args.command == 'record_view':
|
|
904
|
-
target_date = None
|
|
905
|
-
if args.date:
|
|
906
|
-
try:
|
|
907
|
-
target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
|
|
908
|
-
except ValueError:
|
|
909
|
-
print("Error: Invalid date format. Use YYYY-MM-DD")
|
|
910
|
-
exit(1)
|
|
911
|
-
|
|
912
|
-
state = sm.load_state()
|
|
913
|
-
sm.record_keypoint_view(state, target_date)
|
|
914
|
-
sm.save_state(state)
|
|
915
|
-
print("View recorded successfully")
|
|
916
|
-
|
|
917
|
-
elif args.command == 'stats':
|
|
918
|
-
"""Display learning progress summary."""
|
|
919
|
-
from gamification import GamificationManager
|
|
920
|
-
state = sm.load_state()
|
|
921
|
-
gm = GamificationManager()
|
|
922
|
-
summary = gm.get_progress_summary(state)
|
|
923
|
-
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
924
|
-
|
|
925
|
-
elif args.command == 'config':
|
|
926
|
-
"""Display or update user configuration."""
|
|
927
|
-
state = sm.load_state()
|
|
928
|
-
|
|
929
|
-
# If no update options, just show current config
|
|
930
|
-
if not any([args.cefr, args.style, args.oral_ratio is not None]):
|
|
931
|
-
config = {
|
|
932
|
-
"cefr_level": state.get("preferences", {}).get("cefr_level", "B1"),
|
|
933
|
-
"tutor_style": state.get("preferences", {}).get("tutor_style", "humorous"),
|
|
934
|
-
"oral_ratio": state.get("preferences", {}).get("oral_ratio", 70),
|
|
935
|
-
"topic_weights": state.get("preferences", {}).get("topic_weights", {}),
|
|
936
|
-
"schedule": state.get("schedule", {})
|
|
937
|
-
}
|
|
938
|
-
print(json.dumps(config, indent=2, ensure_ascii=False))
|
|
939
|
-
else:
|
|
940
|
-
# Update configuration
|
|
941
|
-
if args.cefr:
|
|
942
|
-
if args.cefr not in ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']:
|
|
943
|
-
print("Error: Invalid CEFR level. Must be A1, A2, B1, B2, C1, or C2")
|
|
944
|
-
exit(1)
|
|
945
|
-
state = sm.update_preferences(state, cefr_level=args.cefr)
|
|
946
|
-
print(f"Updated CEFR level to: {args.cefr}")
|
|
947
|
-
|
|
948
|
-
if args.style:
|
|
949
|
-
if args.style not in ['humorous', 'rigorous', 'casual', 'professional']:
|
|
950
|
-
print("Error: Invalid style. Must be humorous, rigorous, casual, or professional")
|
|
951
|
-
exit(1)
|
|
952
|
-
state = sm.update_preferences(state, tutor_style=args.style)
|
|
953
|
-
print(f"Updated tutor style to: {args.style}")
|
|
954
|
-
|
|
955
|
-
if args.oral_ratio is not None:
|
|
956
|
-
if not 0 <= args.oral_ratio <= 100:
|
|
957
|
-
print("Error: Oral ratio must be between 0 and 100")
|
|
958
|
-
exit(1)
|
|
959
|
-
state = sm.update_preferences(state, oral_ratio=args.oral_ratio)
|
|
960
|
-
print(f"Updated oral ratio to: {args.oral_ratio}%")
|
|
961
|
-
|
|
962
|
-
sm.save_state(state)
|
|
963
|
-
print("Configuration updated successfully")
|
|
964
|
-
|
|
965
|
-
elif args.command == 'errors':
|
|
966
|
-
"""Error notebook operations."""
|
|
967
|
-
state = sm.load_state()
|
|
968
|
-
|
|
969
|
-
if args.stats:
|
|
970
|
-
# Get error statistics
|
|
971
|
-
stats = sm.get_error_stats(state)
|
|
972
|
-
print(json.dumps(stats, indent=2, ensure_ascii=False))
|
|
973
|
-
|
|
974
|
-
elif args.review is not None:
|
|
975
|
-
# Get errors for review session
|
|
976
|
-
errors = sm.get_review_errors(state, count=args.review)
|
|
977
|
-
result = {
|
|
978
|
-
"count": len(errors),
|
|
979
|
-
"errors": errors
|
|
980
|
-
}
|
|
981
|
-
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
982
|
-
|
|
983
|
-
else:
|
|
984
|
-
# Get paginated errors list
|
|
985
|
-
result = sm.get_errors_page(
|
|
986
|
-
state,
|
|
987
|
-
page=args.page,
|
|
988
|
-
per_page=args.per_page,
|
|
989
|
-
month=args.month,
|
|
990
|
-
random=args.random
|
|
991
|
-
)
|
|
992
|
-
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
993
|
-
|
|
994
|
-
elif args.command == 'schedule':
|
|
995
|
-
"""Display or update schedule configuration."""
|
|
996
|
-
state = sm.load_state()
|
|
997
|
-
|
|
998
|
-
# If no update options, just show current schedule
|
|
999
|
-
if not any([args.keypoint_time, args.quiz_time]):
|
|
1000
|
-
schedule = state.get("schedule", {})
|
|
1001
|
-
print(json.dumps(schedule, indent=2, ensure_ascii=False))
|
|
1002
|
-
else:
|
|
1003
|
-
# Validate quiz_time must be later than keypoint_time
|
|
1004
|
-
current_keypoint = state.get("schedule", {}).get("keypoint_time", "06:45")
|
|
1005
|
-
current_quiz = state.get("schedule", {}).get("quiz_time", "22:45")
|
|
1006
|
-
|
|
1007
|
-
new_keypoint = args.keypoint_time or current_keypoint
|
|
1008
|
-
new_quiz = args.quiz_time or current_quiz
|
|
1009
|
-
|
|
1010
|
-
# Time validation
|
|
1011
|
-
def parse_time(t):
|
|
1012
|
-
h, m = map(int, t.split(':'))
|
|
1013
|
-
return h * 60 + m
|
|
1014
|
-
|
|
1015
|
-
if parse_time(new_quiz) <= parse_time(new_keypoint):
|
|
1016
|
-
print("Error: Quiz time must be later than keypoint time")
|
|
1017
|
-
exit(1)
|
|
1018
|
-
|
|
1019
|
-
state = sm.update_schedule(
|
|
1020
|
-
state,
|
|
1021
|
-
keypoint_time=new_keypoint,
|
|
1022
|
-
quiz_time=new_quiz
|
|
1023
|
-
)
|
|
1024
|
-
sm.save_state(state)
|
|
1025
|
-
print(f"Schedule updated: keypoint at {new_keypoint}, quiz at {new_quiz}")
|
|
948
|
+
from cli import main
|
|
949
|
+
main()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|