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/IntentClassifier.php +251 -251 4.3.9.14.4.8 View file →
@@ -1,251 +1,251 @@
1 -<?php
2 -
3 -namespace LearnPress\AI\Assistant;
4 -
5 -use LearnPress\Services\OpenAiService;
6 -
7 -/**
8 - * IntentClassifier — classifies learner intent via OpenAI.
9 - *
10 - * Accepts raw learner input + conversation history and returns one of the
11 - * supported intent slugs (summarize, explain, quick_quiz, smart_review, general).
12 - *
13 - * @package LearnPress\AI\Assistant
14 - * @since 4.3.5
15 - */
16 -class IntentClassifier {
17 -
18 - public const INTENT_SUMMARIZE = 'summarize';
19 - public const INTENT_EXPLAIN = 'explain';
20 - public const INTENT_QUICK_QUIZ = 'quick_quiz';
21 - public const INTENT_SMART_REVIEW = 'smart_review';
22 - public const INTENT_GENERAL = 'general';
23 - private const HISTORY_LIMIT = 3;
24 - private const HISTORY_CONTENT_MAX_CHARS = 300;
25 -
26 - private TokenQuotaGuard $quota_guard;
27 - private ResponseNormalizer $normalizer;
28 -
29 - public function __construct( TokenQuotaGuard $quota_guard, ResponseNormalizer $normalizer ) {
30 - $this->quota_guard = $quota_guard;
31 - $this->normalizer = $normalizer;
32 - }
33 -
34 - /**
35 - * Classify learner intent.
36 - *
37 - * Returns self::INTENT_GENERAL when classifier is inconclusive or on error.
38 - *
39 - * @param string $message Learner input.
40 - * @param array $history Conversation history.
41 - * @param int $item_id Current item ID (lesson or quiz).
42 - * @param int $course_id Current course ID.
43 - * @param int $user_id Current user ID.
44 - *
45 - * @return string One of the INTENT_* constants.
46 - */
47 - public function classify( string $message, array $history, int $item_id, int $course_id, int $user_id ): string {
48 -
49 - $detected = $this->classify_with_openai( $message, $history, $item_id, $course_id, $user_id );
50 - if ( $this->is_supported_intent( $detected ) ) {
51 - return $detected;
52 - }
53 -
54 - return self::INTENT_GENERAL;
55 - }
56 -
57 - /**
58 - * Return whether intent is one of the known values.
59 - *
60 - * @param string $intent Intent slug.
61 - *
62 - * @return bool
63 - */
64 - public function is_supported_intent( string $intent ): bool {
65 -
66 - return in_array(
67 - $intent,
68 - array(
69 - self::INTENT_SUMMARIZE,
70 - self::INTENT_EXPLAIN,
71 - self::INTENT_QUICK_QUIZ,
72 - self::INTENT_SMART_REVIEW,
73 - self::INTENT_GENERAL,
74 - ),
75 - true
76 - );
77 - }
78 -
79 - // ----------------------------------------------------------------
80 - // Private helpers
81 - // ----------------------------------------------------------------
82 -
83 - /**
84 - * Ask OpenAI to classify learner intent from natural language.
85 - *
86 - * @param string $message Learner input.
87 - * @param array $history Conversation history.
88 - * @param int $item_id Current item ID.
89 - * @param int $course_id Current course ID.
90 - * @param int $user_id Current user ID.
91 - *
92 - * @return string
93 - */
94 - private function classify_with_openai( string $message, array $history, int $item_id, int $course_id, int $user_id ): string {
95 -
96 - $service = OpenAiService::instance();
97 - $item_type = (string) get_post_type( $item_id );
98 - $conversation_slice = $this->slice_recent_history( $history, self::HISTORY_LIMIT );
99 - $context_payload = array(
100 - 'item_id' => $item_id,
101 - 'course_id' => $course_id,
102 - 'item_type' => $item_type,
103 - );
104 -
105 - $messages = array(
106 - array(
107 - 'role' => 'system',
108 - 'content' => __( 'You classify learner intent for LearnPress AI Assistant.', 'learnpress' ),
109 - ),
110 - array(
111 - 'role' => 'system',
112 - 'content' => __( 'Return ONLY valid JSON object in this exact shape: {"intent":"<value>"}. Allowed values: summarize, explain, quick_quiz, smart_review, general. Do not add extra keys.', 'learnpress' ),
113 - ),
114 - array(
115 - 'role' => 'system',
116 - 'content' => __( 'Rules: use smart_review only when the current item type is quiz and the learner asks for quiz-result feedback. If uncertain, return general.', 'learnpress' ),
117 - ),
118 - array(
119 - 'role' => 'system',
120 - 'content' => sprintf(
121 - /* translators: %s: JSON context payload for intent classification. */
122 - __( 'Intent context (JSON): %s', 'learnpress' ),
123 - wp_json_encode( $context_payload )
124 - ),
125 - ),
126 - );
127 -
128 - foreach ( $conversation_slice as $item ) {
129 - $messages[] = array(
130 - 'role' => $item['role'],
131 - 'content' => $item['content'],
132 - );
133 - }
134 -
135 - $messages[] = array(
136 - 'role' => 'user',
137 - 'content' => $message,
138 - );
139 -
140 - try {
141 - $response = $this->quota_guard->send_chat_with_guard( $service, $messages, $user_id );
142 - } catch ( \Throwable $e ) {
143 - return '';
144 - }
145 -
146 - return $this->parse_intent_from_response( (string) ( $response['content'] ?? '' ) );
147 - }
148 -
149 - /**
150 - * Parse classifier JSON response into a supported intent string.
151 - *
152 - * @param string $content Raw OpenAI content.
153 - *
154 - * @return string
155 - */
156 - private function parse_intent_from_response( string $content ): string {
157 -
158 - $content = trim( $content );
159 - if ( $content === '' ) {
160 - return '';
161 - }
162 -
163 - $decoded = $this->normalizer->decode_json( $content );
164 - if ( ! empty( $decoded ) ) {
165 - $intent_raw = '';
166 - foreach ( array( 'intent', 'action', 'type' ) as $key ) {
167 - if ( isset( $decoded[ $key ] ) && is_string( $decoded[ $key ] ) ) {
168 - $intent_raw = $decoded[ $key ];
169 - break;
170 - }
171 - }
172 -
173 - if ( $intent_raw !== '' ) {
174 - return $this->normalize_intent_value( $intent_raw );
175 - }
176 - }
177 -
178 - if ( preg_match( '/\b(summarize|summary|explain|quick[\s_-]*quiz|quickquiz|smart[\s_-]*review|general)\b/ui', $content, $matches ) ) {
179 - return $this->normalize_intent_value( (string) $matches[1] );
180 - }
181 -
182 - return '';
183 - }
184 -
185 - /**
186 - * Normalize classifier intent value to one of supported constants.
187 - *
188 - * @param string $intent Raw intent value from classifier.
189 - *
190 - * @return string
191 - */
192 - private function normalize_intent_value( string $intent ): string {
193 -
194 - $normalized = strtolower( trim( $intent ) );
195 - $normalized = preg_replace( '/\s+/', '_', $normalized ) ?? $normalized;
196 - $normalized = str_replace( '-', '_', $normalized );
197 -
198 - $aliases = array(
199 - 'quickquiz' => self::INTENT_QUICK_QUIZ,
200 - 'quick_quiz' => self::INTENT_QUICK_QUIZ,
201 - 'quiz' => self::INTENT_QUICK_QUIZ,
202 - 'review' => self::INTENT_SMART_REVIEW,
203 - 'summary' => self::INTENT_SUMMARIZE,
204 - 'smartreview' => self::INTENT_SMART_REVIEW,
205 - 'smart_review' => self::INTENT_SMART_REVIEW,
206 - );
207 -
208 - if ( isset( $aliases[ $normalized ] ) ) {
209 - $normalized = $aliases[ $normalized ];
210 - }
211 -
212 - return $this->is_supported_intent( $normalized ) ? $normalized : '';
213 - }
214 -
215 - /**
216 - * Keep only recent and valid chat history rows.
217 - *
218 - * @param array $history Chat history.
219 - * @param int $limit Number of rows to keep from the end.
220 - *
221 - * @return array<int, array{role: string, content: string}>
222 - */
223 - private function slice_recent_history( array $history, int $limit ): array {
224 -
225 - $sanitized = array();
226 -
227 - foreach ( $history as $item ) {
228 - if ( ! is_array( $item ) ) {
229 - continue;
230 - }
231 -
232 - $role = (string) ( $item['role'] ?? '' );
233 - $content = trim( (string) ( $item['content'] ?? '' ) );
234 -
235 - if ( ! in_array( $role, array( 'user', 'assistant' ), true ) || $content === '' ) {
236 - continue;
237 - }
238 -
239 - $sanitized[] = array(
240 - 'role' => $role,
241 - 'content' => mb_substr( $content, 0, self::HISTORY_CONTENT_MAX_CHARS, 'UTF-8' ),
242 - );
243 - }
244 -
245 - if ( empty( $sanitized ) ) {
246 - return array();
247 - }
248 -
249 - return array_slice( $sanitized, -1 * max( 1, $limit ) );
250 - }
251 -}
1 +<?php
2 +
3 +namespace LearnPress\AI\Assistant;
4 +
5 +use LearnPress\Services\OpenAiService;
6 +
7 +/**
8 + * IntentClassifier — classifies learner intent via OpenAI.
9 + *
10 + * Accepts raw learner input + conversation history and returns one of the
11 + * supported intent slugs (summarize, explain, quick_quiz, smart_review, general).
12 + *
13 + * @package LearnPress\AI\Assistant
14 + * @since 4.3.5
15 + */
16 +class IntentClassifier {
17 +
18 + public const INTENT_SUMMARIZE = 'summarize';
19 + public const INTENT_EXPLAIN = 'explain';
20 + public const INTENT_QUICK_QUIZ = 'quick_quiz';
21 + public const INTENT_SMART_REVIEW = 'smart_review';
22 + public const INTENT_GENERAL = 'general';
23 + private const HISTORY_LIMIT = 3;
24 + private const HISTORY_CONTENT_MAX_CHARS = 300;
25 +
26 + private TokenQuotaGuard $quota_guard;
27 + private ResponseNormalizer $normalizer;
28 +
29 + public function __construct( TokenQuotaGuard $quota_guard, ResponseNormalizer $normalizer ) {
30 + $this->quota_guard = $quota_guard;
31 + $this->normalizer = $normalizer;
32 + }
33 +
34 + /**
35 + * Classify learner intent.
36 + *
37 + * Returns self::INTENT_GENERAL when classifier is inconclusive or on error.
38 + *
39 + * @param string $message Learner input.
40 + * @param array $history Conversation history.
41 + * @param int $item_id Current item ID (lesson or quiz).
42 + * @param int $course_id Current course ID.
43 + * @param int $user_id Current user ID.
44 + *
45 + * @return string One of the INTENT_* constants.
46 + */
47 + public function classify( string $message, array $history, int $item_id, int $course_id, int $user_id ): string {
48 +
49 + $detected = $this->classify_with_openai( $message, $history, $item_id, $course_id, $user_id );
50 + if ( $this->is_supported_intent( $detected ) ) {
51 + return $detected;
52 + }
53 +
54 + return self::INTENT_GENERAL;
55 + }
56 +
57 + /**
58 + * Return whether intent is one of the known values.
59 + *
60 + * @param string $intent Intent slug.
61 + *
62 + * @return bool
63 + */
64 + public function is_supported_intent( string $intent ): bool {
65 +
66 + return in_array(
67 + $intent,
68 + array(
69 + self::INTENT_SUMMARIZE,
70 + self::INTENT_EXPLAIN,
71 + self::INTENT_QUICK_QUIZ,
72 + self::INTENT_SMART_REVIEW,
73 + self::INTENT_GENERAL,
74 + ),
75 + true
76 + );
77 + }
78 +
79 + // ----------------------------------------------------------------
80 + // Private helpers
81 + // ----------------------------------------------------------------
82 +
83 + /**
84 + * Ask OpenAI to classify learner intent from natural language.
85 + *
86 + * @param string $message Learner input.
87 + * @param array $history Conversation history.
88 + * @param int $item_id Current item ID.
89 + * @param int $course_id Current course ID.
90 + * @param int $user_id Current user ID.
91 + *
92 + * @return string
93 + */
94 + private function classify_with_openai( string $message, array $history, int $item_id, int $course_id, int $user_id ): string {
95 +
96 + $service = OpenAiService::instance();
97 + $item_type = (string) get_post_type( $item_id );
98 + $conversation_slice = $this->slice_recent_history( $history, self::HISTORY_LIMIT );
99 + $context_payload = array(
100 + 'item_id' => $item_id,
101 + 'course_id' => $course_id,
102 + 'item_type' => $item_type,
103 + );
104 +
105 + $messages = array(
106 + array(
107 + 'role' => 'system',
108 + 'content' => __( 'You classify learner intent for LearnPress AI Assistant.', 'learnpress' ),
109 + ),
110 + array(
111 + 'role' => 'system',
112 + 'content' => __( 'Return ONLY valid JSON object in this exact shape: {"intent":"<value>"}. Allowed values: summarize, explain, quick_quiz, smart_review, general. Do not add extra keys.', 'learnpress' ),
113 + ),
114 + array(
115 + 'role' => 'system',
116 + 'content' => __( 'Rules: use smart_review only when the current item type is quiz and the learner asks for quiz-result feedback. If uncertain, return general.', 'learnpress' ),
117 + ),
118 + array(
119 + 'role' => 'system',
120 + 'content' => sprintf(
121 + /* translators: %s: JSON context payload for intent classification. */
122 + __( 'Intent context (JSON): %s', 'learnpress' ),
123 + wp_json_encode( $context_payload )
124 + ),
125 + ),
126 + );
127 +
128 + foreach ( $conversation_slice as $item ) {
129 + $messages[] = array(
130 + 'role' => $item['role'],
131 + 'content' => $item['content'],
132 + );
133 + }
134 +
135 + $messages[] = array(
136 + 'role' => 'user',
137 + 'content' => $message,
138 + );
139 +
140 + try {
141 + $response = $this->quota_guard->send_chat_with_guard( $service, $messages, $user_id );
142 + } catch ( \Throwable $e ) {
143 + return '';
144 + }
145 +
146 + return $this->parse_intent_from_response( (string) ( $response['content'] ?? '' ) );
147 + }
148 +
149 + /**
150 + * Parse classifier JSON response into a supported intent string.
151 + *
152 + * @param string $content Raw OpenAI content.
153 + *
154 + * @return string
155 + */
156 + private function parse_intent_from_response( string $content ): string {
157 +
158 + $content = trim( $content );
159 + if ( $content === '' ) {
160 + return '';
161 + }
162 +
163 + $decoded = $this->normalizer->decode_json( $content );
164 + if ( ! empty( $decoded ) ) {
165 + $intent_raw = '';
166 + foreach ( array( 'intent', 'action', 'type' ) as $key ) {
167 + if ( isset( $decoded[ $key ] ) && is_string( $decoded[ $key ] ) ) {
168 + $intent_raw = $decoded[ $key ];
169 + break;
170 + }
171 + }
172 +
173 + if ( $intent_raw !== '' ) {
174 + return $this->normalize_intent_value( $intent_raw );
175 + }
176 + }
177 +
178 + if ( preg_match( '/\b(summarize|summary|explain|quick[\s_-]*quiz|quickquiz|smart[\s_-]*review|general)\b/ui', $content, $matches ) ) {
179 + return $this->normalize_intent_value( (string) $matches[1] );
180 + }
181 +
182 + return '';
183 + }
184 +
185 + /**
186 + * Normalize classifier intent value to one of supported constants.
187 + *
188 + * @param string $intent Raw intent value from classifier.
189 + *
190 + * @return string
191 + */
192 + private function normalize_intent_value( string $intent ): string {
193 +
194 + $normalized = strtolower( trim( $intent ) );
195 + $normalized = preg_replace( '/\s+/', '_', $normalized ) ?? $normalized;
196 + $normalized = str_replace( '-', '_', $normalized );
197 +
198 + $aliases = array(
199 + 'quickquiz' => self::INTENT_QUICK_QUIZ,
200 + 'quick_quiz' => self::INTENT_QUICK_QUIZ,
201 + 'quiz' => self::INTENT_QUICK_QUIZ,
202 + 'review' => self::INTENT_SMART_REVIEW,
203 + 'summary' => self::INTENT_SUMMARIZE,
204 + 'smartreview' => self::INTENT_SMART_REVIEW,
205 + 'smart_review' => self::INTENT_SMART_REVIEW,
206 + );
207 +
208 + if ( isset( $aliases[ $normalized ] ) ) {
209 + $normalized = $aliases[ $normalized ];
210 + }
211 +
212 + return $this->is_supported_intent( $normalized ) ? $normalized : '';
213 + }
214 +
215 + /**
216 + * Keep only recent and valid chat history rows.
217 + *
218 + * @param array $history Chat history.
219 + * @param int $limit Number of rows to keep from the end.
220 + *
221 + * @return array<int, array{role: string, content: string}>
222 + */
223 + private function slice_recent_history( array $history, int $limit ): array {
224 +
225 + $sanitized = array();
226 +
227 + foreach ( $history as $item ) {
228 + if ( ! is_array( $item ) ) {
229 + continue;
230 + }
231 +
232 + $role = (string) ( $item['role'] ?? '' );
233 + $content = trim( (string) ( $item['content'] ?? '' ) );
234 +
235 + if ( ! in_array( $role, array( 'user', 'assistant' ), true ) || $content === '' ) {
236 + continue;
237 + }
238 +
239 + $sanitized[] = array(
240 + 'role' => $role,
241 + 'content' => mb_substr( $content, 0, self::HISTORY_CONTENT_MAX_CHARS, 'UTF-8' ),
242 + );
243 + }
244 +
245 + if ( empty( $sanitized ) ) {
246 + return array();
247 + }
248 +
249 + return array_slice( $sanitized, -1 * max( 1, $limit ) );
250 + }
251 +}