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 / Agent.php

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

399 lines 14.1 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 * @param string $user_message The learner's message.
44 * @param int $item_id Current lesson ID.
45 * @param int $course_id Current course ID.
46 * @param int $user_id Current user ID.
47 * @param array $history Previous conversation messages (role/content pairs).
48 * @param array $active_quiz Active quiz state for quiz-mode continuation.
49 *
50 * @return array{type: string, message: string, quiz: array|null}
51 */
52 public function run(
53 string $user_message,
54 int $item_id,
55 int $course_id,
56 int $user_id,
57 array $history = array(),
58 array $active_quiz = array(),
59 ?string $action_hint = null
60 ): array {
61
62 $this->quota_guard->reset();
63 $data_loaders = new DataLoaders();
64
65 // Resume active quiz session.
66 if ( ! empty( $active_quiz['is_active'] ) && empty( $active_quiz['completed'] ) ) {
67 if ( ! AIAssistantController::is_action_enabled( IntentClassifier::INTENT_QUICK_QUIZ ) ) {
68 return $this->get_disabled_action_response( IntentClassifier::INTENT_QUICK_QUIZ );
69 }
70
71 return $this->quiz_engine->continue_session( $user_message, $active_quiz );
72 }
73
74 // Use validated quick-action hint when present, otherwise classify intent via OpenAI.
75 $intent = $this->resolve_intent( $user_message, $history, $item_id, $course_id, $user_id, $action_hint );
76 if ( $this->quota_guard->is_blocked() ) {
77 return $this->normalizer->build_response( $this->quota_guard->get_block_message() );
78 }
79
80 // Gate non-general intents behind admin toggles.
81 if ( $this->requires_action_gate( $intent ) && ! AIAssistantController::is_action_enabled( $intent ) ) {
82 return $this->get_disabled_action_response( $intent );
83 }
84
85 switch ( $intent ) {
86 case IntentClassifier::INTENT_SUMMARIZE:
87 return $this->handle_summarize( $data_loaders, $user_message, $item_id, $user_id, $history );
88
89 case IntentClassifier::INTENT_EXPLAIN:
90 return $this->handle_explain( $data_loaders, $user_message, $item_id, $user_id, $history );
91
92 case IntentClassifier::INTENT_SMART_REVIEW:
93 return $this->handle_smart_review( $data_loaders, $user_message, $user_id, $course_id, $item_id, $history );
94
95 case IntentClassifier::INTENT_QUICK_QUIZ:
96 return $this->quiz_engine->start( $data_loaders, $user_message, $item_id, $user_id, $history );
97
98 case IntentClassifier::INTENT_GENERAL:
99 default:
100 return $this->handle_general( $data_loaders, $user_message, $item_id, $user_id, $history );
101 }
102 }
103
104 /**
105 * Resolve final intent, prioritizing an explicit validated action hint.
106 *
107 * @param string $user_message Learner input.
108 * @param array $history Conversation history.
109 * @param int $item_id Current lesson ID.
110 * @param int $course_id Current course ID.
111 * @param int $user_id Current user ID.
112 * @param string|null $action_hint Optional quick-action hint from frontend.
113 *
114 * @return string
115 */
116 private function resolve_intent(
117 string $user_message,
118 array $history,
119 int $item_id,
120 int $course_id,
121 int $user_id,
122 ?string $action_hint
123 ): string {
124
125 $hint_intent = $this->normalize_action_hint( $action_hint );
126 if ( '' !== $hint_intent ) {
127 return $hint_intent;
128 }
129
130 return $this->classifier->classify( $user_message, $history, $item_id, $course_id, $user_id );
131 }
132
133 /**
134 * Normalize optional action hint to a supported intent.
135 *
136 * @param string|null $action_hint Raw action hint.
137 *
138 * @return string
139 */
140 private function normalize_action_hint( ?string $action_hint ): string {
141 if ( ! is_string( $action_hint ) ) {
142 return '';
143 }
144
145 $normalized = strtolower( trim( $action_hint ) );
146 if ( '' === $normalized ) {
147 return '';
148 }
149
150 $normalized = str_replace( '-', '_', $normalized );
151
152 $aliases = array(
153 'quick_quiz' => IntentClassifier::INTENT_QUICK_QUIZ,
154 'explain' => IntentClassifier::INTENT_EXPLAIN,
155 'summarize' => IntentClassifier::INTENT_SUMMARIZE,
156 'smart_review' => IntentClassifier::INTENT_SMART_REVIEW,
157 );
158
159 if ( isset( $aliases[ $normalized ] ) ) {
160 $normalized = $aliases[ $normalized ];
161 }
162
163 return $this->classifier->is_supported_intent( $normalized ) ? $normalized : '';
164 }
165
166 // ----------------------------------------------------------------
167 // Intent-specific handlers (thin wrappers around ask_openai_text)
168 // ----------------------------------------------------------------
169
170 /**
171 * System prompt for the assistant model.
172 */
173 private function get_system_prompt(): string {
174 return __(
175 '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.',
176 'learnpress'
177 );
178 }
179
180 /**
181 * Build a summary response grounded in the current lesson content.
182 */
183 private function handle_summarize( DataLoaders $loaders, string $message, int $lesson_id, int $user_id, array $history ): array {
184
185 $lesson = $loaders->get_lesson_content( $lesson_id, $user_id );
186 if ( ! empty( $lesson['error'] ) ) {
187 return $this->normalizer->build_response( $lesson['error'] );
188 }
189
190 $instruction = __( 'Summarize this lesson clearly with key points, practical takeaways, and 3 quick review bullets.', 'learnpress' );
191 $content = $this->ask_openai_text( $history, $message, $instruction, array( 'lesson' => $lesson ), $user_id );
192 return $this->normalizer->build_response( $content );
193 }
194
195 /**
196 * Build a concept explanation response grounded in the current lesson.
197 */
198 private function handle_explain( DataLoaders $loaders, string $message, int $lesson_id, int $user_id, array $history ): array {
199
200 $lesson = $loaders->get_lesson_content( $lesson_id, $user_id );
201 if ( ! empty( $lesson['error'] ) ) {
202 return $this->normalizer->build_response( $lesson['error'] );
203 }
204
205 $instruction = __( 'Explain the learner request using lesson context only. Give a short explanation, one concrete example, and one self-check question.', 'learnpress' );
206 $content = $this->ask_openai_text( $history, $message, $instruction, array( 'lesson' => $lesson ), $user_id );
207 return $this->normalizer->build_response( $content );
208 }
209
210 /**
211 * Build a personalized review for the current completed quiz item.
212 */
213 private function handle_smart_review( DataLoaders $loaders, string $message, int $user_id, int $course_id, int $quiz_id, array $history ): array {
214
215 $quiz_review = $loaders->get_quiz_review_result( $user_id, $course_id, $quiz_id );
216 if ( ! empty( $quiz_review['error'] ) ) {
217 return $this->normalizer->build_response( $quiz_review['error'] );
218 }
219
220 $instruction = __( 'Create a smart review for this completed quiz attempt. Summarize performance, identify weak concepts, and provide a concise next-step study plan.', 'learnpress' );
221 $content = $this->ask_openai_text(
222 $history,
223 $message,
224 $instruction,
225 array( 'quiz_review' => $quiz_review ),
226 $user_id
227 );
228 return $this->normalizer->build_response( $content );
229 }
230
231 /**
232 * Handle open-ended chat requests with lesson-grounded context.
233 */
234 private function handle_general( DataLoaders $loaders, string $message, int $lesson_id, int $user_id, array $history ): array {
235
236 $lesson = $loaders->get_lesson_content( $lesson_id, $user_id );
237 $instruction = __( 'Answer naturally and keep guidance grounded in the provided lesson context. If context is missing, say so clearly.', 'learnpress' );
238 $content = $this->ask_openai_text( $history, $message, $instruction, array( 'lesson' => $lesson ), $user_id );
239 return $this->normalizer->build_response( $content );
240 }
241
242 // ----------------------------------------------------------------
243 // Core agentic loop
244 // ----------------------------------------------------------------
245
246 /**
247 * Send a text-generation request to OpenAI and normalize the first content response.
248 *
249 * @param array $history Prior role/content messages.
250 * @param string $user_message Learner input for this turn.
251 * @param string $instruction Intent-specific guidance for the model.
252 * @param array $context Grounded lesson/course context payload.
253 * @param int $user_id Current user ID.
254 *
255 * @return string
256 */
257 private function ask_openai_text( array $history, string $user_message, string $instruction, array $context, int $user_id ): string {
258
259 $service = OpenAiService::instance();
260 $history_slice = $this->slice_recent_history( $history, self::CHAT_HISTORY_LIMIT );
261 $messages = array();
262 $messages[] = array(
263 'role' => 'system',
264 'content' => $this->get_system_prompt() . "\n" . $instruction,
265 );
266 $messages[] = array(
267 'role' => 'system',
268 'content' => $this->language_resolver->build_instruction( $user_message, $history_slice, $user_id ),
269 );
270 $messages[] = array(
271 'role' => 'system',
272 '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' ),
273 );
274 $messages[] = array(
275 'role' => 'system',
276 'content' => sprintf(
277 /* translators: %s: JSON encoded learning context. */
278 __( 'Grounded context (JSON): %s', 'learnpress' ),
279 wp_json_encode( $context )
280 ),
281 );
282
283 foreach ( $history_slice as $item ) {
284 if ( ! empty( $item['role'] ) && isset( $item['content'] ) ) {
285 $messages[] = array(
286 'role' => $item['role'],
287 'content' => $item['content'],
288 );
289 }
290 }
291
292 $messages[] = array(
293 'role' => 'user',
294 'content' => $user_message,
295 );
296
297 for ( $i = 0; $i < self::MAX_TOOL_ITERATIONS; $i++ ) {
298 $response_message = $this->quota_guard->send_chat_with_guard( $service, $messages, $user_id );
299 if ( $this->quota_guard->is_blocked() ) {
300 return $this->quota_guard->get_block_message();
301 }
302
303 if ( ! empty( $response_message['content'] ) ) {
304 return $this->normalizer->normalize( (string) $response_message['content'] );
305 }
306 }
307
308 return __( 'I was unable to complete the request. Please try again.', 'learnpress' );
309 }
310
311 /**
312 * Keep only recent and valid chat history rows for text-generation requests.
313 *
314 * @param array $history Chat history.
315 * @param int $limit Number of rows to keep from the end.
316 *
317 * @return array<int, array{role: string, content: string}>
318 */
319 private function slice_recent_history( array $history, int $limit ): array {
320
321 $sanitized = array();
322
323 foreach ( $history as $item ) {
324 if ( ! is_array( $item ) ) {
325 continue;
326 }
327
328 $role = (string) ( $item['role'] ?? '' );
329 if ( ! in_array( $role, array( 'user', 'assistant' ), true ) ) {
330 continue;
331 }
332
333 $content = trim( (string) ( $item['content'] ?? '' ) );
334 if ( '' === $content ) {
335 continue;
336 }
337
338 $sanitized[] = array(
339 'role' => $role,
340 'content' => mb_substr( $content, 0, self::HISTORY_CONTENT_MAX_CHARS, 'UTF-8' ),
341 );
342 }
343
344 if ( empty( $sanitized ) ) {
345 return array();
346 }
347
348 return array_slice( $sanitized, -1 * max( 1, $limit ) );
349 }
350
351 // ----------------------------------------------------------------
352 // Action gate helpers
353 // ----------------------------------------------------------------
354
355 /**
356 * Determine whether the detected intent maps to a gated assistant action.
357 *
358 * @param string $intent Detected intent.
359 *
360 * @return bool
361 */
362 private function requires_action_gate( string $intent ): bool {
363 return in_array(
364 $intent,
365 array(
366 IntentClassifier::INTENT_SUMMARIZE,
367 IntentClassifier::INTENT_EXPLAIN,
368 IntentClassifier::INTENT_QUICK_QUIZ,
369 IntentClassifier::INTENT_SMART_REVIEW,
370 ),
371 true
372 );
373 }
374
375 /**
376 * Build a user-facing response for a disabled assistant action.
377 *
378 * @param string $intent Disabled action intent.
379 *
380 * @return array{type: string, message: string, quiz: array|null}
381 */
382 private function get_disabled_action_response( string $intent ): array {
383 $action_labels = array(
384 IntentClassifier::INTENT_SUMMARIZE => __( 'Summarize Lesson', 'learnpress' ),
385 IntentClassifier::INTENT_EXPLAIN => __( 'Explain Concept', 'learnpress' ),
386 IntentClassifier::INTENT_QUICK_QUIZ => __( 'Quick Quiz', 'learnpress' ),
387 IntentClassifier::INTENT_SMART_REVIEW => __( 'Smart Review', 'learnpress' ),
388 );
389
390 return $this->normalizer->build_response(
391 sprintf(
392 /* translators: %s: assistant action label. */
393 __( 'The %s action is currently disabled by the site administrator.', 'learnpress' ),
394 $action_labels[ $intent ] ?? __( 'requested', 'learnpress' )
395 )
396 );
397 }
398 }
399