1414# "quiz_format": "multiple_choice" | "free_response" | "code_prediction"
1515# }
1616
17+ SCRIPT_NAME=" quiz-selector"
1718PROFILE_DIR=" $HOME /.code-sensei"
1819PROFILE_FILE=" $PROFILE_DIR /profile.json"
1920PLUGIN_ROOT=" ${CLAUDE_PLUGIN_ROOT:- $(dirname " $( dirname " $0 " ) " )} "
2021QUIZ_BANK=" $PLUGIN_ROOT /data/quiz-bank.json"
2122
23+ # Load shared error handling
24+ LIB_DIR=" $( dirname " $0 " ) /lib"
25+ if [ -f " $LIB_DIR /error-handling.sh" ]; then
26+ source " $LIB_DIR /error-handling.sh"
27+ else
28+ LOG_FILE=" ${PROFILE_DIR} /error.log"
29+ log_error () { printf ' [%s] [%s] %s\n' " $( date -u ' +%Y-%m-%dT%H:%M:%SZ' 2> /dev/null || date ' +%Y-%m-%d' ) " " ${1:- unknown} " " $2 " >> " $LOG_FILE " 2> /dev/null; }
30+ json_escape () {
31+ local str=" $1 "
32+ if command -v jq & > /dev/null; then
33+ printf ' %s' " $str " | jq -Rs ' .'
34+ else
35+ printf ' "%s"' " $( printf ' %s' " $str " | sed ' s/\\/\\\\/g; s/"/\\"/g' ) "
36+ fi
37+ }
38+ check_jq () { command -v jq & > /dev/null; }
39+ fi
40+
2241# Default output if we can't determine anything
2342DEFAULT_OUTPUT=' {"mode":"dynamic","concept":null,"reason":"No profile data available","static_question":null,"belt":"white","quiz_format":"multiple_choice"}'
2443
25- if ! command -v jq & > /dev/null ; then
44+ if ! check_jq " $SCRIPT_NAME " ; then
2645 echo " $DEFAULT_OUTPUT "
2746 exit 0
2847fi
@@ -33,12 +52,42 @@ if [ ! -f "$PROFILE_FILE" ]; then
3352fi
3453
3554# Read profile data
36- BELT=$( jq -r ' .belt // "white"' " $PROFILE_FILE " )
37- QUIZ_HISTORY=$( jq -c ' .quiz_history // []' " $PROFILE_FILE " )
38- CONCEPTS_SEEN=$( jq -c ' .concepts_seen // []' " $PROFILE_FILE " )
39- SESSION_CONCEPTS=$( jq -c ' .session_concepts // []' " $PROFILE_FILE " )
40- TOTAL_QUIZZES=$( jq -r ' .quizzes.total // 0' " $PROFILE_FILE " )
41- CORRECT_QUIZZES=$( jq -r ' .quizzes.correct // 0' " $PROFILE_FILE " )
55+ BELT=$( jq -r ' .belt // "white"' " $PROFILE_FILE " 2>&1 )
56+ if [ $? -ne 0 ]; then
57+ log_error " $SCRIPT_NAME " " jq failed reading belt: $BELT "
58+ echo " $DEFAULT_OUTPUT "
59+ exit 0
60+ fi
61+
62+ QUIZ_HISTORY=$( jq -c ' .quiz_history // []' " $PROFILE_FILE " 2>&1 )
63+ if [ $? -ne 0 ]; then
64+ log_error " $SCRIPT_NAME " " jq failed reading quiz_history: $QUIZ_HISTORY "
65+ QUIZ_HISTORY=" []"
66+ fi
67+
68+ CONCEPTS_SEEN=$( jq -c ' .concepts_seen // []' " $PROFILE_FILE " 2>&1 )
69+ if [ $? -ne 0 ]; then
70+ log_error " $SCRIPT_NAME " " jq failed reading concepts_seen: $CONCEPTS_SEEN "
71+ CONCEPTS_SEEN=" []"
72+ fi
73+
74+ SESSION_CONCEPTS=$( jq -c ' .session_concepts // []' " $PROFILE_FILE " 2>&1 )
75+ if [ $? -ne 0 ]; then
76+ log_error " $SCRIPT_NAME " " jq failed reading session_concepts: $SESSION_CONCEPTS "
77+ SESSION_CONCEPTS=" []"
78+ fi
79+
80+ TOTAL_QUIZZES=$( jq -r ' .quizzes.total // 0' " $PROFILE_FILE " 2>&1 )
81+ if [ $? -ne 0 ]; then
82+ log_error " $SCRIPT_NAME " " jq failed reading quizzes.total: $TOTAL_QUIZZES "
83+ TOTAL_QUIZZES=0
84+ fi
85+
86+ CORRECT_QUIZZES=$( jq -r ' .quizzes.correct // 0' " $PROFILE_FILE " 2>&1 )
87+ if [ $? -ne 0 ]; then
88+ log_error " $SCRIPT_NAME " " jq failed reading quizzes.correct: $CORRECT_QUIZZES "
89+ CORRECT_QUIZZES=0
90+ fi
4291
4392TODAY=$( date -u +%Y-%m-%d)
4493NOW_EPOCH=$( date +%s)
@@ -65,7 +114,7 @@ SPACED_REP_REASON=""
65114
66115if [ " $QUIZ_HISTORY " != " []" ]; then
67116 # Get concepts that were answered incorrectly, with their last wrong date and wrong count
68- WRONG_CONCEPTS=$( echo " $QUIZ_HISTORY " | jq -c '
117+ WRONG_CONCEPTS=$( printf ' %s ' " $QUIZ_HISTORY " | jq -c '
69118 [.[] | select(.result == "incorrect")] |
70119 group_by(.concept) |
71120 map({
@@ -74,20 +123,39 @@ if [ "$QUIZ_HISTORY" != "[]" ]; then
74123 last_wrong: (sort_by(.timestamp) | last | .timestamp),
75124 total_attempts: 0
76125 })
77- ' )
126+ ' 2>&1 )
127+ if [ $? -ne 0 ]; then
128+ log_error " $SCRIPT_NAME " " jq failed computing wrong concepts: $WRONG_CONCEPTS "
129+ WRONG_CONCEPTS=" []"
130+ fi
78131
79132 # For each wrong concept, check if it's due for review
80- for ROW in $( echo " $WRONG_CONCEPTS " | jq -c ' .[]' ) ; do
81- CONCEPT=$( echo " $ROW " | jq -r ' .concept' )
82- WRONG_COUNT=$( echo " $ROW " | jq -r ' .wrong_count' )
83- LAST_WRONG=$( echo " $ROW " | jq -r ' .last_wrong' )
133+ for ROW in $( printf ' %s' " $WRONG_CONCEPTS " | jq -c ' .[]' 2> /dev/null) ; do
134+ CONCEPT=$( printf ' %s' " $ROW " | jq -r ' .concept' 2>&1 )
135+ if [ $? -ne 0 ]; then
136+ log_error " $SCRIPT_NAME " " jq failed reading concept from wrong row: $CONCEPT "
137+ continue
138+ fi
84139
85- # Calculate days since last wrong answer
86- LAST_WRONG_DATE=$( echo " $LAST_WRONG " | cut -d' T' -f1)
140+ WRONG_COUNT=$( printf ' %s' " $ROW " | jq -r ' .wrong_count' 2>&1 )
141+ if [ $? -ne 0 ]; then
142+ log_error " $SCRIPT_NAME " " jq failed reading wrong_count: $WRONG_COUNT "
143+ continue
144+ fi
145+
146+ LAST_WRONG=$( printf ' %s' " $ROW " | jq -r ' .last_wrong' 2>&1 )
147+ if [ $? -ne 0 ]; then
148+ log_error " $SCRIPT_NAME " " jq failed reading last_wrong: $LAST_WRONG "
149+ continue
150+ fi
151+
152+ # Calculate days since last wrong answer (handles both GNU and BSD date)
153+ LAST_WRONG_DATE=$( printf ' %s' " $LAST_WRONG " | cut -d' T' -f1)
87154 LAST_EPOCH=$( date -j -f " %Y-%m-%d" " $LAST_WRONG_DATE " +%s 2> /dev/null || date -d " $LAST_WRONG_DATE " +%s 2> /dev/null)
88155 if [ -n " $LAST_EPOCH " ]; then
89156 DAYS_SINCE=$(( (NOW_EPOCH - LAST_EPOCH) / 86400 ))
90157 else
158+ log_error " $SCRIPT_NAME " " Could not parse date '$LAST_WRONG_DATE ' for spaced repetition; defaulting days_since=999"
91159 DAYS_SINCE=999
92160 fi
93161
@@ -100,9 +168,13 @@ if [ "$QUIZ_HISTORY" != "[]" ]; then
100168 fi
101169
102170 # Check if enough time has passed and concept hasn't been mastered since
103- CORRECT_SINCE=$( echo " $QUIZ_HISTORY " | jq --arg c " $CONCEPT " --arg lw " $LAST_WRONG " '
171+ CORRECT_SINCE=$( printf ' %s ' " $QUIZ_HISTORY " | jq --arg c " $CONCEPT " --arg lw " $LAST_WRONG " '
104172 [.[] | select(.concept == $c and .result == "correct" and .timestamp > $lw)] | length
105- ' )
173+ ' 2>&1 )
174+ if [ $? -ne 0 ]; then
175+ log_error " $SCRIPT_NAME " " jq failed computing correct_since for $CONCEPT : $CORRECT_SINCE "
176+ CORRECT_SINCE=0
177+ fi
106178
107179 if [ " $DAYS_SINCE " -ge " $REVIEW_INTERVAL " ] && [ " $CORRECT_SINCE " -lt 3 ]; then
108180 SPACED_REP_CONCEPT=" $CONCEPT "
@@ -118,23 +190,36 @@ if [ -n "$SPACED_REP_CONCEPT" ] && [ -f "$QUIZ_BANK" ]; then
118190 .quizzes[$concept] // [] |
119191 map(select(.belt == $belt or .belt == "white")) |
120192 first // null
121- ' " $QUIZ_BANK " )
193+ ' " $QUIZ_BANK " 2>&1 )
194+ if [ $? -ne 0 ]; then
195+ log_error " $SCRIPT_NAME " " jq failed reading static question for $SPACED_REP_CONCEPT : $STATIC_Q "
196+ STATIC_Q=" null"
197+ fi
198+
199+ ESCAPED_CONCEPT=$( json_escape " $SPACED_REP_CONCEPT " )
200+ ESCAPED_REASON=$( json_escape " $SPACED_REP_REASON " )
201+ ESCAPED_BELT=$( json_escape " $BELT " )
122202
123203 if [ " $STATIC_Q " != " null" ] && [ -n " $STATIC_Q " ]; then
124- echo " { \ " mode\" : \ " spaced_repetition\" , \ " concept\" : \" $SPACED_REP_CONCEPT \" , \ " reason\" : \" $SPACED_REP_REASON \" , \ " static_question\" : $STATIC_Q , \ " belt\" : \" $BELT \" , \ " quiz_format\" : \" $QUIZ_FORMAT \" } "
125- exit 0
204+ printf ' { "mode": "spaced_repetition", "concept":%s, "reason":%s, "static_question":%s, "belt":%s, "quiz_format":"%s"}\n ' \
205+ " $ESCAPED_CONCEPT " " $ESCAPED_REASON " " $STATIC_Q " " $ESCAPED_BELT " " $QUIZ_FORMAT "
126206 else
127- echo " { \ " mode\" : \ " spaced_repetition\" , \ " concept\" : \" $SPACED_REP_CONCEPT \" , \ " reason\" : \" $SPACED_REP_REASON \" , \ " static_question\ " :null,\ " belt\" : \" $BELT \" , \ " quiz_format\" : \" $QUIZ_FORMAT \" } "
128- exit 0
207+ printf ' { "mode": "spaced_repetition", "concept":%s, "reason":%s, "static_question":null,"belt":%s, "quiz_format":"%s"}\n ' \
208+ " $ESCAPED_CONCEPT " " $ESCAPED_REASON " " $ESCAPED_BELT " " $QUIZ_FORMAT "
129209 fi
210+ exit 0
130211fi
131212
132213# βββ PRIORITY 2: Unquizzed session concepts βββ
133214# Concepts from this session that haven't been quizzed yet
134215UNQUIZZED_CONCEPT=" "
135216if [ " $SESSION_CONCEPTS " != " []" ]; then
136- for CONCEPT in $( echo " $SESSION_CONCEPTS " | jq -r ' .[]' ) ; do
137- BEEN_QUIZZED=$( echo " $QUIZ_HISTORY " | jq --arg c " $CONCEPT " ' [.[] | select(.concept == $c)] | length' )
217+ for CONCEPT in $( printf ' %s' " $SESSION_CONCEPTS " | jq -r ' .[]' 2> /dev/null) ; do
218+ BEEN_QUIZZED=$( printf ' %s' " $QUIZ_HISTORY " | jq --arg c " $CONCEPT " ' [.[] | select(.concept == $c)] | length' 2>&1 )
219+ if [ $? -ne 0 ]; then
220+ log_error " $SCRIPT_NAME " " jq failed checking quiz history for $CONCEPT : $BEEN_QUIZZED "
221+ continue
222+ fi
138223 if [ " $BEEN_QUIZZED " -eq 0 ]; then
139224 UNQUIZZED_CONCEPT=" $CONCEPT "
140225 break
@@ -147,15 +232,23 @@ if [ -n "$UNQUIZZED_CONCEPT" ] && [ -f "$QUIZ_BANK" ]; then
147232 .quizzes[$concept] // [] |
148233 map(select(.belt == $belt or .belt == "white")) |
149234 first // null
150- ' " $QUIZ_BANK " )
235+ ' " $QUIZ_BANK " 2>&1 )
236+ if [ $? -ne 0 ]; then
237+ log_error " $SCRIPT_NAME " " jq failed reading static question for $UNQUIZZED_CONCEPT : $STATIC_Q "
238+ STATIC_Q=" null"
239+ fi
240+
241+ ESCAPED_CONCEPT=$( json_escape " $UNQUIZZED_CONCEPT " )
242+ ESCAPED_BELT=$( json_escape " $BELT " )
151243
152244 if [ " $STATIC_Q " != " null" ] && [ -n " $STATIC_Q " ]; then
153- echo " { \ " mode\" : \ " static\" , \ " concept\" : \" $UNQUIZZED_CONCEPT \" , \ " reason\" : \ " New concept from this session β not yet quizzed.\" , \ " static_question\" : $STATIC_Q , \ " belt\" : \" $BELT \" , \ " quiz_format\" : \" $QUIZ_FORMAT \" } "
154- exit 0
245+ printf ' { "mode": "static", "concept":%s, "reason": "New concept from this session β not yet quizzed.", "static_question":%s, "belt":%s, "quiz_format":"%s"}\n ' \
246+ " $ESCAPED_CONCEPT " " $STATIC_Q " " $ESCAPED_BELT " " $QUIZ_FORMAT "
155247 else
156- echo " { \ " mode\" : \ " dynamic\" , \ " concept\" : \" $UNQUIZZED_CONCEPT \" , \ " reason\" : \ " New concept from this session β no static question available, generate dynamically.\" , \ " static_question\ " :null,\ " belt\" : \" $BELT \" , \ " quiz_format\" : \" $QUIZ_FORMAT \" } "
157- exit 0
248+ printf ' { "mode": "dynamic", "concept":%s, "reason": "New concept from this session β no static question available, generate dynamically.", "static_question":null,"belt":%s, "quiz_format":"%s"}\n ' \
249+ " $ESCAPED_CONCEPT " " $ESCAPED_BELT " " $QUIZ_FORMAT "
158250 fi
251+ exit 0
159252fi
160253
161254# βββ PRIORITY 3: Least-quizzed lifetime concepts βββ
@@ -166,20 +259,34 @@ if [ "$CONCEPTS_SEEN" != "[]" ]; then
166259 .[] as $concept |
167260 ($history | [.[] | select(.concept == $concept)] | length) as $count |
168261 {concept: $concept, count: $count}
169- ' <<< " $CONCEPTS_SEEN" | jq -s ' sort_by(.count) | first | .concept // null' 2> /dev/null)
262+ ' <<< " $CONCEPTS_SEEN" 2>&1 | jq -s ' sort_by(.count) | first | .concept // null' 2>&1 )
263+ if [ $? -ne 0 ]; then
264+ log_error " $SCRIPT_NAME " " jq failed computing least-quizzed concept: $LEAST_QUIZZED "
265+ LEAST_QUIZZED=" "
266+ fi
170267fi
171268
172269if [ -n " $LEAST_QUIZZED " ] && [ " $LEAST_QUIZZED " != " null" ] && [ -f " $QUIZ_BANK " ]; then
173270 STATIC_Q=$( jq -c --arg concept " $LEAST_QUIZZED " --arg belt " $BELT " '
174271 .quizzes[$concept] // [] |
175272 map(select(.belt == $belt or .belt == "white")) |
176273 first // null
177- ' " $QUIZ_BANK " )
274+ ' " $QUIZ_BANK " 2>&1 )
275+ if [ $? -ne 0 ]; then
276+ log_error " $SCRIPT_NAME " " jq failed reading static question for $LEAST_QUIZZED : $STATIC_Q "
277+ STATIC_Q=" null"
278+ fi
279+
280+ ESCAPED_CONCEPT=$( json_escape " $LEAST_QUIZZED " )
281+ ESCAPED_BELT=$( json_escape " $BELT " )
178282
179- echo " {\" mode\" :\" static\" ,\" concept\" :\" $LEAST_QUIZZED \" ,\" reason\" :\" Reinforcing least-practiced concept.\" ,\" static_question\" :$STATIC_Q ,\" belt\" :\" $BELT \" ,\" quiz_format\" :\" $QUIZ_FORMAT \" }"
283+ printf ' {"mode":"static","concept":%s,"reason":"Reinforcing least-practiced concept.","static_question":%s,"belt":%s,"quiz_format":"%s"}\n' \
284+ " $ESCAPED_CONCEPT " " $STATIC_Q " " $ESCAPED_BELT " " $QUIZ_FORMAT "
180285 exit 0
181286fi
182287
183288# βββ FALLBACK: Dynamic generation βββ
184- echo " {\" mode\" :\" dynamic\" ,\" concept\" :null,\" reason\" :\" No specific concept to target β generate from current session context.\" ,\" static_question\" :null,\" belt\" :\" $BELT \" ,\" quiz_format\" :\" $QUIZ_FORMAT \" }"
289+ ESCAPED_BELT=$( json_escape " $BELT " )
290+ printf ' {"mode":"dynamic","concept":null,"reason":"No specific concept to target β generate from current session context.","static_question":null,"belt":%s,"quiz_format":"%s"}\n' \
291+ " $ESCAPED_BELT " " $QUIZ_FORMAT "
185292exit 0
0 commit comments