@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/state_manager.py
CHANGED
|
@@ -16,6 +16,8 @@ from datetime import datetime, date
|
|
|
16
16
|
from typing import Dict, Any, Optional, List
|
|
17
17
|
import shutil
|
|
18
18
|
|
|
19
|
+
from utils import deep_merge
|
|
20
|
+
|
|
19
21
|
|
|
20
22
|
def get_default_state_dir() -> Path:
|
|
21
23
|
"""
|
|
@@ -116,6 +118,16 @@ class StateManager:
|
|
|
116
118
|
except Exception as e:
|
|
117
119
|
print(f"Warning: Migration failed: {e}")
|
|
118
120
|
print("Will use new empty state directory.")
|
|
121
|
+
# Log migration failure to event log (after ensuring directories exist)
|
|
122
|
+
try:
|
|
123
|
+
self._ensure_directories()
|
|
124
|
+
self.append_event('migration_failed', {
|
|
125
|
+
"error": str(e),
|
|
126
|
+
"source_dir": str(old_data_dir),
|
|
127
|
+
"target_dir": str(self.data_dir)
|
|
128
|
+
})
|
|
129
|
+
except Exception:
|
|
130
|
+
pass # Silently ignore logging failures during migration
|
|
119
131
|
|
|
120
132
|
def _ensure_directories(self) -> None:
|
|
121
133
|
"""Create necessary directories if they don't exist."""
|
|
@@ -163,7 +175,6 @@ class StateManager:
|
|
|
163
175
|
"dedup_days": 14
|
|
164
176
|
},
|
|
165
177
|
"progress": {
|
|
166
|
-
"total_points": 0,
|
|
167
178
|
"total_quizzes": 0,
|
|
168
179
|
"correct_rate": 0.0,
|
|
169
180
|
"last_study_date": None,
|
|
@@ -195,26 +206,13 @@ class StateManager:
|
|
|
195
206
|
return self._default_state()
|
|
196
207
|
|
|
197
208
|
def _merge_with_defaults(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
198
|
-
"""
|
|
199
|
-
defaults
|
|
200
|
-
merged = defaults.copy()
|
|
201
|
-
|
|
202
|
-
# Handle top-level boolean/integer fields
|
|
203
|
-
for key in ['initialized', 'onboarding_step', 'version']:
|
|
204
|
-
if key in state:
|
|
205
|
-
merged[key] = state[key]
|
|
206
|
-
|
|
207
|
-
# Handle nested objects
|
|
208
|
-
for key in ['user', 'preferences', 'progress', 'completion_status', 'schedule']:
|
|
209
|
-
if key in state and isinstance(state[key], dict):
|
|
210
|
-
merged[key] = {**defaults.get(key, {}), **state[key]}
|
|
211
|
-
|
|
212
|
-
# Handle arrays
|
|
213
|
-
for key in ['recent_topics', 'error_notebook', 'error_archive']:
|
|
214
|
-
if key in state:
|
|
215
|
-
merged[key] = state[key]
|
|
209
|
+
"""
|
|
210
|
+
Merge loaded state with defaults to ensure all fields exist.
|
|
216
211
|
|
|
217
|
-
|
|
212
|
+
Uses deep merge to handle nested structures properly.
|
|
213
|
+
"""
|
|
214
|
+
defaults = self._default_state()
|
|
215
|
+
return deep_merge(defaults, state)
|
|
218
216
|
|
|
219
217
|
def save_state(self, state: Dict[str, Any]) -> None:
|
|
220
218
|
"""
|
|
@@ -375,6 +373,9 @@ class StateManager:
|
|
|
375
373
|
recent_content = self.get_recent_daily_content(days)
|
|
376
374
|
return [c.get('topic_fingerprint', '') for c in recent_content if c.get('topic_fingerprint')]
|
|
377
375
|
|
|
376
|
+
# Maximum size for error notebook before auto-archiving
|
|
377
|
+
MAX_ERROR_NOTEBOOK_SIZE = 100
|
|
378
|
+
|
|
378
379
|
def add_to_error_notebook(self, state: Dict[str, Any],
|
|
379
380
|
error: Dict[str, Any]) -> Dict[str, Any]:
|
|
380
381
|
"""
|
|
@@ -391,7 +392,23 @@ class StateManager:
|
|
|
391
392
|
error['reviewed'] = False
|
|
392
393
|
error['wrong_count'] = error.get('wrong_count', 1)
|
|
393
394
|
# keypoint_date and question_type are optional
|
|
394
|
-
|
|
395
|
+
|
|
396
|
+
errors = state.get('error_notebook', [])
|
|
397
|
+
errors.append(error)
|
|
398
|
+
|
|
399
|
+
# Auto-archive oldest if over size limit
|
|
400
|
+
if len(errors) > self.MAX_ERROR_NOTEBOOK_SIZE:
|
|
401
|
+
# Sort by date to find oldest
|
|
402
|
+
errors.sort(key=lambda x: x.get('date', ''))
|
|
403
|
+
# Archive the oldest
|
|
404
|
+
oldest = errors.pop(0)
|
|
405
|
+
archive = state.get('error_archive', [])
|
|
406
|
+
oldest['archived_at'] = date.today().isoformat()
|
|
407
|
+
oldest['archived_reason'] = 'notebook_full'
|
|
408
|
+
archive.append(oldest)
|
|
409
|
+
state['error_archive'] = archive
|
|
410
|
+
|
|
411
|
+
state['error_notebook'] = errors
|
|
395
412
|
return state
|
|
396
413
|
|
|
397
414
|
def get_errors_page(self, state: Dict[str, Any],
|
|
@@ -837,189 +854,7 @@ class StateManager:
|
|
|
837
854
|
return False
|
|
838
855
|
|
|
839
856
|
|
|
840
|
-
# CLI interface
|
|
857
|
+
# CLI interface - delegated to cli.py for better code organization
|
|
841
858
|
if __name__ == "__main__":
|
|
842
|
-
import
|
|
843
|
-
|
|
844
|
-
parser = argparse.ArgumentParser(description="State Manager for eng-lang-tutor")
|
|
845
|
-
parser.add_argument('--data-dir', default=None,
|
|
846
|
-
help='Data directory path (default: ~/.openclaw/state/eng-lang-tutor or OPENCLAW_STATE_DIR env)')
|
|
847
|
-
parser.add_argument('command', nargs='?',
|
|
848
|
-
choices=['show', 'backup', 'save_daily', 'record_view',
|
|
849
|
-
'stats', 'config', 'errors', 'schedule'],
|
|
850
|
-
help='Command to execute')
|
|
851
|
-
parser.add_argument('--content-type', help='Content type for save_daily (keypoint, quiz)')
|
|
852
|
-
parser.add_argument('--content', help='JSON content for save_daily')
|
|
853
|
-
parser.add_argument('--date', help='Date for content (YYYY-MM-DD format)')
|
|
854
|
-
# Errors command options
|
|
855
|
-
parser.add_argument('--page', type=int, default=1, help='Page number for errors list')
|
|
856
|
-
parser.add_argument('--per-page', type=int, default=5, help='Items per page for errors')
|
|
857
|
-
parser.add_argument('--month', help='Filter errors by month (YYYY-MM)')
|
|
858
|
-
parser.add_argument('--random', type=int, help='Get N random errors')
|
|
859
|
-
parser.add_argument('--stats', action='store_true', help='Get error statistics')
|
|
860
|
-
parser.add_argument('--review', type=int, help='Get N errors for review session')
|
|
861
|
-
# Config command options
|
|
862
|
-
parser.add_argument('--cefr', help='Set CEFR level (A1-C2)')
|
|
863
|
-
parser.add_argument('--style', help='Set tutor style')
|
|
864
|
-
parser.add_argument('--oral-ratio', type=int, help='Set oral/written ratio (0-100)')
|
|
865
|
-
# Schedule command options
|
|
866
|
-
parser.add_argument('--keypoint-time', help='Set keypoint push time (HH:MM)')
|
|
867
|
-
parser.add_argument('--quiz-time', help='Set quiz push time (HH:MM)')
|
|
868
|
-
|
|
869
|
-
args = parser.parse_args()
|
|
870
|
-
|
|
871
|
-
sm = StateManager(args.data_dir)
|
|
872
|
-
|
|
873
|
-
if args.command == 'show' or not args.command:
|
|
874
|
-
state = sm.load_state()
|
|
875
|
-
print(json.dumps(state, indent=2, ensure_ascii=False))
|
|
876
|
-
|
|
877
|
-
elif args.command == 'backup':
|
|
878
|
-
backup_path = sm.backup_state()
|
|
879
|
-
print(f"Backup created: {backup_path}")
|
|
880
|
-
|
|
881
|
-
elif args.command == 'save_daily':
|
|
882
|
-
if not args.content_type or not args.content:
|
|
883
|
-
print("Error: --content-type and --content are required for save_daily")
|
|
884
|
-
exit(1)
|
|
885
|
-
|
|
886
|
-
try:
|
|
887
|
-
content = json.loads(args.content)
|
|
888
|
-
except json.JSONDecodeError as e:
|
|
889
|
-
print(f"Error: Invalid JSON content: {e}")
|
|
890
|
-
exit(1)
|
|
891
|
-
|
|
892
|
-
target_date = None
|
|
893
|
-
if args.date:
|
|
894
|
-
try:
|
|
895
|
-
target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
|
|
896
|
-
except ValueError:
|
|
897
|
-
print("Error: Invalid date format. Use YYYY-MM-DD")
|
|
898
|
-
exit(1)
|
|
899
|
-
|
|
900
|
-
path = sm.save_daily_content(args.content_type, content, target_date)
|
|
901
|
-
print(f"Saved to: {path}")
|
|
902
|
-
|
|
903
|
-
elif args.command == 'record_view':
|
|
904
|
-
target_date = None
|
|
905
|
-
if args.date:
|
|
906
|
-
try:
|
|
907
|
-
target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
|
|
908
|
-
except ValueError:
|
|
909
|
-
print("Error: Invalid date format. Use YYYY-MM-DD")
|
|
910
|
-
exit(1)
|
|
911
|
-
|
|
912
|
-
state = sm.load_state()
|
|
913
|
-
sm.record_keypoint_view(state, target_date)
|
|
914
|
-
sm.save_state(state)
|
|
915
|
-
print("View recorded successfully")
|
|
916
|
-
|
|
917
|
-
elif args.command == 'stats':
|
|
918
|
-
"""Display learning progress summary."""
|
|
919
|
-
from gamification import GamificationManager
|
|
920
|
-
state = sm.load_state()
|
|
921
|
-
gm = GamificationManager()
|
|
922
|
-
summary = gm.get_progress_summary(state)
|
|
923
|
-
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
924
|
-
|
|
925
|
-
elif args.command == 'config':
|
|
926
|
-
"""Display or update user configuration."""
|
|
927
|
-
state = sm.load_state()
|
|
928
|
-
|
|
929
|
-
# If no update options, just show current config
|
|
930
|
-
if not any([args.cefr, args.style, args.oral_ratio is not None]):
|
|
931
|
-
config = {
|
|
932
|
-
"cefr_level": state.get("preferences", {}).get("cefr_level", "B1"),
|
|
933
|
-
"tutor_style": state.get("preferences", {}).get("tutor_style", "humorous"),
|
|
934
|
-
"oral_ratio": state.get("preferences", {}).get("oral_ratio", 70),
|
|
935
|
-
"topic_weights": state.get("preferences", {}).get("topic_weights", {}),
|
|
936
|
-
"schedule": state.get("schedule", {})
|
|
937
|
-
}
|
|
938
|
-
print(json.dumps(config, indent=2, ensure_ascii=False))
|
|
939
|
-
else:
|
|
940
|
-
# Update configuration
|
|
941
|
-
if args.cefr:
|
|
942
|
-
if args.cefr not in ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']:
|
|
943
|
-
print("Error: Invalid CEFR level. Must be A1, A2, B1, B2, C1, or C2")
|
|
944
|
-
exit(1)
|
|
945
|
-
state = sm.update_preferences(state, cefr_level=args.cefr)
|
|
946
|
-
print(f"Updated CEFR level to: {args.cefr}")
|
|
947
|
-
|
|
948
|
-
if args.style:
|
|
949
|
-
if args.style not in ['humorous', 'rigorous', 'casual', 'professional']:
|
|
950
|
-
print("Error: Invalid style. Must be humorous, rigorous, casual, or professional")
|
|
951
|
-
exit(1)
|
|
952
|
-
state = sm.update_preferences(state, tutor_style=args.style)
|
|
953
|
-
print(f"Updated tutor style to: {args.style}")
|
|
954
|
-
|
|
955
|
-
if args.oral_ratio is not None:
|
|
956
|
-
if not 0 <= args.oral_ratio <= 100:
|
|
957
|
-
print("Error: Oral ratio must be between 0 and 100")
|
|
958
|
-
exit(1)
|
|
959
|
-
state = sm.update_preferences(state, oral_ratio=args.oral_ratio)
|
|
960
|
-
print(f"Updated oral ratio to: {args.oral_ratio}%")
|
|
961
|
-
|
|
962
|
-
sm.save_state(state)
|
|
963
|
-
print("Configuration updated successfully")
|
|
964
|
-
|
|
965
|
-
elif args.command == 'errors':
|
|
966
|
-
"""Error notebook operations."""
|
|
967
|
-
state = sm.load_state()
|
|
968
|
-
|
|
969
|
-
if args.stats:
|
|
970
|
-
# Get error statistics
|
|
971
|
-
stats = sm.get_error_stats(state)
|
|
972
|
-
print(json.dumps(stats, indent=2, ensure_ascii=False))
|
|
973
|
-
|
|
974
|
-
elif args.review is not None:
|
|
975
|
-
# Get errors for review session
|
|
976
|
-
errors = sm.get_review_errors(state, count=args.review)
|
|
977
|
-
result = {
|
|
978
|
-
"count": len(errors),
|
|
979
|
-
"errors": errors
|
|
980
|
-
}
|
|
981
|
-
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
982
|
-
|
|
983
|
-
else:
|
|
984
|
-
# Get paginated errors list
|
|
985
|
-
result = sm.get_errors_page(
|
|
986
|
-
state,
|
|
987
|
-
page=args.page,
|
|
988
|
-
per_page=args.per_page,
|
|
989
|
-
month=args.month,
|
|
990
|
-
random=args.random
|
|
991
|
-
)
|
|
992
|
-
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
993
|
-
|
|
994
|
-
elif args.command == 'schedule':
|
|
995
|
-
"""Display or update schedule configuration."""
|
|
996
|
-
state = sm.load_state()
|
|
997
|
-
|
|
998
|
-
# If no update options, just show current schedule
|
|
999
|
-
if not any([args.keypoint_time, args.quiz_time]):
|
|
1000
|
-
schedule = state.get("schedule", {})
|
|
1001
|
-
print(json.dumps(schedule, indent=2, ensure_ascii=False))
|
|
1002
|
-
else:
|
|
1003
|
-
# Validate quiz_time must be later than keypoint_time
|
|
1004
|
-
current_keypoint = state.get("schedule", {}).get("keypoint_time", "06:45")
|
|
1005
|
-
current_quiz = state.get("schedule", {}).get("quiz_time", "22:45")
|
|
1006
|
-
|
|
1007
|
-
new_keypoint = args.keypoint_time or current_keypoint
|
|
1008
|
-
new_quiz = args.quiz_time or current_quiz
|
|
1009
|
-
|
|
1010
|
-
# Time validation
|
|
1011
|
-
def parse_time(t):
|
|
1012
|
-
h, m = map(int, t.split(':'))
|
|
1013
|
-
return h * 60 + m
|
|
1014
|
-
|
|
1015
|
-
if parse_time(new_quiz) <= parse_time(new_keypoint):
|
|
1016
|
-
print("Error: Quiz time must be later than keypoint time")
|
|
1017
|
-
exit(1)
|
|
1018
|
-
|
|
1019
|
-
state = sm.update_schedule(
|
|
1020
|
-
state,
|
|
1021
|
-
keypoint_time=new_keypoint,
|
|
1022
|
-
quiz_time=new_quiz
|
|
1023
|
-
)
|
|
1024
|
-
sm.save_state(state)
|
|
1025
|
-
print(f"Schedule updated: keypoint at {new_keypoint}, quiz at {new_quiz}")
|
|
859
|
+
from cli import main
|
|
860
|
+
main()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/utils.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Utility functions for eng-lang-tutor.
|
|
4
|
+
|
|
5
|
+
Common utilities used across multiple modules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
|
|
12
|
+
"""
|
|
13
|
+
Safe division that returns default if denominator is zero.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
numerator: The number to divide
|
|
17
|
+
denominator: The number to divide by
|
|
18
|
+
default: Value to return if denominator is zero
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Result of division, or default if denominator is zero
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
>>> safe_divide(10, 2)
|
|
25
|
+
5.0
|
|
26
|
+
>>> safe_divide(10, 0)
|
|
27
|
+
0.0
|
|
28
|
+
>>> safe_divide(10, 0, default=100)
|
|
29
|
+
100.0
|
|
30
|
+
"""
|
|
31
|
+
return numerator / denominator if denominator != 0 else default
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
|
35
|
+
"""
|
|
36
|
+
Recursively merge override dictionary into base dictionary.
|
|
37
|
+
|
|
38
|
+
Creates a new dictionary with values from base, updated with values from override.
|
|
39
|
+
Nested dictionaries are merged recursively; other values are overwritten.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
base: Base dictionary (not modified)
|
|
43
|
+
override: Dictionary with values to override/add
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
New merged dictionary
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
>>> base = {'a': 1, 'b': {'c': 2, 'd': 3}}
|
|
50
|
+
>>> override = {'b': {'c': 10}}
|
|
51
|
+
>>> deep_merge(base, override)
|
|
52
|
+
{'a': 1, 'b': {'c': 10, 'd': 3}}
|
|
53
|
+
"""
|
|
54
|
+
import copy
|
|
55
|
+
result = copy.deepcopy(base)
|
|
56
|
+
|
|
57
|
+
for key, value in override.items():
|
|
58
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
59
|
+
result[key] = deep_merge(result[key], value)
|
|
60
|
+
else:
|
|
61
|
+
result[key] = copy.deepcopy(value)
|
|
62
|
+
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def clamp(value: float, min_val: float, max_val: float) -> float:
|
|
67
|
+
"""
|
|
68
|
+
Clamp a value to a range.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
value: Value to clamp
|
|
72
|
+
min_val: Minimum allowed value
|
|
73
|
+
max_val: Maximum allowed value
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Value clamped to [min_val, max_val]
|
|
77
|
+
"""
|
|
78
|
+
return max(min_val, min(max_val, value))
|