Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new Key Takeaways Feature #843

Merged
merged 17 commits into from
Feb 11, 2025
Merged
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 |
Binary file added assets/img/screenshot-14.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions includes/Classifai/Blocks/key-takeaways/block.json
Original file line number Diff line number Diff line change
@@ -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"
}
185 changes: 185 additions & 0 deletions includes/Classifai/Blocks/key-takeaways/edit.js
Original file line number Diff line number Diff line change
@@ -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 ] );

Check warning on line 73 in includes/Classifai/Blocks/key-takeaways/edit.js

GitHub Actions / eslint

React Hook useEffect has missing dependencies: 'isLoading', 'render', 'setAttributes', and 'takeaways.length'. Either include them or remove the dependency array

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 (
<>
<BlockControls>
<ToolbarGroup controls={ renderControls } />
</BlockControls>
<InspectorControls>
<PanelBody title={ __( 'Settings', 'classifai' ) }>
<Button
label={ __( 'Re-generate key takeaways', 'classifai' ) }
text={ __( 'Refresh results', 'classifai' ) }
variant={ 'secondary' }
onClick={ () => setRun( true ) }
isBusy={ isLoading }
/>
</PanelBody>
</InspectorControls>

{ isLoading && (
<Placeholder
icon={ icon }
label={ __( 'Generating Key Takeaways', 'classifai' ) }
>
<Spinner
style={ {
height: 'calc(4px * 10)',
width: 'calc(4px * 10)',
} }
/>
</Placeholder>
) }

{ ! isLoading && (
<div { ...blockProps }>
<RichText
tagName="h2"
className="wp-block-heading wp-block-classifai-key-takeaways__title"
value={ title }
onChange={ ( value ) =>
setAttributes( { title: value } )
}
placeholder="Key Takeaways"
/>
<div
className="wp-block-classifai-key-takeways__content"
style={ { fontStyle: 'italic' } }
>
{ render === 'list' && (
<ul>
{ takeaways.map( ( takeaway, index ) => (
<RichText
tagName="li"
value={ takeaway }
key={ index }
onChange={ ( value ) =>
editTakeaways( index, value )
}
/>
) ) }
</ul>
) }
{ render === 'paragraph' && (
<>
{ takeaways.map( ( takeaway, index ) => (
<RichText
tagName="p"
value={ takeaway }
key={ index }
onChange={ ( value ) =>
editTakeaways( index, value )
}
/>
) ) }
</>
) }
</div>
</div>
) }
</>
);
};

export default BlockEdit;
25 changes: 25 additions & 0 deletions includes/Classifai/Blocks/key-takeaways/index.js
Original file line number Diff line number Diff line change
@@ -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,
} );
43 changes: 43 additions & 0 deletions includes/Classifai/Blocks/key-takeaways/render.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php
/**
* Render callback for the Key Takeaways block.
*
* @var array $attributes Block attributes.
* @var string $content Block content.
* @var WP_Block $block Block instance.
*/

$block_title = $attributes['title'] ?? '';
$layout = $attributes['render'] ?? 'list';
$takeaways = $attributes['takeaways'] ?? [];
?>

<div <?php echo get_block_wrapper_attributes(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
<?php if ( $block_title ) : ?>
<h2 class="wp-block-heading wp-block-classifai-key-takeaways__title">
<?php echo wp_kses_post( $block_title ); ?>
</h2>
<?php endif; ?>

<div class="wp-block-classifai-key-takeaways__content">
<?php
if ( 'list' === $layout ) {
echo '<ul>';
foreach ( (array) $takeaways as $takeaway ) {
printf(
'<li>%s</li>',
esc_html( $takeaway )
);
}
echo '</ul>';
} else {
foreach ( (array) $takeaways as $takeaway ) {
printf(
'<p>%s</p>',
esc_html( $takeaway )
);
}
}
?>
</div>
</div>
8 changes: 8 additions & 0 deletions includes/Classifai/Blocks/key-takeaways/save.js
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions includes/Classifai/Blocks/key-takeaways/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.wp-block-classifai-key-takeaways__content {
font-style: italic;
}
299 changes: 299 additions & 0 deletions includes/Classifai/Features/KeyTakeaways.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
<?php

namespace Classifai\Features;

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

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

/**
* Class KeyTakeaways
*/
class KeyTakeaways extends Feature {

/**
* ID of the current feature.
*
* @var string
*/
const ID = 'feature_key_takeaways';

/**
* Prompt for generating the key takeaways.
*
* @var string
*/
public $prompt = 'The content you will be provided with is from an already written article. This article has the title of: {{TITLE}}. Your task is to carefully read through this article and provide a summary that captures all the important points. This summary should be concise and limited to about 2-4 points but should also be detailed enough to allow someone to quickly grasp the full article. Read the article a few times before providing the summary and trim each point down to be as concise as possible.';

/**
* Constructor.
*/
public function __construct() {
$this->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(
'<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' ] );
$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<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 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;
}
}
150 changes: 149 additions & 1 deletion includes/Classifai/Providers/Azure/OpenAI.php
Original file line number Diff line number Diff line change
@@ -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.
*
145 changes: 145 additions & 0 deletions includes/Classifai/Providers/OpenAI/ChatGPT.php
Original file line number Diff line number Diff line change
@@ -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.
*
1 change: 1 addition & 0 deletions includes/Classifai/Services/ServicesManager.php
Original file line number Diff line number Diff line change
@@ -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',
1 change: 1 addition & 0 deletions readme.txt
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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 <ContentResizingSettings />;

case 'feature_key_takeaways':
return <KeyTakeawaysSettings />;

case 'feature_descriptive_text_generator':
return <DescriptiveTextGeneratorSettings />;

Original file line number Diff line number Diff line change
@@ -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 (
<>
<SettingsRow
label={ __( 'Prompt', 'classifai' ) }
description={ __( 'Add a custom prompt.', 'classifai' ) }
>
<PromptRepeater
prompts={ featureSettings.key_takeaways_prompt }
setPrompts={ setPrompts }
/>
</SettingsRow>
</>
);
};
Original file line number Diff line number Diff line change
@@ -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' );
} );
} );
} );
Original file line number Diff line number Diff line change
@@ -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' );
} );
} );
} );
3 changes: 3 additions & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
@@ -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',
],