-
Notifications
You must be signed in to change notification settings - Fork 167
Experiment: Add Internal link suggestions #887
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 15 commits
f4165c7
244b572
e987b20
d52ff9f
24fdbab
86e82a1
b480d7c
00733dd
5aa9913
d9618ab
2732715
362b237
d55551b
9153973
4e614db
c544662
d2e30d5
e14a08b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| <?php | ||
| /** | ||
| * System instruction for the Internal Links ability. | ||
| * | ||
| * @package WordPress\AI\Abilities\Internal_Links | ||
| */ | ||
|
|
||
| // Exit if accessed directly. | ||
| if ( ! defined( 'ABSPATH' ) ) { | ||
| exit; | ||
| } | ||
|
|
||
| // phpcs:ignore Squiz.PHP.Heredoc.NotAllowed, PluginCheck.CodeAnalysis.Heredoc.NotAllowed | ||
| return <<<'INSTRUCTION' | ||
| You are an internal-linking assistant for a WordPress site. Your task is to read a post's plain-text content and a list of other pages/posts published on the same site, then suggest the most valuable internal links that could be added. | ||
|
|
||
| ## Rules — read these carefully | ||
|
|
||
| 1. **Use only existing text as anchor text.** Every `anchor_text` value you return MUST be an exact substring of the post content provided in <post-content> tags. Do NOT invent, rephrase, or summarise. Copy the phrase character-for-character. | ||
| 2. **Match to the site index.** Each suggestion must reference a URL from the <site-index> list. Do NOT invent URLs. | ||
| 3. **Relevance first.** Only suggest a link when the target page is genuinely relevant to the anchor phrase in context. Avoid superficial keyword matches. | ||
| 4. **No duplicates.** Do not suggest the same anchor text or the same URL more than once. | ||
| 5. **Respect the cap.** Return at most the number of suggestions specified in <max-suggestions>. | ||
| 6. **Context sentence.** For each suggestion, copy the sentence or clause from the post that contains the anchor text into the `context` field. This helps the editor understand placement. | ||
| 7. **Quality over quantity.** If fewer than <max-suggestions> high-quality links exist, return fewer. An empty array is valid if no good matches exist. | ||
| 8. **Skip already-linked text.** If an `<already-linked>` list is provided, do NOT suggest any anchor text that appears in that list. Those phrases are already hyperlinked in the post. | ||
|
|
||
| ## Output format | ||
|
|
||
| Return a JSON object with a single key `suggestions` whose value is an array. Each element has: | ||
| - `anchor_text` (string) — exact phrase from the post content. | ||
| - `url` (string) — the target URL from the site index. | ||
| - `title` (string) — the title of the target page as given in the site index. | ||
| - `context` (string) — the sentence or clause from the post that contains the anchor text. | ||
|
|
||
| Example: | ||
| { | ||
| "suggestions": [ | ||
| { | ||
| "anchor_text": "REST API", | ||
| "url": "https://example.com/guide-to-rest-api/", | ||
| "title": "Guide to REST API", | ||
| "context": "You can query data using the REST API endpoint provided by WordPress." | ||
| } | ||
| ] | ||
| } | ||
|
|
||
| If there are no good suggestions, return: { "suggestions": [] } | ||
| INSTRUCTION; | ||
| 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\Internal_Links\Internal_Links::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'll want to sort this alphabetically |
||
| ); | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| <?php | ||
| /** | ||
| * Internal Links experiment implementation. | ||
| * | ||
| * @package WordPress\AI | ||
| */ | ||
|
|
||
| declare( strict_types=1 ); | ||
|
|
||
| namespace WordPress\AI\Experiments\Internal_Links; | ||
|
|
||
| use WordPress\AI\Abilities\Internal_Links\Internal_Links as Internal_Links_Ability; | ||
| use WordPress\AI\Abstracts\Abstract_Feature; | ||
| use WordPress\AI\Asset_Loader; | ||
| use WordPress\AI\Experiments\Experiment_Category; | ||
|
|
||
| use function WordPress\AI\get_min_content_length; | ||
|
|
||
| // Exit if accessed directly. | ||
| if ( ! defined( 'ABSPATH' ) ) { | ||
| exit; | ||
| } | ||
|
|
||
| /** | ||
| * Internal Links experiment. | ||
| * | ||
| * Uses AI to suggest contextual internal links within a post by analysing | ||
| * the current draft and identifying relevant published posts or pages on | ||
| * the same site. All suggestions require editor review before being applied. | ||
|
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'd probably just remove this |
||
| * | ||
| * @since x.x.x | ||
| */ | ||
| class Internal_Links extends Abstract_Feature { | ||
|
|
||
| /** | ||
| * {@inheritDoc} | ||
| */ | ||
| public static function get_id(): string { | ||
| return 'internal-links'; | ||
| } | ||
|
|
||
| /** | ||
| * {@inheritDoc} | ||
| */ | ||
| protected function load_metadata(): array { | ||
| return array( | ||
| 'label' => __( 'Internal Link Suggestions', 'ai' ), | ||
| 'description' => __( 'Uses AI to suggest relevant internal links within post content, using existing text as anchor text. All suggestions require editor review before being applied. Requires an AI connector that includes support for text generation models.', 'ai' ), | ||
|
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. No need to call out |
||
| 'category' => Experiment_Category::EDITOR, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * {@inheritDoc} | ||
| */ | ||
| public function register(): void { | ||
| add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); | ||
| add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ) ); | ||
|
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. Let's change the default priority here so we can ensure the |
||
| } | ||
|
|
||
| /** | ||
| * Registers the internal links ability. | ||
| * | ||
| * @since x.x.x | ||
| */ | ||
| public function register_abilities(): void { | ||
| wp_register_ability( | ||
| 'ai/' . $this->get_id(), | ||
| array( | ||
| 'label' => $this->get_label(), | ||
| 'description' => $this->get_description(), | ||
| 'ability_class' => Internal_Links_Ability::class, | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Enqueues and localises the block editor script. | ||
| * | ||
| * @since x.x.x | ||
| */ | ||
| public function enqueue_assets(): void { | ||
| Asset_Loader::enqueue_script( 'internal_links', 'experiments/internal-links', array( 'include_core_abilities' => true ) ); | ||
| Asset_Loader::enqueue_style( 'internal_links', 'experiments/internal-links' ); | ||
| Asset_Loader::localize_script( | ||
| 'internal_links', | ||
| 'InternalLinksData', | ||
| array( | ||
| 'enabled' => $this->is_enabled(), | ||
| 'minContentLength' => get_min_content_length( 'internal-links', 75 ), | ||
| 'maxSuggestions' => $this->get_max_suggestions(), | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the configured maximum number of link suggestions. | ||
| * | ||
| * Defaults to 5 and can be overridden via the `wpai_internal_links_max_suggestions` filter. | ||
| * | ||
| * @since x.x.x | ||
| * | ||
| * @return int Maximum number of suggestions (clamped to 1–10). | ||
| */ | ||
| private function get_max_suggestions(): int { | ||
| /** | ||
| * Filters the maximum number of internal link suggestions returned per request. | ||
| * | ||
| * @since x.x.x | ||
| * | ||
| * @param int $max Maximum suggestions (default 5, clamped to 1–10). | ||
| */ | ||
| $max = (int) apply_filters( 'wpai_internal_links_max_suggestions', 5 ); | ||
|
|
||
| return max( 1, min( 10, $max ) ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| /** | ||
| * WordPress dependencies | ||
| */ | ||
| import { Button, Flex, FlexItem, Spinner } from '@wordpress/components'; | ||
| import { PluginPostStatusInfo } from '@wordpress/editor'; | ||
| import { useInstanceId } from '@wordpress/compose'; | ||
| import { __, sprintf } from '@wordpress/i18n'; | ||
| import { link } from '@wordpress/icons'; | ||
|
|
||
| /** | ||
| * Internal dependencies | ||
| */ | ||
| import { useInternalLinks } from '../hooks/useInternalLinks'; | ||
| import SuggestionList from './SuggestionList'; | ||
|
|
||
| export default function InternalLinksPlugin() { | ||
| const { | ||
| isLoading, | ||
| suggestions, | ||
| isContentTooShort, | ||
| minContentLength, | ||
| fetchSuggestions, | ||
| acceptSuggestion, | ||
| dismissSuggestion, | ||
| } = useInternalLinks(); | ||
|
|
||
| const descriptionId = useInstanceId( | ||
| InternalLinksPlugin, | ||
| 'internal-links-plugin-description' | ||
| ); | ||
|
|
||
| if ( ! ( window as any ).aiInternalLinksData?.enabled ) { | ||
| return null; | ||
| } | ||
|
|
||
| const buttonLabel = isLoading | ||
| ? __( 'Suggesting links…', 'ai' ) | ||
| : __( 'Suggest Internal Links', 'ai' ); | ||
|
|
||
| const buttonDescription = isContentTooShort | ||
| ? sprintf( | ||
| /* translators: %d: minimum number of characters required. */ | ||
| __( | ||
| 'Internal Link Suggestions will be available when the post content has at least %d characters.', | ||
| 'ai' | ||
| ), | ||
| minContentLength | ||
| ) | ||
| : __( | ||
| 'Analyses this post and suggests relevant internal links using existing text as anchor text.', | ||
|
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. This shows |
||
| 'ai' | ||
| ); | ||
|
|
||
| return ( | ||
| <PluginPostStatusInfo> | ||
| <Flex direction="column" gap={ 2 }> | ||
| <FlexItem> | ||
| <Button | ||
| accessibleWhenDisabled | ||
| variant="secondary" | ||
| icon={ isLoading ? <Spinner /> : link } | ||
| onClick={ fetchSuggestions } | ||
| isBusy={ isLoading } | ||
| disabled={ isLoading || isContentTooShort } | ||
| className="ai-internal-links__plugin-button" | ||
| __next40pxDefaultSize | ||
| aria-describedby={ descriptionId } | ||
| > | ||
| { buttonLabel } | ||
| </Button> | ||
| </FlexItem> | ||
|
|
||
| <FlexItem> | ||
| <span | ||
| id={ descriptionId } | ||
| className="description ai-internal-links__plugin-description" | ||
| > | ||
| { buttonDescription } | ||
| </span> | ||
| </FlexItem> | ||
|
|
||
| { suggestions.length > 0 && ( | ||
| <FlexItem> | ||
| <p className="description ai-internal-links__suggestions-header"> | ||
| { sprintf( | ||
| /* translators: %d: number of suggestions found. */ | ||
| __( '%d suggestion(s) found.', 'ai' ), | ||
|
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. Should use |
||
| suggestions.length | ||
| ) } | ||
| </p> | ||
| <SuggestionList | ||
| suggestions={ suggestions } | ||
| onAccept={ acceptSuggestion } | ||
| onDismiss={ dismissSuggestion } | ||
| /> | ||
| </FlexItem> | ||
| ) } | ||
| </Flex> | ||
| </PluginPostStatusInfo> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| /** | ||
| * WordPress dependencies | ||
| */ | ||
| import { Button, ExternalLink } from '@wordpress/components'; | ||
| import { __ } from '@wordpress/i18n'; | ||
| import { check, trash } from '@wordpress/icons'; | ||
|
|
||
| /** | ||
| * Internal dependencies | ||
| */ | ||
| import type { LinkSuggestion } from '../hooks/useInternalLinks'; | ||
|
|
||
| interface Props { | ||
| suggestions: LinkSuggestion[]; | ||
| onAccept: ( suggestion: LinkSuggestion ) => void; | ||
| onDismiss: ( suggestion: LinkSuggestion ) => void; | ||
| } | ||
|
|
||
| export default function SuggestionList( { | ||
| suggestions, | ||
| onAccept, | ||
| onDismiss, | ||
| }: Props ) { | ||
| if ( suggestions.length === 0 ) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <ul className="ai-internal-links__suggestions"> | ||
| { suggestions.map( ( suggestion ) => ( | ||
| <li | ||
| key={ suggestion.anchor_text } | ||
| className="ai-internal-links__suggestion" | ||
| > | ||
| <p className="ai-internal-links__suggestion-anchor"> | ||
| <strong>{ `"${ suggestion.anchor_text }"` }</strong> | ||
| </p> | ||
| <p className="ai-internal-links__suggestion-target"> | ||
| { __( 'Links to:', 'ai' ) }{ ' ' } | ||
| <ExternalLink href={ suggestion.url }> | ||
| { suggestion.title } | ||
| </ExternalLink> | ||
| </p> | ||
| { suggestion.context && ( | ||
| <p className="ai-internal-links__suggestion-context"> | ||
| { `"…${ suggestion.context }…"` } | ||
| </p> | ||
| ) } | ||
| <div className="ai-internal-links__suggestion-actions"> | ||
| <Button | ||
| variant="secondary" | ||
| icon={ check } | ||
| iconSize={ 16 } | ||
| size="small" | ||
| onClick={ () => onAccept( suggestion ) } | ||
| __next40pxDefaultSize={ false } | ||
| > | ||
| { __( 'Accept', 'ai' ) } | ||
| </Button> | ||
| <Button | ||
| variant="tertiary" | ||
| icon={ trash } | ||
| iconSize={ 16 } | ||
| size="small" | ||
| onClick={ () => onDismiss( suggestion ) } | ||
| __next40pxDefaultSize={ false } | ||
| > | ||
| { __( 'Dismiss', 'ai' ) } | ||
| </Button> | ||
| </div> | ||
| </li> | ||
| ) ) } | ||
| </ul> | ||
| ); | ||
| } |
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.
Is this needed? We already provide the output format we expect when making a request so seems like this is unnecessary and wastes tokens