Source: Features/ExcerptGeneration.php

<?php

namespace Classifai\Features;

use Classifai\Providers\XAI\Grok;
use Classifai\Services\LanguageProcessing;
use Classifai\Providers\GoogleAI\GeminiAPI;
use Classifai\Providers\OpenAI\ChatGPT;
use Classifai\Providers\Azure\OpenAI;
use Classifai\Providers\Browser\ChromeAI;
use Classifai\Providers\Localhost\Ollama;
use WP_REST_Server;
use WP_REST_Request;
use WP_Error;

use function Classifai\get_asset_info;
use function Classifai\sanitize_prompts;

/**
 * Class ExcerptGeneration
 */
class ExcerptGeneration extends Feature {
	/**
	 * ID of the current feature.
	 *
	 * @var string
	 */
	const ID = 'feature_excerpt_generation';

	/**
	 * Prompt for generating excerpts.
	 *
	 * @var string
	 */
	public $prompt = 'Summarize the following message using a maximum of {{WORDS}} words. The original message was written by {{AUTHOR}}. Ensure this summary pairs well with the following text: {{TITLE}}.';

	/**
	 * Prompt for generating excerpts for WooCommerce Products.
	 *
	 * @var string
	 */
	public $woo_prompt = 'Create a concise, compelling summary for an ecommerce product that highlights key features, benefits, and unique selling points. Keep it within {{WORDS}} words and ensure it pairs well with the product title: {{TITLE}}.';

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->label = __( 'Excerpt Generation', 'classifai' );

		// Contains all providers that are registered to the service.
		$this->provider_instances = $this->get_provider_instances( LanguageProcessing::get_service_providers() );

		// Contains just the providers this feature supports.
		$this->supported_providers = [
			ChatGPT::ID   => __( 'OpenAI ChatGPT', 'classifai' ),
			GeminiAPI::ID => __( 'Google AI (Gemini API)', 'classifai' ),
			OpenAI::ID    => __( 'Azure OpenAI', 'classifai' ),
			Grok::ID      => __( 'xAI Grok', 'classifai' ),
			ChromeAI::ID  => __( 'Chrome AI (experimental)', 'classifai' ),
			Ollama::ID    => __( 'Ollama', 'classifai' ),
		];
	}

	/**
	 * Set up necessary hooks.
	 *
	 * We utilize this so we can register the REST route.
	 */
	public function setup() {
		parent::setup();
		add_action( 'rest_api_init', [ $this, 'register_endpoints' ] );
		add_action(
			'admin_footer',
			static function () {
				if (
					( isset( $_GET['tab'], $_GET['feature'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
					&& 'language_processing' === sanitize_text_field( wp_unslash( $_GET['tab'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
					&& 'feature_excerpt_generation' === sanitize_text_field( wp_unslash( $_GET['feature'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
				) {
					printf(
						'<div id="js-classifai--delete-prompt-modal" style="display:none;"><p>%1$s</p></div>',
						esc_html__( 'Are you sure you want to delete the prompt?', 'classifai' ),
					);
				}
			}
		);
	}

	/**
	 * Set up necessary hooks.
	 */
	public function feature_setup() {
		add_action( 'enqueue_block_assets', [ $this, 'enqueue_editor_assets' ] );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] );
	}

	/**
	 * Register any needed endpoints.
	 */
	public function register_endpoints() {
		register_rest_route(
			'classifai/v1',
			'generate-excerpt(?:/(?P<id>\d+))?',
			[
				[
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => [ $this, 'rest_endpoint_callback' ],
					'args'                => [
						'id' => [
							'required'          => true,
							'type'              => 'integer',
							'sanitize_callback' => 'absint',
							'description'       => esc_html__( 'Post ID to generate excerpt for.', 'classifai' ),
						],
					],
					'permission_callback' => [ $this, 'generate_excerpt_permissions_check' ],
				],
				[
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => [ $this, 'rest_endpoint_callback' ],
					'args'                => [
						'content' => [
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
							'validate_callback' => 'rest_validate_request_arg',
							'description'       => esc_html__( 'Content to summarize into an excerpt.', 'classifai' ),
						],
						'title'   => [
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
							'validate_callback' => 'rest_validate_request_arg',
							'description'       => esc_html__( 'Title of content we want a summary for.', 'classifai' ),
						],
						'author'  => [
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
							'validate_callback' => 'rest_validate_request_arg',
							'description'       => esc_html__( 'Author name for context in excerpt generation.', 'classifai' ),
						],
					],
					'permission_callback' => [ $this, 'generate_excerpt_permissions_check' ],
				],
			]
		);
	}

	/**
	 * Check if a given request has access to generate an excerpt.
	 *
	 * This check ensures we have a proper post ID, the current user
	 * making the request has access to that post, that we are
	 * properly authenticated with OpenAI and that excerpt generation
	 * is turned on.
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return WP_Error|bool
	 */
	public function generate_excerpt_permissions_check( WP_REST_Request $request ) {
		$post_id = $request->get_param( 'id' );

		// Ensure we have a logged in user that can edit the item.
		if ( empty( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {
			return false;
		}

		$post_type     = get_post_type( $post_id );
		$post_type_obj = get_post_type_object( $post_type );

		// Ensure the post type is allowed in REST endpoints.
		if ( ! $post_type || empty( $post_type_obj ) || empty( $post_type_obj->show_in_rest ) ) {
			return false;
		}

		// Ensure the feature is enabled. Also runs a user check.
		if ( ! $this->is_feature_enabled() ) {
			return new WP_Error( 'not_enabled', esc_html__( 'Excerpt generation not currently enabled.', 'classifai' ) );
		}

		return true;
	}

	/**
	 * Generic request handler for all our custom routes.
	 *
	 * @param WP_REST_Request $request The full request object.
	 * @return \WP_REST_Response
	 */
	public function rest_endpoint_callback( WP_REST_Request $request ) {
		$route = $request->get_route();

		if ( strpos( $route, '/classifai/v1/generate-excerpt' ) === 0 ) {
			$post_id = $request->get_param( 'id' );

			// Get the author name - prefer from request payload, fallback to database.
			$author_name = $request->get_param( 'author' );

			if ( empty( $author_name ) && $post_id ) {
				$post = get_post( $post_id );
				if ( $post ) {
					$author_name = get_the_author_meta( 'display_name', $post->post_author );
				}
			}

			/**
			 * Filter the author name used in excerpt generation.
			 *
			 * @since 3.x.x
			 * @hook classifai_excerpt_generation_author_name
			 *
			 * @param {string} $author_name The author's display name.
			 * @param {int}    $post_id     The post ID.
			 *
			 * @return {string} The author name to use in the prompt.
			 */
			$author_name = apply_filters( 'classifai_excerpt_generation_author_name', $author_name, $post_id );

			return rest_ensure_response(
				$this->run(
					$post_id,
					'excerpt',
					[
						'content' => $request->get_param( 'content' ),
						'title'   => $request->get_param( 'title' ),
						'author'  => $author_name,
					]
				)
			);
		}

		return parent::rest_endpoint_callback( $request );
	}

	/**
	 * Enqueue the editor scripts.
	 */
	public function enqueue_editor_assets() {
		global $post;

		if ( empty( $post ) || ! is_admin() ) {
			return;
		}

		// This script removes the core excerpt panel and replaces it with our own.
		wp_enqueue_script(
			'classifai-plugin-excerpt-generation-js',
			CLASSIFAI_PLUGIN_URL . 'dist/classifai-plugin-excerpt-generation.js',
			array_merge( get_asset_info( 'classifai-plugin-excerpt-generation', 'dependencies' ), [ 'lodash' ] ),
			get_asset_info( 'classifai-plugin-excerpt-generation', 'version' ),
			true
		);
	}

	/**
	 * Enqueue the admin scripts.
	 *
	 * @param string $hook_suffix The current admin page.
	 */
	public function enqueue_admin_assets( string $hook_suffix ) {
		// Load asset in new post and edit post screens.
		if ( 'post.php' === $hook_suffix || 'post-new.php' === $hook_suffix ) {
			$screen = get_current_screen();

			// Load the assets for the classic editor.
			if ( $screen && ! $screen->is_block_editor() ) {
				if ( post_type_supports( $screen->post_type, 'excerpt' ) ) {
					wp_enqueue_style(
						'classifai-plugin-classic-excerpt-generation-css',
						CLASSIFAI_PLUGIN_URL . 'dist/classifai-plugin-classic-excerpt-generation.css',
						[],
						get_asset_info( 'classifai-plugin-classic-excerpt-generation', 'version' ),
						'all'
					);

					wp_enqueue_script(
						'classifai-plugin-classic-excerpt-generation-js',
						CLASSIFAI_PLUGIN_URL . 'dist/classifai-plugin-classic-excerpt-generation.js',
						array_merge( get_asset_info( 'classifai-plugin-classic-excerpt-generation', 'dependencies' ), array( 'wp-api' ) ),
						get_asset_info( 'classifai-plugin-classic-excerpt-generation', 'version' ),
						true
					);

					wp_add_inline_script(
						'classifai-plugin-classic-excerpt-generation-js',
						sprintf(
							'var classifaiGenerateExcerpt = %s;',
							wp_json_encode(
								[
									'path'           => '/classifai/v1/generate-excerpt/',
									'buttonText'     => __( 'Generate excerpt', 'classifai' ),
									'regenerateText' => __( 'Re-generate excerpt', 'classifai' ),
								]
							)
						),
						'before'
					);
				}
			}
		}
	}

	/**
	 * Get the description for the enable field.
	 *
	 * @return string
	 */
	public function get_enable_description(): string {
		return esc_html__( 'A button will be added to the excerpt panel that can be used to generate an excerpt.', 'classifai' );
	}

	/**
	 * Add any needed custom fields.
	 */
	public function add_custom_settings_fields() {
		$settings          = $this->get_settings();
		$post_types        = \Classifai\get_post_types_for_language_settings();
		$post_type_options = array();

		foreach ( $post_types as $post_type ) {
			if ( post_type_supports( $post_type->name, 'excerpt' ) ) {
				$post_type_options[ $post_type->name ] = $post_type->label;
			}
		}

		add_settings_field(
			'generate_excerpt_prompt',
			esc_html__( 'Prompt', 'classifai' ),
			[ $this, 'render_prompt_repeater_field' ],
			$this->get_option_name(),
			$this->get_option_name() . '_section',
			[
				'label_for'     => 'generate_excerpt_prompt',
				'placeholder'   => $this->prompt,
				'default_value' => $settings['generate_excerpt_prompt'],
				'description'   => esc_html__( "Add a custom prompt. Note the following variables that can be used in the prompt and will be replaced with content: {{WORDS}} will be replaced with the desired excerpt length setting. {{TITLE}} will be replaced with the item's title. {{AUTHOR}} will be replaced with the post author's display name.", 'classifai' ),
			]
		);

		add_settings_field(
			'post_types',
			esc_html__( 'Allowed post types', 'classifai' ),
			[ $this, 'render_checkbox_group' ],
			$this->get_option_name(),
			$this->get_option_name() . '_section',
			[
				'label_for'      => 'post_types',
				'options'        => $post_type_options,
				'default_values' => $settings['post_types'],
				'description'    => __( 'Choose which post types support this feature.', 'classifai' ),
			]
		);

		add_settings_field(
			'length',
			esc_html__( 'Excerpt length', 'classifai' ),
			[ $this, 'render_input' ],
			$this->get_option_name(),
			$this->get_option_name() . '_section',
			[
				'label_for'     => 'length',
				'input_type'    => 'number',
				'min'           => 1,
				'step'          => 1,
				'default_value' => $settings['length'],
				'description'   => __( 'How many words should the excerpt be? Note that the final result may not exactly match this, it often tends to exceed this number by 10-15 words.', 'classifai' ),
			]
		);
	}

	/**
	 * Returns the default settings for the feature.
	 *
	 * @return array
	 */
	public function get_feature_default_settings(): array {
		return [
			'generate_excerpt_prompt' => [
				[
					'title'    => esc_html__( 'ClassifAI default', 'classifai' ),
					'prompt'   => $this->prompt,
					'original' => 1,
				],
			],
			'post_types'              => [
				'post' => 'post',
			],
			'length'                  => absint( apply_filters( 'excerpt_length', 55 ) ),
			'provider'                => ChatGPT::ID,
		];
	}

	/**
	 * Returns the settings for the feature.
	 *
	 * @param string $index The index of the setting to return.
	 * @return array|mixed
	 */
	public function get_settings( $index = false ) {
		$settings = parent::get_settings( $index );

		// Keep using the original prompt from the codebase to allow updates.
		if ( $settings && ! empty( $settings['generate_excerpt_prompt'] ) ) {
			foreach ( $settings['generate_excerpt_prompt'] as $key => $prompt ) {
				if ( 1 === intval( $prompt['original'] ) ) {
					$settings['generate_excerpt_prompt'][ $key ]['prompt'] = $this->prompt;
					break;
				}
			}
		}

		return $settings;
	}

	/**
	 * Sanitizes the default feature settings.
	 *
	 * @param array $new_settings Settings being saved.
	 * @return array
	 */
	public function sanitize_default_feature_settings( array $new_settings ): array {
		$settings   = $this->get_settings();
		$post_types = \Classifai\get_post_types_for_language_settings();

		$new_settings['generate_excerpt_prompt'] = sanitize_prompts( 'generate_excerpt_prompt', $new_settings );

		$new_settings['length'] = absint( $new_settings['length'] ?? $settings['length'] );

		foreach ( $post_types as $post_type ) {
			if ( ! post_type_supports( $post_type->name, 'excerpt' ) ) {
				continue;
			}

			if ( ! isset( $new_settings['post_types'][ $post_type->name ] ) ) {
				$new_settings['post_types'][ $post_type->name ] = '';
			} else {
				$new_settings['post_types'][ $post_type->name ] = sanitize_text_field( $new_settings['post_types'][ $post_type->name ] );
			}
		}

		return $new_settings;
	}

	/**
	 * Generates feature setting data required for migration from
	 * ClassifAI < 3.0.0 to 3.0.0
	 *
	 * @return array
	 */
	public function migrate_settings() {
		$old_settings = get_option( 'classifai_openai_chatgpt', array() );
		$new_settings = $this->get_default_settings();

		if ( isset( $old_settings['enable_excerpt'] ) ) {
			$new_settings['status'] = $old_settings['enable_excerpt'];
		}

		if ( isset( $old_settings['length'] ) ) {
			$new_settings['length'] = $old_settings['length'];
		}

		$new_settings['provider'] = 'openai_chatgpt';

		if ( isset( $old_settings['api_key'] ) ) {
			$new_settings['openai_chatgpt']['api_key'] = $old_settings['api_key'];
		}

		if ( isset( $old_settings['authenticated'] ) ) {
			$new_settings['openai_chatgpt']['authenticated'] = $old_settings['authenticated'];
		}

		if ( isset( $old_settings['generate_excerpt_prompt'] ) ) {
			$new_settings['generate_excerpt_prompt'] = $old_settings['generate_excerpt_prompt'];
		}

		if ( isset( $old_settings['excerpt_generation_roles'] ) ) {
			$new_settings['roles'] = $old_settings['excerpt_generation_roles'];
		}

		if ( isset( $old_settings['excerpt_generation_users'] ) ) {
			$new_settings['users'] = $old_settings['excerpt_generation_users'];
		}

		if ( isset( $old_settings['excerpt_generation_user_based_opt_out'] ) ) {
			$new_settings['user_based_opt_out'] = $old_settings['excerpt_generation_user_based_opt_out'];
		}

		return $new_settings;
	}
}