Experiment: Add Internal link suggestions - #887
Infinite-Null wants to merge 26 commits into
Conversation
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…tions Experiment in the block editor'
…gic in Internal_Links ability
…ery and update package-lock.json
… link generation and update lockfile
|
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 If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
dkotter
left a comment
There was a problem hiding this comment.
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' ), |
There was a problem hiding this comment.
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 ) { |
There was a problem hiding this comment.
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'] ); |
There was a problem hiding this comment.
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 ); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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' ), |
There was a problem hiding this comment.
Should use _n here for plurals
| const attributeKey = | ||
| 'content' in block.attributes ? 'content' : 'value'; | ||
|
|
||
| ( dispatch( blockEditorStore ) as any ).updateBlockAttributes( |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
I think we can remove the as any from here
|
|
||
| const acceptSuggestion = ( suggestion: LinkSuggestion ) => { | ||
| const blocks = ( | ||
| select( blockEditorStore ) as any |
There was a problem hiding this comment.
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 ); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…dd test script for ability execution
|
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 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.) |
|
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 1. 2. Anchor match is checked against plain text, but the replace runs against raw HTML — useInternalLinks.ts#L135. 3. Success notice shown even when the link insert silently failed — useInternalLinks.ts#L249. 4. Inline attribute-key pick misses the existing image/pullquote special-case — useInternalLinks.ts#L149. 5. Bypasses the plugin's standard prompt/model-preference filters — Internal_Links.php#L193, get_prompt_builder() L386-402. 6. N+1 queries building the site index — Internal_Links.php#L313 (same spot @dkotter flagged). 7. Loose falsy/ 8. Duplicates existing 9. 10. Full post content re-serialized/re-counted on every keystroke — useInternalLinks.ts#L186. 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. |
… support for excluding existing anchor texts
…rtion failures with error notices
… handling for improved reliability
…mber parsing for internal links configuration
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
Editor Experimentsin/wp-admin/options-general.php?page=ai-wp-admin.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