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/ResponseNormalizer.php +249 -249 4.3.9.14.4.8 View file →
@@ -1,249 +1,249 @@
1 -<?php
2 -
3 -namespace LearnPress\AI\Assistant;
4 -
5 -/**
6 - * ResponseNormalizer — decodes JSON model output and extracts human-readable text.
7 - *
8 - * Handles the common pattern where OpenAI returns JSON-wrapped text such as
9 - * {"message":"..."} and extracts the best text candidate for frontend rendering.
10 - *
11 - * @package LearnPress\AI\Assistant
12 - * @since 4.3.5
13 - */
14 -class ResponseNormalizer {
15 -
16 - /**
17 - * Decode JSON content safely without throwing exceptions.
18 - *
19 - * Accepts either pure JSON or content containing a JSON object substring.
20 - * Returns empty array when content is plain text or invalid JSON.
21 - *
22 - * @param string $content Raw model content.
23 - *
24 - * @return array
25 - */
26 - public function decode_json( string $content ): array {
27 - $content = trim( $content );
28 - if ( '' === $content ) {
29 - return array();
30 - }
31 -
32 - $decoded = json_decode( $content, true );
33 - if ( is_array( $decoded ) && JSON_ERROR_NONE === json_last_error() ) {
34 - return $decoded;
35 - }
36 -
37 - $first_brace = strpos( $content, '{' );
38 - $last_brace = strrpos( $content, '}' );
39 - if ( false === $first_brace || false === $last_brace || $last_brace <= $first_brace ) {
40 - return array();
41 - }
42 -
43 - $json_slice = substr( $content, $first_brace, $last_brace - $first_brace + 1 );
44 - if ( ! is_string( $json_slice ) || '' === $json_slice ) {
45 - return array();
46 - }
47 -
48 - $decoded = json_decode( $json_slice, true );
49 -
50 - return ( is_array( $decoded ) && JSON_ERROR_NONE === json_last_error() ) ? $decoded : array();
51 - }
52 -
53 - /**
54 - * Convert model JSON output into readable assistant text.
55 - *
56 - * @param string $content Raw model content.
57 - *
58 - * @return string
59 - */
60 - public function normalize( string $content ): string {
61 - $content = trim( $content );
62 - if ( '' === $content ) {
63 - return '';
64 - }
65 -
66 - $decoded = $this->decode_json( $content );
67 - if ( empty( $decoded ) ) {
68 - if ( $this->looks_like_metadata_value( $content ) ) {
69 - return __( 'I could not generate a complete answer. Please try rephrasing your request.', 'learnpress' );
70 - }
71 -
72 - return $content;
73 - }
74 -
75 - $extracted = $this->extract_text_from_array( $decoded );
76 -
77 - if ( '' !== $extracted ) {
78 - return $extracted;
79 - }
80 -
81 - return $this->looks_like_metadata_value( $content )
82 - ? __( 'I could not generate a complete answer. Please try rephrasing your request.', 'learnpress' )
83 - : $content;
84 - }
85 -
86 - /**
87 - * Build normalized response from assistant content.
88 - *
89 - * @param string $content The raw assistant text content.
90 - *
91 - * @return array{type: string, message: string, quiz: array|null}
92 - */
93 - public function build_response( string $content ): array {
94 - return array(
95 - 'type' => 'text',
96 - 'message' => $this->normalize( $content ),
97 - 'quiz' => null,
98 - );
99 - }
100 -
101 - /**
102 - * Extract first meaningful text value from a decoded JSON object.
103 - *
104 - * @param array $data Decoded JSON.
105 - *
106 - * @return string
107 - */
108 - private function extract_text_from_array( array $data ): string {
109 - $preferred_keys = array(
110 - 'message',
111 - 'answer',
112 - 'content',
113 - 'summary',
114 - 'explanation',
115 - 'text',
116 - 'response',
117 - 'result',
118 - 'key_points',
119 - 'main_points',
120 - 'bullet_points',
121 - );
122 -
123 - foreach ( $preferred_keys as $key ) {
124 - if ( empty( $data[ $key ] ) ) {
125 - continue;
126 - }
127 -
128 - $value = $data[ $key ];
129 - if ( is_string( $value ) && '' !== trim( $value ) ) {
130 - return trim( $value );
131 - }
132 -
133 - if ( is_array( $value ) ) {
134 - $nested = $this->extract_text_from_array( $value );
135 - if ( '' !== $nested ) {
136 - return $nested;
137 - }
138 - }
139 - }
140 -
141 - foreach ( $data as $key => $value ) {
142 - if ( $this->is_metadata_text_key( (string) $key ) ) {
143 - continue;
144 - }
145 -
146 - if ( is_array( $value ) ) {
147 - $nested = $this->extract_text_from_array( $value );
148 - if ( '' !== $nested ) {
149 - return $nested;
150 - }
151 - }
152 - }
153 -
154 - foreach ( $data as $key => $value ) {
155 - if ( $this->is_metadata_text_key( (string) $key ) || ! is_string( $value ) ) {
156 - continue;
157 - }
158 -
159 - $candidate = trim( $value );
160 - if ( '' === $candidate || $this->looks_like_metadata_value( $candidate ) ) {
161 - continue;
162 - }
163 -
164 - if ( $this->is_substantial_text( $candidate ) ) {
165 - return $candidate;
166 - }
167 - }
168 -
169 - return '';
170 - }
171 -
172 - /**
173 - * Determine whether a decoded JSON key is metadata and should not be shown as answer text.
174 - *
175 - * @param string $key JSON key.
176 - *
177 - * @return bool
178 - */
179 - private function is_metadata_text_key( string $key ): bool {
180 -
181 - $normalized = strtolower( trim( $key ) );
182 - if ( $normalized === '' ) {
183 - return false;
184 - }
185 -
186 - return in_array(
187 - $normalized,
188 - array(
189 - 'language',
190 - 'locale',
191 - 'intent',
192 - 'action',
193 - 'type',
194 - 'status',
195 - 'model',
196 - 'provider',
197 - 'metadata',
198 - ),
199 - true
200 - );
201 - }
202 -
203 - /**
204 - * Check whether a text candidate looks like metadata output.
205 - *
206 - * @param string $value Text candidate.
207 - *
208 - * @return bool
209 - */
210 - private function looks_like_metadata_value( string $value ): bool {
211 -
212 - $trimmed = trim( $value );
213 - if ( $trimmed === '' ) {
214 - return true;
215 - }
216 -
217 - if ( preg_match( '/^(language|locale|intent|action|type|status)\s*[:=]\s*[\w\-]+$/ui', $trimmed ) ) {
218 - return true;
219 - }
220 -
221 - return preg_match( '/^[a-z]{2}[-_][a-z]{2}$/i', $trimmed ) === 1;
222 - }
223 -
224 - /**
225 - * Determine whether candidate text is substantial enough for user-facing response.
226 - *
227 - * @param string $value Text candidate.
228 - *
229 - * @return bool
230 - */
231 - private function is_substantial_text( string $value ): bool {
232 -
233 - $trimmed = trim( $value );
234 - if ( $trimmed === '' ) {
235 - return false;
236 - }
237 -
238 - if ( preg_match( '/[.!?。!?]/u', $trimmed ) ) {
239 - return true;
240 - }
241 -
242 - $length = mb_strlen( $trimmed, 'UTF-8' );
243 - if ( $length >= 20 ) {
244 - return true;
245 - }
246 -
247 - return $length >= 14 && preg_match( '/\s/u', $trimmed ) === 1;
248 - }
249 -}
1 +<?php
2 +
3 +namespace LearnPress\AI\Assistant;
4 +
5 +/**
6 + * ResponseNormalizer — decodes JSON model output and extracts human-readable text.
7 + *
8 + * Handles the common pattern where OpenAI returns JSON-wrapped text such as
9 + * {"message":"..."} and extracts the best text candidate for frontend rendering.
10 + *
11 + * @package LearnPress\AI\Assistant
12 + * @since 4.3.5
13 + */
14 +class ResponseNormalizer {
15 +
16 + /**
17 + * Decode JSON content safely without throwing exceptions.
18 + *
19 + * Accepts either pure JSON or content containing a JSON object substring.
20 + * Returns empty array when content is plain text or invalid JSON.
21 + *
22 + * @param string $content Raw model content.
23 + *
24 + * @return array
25 + */
26 + public function decode_json( string $content ): array {
27 + $content = trim( $content );
28 + if ( '' === $content ) {
29 + return array();
30 + }
31 +
32 + $decoded = json_decode( $content, true );
33 + if ( is_array( $decoded ) && JSON_ERROR_NONE === json_last_error() ) {
34 + return $decoded;
35 + }
36 +
37 + $first_brace = strpos( $content, '{' );
38 + $last_brace = strrpos( $content, '}' );
39 + if ( false === $first_brace || false === $last_brace || $last_brace <= $first_brace ) {
40 + return array();
41 + }
42 +
43 + $json_slice = substr( $content, $first_brace, $last_brace - $first_brace + 1 );
44 + if ( ! is_string( $json_slice ) || '' === $json_slice ) {
45 + return array();
46 + }
47 +
48 + $decoded = json_decode( $json_slice, true );
49 +
50 + return ( is_array( $decoded ) && JSON_ERROR_NONE === json_last_error() ) ? $decoded : array();
51 + }
52 +
53 + /**
54 + * Convert model JSON output into readable assistant text.
55 + *
56 + * @param string $content Raw model content.
57 + *
58 + * @return string
59 + */
60 + public function normalize( string $content ): string {
61 + $content = trim( $content );
62 + if ( '' === $content ) {
63 + return '';
64 + }
65 +
66 + $decoded = $this->decode_json( $content );
67 + if ( empty( $decoded ) ) {
68 + if ( $this->looks_like_metadata_value( $content ) ) {
69 + return __( 'I could not generate a complete answer. Please try rephrasing your request.', 'learnpress' );
70 + }
71 +
72 + return $content;
73 + }
74 +
75 + $extracted = $this->extract_text_from_array( $decoded );
76 +
77 + if ( '' !== $extracted ) {
78 + return $extracted;
79 + }
80 +
81 + return $this->looks_like_metadata_value( $content )
82 + ? __( 'I could not generate a complete answer. Please try rephrasing your request.', 'learnpress' )
83 + : $content;
84 + }
85 +
86 + /**
87 + * Build normalized response from assistant content.
88 + *
89 + * @param string $content The raw assistant text content.
90 + *
91 + * @return array{type: string, message: string, quiz: array|null}
92 + */
93 + public function build_response( string $content ): array {
94 + return array(
95 + 'type' => 'text',
96 + 'message' => $this->normalize( $content ),
97 + 'quiz' => null,
98 + );
99 + }
100 +
101 + /**
102 + * Extract first meaningful text value from a decoded JSON object.
103 + *
104 + * @param array $data Decoded JSON.
105 + *
106 + * @return string
107 + */
108 + private function extract_text_from_array( array $data ): string {
109 + $preferred_keys = array(
110 + 'message',
111 + 'answer',
112 + 'content',
113 + 'summary',
114 + 'explanation',
115 + 'text',
116 + 'response',
117 + 'result',
118 + 'key_points',
119 + 'main_points',
120 + 'bullet_points',
121 + );
122 +
123 + foreach ( $preferred_keys as $key ) {
124 + if ( empty( $data[ $key ] ) ) {
125 + continue;
126 + }
127 +
128 + $value = $data[ $key ];
129 + if ( is_string( $value ) && '' !== trim( $value ) ) {
130 + return trim( $value );
131 + }
132 +
133 + if ( is_array( $value ) ) {
134 + $nested = $this->extract_text_from_array( $value );
135 + if ( '' !== $nested ) {
136 + return $nested;
137 + }
138 + }
139 + }
140 +
141 + foreach ( $data as $key => $value ) {
142 + if ( $this->is_metadata_text_key( (string) $key ) ) {
143 + continue;
144 + }
145 +
146 + if ( is_array( $value ) ) {
147 + $nested = $this->extract_text_from_array( $value );
148 + if ( '' !== $nested ) {
149 + return $nested;
150 + }
151 + }
152 + }
153 +
154 + foreach ( $data as $key => $value ) {
155 + if ( $this->is_metadata_text_key( (string) $key ) || ! is_string( $value ) ) {
156 + continue;
157 + }
158 +
159 + $candidate = trim( $value );
160 + if ( '' === $candidate || $this->looks_like_metadata_value( $candidate ) ) {
161 + continue;
162 + }
163 +
164 + if ( $this->is_substantial_text( $candidate ) ) {
165 + return $candidate;
166 + }
167 + }
168 +
169 + return '';
170 + }
171 +
172 + /**
173 + * Determine whether a decoded JSON key is metadata and should not be shown as answer text.
174 + *
175 + * @param string $key JSON key.
176 + *
177 + * @return bool
178 + */
179 + private function is_metadata_text_key( string $key ): bool {
180 +
181 + $normalized = strtolower( trim( $key ) );
182 + if ( $normalized === '' ) {
183 + return false;
184 + }
185 +
186 + return in_array(
187 + $normalized,
188 + array(
189 + 'language',
190 + 'locale',
191 + 'intent',
192 + 'action',
193 + 'type',
194 + 'status',
195 + 'model',
196 + 'provider',
197 + 'metadata',
198 + ),
199 + true
200 + );
201 + }
202 +
203 + /**
204 + * Check whether a text candidate looks like metadata output.
205 + *
206 + * @param string $value Text candidate.
207 + *
208 + * @return bool
209 + */
210 + private function looks_like_metadata_value( string $value ): bool {
211 +
212 + $trimmed = trim( $value );
213 + if ( $trimmed === '' ) {
214 + return true;
215 + }
216 +
217 + if ( preg_match( '/^(language|locale|intent|action|type|status)\s*[:=]\s*[\w\-]+$/ui', $trimmed ) ) {
218 + return true;
219 + }
220 +
221 + return preg_match( '/^[a-z]{2}[-_][a-z]{2}$/i', $trimmed ) === 1;
222 + }
223 +
224 + /**
225 + * Determine whether candidate text is substantial enough for user-facing response.
226 + *
227 + * @param string $value Text candidate.
228 + *
229 + * @return bool
230 + */
231 + private function is_substantial_text( string $value ): bool {
232 +
233 + $trimmed = trim( $value );
234 + if ( $trimmed === '' ) {
235 + return false;
236 + }
237 +
238 + if ( preg_match( '/[.!?。!?]/u', $trimmed ) ) {
239 + return true;
240 + }
241 +
242 + $length = mb_strlen( $trimmed, 'UTF-8' );
243 + if ( $length >= 20 ) {
244 + return true;
245 + }
246 +
247 + return $length >= 14 && preg_match( '/\s/u', $trimmed ) === 1;
248 + }
249 +}