PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.4
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.4
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 4.2.1 All 138 releases
learnpress / inc / AI / Assistant / QuickQuizEngine.php

QuickQuizEngine.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.4, at inc/AI/Assistant/QuickQuizEngine.php

401 lines 12.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
401