-
Notifications
You must be signed in to change notification settings - Fork 168
Prototype : Implement slug generation feature with UI and tests #897
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
base: develop
Are you sure you want to change the base?
Changes from 11 commits
4834027
6560675
6cfa883
a6c6515
12d4068
8aaad26
8fe69e7
af8a3be
45e45da
b33ed3b
0530904
e0eed59
37d992a
5399fc0
48cc31b
bfb738c
4d18633
69b0005
87cf9db
6e34d46
2587bb4
ec91087
f2ed237
676dabc
bd493ca
e1450cd
5ca010f
ea89f69
9819268
b82d316
216c4d0
18cf443
2ffc92d
662a62d
1e950c8
beee53d
8c38adc
cefda9c
1cd296e
cebfaa5
7132e0e
e4355c1
777289a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,299 @@ | ||
| <?php | ||
| /** | ||
| * Slug generation WordPress Ability implementation. | ||
| * | ||
| * @package WordPress\AI | ||
| */ | ||
|
|
||
| declare( strict_types=1 ); | ||
|
|
||
| namespace WordPress\AI\Abilities\Slug_Generation; | ||
|
|
||
| use WP_Error; | ||
| use WordPress\AI\Abstracts\Abstract_Ability; | ||
| use WordPress\AI\Experiments\Slug_Generation\Slug_Generation as Slug_Generation_Experiment; | ||
|
|
||
| use function WordPress\AI\get_post_context; | ||
| use function WordPress\AI\normalize_content; | ||
|
|
||
| /** | ||
| * Slug generation WordPress Ability. | ||
| * | ||
| * @since x.x.x | ||
| */ | ||
| class Slug_Generation extends Abstract_Ability { | ||
|
|
||
| /** | ||
| * {@inheritDoc} | ||
| * | ||
| * @since x.x.x | ||
| */ | ||
| protected function guideline_categories(): array { | ||
| return array( 'site', 'copy' ); | ||
| } | ||
|
|
||
| /** | ||
| * {@inheritDoc} | ||
| * | ||
| * @since x.x.x | ||
| */ | ||
| protected function input_schema(): array { | ||
| return array( | ||
| 'type' => 'object', | ||
| 'properties' => array( | ||
| 'title' => array( | ||
| 'type' => 'string', | ||
| 'sanitize_callback' => 'sanitize_text_field', | ||
| 'description' => esc_html__( 'Title to generate slug suggestions for.', 'ai' ), | ||
| ), | ||
| 'content' => array( | ||
| 'type' => 'string', | ||
| 'sanitize_callback' => 'sanitize_text_field', | ||
| 'description' => esc_html__( 'Content to generate slug suggestions for.', 'ai' ), | ||
| ), | ||
| 'context' => array( | ||
| 'type' => 'string', | ||
| 'sanitize_callback' => 'sanitize_text_field', | ||
| 'description' => esc_html__( 'Additional context or post ID.', 'ai' ), | ||
| ), | ||
| 'number_of_suggestions' => array( | ||
| 'type' => 'integer', | ||
| 'sanitize_callback' => 'absint', | ||
| 'default' => 3, | ||
| 'description' => esc_html__( 'Number of slug suggestions to return.', 'ai' ), | ||
| ), | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * {@inheritDoc} | ||
| * | ||
| * @since x.x.x | ||
| */ | ||
| protected function output_schema(): array { | ||
| return array( | ||
| 'type' => 'object', | ||
| 'properties' => array( | ||
| 'slugs' => array( | ||
| 'type' => 'array', | ||
| 'items' => array( | ||
| 'type' => 'string', | ||
| ), | ||
| 'description' => esc_html__( 'Generated slug suggestions.', 'ai' ), | ||
| ), | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * {@inheritDoc} | ||
| * | ||
| * @since x.x.x | ||
| */ | ||
| protected function execute_callback( $input ) { | ||
| $args = wp_parse_args( | ||
| $input, | ||
| array( | ||
| 'title' => null, | ||
| 'content' => null, | ||
| 'context' => null, | ||
| 'number_of_suggestions' => 3, | ||
| ) | ||
| ); | ||
|
|
||
| $post_id = null; | ||
| if ( is_numeric( $args['context'] ) ) { | ||
| $post_id = (int) $args['context']; | ||
| $post = get_post( $post_id ); | ||
|
|
||
| if ( ! $post ) { | ||
| return new WP_Error( | ||
| 'post_not_found', | ||
| /* translators: %d: Post ID. */ | ||
| sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), $post_id ) | ||
| ); | ||
| } | ||
|
|
||
| // Fetch the post context when a numeric post ID is provided. | ||
| $context = get_post_context( $post->ID ); | ||
| $post_content = $context['content'] ?? ''; | ||
| $post_title = $post->post_title; | ||
| unset( $context['content'] ); | ||
|
|
||
| // Override with explicitly passed title or content if available. | ||
| if ( $args['title'] ) { | ||
| $post_title = sanitize_text_field( $args['title'] ); | ||
| } | ||
| if ( $args['content'] ) { | ||
| $post_content = normalize_content( $args['content'] ); | ||
| } | ||
| } else { | ||
| $post_content = normalize_content( $args['content'] ?? '' ); | ||
| $post_title = sanitize_text_field( $args['title'] ?? '' ); | ||
| $context = $args['context'] ?? ''; | ||
| } | ||
|
|
||
| if ( empty( $post_title ) && empty( $post_content ) ) { | ||
| return new WP_Error( | ||
| 'insufficient_data', | ||
| esc_html__( 'Post title or content is required to generate slug suggestions.', 'ai' ) | ||
| ); | ||
| } | ||
|
|
||
| // Build the prompt input with structured XML tags for title, content, and context. | ||
| $prompt_input = ''; | ||
| if ( ! empty( $post_title ) ) { | ||
| $prompt_input .= "<title>{$post_title}</title>\n\n"; | ||
| } | ||
| if ( ! empty( $post_content ) ) { | ||
| $prompt_input .= "<content>{$post_content}</content>"; | ||
| } | ||
| if ( ! empty( $context ) ) { | ||
| if ( is_array( $context ) ) { | ||
| $context = implode( "\n", $context ); | ||
| } | ||
| $prompt_input .= "\n\n<additional-context>{$context}</additional-context>"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So our context here loses it's keys, so no way to know what each piece of data actually represents. Ideally we build a |
||
| } | ||
|
|
||
| $number_of_suggestions = (int) apply_filters( 'wpai_slug_generation_number_of_suggestions', (int) $args['number_of_suggestions'] ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should add a filter docblock here
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, we should probably have some validation here after the filter runs. What if someone uses the filter and doesn't return a number, or returns something like 1000 or 0? Ideally we set a minimum and maximum (1 to 10?) and ensure this is always an integer between those two
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we do the above, we can also update the schema with minimum and maximum values |
||
|
|
||
| // Generate the raw slug suggestion text from the AI model. | ||
| $result = $this->generate_slugs( $prompt_input, $context, $number_of_suggestions ); | ||
|
|
||
| if ( is_wp_error( $result ) ) { | ||
| return $result; | ||
| } | ||
|
|
||
| if ( empty( $result ) ) { | ||
| return new WP_Error( | ||
| 'no_results', | ||
| esc_html__( 'No slug suggestion was generated.', 'ai' ) | ||
| ); | ||
| } | ||
|
|
||
| // Parse the output lines into clean, sanitized WordPress slugs. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any reason to approach it this way instead of using structured outputs? Typically that will work better than just hoping the LLM returns things in the structure we want and avoids us having to do this type of parsing
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed! Structured outputs are definitely better. I originally set it up this way because my local testing model didn't support JSON schemas. I'll update the code and push a new commit. |
||
| $lines = explode( "\n", $result ); | ||
| $slugs = array(); | ||
| foreach ( $lines as $line ) { | ||
| $line = trim( $line, " \t\n\r\0\x0B\"'" ); | ||
| if ( empty( $line ) ) { | ||
| continue; | ||
| } | ||
|
|
||
| $slugs[] = sanitize_title( $line ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe |
||
| } | ||
|
|
||
| $slugs = array_slice( array_unique( array_filter( $slugs ) ), 0, $number_of_suggestions ); | ||
|
|
||
| if ( empty( $slugs ) ) { | ||
| return new WP_Error( | ||
| 'no_results', | ||
| esc_html__( 'No slug suggestion was generated.', 'ai' ) | ||
| ); | ||
| } | ||
|
|
||
| return array( | ||
| 'slugs' => $slugs, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * {@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 ); | ||
|
|
||
| if ( ! $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 slugs for this post.', 'ai' ) | ||
| ); | ||
| } | ||
|
|
||
| $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' ) ) { | ||
| return new WP_Error( | ||
| 'insufficient_capabilities', | ||
| esc_html__( 'You do not have permission to generate slugs.', 'ai' ) | ||
| ); | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * {@inheritDoc} | ||
| * | ||
| * @since x.x.x | ||
| */ | ||
| protected function meta(): array { | ||
| return array( | ||
| 'show_in_rest' => true, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Generates slug suggestions from the prompt. | ||
| * | ||
| * @since x.x.x | ||
| * | ||
| * @param string $prompt The prompt. | ||
| * @param mixed $context The context. | ||
| * @param int $number_of_suggestions The number of suggestions. | ||
| * @return string|\WP_Error The generated suggestions, or WP_Error. | ||
| */ | ||
| protected function generate_slugs( string $prompt, $context, int $number_of_suggestions ) { | ||
| $prompt = $this->filter_prompt( $prompt, $context ); | ||
| $prompt_builder = $this->get_prompt_builder( $prompt, $number_of_suggestions ); | ||
|
|
||
| if ( is_wp_error( $prompt_builder ) ) { | ||
| return $prompt_builder; | ||
| } | ||
|
|
||
| return $prompt_builder->generate_text(); | ||
| } | ||
|
|
||
| /** | ||
| * Gets a prompt builder for generating slugs. | ||
| * | ||
| * @since x.x.x | ||
| * | ||
| * @param string $prompt The prompt. | ||
| * @param int $number_of_suggestions The number of suggestions. | ||
| * @return \WP_AI_Client_Prompt_Builder|\WP_Error The prompt builder, or WP_Error. | ||
| */ | ||
| private function get_prompt_builder( string $prompt, int $number_of_suggestions ) { | ||
| $prompt_builder = wp_ai_client_prompt( $prompt ) | ||
| ->using_system_instruction( $this->get_system_instruction( null, array( 'number_of_suggestions' => $number_of_suggestions ) ) ) | ||
| ->using_temperature( 0.5 ); | ||
|
|
||
| $prompt_builder = $this->filter_prompt_builder( $prompt_builder, Slug_Generation_Experiment::class, array(), $prompt ); | ||
|
|
||
| return $this->ensure_text_generation_supported( | ||
| $prompt_builder, | ||
| esc_html__( 'Slug generation failed. Please ensure you have a connected provider that supports text generation.', 'ai' ) | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| <?php | ||
| /** | ||
| * System instruction for the Slug Generation ability. | ||
| * | ||
| * @package WordPress\AI\Abilities\Slug_Generation | ||
| */ | ||
|
|
||
| // Exit if accessed directly. | ||
| if ( ! defined( 'ABSPATH' ) ) { | ||
| exit; | ||
| } | ||
|
|
||
| $wpai_slug_num = isset( $number_of_suggestions ) ? (int) $number_of_suggestions : 3; | ||
|
|
||
| return sprintf( | ||
| 'You are an editorial assistant that generates permalink slug suggestions for online articles and pages. | ||
|
|
||
| Goal: You will be provided with a title and/or content, and optionally some additional context. You should generate a list of url-safe, concise, keyword-relevant permalink slug options (separated by newlines) that represent the content. | ||
|
|
||
| The slug suggestions should follow these requirements: | ||
| - Be concise (typically 2 to 5 words) and optimized for SEO. | ||
| - Focus on key concepts and relevant keywords. | ||
| - Use only lowercase letters, numbers, and hyphens. | ||
| - Do not include any file extensions (e.g., .html, .php). | ||
| - Output exactly %d suggestions, one per line. | ||
| - Do not include any markdown, bullets, numbering, or formatting. | ||
| - Output only the raw slug text. Respond directly without preamble. Do not wrap the output in quotes. Do not add closing remarks or follow-up questions.', | ||
| $wpai_slug_num | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would we want a line in here asking it to match the language of the title given? We do this for other abilities to ensure the content returned matches the language of the content passed in |
||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,7 @@ final class Experiments { | |
| \WordPress\AI\Experiments\Meta_Description\Meta_Description::class, | ||
| \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, | ||
| \WordPress\AI\Experiments\Type_Ahead\Type_Ahead::class, | ||
| \WordPress\AI\Experiments\Slug_Generation\Slug_Generation::class, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We've been trying to organize these alphabetically so they display that way in the settings page |
||
| ); | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Open to thoughts here but not sure if generating a slug benefits from these guidelines. May be fine to just not pass these in