diff --git a/README.md b/README.md
index 3be53e779..5272f6701 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ Tap into leading cloud-based services like [OpenAI](https://openai.com/), [Micro
## Features
* Generate a summary of post content and store it as an excerpt using [OpenAI's ChatGPT API](https://platform.openai.com/docs/guides/chat), [Microsoft Azure's OpenAI service](https://azure.microsoft.com/en-us/products/ai-services/openai-service) or [Google's Gemini API](https://ai.google.dev/docs/gemini_api_overview)
+* Generate key takeaways from post content and render at the top of a post using [OpenAI's ChatGPT API](https://platform.openai.com/docs/guides/chat) or [Microsoft Azure's OpenAI service](https://azure.microsoft.com/en-us/products/ai-services/openai-service)
* Generate titles from post content using [OpenAI's ChatGPT API](https://platform.openai.com/docs/guides/chat), [Microsoft Azure's OpenAI service](https://azure.microsoft.com/en-us/products/ai-services/openai-service) or [Google's Gemini API](https://ai.google.dev/docs/gemini_api_overview)
* Expand or condense text content using [OpenAI's ChatGPT API](https://platform.openai.com/docs/guides/chat), [Microsoft Azure's OpenAI service](https://azure.microsoft.com/en-us/products/ai-services/openai-service) or [Google's Gemini API](https://ai.google.dev/docs/gemini_api_overview)
* Generate new images on demand to use in-content or as a featured image using [OpenAI's DALL·E 3 API](https://platform.openai.com/docs/guides/images)
@@ -41,6 +42,10 @@ Tap into leading cloud-based services like [OpenAI](https://openai.com/), [Micro
| :-: | :-: | :-: | :-: |
| ![Screenshot of ClassifAI audio transcript generation](assets/img/screenshot-9.png "Example of automatic audio transcript generation with OpenAI.") | ![Screenshot of ClassifAI title generation](assets/img/screenshot-10.png "Example of automatic title generation with OpenAI.") | ![Screenshot of ClassifAI expand/condense text feature](assets/img/screenshot-12.png "Example of expanding or condensing text with OpenAI.") | ![Screenshot of ClassifAI text to speech generation](assets/img/screenshot-11.png "Example of automatic text to speech generation with Azure.") |
+| Key Takeaways | | | |
+| :-: | :-: | :-: | :-: |
+| ![Screenshot of the ClassifAI Key Takeaways block](assets/img/screenshot-14.png "Example of generating key takeaways using OpenAI.") | | | |
+
### Image Processing
| Alt Text | Smart Cropping | Tagging | Generate Images |
diff --git a/assets/img/screenshot-14.png b/assets/img/screenshot-14.png
new file mode 100644
index 000000000..a1b4da537
Binary files /dev/null and b/assets/img/screenshot-14.png differ
diff --git a/includes/Classifai/Blocks/key-takeaways/block.json b/includes/Classifai/Blocks/key-takeaways/block.json
new file mode 100644
index 000000000..696b26252
--- /dev/null
+++ b/includes/Classifai/Blocks/key-takeaways/block.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "title": "Key Takeaways",
+ "description": "Generate a list of key takeaways from post content",
+ "textdomain": "classifai",
+ "name": "classifai/key-takeaways",
+ "category": "text",
+ "keywords": [ "tldr", "summary", "takeaways", "abstract" ],
+ "attributes": {
+ "render": {
+ "type": "string",
+ "default": "list"
+ },
+ "title": {
+ "type": "string",
+ "default": "Key Takeaways"
+ },
+ "takeaways": {
+ "type": "array",
+ "default": []
+ }
+ },
+ "supports": {
+ "html": false,
+ "multiple": false
+ },
+ "editorScript": "key-takeaways-editor-script",
+ "style": "file:./style.css",
+ "render": "file:./render.php"
+}
diff --git a/includes/Classifai/Blocks/key-takeaways/edit.js b/includes/Classifai/Blocks/key-takeaways/edit.js
new file mode 100644
index 000000000..e5d56306b
--- /dev/null
+++ b/includes/Classifai/Blocks/key-takeaways/edit.js
@@ -0,0 +1,185 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ useBlockProps,
+ BlockControls,
+ InspectorControls,
+ RichText,
+} from '@wordpress/block-editor';
+import { select } from '@wordpress/data';
+import {
+ Placeholder,
+ ToolbarGroup,
+ Spinner,
+ PanelBody,
+ Button,
+} from '@wordpress/components';
+import { useEffect, useState } from '@wordpress/element';
+import { postList, paragraph } from '@wordpress/icons';
+import { __ } from '@wordpress/i18n';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { ReactComponent as icon } from '../../../../assets/img/block-icon.svg';
+
+const BlockEdit = ( props ) => {
+ const [ isLoading, setIsLoading ] = useState( false );
+ const [ run, setRun ] = useState( false );
+ const { attributes, setAttributes } = props;
+ const { render, takeaways, title } = attributes;
+ const blockProps = useBlockProps();
+
+ useEffect( () => {
+ if ( ( ! isLoading && takeaways.length === 0 ) || run ) {
+ const postId = select( 'core/editor' ).getCurrentPostId();
+ const postContent =
+ select( 'core/editor' ).getEditedPostAttribute( 'content' );
+ const postTitle =
+ select( 'core/editor' ).getEditedPostAttribute( 'title' );
+
+ setRun( false );
+ setIsLoading( true );
+
+ apiFetch( {
+ path: '/classifai/v1/key-takeaways/',
+ method: 'POST',
+ data: {
+ id: postId,
+ content: postContent,
+ title: postTitle,
+ render,
+ },
+ } ).then(
+ async ( res ) => {
+ // Ensure takeaways is always an array.
+ if ( ! Array.isArray( res ) ) {
+ res = [ res ];
+ }
+
+ setAttributes( { takeaways: res } );
+ setIsLoading( false );
+ },
+ ( err ) => {
+ setAttributes( {
+ takeaways: [ `Error: ${ err?.message }` ],
+ } );
+ setIsLoading( false );
+ }
+ );
+ }
+ }, [ run ] );
+
+ const renderControls = [
+ {
+ icon: postList,
+ title: __( 'List view', 'classifai' ),
+ onClick: () => setAttributes( { render: 'list' } ),
+ isActive: render === 'list',
+ },
+ {
+ icon: paragraph,
+ title: __( 'Paragraph view', 'classifai' ),
+ onClick: () => setAttributes( { render: 'paragraph' } ),
+ isActive: render === 'paragraph',
+ },
+ ];
+
+ const editTakeaways = ( index, value ) => {
+ const newTakeaways = [ ...takeaways ];
+
+ if ( ! value ) {
+ newTakeaways.splice( index, 1 );
+ } else {
+ newTakeaways[ index ] = value;
+ }
+
+ setAttributes( {
+ takeaways: newTakeaways,
+ } );
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ { isLoading && (
+
+
+
+ ) }
+
+ { ! isLoading && (
+
+
+ setAttributes( { title: value } )
+ }
+ placeholder="Key Takeaways"
+ />
+
+ { render === 'list' && (
+
+ { takeaways.map( ( takeaway, index ) => (
+
+ editTakeaways( index, value )
+ }
+ />
+ ) ) }
+
+ ) }
+ { render === 'paragraph' && (
+ <>
+ { takeaways.map( ( takeaway, index ) => (
+
+ editTakeaways( index, value )
+ }
+ />
+ ) ) }
+ >
+ ) }
+
+
+ ) }
+ >
+ );
+};
+
+export default BlockEdit;
diff --git a/includes/Classifai/Blocks/key-takeaways/index.js b/includes/Classifai/Blocks/key-takeaways/index.js
new file mode 100644
index 000000000..3568235b1
--- /dev/null
+++ b/includes/Classifai/Blocks/key-takeaways/index.js
@@ -0,0 +1,25 @@
+/**
+ * Key Takeaways block
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import save from './save';
+import block from './block.json';
+import { ReactComponent as icon } from '../../../../assets/img/block-icon.svg';
+
+/**
+ * Register block
+ */
+registerBlockType( block, {
+ edit,
+ save,
+ icon,
+} );
diff --git a/includes/Classifai/Blocks/key-takeaways/render.php b/includes/Classifai/Blocks/key-takeaways/render.php
new file mode 100644
index 000000000..d0a8df9ac
--- /dev/null
+++ b/includes/Classifai/Blocks/key-takeaways/render.php
@@ -0,0 +1,43 @@
+
+
+>
+
+
+
+
+
+
+
+ ';
+ foreach ( (array) $takeaways as $takeaway ) {
+ printf(
+ '
%s',
+ esc_html( $takeaway )
+ );
+ }
+ echo '';
+ } else {
+ foreach ( (array) $takeaways as $takeaway ) {
+ printf(
+ '
%s
',
+ esc_html( $takeaway )
+ );
+ }
+ }
+ ?>
+
+
diff --git a/includes/Classifai/Blocks/key-takeaways/save.js b/includes/Classifai/Blocks/key-takeaways/save.js
new file mode 100644
index 000000000..74a7f39c8
--- /dev/null
+++ b/includes/Classifai/Blocks/key-takeaways/save.js
@@ -0,0 +1,8 @@
+/**
+ * See https://wordpress.org/gutenberg/handbook/designers-developers/developers/block-api/block-edit-save/#save
+ *
+ * @return {null} Dynamic blocks do not save the HTML.
+ */
+const BlockSave = () => null;
+
+export default BlockSave;
diff --git a/includes/Classifai/Blocks/key-takeaways/style.css b/includes/Classifai/Blocks/key-takeaways/style.css
new file mode 100644
index 000000000..8d72bf204
--- /dev/null
+++ b/includes/Classifai/Blocks/key-takeaways/style.css
@@ -0,0 +1,3 @@
+.wp-block-classifai-key-takeaways__content {
+ font-style: italic;
+}
diff --git a/includes/Classifai/Features/KeyTakeaways.php b/includes/Classifai/Features/KeyTakeaways.php
new file mode 100644
index 000000000..a50c038a8
--- /dev/null
+++ b/includes/Classifai/Features/KeyTakeaways.php
@@ -0,0 +1,299 @@
+label = __( 'Key Takeaways', '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' ),
+ OpenAI::ID => __( 'Azure OpenAI', '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
+ && self::ID === sanitize_text_field( wp_unslash( $_GET['feature'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ ) {
+ printf(
+ '',
+ 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' ] );
+ $this->register_block();
+ }
+
+ /**
+ * Register the block used for this feature.
+ */
+ public function register_block() {
+ register_block_type_from_metadata(
+ CLASSIFAI_PLUGIN_DIR . '/includes/Classifai/Blocks/key-takeaways', // this is the directory where the block.json is found.
+ );
+ }
+
+ /**
+ * Register any needed endpoints.
+ */
+ public function register_endpoints() {
+ register_rest_route(
+ 'classifai/v1',
+ 'key-takeaways(?:/(?P\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 key takeaways for.', 'classifai' ),
+ ],
+ 'render' => [
+ 'type' => 'string',
+ 'enum' => [
+ 'list',
+ 'paragraph',
+ ],
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'description' => esc_html__( 'How the key takeaways should be rendered.', 'classifai' ),
+ ],
+ ],
+ 'permission_callback' => [ $this, 'generate_key_takeaways_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 generate key takeaways from.', 'classifai' ),
+ ],
+ 'title' => [
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'description' => esc_html__( 'Title of content to generate key takeaways from.', 'classifai' ),
+ ],
+ 'render' => [
+ 'type' => 'string',
+ 'enum' => [
+ 'list',
+ 'paragraph',
+ ],
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'description' => esc_html__( 'How the key takeaways should be rendered.', 'classifai' ),
+ ],
+ ],
+ 'permission_callback' => [ $this, 'generate_key_takeaways_permissions_check' ],
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Check if a given request has access to generate key takeaways.
+ *
+ * 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 and that the feature is turned on.
+ *
+ * @param WP_REST_Request $request Full data about the request.
+ * @return WP_Error|bool
+ */
+ public function generate_key_takeaways_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__( 'Key takeaways 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/key-takeaways' ) === 0 ) {
+ return rest_ensure_response(
+ $this->run(
+ $request->get_param( 'id' ),
+ 'key_takeaways',
+ [
+ 'content' => $request->get_param( 'content' ),
+ 'title' => $request->get_param( 'title' ),
+ 'render' => $request->get_param( 'render' ),
+ ]
+ )
+ );
+ }
+
+ return parent::rest_endpoint_callback( $request );
+ }
+
+ /**
+ * Enqueue the editor scripts.
+ */
+ public function enqueue_editor_assets() {
+ global $post;
+
+ if ( empty( $post ) || ! is_admin() ) {
+ return;
+ }
+
+ wp_register_script(
+ 'key-takeaways-editor-script',
+ CLASSIFAI_PLUGIN_URL . 'dist/key-takeaways-block.js',
+ get_asset_info( 'key-takeaways', 'dependencies' ),
+ get_asset_info( 'key-takeaways', 'version' ),
+ true
+ );
+ }
+
+ /**
+ * Get the description for the enable field.
+ *
+ * @return string
+ */
+ public function get_enable_description(): string {
+ return esc_html__( 'A new block will be registered that when added to an item, will generate key takeaways from the content.', 'classifai' );
+ }
+
+ /**
+ * Returns the default settings for the feature.
+ *
+ * @return array
+ */
+ public function get_feature_default_settings(): array {
+ return [
+ 'key_takeaways_prompt' => [
+ [
+ 'title' => esc_html__( 'ClassifAI default', 'classifai' ),
+ 'prompt' => $this->prompt,
+ 'original' => 1,
+ ],
+ ],
+ '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['key_takeaways_prompt'] ) ) {
+ foreach ( $settings['key_takeaways_prompt'] as $key => $prompt ) {
+ if ( 1 === intval( $prompt['original'] ) ) {
+ $settings['key_takeaways_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 {
+ $new_settings['key_takeaways_prompt'] = sanitize_prompts( 'key_takeaways_prompt', $new_settings );
+
+ return $new_settings;
+ }
+}
diff --git a/includes/Classifai/Providers/Azure/OpenAI.php b/includes/Classifai/Providers/Azure/OpenAI.php
index 7fe0f397c..219888214 100644
--- a/includes/Classifai/Providers/Azure/OpenAI.php
+++ b/includes/Classifai/Providers/Azure/OpenAI.php
@@ -8,6 +8,7 @@
use Classifai\Features\ContentResizing;
use Classifai\Features\ExcerptGeneration;
use Classifai\Features\TitleGeneration;
+use Classifai\Features\KeyTakeaways;
use Classifai\Providers\Provider;
use Classifai\Normalizer;
use WP_Error;
@@ -250,7 +251,8 @@ protected function prep_api_url( ?\Classifai\Features\Feature $feature = null ):
if (
( $feature instanceof ContentResizing ||
$feature instanceof ExcerptGeneration ||
- $feature instanceof TitleGeneration ) &&
+ $feature instanceof TitleGeneration ||
+ $feature instanceof KeyTakeaways ) &&
$deployment
) {
$endpoint = trailingslashit( $endpoint ) . str_replace( '{deployment-id}', $deployment, $this->chat_completion_url );
@@ -331,6 +333,9 @@ public function rest_endpoint_callback( $post_id = 0, string $route_to_call = ''
case 'resize_content':
$return = $this->resize_content( $post_id, $args );
break;
+ case 'key_takeaways':
+ $return = $this->generate_key_takeaways( $post_id, $args );
+ break;
}
return $return;
@@ -659,6 +664,149 @@ public function resize_content( int $post_id, array $args = array() ) {
return $return;
}
+ /**
+ * Generate key takeaways from content.
+ *
+ * @param int $post_id The Post ID we're processing
+ * @param array $args Arguments passed in.
+ * @return string|WP_Error
+ */
+ public function generate_key_takeaways( int $post_id = 0, array $args = [] ) {
+ if ( ! $post_id || ! get_post( $post_id ) ) {
+ return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required to generate key takeaways.', 'classifai' ) );
+ }
+
+ $feature = new KeyTakeaways();
+ $settings = $feature->get_settings();
+ $args = wp_parse_args(
+ array_filter( $args ),
+ [
+ 'content' => '',
+ 'title' => get_the_title( $post_id ),
+ 'render' => 'list',
+ ]
+ );
+
+ // These checks (and the one above) happen in the REST permission_callback,
+ // but we run them again here in case this method is called directly.
+ if ( empty( $settings ) || ( isset( $settings[ static::ID ]['authenticated'] ) && false === $settings[ static::ID ]['authenticated'] ) || ( ! $feature->is_feature_enabled() && ( ! defined( 'WP_CLI' ) || ! WP_CLI ) ) ) {
+ return new WP_Error( 'not_enabled', esc_html__( 'Key Takeaways generation is disabled or authentication failed. Please check your settings.', 'classifai' ) );
+ }
+
+ $prompt = esc_textarea( get_default_prompt( $settings['key_takeaways_prompt'] ) ?? $feature->prompt );
+
+ // Replace our variables in the prompt.
+ $prompt_search = array( '{{TITLE}}' );
+ $prompt_replace = array( $args['title'] );
+ $prompt = str_replace( $prompt_search, $prompt_replace, $prompt );
+
+ /**
+ * Filter the prompt we will send to Azure OpenAI.
+ *
+ * @since x.x.x
+ * @hook classifai_azure_openai_key_takeaways_prompt
+ *
+ * @param {string} $prompt Prompt we are sending to Azure. Gets added before post content.
+ * @param {int} $post_id ID of post we are summarizing.
+ *
+ * @return {string} Prompt.
+ */
+ $prompt = apply_filters( 'classifai_azure_openai_key_takeaways_prompt', $prompt, $post_id );
+
+ /**
+ * Filter the request body before sending to Azure OpenAI.
+ *
+ * @since x.x.x
+ * @hook classifai_azure_openai_key_takeaways_request_body
+ *
+ * @param {array} $body Request body that will be sent to Azure.
+ * @param {int} $post_id ID of post we are summarizing.
+ *
+ * @return {array} Request body.
+ */
+ $body = apply_filters(
+ 'classifai_azure_openai_key_takeaways_request_body',
+ [
+ 'messages' => [
+ [
+ 'role' => 'system',
+ 'content' => 'You will be provided with content delimited by triple quotes. ' . $prompt,
+ ],
+ [
+ 'role' => 'user',
+ 'content' => '"""' . $this->get_content( $post_id, 0, false, $args['content'] ) . '"""',
+ ],
+ ],
+ 'response_format' => [
+ 'type' => 'json_schema',
+ 'json_schema' => [
+ 'name' => 'key_takeaways',
+ 'schema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'takeaways' => [
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ 'required' => [ 'takeaways' ],
+ 'additionalProperties' => false,
+ ],
+ 'strict' => true,
+ ],
+ ],
+ 'temperature' => 0.9,
+ ],
+ $post_id
+ );
+
+ // Make our API request.
+ $response = wp_remote_post(
+ $this->prep_api_url( $feature ),
+ [
+ 'headers' => [
+ 'api-key' => $settings[ static::ID ]['api_key'],
+ 'Content-Type' => 'application/json',
+ ],
+ 'body' => wp_json_encode( $body ),
+ ]
+ );
+ $response = $this->get_result( $response );
+
+ // Extract out the response, if it exists.
+ if ( ! is_wp_error( $response ) && ! empty( $response['choices'] ) ) {
+ foreach ( $response['choices'] as $choice ) {
+ if ( isset( $choice['message'], $choice['message']['content'] ) ) {
+ // We expect the response to be valid json since we requested that schema.
+ $takeaways = json_decode( $choice['message']['content'], true );
+
+ if ( isset( $takeaways['takeaways'] ) && is_array( $takeaways['takeaways'] ) ) {
+ $response = array_map(
+ function ( $takeaway ) {
+ return sanitize_text_field( trim( $takeaway, ' "\'' ) );
+ },
+ $takeaways['takeaways']
+ );
+ } else {
+ return new WP_Error( 'refusal', esc_html__( 'Request failed', 'classifai' ) );
+ }
+ } else {
+ return new WP_Error( 'refusal', esc_html__( 'Request failed', 'classifai' ) );
+ }
+
+ // If the request was refused, return an error.
+ if ( isset( $choice['message'], $choice['message']['refusal'] ) ) {
+ // translators: %s: error message.
+ return new WP_Error( 'refusal', sprintf( esc_html__( 'Request failed: %s', 'classifai' ), esc_html( $choice['message']['refusal'] ) ) );
+ }
+ }
+ }
+
+ return $response;
+ }
+
/**
* Get our content.
*
diff --git a/includes/Classifai/Providers/OpenAI/ChatGPT.php b/includes/Classifai/Providers/OpenAI/ChatGPT.php
index 7e126b11f..dc74753bd 100644
--- a/includes/Classifai/Providers/OpenAI/ChatGPT.php
+++ b/includes/Classifai/Providers/OpenAI/ChatGPT.php
@@ -9,6 +9,7 @@
use Classifai\Features\DescriptiveTextGenerator;
use Classifai\Features\ExcerptGeneration;
use Classifai\Features\TitleGeneration;
+use Classifai\Features\KeyTakeaways;
use Classifai\Providers\Provider;
use Classifai\Normalizer;
use WP_Error;
@@ -215,6 +216,9 @@ public function rest_endpoint_callback( $post_id = 0, string $route_to_call = ''
case 'resize_content':
$return = $this->resize_content( $post_id, $args );
break;
+ case 'key_takeaways':
+ $return = $this->generate_key_takeaways( $post_id, $args );
+ break;
}
return $return;
@@ -663,6 +667,147 @@ public function resize_content( int $post_id, array $args = array() ) {
return $return;
}
+ /**
+ * Generate key takeaways from content.
+ *
+ * @param int $post_id The Post ID we're processing
+ * @param array $args Arguments passed in.
+ * @return string|WP_Error
+ */
+ public function generate_key_takeaways( int $post_id = 0, array $args = [] ) {
+ if ( ! $post_id || ! get_post( $post_id ) ) {
+ return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required to generate key takeaways.', 'classifai' ) );
+ }
+
+ $feature = new KeyTakeaways();
+ $settings = $feature->get_settings();
+ $args = wp_parse_args(
+ array_filter( $args ),
+ [
+ 'content' => '',
+ 'title' => get_the_title( $post_id ),
+ 'render' => 'list',
+ ]
+ );
+
+ // These checks (and the one above) happen in the REST permission_callback,
+ // but we run them again here in case this method is called directly.
+ if ( empty( $settings ) || ( isset( $settings[ static::ID ]['authenticated'] ) && false === $settings[ static::ID ]['authenticated'] ) || ( ! $feature->is_feature_enabled() && ( ! defined( 'WP_CLI' ) || ! WP_CLI ) ) ) {
+ return new WP_Error( 'not_enabled', esc_html__( 'Key Takeaways generation is disabled or OpenAI authentication failed. Please check your settings.', 'classifai' ) );
+ }
+
+ $request = new APIRequest( $settings[ static::ID ]['api_key'] ?? '', $feature->get_option_name() );
+
+ $prompt = esc_textarea( get_default_prompt( $settings['key_takeaways_prompt'] ) ?? $feature->prompt );
+
+ // Replace our variables in the prompt.
+ $prompt_search = array( '{{TITLE}}' );
+ $prompt_replace = array( $args['title'] );
+ $prompt = str_replace( $prompt_search, $prompt_replace, $prompt );
+
+ /**
+ * Filter the prompt we will send to ChatGPT.
+ *
+ * @since x.x.x
+ * @hook classifai_chatgpt_key_takeaways_prompt
+ *
+ * @param {string} $prompt Prompt we are sending to ChatGPT. Gets added before post content.
+ * @param {int} $post_id ID of post we are summarizing.
+ *
+ * @return {string} Prompt.
+ */
+ $prompt = apply_filters( 'classifai_chatgpt_key_takeaways_prompt', $prompt, $post_id );
+
+ /**
+ * Filter the request body before sending to ChatGPT.
+ *
+ * @since x.x.x
+ * @hook classifai_chatgpt_key_takeaways_request_body
+ *
+ * @param {array} $body Request body that will be sent to ChatGPT.
+ * @param {int} $post_id ID of post we are summarizing.
+ *
+ * @return {array} Request body.
+ */
+ $body = apply_filters(
+ 'classifai_chatgpt_key_takeaways_request_body',
+ [
+ 'model' => $this->chatgpt_model,
+ 'messages' => [
+ [
+ 'role' => 'system',
+ 'content' => 'You will be provided with content delimited by triple quotes. ' . $prompt,
+ ],
+ [
+ 'role' => 'user',
+ 'content' => '"""' . $this->get_content( $post_id, 0, false, $args['content'] ) . '"""',
+ ],
+ ],
+ 'response_format' => [
+ 'type' => 'json_schema',
+ 'json_schema' => [
+ 'name' => 'key_takeaways',
+ 'schema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'takeaways' => [
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ 'required' => [ 'takeaways' ],
+ 'additionalProperties' => false,
+ ],
+ 'strict' => true,
+ ],
+ ],
+ 'temperature' => 0.9,
+ ],
+ $post_id
+ );
+
+ // Make our API request.
+ $response = $request->post(
+ $this->chatgpt_url,
+ [
+ 'body' => wp_json_encode( $body ),
+ ]
+ );
+
+ // Extract out the response, if it exists.
+ if ( ! is_wp_error( $response ) && ! empty( $response['choices'] ) ) {
+ foreach ( $response['choices'] as $choice ) {
+ if ( isset( $choice['message'], $choice['message']['content'] ) ) {
+ // We expect the response to be valid json since we requested that schema.
+ $takeaways = json_decode( $choice['message']['content'], true );
+
+ if ( isset( $takeaways['takeaways'] ) && is_array( $takeaways['takeaways'] ) ) {
+ $response = array_map(
+ function ( $takeaway ) {
+ return sanitize_text_field( trim( $takeaway, ' "\'' ) );
+ },
+ $takeaways['takeaways']
+ );
+ } else {
+ return new WP_Error( 'refusal', esc_html__( 'OpenAI request failed', 'classifai' ) );
+ }
+ } else {
+ return new WP_Error( 'refusal', esc_html__( 'OpenAI request failed', 'classifai' ) );
+ }
+
+ // If the request was refused, return an error.
+ if ( isset( $choice['message'], $choice['message']['refusal'] ) ) {
+ // translators: %s: error message.
+ return new WP_Error( 'refusal', sprintf( esc_html__( 'OpenAI request failed: %s', 'classifai' ), esc_html( $choice['message']['refusal'] ) ) );
+ }
+ }
+ }
+
+ return $response;
+ }
+
/**
* Get our content, trimming if needed.
*
diff --git a/includes/Classifai/Services/ServicesManager.php b/includes/Classifai/Services/ServicesManager.php
index 9c76503ae..764302108 100644
--- a/includes/Classifai/Services/ServicesManager.php
+++ b/includes/Classifai/Services/ServicesManager.php
@@ -77,6 +77,7 @@ public function register_language_processing_features( array $features ): array
'\Classifai\Features\TitleGeneration',
'\Classifai\Features\ExcerptGeneration',
'\Classifai\Features\ContentResizing',
+ '\Classifai\Features\KeyTakeaways',
'\Classifai\Features\TextToSpeech',
'\Classifai\Features\AudioTranscriptsGeneration',
'\Classifai\Features\Moderation',
diff --git a/readme.txt b/readme.txt
index 50da5c9d7..68dc993ad 100644
--- a/readme.txt
+++ b/readme.txt
@@ -19,6 +19,7 @@ Tap into leading cloud-based services like [OpenAI](https://openai.com/), [Micro
**Features**
* Generate a summary of post content and store it as an excerpt using [OpenAI's ChatGPT API](https://platform.openai.com/docs/guides/chat), [Microsoft Azure's OpenAI service](https://azure.microsoft.com/en-us/products/ai-services/openai-service) or [Google's Gemini API](https://ai.google.dev/docs/gemini_api_overview)
+* Generate key takeaways from post content and render at the top of a post using [OpenAI's ChatGPT API](https://platform.openai.com/docs/guides/chat) or [Microsoft Azure's OpenAI service](https://azure.microsoft.com/en-us/products/ai-services/openai-service)
* Generate titles from post content using [OpenAI's ChatGPT API](https://platform.openai.com/docs/guides/chat), [Microsoft Azure's OpenAI service](https://azure.microsoft.com/en-us/products/ai-services/openai-service) or [Google's Gemini API](https://ai.google.dev/docs/gemini_api_overview)
* Expand or condense text content using [OpenAI's ChatGPT API](https://platform.openai.com/docs/guides/chat), [Microsoft Azure's OpenAI service](https://azure.microsoft.com/en-us/products/ai-services/openai-service) or [Google's Gemini API](https://ai.google.dev/docs/gemini_api_overview)
* Generate new images on demand to use in-content or as a featured image using [OpenAI's DALL·E 3 API](https://platform.openai.com/docs/guides/images)
diff --git a/src/js/settings/components/feature-additional-settings/index.js b/src/js/settings/components/feature-additional-settings/index.js
index 99644db2e..adb58a066 100644
--- a/src/js/settings/components/feature-additional-settings/index.js
+++ b/src/js/settings/components/feature-additional-settings/index.js
@@ -15,6 +15,7 @@ import { TextToSpeechSettings } from './text-to-speech';
import { TitleGenerationSettings } from './title-generation';
import { ContentResizingSettings } from './content-resizing';
import { ExcerptGenerationSettings } from './excerpt-generation';
+import { KeyTakeawaysSettings } from './key-takeaways';
import { ClassificationSettings } from './classification';
import { ModerationSettings } from './moderation';
import { Smart404Settings } from './smart-404';
@@ -45,6 +46,9 @@ const AdditionalSettingsFields = () => {
case 'feature_content_resizing':
return ;
+ case 'feature_key_takeaways':
+ return ;
+
case 'feature_descriptive_text_generator':
return ;
diff --git a/src/js/settings/components/feature-additional-settings/key-takeaways.js b/src/js/settings/components/feature-additional-settings/key-takeaways.js
new file mode 100644
index 000000000..ca7b05beb
--- /dev/null
+++ b/src/js/settings/components/feature-additional-settings/key-takeaways.js
@@ -0,0 +1,46 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect, useDispatch } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { SettingsRow } from '../settings-row';
+import { STORE_NAME } from '../../data/store';
+import { PromptRepeater } from './prompt-repeater';
+
+/**
+ * Component for Key Takeaways feature settings.
+ *
+ * This component is used within the FeatureSettings component to allow users
+ * to configure the Key Takeaways feature.
+ *
+ * @return {React.ReactElement} KeyTakeawaysSettings component.
+ */
+export const KeyTakeawaysSettings = () => {
+ const featureSettings = useSelect( ( select ) =>
+ select( STORE_NAME ).getFeatureSettings()
+ );
+ const { setFeatureSettings } = useDispatch( STORE_NAME );
+ const setPrompts = ( prompts ) => {
+ setFeatureSettings( {
+ key_takeaways_prompt: prompts,
+ } );
+ };
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
diff --git a/tests/cypress/integration/language-processing/key-takeaways-azure-openai.test.js b/tests/cypress/integration/language-processing/key-takeaways-azure-openai.test.js
new file mode 100644
index 000000000..663eec193
--- /dev/null
+++ b/tests/cypress/integration/language-processing/key-takeaways-azure-openai.test.js
@@ -0,0 +1,134 @@
+describe( '[Language processing] Key Takeaways Tests', () => {
+ before( () => {
+ cy.login();
+ cy.visitFeatureSettings( 'language_processing/feature_key_takeaways' );
+ cy.get( '#classifai-logo' ).should( 'exist' );
+ cy.enableFeature();
+ cy.saveFeatureSettings();
+ cy.optInAllFeatures();
+ cy.disableClassicEditor();
+ } );
+
+ beforeEach( () => {
+ cy.login();
+ } );
+
+ it( 'Can save Feature settings', () => {
+ cy.visitFeatureSettings( 'language_processing/feature_key_takeaways' );
+ cy.get( '#classifai-logo' ).should( 'exist' );
+ cy.selectProvider( 'azure_openai' );
+ cy.get( 'input#azure_openai_endpoint_url' )
+ .clear()
+ .type( 'https://e2e-test-azure-openai.test/' );
+ cy.get( 'input#azure_openai_api_key' ).clear().type( 'password' );
+ cy.get( 'input#azure_openai_deployment' ).clear().type( 'test' );
+
+ cy.enableFeature();
+ cy.allowFeatureToAdmin();
+ cy.saveFeatureSettings();
+ } );
+
+ it( 'Can add the Key Takeaways block in a post', () => {
+ // Create test post and add our block.
+ cy.createPost( {
+ title: 'Test Key Takeaways post',
+ content: 'Test GPT content',
+ beforeSave: () => {
+ cy.insertBlock( 'classifai/key-takeaways' );
+ },
+ } ).then( () => {
+ cy.getBlockEditor()
+ .find(
+ '.wp-block-classifai-key-takeaways .wp-block-classifai-key-takeways__content'
+ )
+ .should( 'contain.text', 'Request failed' );
+ } );
+ } );
+
+ it( 'Can disable feature', () => {
+ // Disable feature.
+ cy.visitFeatureSettings( 'language_processing/feature_key_takeaways' );
+ cy.disableFeature();
+ cy.saveFeatureSettings();
+
+ // Verify that the feature is not available.
+ cy.createPost( {
+ title: 'Test Key Takeaways post disabled',
+ content: 'Test GPT content',
+ beforeSave: () => {
+ cy.insertBlock( 'classifai/key-takeaways' );
+ },
+ } ).then( () => {
+ cy.getBlockEditor()
+ .find( '.wp-block-classifai-key-takeaways' )
+ .should( 'not.exist' );
+ } );
+ } );
+
+ it( 'Can disable feature by role', () => {
+ cy.visitFeatureSettings( 'language_processing/feature_key_takeaways' );
+ cy.enableFeature();
+ cy.saveFeatureSettings();
+
+ // Disable admin role.
+ cy.disableFeatureForRoles( 'feature_key_takeaways', [
+ 'administrator',
+ ] );
+
+ // Verify that the feature is not available.
+ cy.createPost( {
+ title: 'Test Key Takeaways post disabled user',
+ content: 'Test GPT content',
+ beforeSave: () => {
+ cy.insertBlock( 'classifai/key-takeaways' );
+ },
+ } ).then( () => {
+ cy.getBlockEditor()
+ .find( '.wp-block-classifai-key-takeaways' )
+ .should( 'not.exist' );
+ } );
+ } );
+
+ it( 'Can disable feature by user', () => {
+ // Disable admin role.
+ cy.disableFeatureForRoles( 'feature_key_takeaways', [
+ 'administrator',
+ ] );
+
+ cy.enableFeatureForUsers( 'feature_key_takeaways', [] );
+
+ // Verify that the feature is not available.
+ cy.createPost( {
+ title: 'Test Key Takeaways post disabled user',
+ content: 'Test GPT content',
+ beforeSave: () => {
+ cy.insertBlock( 'classifai/key-takeaways' );
+ },
+ } ).then( () => {
+ cy.getBlockEditor()
+ .find( '.wp-block-classifai-key-takeaways' )
+ .should( 'not.exist' );
+ } );
+ } );
+
+ it( 'User can opt-out of feature', () => {
+ // Enable user based opt-out.
+ cy.enableFeatureOptOut( 'feature_key_takeaways', 'azure_openai' );
+
+ // opt-out
+ cy.optOutFeature( 'feature_key_takeaways' );
+
+ // Verify that the feature is not available.
+ cy.createPost( {
+ title: 'Test Key Takeaways post disabled',
+ content: 'Test GPT content',
+ beforeSave: () => {
+ cy.insertBlock( 'classifai/key-takeaways' );
+ },
+ } ).then( () => {
+ cy.getBlockEditor()
+ .find( '.wp-block-classifai-key-takeaways' )
+ .should( 'not.exist' );
+ } );
+ } );
+} );
diff --git a/tests/cypress/integration/language-processing/key-takeaways-openai-chatgpt.test.js b/tests/cypress/integration/language-processing/key-takeaways-openai-chatgpt.test.js
new file mode 100644
index 000000000..72a20a643
--- /dev/null
+++ b/tests/cypress/integration/language-processing/key-takeaways-openai-chatgpt.test.js
@@ -0,0 +1,180 @@
+describe( '[Language processing] Key Takeaways Tests', () => {
+ before( () => {
+ cy.login();
+ cy.visitFeatureSettings( 'language_processing/feature_key_takeaways' );
+ cy.get( '#classifai-logo' ).should( 'exist' );
+ cy.enableFeature();
+ cy.saveFeatureSettings();
+ cy.optInAllFeatures();
+ cy.disableClassicEditor();
+ } );
+
+ beforeEach( () => {
+ cy.login();
+ } );
+
+ it( 'Can save Feature settings', () => {
+ cy.visitFeatureSettings( 'language_processing/feature_key_takeaways' );
+ cy.get( '#classifai-logo' ).should( 'exist' );
+ cy.selectProvider( 'openai_chatgpt' );
+ cy.get( '#openai_chatgpt_api_key' ).clear().type( 'password' );
+
+ cy.enableFeature();
+ cy.allowFeatureToAdmin();
+ cy.saveFeatureSettings();
+ } );
+
+ it( 'Can add the Key Takeaways block in a post', () => {
+ // Create test post and add our block.
+ cy.createPost( {
+ title: 'Test Key Takeaways post',
+ content: 'Test GPT content',
+ beforeSave: () => {
+ cy.insertBlock( 'classifai/key-takeaways' );
+ },
+ } ).then( () => {
+ cy.getBlockEditor()
+ .find(
+ '.wp-block-classifai-key-takeaways .wp-block-classifai-key-takeways__content'
+ )
+ .should( 'contain.text', 'OpenAI request failed' );
+ } );
+ } );
+
+ it( 'Can set multiple custom prompts, select one as the default and delete one.', () => {
+ cy.visitFeatureSettings( 'language_processing/feature_key_takeaways' );
+
+ // Add three custom prompts.
+ cy.get( 'button.components-button.action__add_prompt' )
+ .click()
+ .click()
+ .click();
+ cy.get(
+ '.classifai-prompts div.classifai-field-type-prompt-setting'
+ ).should( 'have.length', 4 );
+
+ // Set the data for each prompt.
+ cy.get( '#classifai-prompt-setting-1 .classifai-prompt-title input' )
+ .clear()
+ .type( 'First custom prompt' );
+ cy.get( '#classifai-prompt-setting-1 .classifai-prompt-text textarea' )
+ .clear()
+ .type( 'This is our first custom prompt' );
+
+ cy.get( '#classifai-prompt-setting-2 .classifai-prompt-title input' )
+ .clear()
+ .type( 'Second custom prompt' );
+ cy.get( '#classifai-prompt-setting-2 .classifai-prompt-text textarea' )
+ .clear()
+ .type( 'This prompt should be deleted' );
+ cy.get( '#classifai-prompt-setting-3 .classifai-prompt-title input' )
+ .clear()
+ .type( 'Third custom prompt' );
+ cy.get( '#classifai-prompt-setting-3 .classifai-prompt-text textarea' )
+ .clear()
+ .type( 'This is a custom prompt' );
+
+ // Set the third prompt as our default.
+ cy.get(
+ '#classifai-prompt-setting-3 .actions-rows button.action__set_default'
+ ).click( { force: true } );
+
+ // Delete the second prompt.
+ cy.get(
+ '#classifai-prompt-setting-2 .actions-rows button.action__remove_prompt'
+ ).click( { force: true } );
+ cy.get( 'div.components-confirm-dialog button.is-primary' ).click();
+ cy.get(
+ '.classifai-prompts div.classifai-field-type-prompt-setting'
+ ).should( 'have.length', 3 );
+
+ cy.saveFeatureSettings();
+ } );
+
+ it( 'Can disable feature', () => {
+ // Disable feature.
+ cy.visitFeatureSettings( 'language_processing/feature_key_takeaways' );
+ cy.disableFeature();
+ cy.saveFeatureSettings();
+
+ // Verify that the feature is not available.
+ cy.createPost( {
+ title: 'Test Key Takeaways post disabled',
+ content: 'Test GPT content',
+ beforeSave: () => {
+ cy.insertBlock( 'classifai/key-takeaways' );
+ },
+ } ).then( () => {
+ cy.getBlockEditor()
+ .find( '.wp-block-classifai-key-takeaways' )
+ .should( 'not.exist' );
+ } );
+ } );
+
+ it( 'Can disable feature by role', () => {
+ cy.visitFeatureSettings( 'language_processing/feature_key_takeaways' );
+ cy.enableFeature();
+ cy.saveFeatureSettings();
+
+ // Disable admin role.
+ cy.disableFeatureForRoles( 'feature_key_takeaways', [
+ 'administrator',
+ ] );
+
+ // Verify that the feature is not available.
+ cy.createPost( {
+ title: 'Test Key Takeaways post disabled user',
+ content: 'Test GPT content',
+ beforeSave: () => {
+ cy.insertBlock( 'classifai/key-takeaways' );
+ },
+ } ).then( () => {
+ cy.getBlockEditor()
+ .find( '.wp-block-classifai-key-takeaways' )
+ .should( 'not.exist' );
+ } );
+ } );
+
+ it( 'Can disable feature by user', () => {
+ // Disable admin role.
+ cy.disableFeatureForRoles( 'feature_key_takeaways', [
+ 'administrator',
+ ] );
+
+ cy.enableFeatureForUsers( 'feature_key_takeaways', [] );
+
+ // Verify that the feature is not available.
+ cy.createPost( {
+ title: 'Test Key Takeaways post disabled user',
+ content: 'Test GPT content',
+ beforeSave: () => {
+ cy.insertBlock( 'classifai/key-takeaways' );
+ },
+ } ).then( () => {
+ cy.getBlockEditor()
+ .find( '.wp-block-classifai-key-takeaways' )
+ .should( 'not.exist' );
+ } );
+ } );
+
+ it( 'User can opt-out of feature', () => {
+ // Enable user based opt-out.
+ cy.enableFeatureOptOut( 'feature_key_takeaways', 'openai_chatgpt' );
+
+ // opt-out
+ cy.optOutFeature( 'feature_key_takeaways' );
+
+ // Verify that the feature is not available.
+ cy.createPost( {
+ title: 'Test Key Takeaways post disabled',
+ content: 'Test GPT content',
+ beforeSave: () => {
+ cy.insertBlock( 'classifai/key-takeaways' );
+ },
+ } ).then( () => {
+ cy.getBlockEditor()
+ .find( '.wp-block-classifai-key-takeaways' )
+ .should( 'not.exist' );
+ } );
+ } );
+} );
diff --git a/webpack.config.js b/webpack.config.js
index 98e9aa3b4..b6b337104 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -9,6 +9,9 @@ module.exports = {
},
entry: {
admin: [ './src/js/admin.js' ],
+ 'key-takeaways-block': [
+ './includes/Classifai/Blocks/key-takeaways/index.js',
+ ],
'recommended-content-block': [
'./includes/Classifai/Blocks/recommended-content-block/index.js',
],