PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.8
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.8
4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 All 139 releases
← All changes | inc/AI/Assistant/QuickQuizEngine.php +400 -402 4.3.9.14.4.8 View file →
@@ -1,402 +1,400 @@
1 -<?php
2 -
3 -namespace LearnPress\AI\Assistant;
4 -
5 -use LearnPress\Services\OpenAiService;
6 -
7 -/**
8 - * QuickQuizEngine — generates and drives interactive quick quizzes.
9 - *
10 - * Handles quiz generation from lesson content (start) and stateful
11 - * answer evaluation across multiple turns (continue_session).
12 - *
13 - * @package LearnPress\AI\Assistant
14 - * @since 4.3.5
15 - */
16 -class QuickQuizEngine {
17 -
18 - private const QUIZ_PROMPT_HISTORY_LIMIT = 3;
19 - private const HISTORY_CONTENT_MAX_CHARS = 400;
20 -
21 - private TokenQuotaGuard $quota_guard;
22 - private LanguageResolver $language_resolver;
23 - private ResponseNormalizer $normalizer;
24 -
25 - public function __construct(
26 - TokenQuotaGuard $quota_guard,
27 - LanguageResolver $language_resolver,
28 - ResponseNormalizer $normalizer
29 - ) {
30 - $this->quota_guard = $quota_guard;
31 - $this->language_resolver = $language_resolver;
32 - $this->normalizer = $normalizer;
33 - }
34 -
35 - /**
36 - * Start a new interactive quick quiz from lesson content.
37 - *
38 - * @param DataLoaders $loaders Data loader instance.
39 - * @param string $user_message Learner input (may contain explicit count).
40 - * @param int $lesson_id Current lesson ID.
41 - * @param int $user_id Current user ID.
42 - * @param array $history Conversation history.
43 - *
44 - * @return array{type: string, message: string, quiz: array|null}
45 - */
46 - public function start( DataLoaders $loaders, string $user_message, int $lesson_id, int $user_id, array $history ): array {
47 -
48 - $lesson = $loaders->get_lesson_content( $lesson_id, $user_id );
49 - if ( ! empty( $lesson['error'] ) ) {
50 - return $this->normalizer->build_response( $lesson['error'] );
51 - }
52 -
53 - $service = OpenAiService::instance();
54 - $history_slice = $this->slice_recent_history( $history, self::QUIZ_PROMPT_HISTORY_LIMIT );
55 - $question_count = $this->extract_requested_quiz_count( $user_message );
56 - $has_explicit_count = null !== $question_count;
57 - $language_guidance = $this->language_resolver->build_instruction( $user_message, $history_slice, $user_id );
58 -
59 - $system_instructions = $has_explicit_count
60 - ? sprintf(
61 - /* translators: %d: requested number of quiz questions. */
62 - __( 'Create a quick quiz from lesson content. Return ONLY valid JSON with keys: intro (string), questions (array of exactly %d items). Each question must contain: question (string), options (array of 4 strings), correct_index (0-3 integer), explanation (string).', 'learnpress' ),
63 - $question_count
64 - )
65 - : __( 'Create a quick quiz from lesson content with 3-5 questions. Return ONLY valid JSON with keys: intro (string), questions (array of objects). Each question must contain: question (string), options (array of 4 strings), correct_index (0-3 integer), explanation (string).', 'learnpress' );
66 -
67 - $messages = array(
68 - array(
69 - 'role' => 'system',
70 - 'content' => $system_instructions,
71 - ),
72 - array(
73 - 'role' => 'system',
74 - 'content' => $language_guidance,
75 - ),
76 - array(
77 - 'role' => 'system',
78 - 'content' => wp_json_encode( $lesson ),
79 - ),
80 - );
81 -
82 - foreach ( $history_slice as $item ) {
83 - if ( ! empty( $item['role'] ) && isset( $item['content'] ) ) {
84 - $messages[] = array(
85 - 'role' => $item['role'],
86 - 'content' => $item['content'],
87 - );
88 - }
89 - }
90 -
91 - $messages[] = array(
92 - 'role' => 'user',
93 - 'content' => $user_message,
94 - );
95 -
96 - $response = $this->quota_guard->send_chat_with_guard( $service, $messages, $user_id );
97 - if ( $this->quota_guard->is_blocked() ) {
98 - return $this->normalizer->build_response( $this->quota_guard->get_block_message() );
99 - }
100 -
101 - $content = (string) ( $response['content'] ?? '' );
102 - $decoded = $this->normalizer->decode_json( $content );
103 - $questions = $this->sanitize_quiz_questions( $decoded['questions'] ?? array(), $question_count );
104 -
105 - if ( empty( $questions ) ) {
106 - return $this->normalizer->build_response( __( 'I could not generate a quiz right now. Please try again.', 'learnpress' ) );
107 - }
108 -
109 - $quiz_state = array(
110 - 'is_active' => true,
111 - 'completed' => false,
112 - 'current_index' => 0,
113 - 'score' => 0,
114 - 'total' => count( $questions ),
115 - 'questions' => $questions,
116 - );
117 -
118 - return array(
119 - 'type' => 'quiz',
120 - 'message' => $decoded['intro'] ?? __( 'Quick quiz started. Answer each question to continue.', 'learnpress' ),
121 - 'quiz' => $quiz_state,
122 - );
123 - }
124 -
125 - /**
126 - * Process an answer for an active quiz session and advance quiz state.
127 - *
128 - * @param string $user_message Learner answer input.
129 - * @param array $state Current quiz state.
130 - *
131 - * @return array{type: string, message: string, quiz: array|null}
132 - */
133 - public function continue_session( string $user_message, array $state ): array {
134 -
135 - $questions = $state['questions'] ?? array();
136 - $current = absint( $state['current_index'] ?? 0 );
137 - $score = absint( $state['score'] ?? 0 );
138 -
139 - if ( empty( $questions ) || ! isset( $questions[ $current ] ) ) {
140 - return $this->normalizer->build_response( __( 'Quiz state is invalid. Please start a new quick quiz.', 'learnpress' ) );
141 - }
142 -
143 - $question = $questions[ $current ];
144 - $answer_i = $this->parse_answer_index( $user_message, $question['options'] ?? array() );
145 -
146 - if ( $answer_i === null ) {
147 - return array(
148 - 'type' => 'quiz',
149 - 'message' => __( 'Please answer with option number (1-4), letter (A-D), or full option text.', 'learnpress' ),
150 - 'quiz' => $state,
151 - );
152 - }
153 -
154 - $correct_index = absint( $question['correct_index'] ?? 0 );
155 - $is_correct = $answer_i === $correct_index;
156 - if ( $is_correct ) {
157 - ++$score;
158 - }
159 -
160 - $next_index = $current + 1;
161 - $total = count( $questions );
162 -
163 - $state['score'] = $score;
164 - $state['current_index'] = $next_index;
165 - $state['total'] = $total;
166 - $state['feedback'] = array(
167 - 'is_correct' => $is_correct,
168 - 'selected_index' => $answer_i,
169 - 'selected_answer' => $question['options'][ $answer_i ] ?? '',
170 - 'correct_index' => $correct_index,
171 - 'correct_answer' => $question['options'][ $correct_index ] ?? '',
172 - 'explanation' => $question['explanation'] ?? '',
173 - );
174 -
175 - if ( $next_index >= $total ) {
176 - $state['is_active'] = false;
177 - $state['completed'] = true;
178 -
179 - return array(
180 - 'type' => 'quiz',
181 - 'message' => sprintf(
182 - /* translators: 1: score, 2: total questions. */
183 - __( 'Quiz complete. You scored %1$d/%2$d.', 'learnpress' ),
184 - $score,
185 - $total
186 - ),
187 - 'quiz' => $state,
188 - );
189 - }
190 -
191 - $state['is_active'] = true;
192 - $state['completed'] = false;
193 -
194 - $message = $is_correct
195 - ? __( 'Correct. Great job! Moving to the next question.', 'learnpress' )
196 - : __( 'Not quite. Let us move to the next question.', 'learnpress' );
197 -
198 - return array(
199 - 'type' => 'quiz',
200 - 'message' => $message,
201 - 'quiz' => $state,
202 - );
203 - }
204 -
205 - // ----------------------------------------------------------------
206 - // Private helpers
207 - // ----------------------------------------------------------------
208 -
209 - /**
210 - * Keep only recent and valid chat history rows for quiz-generation prompts.
211 - *
212 - * @param array $history Chat history.
213 - * @param int $limit Number of rows to keep from the end.
214 - *
215 - * @return array<int, array{role: string, content: string}>
216 - */
217 - private function slice_recent_history( array $history, int $limit ): array {
218 -
219 - $sanitized = array();
220 -
221 - foreach ( $history as $item ) {
222 - if ( ! is_array( $item ) ) {
223 - continue;
224 - }
225 -
226 - $role = (string) ( $item['role'] ?? '' );
227 - if ( ! in_array( $role, array( 'user', 'assistant' ), true ) ) {
228 - continue;
229 - }
230 -
231 - $content = trim( (string) ( $item['content'] ?? '' ) );
232 - if ( '' === $content ) {
233 - continue;
234 - }
235 -
236 - $sanitized[] = array(
237 - 'role' => $role,
238 - 'content' => mb_substr( $content, 0, self::HISTORY_CONTENT_MAX_CHARS, 'UTF-8' ),
239 - );
240 - }
241 -
242 - if ( empty( $sanitized ) ) {
243 - return array();
244 - }
245 -
246 - return array_slice( $sanitized, -1 * max( 1, $limit ) );
247 - }
248 -
249 - /**
250 - * Parse learner answer into option index.
251 - *
252 - * Supports: numeric (1-4), letter (A-D), and exact option text.
253 - *
254 - * @param string $message Learner answer.
255 - * @param array $options Current question options.
256 - *
257 - * @return int|null
258 - */
259 - private function parse_answer_index( string $message, array $options ): ?int {
260 -
261 - $input = strtolower( trim( $message ) );
262 - if ( $input === '' ) {
263 - return null;
264 - }
265 -
266 - if ( preg_match( '/^[1-4]$/', $input ) ) {
267 - return max( 0, (int) $input - 1 );
268 - }
269 -
270 - $letters = array(
271 - 'a' => 0,
272 - 'b' => 1,
273 - 'c' => 2,
274 - 'd' => 3,
275 - );
276 - if ( isset( $letters[ $input ] ) ) {
277 - return $letters[ $input ];
278 - }
279 -
280 - foreach ( $options as $index => $option ) {
281 - if ( strtolower( trim( (string) $option ) ) === $input ) {
282 - return (int) $index;
283 - }
284 - }
285 -
286 - return null;
287 - }
288 -
289 - /**
290 - * Extract the requested number of quiz questions from the learner message.
291 - *
292 - * Language-agnostic heuristic:
293 - * - Prefer explicit numeric forms (e.g. "/quick-quiz 5", "quiz 5", "5 quiz").
294 - * - If only one standalone number is present, use it as requested count.
295 - * - Return null when count cannot be inferred reliably.
296 - *
297 - * @param string $message Learner input.
298 - *
299 - * @return int|null
300 - */
301 - private function extract_requested_quiz_count( string $message ): ?int {
302 -
303 - $normalized = trim( $message );
304 - if ( '' === $normalized ) {
305 - return null;
306 - }
307 -
308 - $normalized = mb_strtolower( $normalized, 'UTF-8' );
309 - $normalized = preg_replace( '/[^\p{L}\p{N}\s]+/u', ' ', $normalized );
310 - $normalized = is_string( $normalized ) ? trim( preg_replace( '/\s+/', ' ', $normalized ) ?? '' ) : '';
311 - if ( '' === $normalized ) {
312 - return null;
313 - }
314 -
315 - // Explicit command-like request: "/quick-quiz 5".
316 - if ( preg_match( '/\/(?:mini|quick)-?quiz\s+(\d{1,2})(?=\s|$)/u', $normalized, $matches ) ) {
317 - $count = (int) ( $matches[1] ?? 0 );
318 - return ( $count >= 1 && $count <= 20 ) ? $count : null;
319 - }
320 -
321 - // Number close to "quiz" token works for most mixed-language requests.
322 - if ( preg_match( '/(?:quiz[^\p{N}]{0,20}(\d{1,2})|(\d{1,2})[^\p{N}]{0,20}quiz)/u', $normalized, $matches ) ) {
323 - $count = (int) ( $matches[1] ?: $matches[2] ?: 0 );
324 - if ( $count >= 1 && $count <= 20 ) {
325 - return $count;
326 - }
327 - }
328 -
329 - // Fallback: if there is exactly one standalone number, treat it as count.
330 - if ( preg_match_all( '/(?<!\p{N})(\d{1,2})(?!\p{N})/u', $normalized, $matches ) && count( $matches[1] ) === 1 ) {
331 - $count = (int) $matches[1][0];
332 - if ( $count >= 1 && $count <= 20 ) {
333 - return $count;
334 - }
335 - }
336 -
337 - return null;
338 - }
339 -
340 - /**
341 - * Sanitize and normalize generated quiz questions from model output.
342 - *
343 - * If $question_count is null, returns questions as-is (trusting OpenAI's output).
344 - * If $question_count is set, caps to exactly that many questions.
345 - *
346 - * @param array $questions Raw question payload.
347 - * @param int|null $question_count Maximum number of questions to keep, or null to trust model.
348 - *
349 - * @return array
350 - */
351 - private function sanitize_quiz_questions( array $questions, ?int $question_count = null ): array {
352 -
353 - if ( empty( $questions ) || ! is_array( $questions ) ) {
354 - return array();
355 - }
356 -
357 - // If no explicit count, trust OpenAI's output (typically 3-5 questions).
358 - if ( $question_count === null ) {
359 - return array_filter(
360 - array_map( static fn( $q ) => is_array( $q ) ? $q : null, $questions ),
361 - static fn( $q ) => null !== $q
362 - );
363 - }
364 -
365 - $sanitized = array();
366 - foreach ( $questions as $question ) {
367 - if ( ! is_array( $question ) ) {
368 - continue;
369 - }
370 -
371 - $options = $question['options'] ?? array();
372 - if ( ! is_array( $options ) || count( $options ) < 2 ) {
373 - continue;
374 - }
375 -
376 - $clean_options = array_values(
377 - array_map(
378 - static fn( $option ) => sanitize_text_field( (string) $option ),
379 - $options
380 - )
381 - );
382 -
383 - $correct_index = absint( $question['correct_index'] ?? 0 );
384 - if ( $correct_index >= count( $clean_options ) ) {
385 - $correct_index = 0;
386 - }
387 -
388 - $sanitized[] = array(
389 - 'question' => sanitize_text_field( (string) ( $question['question'] ?? '' ) ),
390 - 'options' => $clean_options,
391 - 'correct_index' => $correct_index,
392 - 'explanation' => sanitize_textarea_field( (string) ( $question['explanation'] ?? '' ) ),
393 - );
394 -
395 - if ( count( $sanitized ) >= max( 1, $question_count ) ) {
396 - break;
397 - }
398 - }
399 -
400 - return $sanitized;
401 - }
402 -}
1 +<?php
2 +
3 +namespace LearnPress\AI\Assistant;
4 +
5 +use LearnPress\Services\OpenAiService;
6 +
7 +/**
8 + * QuickQuizEngine — generates and drives interactive quick quizzes.
9 + *
10 + * Handles quiz generation from lesson content (start) and stateful
11 + * answer evaluation across multiple turns (continue_session).
12 + *
13 + * @package LearnPress\AI\Assistant
14 + * @since 4.3.5
15 + */
16 +class QuickQuizEngine {
17 +
18 + private const QUIZ_PROMPT_HISTORY_LIMIT = 3;
19 + private const HISTORY_CONTENT_MAX_CHARS = 400;
20 +
21 + private TokenQuotaGuard $quota_guard;
22 + private LanguageResolver $language_resolver;
23 + private ResponseNormalizer $normalizer;
24 +
25 + public function __construct(
26 + TokenQuotaGuard $quota_guard,
27 + LanguageResolver $language_resolver,
28 + ResponseNormalizer $normalizer
29 + ) {
30 + $this->quota_guard = $quota_guard;
31 + $this->language_resolver = $language_resolver;
32 + $this->normalizer = $normalizer;
33 + }
34 +
35 + /**
36 + * Start a new interactive quick quiz from lesson content.
37 + *
38 + * @param DataLoaders $loaders Data loader instance.
39 + * @param string $user_message Learner input (may contain explicit count).
40 + * @param int $lesson_id Current lesson ID.
41 + * @param int $user_id Current user ID.
42 + * @param array $history Conversation history.
43 + *
44 + * @return array{type: string, message: string, quiz: array|null}
45 + */
46 + public function start( DataLoaders $loaders, string $user_message, int $lesson_id, int $user_id, array $history ): array {
47 +
48 + $lesson = $loaders->get_lesson_content( $lesson_id, $user_id );
49 + if ( ! empty( $lesson['error'] ) ) {
50 + return $this->normalizer->build_response( $lesson['error'] );
51 + }
52 +
53 + $service = OpenAiService::instance();
54 + $history_slice = $this->slice_recent_history( $history, self::QUIZ_PROMPT_HISTORY_LIMIT );
55 + $question_count = $this->extract_requested_quiz_count( $user_message );
56 + $has_explicit_count = null !== $question_count;
57 + $language_guidance = $this->language_resolver->build_instruction( $user_message, $history_slice, $user_id );
58 +
59 + $system_instructions = $has_explicit_count
60 + ? sprintf(
61 + /* translators: %d: requested number of quiz questions. */
62 + __( 'Create a quick quiz from lesson content. Return ONLY valid JSON with keys: intro (string), questions (array of exactly %d items). Each question must contain: question (string), options (array of 4 strings), correct_index (0-3 integer), explanation (string).', 'learnpress' ),
63 + $question_count
64 + )
65 + : __( 'Create a quick quiz from lesson content with 3-5 questions. Return ONLY valid JSON with keys: intro (string), questions (array of objects). Each question must contain: question (string), options (array of 4 strings), correct_index (0-3 integer), explanation (string).', 'learnpress' );
66 +
67 + $messages = array(
68 + array(
69 + 'role' => 'system',
70 + 'content' => $system_instructions,
71 + ),
72 + array(
73 + 'role' => 'system',
74 + 'content' => $language_guidance,
75 + ),
76 + array(
77 + 'role' => 'system',
78 + 'content' => wp_json_encode( $lesson ),
79 + ),
80 + );
81 +
82 + foreach ( $history_slice as $item ) {
83 + if ( ! empty( $item['role'] ) && isset( $item['content'] ) ) {
84 + $messages[] = array(
85 + 'role' => $item['role'],
86 + 'content' => $item['content'],
87 + );
88 + }
89 + }
90 +
91 + $messages[] = array(
92 + 'role' => 'user',
93 + 'content' => $user_message,
94 + );
95 +
96 + $response = $this->quota_guard->send_chat_with_guard( $service, $messages, $user_id );
97 + if ( $this->quota_guard->is_blocked() ) {
98 + return $this->normalizer->build_response( $this->quota_guard->get_block_message() );
99 + }
100 +
101 + $content = (string) ( $response['content'] ?? '' );
102 + $decoded = $this->normalizer->decode_json( $content );
103 + $questions = $this->sanitize_quiz_questions( $decoded['questions'] ?? array(), $question_count );
104 +
105 + if ( empty( $questions ) ) {
106 + return $this->normalizer->build_response( __( 'I could not generate a quiz right now. Please try again.', 'learnpress' ) );
107 + }
108 +
109 + $quiz_state = array(
110 + 'is_active' => true,
111 + 'completed' => false,
112 + 'current_index' => 0,
113 + 'score' => 0,
114 + 'total' => count( $questions ),
115 + 'questions' => $questions,
116 + );
117 +
118 + $intro = sanitize_textarea_field( (string) ( $decoded['intro'] ?? '' ) );
119 +
120 + return array(
121 + 'type' => 'quiz',
122 + 'message' => ! empty( $intro ) ? $intro : __( 'Quick quiz started. Answer each question to continue.', 'learnpress' ),
123 + 'quiz' => $quiz_state,
124 + );
125 + }
126 +
127 + /**
128 + * Process an answer for an active quiz session and advance quiz state.
129 + *
130 + * @param string $user_message Learner answer input.
131 + * @param array $state Current quiz state.
132 + *
133 + * @return array{type: string, message: string, quiz: array|null}
134 + */
135 + public function continue_session( string $user_message, array $state ): array {
136 +
137 + $questions = $state['questions'] ?? array();
138 + $current = absint( $state['current_index'] ?? 0 );
139 + $score = absint( $state['score'] ?? 0 );
140 +
141 + if ( empty( $questions ) || ! isset( $questions[ $current ] ) ) {
142 + return $this->normalizer->build_response( __( 'Quiz state is invalid. Please start a new quick quiz.', 'learnpress' ) );
143 + }
144 +
145 + $question = $questions[ $current ];
146 + $answer_i = $this->parse_answer_index( $user_message, $question['options'] ?? array() );
147 +
148 + if ( $answer_i === null ) {
149 + return array(
150 + 'type' => 'quiz',
151 + 'message' => __( 'Please answer with option number (1-4), letter (A-D), or full option text.', 'learnpress' ),
152 + 'quiz' => $state,
153 + );
154 + }
155 +
156 + $correct_index = absint( $question['correct_index'] ?? 0 );
157 + $is_correct = $answer_i === $correct_index;
158 + if ( $is_correct ) {
159 + ++$score;
160 + }
161 +
162 + $next_index = $current + 1;
163 + $total = count( $questions );
164 +
165 + $state['score'] = $score;
166 + $state['current_index'] = $next_index;
167 + $state['total'] = $total;
168 + $state['feedback'] = array(
169 + 'is_correct' => $is_correct,
170 + 'selected_index' => $answer_i,
171 + 'selected_answer' => $question['options'][ $answer_i ] ?? '',
172 + 'correct_index' => $correct_index,
173 + 'correct_answer' => $question['options'][ $correct_index ] ?? '',
174 + 'explanation' => $question['explanation'] ?? '',
175 + );
176 +
177 + if ( $next_index >= $total ) {
178 + $state['is_active'] = false;
179 + $state['completed'] = true;
180 +
181 + return array(
182 + 'type' => 'quiz',
183 + 'message' => sprintf(
184 + /* translators: 1: score, 2: total questions. */
185 + __( 'Quiz complete. You scored %1$d/%2$d.', 'learnpress' ),
186 + $score,
187 + $total
188 + ),
189 + 'quiz' => $state,
190 + );
191 + }
192 +
193 + $state['is_active'] = true;
194 + $state['completed'] = false;
195 +
196 + $message = $is_correct
197 + ? __( 'Correct. Great job! Moving to the next question.', 'learnpress' )
198 + : __( 'Not quite. Let us move to the next question.', 'learnpress' );
199 +
200 + return array(
201 + 'type' => 'quiz',
202 + 'message' => $message,
203 + 'quiz' => $state,
204 + );
205 + }
206 +
207 + // ----------------------------------------------------------------
208 + // Private helpers
209 + // ----------------------------------------------------------------
210 +
211 + /**
212 + * Keep only recent and valid chat history rows for quiz-generation prompts.
213 + *
214 + * @param array $history Chat history.
215 + * @param int $limit Number of rows to keep from the end.
216 + *
217 + * @return array<int, array{role: string, content: string}>
218 + */
219 + private function slice_recent_history( array $history, int $limit ): array {
220 +
221 + $sanitized = array();
222 +
223 + foreach ( $history as $item ) {
224 + if ( ! is_array( $item ) ) {
225 + continue;
226 + }
227 +
228 + $role = (string) ( $item['role'] ?? '' );
229 + if ( ! in_array( $role, array( 'user', 'assistant' ), true ) ) {
230 + continue;
231 + }
232 +
233 + $content = trim( (string) ( $item['content'] ?? '' ) );
234 + if ( '' === $content ) {
235 + continue;
236 + }
237 +
238 + $sanitized[] = array(
239 + 'role' => $role,
240 + 'content' => mb_substr( $content, 0, self::HISTORY_CONTENT_MAX_CHARS, 'UTF-8' ),
241 + );
242 + }
243 +
244 + if ( empty( $sanitized ) ) {
245 + return array();
246 + }
247 +
248 + return array_slice( $sanitized, -1 * max( 1, $limit ) );
249 + }
250 +
251 + /**
252 + * Parse learner answer into option index.
253 + *
254 + * Supports: numeric (1-4), letter (A-D), and exact option text.
255 + *
256 + * @param string $message Learner answer.
257 + * @param array $options Current question options.
258 + *
259 + * @return int|null
260 + */
261 + private function parse_answer_index( string $message, array $options ): ?int {
262 +
263 + $input = strtolower( trim( $message ) );
264 + if ( $input === '' ) {
265 + return null;
266 + }
267 +
268 + if ( preg_match( '/^[1-4]$/', $input ) ) {
269 + return max( 0, (int) $input - 1 );
270 + }
271 +
272 + $letters = array(
273 + 'a' => 0,
274 + 'b' => 1,
275 + 'c' => 2,
276 + 'd' => 3,
277 + );
278 + if ( isset( $letters[ $input ] ) ) {
279 + return $letters[ $input ];
280 + }
281 +
282 + foreach ( $options as $index => $option ) {
283 + if ( strtolower( trim( (string) $option ) ) === $input ) {
284 + return (int) $index;
285 + }
286 + }
287 +
288 + return null;
289 + }
290 +
291 + /**
292 + * Extract the requested number of quiz questions from the learner message.
293 + *
294 + * Language-agnostic heuristic:
295 + * - Prefer explicit numeric forms (e.g. "/quick-quiz 5", "quiz 5", "5 quiz").
296 + * - If only one standalone number is present, use it as requested count.
297 + * - Return null when count cannot be inferred reliably.
298 + *
299 + * @param string $message Learner input.
300 + *
301 + * @return int|null
302 + */
303 + private function extract_requested_quiz_count( string $message ): ?int {
304 +
305 + $normalized = trim( $message );
306 + if ( '' === $normalized ) {
307 + return null;
308 + }
309 +
310 + $normalized = mb_strtolower( $normalized, 'UTF-8' );
311 + $normalized = preg_replace( '/[^\p{L}\p{N}\s]+/u', ' ', $normalized );
312 + $normalized = is_string( $normalized ) ? trim( preg_replace( '/\s+/', ' ', $normalized ) ?? '' ) : '';
313 + if ( '' === $normalized ) {
314 + return null;
315 + }
316 +
317 + // Explicit command-like request: "/quick-quiz 5".
318 + if ( preg_match( '/\/(?:mini|quick)-?quiz\s+(\d{1,2})(?=\s|$)/u', $normalized, $matches ) ) {
319 + $count = (int) ( $matches[1] ?? 0 );
320 + return ( $count >= 1 && $count <= 20 ) ? $count : null;
321 + }
322 +
323 + // Number close to "quiz" token works for most mixed-language requests.
324 + if ( preg_match( '/(?:quiz[^\p{N}]{0,20}(\d{1,2})|(\d{1,2})[^\p{N}]{0,20}quiz)/u', $normalized, $matches ) ) {
325 + $count = (int) ( $matches[1] ?: $matches[2] ?: 0 );
326 + if ( $count >= 1 && $count <= 20 ) {
327 + return $count;
328 + }
329 + }
330 +
331 + // Fallback: if there is exactly one standalone number, treat it as count.
332 + if ( preg_match_all( '/(?<!\p{N})(\d{1,2})(?!\p{N})/u', $normalized, $matches ) && count( $matches[1] ) === 1 ) {
333 + $count = (int) $matches[1][0];
334 + if ( $count >= 1 && $count <= 20 ) {
335 + return $count;
336 + }
337 + }
338 +
339 + return null;
340 + }
341 +
342 + /**
343 + * Sanitize and normalize generated quiz questions from model output.
344 + *
345 + * Every question is sanitized and given a known shape regardless of whether an
346 + * explicit count was requested. Model output is untrusted input: this state is sent
347 + * to the browser, persisted in localStorage, and echoed back on the next turn, so
348 + * the previous "trust the model when no count was asked for" path is not safe.
349 + *
350 + * $question_count only caps how many valid questions are kept.
351 + *
352 + * @param array $questions Raw question payload.
353 + * @param int|null $question_count Exact number of questions to keep, or null to keep all valid ones.
354 + *
355 + * @return array
356 + */
357 + private function sanitize_quiz_questions( array $questions, ?int $question_count = null ): array {
358 +
359 + if ( empty( $questions ) ) {
360 + return array();
361 + }
362 +
363 + $sanitized = array();
364 + foreach ( $questions as $question ) {
365 + if ( ! is_array( $question ) ) {
366 + continue;
367 + }
368 +
369 + $options = $question['options'] ?? array();
370 + if ( ! is_array( $options ) || count( $options ) < 2 ) {
371 + continue;
372 + }
373 +
374 + $clean_options = array_values(
375 + array_map(
376 + static fn( $option ) => sanitize_text_field( (string) $option ),
377 + $options
378 + )
379 + );
380 +
381 + $correct_index = absint( $question['correct_index'] ?? 0 );
382 + if ( $correct_index >= count( $clean_options ) ) {
383 + $correct_index = 0;
384 + }
385 +
386 + $sanitized[] = array(
387 + 'question' => sanitize_text_field( (string) ( $question['question'] ?? '' ) ),
388 + 'options' => $clean_options,
389 + 'correct_index' => $correct_index,
390 + 'explanation' => sanitize_textarea_field( (string) ( $question['explanation'] ?? '' ) ),
391 + );
392 +
393 + if ( null !== $question_count && count( $sanitized ) >= max( 1, $question_count ) ) {
394 + break;
395 + }
396 + }
397 +
398 + return $sanitized;
399 + }
400 +}