PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.3.9
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.3.9
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.3.9, at inc/AI/Assistant/QuickQuizEngine.php

403 lines 12.3 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 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 }
403