PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.7
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.7
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 / Agent.php

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

447 lines 15.5 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 * AI Assistant Agent - orchestrates the learner-facing conversation loop.
9 *
10 * Thin orchestrator that delegates every domain concern to extracted classes:
11 * - IntentClassifier — detects learner intent via OpenAI
12 * - TokenQuotaGuard — enforces daily token quota
13 * - LanguageResolver — builds language-guidance system prompt
14 * - QuickQuizEngine — generates and drives interactive quick quizzes
15 * - ResponseNormalizer — decodes JSON output and normalizes text
16 *
17 * @package LearnPress\AI\Assistant
18 * @since 4.3.5
19 */
20 class Agent {
21
22 private const MAX_TOOL_ITERATIONS = 4;
23 private const CHAT_HISTORY_LIMIT = 3;
24 private const HISTORY_CONTENT_MAX_CHARS = 400;
25
26 private IntentClassifier $classifier;
27 private TokenQuotaGuard $quota_guard;
28 private LanguageResolver $language_resolver;
29 private QuickQuizEngine $quiz_engine;
30 private ResponseNormalizer $normalizer;
31
32 public function __construct() {
33 $this->normalizer = new ResponseNormalizer();
34 $this->quota_guard = new TokenQuotaGuard();
35 $this->language_resolver = new LanguageResolver();
36 $this->classifier = new IntentClassifier( $this->quota_guard, $this->normalizer );
37 $this->quiz_engine = new QuickQuizEngine( $this->quota_guard, $this->language_resolver, $this->normalizer );
38 }
39
40 /**
41 * Run the assistant agent loop.
42 *
43 * Callers must pass an $item_type already resolved and authorized by
44 * AIAssistantController::resolve_item_access(). This method routes on it but does
45 * not authorize — it is not a security boundary.
46 *
47 * @param string $user_message The learner's message.
48 * @param int $item_id Current course item ID, type proven by $item_type.
49 * @param string $item_type Resolved curriculum type: LP_LESSON_CPT or LP_QUIZ_CPT.
50 * @param int $course_id Current course ID.
51 * @param int $user_id Current user ID.
52 * @param array $history Previous conversation messages (role/content pairs).
53 * @param array $active_quiz Active quiz state for quiz-mode continuation.
54 * @param string $action_hint Optional validated quick-action hint.
55 *
56 * @return array{type: string, message: string, quiz: array|null}
57 */
58 public function run(
59 string $user_message,
60 int $item_id,
61 string $item_type,
62 int $course_id,
63 int $user_id,
64 array $history = array(),
65 array $active_quiz = array(),
66 ?string $action_hint = null
67 ): array {
68
69 $this->quota_guard->reset();
70 $data_loaders = new DataLoaders();
71
72 $is_lesson = LP_LESSON_CPT === $item_type;
73 $is_quiz = LP_QUIZ_CPT === $item_type;
74
75 if ( ! $is_lesson && ! $is_quiz ) {
76 return $this->normalizer->build_response(
77 __( 'The AI Assistant is not available for this type of course item.', 'learnpress' )
78 );
79 }
80
81 // Resume active quiz session. Quick quiz is generated from lesson content, so a
82 // session may only continue while the authorized context is still a lesson.
83 if ( ! empty( $active_quiz['is_active'] ) && empty( $active_quiz['completed'] ) ) {
84 if ( ! $is_lesson ) {
85 return $this->get_lesson_only_response();
86 }
87
88 if ( ! AIAssistantController::is_action_enabled( IntentClassifier::INTENT_QUICK_QUIZ ) ) {
89 return $this->get_disabled_action_response( IntentClassifier::INTENT_QUICK_QUIZ );
90 }
91
92 return $this->quiz_engine->continue_session( $user_message, $active_quiz );
93 }
94
95 // Use validated quick-action hint when present, otherwise classify intent via OpenAI.
96 $intent = $this->resolve_intent( $user_message, $history, $item_id, $course_id, $user_id, $action_hint );
97 if ( $this->quota_guard->is_blocked() ) {
98 return $this->normalizer->build_response( $this->quota_guard->get_block_message() );
99 }
100
101 // Gate non-general intents behind admin toggles.
102 if ( $this->requires_action_gate( $intent ) && ! AIAssistantController::is_action_enabled( $intent ) ) {
103 return $this->get_disabled_action_response( $intent );
104 }
105
106 /**
107 * Typed routing. Smart Review reads a quiz attempt; every other intent is
108 * grounded in lesson content. $item_id is only renamed to $quiz_id/$lesson_id
109 * once the corresponding type check has passed.
110 */
111 if ( IntentClassifier::INTENT_SMART_REVIEW === $intent ) {
112 if ( ! $is_quiz ) {
113 return $this->normalizer->build_response(
114 __( 'Smart Review is only available on a quiz you have completed.', 'learnpress' )
115 );
116 }
117
118 return $this->handle_smart_review( $data_loaders, $user_message, $user_id, $course_id, $item_id, $history );
119 }
120
121 if ( ! $is_lesson ) {
122 return $this->get_lesson_only_response();
123 }
124
125 switch ( $intent ) {
126 case IntentClassifier::INTENT_SUMMARIZE:
127 return $this->handle_summarize( $data_loaders, $user_message, $item_id, $user_id, $history );
128
129 case IntentClassifier::INTENT_EXPLAIN:
130 return $this->handle_explain( $data_loaders, $user_message, $item_id, $user_id, $history );
131
132 case IntentClassifier::INTENT_QUICK_QUIZ:
133 return $this->quiz_engine->start( $data_loaders, $user_message, $item_id, $user_id, $history );
134
135 case IntentClassifier::INTENT_GENERAL:
136 default:
137 return $this->handle_general( $data_loaders, $user_message, $item_id, $user_id, $history );
138 }
139 }
140
141 /**
142 * Resolve final intent, prioritizing an explicit validated action hint.
143 *
144 * @param string $user_message Learner input.
145 * @param array $history Conversation history.
146 * @param int $item_id Current course item ID (lesson or quiz).
147 * @param int $course_id Current course ID.
148 * @param int $user_id Current user ID.
149 * @param string|null $action_hint Optional quick-action hint from frontend.
150 *
151 * @return string
152 */
153 private function resolve_intent(
154 string $user_message,
155 array $history,
156 int $item_id,
157 int $course_id,
158 int $user_id,
159 ?string $action_hint
160 ): string {
161
162 $hint_intent = $this->normalize_action_hint( $action_hint );
163 if ( '' !== $hint_intent ) {
164 return $hint_intent;
165 }
166
167 return $this->classifier->classify( $user_message, $history, $item_id, $course_id, $user_id );
168 }
169
170 /**
171 * Normalize optional action hint to a supported intent.
172 *
173 * @param string|null $action_hint Raw action hint.
174 *
175 * @return string
176 */
177 private function normalize_action_hint( ?string $action_hint ): string {
178 if ( ! is_string( $action_hint ) ) {
179 return '';
180 }
181
182 $normalized = strtolower( trim( $action_hint ) );
183 if ( '' === $normalized ) {
184 return '';
185 }
186
187 $normalized = str_replace( '-', '_', $normalized );
188
189 $aliases = array(
190 'quick_quiz' => IntentClassifier::INTENT_QUICK_QUIZ,
191 'explain' => IntentClassifier::INTENT_EXPLAIN,
192 'summarize' => IntentClassifier::INTENT_SUMMARIZE,
193 'smart_review' => IntentClassifier::INTENT_SMART_REVIEW,
194 );
195
196 if ( isset( $aliases[ $normalized ] ) ) {
197 $normalized = $aliases[ $normalized ];
198 }
199
200 return $this->classifier->is_supported_intent( $normalized ) ? $normalized : '';
201 }
202
203 // ----------------------------------------------------------------
204 // Intent-specific handlers (thin wrappers around ask_openai_text)
205 // ----------------------------------------------------------------
206
207 /**
208 * System prompt for the assistant model.
209 */
210 private function get_system_prompt(): string {
211 return __(
212 'You are a helpful AI learning assistant for an online course. You help learners understand lesson content, explain concepts, generate practice quizzes, and review their quiz performance. Always ground your answers in the actual course data provided by tools. Respond in the same language the learner uses.',
213 'learnpress'
214 );
215 }
216
217 /**
218 * Build a summary response grounded in the current lesson content.
219 */
220 private function handle_summarize( DataLoaders $loaders, string $message, int $lesson_id, int $user_id, array $history ): array {
221
222 $lesson = $loaders->get_lesson_content( $lesson_id, $user_id );
223 if ( ! empty( $lesson['error'] ) ) {
224 return $this->normalizer->build_response( $lesson['error'] );
225 }
226
227 $instruction = __( 'Summarize this lesson clearly with key points, practical takeaways, and 3 quick review bullets.', 'learnpress' );
228 $content = $this->ask_openai_text( $history, $message, $instruction, array( 'lesson' => $lesson ), $user_id );
229 return $this->normalizer->build_response( $content );
230 }
231
232 /**
233 * Build a concept explanation response grounded in the current lesson.
234 */
235 private function handle_explain( DataLoaders $loaders, string $message, int $lesson_id, int $user_id, array $history ): array {
236
237 $lesson = $loaders->get_lesson_content( $lesson_id, $user_id );
238 if ( ! empty( $lesson['error'] ) ) {
239 return $this->normalizer->build_response( $lesson['error'] );
240 }
241
242 $instruction = __( 'Explain the learner request using lesson context only. Give a short explanation, one concrete example, and one self-check question.', 'learnpress' );
243 $content = $this->ask_openai_text( $history, $message, $instruction, array( 'lesson' => $lesson ), $user_id );
244 return $this->normalizer->build_response( $content );
245 }
246
247 /**
248 * Build a personalized review for the current completed quiz item.
249 */
250 private function handle_smart_review( DataLoaders $loaders, string $message, int $user_id, int $course_id, int $quiz_id, array $history ): array {
251
252 $quiz_review = $loaders->get_quiz_review_result( $user_id, $course_id, $quiz_id );
253 if ( ! empty( $quiz_review['error'] ) ) {
254 return $this->normalizer->build_response( $quiz_review['error'] );
255 }
256
257 $instruction = __( 'Create a smart review for this completed quiz attempt. Summarize performance, identify weak concepts, and provide a concise next-step study plan.', 'learnpress' );
258 $content = $this->ask_openai_text(
259 $history,
260 $message,
261 $instruction,
262 array( 'quiz_review' => $quiz_review ),
263 $user_id
264 );
265 return $this->normalizer->build_response( $content );
266 }
267
268 /**
269 * Handle open-ended chat requests with lesson-grounded context.
270 */
271 private function handle_general( DataLoaders $loaders, string $message, int $lesson_id, int $user_id, array $history ): array {
272
273 $lesson = $loaders->get_lesson_content( $lesson_id, $user_id );
274 $instruction = __( 'Answer naturally and keep guidance grounded in the provided lesson context. If context is missing, say so clearly.', 'learnpress' );
275 $content = $this->ask_openai_text( $history, $message, $instruction, array( 'lesson' => $lesson ), $user_id );
276 return $this->normalizer->build_response( $content );
277 }
278
279 // ----------------------------------------------------------------
280 // Core agentic loop
281 // ----------------------------------------------------------------
282
283 /**
284 * Send a text-generation request to OpenAI and normalize the first content response.
285 *
286 * @param array $history Prior role/content messages.
287 * @param string $user_message Learner input for this turn.
288 * @param string $instruction Intent-specific guidance for the model.
289 * @param array $context Grounded lesson/course context payload.
290 * @param int $user_id Current user ID.
291 *
292 * @return string
293 */
294 private function ask_openai_text( array $history, string $user_message, string $instruction, array $context, int $user_id ): string {
295
296 $service = OpenAiService::instance();
297 $history_slice = $this->slice_recent_history( $history, self::CHAT_HISTORY_LIMIT );
298 $messages = array();
299 $messages[] = array(
300 'role' => 'system',
301 'content' => $this->get_system_prompt() . "\n" . $instruction,
302 );
303 $messages[] = array(
304 'role' => 'system',
305 'content' => $this->language_resolver->build_instruction( $user_message, $history_slice, $user_id ),
306 );
307 $messages[] = array(
308 'role' => 'system',
309 'content' => __( 'Output contract: return ONLY valid JSON with exactly one key "message" (string). Put the full reply text inside "message". Do not include keys like language, locale, intent, type, or key_points.', 'learnpress' ),
310 );
311 $messages[] = array(
312 'role' => 'system',
313 'content' => sprintf(
314 /* translators: %s: JSON encoded learning context. */
315 __( 'Grounded context (JSON): %s', 'learnpress' ),
316 wp_json_encode( $context )
317 ),
318 );
319
320 foreach ( $history_slice as $item ) {
321 if ( ! empty( $item['role'] ) && isset( $item['content'] ) ) {
322 $messages[] = array(
323 'role' => $item['role'],
324 'content' => $item['content'],
325 );
326 }
327 }
328
329 $messages[] = array(
330 'role' => 'user',
331 'content' => $user_message,
332 );
333
334 for ( $i = 0; $i < self::MAX_TOOL_ITERATIONS; $i++ ) {
335 $response_message = $this->quota_guard->send_chat_with_guard( $service, $messages, $user_id );
336 if ( $this->quota_guard->is_blocked() ) {
337 return $this->quota_guard->get_block_message();
338 }
339
340 if ( ! empty( $response_message['content'] ) ) {
341 return $this->normalizer->normalize( (string) $response_message['content'] );
342 }
343 }
344
345 return __( 'I was unable to complete the request. Please try again.', 'learnpress' );
346 }
347
348 /**
349 * Keep only recent and valid chat history rows for text-generation requests.
350 *
351 * @param array $history Chat history.
352 * @param int $limit Number of rows to keep from the end.
353 *
354 * @return array<int, array{role: string, content: string}>
355 */
356 private function slice_recent_history( array $history, int $limit ): array {
357
358 $sanitized = array();
359
360 foreach ( $history as $item ) {
361 if ( ! is_array( $item ) ) {
362 continue;
363 }
364
365 $role = (string) ( $item['role'] ?? '' );
366 if ( ! in_array( $role, array( 'user', 'assistant' ), true ) ) {
367 continue;
368 }
369
370 $content = trim( (string) ( $item['content'] ?? '' ) );
371 if ( '' === $content ) {
372 continue;
373 }
374
375 $sanitized[] = array(
376 'role' => $role,
377 'content' => mb_substr( $content, 0, self::HISTORY_CONTENT_MAX_CHARS, 'UTF-8' ),
378 );
379 }
380
381 if ( empty( $sanitized ) ) {
382 return array();
383 }
384
385 return array_slice( $sanitized, -1 * max( 1, $limit ) );
386 }
387
388 // ----------------------------------------------------------------
389 // Action gate helpers
390 // ----------------------------------------------------------------
391
392 /**
393 * Determine whether the detected intent maps to a gated assistant action.
394 *
395 * @param string $intent Detected intent.
396 *
397 * @return bool
398 */
399 private function requires_action_gate( string $intent ): bool {
400 return in_array(
401 $intent,
402 array(
403 IntentClassifier::INTENT_SUMMARIZE,
404 IntentClassifier::INTENT_EXPLAIN,
405 IntentClassifier::INTENT_QUICK_QUIZ,
406 IntentClassifier::INTENT_SMART_REVIEW,
407 ),
408 true
409 );
410 }
411
412 /**
413 * Build a user-facing response for an action that requires a lesson context.
414 *
415 * @return array{type: string, message: string, quiz: array|null}
416 */
417 private function get_lesson_only_response(): array {
418 return $this->normalizer->build_response(
419 __( 'This assistant action is only available on a lesson.', 'learnpress' )
420 );
421 }
422
423 /**
424 * Build a user-facing response for a disabled assistant action.
425 *
426 * @param string $intent Disabled action intent.
427 *
428 * @return array{type: string, message: string, quiz: array|null}
429 */
430 private function get_disabled_action_response( string $intent ): array {
431 $action_labels = array(
432 IntentClassifier::INTENT_SUMMARIZE => __( 'Summarize Lesson', 'learnpress' ),
433 IntentClassifier::INTENT_EXPLAIN => __( 'Explain Concept', 'learnpress' ),
434 IntentClassifier::INTENT_QUICK_QUIZ => __( 'Quick Quiz', 'learnpress' ),
435 IntentClassifier::INTENT_SMART_REVIEW => __( 'Smart Review', 'learnpress' ),
436 );
437
438 return $this->normalizer->build_response(
439 sprintf(
440 /* translators: %s: assistant action label. */
441 __( 'The %s action is currently disabled by the site administrator.', 'learnpress' ),
442 $action_labels[ $intent ] ?? __( 'requested', 'learnpress' )
443 )
444 );
445 }
446 }
447