-
Notifications
You must be signed in to change notification settings - Fork 167
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
Open
milindmore22
wants to merge
43
commits into
WordPress:develop
Choose a base branch
from
milindmore22:feat/suggest-permalink
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
43 commits
Select commit
Hold shift + click to select a range
4834027
Add slug generation ability and system instructions with tests
milindmore22 6560675
Register slug generation experiment and integration tests
milindmore22 6cfa883
Add webpack entry, types, and styles for slug generation feature
milindmore22 a6c6515
Add UI components and pre-publish panel for slug generation
milindmore22 12d4068
test: add snackbar assertions to experiment toggles, include clean st…
milindmore22 8aaad26
test: simplify experiment snackbar assertions and ignore graphify-out…
milindmore22 8fe69e7
chore: update slug generation versioning to x.x.x and ignore graphify…
milindmore22 af8a3be
docs: improve code documentation
milindmore22 45e45da
chore: add JSDoc props to SlugGenerationModal
milindmore22 b33ed3b
chore: ignore graphify output directory and update test access modifi…
milindmore22 0530904
test: add extensive unit test coverage for Slug_Generation ability
milindmore22 e0eed59
Merge branch 'develop' into feat/suggest-permalink
jeffpaul 37d992a
test: add extensive integration tests for slug generation logic
milindmore22 5399fc0
test: update slug generation test case to handle empty post types
milindmore22 48cc31b
chore: remove unused guideline_categories method.
milindmore22 bfb738c
feat: add validation and filtering for slug suggestions
milindmore22 4d18633
feat: improve slug language matching
milindmore22 69b0005
chore: ignore graphify-out directory and correct documentation for en…
milindmore22 87cf9db
feat: reorder slug generation experiment in registration list
milindmore22 6e34d46
feat: add end-to-end test suite and mock API fixtures for slug genera…
milindmore22 2587bb4
refactor: remove obsolete guideline_categories test
milindmore22 ec91087
feat: update slug generation button styling, remove inline styles
milindmore22 f2ed237
feat: add current slug detection to SlugGenerationButton and update b…
milindmore22 676dabc
refactor: improve slug popover closing logic
milindmore22 bd493ca
feat: standardize slug suggestion count logic
milindmore22 e1450cd
feat: improve slug generation context formatting and ensure uniquenes…
milindmore22 5ca010f
refactor: replace slug suggestion buttons with RadioControl and enhan…
milindmore22 ea89f69
refactor: update slug selection logic in modal
milindmore22 9819268
refactor: type-safe global window access for slug generation settings…
milindmore22 b82d316
refactor: optimize slug generation observer with debouncing, targeted…
milindmore22 216c4d0
chore: reset global screen state in slug generation tests
milindmore22 18cf443
Fix linting issues
milindmore22 2ffc92d
refactor: remove redundant array filtering in slug generation
milindmore22 662a62d
refactor: update slug retrieval logic to fallback to generated_slug
milindmore22 1e950c8
chore: add spacing to slug suggestion components
milindmore22 beee53d
style: refactor slug pre-publish panel layout
milindmore22 8c38adc
style: update slug generator button layout
milindmore22 cefda9c
refactor: update mutation observer to track document body
milindmore22 1cd296e
chore: update slug generation container layout styles
milindmore22 cebfaa5
refactor: optimize slug collection logic
milindmore22 7132e0e
refactor: improve slug generation component formatting, consistency.
milindmore22 e4355c1
fix: improve slug panel detection and update E2E tests for WordPress …
milindmore22 777289a
chore: reformat slug generation test
milindmore22 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,326 @@ | ||
| <?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 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', | ||
| 'minimum' => 1, | ||
| 'maximum' => 10, | ||
| '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; | ||
| $post = 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_lines = array(); | ||
| foreach ( $context as $key => $value ) { | ||
| if ( is_array( $value ) ) { | ||
| $value = implode( ', ', $value ); | ||
| } | ||
| if ( is_string( $key ) && ! is_numeric( $key ) ) { | ||
| $context_lines[] = "{$key}: {$value}"; | ||
| } else { | ||
| $context_lines[] = (string) $value; | ||
| } | ||
| } | ||
| $context = implode( "\n", $context_lines ); | ||
| } | ||
| $prompt_input .= "\n\n<additional-context>{$context}</additional-context>"; | ||
| } | ||
|
|
||
| $number_of_suggestions = (int) $args['number_of_suggestions']; | ||
| $number_of_suggestions = min( max( $number_of_suggestions, 1 ), 10 ); | ||
|
|
||
| // 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, and unique WordPress slugs. | ||
| $lines = explode( "\n", $result ); | ||
| $slugs = array(); | ||
| foreach ( $lines as $line ) { | ||
| $line = trim( $line, " \t\n\r\0\x0B\"'" ); | ||
| if ( empty( $line ) ) { | ||
| continue; | ||
| } | ||
|
|
||
| $clean_slug = sanitize_title( str_replace( '_', '-', $line ) ); | ||
| if ( empty( $clean_slug ) ) { | ||
| continue; | ||
| } | ||
|
|
||
| if ( $post instanceof \WP_Post ) { | ||
| $slug = wp_unique_post_slug( | ||
| $clean_slug, | ||
| $post->ID, | ||
| $post->post_status, | ||
| $post->post_type, | ||
| $post->post_parent | ||
| ); | ||
| } else { | ||
| $slug = wp_unique_post_slug( $clean_slug, 0, 'publish', 'post', 0 ); | ||
| } | ||
|
|
||
| if ( empty( $slug ) ) { | ||
| continue; | ||
| } | ||
|
|
||
| $slugs[] = $slug; | ||
| } | ||
|
|
||
| $slugs = array_slice( array_unique( $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' ) | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| <?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). | ||
| - Ensure the slug suggestions use words that match the language of the title/content you are given. For example, if the title is in Spanish, use Spanish words in the slug. | ||
| - 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 |
||
| ); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
So our context here loses it's keys, so no way to know what each piece of data actually represents. Ideally we build a
Key: valuestring