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/AIAssistantController.php +326 -215 4.3.9.14.4.8 View file →
@@ -1,215 +1,326 @@
1 -<?php
2 -
3 -namespace LearnPress\AI\Assistant;
4 -
5 -use Exception;
6 -use LP_Settings;
7 -use LearnPress\Services\OpenAiService;
8 -
9 -/**
10 - * AI Assistant Controller — validates requests, sanitizes input, calls Agent.
11 - *
12 - * Entry point for the AJAX layer. Guarantees the response structure
13 - * required by the frontend: { type, message, quiz }.
14 - *
15 - * @package LearnPress\AI\Assistant
16 - * @since 4.3.5
17 - */
18 -class AIAssistantController {
19 - private const ACTION_SETTINGS = array(
20 - 'summarize' => 'ai_assistant_summarize_enabled',
21 - 'explain' => 'ai_assistant_explain_enabled',
22 - 'quick_quiz' => 'ai_assistant_quick_quiz_enabled',
23 - 'smart_review' => 'ai_assistant_smart_review_enabled',
24 - );
25 -
26 - /**
27 - * Check if the AI Assistant feature is fully enabled.
28 - *
29 - * All three gates must pass:
30 - * - enable_open_ai = yes
31 - * - secret key exists
32 - * - ai_assistant_enabled = yes
33 - *
34 - * @return bool
35 - */
36 - public static function is_enabled(): bool {
37 - $service = OpenAiService::instance();
38 -
39 - if ( ! $service->is_enable() ) {
40 - return false;
41 - }
42 -
43 - if ( empty( LP_Settings::get_option( 'open_ai_secret_key', '' ) ) ) {
44 - return false;
45 - }
46 -
47 - return LP_Settings::get_option( 'ai_assistant_enabled', 'no' ) === 'yes';
48 - }
49 -
50 - /**
51 - * Resolve per-action enabled flags from admin settings.
52 - *
53 - * @return array<string, bool>
54 - */
55 - public static function get_enabled_actions(): array {
56 - $enabled_actions = array();
57 -
58 - foreach ( self::ACTION_SETTINGS as $action => $setting_key ) {
59 - $enabled_actions[ $action ] = LP_Settings::get_option( $setting_key, 'yes' ) === 'yes';
60 - }
61 -
62 - return $enabled_actions;
63 - }
64 -
65 - /**
66 - * Check whether a specific assistant action is enabled.
67 - *
68 - * @param string $action Action slug.
69 - *
70 - * @return bool
71 - */
72 - public static function is_action_enabled( string $action ): bool {
73 - $enabled_actions = self::get_enabled_actions();
74 -
75 - return $enabled_actions[ $action ] ?? true;
76 - }
77 -
78 - /**
79 - * Handle an assistant chat request.
80 - *
81 - * @param array $data Raw decoded data from the AJAX request.
82 - *
83 - * @return array{type: string, message: string, quiz: array|null}
84 - * @throws Exception On validation failure or API error.
85 - */
86 - public function handle_chat( array $data ): array {
87 - $message = trim( $data['message'] ?? '' );
88 - $item_id = absint( $data['item_id'] ?? 0 );
89 - $course_id = absint( $data['course_id'] ?? 0 );
90 - $history = $data['history'] ?? array();
91 - $quiz_data = $data['active_quiz_questions'] ?? array();
92 - $action_hint = $this->sanitize_action_hint( $data['action_hint'] ?? '' );
93 -
94 - if ( $message === '' ) {
95 - throw new Exception( __( 'Message is required.', 'learnpress' ) );
96 - }
97 -
98 - if ( empty( $item_id ) ) {
99 - throw new Exception( __( 'Item ID is required.', 'learnpress' ) );
100 - }
101 -
102 - if ( $course_id === 0 ) {
103 - throw new Exception( __( 'Course ID is required.', 'learnpress' ) );
104 - }
105 -
106 - $user_id = get_current_user_id();
107 -
108 - if ( $user_id === 0 ) {
109 - throw new Exception( __( 'User must be logged in.', 'learnpress' ) );
110 - }
111 -
112 - // Sanitize history — only allow safe role/content pairs.
113 - $sanitized_history = array();
114 - if ( is_array( $history ) ) {
115 - foreach ( $history as $msg ) {
116 - $role = $msg['role'] ?? '';
117 - $content = $msg['content'] ?? '';
118 -
119 - if ( in_array( $role, array( 'user', 'assistant' ), true ) && is_string( $content ) ) {
120 - $sanitized_history[] = array(
121 - 'role' => $role,
122 - 'content' => sanitize_textarea_field( $content ),
123 - );
124 - }
125 - }
126 - }
127 -
128 - $agent = new Agent();
129 - $sanitized_quiz_state = $this->sanitize_active_quiz_state( $quiz_data );
130 -
131 - return $agent->run(
132 - sanitize_textarea_field( $message ),
133 - $item_id,
134 - $course_id,
135 - $user_id,
136 - $sanitized_history,
137 - $sanitized_quiz_state,
138 - $action_hint
139 - );
140 - }
141 -
142 - /**
143 - * Sanitize optional quick-action hint from frontend.
144 - *
145 - * @param mixed $action_hint Raw action hint.
146 - *
147 - * @return string|null
148 - */
149 - private function sanitize_action_hint( $action_hint ): ?string {
150 - if ( ! is_scalar( $action_hint ) ) {
151 - return null;
152 - }
153 -
154 - $normalized = strtolower( trim( (string) $action_hint ) );
155 - if ( '' === $normalized ) {
156 - return null;
157 - }
158 -
159 - $normalized = str_replace( '-', '_', $normalized );
160 -
161 - $aliases = array(
162 - 'quick_quiz' => 'quick_quiz',
163 - 'summarize' => 'summarize',
164 - 'explain' => 'explain',
165 - 'smart_review' => 'smart_review',
166 - );
167 -
168 - return $aliases[ $normalized ] ?? null;
169 - }
170 -
171 - /**
172 - * Sanitize active quick-quiz state from frontend.
173 - *
174 - * @param mixed $quiz_data
175 - *
176 - * @return array
177 - */
178 - private function sanitize_active_quiz_state( $quiz_data ): array {
179 - if ( ! is_array( $quiz_data ) ) {
180 - return array();
181 - }
182 -
183 - $questions = array();
184 - if ( ! empty( $quiz_data['questions'] ) && is_array( $quiz_data['questions'] ) ) {
185 - foreach ( $quiz_data['questions'] as $question ) {
186 - if ( ! is_array( $question ) ) {
187 - continue;
188 - }
189 -
190 - $options = array();
191 - if ( ! empty( $question['options'] ) && is_array( $question['options'] ) ) {
192 - foreach ( $question['options'] as $option ) {
193 - $options[] = sanitize_text_field( (string) $option );
194 - }
195 - }
196 -
197 - $questions[] = array(
198 - 'question' => sanitize_text_field( (string) ( $question['question'] ?? '' ) ),
199 - 'options' => $options,
200 - 'correct_index' => absint( $question['correct_index'] ?? 0 ),
201 - 'explanation' => sanitize_textarea_field( (string) ( $question['explanation'] ?? '' ) ),
202 - );
203 - }
204 - }
205 -
206 - return array(
207 - 'is_active' => ! empty( $quiz_data['is_active'] ),
208 - 'completed' => ! empty( $quiz_data['completed'] ),
209 - 'current_index' => absint( $quiz_data['current_index'] ?? 0 ),
210 - 'score' => absint( $quiz_data['score'] ?? 0 ),
211 - 'total' => absint( $quiz_data['total'] ?? count( $questions ) ),
212 - 'questions' => $questions,
213 - );
214 - }
215 -}
1 +<?php
2 +
3 +namespace LearnPress\AI\Assistant;
4 +
5 +use LearnPress\Models\CourseModel;
6 +use LearnPress\Models\PostModel;
7 +use LP_Settings;
8 +use LP_User;
9 +use LearnPress\Services\OpenAiService;
10 +use Exception;
11 +
12 +/**
13 + * AI Assistant Controller — validates requests, sanitizes input, calls Agent.
14 + *
15 + * Entry point for the AJAX layer. Guarantees the response structure
16 + * required by the frontend: { type, message, quiz }.
17 + *
18 + * @package LearnPress\AI\Assistant
19 + * @since 4.3.5
20 + */
21 +class AIAssistantController {
22 + private const ACTION_SETTINGS = array(
23 + 'summarize' => 'ai_assistant_summarize_enabled',
24 + 'explain' => 'ai_assistant_explain_enabled',
25 + 'quick_quiz' => 'ai_assistant_quick_quiz_enabled',
26 + 'smart_review' => 'ai_assistant_smart_review_enabled',
27 + );
28 +
29 + /**
30 + * Check if the AI Assistant feature is fully enabled.
31 + *
32 + * All three gates must pass:
33 + * - enable_open_ai = yes
34 + * - secret key exists
35 + * - ai_assistant_enabled = yes
36 + *
37 + * @return bool
38 + */
39 + public static function is_enabled(): bool {
40 + $service = OpenAiService::instance();
41 +
42 + if ( ! $service->is_enable() ) {
43 + return false;
44 + }
45 +
46 + if ( empty( LP_Settings::get_option( 'open_ai_secret_key', '' ) ) ) {
47 + return false;
48 + }
49 +
50 + return LP_Settings::get_option( 'ai_assistant_enabled', 'no' ) === 'yes';
51 + }
52 +
53 + /**
54 + * Resolve per-action enabled flags from admin settings.
55 + *
56 + * @return array<string, bool>
57 + */
58 + public static function get_enabled_actions(): array {
59 + $enabled_actions = array();
60 +
61 + foreach ( self::ACTION_SETTINGS as $action => $setting_key ) {
62 + $enabled_actions[ $action ] = LP_Settings::get_option( $setting_key, 'yes' ) === 'yes';
63 + }
64 +
65 + return $enabled_actions;
66 + }
67 +
68 + /**
69 + * Check whether a specific assistant action is enabled.
70 + *
71 + * @param string $action Action slug.
72 + *
73 + * @return bool
74 + */
75 + public static function is_action_enabled( string $action ): bool {
76 + $enabled_actions = self::get_enabled_actions();
77 +
78 + return $enabled_actions[ $action ] ?? true;
79 + }
80 +
81 + /**
82 + * Course item types the assistant can be grounded on.
83 + *
84 + * Deliberately excludes LP_QUESTION_CPT and every third-party item type: an item
85 + * type is only listed here once the assistant has a data loader and an access rule
86 + * for it. Not a filter — widening this is a code change, reviewed as such.
87 + *
88 + * Naming contract for the `LearnPress\AI\Assistant` namespace:
89 + * - `$item_id` — a course item ID whose type is not yet proven.
90 + * - `$item_type` — the resolved curriculum type: LP_LESSON_CPT or LP_QUIZ_CPT.
91 + * - `$lesson_id` — an `$item_id` already proven to be LP_LESSON_CPT.
92 + * - `$quiz_id` — an `$item_id` already proven to be LP_QUIZ_CPT.
93 + *
94 + * Only proven IDs may be passed to the DataLoaders layer.
95 + *
96 + * @return string[]
97 + */
98 + public static function get_supported_item_types(): array {
99 + return array( LP_LESSON_CPT, LP_QUIZ_CPT );
100 + }
101 +
102 + /**
103 + * Resolve and authorize the composite course-item identity for a request.
104 + *
105 + * A curriculum item is identified by the tuple (course_id, item_type, item_id).
106 + * `item_type` arrives from the client and is therefore untrusted: it selects which
107 + * typed lookup runs, and the lookup itself is what proves the tuple. It never grants
108 + * access on its own. Nothing is ever resolved from `item_id` alone.
109 + *
110 + * Used by both the AJAX controller and the template renderer so the two cannot drift.
111 + *
112 + * @param int $user_id Current user ID.
113 + * @param int $course_id Course the item is claimed to belong to.
114 + * @param string $item_type Raw item type from the request.
115 + * @param int $item_id Course item ID.
116 + *
117 + * @return array{course:CourseModel,item:PostModel,item_id:int,item_type:string} Trusted context.
118 + * @throws Exception When the tuple is invalid or the user may not view the item.
119 + */
120 + public static function resolve_item_access( int $user_id, int $course_id, string $item_type, int $item_id ): array {
121 + $denied = __( 'You do not have permission to use the AI Assistant for this course item.', 'learnpress' );
122 +
123 + // item_type is mandatory: without it there is no identity to verify.
124 + $item_type = sanitize_key( $item_type );
125 + if ( empty( $item_type ) || ! in_array( $item_type, self::get_supported_item_types(), true ) ) {
126 + throw new Exception(
127 + __( 'The AI Assistant is not available for this type of course item.', 'learnpress' )
128 + );
129 + }
130 +
131 + if ( $user_id <= 0 || $course_id <= 0 || $item_id <= 0 ) {
132 + throw new Exception( $denied );
133 + }
134 +
135 + $courseModel = CourseModel::find( $course_id, true );
136 + if ( ! $courseModel instanceof CourseModel ) {
137 + throw new Exception( $denied );
138 + }
139 +
140 + /**
141 + * Resolves through the supplied type AND asserts curriculum membership, so a
142 + * course_id/item_id pair from different courses cannot be combined, and a quiz
143 + * ID cannot be resolved as a lesson.
144 + */
145 + $itemModel = $courseModel->get_item_model( $item_id, $item_type, true );
146 + if ( ! $itemModel instanceof PostModel ) {
147 + throw new Exception( $denied );
148 + }
149 +
150 + // Reject drafts, pending, private and trashed items — get_item_model() does not filter status.
151 + if ( PostModel::STATUS_PUBLISH !== $itemModel->post_status ) {
152 + throw new Exception( $denied );
153 + }
154 +
155 + // Canonical LearnPress access policy: course-level gate, then the item-level rule
156 + // that lets preview items through. Both must pass.
157 + $user = learn_press_get_user( $user_id );
158 + if ( ! $user instanceof LP_User ) {
159 + throw new Exception( $denied );
160 + }
161 +
162 + $can_view_course = $user->can_view_content_course( $course_id );
163 + $can_view_item = $user->can_view_item( $item_id, $can_view_course );
164 + if ( empty( $can_view_item->flag ) ) {
165 + $message = (string) ( $can_view_item->message ?? '' );
166 +
167 + throw new Exception( '' !== $message ? $message : $denied );
168 + }
169 +
170 + return array(
171 + 'course' => $courseModel,
172 + 'item' => $itemModel,
173 + 'item_id' => $item_id,
174 + 'item_type' => $item_type,
175 + );
176 + }
177 +
178 + /**
179 + * Handle an assistant chat request.
180 + *
181 + * @param array $data Raw decoded data from the AJAX request.
182 + *
183 + * @return array{type: string, message: string, quiz: array|null}
184 + * @throws Exception On validation failure or denied access.
185 + * @throws Throwable On provider/transport failure — logged and masked by the AJAX layer.
186 + */
187 + public function handle_chat( array $data ): array {
188 + $message = trim( $data['message'] ?? '' );
189 + $item_id = absint( $data['item_id'] ?? 0 );
190 + $item_type = is_scalar( $data['item_type'] ?? null ) ? (string) $data['item_type'] : '';
191 + $course_id = absint( $data['course_id'] ?? 0 );
192 + $history = $data['history'] ?? array();
193 + $quiz_data = $data['active_quiz_questions'] ?? array();
194 + $action_hint = $this->sanitize_action_hint( $data['action_hint'] ?? '' );
195 +
196 + if ( $message === '' ) {
197 + throw new Exception( __( 'Message is required.', 'learnpress' ) );
198 + }
199 +
200 + if ( empty( $item_id ) ) {
201 + throw new Exception( __( 'Item ID is required.', 'learnpress' ) );
202 + }
203 +
204 + if ( $course_id === 0 ) {
205 + throw new Exception( __( 'Course ID is required.', 'learnpress' ) );
206 + }
207 +
208 + $user_id = get_current_user_id();
209 +
210 + if ( $user_id === 0 ) {
211 + throw new Exception( __( 'User must be logged in.', 'learnpress' ) );
212 + }
213 +
214 + /**
215 + * Authoritative gate. Runs before the Agent is constructed, so a denied request
216 + * costs no prompt construction, no quota accounting and no OpenAI call. The
217 + * widget markup check is advisory only — this endpoint is reachable directly.
218 + */
219 + $access = self::resolve_item_access( $user_id, $course_id, $item_type, $item_id );
220 + $item_type = $access['item_type'];
221 +
222 + // Sanitize history — only allow safe role/content pairs.
223 + $sanitized_history = array();
224 + if ( is_array( $history ) ) {
225 + foreach ( $history as $msg ) {
226 + $role = $msg['role'] ?? '';
227 + $content = $msg['content'] ?? '';
228 +
229 + if ( in_array( $role, array( 'user', 'assistant' ), true ) && is_string( $content ) ) {
230 + $sanitized_history[] = array(
231 + 'role' => $role,
232 + 'content' => sanitize_textarea_field( $content ),
233 + );
234 + }
235 + }
236 + }
237 +
238 + $agent = new Agent();
239 + $sanitized_quiz_state = $this->sanitize_active_quiz_state( $quiz_data );
240 +
241 + return $agent->run(
242 + sanitize_textarea_field( $message ),
243 + $item_id,
244 + $item_type,
245 + $course_id,
246 + $user_id,
247 + $sanitized_history,
248 + $sanitized_quiz_state,
249 + $action_hint
250 + );
251 + }
252 +
253 + /**
254 + * Sanitize optional quick-action hint from frontend.
255 + *
256 + * @param mixed $action_hint Raw action hint.
257 + *
258 + * @return string|null
259 + */
260 + private function sanitize_action_hint( $action_hint ): ?string {
261 + if ( ! is_scalar( $action_hint ) ) {
262 + return null;
263 + }
264 +
265 + $normalized = strtolower( trim( (string) $action_hint ) );
266 + if ( '' === $normalized ) {
267 + return null;
268 + }
269 +
270 + $normalized = str_replace( '-', '_', $normalized );
271 +
272 + $aliases = array(
273 + 'quick_quiz' => 'quick_quiz',
274 + 'summarize' => 'summarize',
275 + 'explain' => 'explain',
276 + 'smart_review' => 'smart_review',
277 + );
278 +
279 + return $aliases[ $normalized ] ?? null;
280 + }
281 +
282 + /**
283 + * Sanitize active quick-quiz state from frontend.
284 + *
285 + * @param mixed $quiz_data
286 + *
287 + * @return array
288 + */
289 + private function sanitize_active_quiz_state( $quiz_data ): array {
290 + if ( ! is_array( $quiz_data ) ) {
291 + return array();
292 + }
293 +
294 + $questions = array();
295 + if ( ! empty( $quiz_data['questions'] ) && is_array( $quiz_data['questions'] ) ) {
296 + foreach ( $quiz_data['questions'] as $question ) {
297 + if ( ! is_array( $question ) ) {
298 + continue;
299 + }
300 +
301 + $options = array();
302 + if ( ! empty( $question['options'] ) && is_array( $question['options'] ) ) {
303 + foreach ( $question['options'] as $option ) {
304 + $options[] = sanitize_text_field( (string) $option );
305 + }
306 + }
307 +
308 + $questions[] = array(
309 + 'question' => sanitize_text_field( (string) ( $question['question'] ?? '' ) ),
310 + 'options' => $options,
311 + 'correct_index' => absint( $question['correct_index'] ?? 0 ),
312 + 'explanation' => sanitize_textarea_field( (string) ( $question['explanation'] ?? '' ) ),
313 + );
314 + }
315 + }
316 +
317 + return array(
318 + 'is_active' => ! empty( $quiz_data['is_active'] ),
319 + 'completed' => ! empty( $quiz_data['completed'] ),
320 + 'current_index' => absint( $quiz_data['current_index'] ?? 0 ),
321 + 'score' => absint( $quiz_data['score'] ?? 0 ),
322 + 'total' => absint( $quiz_data['total'] ?? count( $questions ) ),
323 + 'questions' => $questions,
324 + );
325 + }
326 +}