Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
379023c
Create new Experiment with metadata registration
TylerB24890 Mar 18, 2026
2227168
Create base meta description unit tests
TylerB24890 Mar 18, 2026
c00c9c6
Add unit tests for Meta Description ability and SEO Integration class.
TylerB24890 Mar 18, 2026
22532df
Create MetaDescription UI and CharacterCount components.
TylerB24890 Mar 18, 2026
0f3a901
Make the edit & regenerate links match the excerpt links
TylerB24890 Mar 18, 2026
0e938c6
PHPCS fixes
TylerB24890 Mar 18, 2026
09ba09d
Add filters to customize the model parameters and results
TylerB24890 Mar 18, 2026
5a029ca
Use wordpress useCopyToClipboard hook instead of custom implementation
TylerB24890 Mar 18, 2026
9160823
Update all styles to use wp core variables
TylerB24890 Mar 18, 2026
bb5dab3
Remove unnecessary loading spinner
TylerB24890 Mar 18, 2026
e5f5079
Add experiment documentation
TylerB24890 Mar 18, 2026
a09ab00
Override default button hover color for suggestions
TylerB24890 Mar 18, 2026
870d5ac
Ensure no direct access to system-instruction file
TylerB24890 Mar 18, 2026
fbcfb4f
Merge latest develop and resolve conflicts. Update Experiments to Fea…
TylerB24890 Mar 20, 2026
dca3163
Update all filter prefixes
TylerB24890 Mar 20, 2026
aaba592
Update experiment title and fix SEO Integration tests.
TylerB24890 Mar 20, 2026
d4dcd13
Fix generate/regenerate button labels.
TylerB24890 Mar 20, 2026
a6fee9f
Ensure the metadata can be saved with revisions
TylerB24890 Mar 20, 2026
9df548a
Merge branch 'develop' into feature/issue-240-seo-descriptions
TylerB24890 Mar 23, 2026
479c466
Apply additional unit tests to meet coverage requirements
TylerB24890 Mar 23, 2026
601ce58
Ensure all meta is saved when description is generated
TylerB24890 Mar 24, 2026
46c7991
Fix meta saving on drafted content
TylerB24890 Mar 24, 2026
7a6e467
Fix @since tags
TylerB24890 Mar 24, 2026
86d64f5
Fix unit tests
TylerB24890 Mar 24, 2026
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
473 changes: 473 additions & 0 deletions docs/experiments/meta-description.md

Large diffs are not rendered by default.

319 changes: 319 additions & 0 deletions includes/Abilities/Meta_Description/Meta_Description.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
<?php
/**
* Meta description generation WordPress Ability implementation.
*
* @package WordPress\AI
*/

declare( strict_types=1 );

namespace WordPress\AI\Abilities\Meta_Description;

use WP_Error;
use WP_Post;
use WP_Post_Type;
use WordPress\AI\Abstracts\Abstract_Ability;
use function WordPress\AI\get_post_context;
use function WordPress\AI\get_preferred_models_for_text_generation;
use function WordPress\AI\normalize_content;

/**
* Meta description generation WordPress Ability.
*
* @since x.x.x
*/
class Meta_Description extends Abstract_Ability {

/**
* Default number of suggestions to generate.
*
* @since x.x.x
* @var int
*/
public const DEFAULT_CANDIDATE_COUNT = 3;

/**
* {@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__( 'Post content to generate a meta description for.', 'ai' ),
),
'title' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => esc_html__( 'The post title, used to avoid duplication in the generated description.', 'ai' ),
),
'post_id' => array(
'type' => 'integer',
'sanitize_callback' => 'absint',
'description' => esc_html__( 'The post ID to generate a meta description for. If provided without content, the post content will be used.', 'ai' ),
),
),
);
}

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function output_schema(): array {
return array(
'type' => 'object',
'description' => esc_html__( 'Generated meta description suggestions.', 'ai' ),
'properties' => array(
'descriptions' => array(
'type' => 'array',
'description' => esc_html__( 'Array of meta description suggestions.', 'ai' ),
'items' => array(
'type' => 'object',
'properties' => array(
'text' => array(
'type' => 'string',
'description' => esc_html__( 'The meta description text.', 'ai' ),
),
'character_count' => array(
'type' => 'integer',
'description' => esc_html__( 'The character count of the description.', 'ai' ),
),
),
),
),
),
);
}

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function execute_callback( $input ) {
$args = wp_parse_args(
$input,
array(
'content' => null,
'title' => null,
'post_id' => null,
),
);

$content = '';
$title = $args['title'] ?? '';
$context = '';

// If a post ID is provided, fetch content and context from the post.
if ( $args['post_id'] ) {
$post = get_post( (int) $args['post_id'] );

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

$post_context = get_post_context( $post->ID );
$content = $post_context['content'] ?? '';
unset( $post_context['content'] );
$context = $post_context;

// Use the post title if none was provided.
if ( empty( $title ) && ! empty( $post->post_title ) ) {
$title = $post->post_title;
}
}

// Prefer explicitly provided content over post content.
if ( $args['content'] ) {
$content = normalize_content( $args['content'] );
}

if ( empty( $content ) ) {
return new WP_Error(
'content_not_provided',
esc_html__( 'Content is required to generate a meta description.', 'ai' )
);
}

$descriptions = $this->generate_descriptions( $content, $title, $context );
if ( is_wp_error( $descriptions ) ) {
return $descriptions;
}

if ( empty( $descriptions ) ) {
return new WP_Error(
'no_results',
esc_html__( 'No meta description suggestions were generated.', 'ai' )
);
}

return array( 'descriptions' => $descriptions );
}

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

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

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

if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'insufficient_capabilities',
esc_html__( 'You do not have permission to generate meta descriptions for this post.', 'ai' )
);
}

$post_type_obj = get_post_type_object( $post->post_type );
if ( ! $post_type_obj instanceof WP_Post_Type || empty( $post_type_obj->show_in_rest ) ) {
return false;
}
} elseif ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'insufficient_capabilities',
esc_html__( 'You do not have permission to generate meta descriptions.', 'ai' )
);
}

return true;
}

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

/**
* Generate meta description suggestions from the given content.
*
* @since x.x.x
*
* @param string $content The content to generate descriptions from.
* @param string $title The post title.
* @param string|array<string, string> $context Additional context to use.
* @return array<int, array{text: string, character_count: int}>|\WP_Error The generated descriptions, or a WP_Error.
*/
protected function generate_descriptions( string $content, string $title, $context ) {
// 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
)
);
}

$prompt = '<content>' . $content . '</content>';

if ( ! empty( $title ) ) {
$prompt .= "\n\n<title>" . $title . '</title>';
}

if ( ! empty( $context ) ) {
$prompt .= "\n\n<additional-context>" . $context . '</additional-context>';
}

/**
* Filters the prompt content sent to the AI model for meta description generation.
*
* Allows developers to modify or augment the content before it is sent to the model.
*
* @since x.x.x
*
* @param string $prompt The assembled prompt including content, title, and context tags.
* @param string $content The normalized post content.
* @param string $title The post title.
*/
$prompt = (string) apply_filters( 'wpai_meta_description_prompt', $prompt, $content, $title );

/**
* Filters the number of meta description candidates to generate.
*
* @since x.x.x
*
* @param int $candidate_count The number of candidates to request from the AI model.
*/
$candidate_count = (int) apply_filters( 'wpai_meta_description_candidate_count', self::DEFAULT_CANDIDATE_COUNT );

/**
* Filters the temperature for the result of the meta description generation.
*
* @since x.x.x
*
* @param float $result_temperature The temperature for the result of the meta description generation.
*/
$result_temperature = (float) apply_filters( 'wpai_meta_description_result_temperature', 0.7 );

$results = wp_ai_client_prompt( $prompt )
->using_system_instruction( $this->get_system_instruction() )
->using_temperature( $result_temperature )
->using_candidate_count( $candidate_count )
->using_model_preference( ...get_preferred_models_for_text_generation() )
->generate_texts();

if ( is_wp_error( $results ) ) {
return $results;
}

if ( ! is_array( $results ) ) {
return new WP_Error(
'no_results',
esc_html__( 'No meta description suggestions were generated.', 'ai' )
);
}

$descriptions = array();

foreach ( $results as $result ) {
if ( ! is_string( $result ) || empty( trim( $result ) ) ) {
continue;
}

$text = sanitize_text_field( trim( $result, ' "\'' ) );

$descriptions[] = array(
'text' => $text,
'character_count' => mb_strlen( $text ),
);
}

return $descriptions;
}
}
Loading
Loading