Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
73 changes: 35 additions & 38 deletions includes/Abilities/Title_Generation/Title_Generation.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,9 @@ class Title_Generation extends Abstract_Ability {
protected const CANDIDATES_DEFAULT = 3;

/**
* Returns the input schema of the ability.
* {@inheritDoc}
*
* @since 0.1.0
*
* @return array<string, mixed> The input schema of the ability.
*/
protected function input_schema(): array {
return array(
Expand All @@ -48,10 +46,10 @@ protected function input_schema(): array {
'sanitize_callback' => 'sanitize_text_field',
'description' => esc_html__( 'Content to generate title suggestions for.', 'ai' ),
),
'post_id' => array(
'type' => 'integer',
'sanitize_callback' => 'absint',
'description' => esc_html__( 'Content from this post will be used to generate title suggestions. This overrides the content parameter if both are provided.', 'ai' ),
'context' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => esc_html__( 'Additional context to use when generating title suggestions. 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' ),
),
'candidates' => array(
'type' => 'integer',
Expand All @@ -66,11 +64,9 @@ protected function input_schema(): array {
}

/**
* Returns the output schema of the ability.
* {@inheritDoc}
*
* @since 0.1.0
*
* @return array<string, mixed> The output schema of the ability.
*/
protected function output_schema(): array {
return array(
Expand All @@ -88,59 +84,57 @@ protected function output_schema(): array {
}

/**
* Executes the ability with the given input arguments.
* {@inheritDoc}
*
* @since 0.1.0
*
* @param mixed $input The input arguments to the ability.
* @return array{titles: array<string>}|\WP_Error The result of the ability execution, or a WP_Error on failure.
*/
protected function execute_callback( $input ) {
// Default arguments.
$args = wp_parse_args(
$input,
array(
'content' => null,
'post_id' => null,
'context' => null,
'candidates' => self::CANDIDATES_DEFAULT,
),
);

// If a post ID is provided, ensure the post exists before using its' content.
if ( $args['post_id'] ) {
$post = get_post( (int) $args['post_id'] );
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['post_id'] ) )
sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['context'] ) )
);
}

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

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

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

// Generate the titles.
$result = $this->generate_titles( $context, $args['candidates'] );
$result = $this->generate_titles( $content, $context, $args['candidates'] );

// If we have an error, return it.
if ( is_wp_error( $result ) ) {
Expand All @@ -167,25 +161,22 @@ static function ( $title ) {
}

/**
* Returns the permission callback of the ability.
* {@inheritDoc}
*
* @since 0.1.0
*
* @param mixed $args The input arguments to the ability.
* @return bool|\WP_Error True if the user has permission, WP_Error otherwise.
*/
protected function permission_callback( $args ) {
$post_id = isset( $args['post_id'] ) ? absint( $args['post_id'] ) : null;
$post_id = isset( $args['context'] ) && is_numeric( $args['context'] ) ? absint( $args['context'] ) : null;

if ( $post_id ) {
$post = get_post( $args['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( $args['post_id'] ) )
sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $post_id ) )
);
}

Expand Down Expand Up @@ -221,11 +212,9 @@ protected function permission_callback( $args ) {
}

/**
* Returns the meta of the ability.
* {@inheritDoc}
*
* @since 0.1.0
*
* @return array<string, mixed> The meta of the ability.
*/
protected function meta(): array {
return array(
Expand All @@ -238,11 +227,12 @@ protected function meta(): array {
*
* @since 0.1.0
*
* @param string|array<string, string> $context The context to generate a title from.
* @param string $content The content to generate title suggestions for.
* @param string|array<string, string> $context Additional context to use.
* @param int $candidates The number of titles to generate.
* @return array<string>|\WP_Error The generated titles, or a WP_Error if there was an error.
*/
protected function generate_titles( $context, int $candidates = 1 ) {
protected function generate_titles( string $content, $context, int $candidates = 1 ) {
// Convert the context to a string if it's an array.
if ( is_array( $context ) ) {
$context = implode(
Expand All @@ -261,8 +251,15 @@ static function ( $key, $value ) {
);
}

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

// If we have additional context, add it to the content.
if ( $context ) {
$content .= "\n\n<additional-context>" . $context . '</additional-context>';
}

// Generate the titles using the AI client.
return wp_ai_client_prompt( '"""' . $context . '"""' )
return wp_ai_client_prompt( $content )
->using_system_instruction( $this->get_system_instruction() )
->using_temperature( 0.7 )
->using_candidate_count( (int) $candidates )
Expand Down
9 changes: 6 additions & 3 deletions includes/Abilities/Title_Generation/system-instruction.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@
* @package WordPress\AI\Abilities\Title_Generation
*/

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

// phpcs:ignore Squiz.PHP.Heredoc.NotAllowed
return <<<'INSTRUCTION'
You are an editorial assistant that generates title suggestions for online articles and pages.

Goal: You will be provided with some context and you should then generate a concise, engaging, and accurate title that reflects that context. This title should be optimized for clarity, engagement, and SEO - while maintaining an appropriate tone for the author's intent and audience.
Goal: You will be provided with content and optionally some additional context and you should then generate a concise, engaging, and accurate title that reflects that. This title should be optimized for clarity, engagement, and SEO - while maintaining an appropriate tone for the author's intent and audience.

The title suggestion should follow these requirements:

- Be no more than 80 characters
- Should not contain any markdown, bullets, numbering, or formatting - plain text only
- Should be distinct in tone and focus
- Must reflect the actual content and context, not generic clickbait

The context you will be provided is delimited by triple quotes.
INSTRUCTION;
6 changes: 3 additions & 3 deletions src/experiments/title-generation/components/TitleToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,16 +120,16 @@ function TitleOptionsList( {
/**
* Generates titles for the given post ID and content.
*
* @param {number} postId The ID of the post to generate a title for.
* @param {string} content The content of the post to generate a title for.
* @param {number} postId The ID of the post to generate titles for.
* @param {string} content The content of the post to generate titles for.
* @return {Promise<string[]>} A promise that resolves to the generated titles.
*/
async function generateTitles(
postId: number,
content: string
): Promise< string[] > {
const params: TitleGenerationAbilityInput = {
post_id: postId,
context: postId.toString(),
content,
};

Expand Down
2 changes: 1 addition & 1 deletion src/experiments/title-generation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
export interface TitleGenerationAbilityInput {
content: string;
post_id: number;
context: string;
[ key: string ]: string | number | undefined;
}

Expand Down
26 changes: 13 additions & 13 deletions tests/Integration/Includes/Abilities/Title_GenerationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,16 @@ public function test_input_schema_returns_expected_structure() {
$this->assertEquals( 'object', $schema['type'], 'Schema type should be object' );
$this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' );
$this->assertArrayHasKey( 'content', $schema['properties'], 'Schema should have content property' );
$this->assertArrayHasKey( 'post_id', $schema['properties'], 'Schema should have post_id property' );
$this->assertArrayHasKey( 'context', $schema['properties'], 'Schema should have context property' );
$this->assertArrayHasKey( 'candidates', $schema['properties'], 'Schema should have candidates property' );

// Verify content property.
$this->assertEquals( 'string', $schema['properties']['content']['type'], 'Content should be string type' );
$this->assertEquals( 'sanitize_text_field', $schema['properties']['content']['sanitize_callback'], 'Content should use sanitize_text_field' );

// Verify post_id property.
$this->assertEquals( 'integer', $schema['properties']['post_id']['type'], 'Post ID should be integer type' );
$this->assertEquals( 'absint', $schema['properties']['post_id']['sanitize_callback'], 'Post ID should use absint' );
// Verify context property (can be string or numeric post ID).
$this->assertEquals( 'string', $schema['properties']['context']['type'], 'Context should be string type' );
$this->assertEquals( 'sanitize_text_field', $schema['properties']['context']['sanitize_callback'], 'Context should use sanitize_text_field' );

// Verify candidates property.
$this->assertEquals( 'integer', $schema['properties']['candidates']['type'], 'candidates should be integer type' );
Expand Down Expand Up @@ -229,7 +229,7 @@ public function test_execute_callback_with_post_id() {
);

$input = array(
'post_id' => $post_id,
'context' => $post_id,
'candidates' => 2,
);

Expand All @@ -253,7 +253,7 @@ public function test_execute_callback_with_post_id() {
}

/**
* Test that execute_callback() returns error when post_id points to non-existent post.
* Test that execute_callback() returns error when context points to non-existent post.
*
* @since 0.1.0
*/
Expand All @@ -263,7 +263,7 @@ public function test_execute_callback_with_invalid_post_id() {
$method->setAccessible( true );

$input = array(
'post_id' => 99999, // Non-existent post ID.
'context' => 99999, // Non-existent post ID.
);
$result = $method->invoke( $this->ability, $input );

Expand Down Expand Up @@ -322,7 +322,7 @@ public function test_execute_callback_uses_defaults() {
}

/**
* Test that execute_callback() prioritizes post_id over content.
* Test that execute_callback() prioritizes context (post ID) over content.
*
* @since 0.1.0
*/
Expand All @@ -341,7 +341,7 @@ public function test_execute_callback_post_id_overrides_content() {

$input = array(
'content' => 'This content should be ignored.',
'post_id' => $post_id,
'context' => $post_id,
);

try {
Expand Down Expand Up @@ -444,7 +444,7 @@ public function test_permission_callback_with_post_id_and_edit_capability() {
$user_id = $this->factory->user->create( array( 'role' => 'editor' ) );
wp_set_current_user( $user_id );

$result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) );
$result = $method->invoke( $this->ability, array( 'context' => $post_id ) );

$this->assertTrue( $result, 'Permission should be granted for user with edit_post capability' );
}
Expand All @@ -471,7 +471,7 @@ public function test_permission_callback_with_post_id_without_edit_capability()
$user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
wp_set_current_user( $user_id );

$result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) );
$result = $method->invoke( $this->ability, array( 'context' => $post_id ) );

$this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' );
$this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' );
Expand All @@ -490,7 +490,7 @@ public function test_permission_callback_with_nonexistent_post_id() {
$user_id = $this->factory->user->create( array( 'role' => 'editor' ) );
wp_set_current_user( $user_id );

$result = $method->invoke( $this->ability, array( 'post_id' => 99999 ) );
$result = $method->invoke( $this->ability, array( 'context' => 99999 ) );

$this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' );
$this->assertEquals( 'post_not_found', $result->get_error_code(), 'Error code should be post_not_found' );
Expand Down Expand Up @@ -527,7 +527,7 @@ public function test_permission_callback_with_post_type_without_show_in_rest() {
$user_id = $this->factory->user->create( array( 'role' => 'editor' ) );
wp_set_current_user( $user_id );

$result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) );
$result = $method->invoke( $this->ability, array( 'context' => $post_id ) );

$this->assertFalse( $result, 'Permission should be denied for post type without show_in_rest' );

Expand Down
Loading