@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.
- package/.claude/settings.local.json +22 -0
- package/.gitignore +32 -0
- package/CHANGELOG.md +37 -0
- package/CLAUDE.md +275 -0
- package/README.md +369 -0
- package/SKILL.md +613 -0
- package/bin/eng-lang-tutor.js +177 -0
- package/docs/OPENCLAW_DEPLOYMENT.md +241 -0
- package/examples/sample_keypoint_a1.json +112 -0
- package/examples/sample_keypoint_a2.json +124 -0
- package/examples/sample_keypoint_b1.json +135 -0
- package/examples/sample_keypoint_b2.json +137 -0
- package/examples/sample_keypoint_c1.json +134 -0
- package/examples/sample_keypoint_c2.json +141 -0
- package/examples/sample_quiz_a1.json +94 -0
- package/examples/sample_quiz_a2.json +94 -0
- package/examples/sample_quiz_b1.json +92 -0
- package/examples/sample_quiz_b2.json +94 -0
- package/examples/sample_quiz_c1.json +94 -0
- package/examples/sample_quiz_c2.json +104 -0
- package/package.json +41 -0
- package/references/resources.md +292 -0
- package/requirements.txt +16 -0
- package/scripts/__init__.py +28 -0
- package/scripts/audio/__init__.py +23 -0
- package/scripts/audio/composer.py +367 -0
- package/scripts/audio/converter.py +331 -0
- package/scripts/audio/feishu_voice.py +404 -0
- package/scripts/audio/tts/__init__.py +30 -0
- package/scripts/audio/tts/base.py +166 -0
- package/scripts/audio/tts/manager.py +306 -0
- package/scripts/audio/tts/providers/__init__.py +12 -0
- package/scripts/audio/tts/providers/edge.py +111 -0
- package/scripts/audio/tts/providers/xunfei.py +205 -0
- package/scripts/audio/utils.py +63 -0
- package/scripts/cli/__init__.py +7 -0
- package/scripts/cli/cli.py +229 -0
- package/scripts/cli/command_parser.py +336 -0
- package/scripts/core/__init__.py +30 -0
- package/scripts/core/constants.py +125 -0
- package/scripts/core/error_notebook.py +308 -0
- package/scripts/core/gamification.py +405 -0
- package/scripts/core/scorer.py +295 -0
- package/scripts/core/state_manager.py +814 -0
- package/scripts/eng-lang-tutor +16 -0
- package/scripts/scheduling/__init__.py +6 -0
- package/scripts/scheduling/cron_push.py +229 -0
- package/scripts/utils/__init__.py +12 -0
- package/scripts/utils/dedup.py +331 -0
- package/scripts/utils/helpers.py +82 -0
- package/templates/keypoint_schema.json +420 -0
- package/templates/prompt_templates.md +73 -0
- package/templates/prompts/display_guide.md +106 -0
- package/templates/prompts/initialization.md +350 -0
- package/templates/prompts/keypoint_generation.md +272 -0
- package/templates/prompts/output_rules.md +106 -0
- package/templates/prompts/quiz_generation.md +190 -0
- package/templates/prompts/responses.md +339 -0
- package/templates/prompts/shared_enums.md +252 -0
- package/templates/quiz_schema.json +214 -0
- 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
|
+
]
|