PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / _inc / lib / class-jetpack-ai-helper.php

class-jetpack-ai-helper.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.3, at _inc/lib/class-jetpack-ai-helper.php

530 lines 15.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * API helper for the AI blocks.
4 *
5 * @package automattic/jetpack
6 * @since 11.8
7 */
8
9 use Automattic\Jetpack\Connection\Client;
10 use Automattic\Jetpack\Connection\Manager;
11 use Automattic\Jetpack\Search\Plan as Search_Plan;
12 use Automattic\Jetpack\Status;
13 use Automattic\Jetpack\Status\Visitor;
14
15 // Required directly rather than relying on the plugin bootstrap: on
16 // WordPress.com Simple this helper is loaded outside load-jetpack.php
17 // (see the GUIDELINES_BANNER_DISMISSED_META_KEY note below).
18 require_once __DIR__ . '/class-jetpack-ai-settings.php';
19
20 /**
21 * Class Jetpack_AI_Helper
22 *
23 * @since 11.8
24 */
25 class Jetpack_AI_Helper {
26 /**
27 * User meta key storing whether the user dismissed the Content Guidelines
28 * AI empty-state banner. Written by the guidelines-banner-dismissed REST
29 * endpoint and read by the Content Guidelines admin-page preload.
30 *
31 * Lives here rather than on the endpoint class because on WordPress.com
32 * Simple the wpcom-endpoints classes are only loaded in REST requests,
33 * while the preload needs the key during admin page loads.
34 *
35 * @since 16.1
36 *
37 * @var string
38 */
39 const GUIDELINES_BANNER_DISMISSED_META_KEY = 'jetpack_content_guidelines_ai_banner_dismissed';
40
41 /**
42 * Allow new completion every X seconds. Will return cached result otherwise.
43 *
44 * @var int
45 */
46 public static $text_completion_cooldown_seconds = 15;
47
48 /**
49 * Cache images for a prompt for a month.
50 *
51 * @var int
52 */
53 public static $image_generation_cache_timeout = MONTH_IN_SECONDS;
54
55 /**
56 * Cache AI-assistant feature for 60 seconds.
57 *
58 * @var int
59 */
60 public static $ai_assistant_feature_cache_timeout = 60;
61
62 /**
63 * Cache AI-assistant errors for ten seconds.
64 *
65 * @var int
66 */
67 public static $ai_assistant_feature_error_cache_timeout = 10;
68
69 /**
70 * Stores the number of JetpackAI calls in case we want to mark AI-assisted posts some way.
71 *
72 * @var int
73 */
74 public static $post_meta_with_ai_generation_number = '_jetpack_ai_calls';
75
76 /**
77 * Storing the error to prevent repeated requests to WPCOM after failure.
78 *
79 * @var null|WP_Error
80 */
81 private static $ai_assistant_failed_request = null;
82
83 /**
84 * Checks if a given request is allowed to get AI data from WordPress.com.
85 *
86 * @param WP_REST_Request $request Full details about the request.
87 *
88 * @return true|WP_Error True if the request has access, WP_Error object otherwise.
89 */
90 public static function get_status_permission_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
91
92 /*
93 * This may need to be updated
94 * to take into account the different ways we can make requests
95 * (from a WordPress.com site, from a Jetpack site).
96 */
97 if ( ! current_user_can( 'edit_posts' ) ) {
98 return new WP_Error(
99 'rest_forbidden',
100 __( 'Sorry, you are not allowed to access Jetpack AI help on this site.', 'jetpack' ),
101 array( 'status' => rest_authorization_required_code() )
102 );
103 }
104
105 return true;
106 }
107
108 /**
109 * Return true if these features should be active on the current site.
110 * Currently, it's limited to WPCOM Simple and Atomic.
111 */
112 public static function is_enabled() {
113 $default = false;
114
115 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
116 $default = true;
117 } elseif ( ( new Automattic\Jetpack\Status\Host() )->is_woa_site() ) {
118 $default = true;
119 }
120
121 // The jetpack_ai_enabled filter runs inside the helper; the host and
122 // master gates apply after the chain and cannot be filtered back on.
123 return Jetpack_AI_Settings::is_ai_enabled( $default );
124 }
125
126 /**
127 * Return true if the Content Guidelines AI surfaces should be active on the
128 * current site.
129 *
130 * Same platforms as is_enabled() (WPCOM Simple and Atomic), plus WordPress
131 * VIP sites. Kept separate from is_enabled() so widening Content Guidelines
132 * to VIP does not also open the general AI proxy endpoint there.
133 *
134 * @since 16.2
135 *
136 * @return bool
137 */
138 public static function is_enabled_for_content_guidelines() {
139 $default = false;
140
141 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
142 $default = true;
143 } else {
144 $host = new Automattic\Jetpack\Status\Host();
145 if ( $host->is_woa_site() || $host->is_vip_site() ) {
146 $default = true;
147 }
148 }
149
150 // The jetpack_ai_enabled filter runs inside the helper; the host and
151 // master gates apply after the chain and cannot be filtered back on.
152 return Jetpack_AI_Settings::is_ai_enabled( $default );
153 }
154
155 /**
156 * Whether the current user has dismissed the Content Guidelines AI
157 * empty-state banner.
158 *
159 * @since 16.1
160 *
161 * @return bool
162 */
163 public static function is_guidelines_banner_dismissed() {
164 return (bool) get_user_meta( get_current_user_id(), self::GUIDELINES_BANNER_DISMISSED_META_KEY, true );
165 }
166
167 /**
168 * Return true if the AI chat feature should be active on the current site.
169 *
170 * @todo IS_WPCOM (the endpoints need to be updated too).
171 *
172 * @return bool
173 */
174 public static function is_ai_chat_enabled() {
175 $default = false;
176
177 $connection = new Manager();
178 $plan = new Search_Plan();
179 if ( $connection->is_connected() && $plan->supports_search() ) {
180 $default = true;
181 }
182
183 /**
184 * Filter whether the AI chat feature is enabled in the Jetpack plugin.
185 *
186 * @since 12.6
187 *
188 * @param bool $default Is AI chat enabled? Defaults to false.
189 */
190 return apply_filters( 'jetpack_ai_chat_enabled', $default );
191 }
192
193 /**
194 * Get the name of the transient for image generation. Unique per prompt and allows for reuse of results for the same prompt across entire WPCOM.
195 * I expext "puppy" to always be from cache.
196 *
197 * @param string $prompt - Supplied prompt.
198 */
199 public static function transient_name_for_image_generation( $prompt ) {
200 return 'jetpack_openai_image_' . md5( $prompt );
201 }
202
203 /**
204 * Get the name of the transient for text completion. Unique per user, but not per text. Serves more as a cooldown.
205 */
206 public static function transient_name_for_completion() {
207 return 'jetpack_openai_completion_' . get_current_user_id(); // Cache for each user, so that other users dont get weird cached version from somebody else.
208 }
209
210 /**
211 * Get the name of the transient for AI assistance feature. Unique per user.
212 *
213 * @param int $blog_id - Blog ID to get the transient name for.
214 * @return string
215 */
216 public static function transient_name_for_ai_assistance_feature( $blog_id ) {
217 return 'jetpack_openai_ai_assistance_feature_' . $blog_id;
218 }
219
220 /**
221 * Mark the edited post as "touched" by AI stuff.
222 *
223 * @param int $post_id Post ID for which the content is being generated.
224 * @return void
225 */
226 private static function mark_post_as_ai_assisted( $post_id ) {
227 if ( ! $post_id ) {
228 return;
229 }
230 $previous = get_post_meta( $post_id, self::$post_meta_with_ai_generation_number, true );
231 if ( ! $previous ) {
232 $previous = 0;
233 } elseif ( ! is_numeric( $previous ) ) {
234 // Data corrupted, nothing to do.
235 return;
236 }
237 $new_value = intval( $previous ) + 1;
238 update_post_meta( $post_id, self::$post_meta_with_ai_generation_number, $new_value );
239 }
240
241 /**
242 * Get text back from WordPress.com based off a starting text.
243 *
244 * @param string $content The content provided to send to the AI.
245 * @param int $post_id Post ID for which the content is being generated.
246 * @param bool $skip_cache Skip cache and force a new request.
247 * @return mixed
248 */
249 public static function get_gpt_completion( $content, $post_id, $skip_cache = false ) {
250 $content = wp_strip_all_tags( $content );
251 $cache = get_transient( self::transient_name_for_completion() );
252 if ( $cache && ! $skip_cache ) {
253 return $cache;
254 }
255
256 if ( ( new Status() )->is_offline_mode() ) {
257 return new WP_Error(
258 'dev_mode',
259 __( 'Jetpack AI is not available in offline mode.', 'jetpack' )
260 );
261 }
262
263 $site_id = Manager::get_site_id();
264 if ( is_wp_error( $site_id ) ) {
265 return $site_id;
266 }
267
268 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
269 if ( ! class_exists( 'OpenAI' ) ) {
270 \require_lib( 'openai' );
271 }
272
273 // Set the content for chatGPT endpoint
274 $data = array(
275 array(
276 'role' => 'user',
277 'content' => $content,
278 ),
279 );
280
281 $openai = new OpenAI( 'openai', array( 'post_id' => $post_id ) );
282 $moderation_result = $openai->moderate(
283 implode(
284 ' ',
285 array_map(
286 function ( $msg ) {
287 return $msg['role'] === 'user' ? $msg['content'] : '';
288 },
289 $data
290 )
291 )
292 );
293
294 if ( is_wp_error( $moderation_result ) ) {
295 return $moderation_result;
296 }
297
298 $max_tokens = 480; // Default
299 $result = $openai->request_chat_completion( $data, $max_tokens );
300
301 if ( is_wp_error( $result ) ) {
302 return $result;
303 }
304
305 $response = $result->choices[0]->message->content;
306
307 // In case of Jetpack we are setting a transient on the WPCOM and not the remote site. I think the 'get_current_user_id' may default for the connection owner at this point but we'll deal with this later.
308 set_transient( self::transient_name_for_completion(), $response, self::$text_completion_cooldown_seconds );
309 self::mark_post_as_ai_assisted( $post_id );
310 return $response;
311 }
312
313 $response = Client::wpcom_json_api_request_as_user(
314 sprintf( '/sites/%d/jetpack-ai/completions', $site_id ),
315 2,
316 array(
317 'method' => 'post',
318 'headers' => array( 'content-type' => 'application/json' ),
319 ),
320 wp_json_encode(
321 array(
322 'content' => $content,
323 ),
324 JSON_UNESCAPED_SLASHES
325 ),
326 'wpcom'
327 );
328
329 if ( is_wp_error( $response ) ) {
330 return $response;
331 }
332
333 $data = json_decode( wp_remote_retrieve_body( $response ) );
334
335 if ( wp_remote_retrieve_response_code( $response ) >= 400 ) {
336 return new WP_Error( $data->code, $data->message, $data->data );
337 }
338
339 // Do not cache if it should be skipped.
340 if ( ! $skip_cache ) {
341 set_transient( self::transient_name_for_completion(), $data, self::$text_completion_cooldown_seconds );
342 }
343 self::mark_post_as_ai_assisted( $post_id );
344
345 return $data;
346 }
347
348 /**
349 * Get an array of image objects back from WordPress.com based off a prompt.
350 *
351 * @param string $prompt The prompt to generate images for.
352 * @param int $post_id Post ID for which the content is being generated.
353 * @return mixed
354 */
355 public static function get_dalle_generation( $prompt, $post_id ) {
356 $cache = get_transient( self::transient_name_for_image_generation( $prompt ) );
357 if ( $cache ) {
358 self::mark_post_as_ai_assisted( $post_id );
359 return $cache;
360 }
361
362 if ( ( new Status() )->is_offline_mode() ) {
363 return new WP_Error(
364 'dev_mode',
365 __( 'Jetpack AI is not available in offline mode.', 'jetpack' )
366 );
367 }
368
369 $site_id = Manager::get_site_id();
370 if ( is_wp_error( $site_id ) ) {
371 return $site_id;
372 }
373
374 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
375 if ( ! class_exists( 'OpenAI' ) ) {
376 \require_lib( 'openai' );
377 }
378
379 $result = ( new OpenAI( 'openai', array( 'post_id' => $post_id ) ) )->request_dalle_generation( $prompt );
380 if ( is_wp_error( $result ) ) {
381 return $result;
382 }
383 set_transient( self::transient_name_for_image_generation( $prompt ), $result, self::$image_generation_cache_timeout );
384 self::mark_post_as_ai_assisted( $post_id );
385 return $result;
386 }
387
388 $response = Client::wpcom_json_api_request_as_user(
389 sprintf( '/sites/%d/jetpack-ai/images/generations', $site_id ),
390 2,
391 array(
392 'method' => 'post',
393 'headers' => array( 'content-type' => 'application/json' ),
394 ),
395 wp_json_encode(
396 array(
397 'prompt' => $prompt,
398 ),
399 JSON_UNESCAPED_SLASHES
400 ),
401 'wpcom'
402 );
403
404 if ( is_wp_error( $response ) ) {
405 return $response;
406 }
407
408 $data = json_decode( wp_remote_retrieve_body( $response ) );
409
410 if ( wp_remote_retrieve_response_code( $response ) >= 400 ) {
411 return new WP_Error( $data->code, $data->message, $data->data );
412 }
413 set_transient( self::transient_name_for_image_generation( $prompt ), $data, self::$image_generation_cache_timeout );
414 self::mark_post_as_ai_assisted( $post_id );
415
416 return $data;
417 }
418
419 /**
420 * Get an object with useful data about the requests made to the AI.
421 *
422 * @return mixed
423 */
424 public static function get_ai_assistance_feature() {
425 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
426 // On WPCOM, we can get the ID from the site.
427 $blog_id = get_current_blog_id();
428 $has_ai_assistant_feature = \wpcom_site_has_feature( 'ai-assistant', $blog_id );
429
430 if ( ! class_exists( 'WPCOM\Jetpack_AI\Usage\Helper' ) ) {
431 if ( is_readable( WP_CONTENT_DIR . '/lib/jetpack-ai/usage/helper.php' ) ) {
432 require_once WP_CONTENT_DIR . '/lib/jetpack-ai/usage/helper.php';
433 } else {
434 return new WP_Error(
435 'jetpack_ai_usage_helper_not_found',
436 __( 'WPCOM\Jetpack_AI\Usage\Helper class not found.', 'jetpack' )
437 );
438 }
439 }
440
441 if ( ! class_exists( 'WPCOM\Jetpack_AI\Feature_Control' ) ) {
442 if ( is_readable( WP_CONTENT_DIR . '/lib/jetpack-ai/feature-control.php' ) ) {
443 require_once WP_CONTENT_DIR . '/lib/jetpack-ai/feature-control.php';
444 } else {
445 return new WP_Error(
446 'jetpack_ai_feature_control_not_found',
447 __( 'WPCOM\Jetpack_AI\Feature_Control class not found.', 'jetpack' )
448 );
449 }
450 }
451
452 // Determine the upgrade type
453 $upgrade_type = wpcom_is_vip( $blog_id ) ? 'vip' : 'default';
454
455 return array(
456 'has-feature' => $has_ai_assistant_feature,
457 'is-over-limit' => WPCOM\Jetpack_AI\Usage\Helper::is_over_limit( $blog_id ),
458 'requests-count' => WPCOM\Jetpack_AI\Usage\Helper::get_all_time_requests_count( $blog_id ),
459 'requests-limit' => WPCOM\Jetpack_AI\Usage\Helper::get_free_requests_limit( $blog_id ),
460 'usage-period' => WPCOM\Jetpack_AI\Usage\Helper::get_period_data( $blog_id ),
461 'site-require-upgrade' => WPCOM\Jetpack_AI\Usage\Helper::site_requires_upgrade( $blog_id ),
462 'upgrade-type' => $upgrade_type,
463 'upgrade-url' => WPCOM\Jetpack_AI\Usage\Helper::get_upgrade_url( $blog_id ),
464 'current-tier' => WPCOM\Jetpack_AI\Usage\Helper::get_current_tier( $blog_id ),
465 'next-tier' => WPCOM\Jetpack_AI\Usage\Helper::get_next_tier( $blog_id ),
466 'tier-plans' => WPCOM\Jetpack_AI\Usage\Helper::get_tier_plans_list(),
467 'tier-plans-enabled' => WPCOM\Jetpack_AI\Usage\Helper::ai_tier_plans_enabled(),
468 'costs' => WPCOM\Jetpack_AI\Usage\Helper::get_costs(),
469 'features-control' => WPCOM\Jetpack_AI\Feature_Control::get_features(),
470 );
471 }
472
473 // Outside of WPCOM, we need to fetch the data from the site.
474 $blog_id = Jetpack_Options::get_option( 'id' );
475
476 // Try to pick the AI Assistant feature from cache.
477 $transient_name = self::transient_name_for_ai_assistance_feature( $blog_id );
478 $cache = get_transient( $transient_name );
479 if ( $cache ) {
480 return $cache;
481 }
482
483 if ( null !== static::$ai_assistant_failed_request ) {
484 return static::$ai_assistant_failed_request;
485 }
486
487 $request_path = sprintf( '/sites/%d/jetpack-ai/ai-assistant-feature', $blog_id );
488
489 $wpcom_request = Client::wpcom_json_api_request_as_user(
490 $request_path,
491 'v2',
492 array(
493 'method' => 'GET',
494 'headers' => array(
495 'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
496 ),
497 'timeout' => 30,
498 ),
499 null,
500 'wpcom'
501 );
502
503 $response_code = wp_remote_retrieve_response_code( $wpcom_request );
504 if ( 200 === $response_code ) {
505 $ai_assistant_feature_data = json_decode( wp_remote_retrieve_body( $wpcom_request ), true );
506
507 // Cache the AI Assistant feature, for Jetpack sites.
508 set_transient( $transient_name, $ai_assistant_feature_data, self::$ai_assistant_feature_cache_timeout );
509
510 return $ai_assistant_feature_data;
511 } else {
512 $error = new WP_Error(
513 'failed_to_fetch_data',
514 esc_html__( 'Unable to fetch the requested data.', 'jetpack' ),
515 array(
516 'status' => $response_code,
517 'ts' => time(),
518 )
519 );
520
521 // Cache the AI Assistant feature error, for Jetpack sites, avoid API hammering.
522 set_transient( $transient_name, $error, self::$ai_assistant_feature_error_cache_timeout );
523
524 static::$ai_assistant_failed_request = $error;
525
526 return $error;
527 }
528 }
529 }
530