@rookiestar/eng-lang-tutor 1.0.2 → 1.0.3
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/scripts/__pycache__/cli.cpython-313.pyc +0 -0
- package/scripts/__pycache__/command_parser.cpython-313.pyc +0 -0
- package/scripts/__pycache__/constants.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cron_push.cpython-313.pyc +0 -0
- package/scripts/__pycache__/dedup.cpython-313.pyc +0 -0
- package/scripts/__pycache__/gamification.cpython-313.pyc +0 -0
- package/scripts/__pycache__/scorer.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/cli.py +203 -0
- package/scripts/constants.py +56 -0
- package/scripts/gamification.py +48 -34
- package/scripts/scorer.py +14 -32
- package/scripts/state_manager.py +41 -206
- 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/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/tts/providers/__pycache__/xunfei.cpython-313.pyc +0 -0
- 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/cli.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
from state_manager import StateManager
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main():
|
|
17
|
+
"""Main CLI entry point."""
|
|
18
|
+
parser = argparse.ArgumentParser(description="State Manager for eng-lang-tutor")
|
|
19
|
+
parser.add_argument('--data-dir', default=None,
|
|
20
|
+
help='Data directory path (default: ~/.openclaw/state/eng-lang-tutor or OPENCLAW_STATE_DIR env)')
|
|
21
|
+
parser.add_argument('command', nargs='?',
|
|
22
|
+
choices=['show', 'backup', 'save_daily', 'record_view',
|
|
23
|
+
'stats', 'config', 'errors', 'schedule'],
|
|
24
|
+
help='Command to execute')
|
|
25
|
+
parser.add_argument('--content-type', help='Content type for save_daily (keypoint, quiz)')
|
|
26
|
+
parser.add_argument('--content', help='JSON content for save_daily')
|
|
27
|
+
parser.add_argument('--date', help='Date for content (YYYY-MM-DD format)')
|
|
28
|
+
# Errors command options
|
|
29
|
+
parser.add_argument('--page', type=int, default=1, help='Page number for errors list')
|
|
30
|
+
parser.add_argument('--per-page', type=int, default=5, help='Items per page for errors')
|
|
31
|
+
parser.add_argument('--month', help='Filter errors by month (YYYY-MM)')
|
|
32
|
+
parser.add_argument('--random', type=int, help='Get N random errors')
|
|
33
|
+
parser.add_argument('--stats', action='store_true', help='Get error statistics')
|
|
34
|
+
parser.add_argument('--review', type=int, help='Get N errors for review session')
|
|
35
|
+
# Config command options
|
|
36
|
+
parser.add_argument('--cefr', help='Set CEFR level (A1-C2)')
|
|
37
|
+
parser.add_argument('--style', help='Set tutor style')
|
|
38
|
+
parser.add_argument('--oral-ratio', type=int, help='Set oral/written ratio (0-100)')
|
|
39
|
+
# Schedule command options
|
|
40
|
+
parser.add_argument('--keypoint-time', help='Set keypoint push time (HH:MM)')
|
|
41
|
+
parser.add_argument('--quiz-time', help='Set quiz push time (HH:MM)')
|
|
42
|
+
|
|
43
|
+
args = parser.parse_args()
|
|
44
|
+
|
|
45
|
+
sm = StateManager(args.data_dir)
|
|
46
|
+
|
|
47
|
+
if args.command == 'show' or not args.command:
|
|
48
|
+
state = sm.load_state()
|
|
49
|
+
print(json.dumps(state, indent=2, ensure_ascii=False))
|
|
50
|
+
|
|
51
|
+
elif args.command == 'backup':
|
|
52
|
+
backup_path = sm.backup_state()
|
|
53
|
+
print(f"Backup created: {backup_path}")
|
|
54
|
+
|
|
55
|
+
elif args.command == 'save_daily':
|
|
56
|
+
if not args.content_type or not args.content:
|
|
57
|
+
print("Error: --content-type and --content are required for save_daily")
|
|
58
|
+
exit(1)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
content = json.loads(args.content)
|
|
62
|
+
except json.JSONDecodeError as e:
|
|
63
|
+
print(f"Error: Invalid JSON content: {e}")
|
|
64
|
+
exit(1)
|
|
65
|
+
|
|
66
|
+
target_date = None
|
|
67
|
+
if args.date:
|
|
68
|
+
try:
|
|
69
|
+
target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
|
|
70
|
+
except ValueError:
|
|
71
|
+
print("Error: Invalid date format. Use YYYY-MM-DD")
|
|
72
|
+
exit(1)
|
|
73
|
+
|
|
74
|
+
path = sm.save_daily_content(args.content_type, content, target_date)
|
|
75
|
+
print(f"Saved to: {path}")
|
|
76
|
+
|
|
77
|
+
elif args.command == 'record_view':
|
|
78
|
+
target_date = None
|
|
79
|
+
if args.date:
|
|
80
|
+
try:
|
|
81
|
+
target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
|
|
82
|
+
except ValueError:
|
|
83
|
+
print("Error: Invalid date format. Use YYYY-MM-DD")
|
|
84
|
+
exit(1)
|
|
85
|
+
|
|
86
|
+
state = sm.load_state()
|
|
87
|
+
sm.record_keypoint_view(state, target_date)
|
|
88
|
+
sm.save_state(state)
|
|
89
|
+
print("View recorded successfully")
|
|
90
|
+
|
|
91
|
+
elif args.command == 'stats':
|
|
92
|
+
"""Display learning progress summary."""
|
|
93
|
+
from gamification import GamificationManager
|
|
94
|
+
state = sm.load_state()
|
|
95
|
+
gm = GamificationManager()
|
|
96
|
+
summary = gm.get_progress_summary(state)
|
|
97
|
+
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
98
|
+
|
|
99
|
+
elif args.command == 'config':
|
|
100
|
+
"""Display or update user configuration."""
|
|
101
|
+
state = sm.load_state()
|
|
102
|
+
|
|
103
|
+
# If no update options, just show current config
|
|
104
|
+
if not any([args.cefr, args.style, args.oral_ratio is not None]):
|
|
105
|
+
config = {
|
|
106
|
+
"cefr_level": state.get("preferences", {}).get("cefr_level", "B1"),
|
|
107
|
+
"tutor_style": state.get("preferences", {}).get("tutor_style", "humorous"),
|
|
108
|
+
"oral_ratio": state.get("preferences", {}).get("oral_ratio", 70),
|
|
109
|
+
"topic_weights": state.get("preferences", {}).get("topic_weights", {}),
|
|
110
|
+
"schedule": state.get("schedule", {})
|
|
111
|
+
}
|
|
112
|
+
print(json.dumps(config, indent=2, ensure_ascii=False))
|
|
113
|
+
else:
|
|
114
|
+
# Update configuration
|
|
115
|
+
if args.cefr:
|
|
116
|
+
if args.cefr not in ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']:
|
|
117
|
+
print("Error: Invalid CEFR level. Must be A1, A2, B1, B2, C1, or C2")
|
|
118
|
+
exit(1)
|
|
119
|
+
state = sm.update_preferences(state, cefr_level=args.cefr)
|
|
120
|
+
print(f"Updated CEFR level to: {args.cefr}")
|
|
121
|
+
|
|
122
|
+
if args.style:
|
|
123
|
+
if args.style not in ['humorous', 'rigorous', 'casual', 'professional']:
|
|
124
|
+
print("Error: Invalid style. Must be humorous, rigorous, casual, or professional")
|
|
125
|
+
exit(1)
|
|
126
|
+
state = sm.update_preferences(state, tutor_style=args.style)
|
|
127
|
+
print(f"Updated tutor style to: {args.style}")
|
|
128
|
+
|
|
129
|
+
if args.oral_ratio is not None:
|
|
130
|
+
if not 0 <= args.oral_ratio <= 100:
|
|
131
|
+
print("Error: Oral ratio must be between 0 and 100")
|
|
132
|
+
exit(1)
|
|
133
|
+
state = sm.update_preferences(state, oral_ratio=args.oral_ratio)
|
|
134
|
+
print(f"Updated oral ratio to: {args.oral_ratio}%")
|
|
135
|
+
|
|
136
|
+
sm.save_state(state)
|
|
137
|
+
print("Configuration updated successfully")
|
|
138
|
+
|
|
139
|
+
elif args.command == 'errors':
|
|
140
|
+
"""Error notebook operations."""
|
|
141
|
+
state = sm.load_state()
|
|
142
|
+
|
|
143
|
+
if args.stats:
|
|
144
|
+
# Get error statistics
|
|
145
|
+
stats = sm.get_error_stats(state)
|
|
146
|
+
print(json.dumps(stats, indent=2, ensure_ascii=False))
|
|
147
|
+
|
|
148
|
+
elif args.review is not None:
|
|
149
|
+
# Get errors for review session
|
|
150
|
+
errors = sm.get_review_errors(state, count=args.review)
|
|
151
|
+
result = {
|
|
152
|
+
"count": len(errors),
|
|
153
|
+
"errors": errors
|
|
154
|
+
}
|
|
155
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
156
|
+
|
|
157
|
+
else:
|
|
158
|
+
# Get paginated errors list
|
|
159
|
+
result = sm.get_errors_page(
|
|
160
|
+
state,
|
|
161
|
+
page=args.page,
|
|
162
|
+
per_page=args.per_page,
|
|
163
|
+
month=args.month,
|
|
164
|
+
random=args.random
|
|
165
|
+
)
|
|
166
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
167
|
+
|
|
168
|
+
elif args.command == 'schedule':
|
|
169
|
+
"""Display or update schedule configuration."""
|
|
170
|
+
state = sm.load_state()
|
|
171
|
+
|
|
172
|
+
# If no update options, just show current schedule
|
|
173
|
+
if not any([args.keypoint_time, args.quiz_time]):
|
|
174
|
+
schedule = state.get("schedule", {})
|
|
175
|
+
print(json.dumps(schedule, indent=2, ensure_ascii=False))
|
|
176
|
+
else:
|
|
177
|
+
# Validate quiz_time must be later than keypoint_time
|
|
178
|
+
current_keypoint = state.get("schedule", {}).get("keypoint_time", "06:45")
|
|
179
|
+
current_quiz = state.get("schedule", {}).get("quiz_time", "22:45")
|
|
180
|
+
|
|
181
|
+
new_keypoint = args.keypoint_time or current_keypoint
|
|
182
|
+
new_quiz = args.quiz_time or current_quiz
|
|
183
|
+
|
|
184
|
+
# Time validation
|
|
185
|
+
def parse_time(t):
|
|
186
|
+
h, m = map(int, t.split(':'))
|
|
187
|
+
return h * 60 + m
|
|
188
|
+
|
|
189
|
+
if parse_time(new_quiz) <= parse_time(new_keypoint):
|
|
190
|
+
print("Error: Quiz time must be later than keypoint time")
|
|
191
|
+
exit(1)
|
|
192
|
+
|
|
193
|
+
state = sm.update_schedule(
|
|
194
|
+
state,
|
|
195
|
+
keypoint_time=new_keypoint,
|
|
196
|
+
quiz_time=new_quiz
|
|
197
|
+
)
|
|
198
|
+
sm.save_state(state)
|
|
199
|
+
print(f"Schedule updated: keypoint at {new_keypoint}, quiz at {new_quiz}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Shared constants for eng-lang-tutor.
|
|
4
|
+
|
|
5
|
+
This module contains constants used across multiple scripts to avoid duplication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Level thresholds (XP needed for each level)
|
|
9
|
+
# Note: Level is Activity Level (活跃等级), measuring engagement depth,
|
|
10
|
+
# NOT language ability (which is CEFR level A1-C2)
|
|
11
|
+
LEVEL_THRESHOLDS = [
|
|
12
|
+
0, 50, 100, 200, 350, # 1-5 Starter (启程者)
|
|
13
|
+
550, 800, 1100, 1500, 2000, # 6-10 Traveler (行路人)
|
|
14
|
+
2600, 3300, 4100, 5000, 6000, # 11-15 Explorer (探索者)
|
|
15
|
+
7200, 8500, 10000, 12000, 15000 # 16-20 Pioneer (开拓者)
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# Level range to name mapping
|
|
19
|
+
LEVEL_NAMES = {
|
|
20
|
+
(1, 5): "Starter", # 启程者
|
|
21
|
+
(6, 10): "Traveler", # 行路人
|
|
22
|
+
(11, 15): "Explorer", # 探索者
|
|
23
|
+
(16, 20): "Pioneer" # 开拓者
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_level_name(level: int) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Get the journey stage name for a level.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
level: Activity level (1-20)
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Stage name (Starter/Traveler/Explorer/Pioneer)
|
|
36
|
+
"""
|
|
37
|
+
for (min_level, max_level), name in LEVEL_NAMES.items():
|
|
38
|
+
if min_level <= level <= max_level:
|
|
39
|
+
return name
|
|
40
|
+
return "Unknown"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def calculate_level(xp: int) -> int:
|
|
44
|
+
"""
|
|
45
|
+
Calculate level from total XP.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
xp: Total experience points
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Level (1-20)
|
|
52
|
+
"""
|
|
53
|
+
for i in range(len(LEVEL_THRESHOLDS) - 1, -1, -1):
|
|
54
|
+
if xp >= LEVEL_THRESHOLDS[i]:
|
|
55
|
+
return i + 1
|
|
56
|
+
return 1
|
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])
|