Skip to content

Experiment: Add Internal link suggestions - #887

Open
Infinite-Null wants to merge 26 commits into
WordPress:developfrom
Infinite-Null:feature/internal-link-suggestions
Open

Infinite-Null wants to merge 26 commits into
WordPress:developfrom
Infinite-Null:feature/internal-link-suggestions

Conversation

@Infinite-Null

@Infinite-Null Infinite-Null commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Closes #875

Internal Link Suggestions Experiment

Description

This PR implements the Internal Link Suggestions experiment, which uses AI to suggest contextual internal links within post content by analyzing the current draft and matching relevant phrases to published posts or pages on the site.

Testing Instructions

  1. Ensure an AI provider is connected and enabled under Settings → AI, and the Internal Link Suggestions experiment is active under Editor Experiments in /wp-admin/options-general.php?page=ai-wp-admin.
  2. Create or edit a post with several paragraphs of content. Ensure you have other published posts/pages on your site to link to.
  3. Open the Post sidebar and click Suggest Internal Links.
  4. Verify that relevant internal link suggestions appear with context snippets.
  5. Click Accept on a suggestion to verify that the suggestion is accepted.
  6. Click Dismiss on a suggestion to verify it is not accepted.

Screenshots or screencast

Screen.Recording.2026-07-27.at.12.45.28.PM.mov

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Sonnet 4.6
Used for: Validating bug, suggesting a fix.

Changelog Entry

Added - Internal Link Suggestions Experiment

Open WordPress Playground Preview

@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.87879% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.70%. Comparing base (f6acb37) to head (5b4ed5d).

Files with missing lines Patch % Lines
...cludes/Abilities/Internal_Links/Internal_Links.php 86.52% 31 Missing ⚠️
...es/Abilities/Internal_Links/system-instruction.php 50.00% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop     #887      +/-   ##
=============================================
+ Coverage      74.42%   74.70%   +0.28%     
- Complexity      3116     3173      +57     
=============================================
  Files            132      135       +3     
  Lines          12179    12443     +264     
=============================================
+ Hits            9064     9296     +232     
- Misses          3115     3147      +32     
Flag Coverage Δ
unit 74.70% <87.87%> (+0.28%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Infinite-Null
Infinite-Null marked this pull request as ready for review July 27, 2026 08:10
@Infinite-Null
Infinite-Null requested a review from a team July 27, 2026 08:10
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: Infinite-Null <ankitkumarshah@git.wordpress.org>
Co-authored-by: dkotter <dkotter@git.wordpress.org>
Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org>
Co-authored-by: dugyen <ugyensupport@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@dkotter dkotter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testing works though results aren't great (which I've left a comment around this). Probably worth a discussion on the approach here prior to proceeding.

In addition, adding documentation to match how our other experiments are documented would be great

'max_suggestions' => array(
'type' => 'integer',
'sanitize_callback' => 'absint',
'description' => esc_html__( 'Maximum number of link suggestions to return (1–10).', 'ai' ),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we limit this from 1 to 10, our schema should set that here with minimum and maximum attributes

protected function permission_callback( $input ) {
$post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;

if ( ! $post_id ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our schema says that a post ID is required so I don't think this check will ever be needed, unless I'm missing something

);

$post_content = wp_kses_post( (string) $args['post_content'] );
$post_id = absint( $args['post_id'] );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we validate post ID in our permission callback but to match our other abilities, probably best to ensure this is a valid post ID here

->using_system_instruction( $this->get_system_instruction() )
->as_json_response( $this->suggestions_schema() );

$prompt_builder = $this->set_provider_model_preference( $prompt_builder, Internal_Links_Experiment::class );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have two new methods we'll want to integrate here: $this->filter_prompt and $this->filter_prompt_builder.

The latter replaces this $this->set_provider_model_preference call

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

Copy link
Copy Markdown
Contributor

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

<p className="description ai-internal-links__suggestions-header">
{ sprintf(
/* translators: %d: number of suggestions found. */
__( '%d suggestion(s) found.', 'ai' ),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should use _n here for plurals

const attributeKey =
'content' in block.attributes ? 'content' : 'value';

( dispatch( blockEditorStore ) as any ).updateBlockAttributes(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove the as any from here

( window as any ).aiInternalLinksData?.maxSuggestions ?? 5;

const { content, postId } = useSelect( ( selectStore ) => {
const editor = selectStore( editorStore ) as any;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove the as any from here


const acceptSuggestion = ( suggestion: LinkSuggestion ) => {
const blocks = (
select( blockEditorStore ) as any

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove the as any from here

}

// Build the list of linkable posts/pages from this site.
$site_index = $this->build_site_index( $post_id );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In testing, I'm not getting very good results based on this approach. I think we may want to pause here to decide if this is an approach we should continue on or not. My suggestion would be to wait until we have embeddings support and we can use those to find relationships, which I think will scale better and give us much better results.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Embeddings is almost certainly what I had imagined leveraging when I opened the related issue, so feels good to update to leverage that (those?).

…mpt filtering, updated JSON schema handling, and improved UI localization
…er parsing for suggestions, and adjust experiment registration priorities and ordering.
@jeffpaul jeffpaul modified the milestones: Future Release, 1.3.0 Aug 5, 2026
@jeffpaul jeffpaul mentioned this pull request Aug 10, 2026
48 tasks
@dkotter dkotter removed this from the 1.3.0 milestone Aug 12, 2026
@dkotter dkotter added this to the 1.4.0 milestone Aug 12, 2026
@dugyen

dugyen commented Sep 7, 2026

Copy link
Copy Markdown

While testing this PR, I ran into the two currently-failing "Run E2E tests" checks and traced them to stale assertions in the e2e spec rather than app bugs — a rebrand commit changed the UI copy from "the post content" to "the content", and the suggestions-found header now uses _n() for proper pluralization ("1 suggestion found." for the singular case), but the spec still asserted the old strings.

Opened a small fix against this PR's branch: Infinite-Null#4

(The PHP 8.3 WP trunk failure looks like an unrelated transient composer/GitHub API 504 during dependency download, not a code issue.)

@dugyen

dugyen commented Sep 7, 2026

Copy link
Copy Markdown

Ran a fuller review over this PR's diff (8 independent finder passes + direct source verification of every candidate). 10 findings, most severe first. Findings #1–#3 substantially overlap with what @dkotter already flagged in review (the excluded_anchors gap and the "success shown even when the replace fails" issue) — including them here with exact repro steps in case they're useful for the fix.

1. excluded_anchors sanitized and sent to the prompt, but never re-checked when validating the AI's responseInternal_Links.php#L210 (call site) / #L421 (parse_and_validate_response() signature).
Every other rule (verbatim anchor, valid URL, no duplicate anchor/URL) is defensively re-checked in parse_and_validate_response(), but excluded_anchors isn't even passed in. If the model ignores rule 8 in system-instruction.php and returns an already-linked phrase with a valid site-index URL, nothing filters it back out — it's shown to the editor as a fresh suggestion.

2. Anchor match is checked against plain text, but the replace runs against raw HTMLuseInternalLinks.ts#L135.
applyLinkToBlock tests plainContent.includes(anchorText) (tags stripped) but inserts the link via rawContent.replace(regex, ...) against the raw HTML. E.g. for Our <strong>customer support</strong> team is great., plain text contains "customer support team" so the check passes, but the raw HTML has </strong> splitting that exact substring, so the regex never matches — updatedHtml === rawContent and no link is ever inserted.

3. Success notice shown even when the link insert silently faileduseInternalLinks.ts#L249.
acceptSuggestion calls applyLinkToBlock (returns void, no success/failure signal), then unconditionally removes the suggestion from the list and shows "Internal link applied. Save the post to keep the change." Compounds #2: the editor believes the link was added and saves, only to find out later (or never) that it wasn't.

4. Inline attribute-key pick misses the existing image/pullquote special-caseuseInternalLinks.ts#L149.
Picks the write attribute via 'content' in block.attributes ? 'content' : 'value', reimplementing (with different semantics than the read at L123's content ?? value fallback) what getEditableTextAttribute() in src/utils/blocks.ts already does, including its core/imagealt special case. A suggestion whose anchor text lives in an image's alt text will write to the wrong attribute or no-op instead.

5. Bypasses the plugin's standard prompt/model-preference filtersInternal_Links.php#L193, get_prompt_builder() L386-402.
execute_callback() never routes the prompt through ->filter_prompt(), and get_prompt_builder() inlines its own model-preference logic instead of Abstract_Ability::filter_prompt_builder() — the pattern every sibling ability (Editorial_Notes, Meta_Description, Excerpt_Generation, Summarization, ...) uses. Any code hooking the standard wpai_{slug}_prompt / wpai_{slug}_prompt_builder filters silently never fires for this ability.

6. N+1 queries building the site indexInternal_Links.php#L313 (same spot @dkotter flagged).
build_site_index() runs WP_Query with 'fields' => 'ids' (doesn't warm the post cache) with no _prime_post_caches() call, then loops get_the_title()/get_permalink() per ID — up to SITE_INDEX_LIMIT (200) extra individual DB lookups per "Suggest Internal Links" click.

7. Loose falsy/empty() checks silently drop literal "0" valuesInternal_Links.php#L332 and L445-447.
if ( ! \$title || ! \$url ) and empty( \$item['anchor_text'] ) || ... both treat the string "0" as absent. A page titled exactly "0" is silently excluded from the site index; a suggestion with anchor/url/title exactly "0" is silently dropped even though the is_string() checks two lines later would otherwise accept it. Edge cases, but silent.

8. Duplicates existing utils/blocks.ts helpersuseInternalLinks.ts#L51 (toPlainString) and L106 (flattenAll).
Both reimplement toPlainText()/flattenBlocks() from src/utils/blocks.ts, already used the same way by sibling experiments (e.g. editorial-updates). A future fix to the shared helpers (their own comment notes RichText's shape has changed before) won't propagate here.

9. as any casts hide a real type/runtime mismatchuseInternalLinks.ts#L182.
Every read of the global goes through (window as any).aiInternalLinksData instead of the typed window.aiInternalLinksData declared in index.tsx, and maxSuggestions — typed number there — is run through parseInt(...), implying the localized value is actually a string at runtime. The casts mean TS can't catch this mismatch at any of the 4 call sites.

10. Full post content re-serialized/re-counted on every keystrokeuseInternalLinks.ts#L186.
useInternalLinks() subscribes unconditionally to editor.getEditedPostContent() (re-serializes the whole block tree on every content change) from an always-mounted sidebar panel, and recomputes the content-length check on every render without memoizing on content. On a long post, every keystroke does a full serialize + character-count scan just to toggle a disabled state.


Reviewed via 8 independent finder passes (correctness, removed-behavior, cross-file tracing, reuse, simplification, efficiency, altitude, conventions) followed by direct source verification of each candidate — using Claude Code.

@jeffpaul jeffpaul modified the milestones: 1.4.0, 1.6.0 Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New Experiment: Suggest internal links within post content

4 participants