@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.

Files changed (37) 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/requirements.txt +2 -0
  8. package/scripts/__pycache__/audio_composer.cpython-313.pyc +0 -0
  9. package/scripts/__pycache__/audio_converter.cpython-313.pyc +0 -0
  10. package/scripts/__pycache__/audio_enhancer.cpython-313.pyc +0 -0
  11. package/scripts/__pycache__/state_manager.cpython-313.pyc +0 -0
  12. package/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  13. package/scripts/audio_composer.py +389 -0
  14. package/scripts/audio_converter.py +245 -0
  15. package/scripts/cli.py +223 -0
  16. package/scripts/constants.py +56 -0
  17. package/scripts/feishu_voice.py +421 -0
  18. package/scripts/gamification.py +48 -34
  19. package/scripts/scorer.py +14 -32
  20. package/scripts/state_manager.py +131 -207
  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/manager.py +14 -4
  25. package/scripts/tts/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  26. package/scripts/tts/providers/__pycache__/xunfei.cpython-313.pyc +0 -0
  27. package/scripts/tts/providers/xunfei.py +10 -1
  28. package/scripts/utils.py +78 -0
  29. package/templates/prompt_templates.md +47 -1235
  30. package/templates/prompts/display_guide.md +106 -0
  31. package/templates/prompts/initialization.md +213 -0
  32. package/templates/prompts/keypoint_generation.md +268 -0
  33. package/templates/prompts/output_rules.md +106 -0
  34. package/templates/prompts/quiz_generation.md +187 -0
  35. package/templates/prompts/responses.md +291 -0
  36. package/templates/prompts/shared_enums.md +97 -0
  37. package/templates/state_schema.json +1 -6
@@ -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])
@@ -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
- """Merge loaded state with defaults to ensure all fields exist."""
199
- defaults = self._default_state()
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
- return merged
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) -> Path:
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
- state['error_notebook'].append(error)
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 argparse
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()