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/Agent.php +446 -398 4.3.94.4.8 View file →
@@ -1,398 +1,446 @@
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 -}
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 +}