Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b9917b5
Add the base Summarization Experiment code
dkotter Dec 4, 2025
82bdbcf
Add the base Summarization Ability code
dkotter Dec 4, 2025
eb8e4e6
Add the system instructions for the Summarization Ability
dkotter Dec 4, 2025
7544a40
Modify a bit our post context Ability and helper function to return '…
dkotter Dec 4, 2025
47df6e4
Add tests
dkotter Dec 4, 2025
72a97e0
Revert changes to the get-post-details ability and instead remove emp…
dkotter Dec 4, 2025
da233f9
Merge branch 'develop' into feature/content-summarization-experiment
dkotter Dec 10, 2025
8bd5d6a
Allow passing in extra data when our system instructions are loaded
dkotter Dec 10, 2025
6824c04
Allow choosing the summary length, from short, medium or long and pas…
dkotter Dec 10, 2025
009f2c0
Dynamically set the expected return length depending on the passed in…
dkotter Dec 10, 2025
b56d200
Update tests
dkotter Dec 10, 2025
d186657
Pull the content out of the context so we can separate those a bit mo…
dkotter Dec 10, 2025
97ef7f9
Change from a post_id field to a context field. Only add context to o…
dkotter Dec 10, 2025
fd1a81c
Update tests
dkotter Dec 10, 2025
42834c7
Merge branch 'develop' into feature/content-summarization-experiment
dkotter Dec 12, 2025
e7b5109
Load the assets on the post edit screen
dkotter Dec 17, 2025
5f8e927
Build our assets
dkotter Dec 17, 2025
12d8d47
Add the base plugin registration and render component
dkotter Dec 17, 2025
a400786
Wire up the generation of a summary when the button is clicked
dkotter Dec 17, 2025
bb17301
Insert a paragraph block with the summary after it is generated. Make…
dkotter Dec 17, 2025
d4940db
Store the summary in post meta after it is generated
dkotter Dec 18, 2025
bc96c0f
Update outdated dependencies
dkotter Dec 18, 2025
d0392cf
Create a paragraph variation block and use that when we insert the su…
dkotter Dec 18, 2025
74acd10
Check if we have an existing summary block and if so, pull the conten…
dkotter Dec 18, 2025
b0a099f
Add custom toolbar controls to paragraph blocks marked as AI summaries
dkotter Dec 18, 2025
96ed39f
Move shared functionality into a hook so we can easily pull that in w…
dkotter Dec 18, 2025
cddf1e8
Fix some unrelated failing plugin checks
dkotter Dec 18, 2025
a211e84
Change prefix to see if that fixes plugin check
dkotter Dec 18, 2025
b12611c
Try turning off sniff
dkotter Dec 18, 2025
a3f1e4e
Merge branch 'develop' into feature/content-summarization-ui
jeffpaul Dec 20, 2025
54a312e
Create screenshot-#.gif
jeffpaul Dec 20, 2025
dd8ea07
add content summarization to readme
jeffpaul Dec 20, 2025
a3c9cbe
Merge branch 'develop' into feature/content-summarization-ui
dkotter Dec 23, 2025
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
Binary file added .wordpress-org/screenshot-#.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
252 changes: 252 additions & 0 deletions includes/Abilities/Summarization/Summarization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
<?php
/**
* Content summarization WordPress Ability implementation.
*
* @package WordPress\AI
*/

declare( strict_types=1 );

namespace WordPress\AI\Abilities\Summarization;

use WP_Error;
use WordPress\AI\Abstracts\Abstract_Ability;
use WordPress\AI_Client\AI_Client;

use function WordPress\AI\get_post_context;
use function WordPress\AI\get_preferred_models;
use function WordPress\AI\normalize_content;

/**
* Content summarization WordPress Ability.
*
* @since x.x.x
*/
class Summarization extends Abstract_Ability {

/**
* The default length of the summary.
*
* @since x.x.x
*
* @var string
*/
protected const LENGTH_DEFAULT = 'medium';

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function input_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'content' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => esc_html__( 'Content to summarize.', 'ai' ),
),
'context' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => esc_html__( 'Additional context to use when summarizing the content. This can either be a string of additional context or can be a post ID that will then be used to get context from that post (if it exists). If no content is provided but a valid post ID is used here, the content from that post will be used.', 'ai' ),
),
'length' => array(
'type' => 'enum',
'enum' => array( 'short', 'medium', 'long' ),
'default' => self::LENGTH_DEFAULT,
'description' => esc_html__( 'The length of the summary.', 'ai' ),
),
),
);
}

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function output_schema(): array {
return array(
'type' => 'string',
'description' => esc_html__( 'The summary of the content.', 'ai' ),
);
}

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function execute_callback( $input ) {
// Default arguments.
$args = wp_parse_args(
$input,
array(
'content' => null,
'context' => null,
'length' => self::LENGTH_DEFAULT,
),
);

// If a post ID is provided, ensure the post exists before using its' content.
if ( is_numeric( $args['context'] ) ) {
$post = get_post( (int) $args['context'] );

if ( ! $post ) {
return new WP_Error(
'post_not_found',
/* translators: %d: Post ID. */
sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['context'] ) )
);
}

// Get the post context.
$context = get_post_context( $post->ID );
$content = $context['content'] ?? '';
unset( $context['content'] );

// Default to the passed in content if it exists.
if ( $args['content'] ) {
$content = normalize_content( $args['content'] );
}
} else {
$content = normalize_content( $args['content'] ?? '' );
$context = $args['context'] ?? '';
}

// If we have no content, return an error.
if ( empty( $content ) ) {
return new WP_Error(
'content_not_provided',
esc_html__( 'Content is required to generate a summary.', 'ai' )
);
}

// Generate the summary.
$result = $this->generate_summary( $content, $context, $args['length'] );

// If we have an error, return it.
if ( is_wp_error( $result ) ) {
return $result;
}

// If we have no results, return an error.
if ( empty( $result ) ) {
return new WP_Error(
'no_results',
esc_html__( 'No summary was generated.', 'ai' )
);
}

// Return the summary in the format the Ability expects.
return sanitize_text_field( trim( $result ) );
}

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function permission_callback( $args ) {
$post_id = isset( $args['context'] ) && is_numeric( $args['context'] ) ? absint( $args['context'] ) : null;

if ( $post_id ) {
$post = get_post( $post_id );

// Ensure the post exists.
if ( ! $post ) {
return new WP_Error(
'post_not_found',
/* translators: %d: Post ID. */
sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $post_id ) )
);
}

// Ensure the user has permission to read this particular post.
if ( ! current_user_can( 'read_post', $post_id ) ) {
return new WP_Error(
'insufficient_capabilities',
esc_html__( 'You do not have permission to summarize this post.', 'ai' )
);
}

// Ensure the post type is allowed in REST endpoints.
$post_type = get_post_type( $post_id );

if ( ! $post_type ) {
return false;
}

$post_type_obj = get_post_type_object( $post_type );

if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) {
return false;
}
} elseif ( ! current_user_can( 'edit_posts' ) ) {
// Ensure the user has permission to edit posts in general.
return new WP_Error(
'insufficient_capabilities',
esc_html__( 'You do not have permission to summarize content.', 'ai' )
);
}

return true;
}

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function meta(): array {
return array(
'show_in_rest' => true,
);
}

/**
* Generates a summary from the given content.
*
* @since x.x.x
*
* @param string $content The content to summarize.
* @param string|array<string, string> $context Additional context to use.
* @param string $length The desired length of the summary.
* @return string|\WP_Error The generated summary, or a WP_Error if there was an error.
*/
protected function generate_summary( string $content, $context, string $length ) {
// Convert the context to a string if it's an array.
if ( is_array( $context ) ) {
$context = implode(
"\n",
array_map(
static function ( $key, $value ) {
return sprintf(
'%s: %s',
ucwords( str_replace( '_', ' ', $key ) ),
$value
);
},
array_keys( $context ),
$context
)
);
}

$content = "## Content\n\n" . $content;

// If we have additional context, add it to the content.
if ( $context ) {
$content .= "\n\n## Additional Context\n\n" . $context;
}

// Generate the summary using the AI client.
return AI_Client::prompt_with_wp_error( '"""' . $content . '"""' )
->using_system_instruction( $this->get_system_instruction( 'system-instruction.php', array( 'length' => $length ) ) )
->using_temperature( 0.9 )
->using_model_preference( ...get_preferred_models() )
->generate_text();
}
}
35 changes: 35 additions & 0 deletions includes/Abilities/Summarization/system-instruction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php
/**
* System instruction for the Summarization ability.
*
* @package WordPress\AI\Abilities\Summarization
*/

// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound

// Determine the length from the passed in global.
$length_desc = '2-3 sentences; 25-80 words';
if ( isset( $length ) ) {
if ( 'short' === $length ) {
$length_desc = '1 sentence; <= 25 words';
} elseif ( 'long' === $length ) {
$length_desc = '4-6 sentences; 80-160 words';
}
}

// phpcs:ignore Squiz.PHP.Heredoc.NotAllowed
return <<<INSTRUCTION
You are an editorial assistant that generates concise, factual, and neutral summaries of long-form content. Your summaries support both inline readability (e.g., top-of-post overview) and structured metadata use cases (search previews, featured cards, accessibility tools).

Goal: You will be provided with content and optionally some additional context. You will then generate a concise, factual, and neutral summary of that content that also keeps in mind the context. Write in complete sentences, avoid persuasive or stylistic language, do not use humor or exaggeration, and do not introduce information not present in the source.

The summary should follow these requirements:

- Target {$length_desc}
- Should not contain any markdown, bullets, numbering, or formatting - plain text only
- Provide a high-level overview, not a list of details
- Do not start with "This article is about..." or "This post explains..." or "This content describes..." or any other generic introduction
- Must reflect the actual content, not generic filler text

The data you will be provided is delimited by triple quotes.
INSTRUCTION;
27 changes: 20 additions & 7 deletions includes/Abstracts/Abstract_Ability.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,14 @@ abstract protected function meta(): array;
*
* @since 0.1.0
*
* @param string|null $filename Optional. Explicit filename to load. If not provided,
* attempts to load `system-instruction.php` or `prompt.php`.
* @param string|null $filename Optional. Explicit filename to load. If not provided,
* attempts to load `system-instruction.php` or `prompt.php`.
* @param array<string, mixed> $data Optional. Data to expose to the system instruction file.
* This data will be extracted as variables available in the file scope.
* @return string The system instruction for the feature.
*/
public function get_system_instruction( ?string $filename = null ): string {
return $this->load_system_instruction_from_file( $filename );
public function get_system_instruction( ?string $filename = null, array $data = array() ): string {
return $this->load_system_instruction_from_file( $filename, $data );
}

/**
Expand All @@ -135,13 +137,19 @@ public function get_system_instruction( ?string $filename = null ): string {
* return 'Your system instruction text here...';
* ```
*
* If data is provided, it will be extracted as variables available in the file scope.
* For example, if you pass `array( 'length' => 'short' )`, the variable `$length`
* will be available in the system instruction file.
*
* @since 0.1.0
*
* @param string|null $filename Optional. Explicit filename to load. If not provided,
* attempts to load `system-instruction.php`.
* @param string|null $filename Optional. Explicit filename to load. If not provided,
* attempts to load `system-instruction.php`.
* @param array<string, mixed> $data Optional. Data to expose to the system instruction file.
* This data will be extracted as variables available in the file scope.
* @return string The contents of the file, or empty string if file not found.
*/
protected function load_system_instruction_from_file( ?string $filename = null ): string {
protected function load_system_instruction_from_file( ?string $filename = null, array $data = array() ): string {
// Get the feature's directory using reflection.
$reflection = new ReflectionClass( $this );
$file_name = $reflection->getFileName();
Expand All @@ -152,6 +160,11 @@ protected function load_system_instruction_from_file( ?string $filename = null )

$feature_dir = dirname( $file_name );

// Extract data into variables for use in the included file.
if ( ! empty( $data ) ) {
extract( $data, EXTR_SKIP ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract
}

// If explicit filename provided, use it.
if ( null !== $filename ) {
$file_path = trailingslashit( $feature_dir ) . $filename;
Expand Down
3 changes: 2 additions & 1 deletion includes/Experiment_Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,10 @@ public function register_default_experiments(): void {
*/
private function get_default_experiments(): array {
$experiment_classes = array(
\WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class,
\WordPress\AI\Experiments\Image_Generation\Image_Generation::class,
\WordPress\AI\Experiments\Summarization\Summarization::class,
\WordPress\AI\Experiments\Title_Generation\Title_Generation::class,
\WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class,
);

/**
Expand Down
Loading