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

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

327 lines 10.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\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 }
327