@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,229 @@
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
+ try:
14
+ from ..core.state_manager import StateManager
15
+ except ImportError:
16
+ from scripts.core.state_manager import StateManager
17
+
18
+
19
+ def main():
20
+ """Main CLI entry point."""
21
+ parser = argparse.ArgumentParser(description="State Manager for eng-lang-tutor")
22
+ parser.add_argument('--data-dir', default=None,
23
+ help='Data directory path (default: ~/.openclaw/state/eng-lang-tutor or OPENCLAW_STATE_DIR env)')
24
+ parser.add_argument('command', nargs='?',
25
+ choices=['show', 'backup', 'save_daily', 'record_view',
26
+ 'stats', 'config', 'errors', 'schedule',
27
+ 'generate_audio'],
28
+ help='Command to execute')
29
+ parser.add_argument('--content-type', help='Content type for save_daily (keypoint, quiz)')
30
+ parser.add_argument('--content', help='JSON content for save_daily')
31
+ parser.add_argument('--date', help='Date for content (YYYY-MM-DD format)')
32
+ # Errors command options
33
+ parser.add_argument('--page', type=int, default=1, help='Page number for errors list')
34
+ parser.add_argument('--per-page', type=int, default=5, help='Items per page for errors')
35
+ parser.add_argument('--month', help='Filter errors by month (YYYY-MM)')
36
+ parser.add_argument('--random', type=int, help='Get N random errors')
37
+ parser.add_argument('--stats', action='store_true', help='Get error statistics')
38
+ parser.add_argument('--review', type=int, help='Get N errors for review session')
39
+ # Config command options
40
+ parser.add_argument('--cefr', help='Set CEFR level (A1-C2)')
41
+ parser.add_argument('--style', help='Set tutor style')
42
+ parser.add_argument('--oral-ratio', type=int, help='Set oral/written ratio (0-100)')
43
+ # Schedule command options
44
+ parser.add_argument('--keypoint-time', help='Set keypoint push time (HH:MM)')
45
+ parser.add_argument('--quiz-time', help='Set quiz push time (HH:MM)')
46
+
47
+ args = parser.parse_args()
48
+
49
+ sm = StateManager(args.data_dir)
50
+
51
+ if args.command == 'show' or not args.command:
52
+ state = sm.load_state()
53
+ print(json.dumps(state, indent=2, ensure_ascii=False))
54
+
55
+ elif args.command == 'backup':
56
+ backup_path = sm.backup_state()
57
+ print(f"Backup created: {backup_path}")
58
+
59
+ elif args.command == 'save_daily':
60
+ if not args.content_type or not args.content:
61
+ print("Error: --content-type and --content are required for save_daily")
62
+ exit(1)
63
+
64
+ try:
65
+ content = json.loads(args.content)
66
+ except json.JSONDecodeError as e:
67
+ print(f"Error: Invalid JSON content: {e}")
68
+ exit(1)
69
+
70
+ target_date = None
71
+ if args.date:
72
+ try:
73
+ target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
74
+ except ValueError:
75
+ print("Error: Invalid date format. Use YYYY-MM-DD")
76
+ exit(1)
77
+
78
+ path = sm.save_daily_content(args.content_type, content, target_date)
79
+ print(f"Saved to: {path}")
80
+
81
+ elif args.command == 'record_view':
82
+ target_date = None
83
+ if args.date:
84
+ try:
85
+ target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
86
+ except ValueError:
87
+ print("Error: Invalid date format. Use YYYY-MM-DD")
88
+ exit(1)
89
+
90
+ state = sm.load_state()
91
+ sm.record_keypoint_view(state, target_date)
92
+ sm.save_state(state)
93
+ print("View recorded successfully")
94
+
95
+ elif args.command == 'stats':
96
+ """Display learning progress summary."""
97
+ try:
98
+ from ..core.gamification import GamificationManager
99
+ except ImportError:
100
+ from scripts.core.gamification import GamificationManager
101
+ state = sm.load_state()
102
+ gm = GamificationManager()
103
+ summary = gm.get_progress_summary(state)
104
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
105
+
106
+ elif args.command == 'config':
107
+ """Display or update user configuration."""
108
+ state = sm.load_state()
109
+
110
+ # If no update options, just show current config
111
+ if not any([args.cefr, args.style, args.oral_ratio is not None]):
112
+ config = {
113
+ "cefr_level": state.get("preferences", {}).get("cefr_level", "B1"),
114
+ "tutor_style": state.get("preferences", {}).get("tutor_style", "humorous"),
115
+ "oral_ratio": state.get("preferences", {}).get("oral_ratio", 70),
116
+ "topic_weights": state.get("preferences", {}).get("topic_weights", {}),
117
+ "schedule": state.get("schedule", {})
118
+ }
119
+ print(json.dumps(config, indent=2, ensure_ascii=False))
120
+ else:
121
+ # Update configuration
122
+ if args.cefr:
123
+ if args.cefr not in ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']:
124
+ print("Error: Invalid CEFR level. Must be A1, A2, B1, B2, C1, or C2")
125
+ exit(1)
126
+ state = sm.update_preferences(state, cefr_level=args.cefr)
127
+ print(f"Updated CEFR level to: {args.cefr}")
128
+
129
+ if args.style:
130
+ if args.style not in ['humorous', 'rigorous', 'casual', 'professional']:
131
+ print("Error: Invalid style. Must be humorous, rigorous, casual, or professional")
132
+ exit(1)
133
+ state = sm.update_preferences(state, tutor_style=args.style)
134
+ print(f"Updated tutor style to: {args.style}")
135
+
136
+ if args.oral_ratio is not None:
137
+ if not 0 <= args.oral_ratio <= 100:
138
+ print("Error: Oral ratio must be between 0 and 100")
139
+ exit(1)
140
+ state = sm.update_preferences(state, oral_ratio=args.oral_ratio)
141
+ print(f"Updated oral ratio to: {args.oral_ratio}%")
142
+
143
+ sm.save_state(state)
144
+ print("Configuration updated successfully")
145
+
146
+ elif args.command == 'errors':
147
+ """Error notebook operations."""
148
+ state = sm.load_state()
149
+
150
+ if args.stats:
151
+ # Get error statistics
152
+ stats = sm.get_error_stats(state)
153
+ print(json.dumps(stats, indent=2, ensure_ascii=False))
154
+
155
+ elif args.review is not None:
156
+ # Get errors for review session
157
+ errors = sm.get_review_errors(state, count=args.review)
158
+ result = {
159
+ "count": len(errors),
160
+ "errors": errors
161
+ }
162
+ print(json.dumps(result, indent=2, ensure_ascii=False))
163
+
164
+ else:
165
+ # Get paginated errors list
166
+ result = sm.get_errors_page(
167
+ state,
168
+ page=args.page,
169
+ per_page=args.per_page,
170
+ month=args.month,
171
+ random=args.random
172
+ )
173
+ print(json.dumps(result, indent=2, ensure_ascii=False))
174
+
175
+ elif args.command == 'schedule':
176
+ """Display or update schedule configuration."""
177
+ state = sm.load_state()
178
+
179
+ # If no update options, just show current schedule
180
+ if not any([args.keypoint_time, args.quiz_time]):
181
+ schedule = state.get("schedule", {})
182
+ print(json.dumps(schedule, indent=2, ensure_ascii=False))
183
+ else:
184
+ # Validate quiz_time must be later than keypoint_time
185
+ current_keypoint = state.get("schedule", {}).get("keypoint_time", "06:45")
186
+ current_quiz = state.get("schedule", {}).get("quiz_time", "22:45")
187
+
188
+ new_keypoint = args.keypoint_time or current_keypoint
189
+ new_quiz = args.quiz_time or current_quiz
190
+
191
+ # Time validation
192
+ def parse_time(t):
193
+ h, m = map(int, t.split(':'))
194
+ return h * 60 + m
195
+
196
+ if parse_time(new_quiz) <= parse_time(new_keypoint):
197
+ print("Error: Quiz time must be later than keypoint time")
198
+ exit(1)
199
+
200
+ state = sm.update_schedule(
201
+ state,
202
+ keypoint_time=new_keypoint,
203
+ quiz_time=new_quiz
204
+ )
205
+ sm.save_state(state)
206
+ print(f"Schedule updated: keypoint at {new_keypoint}, quiz at {new_quiz}")
207
+
208
+ elif args.command == 'generate_audio':
209
+ """Generate audio for a keypoint."""
210
+ target_date = None
211
+ if args.date:
212
+ try:
213
+ target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
214
+ except ValueError:
215
+ print("Error: Invalid date format. Use YYYY-MM-DD")
216
+ exit(1)
217
+
218
+ result = sm.generate_keypoint_audio(target_date)
219
+
220
+ if result.get('success'):
221
+ print(f"Audio generated: {result.get('audio_path')}")
222
+ print(f"Duration: {result.get('duration_seconds', 0):.1f} seconds")
223
+ else:
224
+ print(f"Failed to generate audio: {result.get('error_message')}")
225
+ exit(1)
226
+
227
+
228
+ if __name__ == "__main__":
229
+ main()
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Command Parser - Parses user messages and routes to appropriate handlers.
4
+
5
+ Handles:
6
+ - Command recognition via regex patterns
7
+ - Parameter extraction from messages
8
+ - Initialization flow detection
9
+ - Bilingual support (English/Chinese)
10
+ """
11
+
12
+ import re
13
+ from typing import Dict, Any, Optional, Tuple
14
+ from datetime import datetime, date, timedelta
15
+
16
+
17
+ class CommandParser:
18
+ """Parses user messages to determine intent and extract parameters."""
19
+
20
+ # Command patterns with bilingual support
21
+ COMMAND_PATTERNS = {
22
+ # Initialization commands
23
+ "init_start": r"(?i)^(start|begin|开始|初始化|你好|hello|hi|嗨|hey).*$",
24
+
25
+ # Keypoint commands
26
+ "keypoint_today": r"(?i)(keypoint|知识点|今天|today).*(?!.*(历史|history|昨天|yesterday|前天|上周))",
27
+ "keypoint_history": r"(?i)(keypoint|知识点).*(历史|history|昨天|yesterday|前天|previous|上周)",
28
+ "keypoint_date": r"(?i)(keypoint|知识点).*?(\d{4}-\d{2}-\d{2}|\d{1,2}月\d{1,2}[日号]?)",
29
+
30
+ # Quiz commands
31
+ "quiz_take": r"(?i)(quiz|测验|test|测试|答题|考试)",
32
+
33
+ # Stats commands
34
+ "stats_view": r"(?i)(stats|progress|进度|统计|等级|level|xp|连胜|streak|成就|achievement)",
35
+
36
+ # Config commands
37
+ "config_view": r"(?i)(config|settings?|设置|配置|偏好|preference)(?!.*(change|改|set|设|更新))",
38
+ "config_change_cefr": r"(?i)(cefr|等级|level).*(A1|A2|B1|B2|C1|C2)",
39
+ "config_change_style": r"(?i)(style|风格|导师).*(humorous|rigorous|casual|professional|幽默|严谨|随意|专业)",
40
+ "config_change_topics": r"(?i)(topic|主题|配比|权重|兴趣)",
41
+ "config_change_ratio": r"(?i)(ratio|比例).*(口语|oral|书面|written|speaking|writing)",
42
+
43
+ # Schedule commands
44
+ "schedule_view": r"(?i)(schedule|时间表|推送时间|定时)(?!.*(change|改|set|设))",
45
+ "schedule_change": r"(?i)(schedule|时间表|推送时间).*(change|改|set|设|调整)",
46
+
47
+ # Error notebook commands
48
+ "errors_view": r"(?i)(errors?|错题本|mistakes?|wrong|错误|错题)(?!.*(更多|more|随机|random|清空|clear|删除|remove|\d{4}-\d{2}))",
49
+ "errors_more": r"(?i)(errors?|错题本).*(更多|more|下一页|next)",
50
+ "errors_page": r"(?i)(errors?|错题本).*(第\s*\d+\s*页|page\s*\d+)",
51
+ "errors_month": r"(?i)(errors?|错题本).*(\d{4}-\d{2})(?!.*\d{2})", # YYYY-MM format
52
+ "errors_random": r"(?i)(errors?|错题本).*(随机|random)",
53
+ "errors_clear": r"(?i)(errors?|错题本).*(clear|清空|删除|remove)",
54
+ "errors_stats": r"(?i)(errors?|错题本).*(统计|stats|statistics)",
55
+ "errors_review": r"(?i)(errors?|错题本).*(复习|review|练习|practice)(?!.*(更多|more|随机|random|清空|clear|删除|remove|统计|stats))",
56
+
57
+ # Help command
58
+ "help": r"(?i)(help|帮助|usage|怎么用|how to use|command|命令|指令|功能)",
59
+ }
60
+
61
+ # Onboarding step patterns for collecting user input
62
+ ONBOARDING_PATTERNS = {
63
+ "cefr_level": r"(?i)(A1|A2|B1|B2|C1|C2)",
64
+ "tutor_style": r"(?i)(humorous|rigorous|casual|professional|幽默|严谨|随意|专业)",
65
+ "topics": r"(?i)(movies?|新闻?|news|游戏?|gaming|体育?|sports?|职场?|workplace|社交?|social|生活?|daily)",
66
+ "ratio": r"(\d{1,3})\s*(%|percent|百分比)?",
67
+ }
68
+
69
+ def __init__(self, state_manager=None):
70
+ """
71
+ Initialize the command parser.
72
+
73
+ Args:
74
+ state_manager: Optional StateManager instance for context
75
+ """
76
+ self.state_manager = state_manager
77
+
78
+ def parse(self, message: str, state: Dict[str, Any]) -> Dict[str, Any]:
79
+ """
80
+ Parse user message and return command info.
81
+
82
+ Args:
83
+ message: User's message text
84
+ state: Current state dictionary
85
+
86
+ Returns:
87
+ Dictionary with:
88
+ - command: Command name
89
+ - params: Extracted parameters
90
+ - requires_init: Whether command requires initialization
91
+ - onboarding_input: If in onboarding, the detected input type
92
+ """
93
+ # Check if user is in onboarding flow
94
+ if not state.get("initialized", False):
95
+ return self._handle_uninitialized(message, state)
96
+
97
+ # Parse for initialized users
98
+ return self._parse_command(message)
99
+
100
+ def _handle_uninitialized(self, message: str, state: Dict[str, Any]) -> Dict[str, Any]:
101
+ """Handle messages from uninitialized users."""
102
+ step = state.get("onboarding_step", 0)
103
+
104
+ if step == 0:
105
+ # Check for start command
106
+ if re.search(self.COMMAND_PATTERNS["init_start"], message):
107
+ return {
108
+ "command": "init_start",
109
+ "params": {},
110
+ "requires_init": False,
111
+ "onboarding_input": None
112
+ }
113
+ else:
114
+ # Any other message triggers welcome
115
+ return {
116
+ "command": "init_welcome",
117
+ "params": {},
118
+ "requires_init": False,
119
+ "onboarding_input": None
120
+ }
121
+
122
+ # User is in onboarding, detect what they're providing
123
+ onboarding_input = self._detect_onboarding_input(message, step)
124
+
125
+ return {
126
+ "command": "init_continue",
127
+ "params": {"step": step},
128
+ "requires_init": False,
129
+ "onboarding_input": onboarding_input
130
+ }
131
+
132
+ def _detect_onboarding_input(self, message: str, step: int) -> Optional[Dict[str, Any]]:
133
+ """Detect user input during onboarding based on current step."""
134
+ result = {"type": None, "value": None}
135
+
136
+ if step == 1: # CEFR level
137
+ match = re.search(self.ONBOARDING_PATTERNS["cefr_level"], message)
138
+ if match:
139
+ result = {"type": "cefr_level", "value": match.group(1).upper()}
140
+
141
+ elif step == 2: # Topics
142
+ topics = {}
143
+ topic_keywords = {
144
+ "movies": ["movie", "film", "影视", "电影", "美剧"],
145
+ "news": ["news", "新闻"],
146
+ "gaming": ["game", "gaming", "游戏"],
147
+ "sports": ["sport", "sports", "体育", "运动"],
148
+ "workplace": ["work", "workplace", "office", "职场", "工作"],
149
+ "social": ["social", "社交"],
150
+ "daily_life": ["daily", "life", "生活", "日常"]
151
+ }
152
+
153
+ for topic, keywords in topic_keywords.items():
154
+ for kw in keywords:
155
+ if kw.lower() in message.lower():
156
+ topics[topic] = 0.2 # Default weight
157
+ break
158
+
159
+ if topics:
160
+ # Normalize weights to sum to 1.0
161
+ total = sum(topics.values())
162
+ topics = {k: round(v / total, 2) for k, v in topics.items()}
163
+ result = {"type": "topics", "value": topics}
164
+
165
+ elif step == 3: # Tutor style
166
+ style_map = {
167
+ "humorous": ["humorous", "幽默"],
168
+ "rigorous": ["rigorous", "严谨"],
169
+ "casual": ["casual", "随意", "轻松"],
170
+ "professional": ["professional", "专业"]
171
+ }
172
+ for style, keywords in style_map.items():
173
+ for kw in keywords:
174
+ if kw.lower() in message.lower():
175
+ result = {"type": "tutor_style", "value": style}
176
+ break
177
+ if result["value"]:
178
+ break
179
+
180
+ elif step == 4: # Oral/written ratio
181
+ match = re.search(self.ONBOARDING_PATTERNS["ratio"], message)
182
+ if match:
183
+ ratio = int(match.group(1)) / 100.0
184
+ ratio = max(0, min(1, ratio)) # Clamp to 0-1
185
+ result = {"type": "oral_written_ratio", "value": ratio}
186
+
187
+ return result if result["type"] else None
188
+
189
+ def _parse_command(self, message: str) -> Dict[str, Any]:
190
+ """Parse message for an initialized user."""
191
+ result = {
192
+ "command": "unknown",
193
+ "params": {},
194
+ "requires_init": False,
195
+ "onboarding_input": None
196
+ }
197
+
198
+ # Check each command pattern
199
+ for cmd_name, pattern in self.COMMAND_PATTERNS.items():
200
+ match = re.search(pattern, message)
201
+ if match:
202
+ result["command"] = cmd_name
203
+ result["params"] = self._extract_params(cmd_name, match, message)
204
+ result["requires_init"] = not cmd_name.startswith("init") and cmd_name != "help"
205
+ break
206
+
207
+ return result
208
+
209
+ def _extract_params(self, cmd_name: str, match: re.Match, message: str) -> Dict[str, Any]:
210
+ """Extract parameters from matched command."""
211
+ params = {}
212
+
213
+ # Extract date from keypoint queries
214
+ if "date" in cmd_name or cmd_name in ["keypoint_today", "keypoint_history"]:
215
+ date_match = re.search(r"(\d{4}-\d{2}-\d{2})", message)
216
+ if date_match:
217
+ params["date"] = date_match.group(1)
218
+ elif "history" in cmd_name or "昨天" in message or "yesterday" in message.lower():
219
+ params["date"] = (date.today() - timedelta(days=1)).isoformat()
220
+ elif "前天" in message:
221
+ params["date"] = (date.today() - timedelta(days=2)).isoformat()
222
+ else:
223
+ params["date"] = date.today().isoformat()
224
+
225
+ # Extract CEFR level
226
+ if "cefr" in cmd_name:
227
+ level_match = re.search(r"(A1|A2|B1|B2|C1|C2)", message, re.I)
228
+ if level_match:
229
+ params["cefr_level"] = level_match.group(1).upper()
230
+
231
+ # Extract style
232
+ if "style" in cmd_name:
233
+ style_map = {
234
+ "humorous": "humorous", "幽默": "humorous",
235
+ "rigorous": "rigorous", "严谨": "rigorous",
236
+ "casual": "casual", "随意": "casual", "轻松": "casual",
237
+ "professional": "professional", "专业": "professional"
238
+ }
239
+ for keyword, style in style_map.items():
240
+ if keyword in message.lower():
241
+ params["tutor_style"] = style
242
+ break
243
+
244
+ # Extract ratio
245
+ if "ratio" in cmd_name:
246
+ ratio_match = re.search(r"(\d{1,3})\s*(%|percent|百分比)?", message)
247
+ if ratio_match:
248
+ params["oral_written_ratio"] = int(ratio_match.group(1)) / 100.0
249
+
250
+ # Extract error notebook pagination params
251
+ if cmd_name and cmd_name.startswith("errors"):
252
+ # Page number
253
+ page_match = re.search(r"(第\s*)?(\d+)(\s*页|page)", message, re.I)
254
+ if page_match:
255
+ params["page"] = int(page_match.group(2))
256
+
257
+ # Month filter (YYYY-MM)
258
+ month_match = re.search(r"(\d{4}-\d{2})(?!-\d{2})", message)
259
+ if month_match:
260
+ params["month"] = month_match.group(1)
261
+
262
+ # Random count
263
+ random_match = re.search(r"(随机|random)\s*(\d*)", message, re.I)
264
+ if random_match:
265
+ count = int(random_match.group(2)) if random_match.group(2) else 5
266
+ params["random"] = count
267
+
268
+ # Default page for "more" command
269
+ if "more" in cmd_name:
270
+ params["page"] = params.get("page", 2)
271
+
272
+ # Review count (for errors_review command)
273
+ if "review" in cmd_name:
274
+ count_match = re.search(r"(复习|review|练习|practice)\s*(\d*)", message, re.I)
275
+ if count_match and count_match.group(2):
276
+ params["count"] = int(count_match.group(2))
277
+ else:
278
+ params["count"] = 5 # Default 5 questions per session
279
+
280
+ return params
281
+
282
+ def get_command_suggestions(self, context: str = "general") -> list:
283
+ """
284
+ Get suggested commands based on context.
285
+
286
+ Args:
287
+ context: Context for suggestions (general, after_quiz, morning, etc.)
288
+
289
+ Returns:
290
+ List of suggested command strings
291
+ """
292
+ suggestions = {
293
+ "general": ["keypoint", "quiz", "stats", "help"],
294
+ "after_quiz": ["stats", "errors", "keypoint"],
295
+ "morning": ["keypoint", "quiz"],
296
+ "evening": ["quiz", "stats"]
297
+ }
298
+ return suggestions.get(context, suggestions["general"])
299
+
300
+
301
+ # CLI interface for testing
302
+ if __name__ == "__main__":
303
+ import argparse
304
+ import json
305
+
306
+ parser = argparse.ArgumentParser(description="Command Parser for eng-lang-tutor")
307
+ parser.add_argument('--message', type=str, help='Message to parse')
308
+ parser.add_argument('--demo', action='store_true', help='Run demo')
309
+
310
+ args = parser.parse_args()
311
+
312
+ cp = CommandParser()
313
+
314
+ if args.demo:
315
+ test_messages = [
316
+ "start",
317
+ "今天知识点",
318
+ "keypoint today",
319
+ "quiz",
320
+ "我的进度",
321
+ "stats",
322
+ "config",
323
+ "设置 CEFR 为 B2",
324
+ "help",
325
+ "错题本"
326
+ ]
327
+
328
+ print("=== Command Parser Demo ===\n")
329
+ for msg in test_messages:
330
+ result = cp._parse_command(msg)
331
+ print(f"Message: {msg}")
332
+ print(f"Result: {json.dumps(result, indent=2)}\n")
333
+
334
+ elif args.message:
335
+ result = cp._parse_command(args.message)
336
+ print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env python3
2
+ """Core functionality: state management, scoring, gamification."""
3
+
4
+ from .state_manager import StateManager
5
+ from .scorer import Scorer
6
+ from .gamification import GamificationManager
7
+ from .constants import (
8
+ LEVEL_THRESHOLDS,
9
+ LEVEL_NAMES,
10
+ STREAK_BONUS_PER_DAY,
11
+ STREAK_BONUS_CAP,
12
+ get_level_name,
13
+ calculate_level,
14
+ get_streak_multiplier,
15
+ )
16
+ from .error_notebook import ErrorNotebookManager
17
+
18
+ __all__ = [
19
+ 'StateManager',
20
+ 'Scorer',
21
+ 'GamificationManager',
22
+ 'ErrorNotebookManager',
23
+ 'LEVEL_THRESHOLDS',
24
+ 'LEVEL_NAMES',
25
+ 'STREAK_BONUS_PER_DAY',
26
+ 'STREAK_BONUS_CAP',
27
+ 'get_level_name',
28
+ 'calculate_level',
29
+ 'get_streak_multiplier',
30
+ ]